- 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
183 lines
7.1 KiB
JavaScript
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.');
|