Files
dwroller/scripts/verify-rules-ingame-scenarios.js
Alex 7873f450da feat: rules midgame priority system, mission enhancements, and requisition shop improvements
- Add midgame priority ranking for rules (midgamePriority field)
- Enhance rules search to support midgame filtering and sorting
- Improve mission simulation and midgame tracking
- Fix RequisitionShop player name handling
- Add rules midplay simulation tests
- Update MissionTab with midgame scene tracking
- Add backend logging for rules updates
2026-06-24 12:09:25 +02:00

183 lines
7.1 KiB
JavaScript

#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const rulesPath = path.join(repoRoot, 'database', 'rules', 'rules-database.json');
const db = JSON.parse(fs.readFileSync(rulesPath, 'utf8'));
const rules = db.rules || [];
const scenarios = [
{
name: 'Brother Kael Dodges a Warrior',
tableMoment: 'A Tyranid Warrior hits Brother Kael in melee. The player asks whether he can spend his Reaction to avoid the hit.',
query: 'dodge reaction',
mustFind: ['Dodge'],
formatChecks: [
{ title: 'Dodge', category: 'actions', source: 'CR', minConfidence: 0.9 }
],
worksIf: 'Dodge appears before broad combat pages and has a CR page citation.'
},
{
name: 'Devastator Uses Full Auto',
tableMoment: 'Brother Aeldan fires a heavy bolter on full auto and needs the additional-hit rule.',
query: 'full auto burst additional hits',
mustFind: ['Full Auto Burst'],
formatChecks: [
{ title: 'Full Auto Burst', category: 'actions', source: 'CR', minConfidence: 0.9 }
],
worksIf: 'The card distinguishes Full Auto from Semi-Auto and links to Weapon Jams.'
},
{
name: 'GM Runs a Horde',
tableMoment: 'The GM turns thirty Hormagaunts into one Horde and needs magnitude/damage handling.',
query: 'horde magnitude damage',
mustFind: ['Hordes'],
formatChecks: [
{ title: 'Hordes', category: 'hordes', source: 'CR', minConfidence: 0.9 }
],
worksIf: 'Hordes is found as its own card instead of buried in adversary text.'
},
{
name: 'Kill-team Takes Blast Damage in Squad Mode',
tableMoment: 'A blast weapon hits a Battle-Brother in Squad Mode. The GM checks if the team loses Cohesion.',
query: 'cohesion damage blast command test',
mustFind: ['Cohesion Damage'],
formatChecks: [
{ title: 'Cohesion Damage', category: 'cohesion', source: 'CR', minConfidence: 0.9 }
],
worksIf: 'The result points to Cohesion Damage, not only the general Cohesion card.'
},
{
name: 'Librarian Triggers Perils',
tableMoment: 'The Librarian pushes a psychic power and rolls badly. The player asks for the Perils table.',
query: 'perils of the warp psychic backlash',
mustFind: ['Perils of the Warp'],
formatChecks: [
{ title: 'Perils of the Warp', category: 'psychic', source: 'CR', minConfidence: 0.9 }
],
worksIf: 'The Perils table is directly discoverable from common player language.'
},
{
name: 'Fear Test in the Genatorium',
tableMoment: 'A daemonhost manifests in a ruined genatorium. The GM needs the Fear test modifier.',
query: 'fear test modifier',
mustFind: ['Fear'],
formatChecks: [
{ title: 'Fear', category: 'conditions', source: 'CR', minConfidence: 0.9 }
],
worksIf: 'Fear outranks generic test difficulty and trait references.'
},
{
name: 'Mission Prep Requisition',
tableMoment: 'Before extraction, players pool Requisition for a lascannon and ask how Renown gates it.',
query: 'requisition renown pooling',
mustFind: ['Requisition', 'Renown'],
formatChecks: [
{
title: 'Requisition',
category: 'requisition',
source: 'CR',
minConfidence: 0.9,
sourceMethod: 'pdf+curated',
requiredSections: ['Quick Use', 'Pooling', 'Renown Gate', 'Availability to Requisition', 'Mid-play Ruling']
},
{
title: 'Renown',
category: 'renown',
source: 'CR',
minConfidence: 0.9,
sourceMethod: 'pdf+curated',
requiredSections: ['Quick Use', 'Renown Ranks', 'Mid-play Ruling']
}
],
worksIf: 'The answer is formatted as a play aid rather than raw PDF prose.'
}
];
function fields(rule) {
return [
rule.title,
rule.summary,
rule.content,
rule.category,
...(rule.tags || []),
...(rule.aliases || [])
].map(v => String(v || '').toLowerCase());
}
function score(rule, query) {
const term = query.toLowerCase();
const tokens = term.split(/\s+/).filter(Boolean);
const title = String(rule.title || '').toLowerCase();
const haystacks = fields(rule);
return (title === term ? 100 : 0) +
(title.includes(term) ? 45 : 0) +
Number(rule.midgamePriority || 0) +
tokens.reduce((sum, token) => sum + haystacks.reduce((hits, value) => hits + (value.includes(token) ? 1 : 0), 0), 0);
}
function search(query, limit = 5) {
const term = query.toLowerCase();
const tokens = term.split(/\s+/).filter(Boolean);
return rules
.filter(rule => {
const haystacks = fields(rule);
return haystacks.some(value => value.includes(term)) ||
tokens.every(token => haystacks.some(value => value.includes(token)));
})
.sort((a, b) => score(b, query) - score(a, query))
.slice(0, limit);
}
function assertRuleFormat(check) {
const rule = rules.find(r => r.title === check.title);
const errors = [];
if (!rule) return [`Missing rule card: ${check.title}`];
if (rule.category !== check.category) errors.push(`${check.title}: category ${rule.category} !== ${check.category}`);
if (rule.sourceAbbr !== check.source) errors.push(`${check.title}: source ${rule.sourceAbbr} !== ${check.source}`);
if (!rule.page && rule.page !== 0) errors.push(`${check.title}: missing page`);
if (!rule.sourceMethod) errors.push(`${check.title}: missing sourceMethod`);
if (check.sourceMethod && rule.sourceMethod !== check.sourceMethod) errors.push(`${check.title}: sourceMethod ${rule.sourceMethod} !== ${check.sourceMethod}`);
if (Number(rule.confidence || 0) < check.minConfidence) errors.push(`${check.title}: confidence ${rule.confidence} < ${check.minConfidence}`);
if (!rule.summary) errors.push(`${check.title}: missing summary`);
if (!rule.content || rule.content.length < 80) errors.push(`${check.title}: content too short`);
if (!Array.isArray(rule.relatedRules)) errors.push(`${check.title}: relatedRules is not an array`);
for (const section of check.requiredSections || []) {
if (!rule.content.includes(section)) errors.push(`${check.title}: missing section "${section}"`);
}
return errors;
}
let failures = 0;
console.log('In-game rules format and browse verification\n');
for (const scenario of scenarios) {
const results = search(scenario.query);
const titles = results.map(r => r.title);
const lookupOk = scenario.mustFind.every(title => titles.slice(0, 4).includes(title));
const formatErrors = scenario.formatChecks.flatMap(assertRuleFormat);
const ok = lookupOk && formatErrors.length === 0;
if (!ok) failures += 1;
console.log(`${ok ? 'PASS' : 'FAIL'} ${scenario.name}`);
console.log(` Moment: ${scenario.tableMoment}`);
console.log(` Query: ${scenario.query}`);
console.log(` Top results: ${titles.slice(0, 4).join(' | ') || '(none)'}`);
console.log(` Works if: ${scenario.worksIf}`);
for (const error of formatErrors) console.log(` Format error: ${error}`);
if (!lookupOk) console.log(` Lookup error: expected ${scenario.mustFind.join(', ')} in top 4 results`);
console.log('');
}
if (failures) {
console.error(`${failures} scenario(s) failed.`);
process.exit(1);
}
console.log('All invented in-game scenarios passed lookup and format checks.');