Files
Dune/tests/selected-independent-ixian-betrayal.test.ts
Quintine 8793e23d6b Compose selected E3 independent cards with Nexus rules
Selected Ecaz/Moritani decks with independent cards and Nexus now retain native Occupy, Advanced assassination, arrival ordering and shared Duke consumers, including original optional modules. Keep the trained E3/Harkonnen capture boundary and remaining individual Nexus effect limits explicit.

Validation: frozen 17-file 168/168, types/lint/build, seven finite original-action and table-SSR programmes, plus four minimal legal Cunning/expiry policies. Fixture corrections preserve the printed alliance draw gate and real paid placement affordability. No stored-game or production change; comprehensive and deployed acceptance remain open.
2026-10-18 10:59:57 +11:00

162 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { richeseCards } from '../game/richese-cards';
import { NEXUS_FACTIONS, type NexusPlayer } from '../game/nexus-cards';
import {
IxianBetrayalError, closeIxianBetrayalSource, createIxianBetrayalSource,
initialIxianBetrayalCursor, ixianBetrayalEligible, ixianBetrayalResponders,
validateIxianBetrayalFrame, validateIxianBetrayalHistory,
type IxianBetrayalAuthority, type IxianBetrayalContext,
type IxianBetrayalPhysicalSelection,
} from '../game/nexus-ixian-betrayal';
function fixture(mixed = true, advanced = true): IxianBetrayalContext {
const players: NexusPlayer[] = mixed ? [
{ id: 'ix', faction: 'ixians' }, { id: 'holder', faction: 'richese' },
{ id: 'choam', faction: 'choam' }, { id: 'tl', faction: 'tleilaxu' },
{ id: 'observer', faction: 'atreides' },
] : [
{ id: 'ix', faction: 'ixians' }, { id: 'holder', faction: 'atreides' },
{ id: 'observer', faction: 'tleilaxu' },
];
const expansions = mixed ? ['ix', 'choam'] : ['ix'];
return {
status: 'playing', phase: 3, turn: 2, advanced, sequence: 0,
expansions, ecazTreachery: true, players,
cards: { version: 1, deck: NEXUS_FACTIONS.filter(card => card !== 'ixians' && card !== 'guild'),
discard: [], hands: Object.fromEntries(players.map(player =>
[player.id, player.id === 'holder' ? 'ixians' : player.id === 'observer' ? 'guild' : null])) },
physicalCards: [...treacheryDeck(expansions), ...ecazTreacheryCards(), ...(mixed ? richeseCards() : [])],
};
}
function authority(technology = false): IxianBetrayalAuthority {
return { kind: technology ? 'technology' : 'bidding', provider: 'ix',
nativeWindow: technology ? 'ixTechnology' : 'ixAuction',
nativeContext: technology ? 'original-native-unused-card-exchange' : 'original-undrawn-native-pool',
nativeRequired: ['observer'], nativePassed: ['observer'] };
}
function selection(ctx: IxianBetrayalContext): IxianBetrayalPhysicalSelection {
return { expansions: ctx.expansions, ecazTreachery: ctx.ecazTreachery,
richese: ctx.players.some(player => player.faction === 'richese') };
}
for (const mixed of [false, true]) for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} selected ${mixed ? 'Ix/CHOAM with native cache' : 'Ix'} independent inventory cancels the original native Bidding attempt`, () => {
const ctx = fixture(mixed, advanced), before = structuredClone(ctx), native = authority();
assert.equal(ctx.physicalCards.length, mixed ? 60 : 50);
const source = createIxianBetrayalSource(ctx, native, 'current-native-bidding-parent');
assert.deepEqual(source.required, ['holder', 'observer']);
assert.equal(source.eligible, 'holder');
assert.deepEqual(ixianBetrayalResponders(ctx.cards, ctx.players, selection(ctx)), source.required);
assert.equal(ixianBetrayalEligible(ctx.cards, ctx.players, 'holder', selection(ctx)), true);
validateIxianBetrayalFrame(ctx, source, native, source.parent, source.required, ['observer']);
const closed = closeIxianBetrayalSource(source, 'use', 'holder', initialIxianBetrayalCursor());
assert.equal(closed.receipt.outcome, 'use', 'The receipt cancels this native attempt, not a different draw or exchange.');
assert.equal(closed.receipt.source.nativeWindow, 'ixAuction');
assert.equal(closed.cursor.sequence, 1);
validateIxianBetrayalHistory([closed.receipt], closed.cursor, ctx.physicalCards, selection(ctx));
assert.deepEqual(ctx, before, 'The pure native cancellation does not deal, discard or move physical inventory.');
// Historical cancellation survives ordinary later custody changes; the
// caller still supplies the same public complete selected inventory.
const recycled = structuredClone(ctx);
recycled.cards.hands.holder = null;
recycled.cards.discard.push('ixians');
recycled.physicalCards = [...recycled.physicalCards].reverse();
validateIxianBetrayalHistory([closed.receipt], closed.cursor, recycled.physicalCards, selection(recycled));
assert.throws(() => validateIxianBetrayalFrame({ ...ctx, sequence: 1 }, source,
native, source.parent, source.required, []), IxianBetrayalError);
});
}
void test('a passed native draw and a later canceled Technology exchange retain distinct original selected-deck sources', () => {
const ctx = fixture(), bidding = authority();
const first = createIxianBetrayalSource(ctx, bidding, 'original-draw-parent');
const passed = closeIxianBetrayalSource(first, 'pass', null, initialIxianBetrayalCursor());
assert.equal(passed.receipt.outcome, 'pass');
const technology = authority(true), next = { ...ctx, sequence: passed.cursor.sequence };
const second = createIxianBetrayalSource(next, technology, 'original-technology-parent');
const used = closeIxianBetrayalSource(second, 'use', 'holder', passed.cursor);
assert.notEqual(second.event, first.event);
assert.equal(used.receipt.source.nativeWindow, 'ixTechnology');
validateIxianBetrayalHistory([passed.receipt, used.receipt], used.cursor, ctx.physicalCards, selection(ctx));
assert.throws(() => createIxianBetrayalSource({ ...next, advanced: false }, technology,
second.parent), IxianBetrayalError, 'Basic does not gain Advanced Technology.');
assert.throws(() => validateIxianBetrayalFrame(next, second, bidding, first.parent,
second.required, []), IxianBetrayalError, 'The canceled exchange cannot authorize a different native draw.');
});
void test('selected physical inventories require all three independent faces and only the actually seated native cache', () => {
const ctx = fixture(), before = structuredClone(ctx);
const independentIndex = ctx.physicalCards.findIndex(card => card.id === 'ecaz-recruits');
assert.ok(independentIndex >= 0);
const missing = ctx.physicalCards.filter((_, index) => index !== independentIndex);
const duplicated = [...ctx.physicalCards, ctx.physicalCards[independentIndex]];
const malformed = [...structuredClone(ctx.physicalCards)];
malformed[independentIndex] = { ...malformed[independentIndex], name: 'Invented Recruits' };
const missingCache = ctx.physicalCards.filter(card => !richeseCards().some(cache => cache.id === card.id));
for (const physicalCards of [missing, duplicated, malformed, missingCache])
assert.throws(() => createIxianBetrayalSource({ ...ctx, physicalCards }, authority(),
'original-parent'), IxianBetrayalError);
const ixOnly = fixture(false);
assert.throws(() => createIxianBetrayalSource({ ...ixOnly,
physicalCards: [...ixOnly.physicalCards, ...richeseCards()] }, authority(), 'original-parent'), IxianBetrayalError);
assert.throws(() => createIxianBetrayalSource({ ...ctx, ecazTreachery: false }, authority(),
'original-parent'), IxianBetrayalError, 'A physical hand cannot silently opt into the independent variant.');
assert.deepEqual(ctx, before);
});
void test('public selected families authorize native responders without admitting unselected native seats', () => {
const ctx = fixture();
for (const expansions of [['choam'], ['ix', 'ix'], ['ix', 'ecaz'], []])
assert.throws(() => createIxianBetrayalSource({ ...ctx, expansions }, authority(),
'original-parent'), IxianBetrayalError);
assert.throws(() => ixianBetrayalResponders(ctx.cards, ctx.players), IxianBetrayalError,
'Omitted selection keeps the original base/Ix/Tleilaxu roster contract.');
for (const faction of ['ecaz', 'moritani'] as const) {
const players = ctx.players.map(player => player.id === 'holder' ? { ...player, faction } : player);
assert.throws(() => ixianBetrayalResponders(ctx.cards, players, selection(ctx)), IxianBetrayalError);
}
const allied = { ...ctx, players: ctx.players.map(player => player.id === 'holder'
? { ...player, ally: 'observer' } : player.id === 'observer' ? { ...player, ally: 'holder' } : player) };
assert.deepEqual(ixianBetrayalResponders(allied.cards, allied.players, selection(allied)), []);
assert.equal(ixianBetrayalEligible(allied.cards, allied.players, 'holder', selection(allied)), false);
});
for (const mixed of [false, true]) void test(`selected E3/${mixed ? 'Ix/CHOAM cache' : 'Ix'} native responders preserve original cancellation and physical cards`, () => {
const original = fixture(mixed);
const expansions = [...original.expansions!, 'ecaz'];
const players: NexusPlayer[] = [...original.players.map(player => player.id === 'observer'
? {...player, faction:'ecaz' as const} : player), {id:'moritani', faction:'moritani'}];
const ctx: IxianBetrayalContext = {...original, expansions, players,
cards:{...original.cards, hands:{...original.cards.hands, moritani:null}},
physicalCards:[...treacheryDeck(expansions), ...ecazTreacheryCards(), ...(mixed ? richeseCards() : [])]};
const before = structuredClone(ctx);
const source = createIxianBetrayalSource(ctx, authority(), 'original-selected-e3-native-pool');
assert.equal(source.eligible, 'holder');
assert.deepEqual(ixianBetrayalResponders(ctx.cards, players, selection(ctx)), source.required);
const closed = closeIxianBetrayalSource(source, 'use', 'holder', initialIxianBetrayalCursor());
validateIxianBetrayalHistory([closed.receipt], closed.cursor, ctx.physicalCards, selection(ctx));
assert.equal(ctx.physicalCards.length, mixed ? 60 : 50);
assert.deepEqual(ctx, before, 'Neither the native E3 presence nor cancellation changes the selected catalogue.');
assert.throws(() => createIxianBetrayalSource({...ctx, ecazTreachery:false}, authority(),
source.parent), IxianBetrayalError, 'This new selected E3 lane requires its real independent-card selection.');
});
void test('omitted selection preserves original Ix47 source and receipt grammar and rejects silent new card inventory', () => {
const selected = fixture(false), ctx = { ...selected, physicalCards: treacheryDeck(['ix']) };
delete ctx.expansions;
delete ctx.ecazTreachery;
const native = authority(), source = createIxianBetrayalSource(ctx, native, 'legacy-parent');
const explicit = createIxianBetrayalSource({ ...ctx, expansions: ['ix'], ecazTreachery: false },
native, 'legacy-parent');
assert.deepEqual(explicit, source, 'Public configuration adds no unconditional receipt fields or signature changes.');
const closed = closeIxianBetrayalSource(source, 'use', 'holder', initialIxianBetrayalCursor());
validateIxianBetrayalHistory([closed.receipt], closed.cursor, ctx.physicalCards);
assert.throws(() => validateIxianBetrayalHistory([closed.receipt], closed.cursor,
selected.physicalCards), IxianBetrayalError);
validateIxianBetrayalHistory([closed.receipt], closed.cursor, selected.physicalCards, selection(selected));
});