Connect revived arrivals to native Terror continuations

This commit is contained in:
Quintine committed 2026-10-25 22:04:33 +11:00
1 parent ab69f97cfa
commit 724d9dce1e
20 files changed
+1527 -55

No files matched your search

+266
View File
@@ -0,0 +1,266 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { homeworldGameIntegrity, homeworldTable } from '../game/homeworld-game';
import { nativeShipmentSources } from '../game/homeworld-options';
import { treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import type { TerrorKind } from '../game/moritani-terror';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import {
actRevivalArrivalReactions, revivalArrivalReactionsClean,
revivalArrivalReactionsInventory, revivalArrivalReactionsPlayer,
} from './fixture-revival-arrival-reactions';
export type RevivalTerrorStep = { actor: string; action: Action };
export type RevivalTerrorTransition = { before: Game; steps: RevivalTerrorStep[]; after: Game };
/** Later revealed effects use native entropy, including Assassination's real
* living-disc lottery. Directed original shuffles belong only to construction. */
export function actRevivalTerror(game: Game, actor: string, action: Action,
actions: RevivalTerrorStep[] = []): Game {
const after = applyAction(game, actor, action);
actions.push({ actor, action: structuredClone(action) }); return after;
}
export const revivalTerrorClean = revivalArrivalReactionsClean;
export const revivalTerrorPlayer = revivalArrivalReactionsPlayer;
export const revivalTerrorInventory = revivalArrivalReactionsInventory;
/** Original Mentat placement, returned-group placement and Terror choices
* remain human controls. No printed Terror effect is silently selected. */
export function nextRevivalTerror(game: Game): RevivalTerrorStep | null {
if (game.response?.automaticAdvisorFlip || game.truthtrance) return null;
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
const d = game.decision;
if (d && ['moritaniPlacement', 'moritaniTerror', 'moritaniExtortion',
'ecazPlacement', 'ecazAmbassador', 'arrivalOverlap', 'advisor', 'intrusion',
'homeworldRevivalDeployment', 'revivalStop'].includes(d.kind)) return null;
const bg = game.players.find(seat => seat.faction === 'beneGesserit');
if (bg && game.status === 'setup' && game.setupStage === 'prediction' &&
viewGame(game, bg.id).setupPending.includes(bg.id))
return { actor: bg.id, action: { type: 'predict', faction: 'tleilaxu', turn: 10 } };
if (bg && game.advanced && game.status === 'setup' && game.setupStage === 'forces' && !bg.advisorSetup &&
viewGame(game, bg.id).setupPending.includes(bg.id))
return { actor: bg.id, action: { type: 'advisorSetup', territory: 'polar_sink', sector: 0 } };
const ecaz = game.players.find(seat => seat.faction === 'ecaz');
if (ecaz && game.status === 'setup' && game.setupStage === 'forces' && ecaz.reserves === 20 &&
viewGame(game, ecaz.id).setupPending.includes(ecaz.id))
return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:9': 6 } } };
if (d?.kind === 'moritaniSetup') return { actor: d.player,
action: { type: 'decision', territory: 'tueks_sietch', sector: 5 } };
if (d?.kind === 'moritaniAssassinate') return { actor: d.player,
action: { type: 'decision', event: d.event, decline: true } };
if (d?.kind === 'advisorBattle') return { actor: d.player, action: { type: 'decision', accept: false } };
if (game.status === 'playing' && game.phase === 0 && game.stormPending === null) {
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
if (actor) return { actor, action: { type: 'stormDial', amount: game.turn === 1
? game.stormDialers.indexOf(actor) === 0 ? game.advanced ? 11 : 10 : 0
: 1 } };
}
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceRevivalTerror(game: Game, until: (state: Game) => boolean,
actions: RevivalTerrorStep[] = []): Game {
for (let count = 0; count < 1200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Terror source ended before its next native control');
const next = nextRevivalTerror(game);
assert.ok(next, `Choose the original Terror/placement control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actRevivalTerror(game, next.actor, next.action, actions);
}
throw Error('The genuine revival-Terror programme did not reach its finite boundary');
}
export function passRevivalTerror(game: Game, actions: RevivalTerrorStep[] = []): Game {
for (let count = 0; game.response && !game.response.automaticAdvisorFlip && count < 100; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actRevivalTerror(game, actor.id, { type: 'passResponse' }, actions);
}
assert.ok(!game.response || game.response.automaticAdvisorFlip); return game;
}
/** Consume original discard machinery until the next owned Terror choice. */
export function settleRevivalTerror(game: Game, actions: RevivalTerrorStep[] = []): Game {
return advanceRevivalTerror(game, state => !state.response && !state.phaseOpening && !state.pendingTreacheryDiscard &&
(state.decision?.kind === 'moritaniTerror' || !state.pendingTerrorEntry && revivalTerrorClean(state)), actions);
}
export function finishRevivalTerror(game: Game, actions: RevivalTerrorStep[] = []): Game {
return advanceRevivalTerror(game, state => state.homeworldRevivalReturn?.stage === 'complete' &&
!state.pendingTerrorEntry && revivalTerrorClean(state), actions);
}
export function commitRevivalTerror(game: Game, fixture: { entrant: string; action: Action },
actions: RevivalTerrorStep[] = []) {
const before = structuredClone(game), start = actions.length;
game = actRevivalTerror(game, fixture.entrant, fixture.action, actions);
return { game, before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
}
/** Public Basic/Advanced Tleilaxu/Moritani, actual Homeworlds, original E1/E3.
* Native casualties and first Mentat Terror placement precede ordinary free
* revive1. Source stops BEFORE selecting the real revived-group destination. */
export function createRevivalTerrorFixture(options: {
advanced?: boolean; kind?: TerrorKind; bg?: boolean; ecaz?: boolean; ambassadorFirst?: boolean;
} = {}) {
const advanced = options.advanced ?? true, kind = options.kind ?? 'robbery';
const entrant = 'tleilaxu', owner = 'moritani', holder = entrant;
const territory = !advanced && options.ecaz ? 'arrakeen' : 'carthag';
const sector = territory === 'arrakeen' ? 10 : 11, location = `${territory}:${sector}`;
const casualtyLocation = 'hagga_basin:13';
let game = createGame('REVIVALTERROR', newPlayer(entrant, 'Tleilaxu', 'tleilaxu'), advanced, ['ix', 'ecaz']);
const bg = options.bg ? 'beneGesserit' : null, ecaz = options.ecaz ? 'ecaz' : null;
if (ecaz && options.ambassadorFirst) {
joinGame(game, newPlayer(ecaz, 'Ecaz', 'ecaz'));
if (bg) joinGame(game, newPlayer(bg, 'Bene Gesserit', 'beneGesserit'));
joinGame(game, newPlayer(owner, 'Moritani', 'moritani'));
} else {
joinGame(game, newPlayer(owner, 'Moritani', 'moritani'));
if (bg) joinGame(game, newPlayer(bg, 'Bene Gesserit', 'beneGesserit'));
if (ecaz) joinGame(game, newPlayer(ecaz, 'Ecaz', 'ecaz'));
}
const initial = structuredClone(game), actions: RevivalTerrorStep[] = [];
let ambassadorToken: string | null = null, ambassadorPlacement: RevivalTerrorTransition | null = null;
const act = (actor: string, action: Action) => { game = actRevivalArrivalReactions(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => {
for (let count = 0; count < 1200 && !until(game); count++) {
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
const d = game.decision;
if (d?.kind === 'advisor' || d?.kind === 'intrusion') {
act(d.player, { type: 'decision', accept: false }); continue;
}
if (d?.kind === 'ecazPlacement') {
if (!ambassadorToken && game.turn === 1) {
const token = game.ecazAmbassadors!.tokens.find(token => token.effect === 'emperor' &&
token.zone === 'supply' && game.ecazAmbassadors!.cohort.includes(token.id));
assert.ok(token, 'The selected original packet must actually supply its Emperor Ambassador');
ambassadorToken = token.id;
const before = structuredClone(game), start = actions.length;
act(d.player, { type: 'decision', token: token.id, territory });
for (let count = 0; game.response && count < 80; count++) {
const actor = game.players.find(seat => !game.response!.passed.includes(seat.id)); assert.ok(actor);
act(actor.id, { type: 'passResponse' });
}
assert.equal(game.ecazAmbassadors!.tokens.find(candidate => candidate.id === token.id)!.zone, 'placed');
ambassadorPlacement = { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
} else act(d.player, { type: 'decision', decline: true });
continue;
}
}
const next = nextRevivalTerror(game); assert.ok(next, 'The original source needs its owned native control');
act(next.actor, next.action);
}
assert.ok(until(game), 'The original revival-Terror source must reach its next finite control');
};
const transition = (run: () => void): RevivalTerrorTransition => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
const homeworlds = transition(() => { act(game.host, { type: 'homeworlds', enabled: true }); });
const optionalEcazModules = ecaz ? transition(() => {
act(game.host, { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
act(game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true });
}) : null;
for (const seat of game.players) act(seat.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.ok(game.homeworlds);
assert.deepEqual(game.expansions, ['ix', 'ecaz']);
assert.ok(!game.leaderSkills && !game.strongholdCards && !game.techTokens && !game.discoveryEnabled);
assert.equal(!!game.nexusCards, !!ecaz); assert.equal(game.ecazTreachery === true, !!ecaz);
assert.ok(game.players.every(seat => !seat.hand.length));
const originalInventory = revivalTerrorInventory(game);
const selectedOriginalCards = [...treacheryDeck(game.expansions), ...(ecaz ? ecazTreacheryCards() : [])]
.map(card => card.id).sort();
assert.deepEqual(originalInventory, selectedOriginalCards); assert.equal(originalInventory.length, ecaz ? 50 : 47);
const karama = game.deck.find(card => card.effect === 'karama');
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind))
.slice(0, game.players.length - 1);
const gift = ordinary[0]; assert.ok(karama && gift); assert.equal(ordinary.length, game.players.length - 1);
let nextOrdinary = 1;
const selected = game.players.map(seat => seat.id === entrant ? karama :
seat.id === owner ? gift : ordinary[nextOrdinary++]);
const dealOrder = selected.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game);
assert.deepEqual(game.players.flatMap(seat => seat.hand.map(card => card.id)), selected.map(card => card.id));
assert.equal(revivalTerrorPlayer(game, entrant).reserves, 20);
assert.equal(revivalTerrorPlayer(game, entrant).faceDancers?.length, 3);
assert.deepEqual(revivalTerrorPlayer(game, owner).forces, { 'tueks_sietch:5': 6 });
assert.equal(revivalTerrorPlayer(game, owner).reserves, 14);
const originalTerror = structuredClone(game.moritaniTerror);
assert.equal(originalTerror?.tokens.length, 6);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = Array.from({ length: advanced ? 4 : 2 }, (_, position) => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card &&
!card.discovery && !['hagga_basin', 'tueks_sietch', territory].includes(card.territory));
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
const firstStorm = transition(() => { reach(state => state.turn === 1 && state.phase === 1); });
assert.equal(game.storm, advanced ? 12 : 11);
reach(state => state.turn === 1 && state.phase === 5 && state.active === entrant && revivalTerrorClean(state));
const beforeShipment = structuredClone(game);
const homeworldSources = nativeShipmentSources(viewGame(game, entrant), 1, 0); assert.ok(homeworldSources);
const shipmentAction: Action = { type: 'ship', territory: 'hagga_basin', sector: 13,
amount: 1, elite: 0, allyPayment: 0, homeworldSources };
const shipment = transition(() => { act(entrant, shipmentAction); reach(revivalTerrorClean); });
assert.equal(revivalTerrorPlayer(beforeShipment, entrant).spice - revivalTerrorPlayer(game, entrant).spice, 2);
assert.equal(revivalTerrorPlayer(game, entrant).forces[casualtyLocation], 1);
reach(state => state.turn === 1 && !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement');
const token = game.moritaniTerror!.tokens.find(token => token.kind === kind && token.status === 'available'); assert.ok(token);
const beforePlacement = structuredClone(game);
const placementAction: Action = { type: 'decision', token: token.id, territory };
const placement = transition(() => {
act(owner, placementAction);
for (let count = 0; game.response && count < 80; count++) {
const actor = game.players.find(seat => !game.response!.passed.includes(seat.id)); assert.ok(actor);
act(actor.id, { type: 'passResponse' });
}
assert.ok(!game.response);
});
assert.equal(game.moritaniTerror!.tokens.find(candidate => candidate.id === token.id)!.status, 'placed');
assert.equal(game.moritaniTerror!.tokens.find(candidate => candidate.id === token.id)!.location, territory);
reach(state => state.turn === 2 && state.phase === 0 && !state.phaseOpening && !state.response &&
state.stormPending === (advanced ? 1 : 2));
const beforeStormMovement = structuredClone(game), stormSource = structuredClone(game.stormMovementSource);
const storm = transition(() => { reach(state => state.turn === 2 && state.phase === 1); });
assert.equal(game.storm, 13);
assert.equal(revivalTerrorPlayer(game, entrant).forces[casualtyLocation], undefined);
assert.equal(revivalTerrorPlayer(game, entrant).tanks, 1);
reach(state => state.turn === 2 && state.phase === 4 && revivalTerrorClean(state));
const beforeRequest = structuredClone(game), revivalAction: Action = { type: 'revive', amount: 1, elite: 0, freeElite: 0 };
const revival = transition(() => {
act(entrant, revivalAction);
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'homeworldRevivalDeployment');
});
assert.equal(revivalTerrorPlayer(game, entrant).reserves, 20);
assert.equal(revivalTerrorPlayer(game, entrant).tanks, 0);
assert.equal(revivalTerrorPlayer(game, entrant).revived, 1);
assert.equal(revivalTerrorPlayer(game, entrant).freeForcesRevived, 1);
assert.equal(homeworldTable(game)!.find(home => home.native === entrant)!.side, 'high');
assert.equal(homeworldTable(game)!.find(home => home.native === owner)!.side, 'high');
assert.ok(revivalTerrorPlayer(game, holder).hand.some(card => card.id === karama.id));
assert.deepEqual(revivalTerrorInventory(game), originalInventory); homeworldGameIntegrity(game);
const event = game.homeworldRevivalReturn!.event, source = structuredClone(game);
const action: Action = { type: 'decision', event, destination: location, amount: 1 };
return {
game, source, beforeDeployment: source, beforeRequest, initial, lobby, setup, beforeStartingDeal,
afterSetup, homeworlds, optionalEcazModules, firstStorm, beforeShipment, shipmentAction, shipment,
beforePlacement, placementAction, placement, beforeStormMovement, stormSource, storm, revival, revivalAction,
entrant, tleilaxu: entrant, holder, owner, moritani: owner, bg, ecaz,
ambassadorToken, ambassadorPlacement, ambassadorFirst: options.ambassadorFirst ?? false, kind, token: token.id,
karama: karama.id, gift: gift.id, territory, sector, location, destination: location, casualtyLocation, event, action,
revealAction: { type: 'decision', reveal: true } as Action,
declineAction: { type: 'decision', decline: true } as Action,
cancelAction: { type: 'card', card: karama.id, mode: 'cancel' } as Action,
originalInventory, originalCards: originalInventory, originalTerror, originalSpice, spiceOrder, dealOrder,
actions, steps: actions, sourceActionCount: actions.length, advanced,
staging: [
`Public Basic/Advanced Tleilaxu/Moritani with actual Homeworlds setting and original E1/E3 deck. Default and BG-only sources retain main47 and no other optional module. Optional Ecaz uses only the already-admitted actual public independent three-card Ecaz variant plus Nexus Cards, retaining original main50; ordinary original Spice draws produce no Worm or Nexus acquisition in this prefix. Only original unopened starting-deal positions (Tleilaxu Karama and one ordinary face per other original seat), unopened ordinary land Spice positions and native scalar lotteries are selected. Tleilaxu gets original Karama/three Face Dancers; Moritani really places its original six at Tueks5 and retains fourteen native reserves. Actual first Storm ends11 (Basic10+0) or12 (Advanced11+0); Tleilaxu pays two spice for one Hagga13 reserve shipment. Moritani really selects the requested original Terror face for ${location} during its first Mentat placement and every original placement response passes. Next native Storm ends13 (Basic1+1, Advanced original Storm Card1), killing that Tleilaxu counter. Actual phase controls reach turn2 Revival; real ordinary free revive1 opens the original returned-group choice. Source stops before ${location} destination selection. Optional original BG seat really starts at Polar (Advanced uses original advisorSetup) and declines the original Hagga Spiritual offer. Optional original Ecaz seat really sets up six in printed Imperial Basin9; the first native Storm crosses that sector and destroys those six counters, which remain in its actual Tanks when ordinary Revival is skipped through readiness. Ecaz pays one for its original cohort Emperor Ambassador at ${location} during first Revival; Basic uses Arrakeen10 because the original Storm11 prevents Carthag placement, while Advanced retains Carthag11. No played force, wallet, phase, Tank, token, hand, alliance, response or revival receipt is installed. Later Terror victim lotteries use native entropy; no controlled supplemental effect source is claimed.`,
],
};
}
export type RevivalTerrorFixture = ReturnType<typeof createRevivalTerrorFixture>;
+3 -3
View File
@@ -105,18 +105,18 @@ void test('BG fighters do not refuse SEND destinations before their original Int
assert.equal(target().blocked, null);
});
void test('the unfinished Revival Terror continuation stays fenced without reading a token face', () => {
void test('revival Terror destinations remain available without reading private token faces', () => {
const { g, grant } = fixture('tleilaxu');
g.moritaniTerror = createTerrorState(() => 0);
const token = g.moritaniTerror.tokens[0];
g.moritaniTerror = placeTerror(g.moritaniTerror, token.id, 'arrakeen', 1);
for (const t of g.moritaniTerror.tokens) Object.defineProperty(t, 'kind', { get() { throw new Error('private Terror face read'); } });
const target = () => homeworldRevivalDestinations(g, 'p', grant).find((c) => c.id === 'arrakeen:10')!;
assert.match(target().blocked!, /Terror.*continuation/);
assert.equal(target().blocked, null);
g.players[2].reserves = 7;
assert.equal(target().blocked, null);
grant.normal = 3;
assert.match(target().blocked!, /Terror.*continuation/);
assert.equal(target().blocked, null);
g.players[2].ally = 'p'; g.players[0].ally = 'm';
assert.equal(target().blocked, null);
});
@@ -0,0 +1,275 @@
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);
});
+121
View File
@@ -0,0 +1,121 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { viewGame, type Game } from '../game/engine';
import { isAdvisor } from '../game/advisors';
import {
createRevivalTerrorFixture as fixture, commitRevivalTerror as commit,
actRevivalTerror as act, passRevivalTerror as pass, finishRevivalTerror as finish,
revivalTerrorPlayer as player, revivalTerrorInventory as inventory,
type RevivalTerrorFixture,
} from './fixture-revival-terror';
function enter(f: RevivalTerrorFixture) {
let g = commit(f.game, f).game;
if (g.decision?.kind === 'advisor') {
assert.equal(g.decision.shipment, f.entrant);
g = act(g, f.bg!, {type:'decision',accept:false});
}
assert.equal(player(g,f.entrant).forces[f.location],1);
assert.equal(g.homeworldRevivalReturn?.stage,'arrival');
return g;
}
function declineAmbassador(g: Game, f: RevivalTerrorFixture) {
assert.equal(g.decision?.kind,'ecazAmbassador');
assert.equal(g.decision?.player,f.ecaz);
return act(g,f.ecaz!,{type:'decision',event:g.pendingAmbassador!.event,decline:true});
}
function complete(g: Game, f: RevivalTerrorFixture) {
g = finish(g);
assert.equal(g.homeworldRevivalReturn?.stage,'complete');
assert.equal(g.pendingArrivalOverlap ?? null,null);
assert.equal(g.pendingArrivalReaction ?? null,null);
assert.equal(g.pendingTerrorEntry ?? null,null);
assert.equal(g.pendingAmbassador ?? null,null);
assert.equal(player(g,f.entrant).forces[f.location],1);
assert.equal(player(g,f.entrant).spice,player(f.beforeRequest,f.entrant).spice+1);
assert.equal(player(g,f.entrant).shipped,player(f.game,f.entrant).shipped);
assert.deepEqual(inventory(g),f.originalInventory);
return g;
}
for (const advanced of [false,true]) for (const ambassadorFirst of [false,true]) {
void test(`${advanced?'Advanced':'Basic'} native revived overlap: ${ambassadorFirst?'Ambassador':'Terror'} acts first and both declines finish original income`,()=>{
const f=fixture({advanced,ecaz:true,ambassadorFirst}), g=enter(f);
assert.equal(g.pendingArrivalOverlap?.first,ambassadorFirst?'ambassador':'terror');
assert.equal(g.pendingArrivalOverlap?.cause,'homeworldRevival');
assert.equal(g.pendingArrivalOverlap?.revivalArrival?.event,f.event);
const second=ambassadorFirst?declineAmbassador(g,f):act(g,f.owner,f.declineAction);
assert.equal(second.homeworldRevivalReturn?.stage,'arrival');
const result=complete(ambassadorFirst?act(second,f.owner,f.declineAction):declineAmbassador(second,f),f);
assert.ok(result.homeworldRevivalReturn!.ambassadors!.every(entry=>entry.completed));
assert.equal(result.moritaniTerror!.tokens.find(token=>token.id===f.token)!.status,'placed');
assert.equal(result.ecazAmbassadors!.tokens.find(token=>token.id===f.ambassadorToken)!.zone,'placed');
});
}
for (const ambassadorFirst of [false,true]) {
void test(`native revival Sabotage discard retains ${ambassadorFirst?'completed':'pending'} original Ambassador opportunity`,()=>{
const f=fixture({kind:'sabotage',ecaz:true,ambassadorFirst});
let g=enter(f);
if (ambassadorFirst) g=declineAmbassador(g,f);
g=act(g,f.owner,f.revealAction);
assert.equal(g.pendingTreacheryDiscard ?? null,null);
assert.equal(g.pendingTerrorEntry?.stage,'gift');
assert.equal(g.pendingTerrorEntry?.revivalArrival?.event,f.event);
assert.equal(g.discard.filter(card=>card.id===f.karama).length,1);
assert.equal(player(g,f.entrant).hand.length,0);
g=act(g,f.owner,{type:'decision',decline:true});
if (!ambassadorFirst) g=declineAmbassador(g,f);
complete(g,f);
});
}
for (const advanced of [false,true]) {
void test(`${advanced?'Advanced':'Basic'} native revived Sneak preserves original Ambassador then opens its own BG and Ambassador arrivals`,()=>{
const f=fixture({advanced,kind:'sneakAttack',bg:true,ecaz:true}), source=enter(f);
assert.equal(source.pendingArrivalOverlap?.first,'terror');
let g=act(source,f.owner,f.revealAction);
g=act(g,f.owner,{type:'decision',amount:1});
assert.equal(g.decision?.kind,'ecazAmbassador');
assert.equal(g.pendingAmbassador?.entrant,f.entrant);
assert.equal(g.pendingArrivalReaction?.[0].revivalSneakAdvisor,true);
assert.equal(player(g,f.owner).forces[f.location],1);
g=declineAmbassador(g,f);
assert.equal(g.decision?.kind,'advisor');
if(g.decision?.kind!=='advisor')throw Error('Expected native Sneak Spiritual control');
assert.equal(g.decision.shipment,f.owner);
assert.equal(g.decision.revivalEvent,f.event);
assert.equal(g.decision.revivalTerror?.owner,f.owner);
g=act(g,f.bg!,{type:'decision',accept:true,accompany:advanced,amount:1});
assert.equal(g.response?.kind,'advisor');
assert.equal(g.response?.spiritualAdvisorSource?.revivalEvent,f.event);
g=pass(g);
assert.equal(g.decision?.kind,'ecazAmbassador');
assert.equal(g.pendingAmbassador?.entrant,f.owner);
const child=g.homeworldRevivalReturn!.ambassadors!.at(-1)!;
assert.equal(child.terror?.owner,f.owner);
assert.equal(child.terror?.amount,1);
g=declineAmbassador(g,f);
g=complete(g,f);
assert.equal(player(g,f.owner).reserves,13);
assert.equal(player(g,f.bg!).forces[advanced?f.location:'polar_sink:0'],advanced?1:2);
if(advanced)assert.equal(isAdvisor(player(g,f.bg!),f.territory),true);
assert.deepEqual(g.homeworldRevivalReturn!.ambassadors!.map(entry=>entry.entrant),[f.entrant,f.owner]);
});
}
void test('native original Karama stops only the Sneak-derived free BG packet, retaining both committed groups',()=>{
const f=fixture({kind:'sneakAttack',bg:true});
let g=enter(f);
g=act(g,f.owner,f.revealAction);
g=act(g,f.owner,{type:'decision',amount:1});
assert.equal(g.decision?.kind,'advisor');
g=act(g,f.bg!,{type:'decision',accept:true,accompany:true,amount:1});
assert.equal(g.response?.spiritualAdvisorSource?.revivalEvent,f.event);
g=act(g,f.holder,f.cancelAction);
g=complete(g,f);
assert.equal(player(g,f.owner).forces[f.location],1);
assert.equal(player(g,f.bg!).forces[f.location],undefined);
assert.equal(g.discard.filter(card=>card.id===f.karama).length,1);
assert.equal(viewGame(g,f.entrant).terrorEntry,null);
});
+160
View File
@@ -0,0 +1,160 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createGame, newPlayer, type Game } from '../game/engine';
import { createHomeworldCustody, HomeworldCustodyError } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import { appendHomeworldArrivalAmbassador, homeworldArrivalSignature } from '../game/homeworld-arrival';
import { makeHomeworldRevivalReturn, makeHomeworldRevivalProgress } from '../game/homeworld-revival-return';
import { terrorEntrySignature, validateTerrorEntrySignature } from '../game/terror-entry-receipt';
import {
revivalOwnsTerrorEntry, validateRevivalTerrorEntry, validateRevivalArrivalSource,
validateRevivalTerrorPacket, validRevivalEntryParent, validRevivalTerrorPacket,
type RevivalEntryParent, type RevivalTerrorPacket, type RevivalArrivalDetails,
} from '../game/revival-terror-entry';
type Entry = NonNullable<Game['pendingTerrorEntry']>;
/** Controlled conserved arrival context with a real constructed original-return
* receipt. No native play or card-disposal chronology is claimed here. */
function fixture(fremen = false) {
const g = createGame('REVIVALTERRORSOURCE', newPlayer('native', 'Native', fremen ? 'fremen' : 'tleilaxu'), false, ['ix', 'ecaz']);
g.players.push(newPlayer('bg', 'BG', 'beneGesserit'), newPlayer('m', 'Moritani', 'moritani'), newPlayer('ec', 'Ecaz', 'ecaz'));
g.status = 'playing'; g.turn = 2; g.phase = 4;
const native = g.players[0];
native.reserves = fremen ? 2 : 10; native.tanks = fremen ? 17 : 8;
native.forces = { 'arrakeen:10': fremen ? 1 : 2 };
if (fremen) native.elites = { reserves: 1, tanks: 1, forces: { 'arrakeen:10': 1 }, revived: 1 };
g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
const frame = makeHomeworldRevivalReturn({ event: 'original-return', turn: 2, phase: 4, player: 'native', source: 'normal',
group: fremen ? { amount: 1, elite: 1, free: 1 } : { amount: 3, elite: 0, free: 2 },
quote: fremen ? { kind: 'fedaykin', normal: 0, elite: 1, beforePopulation: 2, afterPopulation: 3, blocked: null }
: { kind: 'tleilax', normal: 2, elite: 0, beforePopulation: 9, afterPopulation: 12, blocked: null } });
frame.stage = 'arrival'; frame.destination = 'arrakeen:10'; frame.resumeResponse = null; frame.resumeSignature = 'null';
frame.ambassadors = []; frame.arrivalSignature = homeworldArrivalSignature(frame);
g.homeworldRevivalReturn = frame; g.homeworldRevivalProgress = makeHomeworldRevivalProgress(frame);
const entry: Entry = { token: 'original-opaque-token', entrant: 'native', territory: 'arrakeen', sector: 10,
amount: frame.quote.normal + frame.quote.elite, elite: frame.quote.elite,
cause: 'homeworldRevival', turn: 2, phase: 4, stage: 'offer', resume: 'none', revivalArrival: { event: frame.event } };
entry.entrySignature = terrorEntrySignature(entry);
return { g, frame, entry };
}
function spiritual(parent: RevivalEntryParent, territory = 'arrakeen', sector = 10): RevivalEntryParent {
return { ...structuredClone(parent), spiritual: { event: 'original-spiritual-packet', owner: 'bg', turn: 2, phase: 4,
territory, sector, amount: 1, revivalEvent: parent.event } };
}
function packet(entry: Entry): RevivalTerrorPacket {
return { event: entry.entrySignature!, owner: 'm', territory: entry.territory, sector: entry.sector, amount: 2, elite: 0 };
}
const sneakDetails = (source: RevivalTerrorPacket): RevivalArrivalDetails => ({ entrant: source.owner,
territory: source.territory, sector: source.sector, amount: source.amount, elite: source.elite, cause: 'shipment' });
void test('original native return binds exact force types, clock and board destination without mutation', () => {
for (const fremen of [false, true]) {
const { g, entry } = fixture(fremen), before = structuredClone({ g, entry });
assert.equal(revivalOwnsTerrorEntry(g, entry), true);
validateRevivalTerrorEntry(g, entry); validateTerrorEntrySignature(entry);
assert.deepEqual({ g, entry }, before);
}
});
void test('present metadata rejects stale, foreign, wrong-actor and changed original groups', () => {
const changes: ((g: Game, entry: Entry) => void)[] = [
(_g, entry) => { entry.revivalArrival!.event = 'foreign-return'; },
(_g, entry) => { entry.entrant = 'bg'; }, (_g, entry) => { entry.cause = 'movement'; },
(_g, entry) => { entry.amount++; }, (_g, entry) => { entry.elite = 1; },
(_g, entry) => { entry.territory = 'carthag'; entry.sector = 11; },
(_g, entry) => { entry.turn = 3; }, (_g, entry) => { entry.phase = 5; },
g => { delete g.homeworldRevivalReturn; },
g => { g.homeworldRevivalReturn!.stage = 'complete'; },
g => { g.homeworldRevivalReturn!.group.amount++; },
];
for (const change of changes) {
const { g, entry } = fixture(); change(g, entry);
const before = structuredClone({ g, entry });
assert.throws(() => revivalOwnsTerrorEntry(g, entry), HomeworldCustodyError);
assert.deepEqual({ g, entry }, before);
}
});
void test('Spiritual entry uses the exact committed BG packet rather than original native counts or current survivors', () => {
const { g, entry } = fixture();
entry.revivalArrival = spiritual(entry.revivalArrival!); entry.entrant = 'bg'; entry.cause = 'advisor'; entry.amount = 1;
entry.entrySignature = terrorEntrySignature(entry);
validateRevivalTerrorEntry(g, entry);
assert.deepEqual(g.players.find(player => player.id === 'bg')!.forces, {});
for (const patch of [{ entrant: 'native' }, { cause: 'shipment' }, { amount: 2 }, { elite: 1 }])
assert.throws(() => validateRevivalTerrorEntry(g, { ...entry, ...patch } as Entry), HomeworldCustodyError);
});
void test('Sneak source retains its captured exact revival identity after the original Terror action is gone', () => {
const { g, entry, frame } = fixture(), terror = packet(entry);
const parent = { event: frame.event, terror };
assert.ok(validRevivalTerrorPacket(terror));
validateRevivalTerrorPacket({ ...g, revivalPlayer: frame.player }, terror, frame.destination!, frame.event);
validateRevivalArrivalSource(g, parent, sneakDetails(terror));
const changed = structuredClone(terror); changed.elite = 1;
assert.equal(validRevivalTerrorPacket(changed), false);
assert.throws(() => validateRevivalArrivalSource(g, { event: frame.event, terror: changed }, sneakDetails(changed)), HomeworldCustodyError);
assert.throws(() => validateRevivalTerrorPacket({ ...g, revivalPlayer: frame.player }, terror, frame.destination!, 'foreign-return'), HomeworldCustodyError);
assert.throws(() => validateRevivalArrivalSource(g, parent, { ...sneakDetails(terror), amount: 1 }), HomeworldCustodyError);
assert.throws(() => validateRevivalArrivalSource(g, { event: frame.event, terror: { ...terror, owner: 'bg' } }, sneakDetails(terror)), HomeworldCustodyError);
});
void test('captured BG Polar Terror source and subsequent Southern-derived Spiritual packet retain both actual packets', () => {
const { g, entry, frame } = fixture(true);
assert.throws(() => validateRevivalArrivalSource(g, spiritual({ event: frame.event }),
{ entrant: 'bg', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, cause: 'advisor' }), HomeworldCustodyError);
// A committed BG packet from a native T return can reach Polar Nexus Terror;
// the resulting Sneak record binds that source point, not the revival sector.
const t = fixture();
t.entry.revivalArrival = spiritual(t.entry.revivalArrival!, 'polar_sink', 0);
Object.assign(t.entry, { entrant: 'bg', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, cause: 'advisor' });
t.entry.nexusPlacements = { [t.entry.token]: 'original-nexus-placement' };
t.entry.entrySignature = terrorEntrySignature(t.entry);
validateTerrorEntrySignature(t.entry);
const terror = packet(t.entry);
validateRevivalTerrorPacket({ ...t.g, revivalPlayer: t.frame.player }, terror, t.frame.destination!, t.frame.event);
const southernTerror = packet(entry), combined = spiritual({ event: frame.event, terror: southernTerror }, 'polar_sink', 0);
const details: RevivalArrivalDetails = { entrant: 'bg', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, cause: 'advisor' };
const before = structuredClone({ g, combined, details });
validateRevivalArrivalSource(g, combined, details);
assert.deepEqual({ g, combined, details }, before);
assert.throws(() => validateRevivalArrivalSource(g, combined, { ...details, amount: 2 }), HomeworldCustodyError);
});
void test('an owned Ambassador suspended for its physical discard still validates the original return', () => {
const { g, entry, frame } = fixture();
const ambassador: NonNullable<Game['pendingAmbassador']> = { event: 'original-ambassador', owner: 'ec', entrant: 'native',
territory: 'arrakeen', sector: 10, turn: 2, phase: 4, revivalEvent: frame.event,
token: 'original-ambassador-token', stage: 'offer', copyChoices: [], resume: 'none' };
appendHomeworldArrivalAmbassador(frame, ambassador);
// Only the wrapper is controlled: this unit does not claim a real discard.
g.pendingTreacheryDiscard = { continuation: { kind: 'ambassador', entry: ambassador } } as Game['pendingTreacheryDiscard'];
g.pendingAmbassador = null;
const before = structuredClone(g);
validateRevivalTerrorEntry(g, entry);
assert.deepEqual(g, before);
});
void test('source-absent legacy entries retain their exact old bytes and never gain revival ownership', () => {
const { g, entry } = fixture(); delete entry.revivalArrival; entry.cause = 'shipment';
entry.entrySignature = terrorEntrySignature(entry);
assert.equal(entry.entrySignature, JSON.stringify([entry.token, entry.entrant, entry.territory, entry.sector,
entry.amount, entry.elite, entry.cause, entry.turn, entry.phase, entry.resume, null]));
validateTerrorEntrySignature(entry);
assert.equal(revivalOwnsTerrorEntry(g, entry), false);
const noReceipt = { ...entry }; delete noReceipt.entrySignature;
validateRevivalTerrorEntry(g, noReceipt); validateTerrorEntrySignature(noReceipt);
assert.equal(revivalOwnsTerrorEntry(g, noReceipt), false);
});
void test('new metadata is signature-bound and malformed optional packets cannot bypass receipt checks', () => {
const { entry } = fixture(), original = entry.entrySignature;
entry.revivalArrival = spiritual(entry.revivalArrival!);
assert.notEqual(terrorEntrySignature(entry), original);
assert.throws(() => validateTerrorEntrySignature(entry), HomeworldCustodyError);
assert.equal(validRevivalEntryParent({ event: 'original-return', extra: true }), false);
assert.equal(validRevivalEntryParent({ event: 'original-return', terror: { event: 'not-json' } }), false);
assert.equal(validRevivalEntryParent({ event: 'original-return', spiritual: undefined }), false);
delete entry.entrySignature;
assert.throws(() => validateTerrorEntrySignature(entry), HomeworldCustodyError);
});
+182
View File
@@ -0,0 +1,182 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { homeworldGameIntegrity, homeworldTable } from '../game/homeworld-game';
import {
createRevivalTerrorFixture as fixture,
actRevivalTerror as act,
commitRevivalTerror as commit,
finishRevivalTerror as finish,
settleRevivalTerror as settle,
revivalTerrorPlayer as player,
revivalTerrorInventory as inventory,
type RevivalTerrorFixture,
} from './fixture-revival-terror';
function entered(f: RevivalTerrorFixture) {
const game = commit(f.game, f).game;
assert.equal(game.decision?.kind, 'moritaniTerror'); assert.equal(game.decision?.player, f.owner);
assert.equal(game.pendingTerrorEntry?.stage, 'offer');
assert.equal(game.pendingTerrorEntry.entrant, f.entrant); assert.equal(game.pendingTerrorEntry.token, f.token);
assert.equal(game.pendingTerrorEntry.territory, f.territory); assert.equal(game.pendingTerrorEntry.sector, f.sector);
assert.equal(game.pendingTerrorEntry.amount, 1); assert.equal(game.pendingTerrorEntry.cause, 'homeworldRevival');
assert.equal(game.homeworldRevivalReturn?.stage, 'arrival');
assert.equal(game.homeworldRevivalReturn.event, f.event);
assert.equal(player(game, f.entrant).forces[f.location], 1); assert.equal(player(game, f.entrant).reserves, 19);
assert.equal(player(game, f.entrant).spice, player(f.game, f.entrant).spice,
'Original revival income waits until the entry effects are complete');
assert.equal(viewGame(game, f.owner).terrorEntry?.canReveal, true);
return game;
}
function completed(game: Game, f: RevivalTerrorFixture, surviving = 1) {
assert.equal(game.turn, 2); assert.equal(game.phase, 4);
assert.ok(!game.pendingTerrorEntry); assert.ok(!game.pendingTreacheryDiscard);
assert.equal(game.homeworldRevivalReturn?.stage, 'complete');
assert.equal(game.decision, null); assert.equal(game.response, null);
const returned = player(game, f.entrant), source = player(f.game, f.entrant);
assert.equal(returned.reserves, 19); assert.equal(returned.forces[f.location] ?? 0, surviving);
assert.equal(returned.tanks, 1 - surviving); assert.equal(returned.revived, 1); assert.equal(returned.freeForcesRevived, 1);
assert.equal(returned.shipped, source.shipped); assert.equal(returned.moved, source.moved);
for (const p of game.players)
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, amount) => sum + amount, 0), 20);
assert.deepEqual(inventory(game), f.originalInventory); homeworldGameIntegrity(game);
}
for (const advanced of [false, true]) {
const mode = advanced ? 'Advanced' : 'Basic';
void test(`${mode} native revived entry permits Moritani to decline its genuinely Mentat-placed Terror`, () => {
const f = fixture({ advanced });
assert.deepEqual(f.game.expansions, ['ix', 'ecaz']); assert.ok(f.game.homeworlds);
assert.equal(f.originalInventory.length, 47); assert.equal(f.sourceActionCount, f.actions.length);
assert.equal(player(f.afterSetup, f.entrant).reserves, 20);
assert.equal(player(f.beforeRequest, f.entrant).tanks, 1);
assert.equal(player(f.beforeRequest, f.entrant).reserves, 19);
assert.equal(f.beforePlacement.turn, 1); assert.equal(f.beforePlacement.phase, 8);
assert.equal(f.beforePlacement.decision?.kind, 'moritaniPlacement');
assert.deepEqual(f.placement.steps[0], { actor: f.owner, action: f.placementAction });
assert.equal(player(f.shipment.before, f.entrant).spice - player(f.shipment.after, f.entrant).spice, 2);
assert.equal(player(f.storm.after, f.entrant).tanks, 1); assert.equal(f.storm.after.storm, 13);
assert.equal(player(f.game, f.entrant).tanks, 0); assert.equal(player(f.game, f.entrant).reserves, 20);
assert.equal(homeworldTable(f.game)!.find(home => home.native === f.entrant)!.side, 'high');
assert.equal(homeworldTable(f.game)!.find(home => home.native === f.owner)!.side, 'high');
const done = finish(act(entered(f), f.owner, f.declineAction));
assert.equal(done.moritaniTerror!.tokens.find(token => token.id === f.token)!.status, 'placed');
assert.equal(player(done, f.entrant).spice, player(f.game, f.entrant).spice + 1);
assert.ok(player(done, f.holder).hand.some(card => card.id === f.karama)); completed(done, f);
});
void test(`${mode} native revival Robbery resolves either original spice theft or top-card draw before free-revival income`, () => {
const f = fixture({ advanced, kind: 'robbery' });
for (const choice of ['spice', 'card']) {
const offered = entered(f), cash = player(offered, f.entrant).spice;
const moritaniCash = player(offered, f.owner).spice, deckTop = offered.deck[0].id;
const revealed = act(offered, f.owner, f.revealAction);
assert.equal(revealed.pendingTerrorEntry?.stage, 'robbery');
assert.equal(revealed.homeworldRevivalReturn?.stage, 'arrival');
const done = finish(act(revealed, f.owner, { type: 'decision', choice }));
assert.equal(player(done, f.entrant).spice, cash - (choice === 'spice' ? Math.ceil(cash / 2) : 0) + 1);
assert.equal(player(done, f.owner).spice, moritaniCash + (choice === 'spice' ? Math.ceil(cash / 2) : 0));
assert.equal(player(done, f.owner).hand.some(card => card.id === deckTop), choice === 'card');
assert.equal(done.deck.length, offered.deck.length - Number(choice === 'card'));
assert.equal(done.moritaniTerror!.tokens.find(token => token.id === f.token)!.status, 'removed');
completed(done, f);
}
});
void test(`${mode} native revival Sabotage completes its actual discard and optional original-card gift`, () => {
const f = fixture({ advanced, kind: 'sabotage' });
for (const give of [false, true]) {
const offered = entered(f), cash = player(offered, f.entrant).spice;
const pending = settle(act(offered, f.owner, f.revealAction));
assert.equal(pending.pendingTerrorEntry?.stage, 'gift');
assert.equal(pending.homeworldRevivalReturn?.stage, 'arrival');
assert.equal(player(pending, f.entrant).spice, cash);
assert.equal(pending.discard.filter(card => card.id === f.karama).length, 1);
const done = finish(act(pending, f.owner, give
? { type: 'decision', card: f.gift } : f.declineAction));
assert.equal(player(done, f.entrant).hand.some(card => card.id === f.gift), give);
assert.equal(player(done, f.owner).hand.some(card => card.id === f.gift), !give);
assert.equal(player(done, f.entrant).spice, cash + 1);
assert.equal(done.moritaniTerror!.tokens.find(token => token.id === f.token)!.status, 'removed');
completed(done, f);
}
});
void test(`${mode} native revival Assassination uses its real living-disc lottery and original bounty`, () => {
const f = fixture({ advanced, kind: 'assassination' }), offered = entered(f);
const before = structuredClone(player(offered, f.entrant).leaders), ownerCash = player(offered, f.owner).spice;
const done = finish(act(offered, f.owner, f.revealAction));
const killed = player(done, f.entrant).leaders.filter(leader => leader.dead && !before.find(old => old.id === leader.id)!.dead);
assert.equal(killed.length, 1);
assert.equal(killed[0].deaths, before.find(leader => leader.id === killed[0].id)!.deaths + 1);
assert.equal(player(done, f.owner).spice, ownerCash + (killed[0].id === 'tleilaxu-0' ? 3 : killed[0].strength));
assert.equal(player(done, f.entrant).spice, player(offered, f.entrant).spice + 1);
assert.equal(done.moritaniTerror!.tokens.find(token => token.id === f.token)!.status, 'removed'); completed(done, f);
});
void test(`${mode} native revival Extortion reserves its original bank five without prematurely paying it`, () => {
const f = fixture({ advanced, kind: 'extortion' }), offered = entered(f), before = player(offered, f.owner).spice;
const done = finish(act(offered, f.owner, f.revealAction));
assert.equal(done.moritaniExtortion?.stage, 'reserved');
assert.deepEqual(done.moritaniExtortion?.bank, { reserved: 5, collected: 0 });
assert.equal(done.moritaniExtortion?.owner, f.owner); assert.equal(done.moritaniExtortion?.token, f.token);
assert.equal(player(done, f.owner).spice, before);
assert.equal(player(done, f.entrant).spice, player(offered, f.entrant).spice + 1);
assert.equal(done.moritaniTerror!.tokens.find(token => token.id === f.token)!.status, 'extortion'); completed(done, f);
});
void test(`${mode} native revival Sneak Attack moves an actual Moritani reserve counter without another shipment fee`, () => {
const f = fixture({ advanced, kind: 'sneakAttack' }), offered = entered(f), before = structuredClone(player(offered, f.owner));
const pending = act(offered, f.owner, f.revealAction);
assert.equal(pending.pendingTerrorEntry?.stage, 'sneakAttack');
const done = finish(act(pending, f.owner, { type: 'decision', amount: 1, sector: f.sector }));
assert.equal(player(done, f.owner).reserves, before.reserves - 1);
assert.equal(player(done, f.owner).forces[f.location], 1); assert.equal(player(done, f.owner).spice, before.spice);
assert.equal(player(done, f.owner).shipped, before.shipped); assert.equal(player(done, f.owner).moved, before.moved);
assert.equal(done.moritaniTerror!.tokens.find(token => token.id === f.token)!.status, 'removed'); completed(done, f);
});
void test(`${mode} native revival Atomics destroys the already revived entrant and finishes the original return`, () => {
const f = fixture({ advanced, kind: 'atomics' }), offered = entered(f);
const done = finish(act(offered, f.owner, f.revealAction));
assert.equal(done.moritaniAtomics?.territory, f.territory);
assert.equal(done.moritaniAtomics?.turn, 2); assert.equal(done.moritaniAtomics?.moritaniId, f.owner);
assert.equal(player(done, f.owner).atomicsHandLimitPenalty, true);
assert.equal(player(done, f.entrant).spice, player(offered, f.entrant).spice + 1);
assert.equal(done.moritaniTerror!.tokens.find(token => token.id === f.token)!.status, 'removed'); completed(done, f, 0);
});
void test(`${mode} all four minimal policies complete legal native revival Terror choices`, () => {
const f = fixture({ advanced });
for (const difficulty of DIFFICULTIES) {
let game = entered(f);
for (let count = 0; count < 100 && (game.homeworldRevivalReturn?.stage !== 'complete' || game.response || game.decision); count++) {
let next;
for (const seat of game.players) {
const own = viewGame(game, seat.id); own.players.find(player => player.id === seat.id)!.bot = difficulty;
const action = botActions(own)[0];
if (action) { next = { actor: seat.id, action }; break; }
}
assert.ok(next, `${difficulty} must retain a legal current Terror/response action`);
game = act(game, next.actor, next.action);
}
completed(game, f);
}
});
}
void test('native original Tleilaxu Karama can cancel Enemy of My Enemy while keeping the revived entry committed', () => {
const f = fixture(), offered = entered(f), pending = act(offered, f.owner, { type: 'decision', alliance: true });
assert.equal(pending.response?.kind, 'moritaniAlliance');
const prevented = act(pending, f.holder, f.cancelAction);
assert.equal(prevented.pendingTerrorEntry?.stage, 'offer'); assert.equal(prevented.decision?.player, f.owner);
assert.equal(player(prevented, f.entrant).ally, null); assert.equal(player(prevented, f.owner).ally, null);
assert.equal(player(prevented, f.entrant).forces[f.location], 1);
assert.equal(prevented.homeworldRevivalReturn?.stage, 'arrival');
const done = finish(act(prevented, f.owner, f.declineAction));
assert.equal(done.discard.filter(card => card.id === f.karama).length, 1);
assert.equal(player(done, f.entrant).spice, player(offered, f.entrant).spice + 1); completed(done, f);
});