410 lines
14 KiB
TypeScript
410 lines
14 KiB
TypeScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import {
|
||
RULE_TOPICS,
|
||
RULE_CHECKLIST_AREAS,
|
||
PHASE_HELP,
|
||
phaseRuleId,
|
||
} from '../game/reference';
|
||
import { FACTIONS, PHASES } from '../game/catalog';
|
||
import { readFileSync } from 'node:fs';
|
||
|
||
void test('every phase and base faction has a discoverable rule topic with valid related links', () => {
|
||
const ids = new Set(RULE_TOPICS.map((t) => t.id));
|
||
assert.equal(ids.size, RULE_TOPICS.length);
|
||
for (const topic of RULE_TOPICS) {
|
||
assert.ok(topic.steps.length > 0, topic.id);
|
||
for (const related of topic.related ?? [])
|
||
assert.ok(ids.has(related), `${topic.id} -> ${related}`);
|
||
}
|
||
assert.equal(PHASE_HELP.length, PHASES.length);
|
||
PHASES.forEach((_, i) => assert.ok(ids.has(phaseRuleId(i))));
|
||
FACTIONS.forEach((f) => assert.ok(ids.has(`faction-${f.id}`)));
|
||
assert.ok(ids.has('ai-players'));
|
||
});
|
||
|
||
void test('reviewed feature checklists expose all five independent facets and real local verification evidence', () => {
|
||
const hub = RULE_TOPICS.find(
|
||
(topic) => topic.id === 'implementation-checklist',
|
||
)!;
|
||
const features = [
|
||
'setup',
|
||
'truthtrance-spice',
|
||
'richese-cards',
|
||
'richese-acquisition',
|
||
'richese-no-field',
|
||
'nullentropy-search',
|
||
'ecaz-ambassadors',
|
||
'automatic-casualties',
|
||
'automatic-responses',
|
||
'automatic-decisions',
|
||
'ai-pacing',
|
||
];
|
||
for (const id of features) {
|
||
const topic = RULE_TOPICS.find((candidate) => candidate.id === id)!;
|
||
assert.ok(
|
||
hub.related?.includes(id),
|
||
`${id} is discoverable from the checklist hub`,
|
||
);
|
||
assert.deepEqual(
|
||
topic.checklist?.map((item) => item.area),
|
||
[...RULE_CHECKLIST_AREAS],
|
||
id,
|
||
);
|
||
assert.equal(
|
||
topic.checklist?.find((item) => item.area === 'Verification')?.status,
|
||
'Partial',
|
||
'focused tests do not certify all browser and expansion interactions',
|
||
);
|
||
for (const item of topic.checklist ?? []) {
|
||
assert.ok(
|
||
item.detail.length > 20,
|
||
`${id}: ${item.area} has an explicit scope`,
|
||
);
|
||
for (const evidence of item.evidence ?? []) {
|
||
assert.match(evidence, /^tests\/[a-z0-9-]+\.test\.tsx?$/);
|
||
assert.match(
|
||
readFileSync(new URL(`../${evidence}`, import.meta.url), 'utf8'),
|
||
/test\(/,
|
||
evidence,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
void test('Richese checklist reports integrated auctions separately from inert card effects and unresolved rules', () => {
|
||
const topic = RULE_TOPICS.find(
|
||
(candidate) => candidate.id === 'richese-cards',
|
||
)!;
|
||
const guide = topic.steps.join(' ');
|
||
assert.equal(topic.coverage, 'Partial');
|
||
for (const phrase of [
|
||
'Once Around or Silent bidding',
|
||
'normal, Once Around or Silent bidding',
|
||
'Silent bids remain private',
|
||
'pledged ally spice',
|
||
'Atreides inspection',
|
||
'Harkonnen bonus',
|
||
'positive Black Market self-bids',
|
||
'exhausted-cache arithmetic',
|
||
'Ixian Technology substitution',
|
||
'skips the optional Black Market prelude',
|
||
'Juice of Sapho',
|
||
'Richese expansion starts remain disabled',
|
||
'persisted-room checks pass',
|
||
])
|
||
assert.ok(guide.includes(phrase), phrase);
|
||
assert.match(
|
||
guide,
|
||
/implementation guards.*not claims of official prohibitions/,
|
||
);
|
||
assert.equal(
|
||
topic.checklist!.find((item) => item.area === 'AI')!.status,
|
||
'Partial',
|
||
);
|
||
assert.ok(
|
||
topic
|
||
.checklist!.find((item) => item.area === 'Verification')!
|
||
.evidence!.includes('tests/richese-auction-bots.test.ts'),
|
||
);
|
||
assert.ok(
|
||
topic
|
||
.checklist!.find((item) => item.area === 'Verification')!
|
||
.evidence!.includes('tests/richese-engine.test.ts'),
|
||
);
|
||
assert.doesNotMatch(JSON.stringify(topic), /https?:\/\//);
|
||
});
|
||
|
||
void test('Richese acquisition guide separates purchase immunity, income response and independent transfer/search controls', () => {
|
||
const topic = RULE_TOPICS.find(
|
||
(candidate) => candidate.id === 'richese-acquisition',
|
||
)!;
|
||
assert.equal(topic.coverage, 'Partial');
|
||
const text = topic.steps.join(' ');
|
||
for (const phrase of [
|
||
'once per game',
|
||
'enters your hand secretly',
|
||
'cannot cancel the special purchase',
|
||
'Emperor income has a separate response',
|
||
'Full-hand activation',
|
||
'final cached card',
|
||
'have their own hand-panel controls',
|
||
])
|
||
assert.ok(text.includes(phrase), phrase);
|
||
assert.equal(topic.checklist!.length, 5);
|
||
assert.ok(topic.checklist!.every((item) => item.status === 'Partial'));
|
||
assert.ok(
|
||
topic
|
||
.checklist!.find((item) => item.area === 'Verification')!
|
||
.evidence!.includes('tests/richese-acquisition-bots.test.ts'),
|
||
);
|
||
assert.doesNotMatch(JSON.stringify(topic), /https?:\/\//);
|
||
});
|
||
|
||
void test('Ecaz checklist distinguishes integrated lifecycle from unopened expansion starts', () => {
|
||
const topic = RULE_TOPICS.find(
|
||
(candidate) => candidate.id === 'ecaz-ambassadors',
|
||
)!;
|
||
assert.equal(topic.coverage, 'Partial');
|
||
const guide = topic.steps.join(' ');
|
||
assert.match(guide, /before spice is paid/);
|
||
assert.match(guide, /previous placements and payments remain/);
|
||
assert.match(
|
||
guide,
|
||
/supply policy is an interpretation, not an explicit official FAQ ruling/,
|
||
);
|
||
assert.match(
|
||
guide,
|
||
/CHOAM market, deferred technology income, then Ecaz placement/,
|
||
);
|
||
assert.match(guide, /not a priority established by a combined official FAQ/);
|
||
assert.match(
|
||
guide,
|
||
/Storm crossings return tokens.*overriding traitor victory leaves it placed/,
|
||
);
|
||
assert.match(guide, /Full Ecaz starts remain disabled/);
|
||
assert.ok(
|
||
RULE_TOPICS.find(
|
||
(candidate) => candidate.id === 'revival',
|
||
)!.related?.includes(topic.id),
|
||
);
|
||
assert.ok(
|
||
RULE_TOPICS.find(
|
||
(candidate) => candidate.id === 'ecaz-modules',
|
||
)!.related?.includes(topic.id),
|
||
);
|
||
});
|
||
|
||
void test('automation reference keeps single-outcome settlement, cosmetic notices and paced AI distinct', () => {
|
||
const casualty = RULE_TOPICS.find(
|
||
(topic) => topic.id === 'automatic-casualties',
|
||
)!;
|
||
const pacing = RULE_TOPICS.find((topic) => topic.id === 'ai-pacing')!;
|
||
assert.match(
|
||
casualty.steps.join(' '),
|
||
/If several physical casualty combinations are legal, the winner still chooses/,
|
||
);
|
||
assert.match(casualty.steps.join(' '), /1\.5 seconds each/);
|
||
assert.match(casualty.steps.join(' '), /while notices are off are consumed/);
|
||
assert.match(casualty.steps.join(' '), /turning notices on does not replay/);
|
||
assert.match(
|
||
casualty.steps.join(' '),
|
||
/Successful powers without a notice tag remain outside/,
|
||
);
|
||
assert.match(
|
||
casualty.steps.join(' '),
|
||
/hiding notices does not disable automatic rule settlement or change AI timing/,
|
||
);
|
||
assert.match(
|
||
casualty.steps.join(' '),
|
||
/Uncancelable responses now settle automatically/,
|
||
);
|
||
for (const event of [
|
||
'successful Ecaz Ambassador placement',
|
||
'Emperor auction income',
|
||
'Harkonnen bonus-card draws',
|
||
'private full-plan inspection availability',
|
||
])
|
||
assert.ok(casualty.steps.join(' ').includes(event), event);
|
||
assert.match(
|
||
casualty.steps.join(' '),
|
||
/without exposing its private card identity/,
|
||
);
|
||
assert.match(pacing.steps.join(' '), /at most one AI action per step/);
|
||
assert.match(pacing.steps.join(' '), /Competing workers cannot both commit/);
|
||
assert.match(pacing.steps.join(' '), /Offline simulations use fast batches/);
|
||
assert.ok(
|
||
RULE_TOPICS.find((topic) => topic.id === 'battle')!.related?.includes(
|
||
casualty.id,
|
||
),
|
||
);
|
||
assert.ok(
|
||
RULE_TOPICS.find((topic) => topic.id === 'ai-players')!.related?.includes(
|
||
pacing.id,
|
||
),
|
||
);
|
||
});
|
||
|
||
void test('player-facing entry and rules pages contain no external navigation links', () => {
|
||
for (const file of [
|
||
'app/page.tsx',
|
||
'components/game-table.tsx',
|
||
'components/rules-reference.tsx',
|
||
'app/rules/coverage/page.tsx',
|
||
]) {
|
||
const source = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8');
|
||
assert.doesNotMatch(source, /href\s*=\s*["'](?:https?:)?\/\//, file);
|
||
}
|
||
});
|
||
|
||
void test('automatic allowance checklist distinguishes implemented physical custody from conservative promise legality and incomplete notice coverage', () => {
|
||
const topic = RULE_TOPICS.find(
|
||
(candidate) => candidate.id === 'automatic-responses',
|
||
)!;
|
||
const guide = topic.steps.join(' ');
|
||
assert.equal(topic.coverage, 'Partial');
|
||
assert.match(
|
||
guide,
|
||
/owner cannot cancel that power and needs no Allow click/,
|
||
);
|
||
assert.match(guide, /only your own cancelable cards/);
|
||
assert.match(guide, /Other players’ pass choices.*are not exposed/);
|
||
assert.match(guide, /already allowed may still cancel/);
|
||
assert.match(guide, /Allies retain legal cancellation choices/);
|
||
assert.match(
|
||
guide,
|
||
/active Truthtrance question or phase-opening choice pauses/,
|
||
);
|
||
assert.match(
|
||
guide,
|
||
/remain listed conservatively.*would break a promise and be rejected/,
|
||
);
|
||
assert.match(
|
||
guide,
|
||
/merely preparing a player’s view does not award spice or draw cards/,
|
||
);
|
||
assert.match(guide, /all successful-power notice tags.*remain unfinished/);
|
||
const verification = topic.checklist!.find(
|
||
(item) => item.area === 'Verification',
|
||
)!;
|
||
assert.equal(verification.status, 'Partial');
|
||
assert.ok(
|
||
verification.evidence?.includes('tests/automatic-responses.test.ts'),
|
||
);
|
||
assert.ok(
|
||
verification.evidence?.includes(
|
||
'tests/automatic-response-recovery.test.ts',
|
||
),
|
||
);
|
||
assert.equal(
|
||
topic.checklist!.find((item) => item.area === 'Player controls')!.status,
|
||
'Partial',
|
||
);
|
||
assert.ok(topic.related?.includes('card-truthtrance'));
|
||
assert.ok(
|
||
RULE_TOPICS.find(
|
||
(candidate) => candidate.id === 'automatic-casualties',
|
||
)!.related?.includes(topic.id),
|
||
);
|
||
});
|
||
|
||
void test('no-choice decision checklist preserves genuine alternatives and persistent owner-only inspection', () => {
|
||
const topic = RULE_TOPICS.find(
|
||
(candidate) => candidate.id === 'automatic-decisions',
|
||
)!;
|
||
const guide = topic.steps.join(' ');
|
||
assert.equal(topic.coverage, 'Partial');
|
||
assert.match(guide, /declared split.*can be paid exactly/);
|
||
assert.match(
|
||
guide,
|
||
/genuine Karama option or an unfunded payment still requires/,
|
||
);
|
||
assert.match(guide, /There is no Finish inspection action/);
|
||
assert.match(guide, /outside the battle helping an ally/);
|
||
assert.match(guide, /not copied into the ally’s private view/);
|
||
assert.match(
|
||
guide,
|
||
/Card enlargement and Truthtrance promise controls remain separate/,
|
||
);
|
||
assert.match(
|
||
guide,
|
||
/Other decisions with only one legal outcome.*remain under review/,
|
||
);
|
||
const special = RULE_TOPICS.find(
|
||
(candidate) => candidate.id === 'special-karama',
|
||
)!;
|
||
assert.match(
|
||
special.steps.join(' '),
|
||
/No inspection acknowledgement is required/,
|
||
);
|
||
assert.doesNotMatch(
|
||
special.steps.join(' '),
|
||
/table pauses while Atreides reads/,
|
||
);
|
||
assert.ok(
|
||
topic
|
||
.checklist!.find((item) => item.area === 'Verification')!
|
||
.evidence!.includes('tests/automatic-decisions.test.ts'),
|
||
);
|
||
});
|
||
|
||
void test('Ecaz entry checklist separates eight supported effects and private historical snapshots from unopened interactions', () => {
|
||
const topic = RULE_TOPICS.find(
|
||
(candidate) => candidate.id === 'ecaz-ambassadors',
|
||
)!;
|
||
const guide = topic.steps.join(' ');
|
||
assert.equal(topic.coverage, 'Partial');
|
||
assert.match(
|
||
guide,
|
||
/Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are integrated/,
|
||
);
|
||
assert.match(guide, /Bene Gesserit copies of those eight effects/);
|
||
assert.match(guide, /recipient’s private inspection history/);
|
||
assert.match(guide, /do not become a live view of later changes/);
|
||
assert.match(guide, /closing them needs no game acknowledgement/);
|
||
assert.match(guide, /including none, for three spice each/);
|
||
assert.match(guide, /Ixians require one available card/);
|
||
assert.match(
|
||
guide,
|
||
/Entries with competing arrival reactions remain blocked/,
|
||
);
|
||
assert.match(guide, /Full Ecaz starts remain disabled/);
|
||
const evidence = topic.checklist!.find(
|
||
(item) => item.area === 'Verification',
|
||
)!.evidence!;
|
||
assert.ok(evidence.includes('tests/ecaz-entry.test.ts'));
|
||
assert.ok(evidence.includes('tests/ecaz-entry-bots.test.ts'));
|
||
assert.ok(evidence.includes('tests/guild-ambassador-bots.test.ts'));
|
||
assert.ok(evidence.includes('tests/guild-ambassador-engine.test.ts'));
|
||
assert.match(
|
||
guide,
|
||
/No-Field substitution.*awaits the user’s interpretation/,
|
||
);
|
||
assert.match(
|
||
guide,
|
||
/Simultaneous Intrusion\/Terror and Ambassador\/Terror combinations remain blocked/,
|
||
);
|
||
});
|
||
|
||
void test('Nullentropy checklist preserves paid-only privacy, real choices, recovery and explicit interpretation guards', () => {
|
||
const topic = RULE_TOPICS.find(
|
||
(candidate) => candidate.id === 'nullentropy-search',
|
||
)!;
|
||
const text = topic.steps.join(' ');
|
||
assert.equal(topic.coverage, 'Partial');
|
||
assert.equal(topic.checklist!.length, 5);
|
||
assert.ok(topic.checklist!.every((item) => item.status === 'Partial'));
|
||
for (const phrase of [
|
||
'two spice to the bank',
|
||
'no discard names or candidate count',
|
||
'Only the paying player',
|
||
'no free cancellation',
|
||
'exactly one legal card',
|
||
'without charging again',
|
||
'Temporary search access ends',
|
||
'pre-existing free hand slot',
|
||
'pending Guild refund claim',
|
||
'not a general ban',
|
||
'before payment',
|
||
'without discard lookahead',
|
||
'full Richese starts remain disabled',
|
||
]) {
|
||
assert.ok(
|
||
(text + JSON.stringify(topic.checklist)).includes(phrase),
|
||
phrase,
|
||
);
|
||
}
|
||
assert.ok(
|
||
topic
|
||
.checklist!.find((item) => item.area === 'Verification')!
|
||
.evidence!.includes('tests/nullentropy-box-recovery.test.ts'),
|
||
);
|
||
assert.ok(
|
||
RULE_TOPICS.find(
|
||
(candidate) => candidate.id === 'card-richese-nullentropy-box',
|
||
)!.related!.includes(topic.id),
|
||
);
|
||
assert.doesNotMatch(JSON.stringify(topic), /https?:\/\//);
|
||
});
|