Files
Dune/tests/homeworld-revival-terror-arrival.test.ts

276 lines
17 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { createGame, newPlayer, type Game } from '../game/engine';
import { createHomeworldCustody } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import {
appendHomeworldArrivalAmbassador, completeHomeworldArrivalAmbassador,
homeworldArrivalSignature, validateHomeworldArrival,
} from '../game/homeworld-arrival';
import {
makeHomeworldRevivalReturn, makeHomeworldRevivalProgress,
homeworldRevivalResumeSignature, validateHomeworldRevivalReturn,
} from '../game/homeworld-revival-return';
import { ambassadorPhaseAllowed } from '../game/ambassador-phase';
import { arrivalOverlapSignature, overlapOwnsAmbassadorDiscard, overlapOwnsTerrorDiscard } from '../game/arrival-overlap';
import { terrorEntrySignature } from '../game/terror-entry-receipt';
import type { RevivalEntryParent, RevivalTerrorPacket } from '../game/revival-terror-entry';
import type { SpiritualAdvisorSource } from '../game/spiritual-advisor';
type Ambassador = NonNullable<Game['pendingAmbassador']>;
type Terror = NonNullable<Game['pendingTerrorEntry']>;
type Overlap = NonNullable<Game['pendingArrivalOverlap']>;
/** Controlled completed-transfer witnesses exercise public continuation rules.
* Native revival, token revelation and the physical Sneak deposit are root's
* separate producer tests; no current army is used to recreate these receipts. */
function fixture(fedaykin = false, phase = 4) {
const owner = fedaykin ? 'f' : 't';
const g = createGame('REVIVAL-TERROR-ARRIVAL', newPlayer(owner, owner, fedaykin ? 'fremen' : 'tleilaxu'), true, ['ecaz', 'ix']);
g.players.push(newPlayer('m', 'Moritani', 'moritani'), newPlayer('b', 'BG', 'beneGesserit'),
newPlayer('e', 'Ecaz', 'ecaz'), newPlayer('i', 'Ixians', 'ixians'));
g.status = 'playing'; g.turn = 2; g.phase = phase;
g.order = ['e', 'm', owner, 'b', 'i'];
g.players[0].reserves = fedaykin ? 4 : 11;
if (fedaykin) g.players[0].elites = { reserves: 1, tanks: 2, forces: {}, revived: 0 };
g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
const frame = makeHomeworldRevivalReturn({ event: 'revival-original', player: owner, turn: 2, phase,
source: fedaykin ? 'ghola' : 'normal', ...(fedaykin ? { card: 'original-ghola' } : {}),
group: fedaykin ? { amount: 1, elite: 1, free: 0 } : { amount: 2, elite: 0, free: 2 },
quote: fedaykin
? { kind: 'fedaykin', normal: 0, elite: 1, beforePopulation: 3, afterPopulation: 4, blocked: null }
: { kind: 'tleilax', normal: 2, elite: 0, beforePopulation: 9, afterPopulation: 11, blocked: null },
});
frame.stage = 'arrival'; frame.destination = 'arrakeen:10'; frame.ambassadors = [];
frame.resumeResponse = null; frame.resumeSignature = homeworldRevivalResumeSignature(null);
frame.arrivalSignature = homeworldArrivalSignature(frame);
g.homeworldRevivalReturn = frame; g.homeworldRevivalProgress = makeHomeworldRevivalProgress(frame);
g.pendingAmbassador = null;
return { g, frame };
}
function ambassador(g: Game, event: string, entrant: string, sector = 10): Ambassador {
return { event, owner: 'e', entrant, territory: 'arrakeen', sector, turn: g.turn, phase: g.phase,
revivalEvent: g.homeworldRevivalReturn!.event, token: 'ambassador-token', stage: 'cards',
copyChoices: [], resume: 'none' };
}
function spiritual(g: Game, event = 'spiritual-sneak'): SpiritualAdvisorSource {
return { event, owner: 'b', turn: g.turn, phase: g.phase, territory: 'arrakeen', sector: 10,
amount: 1, revivalEvent: g.homeworldRevivalReturn!.event };
}
function originalTerror(g: Game): Terror {
const frame = g.homeworldRevivalReturn!;
const entry: Terror = { token: 'terror-sneak-token', entrant: frame.player,
territory: 'arrakeen', sector: 10, amount: frame.quote.normal + frame.quote.elite,
elite: frame.quote.elite, cause: 'homeworldRevival', turn: g.turn, phase: g.phase,
resume: 'none', stage: 'offer', revivalArrival: { event: frame.event } };
entry.entrySignature = terrorEntrySignature(entry);
return entry;
}
function sneak(g: Game): RevivalTerrorPacket {
return { event: originalTerror(g).entrySignature!, owner: 'm', territory: 'arrakeen', sector: 10, amount: 2, elite: 0 };
}
function overlap(g: Game, parent: RevivalEntryParent, entrant = g.homeworldRevivalReturn!.player,
cause: Terror['cause'] = 'homeworldRevival', first: Overlap['first'] = 'ambassador'): Overlap {
const source = parent.spiritual;
const record: Overlap = { event: 'arrival-overlap', signature: '', turn: g.turn, phase: g.phase,
entrant, territory: 'arrakeen', sector: 10, amount: source?.amount ?? g.homeworldRevivalReturn!.quote.normal + g.homeworldRevivalReturn!.quote.elite,
elite: source ? 0 : g.homeworldRevivalReturn!.quote.elite, cause, resume: 'none', ecaz: 'e', moritani: 'm',
first, stage: 'first', ambassadorToken: 'ambassador-token', terrorTokens: ['terror-sneak-token'], revivalArrival: parent };
g.order = first === 'ambassador' ? ['e', 'm', ...g.order.filter(id => id !== 'e' && id !== 'm')]
: ['m', 'e', ...g.order.filter(id => id !== 'e' && id !== 'm')];
record.signature = arrivalOverlapSignature(record); g.pendingArrivalOverlap = record;
return record;
}
void test('a native Moritani Sneak root may surround the one original revived root without replacing it', () => {
for (const order of ['before', 'after'] as const) {
const { g, frame } = fixture(), packet = sneak(g);
const original = ambassador(g, 'ambassador-original', frame.player), child = ambassador(g, 'ambassador-sneak', 'm');
for (const current of order === 'before' ? [child, original] : [original, child]) {
appendHomeworldArrivalAmbassador(frame, current, undefined, undefined, current === child ? packet : undefined);
g.pendingAmbassador = current;
validateHomeworldRevivalReturn(g, frame);
assert.equal(ambassadorPhaseAllowed(g), true);
completeHomeworldArrivalAmbassador(frame, current.event);
}
assert.notEqual(frame.ambassadors!.find(record => record.terror)?.terror, packet);
const before = JSON.stringify(frame);
assert.throws(() => appendHomeworldArrivalAmbassador(frame, { ...child, event: 'duplicate-sneak' }, undefined, undefined, packet));
assert.throws(() => appendHomeworldArrivalAmbassador(frame, { ...original, event: 'duplicate-original' }));
assert.equal(JSON.stringify(frame), before);
g.pendingAmbassador = null; frame.stage = 'complete';
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(frame);
validateHomeworldRevivalReturn(g, frame);
}
});
void test('Sneak roots retain the phase of an actual Southern return instead of requiring Revival phase', () => {
for (const phase of [1, 3, 5, 8]) {
const { g, frame } = fixture(true, phase), child = ambassador(g, 'ambassador-sneak', 'm');
appendHomeworldArrivalAmbassador(frame, child, undefined, undefined, sneak(g));
g.pendingAmbassador = child;
validateHomeworldRevivalReturn(g, frame);
assert.equal(ambassadorPhaseAllowed(g), true);
const wrong = structuredClone(g);
wrong.phase = phase === 8 ? 0 : phase + 1;
assert.equal(ambassadorPhaseAllowed(wrong), false);
}
});
void test('Southern permits a BG Spiritual root only with the same committed native Sneak witness', () => {
const { g, frame } = fixture(true), bg = ambassador(g, 'ambassador-bg', 'b'), source = spiritual(g), packet = sneak(g);
appendHomeworldArrivalAmbassador(frame, bg, undefined, source, packet);
g.pendingAmbassador = bg;
validateHomeworldRevivalReturn(g, frame);
assert.equal(ambassadorPhaseAllowed(g), true);
completeHomeworldArrivalAmbassador(frame, bg.event);
const changed = { ...packet, amount: 3 }, before = JSON.stringify(frame);
assert.throws(() => appendHomeworldArrivalAmbassador(frame, ambassador(g, 'changed-sneak', 'm'), undefined, undefined, changed));
assert.equal(JSON.stringify(frame), before);
const native = ambassador(g, 'ambassador-sneak', 'm');
appendHomeworldArrivalAmbassador(frame, native, undefined, undefined, packet);
g.pendingAmbassador = native;
assert.equal(ambassadorPhaseAllowed(g), true);
const unbacked = fixture(true), unbackedBg = ambassador(unbacked.g, 'unbacked-bg', 'b');
appendHomeworldArrivalAmbassador(unbacked.frame, unbackedBg, undefined, spiritual(unbacked.g));
unbacked.g.pendingAmbassador = unbackedBg;
assert.equal(ambassadorPhaseAllowed(unbacked.g), false);
});
void test('a captured Polar Spiritual arrival supports its actual Sneak and BG child roots', () => {
const { g, frame } = fixture();
const polarSource = { ...spiritual(g, 'spiritual-polar'), territory: 'polar_sink', sector: 0 };
const trigger: Terror = { ...originalTerror(g), entrant: 'b', territory: 'polar_sink', sector: 0,
amount: 1, elite: 0, cause: 'advisor', revivalArrival: { event: frame.event, spiritual: polarSource } };
trigger.entrySignature = terrorEntrySignature(trigger);
const packet: RevivalTerrorPacket = { event: trigger.entrySignature, owner: 'm', territory: 'polar_sink', sector: 0, amount: 2, elite: 0 };
const native = { ...ambassador(g, 'polar-sneak-root', 'm'), territory: 'polar_sink', sector: 0 };
appendHomeworldArrivalAmbassador(frame, native, undefined, undefined, packet);
g.pendingAmbassador = native;
validateHomeworldRevivalReturn(g, frame);
assert.equal(ambassadorPhaseAllowed(g), true);
completeHomeworldArrivalAmbassador(frame, native.event);
const bg = { ...ambassador(g, 'polar-bg-child', 'b'), territory: 'polar_sink', sector: 0 };
appendHomeworldArrivalAmbassador(frame, bg, undefined,
{ ...polarSource, event: 'spiritual-from-polar-sneak' }, packet);
g.pendingAmbassador = bg;
validateHomeworldRevivalReturn(g, frame);
assert.equal(ambassadorPhaseAllowed(g), true);
const wrong = structuredClone(g);
const current = wrong.homeworldRevivalReturn!.ambassadors!.at(-1)!;
current.terror!.territory = 'carthag'; current.terror!.sector = 11;
assert.throws(() => validateHomeworldArrival(wrong, wrong.homeworldRevivalReturn!, 'revivalEvent'));
});
void test('Sneak Ambassador provenance rejects foreign packets, quantities, historical clocks and synthetic entrants', () => {
for (const patch of [{ owner: 'b' }, { amount: 0 }, { amount: 6 }, { elite: 1 },
{ territory: 'carthag', sector: 11 }, { card: 'private-card' }, { event: 'unbound-source' }]) {
const { g, frame } = fixture(), before = JSON.stringify(frame);
assert.throws(() => {
appendHomeworldArrivalAmbassador(frame, ambassador(g, 'ambassador-sneak', 'm'), undefined, undefined,
{ ...sneak(g), ...patch } as RevivalTerrorPacket);
g.pendingAmbassador = ambassador(g, 'ambassador-sneak', 'm');
validateHomeworldArrival(g, frame, 'revivalEvent');
});
if (frame.ambassadors!.length === 0) assert.equal(JSON.stringify(frame), before);
}
const { g, frame } = fixture(), original = originalTerror(g);
original.turn = 1; original.entrySignature = terrorEntrySignature(original);
appendHomeworldArrivalAmbassador(frame, ambassador(g, 'old-clock', 'm'), undefined, undefined,
{ ...sneak(g), event: original.entrySignature });
g.pendingAmbassador = ambassador(g, 'old-clock', 'm');
assert.equal(ambassadorPhaseAllowed(g), false);
const other = fixture();
assert.throws(() => appendHomeworldArrivalAmbassador(other.frame, ambassador(other.g, 'synthetic-original', 't'), undefined, undefined, sneak(other.g)));
});
void test('old Spiritual signature rows stay unchanged and native Sneak roots retain Guild suffixes', () => {
const direct = fixture(), bg = ambassador(direct.g, 'direct-bg', 'b'), source = spiritual(direct.g, 'direct-spiritual');
appendHomeworldArrivalAmbassador(direct.frame, bg, undefined, source);
assert.equal(direct.frame.arrivalSignature, JSON.stringify(['homeworldRevivalArrival', direct.frame.signature, 'arrakeen:10',
[[bg.event, 'b', 'arrakeen:10', null, false, [source.event, 'b', 2, 4, 'arrakeen', 10, 1, direct.frame.event]]]]));
const { g, frame } = fixture(), root = ambassador(g, 'sneak-root', 'm'), packet = sneak(g);
appendHomeworldArrivalAmbassador(frame, root, undefined, undefined, packet);
completeHomeworldArrivalAmbassador(frame, root.event);
const suffix: Ambassador = { ...ambassador(g, 'guild-bg-suffix', 'b'),
guildAdvisorOrigin: { event: root.event, player: 'i', territory: 'arrakeen', sector: 10 } };
assert.throws(() => appendHomeworldArrivalAmbassador(frame, suffix, root.event, undefined, packet));
appendHomeworldArrivalAmbassador(frame, suffix, root.event);
g.pendingAmbassador = suffix;
assert.equal(ambassadorPhaseAllowed(g), true);
});
void test('overlap signatures preserve legacy bytes and bind new revived provenance canonically', () => {
const { g } = fixture(), parent: RevivalEntryParent = { event: g.homeworldRevivalReturn!.event, spiritual: spiritual(g), terror: sneak(g) };
const record = overlap(g, parent, 'b', 'advisor');
const signature = record.signature;
record.revivalArrival = { terror: { ...parent.terror! }, spiritual: { ...parent.spiritual! }, event: parent.event };
assert.equal(arrivalOverlapSignature(record), signature);
delete record.revivalArrival;
record.phase = 5; record.cause = 'shipment';
assert.equal(arrivalOverlapSignature(record), JSON.stringify([record.event, record.turn, 5, record.entrant,
record.territory, record.sector, record.amount, record.elite, 'shipment', record.resume,
record.ecaz, record.moritani, record.first, record.ambassadorToken, record.terrorTokens]));
});
void test('a bound revived overlap owns each active discard while the primary and its clock remain live', () => {
const { g, frame } = fixture(), parent = { event: frame.event };
const current = ambassador(g, 'original-ambassador', frame.player);
current.arrivalCause = 'homeworldRevival';
appendHomeworldArrivalAmbassador(frame, current);
const record = overlap(g, parent);
record.ambassadorEvent = current.event;
g.pendingAmbassador = null;
assert.equal(overlapOwnsAmbassadorDiscard(g, current), true, 'The discard owns the suspended actual Ambassador.');
assert.equal(overlapOwnsTerrorDiscard(g, originalTerror(g), 'm'), false);
completeHomeworldArrivalAmbassador(frame, current.event);
record.stage = 'second';
const terror = originalTerror(g);
assert.equal(overlapOwnsTerrorDiscard(g, terror, 'm'), true);
assert.equal(overlapOwnsTerrorDiscard(g, terror, 'e'), false);
assert.equal(overlapOwnsTerrorDiscard(g, { ...terror, amount: 1 }, 'm'), false);
assert.equal(overlapOwnsTerrorDiscard(g, { ...terror, revivalArrival: { event: 'unrelated' } }, 'm'), false);
const wrongPhase = structuredClone(g); wrongPhase.phase = 5;
assert.equal(overlapOwnsTerrorDiscard(wrongPhase, terror, 'm'), false);
const missing = structuredClone(g); delete missing.pendingArrivalOverlap!.revivalArrival;
missing.pendingArrivalOverlap!.signature = arrivalOverlapSignature(missing.pendingArrivalOverlap!);
assert.equal(overlapOwnsTerrorDiscard(missing, terror, 'm'), false);
});
void test('Spiritual overlap discard matches the recorded packet, including Southern Sneak context, without private reads', () => {
const { g, frame } = fixture(true), source = spiritual(g), packet = sneak(g);
const parent: RevivalEntryParent = { event: frame.event, spiritual: source, terror: packet };
const bg = ambassador(g, 'sneak-bg-ambassador', 'b'); bg.arrivalCause = 'advisor';
appendHomeworldArrivalAmbassador(frame, bg, undefined, source, packet);
const record = overlap(g, parent, 'b', 'advisor'); record.ambassadorEvent = bg.event;
g.pendingAmbassador = null;
assert.equal(overlapOwnsAmbassadorDiscard(g, bg), true);
const changed = structuredClone(g);
changed.pendingArrivalOverlap!.revivalArrival!.spiritual!.event = 'another-spiritual';
changed.pendingArrivalOverlap!.signature = arrivalOverlapSignature(changed.pendingArrivalOverlap!);
assert.equal(overlapOwnsAmbassadorDiscard(changed, bg), false, 'A same-price packet cannot replace the recorded physical source.');
for (const player of g.players)
for (const field of ['hand', 'spice', 'forces', 'reserves', 'tanks', 'traitors', 'faceDancers'])
Object.defineProperty(player, field, { get() { throw new Error(`unrelated ${field}`); } });
assert.equal(overlapOwnsAmbassadorDiscard(g, bg), true);
});
void test('an out-of-phase Southern overlap can settle Terror first and its original Ambassador second', () => {
const { g, frame } = fixture(true, 3), parent = { event: frame.event };
const record = overlap(g, parent, frame.player, 'homeworldRevival', 'terror');
assert.equal(overlapOwnsTerrorDiscard(g, originalTerror(g), 'm'), true);
const current = ambassador(g, 'southern-original-ambassador', frame.player);
current.arrivalCause = 'homeworldRevival';
appendHomeworldArrivalAmbassador(frame, current);
record.stage = 'second'; record.ambassadorEvent = current.event;
g.pendingAmbassador = null;
assert.equal(overlapOwnsAmbassadorDiscard(g, current), true);
assert.equal(overlapOwnsTerrorDiscard(g, originalTerror(g), 'm'), false);
});