Files
Dune/tests/ecaz-cards.test.ts
T
Quintine cab2f1c42d Connect expansion faction setup and saved Ixian auction decline
Add a local faction-only prototype profile for all seven nonempty expansion selections, genuine Basic/Advanced initialization and existing private setup, controls, AI and saved continuation. Ecaz allocates six forces across Imperial Basin sectors and retains fourteen reserves before later setup; preserve Fremen, Advanced BG and Moritani ordering with printed and inferred boundaries explicit. Keep optional Ecaz Treachery cards separate, ordinary deck counts 33/35/47, and Richese cache separate. Public start and publication gates remain closed.

Replace the Advanced Ixian/Richese pre-offer stop with an explicit per-lot Ixian decline. Bind the saved original offer to its round, turn, owner, independent event, exact card custody and terms before view/action/normalization. Decline resumes that unchanged lot once without using Technology; later normal lots still offer the exchange. Protect reserved cards across transfers, spending and random Harkonnen exchange, and preserve legitimate Gift/Truthtrance interruptions. The actual special-lot exchange still awaits replacement-custody interpretation.

Validation: focused setup, rules, UI/bot, private JSON/SQLite reload and concurrent CAS tests and independent custody/persistence review passed. Typecheck, lint, 4573 offline tests, production build and 43 HTTP tests passed. Browser Ecaz setup rejected an incomplete total, saved a 2/2/2 split, and refreshed to fourteen reserves and unchanged private cards. A separate genuine Advanced QA game restored the Ixian choice; explicit decline opened the unchanged cache lot with legal bidding and retained private hand, spice, pool count and unused Technology after refresh.

Five of six genuine four-profile samples finish: CHOAM Basic 1054 actions/28 JSON restores, CHOAM Advanced 225/6, Ecaz Basic 91/2, Ecaz Advanced 523/14, all-three Basic 476/12. Combined Advanced passes three former auction stops and reaches turn three/action614 with 16 restores, then hits an existing CHOAM movement/Ambassador arrival guard. Ecaz samples also include guarded arrival candidates with legal alternatives. Full combined play is not certified; arrival integration is the next gap. Retain initial failed logs, including a corrected private harness sold-card receipt alias.

After the outage, all 597 opening rooms and original seat/recovery records were preserved (632 after QA/HTTP). A stale development module cache required restart after a fresh 598-room backup; all 598 matched afterward and the QA seat restored. Final integrity and foreign keys passed. No database reset, recurring automation or publication.

Verified source tree: 5e2ff244a1167f2834bb46d609b8de914a34f7049ff7b06046243068c0935131
2026-09-13 22:51:19 +10:00

208 lines
7.9 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { baseDeck, ixDeck, treacheryDeck, type Card } from '../game/cards';
import { cardPresentation } from '../game/card-presentation';
import { DEFENSE_KINDS, WEAPON_KINDS } from '../game/battle-cards';
import {
ECAZ_TREACHERY_DEFINITIONS,
ECAZ_TREACHERY_VARIANT,
ecazTreacheryCards,
ecazTreacheryDefinition,
} from '../game/ecaz-cards';
void test('the verified Ecaz variant has one physical special of each identity and does not silently activate', () => {
const cards = ecazTreacheryCards();
assert.deepEqual(cards, [
{
id: 'ecaz-recruits',
name: 'Recruits',
kind: 'special',
effect: 'recruits',
},
{
id: 'ecaz-reinforcements',
name: 'Reinforcements',
kind: 'special',
effect: 'reinforcements',
},
{
id: 'ecaz-harass-withdraw',
name: 'Harass & Withdraw',
kind: 'special',
effect: 'harassWithdraw',
},
]);
const combined = [...baseDeck(), ...ixDeck(), ...cards];
assert.equal(combined.length, 50);
assert.equal(new Set(combined.map((card) => card.id)).size, 50);
assert.equal(treacheryDeck().length, 33);
assert.equal(treacheryDeck(['ix']).length, 47);
assert.equal(treacheryDeck(['ecaz']).length, 33);
for (const unsupported of [ECAZ_TREACHERY_VARIANT.id, 'unknown'])
assert.throws(() => treacheryDeck([unsupported]), /not implemented/);
assert.equal(ECAZ_TREACHERY_VARIANT.independentOfFactions, true);
assert.equal(ECAZ_TREACHERY_VARIANT.independentOfOtherVariants, true);
assert.equal(ECAZ_TREACHERY_VARIANT.activation, 'not-implemented');
});
void test('Ecaz faction selection preserves each ordinary deck and never includes its separate unfinished variant', () => {
for (const other of [[], ['ix'], ['choam'], ['ix', 'choam']]) {
const cards = treacheryDeck([...other, 'ecaz']);
assert.deepEqual(cards, treacheryDeck(other));
assert.equal(new Set(cards.map((card) => card.id)).size, cards.length);
assert.equal(
cards.some((card) => card.id.startsWith('ecaz-')),
false,
);
assert.equal(
cards.length,
other.includes('ix') ? 47 : other.includes('choam') ? 35 : 33,
);
}
assert.throws(
() => treacheryDeck(['ecaz', ECAZ_TREACHERY_VARIANT.id]),
/not implemented/,
);
});
void test('factory instances and their mutable cards are independent of canonical definitions', () => {
const first = ecazTreacheryCards();
first[0].name = 'Changed';
first.splice(1, 1);
const second = ecazTreacheryCards();
assert.equal(second.length, 3);
assert.equal(second[0].name, 'Recruits');
assert.equal(ECAZ_TREACHERY_DEFINITIONS[0].card.name, 'Recruits');
assert.ok(Object.isFrozen(ECAZ_TREACHERY_DEFINITIONS));
for (const definition of ECAZ_TREACHERY_DEFINITIONS) {
assert.ok(Object.isFrozen(definition));
assert.ok(Object.isFrozen(definition.card));
assert.ok(Object.isFrozen(definition.gameplay));
assert.ok(Object.isFrozen(definition.battleSlots));
assert.ok(Object.isFrozen(definition.verification.unresolved));
}
});
void test('slot occupancy never reclassifies the new special cards as weapons or defenses', () => {
for (const card of ecazTreacheryCards()) {
const definition = ecazTreacheryDefinition(card)!;
assert.equal(definition.weaponCategory, false);
assert.equal(definition.defenseCategory, false);
assert.equal(WEAPON_KINDS.includes(card.kind), false);
assert.equal(DEFENSE_KINDS.includes(card.kind), false);
assert.deepEqual(
definition.battleSlots,
card.effect === 'recruits' ? [] : ['weapon', 'defense'],
);
assert.equal(definition.discard, 'after-use');
}
});
void test('presentation lookup requires the canonical physical ID, kind and effect', () => {
const card = ecazTreacheryCards()[0];
assert.equal(ecazTreacheryDefinition(card)?.card.name, 'Recruits');
assert.equal(
ecazTreacheryDefinition({ ...card, id: 'treachery-0' }),
undefined,
);
assert.equal(
ecazTreacheryDefinition({ ...card, effect: 'harvester' }),
undefined,
);
assert.equal(
ecazTreacheryDefinition({ ...card, kind: 'worthless' }),
undefined,
);
assert.equal(ecazTreacheryDefinition(baseDeck()[0]), undefined);
});
void test('verified inventory and source rules remain distinct from unimplemented runtime and unresolved interactions', () => {
for (const definition of ECAZ_TREACHERY_DEFINITIONS) {
assert.equal(definition.quantity, 1);
assert.equal(definition.verification.inventory, 'verified');
assert.equal(definition.verification.sourceRules, 'verified');
assert.equal(definition.verification.runtime, 'not-implemented');
assert.equal(definition.verification.combinedInteractions, 'incomplete');
assert.ok(definition.verification.unresolved.length > 0);
assert.ok(definition.summary.length > 20);
assert.ok(definition.gameplay.length >= 4);
assert.ok(definition.gameplay.every((paragraph) => paragraph.length > 15));
}
});
void test('every canonical Ecaz face receives its full original gameplay guide and a concise normal-card summary', () => {
for (const card of ecazTreacheryCards()) {
const definition = ecazTreacheryDefinition(card)!;
const presentation = cardPresentation(card);
assert.equal(presentation.guidance, definition.summary);
assert.deepEqual(presentation.gameplay, definition.gameplay);
assert.equal(presentation.category, 'Special treachery');
assert.equal(presentation.role, 'utility');
assert.deepEqual(Object.keys(presentation).sort(), [
'category',
'gameplay',
'guidance',
'role',
'topics',
]);
const serialized = JSON.stringify(presentation);
for (const note of definition.verification.unresolved)
assert.equal(serialized.includes(note), false);
assert.equal(serialized.includes('sourceRules'), false);
assert.equal(serialized.includes('not-implemented'), false);
assert.deepEqual(presentation.topics, []);
}
});
void test('missing, mismatched and merely similar Ecaz identities do not receive a canonical gameplay guide', () => {
for (const card of ecazTreacheryCards()) {
const bad: Card[] = [
{ ...card, id: '' },
{ ...card, id: 'unrelated-card' },
{ ...card, effect: undefined },
{ ...card, effect: 'unrelated-effect' },
{ ...card, kind: 'worthless' },
{ ...card, name: 'Unrelated name' },
{ ...card, id: card.id.toUpperCase() },
{ ...card, effect: card.effect.toUpperCase() },
];
for (const supplied of bad) {
const presentation = cardPresentation(supplied);
assert.equal(presentation.gameplay, undefined);
assert.notEqual(
presentation.guidance,
ecazTreacheryDefinition(card)!.summary,
);
}
}
});
void test('base and Ix card guides retain their role, summary and reference topics without Ecaz enrichment', () => {
for (const card of [...baseDeck(), ...ixDeck()]) {
const presentation = cardPresentation(card);
assert.equal(presentation.gameplay, undefined);
assert.ok(presentation.guidance.length > 20);
}
const weapon = cardPresentation(baseDeck()[0]);
assert.equal(weapon.role, 'weapon');
assert.equal(
weapon.guidance,
'Play in the weapon slot of your battle plan. A projectile weapon kills an opposing leader who has no projectile defense.',
);
assert.ok(weapon.topics.some((topic) => topic.id === 'battle-cards'));
const poisonTooth = cardPresentation(
ixDeck().find((card) => card.kind === 'poisonTooth')!,
);
assert.equal(poisonTooth.role, 'weapon');
assert.ok(
poisonTooth.topics.some((topic) => topic.id === 'card-poison-tooth'),
);
const hero = cardPresentation(
baseDeck().find((card) => card.kind === 'hero')!,
);
assert.equal(hero.role, 'leader');
assert.ok(hero.topics.some((topic) => topic.id === 'cheap-hero-traitor'));
assert.equal(treacheryDeck().length, 33);
assert.equal(treacheryDeck(['ix']).length, 47);
});