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

@@ -61,6 +61,11 @@ function DeploymentChoice({ game, act, busy }: Props) {
{' '}The revived group is already placed before those responses. Preventing
a Bene Gesserit power does not undo your revival or use your ordinary shipment.
</p>}
{game.players.some(player => player.faction === 'moritani') && <p className="fine">
Entry into an eligible Terror territory gives Moritani its normal choice
after the group is placed. Your original revival waits for that effect
and any Ambassador reaction; no ordinary shipment is used.
</p>}
<label className="block space-y-1">
<span>Revival destination</span>
<select
+5
View File
@@ -572,6 +572,11 @@ export function MoritaniEntry({
{entrant?.name ?? 'Another faction'} entered{' '}
{territory(entry.territory).name}, sector {entry.sector}, {arrival}.
</p>
{entry.revivalArrival && <p className="text-sm leading-6">
The revived group is already placed. Finish this Terror effect and any
Ambassador reaction before the original revival continues. Resolving
this entry uses no ordinary shipment or additional revival payment.
</p>}
{token && (
<div className="mb-4 grid grid-cols-1">
<TokenInspector token={token} owner />
+34
View File
@@ -16,6 +16,40 @@ open mode gates.
## Current checkpoint and work
**Revival Terror continuation — 25 October. Prototyped / Partial.**
Physical revived entry now reaches native Terror Reveal/Decline, all six effects
and Enemy of My Enemy. Exact original return/group metadata survives token
selection, independent Karama, native discard and storm-ordered Ambassador
opportunities. Positive Sneak captures its actual reserve group, preserves the
original second owner's prompt, then opens its own BG/Ambassador children.
Original revival income resumes once after all children; no new fee, ordinary
allowance or phase4 industry income is introduced. SEND composition remains
explicitly provisional; full modes and publication remain gated.
[Source and scope](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md#revival-terror-continuation--25-october-2026).
Three disjoint lanes, bounded review and root integration pass **23 files,
310/310**, types/lint/build and six native engine/GameTable SSR journeys.
Default public Basic71/Advanced73 actions earn actual paid Hagga13 shipment,
real Storm casualty, ordinary free revive1 and a token really placed at first
Mentat. Printed effects, independent original Karama and all four minimal
policies run legally. Genuine optional Advanced BG/Ecaz source146 uses the already
admitted independent three-card/Nexus profile and original50; default sources
retain47. Real Ecaz Basin9 setup incurs native Storm losses. Basic Ecaz uses
Arrakeen10 because first Storm11 forbids placing at Carthag11. Both owner
orders, Sabotage discards and Sneak-derived reactions keep their original
parents. Controlled public-source cases supplement native paths, including
Polar captures; no installed played source hand/wallet/force/phase/Tank is used.
Feedback fixes a phase5-only alliance-cancellation preflight, which now checks
the original revived source before spending Karama. Fixture-only repairs use
actual admitted options, legal setup ownership/destinations and native returned-
Terror supply rekeying; no start gate or rule was weakened. Historical receipt
bytes remain unchanged without new metadata. Private source-bound proof/HTML:
`/home/quintine/.local/share/dune/checkpoints/20261025-revival-terror/`.
No saved-room/D1/server/reset/restart/deployment operation occurred. DOM callbacks,
hydration, browser/mobile/deployed acceptance, wider nested combinations and
comprehensive rules/recovery/privacy assurance remain open.
**Revived force BG arrival responses — 25 October. Prototyped / Partial.**
Actual revived placement now permits native Advanced Intrusion. Off-planet
Tleilax→Arrakis also earns ordinary BG Spiritual accompaniment under an explicit
@@ -2,6 +2,77 @@
Primary-source audit, 10 September 2026. This is a source and implementation-boundary contract, not a runtime completion claim. The coordinator has already asked about threshold restoration and splitting several revived Fedaykin; those answers remain pending.
## Revival Terror continuation — 25 October 2026
**Prototyped / Partial.** The adopted physical SEND-entry interpretation now
connects eligible Terror territories to the original revived group. The native
Moritani owner keeps its Reveal/Decline and Enemy of My Enemy choices, all six
printed effects and original card, force, leader and deferred-payment mechanics.
The original revival income waits for the complete entry; no shipment fee,
ordinary shipment allowance or Revival-phase Heighliner income is introduced.
This is the provisional composition selected under the 14 October instruction,
not a publisher FAQ.
A new public source binds the exact original return, destination and physical
group. Terror receipts and ordered overlaps retain it through native discard
continuations. Historical source-absent receipt bytes remain unchanged. When
Terror and Ambassador tokens both qualify, their owners keep the existing
storm-order opportunities; resolving one never overwrites the other's prompt.
A positive Sneak Attack is its own committed reserve entry. It captures the
actual Moritani group and original Terror receipt, then waits for the original
overlap before opening its BG and Ambassador reactions. The original revival
continues after these children. Zero forces produces no new arrival. The new
phase-four Intrusion counter binds that actual Sneak source and retains the
independent Spiritual followup. Other phases retain their existing Spiritual
packet format. New source metadata is historical evidence after a committed
effect, not a fresh shipment permission or a requirement that victims survive.
Evidence paths: [source helper](../game/revival-terror-entry.ts),
[Terror receipt](../game/terror-entry-receipt.ts),
[ordered overlap](../game/arrival-overlap.ts),
[arrival history](../game/homeworld-arrival.ts),
[native source](../tests/fixture-revival-terror.ts),
[printed effects](../tests/revival-terror-runtime.test.ts).
The following BG checkpoint is retained as prior evidence; its Terror fence is
superseded by this continuation. Southern storm, HMS classification, broader
combinations, complete modes and comprehensive assurance remain open.
Frozen **23 files, 310/310**, types/lint/build and six native engine/GameTable
SSR journeys pass on unchanged source. Default Basic71/Advanced73 original
actions earn actual High19/Tanks1 through paid Hagga13 shipment, next-Storm13
loss and real first-Mentat Terror placement, then ordinary free revive1. All
six effects, decline, original Karama versus Enemy of My Enemy and four minimal
policies use native controls. Both storm orders and Sabotage discard ownership
are exercised. Optional native Advanced BG/Ecaz programme146 uses actual independent
three-card/Nexus selection and original50; defaults retain47. Ecaz's legal
Basin9 setup really loses six in the first sweep. Basic Ecaz's actual token
uses Arrakeen10 while Storm11 excludes Carthag11. No source played hand/wallet/
force/phase/Tank/response is installed.
Eight source and ten controlled arrival cases include exact packet/clock/actor,
legacy bytes, Southern support and captured Polar origin. Nine additional
native integration cases check both overlaps, fresh Sabotage discard, Sneak's
original second owner before its own BG/entrant Ambassador and an independent
original Karama versus the Sneak-derived Spiritual packet. The direct cancelled-
alliance preflight now admits its exact revived parent before card cost. A
bounded source review also removes a redundant original-territory fence from
captured Sneak history. Other-phase Spiritual formats remain unchanged.
The first frozen quick check passes; runtime then catches an unsupported fixture
roster. The source now uses actual admitted options instead of bypassing that
start guard. Further fixture feedback corrects Ecaz setup territory/owner and
Basic's in-storm Ambassador target. A private runtime assertion also follows
the native hidden-supply rekey after accepted alliance, rather than requiring
the old token ID. Initial related proof was 224/228; final 310/310 adds the original
alliance files and the actual preflight repair. No rules are relaxed to obtain
a green fixture. Logs/HTML and matching code fingerprint remain private under
`/home/quintine/.local/share/dune/checkpoints/20261025-revival-terror/`.
These are engine/SSR journeys, not DOM callbacks, hydration, browser/mobile or
deployed acceptance. Wider nested combinations, complete modes and final
assurance remain open. No saved-room/D1/server/reset/restart/deployment operation
occurred.
## Revival arrival responses — 25 October 2026
**Prototyped / Partial.** The original positive revived group now opens native
+37
View File
@@ -1,5 +1,42 @@
# Dune implementation status
## 25 October 2026 — Original revival reaches Terror and ordered arrival children
**Revival Terror continuation — 25 October. Prototyped / Partial.**
Physical revived entry now reaches native Terror Reveal/Decline, all six effects
and Enemy of My Enemy. Exact original return/group metadata survives token
selection, independent Karama, native discard and storm-ordered Ambassador
opportunities. Positive Sneak captures its actual reserve group, preserves the
original second owner's prompt, then opens its own BG/Ambassador children.
Original revival income resumes once after all children; no new fee, ordinary
allowance or phase4 industry income is introduced. SEND composition remains
explicitly provisional; full modes and publication remain gated.
[Source and scope](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md#revival-terror-continuation--25-october-2026).
Three disjoint lanes, bounded review and root integration pass **23 files,
310/310**, types/lint/build and six native engine/GameTable SSR journeys.
Default public Basic71/Advanced73 actions earn actual paid Hagga13 shipment,
real Storm casualty, ordinary free revive1 and a token really placed at first
Mentat. Printed effects, independent original Karama and all four minimal
policies run legally. Genuine optional Advanced BG/Ecaz source146 uses the already
admitted independent three-card/Nexus profile and original50; default sources
retain47. Real Ecaz Basin9 setup incurs native Storm losses. Basic Ecaz uses
Arrakeen10 because first Storm11 forbids placing at Carthag11. Both owner
orders, Sabotage discards and Sneak-derived reactions keep their original
parents. Controlled public-source cases supplement native paths, including
Polar captures; no installed played source hand/wallet/force/phase/Tank is used.
Feedback fixes a phase5-only alliance-cancellation preflight, which now checks
the original revived source before spending Karama. Fixture-only repairs use
actual admitted options, legal setup ownership/destinations and native returned-
Terror supply rekeying; no start gate or rule was weakened. Historical receipt
bytes remain unchanged without new metadata. Private source-bound proof/HTML:
`/home/quintine/.local/share/dune/checkpoints/20261025-revival-terror/`.
No saved-room/D1/server/reset/restart/deployment operation occurred. DOM callbacks,
hydration, browser/mobile/deployed acceptance, wider nested combinations and
comprehensive rules/recovery/privacy assurance remain open.
## 25 October 2026 — Revival placement reaches BG arrival responses and deferred entry
High revived placement now opens native Advanced Intrusion; Tleilax off-planet
+32 -11
View File
@@ -1,26 +1,40 @@
import type { Game } from './engine';
import { validateRevivalArrivalSource, revivalOwnsTerrorEntry, validRevivalEntryParent,
revivalEntryParentTuple, type RevivalEntryParent } from './revival-terror-entry';
type Overlap = NonNullable<Game['pendingArrivalOverlap']>;
type TerrorEntry = NonNullable<Game['pendingTerrorEntry']>;
type Overlap = NonNullable<Game['pendingArrivalOverlap']> & { revivalArrival?: RevivalEntryParent };
type TerrorEntry = NonNullable<Game['pendingTerrorEntry']> & { revivalArrival?: RevivalEntryParent };
type AmbassadorEntry = NonNullable<Game['pendingAmbassador']>;
type DiscardContext = Pick<Game, 'pendingArrivalOverlap' | 'turn' | 'phase' | 'order' | 'players'>;
type DiscardContext = Parameters<typeof validateRevivalArrivalSource>[0] & Pick<Game, 'pendingArrivalOverlap' | 'order'>;
function sameRevivalSource(left: RevivalEntryParent | undefined, right: RevivalEntryParent | undefined) {
return validRevivalEntryParent(left) && validRevivalEntryParent(right) &&
JSON.stringify(revivalEntryParentTuple(left)) === JSON.stringify(revivalEntryParentTuple(right));
}
/** The immutable trigger, not later owner choices or child stages. */
export function arrivalOverlapSignature(record: Overlap): string {
return JSON.stringify([record.event, record.turn, record.phase, record.entrant,
record.territory, record.sector, record.amount, record.elite, record.cause,
record.resume, record.ecaz, record.moritani, record.first,
record.ambassadorToken, record.terrorTokens]);
record.ambassadorToken, record.terrorTokens,
...(record.revivalArrival === undefined ? [] : [revivalEntryParentTuple(record.revivalArrival)])]);
}
/** A fresh discard belongs to the currently active owner of one ordinary entry.
/** A fresh discard belongs to the active owner of an ordinary or bound revived entry.
* The parent keeps the other owner's opportunity while the child is suspended. */
function ordinaryDiscardOverlap(g: DiscardContext, owner: Overlap['first']): Overlap | null {
const overlap = g.pendingArrivalOverlap;
const overlap: Overlap | null | undefined = g.pendingArrivalOverlap;
if (!overlap || overlap.signature !== arrivalOverlapSignature(overlap) ||
overlap.turn !== g.turn || overlap.phase !== g.phase || g.phase !== 5 ||
overlap.resume !== 'none' || !['shipment', 'movement'].includes(overlap.cause) ||
overlap.turn !== g.turn || overlap.phase !== g.phase ||
overlap.resume !== 'none' ||
!['first', 'second'].includes(overlap.stage)) return null;
if (overlap.revivalArrival !== undefined) {
try {
validateRevivalArrivalSource(g, overlap.revivalArrival, overlap);
} catch { return null; }
} else if (g.phase !== 5 ||
!['shipment', 'movement'].includes(overlap.cause)) return null;
const ecaz = g.order.indexOf(overlap.ecaz), moritani = g.order.indexOf(overlap.moritani);
if (ecaz < 0 || moritani < 0 || ecaz === moritani ||
overlap.first !== (ecaz < moritani ? 'ambassador' : 'terror') ||
@@ -32,12 +46,17 @@ function ordinaryDiscardOverlap(g: DiscardContext, owner: Overlap['first']): Ove
}
export function overlapOwnsAmbassadorDiscard(g: DiscardContext, entry: AmbassadorEntry): boolean {
const overlap = ordinaryDiscardOverlap(g, 'ambassador');
const overlap = ordinaryDiscardOverlap({ ...g, pendingAmbassador: entry }, 'ambassador');
const arrival = g.homeworldRevivalReturn?.ambassadors?.find(record => record.event === entry.event);
return !!overlap && entry.stage === 'cards' && entry.resume === 'none' &&
entry.event === overlap.ambassadorEvent && entry.owner === overlap.ecaz &&
entry.token === overlap.ambassadorToken && entry.entrant === overlap.entrant &&
entry.territory === overlap.territory && entry.sector === overlap.sector &&
entry.arrivalCause === overlap.cause && entry.turn === overlap.turn && entry.phase === overlap.phase;
entry.arrivalCause === overlap.cause && entry.turn === overlap.turn && entry.phase === overlap.phase &&
(overlap.revivalArrival === undefined || entry.revivalEvent === overlap.revivalArrival.event &&
!!arrival && sameRevivalSource(overlap.revivalArrival, { event: overlap.revivalArrival.event,
...(arrival.spiritual === undefined ? {} : { spiritual: arrival.spiritual }),
...(arrival.terror === undefined ? {} : { terror: arrival.terror }) }));
}
export function overlapOwnsTerrorDiscard(g: DiscardContext, entry: TerrorEntry, owner: string): boolean {
@@ -46,7 +65,9 @@ export function overlapOwnsTerrorDiscard(g: DiscardContext, entry: TerrorEntry,
overlap.terrorTokens.includes(entry.token) && entry.entrant === overlap.entrant &&
entry.territory === overlap.territory && entry.sector === overlap.sector &&
entry.amount === overlap.amount && entry.elite === overlap.elite &&
entry.cause === overlap.cause && entry.turn === overlap.turn && entry.phase === overlap.phase;
entry.cause === overlap.cause && entry.turn === overlap.turn && entry.phase === overlap.phase &&
(overlap.revivalArrival === undefined ||
sameRevivalSource(overlap.revivalArrival, entry.revivalArrival) && revivalOwnsTerrorEntry(g, entry));
}
/** The overlap owns the worm return while either serialized Terror prompt is live. */
+74 -19
View File
@@ -149,6 +149,7 @@ import {
} from './homeworld-revival-return';
import { homeworldRevivalDestinations, quoteHomeworldRevivalDestination } from './homeworld-revival-destinations';
import { terrorEntrySignature, terrorSelectionSignature, validateTerrorEntrySignature } from './terror-entry-receipt';
import { validateRevivalArrivalSource, validateRevivalTerrorEntry, type RevivalEntryParent } from './revival-terror-entry';
import { arrivalOverlapSignature, overlapOwnsAmbassadorDiscard, overlapOwnsTerrorDiscard, overlapOwnsWormTerror } from './arrival-overlap';
import { ambassadorPlacementDestination, ambassadorPlacementDestinations, quoteEcazAmbassadorPlacement } from './ecaz-ambassador-placement';
import { GuildAmbassadorMarkerError, markerDestinations, markerOwner, quoteGuildAmbassadorMarker,
@@ -1026,6 +1027,7 @@ export type Decision =
destination?: string;
ambassadorEvent?: string;
revivalEvent?: string;
revivalTerror?: RevivalEntryParent['terror'];
}
| {
kind: 'intrusion';
@@ -1035,7 +1037,8 @@ export type Decision =
discoveryEntry?: DiscoveryEntryArrivalChild;
ambassadorEvent?: string;
revivalEvent?: string;
followup?: { shipment: string; destination: string; revivalEvent?: string };
revivalTerror?: RevivalEntryParent['terror'];
followup?: { shipment: string; destination: string; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] };
}
| { kind: 'advisorBattle'; player: string; territories: string[] }
| {
@@ -1112,8 +1115,9 @@ export type ResponseWindow = {
discoveryEntry?: DiscoveryEntryArrivalChild;
advisorAmbassadorEvent?: string;
advisorRevivalEvent?: string;
advisorRevivalTerror?: RevivalEntryParent['terror'];
advisorRemaining?: string[];
advisorFollowup?: { shipment: string; destination: string; revivalEvent?: string };
advisorFollowup?: { shipment: string; destination: string; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] };
amount?: number;
elite?: number;
recipient?: string;
@@ -1210,7 +1214,6 @@ type PendingHomeworldShipment = HomeworldShipmentIntent & {
allyPayment: number;
pools: ReturnType<typeof quoteHomeworldShipment>['sources'] | ReturnType<typeof quoteGuildHomeworldShipment>['boardSources'];
};
type RevivalEntryParent = { event: string; spiritual?: SpiritualAdvisorSource };
type PendingShipment = {
/** Only new declarations use the bonus; legacy quoted shipments retain their original price. */
smuggler?: SmugglerShipment;
@@ -1925,6 +1928,7 @@ export type Game = {
dukeVidal?: DukeState;
dukeAcquisitionTurn?: number;
pendingTerrorEntry?: {
revivalArrival?: RevivalEntryParent;
/** Original public arrival, absent only in legacy saves. */
entrySignature?: string;
candidates?: string[];
@@ -1981,9 +1985,12 @@ export type Game = {
turn: number;
phase: number;
revivalArrival?: RevivalEntryParent;
/** A committed revival Sneak group waits for the original overlap before BG reactions. */
revivalSneakAdvisor?: true;
}[] | null;
/** One committed entry with two independently optional expansion reactions. */
pendingArrivalOverlap?: {
revivalArrival?: RevivalEntryParent;
event: string;
signature: string;
turn: number;
@@ -7268,6 +7275,7 @@ function treacheryDiscardIntegrity(g: Game) {
(t) => t.id === entry?.token,
);
if (entry?.cause === 'ambassador') ambassadorRelocationIntegrity(g);
if (entry?.revivalArrival) homeworldRule(() => validateRevivalTerrorEntry(g, entry));
const sabotage = source === 'sabotage';
const discarder = sabotage ? entrant : owner;
requireRule(
@@ -7288,6 +7296,7 @@ function treacheryDiscardIntegrity(g: Game) {
'guildTransport',
'advisor',
'wormRide',
'homeworldRevival',
'ambassador',
].includes(entry.cause) &&
['none', 'wormRide', 'ambassador'].includes(entry.resume) &&
@@ -7297,7 +7306,7 @@ function treacheryDiscardIntegrity(g: Game) {
? (entry.resume === 'wormRide' || overlapOwnsWormTerror(g, entry)) &&
g.phase === 1 &&
entrant.faction === 'fremen'
: entry.resume === 'none') &&
: entry.resume === 'none' && (entry.cause !== 'homeworldRevival' || !!entry.revivalArrival)) &&
Number.isSafeInteger(entry.amount) &&
entry.amount >= 0 &&
Number.isSafeInteger(entry.elite) &&
@@ -7323,6 +7332,7 @@ function treacheryDiscardIntegrity(g: Game) {
const { entry, aftermath, handBefore } = continuation;
atomicsIntegrity(g);
if (entry?.cause === 'ambassador') ambassadorRelocationIntegrity(g);
if (entry?.revivalArrival) homeworldRule(() => validateRevivalTerrorEntry(g, entry));
const owner = byFaction(g, 'moritani');
const token = g.moritaniTerror?.tokens.find(row => row.id === entry?.token);
requireRule(!g.pendingTerrorEntry && !g.pendingExchange && !g.summonedWorm &&
@@ -7332,8 +7342,8 @@ function treacheryDiscardIntegrity(g: Game) {
: entry.cause === 'wormRide' ?
(entry.resume === 'wormRide' || overlapOwnsWormTerror(g,entry)) && g.phase === 1 &&
g.players.some(player => player.id === entry.entrant && player.faction === 'fremen')
: entry.resume === 'none') &&
['shipment', 'movement', 'guildTransport', 'advisor', 'wormRide', 'ambassador'].includes(entry.cause) &&
: entry.resume === 'none' && (entry.cause !== 'homeworldRevival' || !!entry.revivalArrival)) &&
['shipment', 'movement', 'guildTransport', 'advisor', 'wormRide', 'homeworldRevival', 'ambassador'].includes(entry.cause) &&
entry.entrant !== owner?.id && entry.territory === aftermath.territory &&
terrorEntryLocationAllowed(g,entry) && validGameLocation(g, entry.territory, entry.sector) &&
token?.kind === 'atomics' && token.status === 'removed' &&
@@ -17479,7 +17489,8 @@ function ambassadorEffectBlock(
return 'The entrant has no held Traitor Card to inspect.';
return null;
}
function arrivalOverlapModeSupported(g: Game): boolean {
function arrivalOverlapModeSupported(g: Game, revivalArrival?: RevivalEntryParent): boolean {
if (revivalArrival && g.homeworlds && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
if (bareBasicSemutaProfile(g) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
if ((independentNexusProfile(g) || independentDiscoveryProfile(g)) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
if ((mixedE3NexusLeaderSkillsProfile(g) || mixedE3NexusProfile(g)) &&
@@ -17504,9 +17515,11 @@ function arrivalOverlapIntegrity(g: Game) {
(continuation?.kind === 'ambassador' ? continuation.entry : null);
const terror = g.pendingTerrorEntry ??
(continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics' ? continuation.entry : null);
if (record.revivalArrival)
homeworldRule(() => validateRevivalArrivalSource(g, record.revivalArrival!, record));
const active = record.stage === 'first' ? record.first :
record.first === 'ambassador' ? 'terror' : 'ambassador';
requireRule(g.status === 'playing' && arrivalOverlapModeSupported(g) &&
requireRule(g.status === 'playing' && arrivalOverlapModeSupported(g, record.revivalArrival) &&
record.turn === g.turn && record.phase === g.phase &&
typeof record.event === 'string' && record.event.length > 0 &&
typeof record.signature === 'string' &&
@@ -17544,6 +17557,7 @@ function arrivalOverlapIntegrity(g: Game) {
terror.territory === record.territory &&
terror.turn === record.turn && terror.phase === record.phase &&
terror.cause === record.cause &&
JSON.stringify(terror.revivalArrival) === JSON.stringify(record.revivalArrival) &&
record.terrorTokens.includes(terror.token)),
'The saved Ambassador/Terror overlap lost its original ordered arrival.');
}
@@ -17578,7 +17592,7 @@ function openAmbassadorEntry(
'This entry has lost its original revived group.');
g.pendingAmbassador.revivalEvent = frame.event;
homeworldRule(() => appendHomeworldArrivalAmbassador(frame, g.pendingAmbassador!, undefined,
revivalArrival.spiritual));
revivalArrival.spiritual, revivalArrival.terror));
}
log(g, `${owner.name} may trigger the ${faction(token.effect).name} Ambassador because ${entrant.name} entered ${territory(to).name}. The entrant’s remaining actions wait for this opportunity.`);
return true;
@@ -17602,6 +17616,9 @@ function openTerritoryEntry(
resume: 'none' | 'wormRide' = 'none',
revivalArrival?: RevivalEntryParent,
) {
if (revivalArrival)
homeworldRule(() => validateRevivalArrivalSource(g, revivalArrival,
{entrant:entrant.id,territory:to,sector,amount,elite,cause}));
// The arrival's own interaction (guild payment, advisor choice, storm
// protection) settles first; the entry then opens from the automatic pass.
if (g.response || g.decision || g.pendingTerrorEntry || g.pendingAmbassador ||
@@ -17638,6 +17655,8 @@ function openTerritoryEntry(
elite,
cause,
resume,
undefined,
revivalArrival,
);
const moritani = byFaction(g, 'moritani');
const terrorTokens = moritani &&
@@ -17650,8 +17669,7 @@ function openTerritoryEntry(
'Finish the other arrival reaction before opening this entry.');
if (!terrorTokens.length)
return openAmbassadorEntry(g, owner, token, entrant, to, sector, resume, cause, revivalArrival);
requireRule(!revivalArrival, 'Revival deployment with overlapping Terror and Ambassador reactions is still being implemented.');
requireRule(arrivalOverlapModeSupported(g) && !g.pendingArrivalOverlap &&
requireRule(arrivalOverlapModeSupported(g, revivalArrival) && !g.pendingArrivalOverlap &&
moritani && g.order.includes(owner.id) && g.order.includes(moritani.id),
'Ambassador and Terror overlap still needs a supported classic Ecaz/Moritani table.');
const first = g.order.indexOf(owner.id) < g.order.indexOf(moritani.id)
@@ -17661,13 +17679,14 @@ function openTerritoryEntry(
entrant: entrant.id, territory: to, sector, amount, elite, cause, resume,
ecaz: owner.id, moritani: moritani.id, first, stage: 'first',
ambassadorToken: token.id, terrorTokens: terrorTokens.map(t => t.id),
...(revivalArrival ? {revivalArrival:structuredClone(revivalArrival)} : {}),
};
overlap.signature = arrivalOverlapSignature(overlap);
g.pendingArrivalOverlap = overlap;
log(g, `${owner.name} and ${moritani.name} both have optional reactions to ${entrant.name}'s entry in ${territory(to).name}; ${first === 'ambassador' ? owner.name : moritani.name} acts first in storm order.`);
if (first === 'terror')
return openTerrorEntry(g, entrant, to, sector, amount, elite, cause, 'none');
openAmbassadorEntry(g, owner, token, entrant, to, sector, 'none', cause);
return openTerrorEntry(g, entrant, to, sector, amount, elite, cause, 'none', undefined, revivalArrival);
openAmbassadorEntry(g, owner, token, entrant, to, sector, 'none', cause, revivalArrival);
overlap.ambassadorEvent = g.pendingAmbassador!.event;
return true;
}
@@ -17706,7 +17725,7 @@ function finishAmbassador(g: Game, deferResume = false) {
overlap.stage = 'second';
requireRule(openTerrorEntry(g, getPlayer(g, overlap.entrant),
overlap.territory, overlap.sector, overlap.amount, overlap.elite,
overlap.cause, 'none'),
overlap.cause, 'none', undefined, overlap.revivalArrival),
'The originally triggered Terror token is no longer in the stronghold.');
return;
}
@@ -19944,6 +19963,7 @@ function terrorEntryIntegrity(g: Game) {
for (const entry of entries) {
if (!entry) continue;
homeworldRule(() => validateTerrorEntrySignature(entry));
if (entry.revivalArrival) homeworldRule(() => validateRevivalTerrorEntry(g, entry));
if (entry.entrySignature === undefined) continue;
requireRule(entry.turn === g.turn && entry.phase === g.phase &&
g.players.some((p) => p.id === entry.entrant && p.faction !== 'moritani') &&
@@ -19983,7 +20003,11 @@ function openTerrorEntry(
cause: NonNullable<Game['pendingTerrorEntry']>['cause'],
resume: 'none' | 'wormRide' | 'ambassador' = 'none',
ambassadorEvent?: string,
revivalArrival?: RevivalEntryParent,
) {
if (revivalArrival)
homeworldRule(() => validateRevivalArrivalSource(g, revivalArrival,
{entrant:entrant.id,territory:to,sector,amount,elite,cause}));
const moritani = byFaction(g, 'moritani');
const overlap = g.pendingArrivalOverlap;
const originallyTriggered = !!overlap &&
@@ -20018,6 +20042,7 @@ function openTerrorEntry(
stage: tokens.length > 1 ? 'select' : 'offer',
resume,
...(ambassadorEvent ? { ambassadorEvent } : {}),
...(revivalArrival ? {revivalArrival:structuredClone(revivalArrival)} : {}),
};
if (!TERROR_STRONGHOLDS.includes(to)) {
const proofs = tokens.map(token => {
@@ -20055,7 +20080,7 @@ function finishTerrorEntry(g: Game) {
t.location === overlap.territory);
requireRule(token, 'The originally triggered Ambassador token is no longer present.');
openAmbassadorEntry(g, getPlayer(g, overlap.ecaz), token,
getPlayer(g, overlap.entrant), overlap.territory, overlap.sector, 'none', overlap.cause);
getPlayer(g, overlap.entrant), overlap.territory, overlap.sector, 'none', overlap.cause, overlap.revivalArrival);
overlap.ambassadorEvent = g.pendingAmbassador!.event;
return;
}
@@ -20375,7 +20400,18 @@ function decideTerror(g: Game, p: Player, action: Action) {
`${p.name} sent ${amount} reserves into ${territory(entry.territory).name} through Sneak Attack.`,
);
} else log(g, `${p.name} sent no forces through Sneak Attack.`);
const revivalArrival: RevivalEntryParent | undefined = amount > 0 && entry.revivalArrival
? {event:entry.revivalArrival.event,terror:{event:entry.entrySignature!,owner:p.id,
territory:entry.territory,sector:entry.sector,amount,elite:0}} : undefined;
finishTerrorEntry(g);
// Keep the original two-owner opportunity intact before this new group's
// reactions; never overwrite an Ambassador opened by finishing Terror.
if (revivalArrival) {
(g.pendingArrivalReaction ??= []).push({entrant:p.id,territory:entry.territory,
sector:entry.sector,amount,elite:0,cause:'shipment',resume:'none',turn:g.turn,
phase:g.phase,revivalArrival,revivalSneakAdvisor:true});
return;
}
// The Terror effect is the arrival itself, so the adopted order resolves
// the committed entry first and then offers the Bene Gesserit reactions
// the same off-planet reserve entry already uses for ordinary shipments.
@@ -20696,7 +20732,8 @@ function completeSpiritualAdvisorArrival(g:Game){
{...order,amount:order.amount as 1|2,advisors:isAdvisor(bg,order.territory)});
}else{
openTerritoryEntry(g,bg,order.territory,order.sector,order.amount,0,'advisor','none',
frame.source!.revivalEvent ? {event:frame.source!.revivalEvent,spiritual:frame.source} : undefined);
frame.source!.revivalEvent ? {event:frame.source!.revivalEvent,spiritual:frame.source,
...(frame.decision.revivalTerror ? {terror:frame.decision.revivalTerror} : {})} : undefined);
}
}
type TransportMatchingIntent=NonNullable<Game['pendingAdvisorTransportMatching']>['intent'];
@@ -22120,8 +22157,9 @@ function finishAdvisorReaction(
discoveryEntry?: DiscoveryEntryArrivalChild;
advisorAmbassadorEvent?: string;
advisorRevivalEvent?: string;
advisorRevivalTerror?: RevivalEntryParent['terror'];
advisorRemaining?: string[];
advisorFollowup?: { shipment: string; destination: string; revivalEvent?: string };
advisorFollowup?: { shipment: string; destination: string; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] };
},
) {
const bg = byFaction(g, 'beneGesserit')!;
@@ -22147,7 +22185,8 @@ function intrusion(
discoveryEntry?: DiscoveryEntryArrivalChild;
ambassadorEvent?: string;
revivalEvent?: string;
followup?: { shipment: string; destination: string; revivalEvent?: string };
revivalTerror?: RevivalEntryParent['terror'];
followup?: { shipment: string; destination: string; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] };
} = {},
) {
const bg = byFaction(g, 'beneGesserit');
@@ -27956,8 +27995,9 @@ function stampHomeworldRevivalProgress(g: Game) {
}
function homeworldRevivalReturnIntegrity(g: Game) {
const frame = g.homeworldRevivalReturn;
homeworldRule(() => validateHomeworldRevivalReturn(g, frame));
const continuation = g.pendingTreacheryDiscard?.continuation;
homeworldRule(() => validateHomeworldRevivalReturn(
continuation?.kind === 'ambassador' ? {...g,pendingAmbassador:continuation.entry} : g, frame));
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume,
g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume,
continuation && 'resume' in continuation ? continuation.resume : null];
@@ -33403,6 +33443,19 @@ function settleAutomaticContinuations(g: Game) {
if (!g.pendingArrivalReaction.length) g.pendingArrivalReaction = null;
const entrant = g.players.find(p => p.id === deferred.entrant);
requireRule(!!entrant, 'The deferred arrival entry has lost its entrant.');
if (deferred.revivalSneakAdvisor) {
requireRule(deferred.revivalArrival?.terror && !deferred.revivalArrival.spiritual &&
deferred.turn === g.turn && deferred.phase === g.phase,
'The queued Sneak reactions have lost their committed revival entry.');
homeworldRule(() => validateRevivalArrivalSource(g, deferred.revivalArrival!, deferred));
const bg = byFaction(g, 'beneGesserit');
const source = {revivalEvent:deferred.revivalArrival!.event,
revivalTerror:deferred.revivalArrival!.terror};
const followup = bg && bg.id !== entrant!.id && spiritualAdvisorMaximum(g,bg.id) > 0
? {shipment:entrant!.id,destination:location(deferred.territory,deferred.sector),...source} : undefined;
if (!intrusion(g,entrant!,deferred.territory,{...source,...(followup ? {followup} : {})}) && followup)
g.decision = {kind:'advisor',player:bg!.id,...followup};
}
openTerritoryEntry(g, entrant!, deferred.territory, deferred.sector,
deferred.amount, deferred.elite, deferred.cause, deferred.resume, deferred.revivalArrival);
if (deferred.revivalArrival) resumeHomeworldRevivalReturn(g);
@@ -35287,6 +35340,7 @@ function applyActionInner(
: undefined,
advisorAmbassadorEvent: decision.ambassadorEvent,
advisorRevivalEvent: decision.revivalEvent,
advisorRevivalTerror: decision.revivalTerror,
discoveryEntry: decision.discoveryEntry,
};
if (action.accept)
@@ -37829,6 +37883,7 @@ export function viewGame(state: Game, id: string) {
sector: entry.sector,
cause: entry.cause,
stage: entry.stage,
...(entry.revivalArrival ? {revivalArrival:true} : {}),
...(me.faction === 'moritani' && entry.stage === 'select'
? { candidates: entry.candidates!.map((id) => {
const candidate = g.moritaniTerror!.tokens.find((t) => t.id === id)!;
+48 -2
View File
@@ -2,6 +2,7 @@ import type { Game } from './engine';
import { location, splitLocation, validLocation } from './board';
import { HomeworldCustodyError } from './homeworld-custody';
import type { SpiritualAdvisorSource } from './spiritual-advisor';
import { validRevivalTerrorPacket, validateRevivalTerrorPacket, type RevivalTerrorPacket } from './revival-terror-entry';
export type HomeworldAmbassadorArrival = {
event: string;
@@ -9,6 +10,7 @@ export type HomeworldAmbassadorArrival = {
destination: string;
parent?: string;
spiritual?: SpiritualAdvisorSource;
terror?: RevivalTerrorPacket;
completed: boolean;
};
export type HomeworldArrivalFrame = {
@@ -53,6 +55,7 @@ function spiritualSource(
const source = entry.spiritual as (SpiritualAdvisorSource & { revivalEvent?: string }) | undefined;
if (!source || !keys(source, ['event', 'owner', 'turn', 'phase', 'territory', 'sector', 'amount', 'revivalEvent'])) fail();
const destination = splitLocation(frame.destination!);
const origin = entry.terror?.territory ?? destination.territory;
if (
Object.keys(source).sort().join(',') !== 'amount,event,owner,phase,revivalEvent,sector,territory,turn' ||
!text(source.event) ||
@@ -67,7 +70,7 @@ function spiritualSource(
!Number.isSafeInteger(source.sector) ||
!validLocation(source.territory, source.sector) ||
!validLocation(destination.territory, destination.sector) ||
(source.territory !== destination.territory && source.territory !== 'polar_sink') ||
(source.territory !== origin && source.territory !== 'polar_sink') ||
(source.amount !== 1 && source.amount !== 2) ||
entry.parent !== undefined ||
entry.destination !== location(source.territory, source.sector)
@@ -75,6 +78,22 @@ function spiritualSource(
return source as SpiritualAdvisorSource & { revivalEvent: string };
}
function terrorPacket(source: RevivalTerrorPacket) {
return [source.event, source.owner, source.territory, source.sector, source.amount, source.elite];
}
/** Sneak Attack is a distinct committed packet, not another disposition of the
* original revived counters. Its captured Terror witness stays engine-owned. */
function terrorSource(frame: HomeworldArrivalFrame, entry: HomeworldAmbassadorArrival): RevivalTerrorPacket {
const source = entry.terror;
const destination = splitLocation(frame.destination!);
if (!validRevivalTerrorPacket(source) || entry.parent !== undefined ||
!validLocation(destination.territory, destination.sector) ||
(entry.spiritual === undefined && (entry.entrant !== source.owner ||
entry.destination !== location(source.territory, source.sector)))) fail();
return source;
}
/** Arrival provenance is fixed separately from the original revival grant. */
export function homeworldArrivalSignature(
frame: HomeworldArrivalFrame,
@@ -82,6 +101,8 @@ export function homeworldArrivalSignature(
if (!text(frame.destination) || !Array.isArray(frame.ambassadors)) fail();
const seen = new Set<string>();
const spiritualEvents = new Set<string>();
const terrorEvents = new Map<string, string>();
const terrorRoots = new Set<string>();
let primary = false;
for (const [index, entry] of frame.ambassadors.entries()) {
if (
@@ -91,6 +112,7 @@ export function homeworldArrivalSignature(
'destination',
'parent',
'spiritual',
'terror',
'completed',
]) ||
!text(entry.event) ||
@@ -101,10 +123,21 @@ export function homeworldArrivalSignature(
seen.has(entry.event)
)
fail();
if (entry.terror !== undefined) {
const source = terrorSource(frame, entry), packet = JSON.stringify(terrorPacket(source));
if (terrorEvents.has(source.event) && terrorEvents.get(source.event) !== packet) fail();
terrorEvents.set(source.event, packet);
if (entry.spiritual === undefined) {
if (terrorRoots.has(source.event)) fail();
terrorRoots.add(source.event);
}
}
if (entry.spiritual !== undefined) {
const source = spiritualSource(frame, entry);
if (spiritualEvents.has(source.event)) fail();
spiritualEvents.add(source.event);
} else if (entry.terror !== undefined) {
// Its separate public packet was checked above; it is not the primary root.
} else if (entry.parent === undefined) {
if (primary || entry.entrant !== frame.player || entry.destination !== frame.destination) fail();
primary = true;
@@ -135,6 +168,7 @@ export function homeworldArrivalSignature(
entry.spiritual.amount,
spiritualSource(frame, entry).revivalEvent,
]]),
...(entry.terror === undefined ? [] : [['terror', terrorPacket(entry.terror)]]),
]),
]);
}
@@ -152,6 +186,7 @@ export function appendHomeworldArrivalAmbassador(
},
parent?: string,
spiritual?: SpiritualAdvisorSource,
terror?: RevivalTerrorPacket,
): void {
if (
frame.stage !== 'arrival' ||
@@ -169,6 +204,7 @@ export function appendHomeworldArrivalAmbassador(
completed: false,
...(parent === undefined ? {} : { parent }),
...(spiritual === undefined ? {} : { spiritual }),
...(terror === undefined ? {} : { terror }),
};
const ambassadors = [
...frame.ambassadors!,
@@ -179,6 +215,7 @@ export function appendHomeworldArrivalAmbassador(
ambassadors,
});
if (spiritual !== undefined) appended.spiritual = structuredClone(spiritual);
if (terror !== undefined) appended.terror = structuredClone(terror);
frame.ambassadors = ambassadors;
frame.arrivalSignature = arrivalSignature;
}
@@ -231,11 +268,20 @@ export function validateHomeworldArrival(
(frame.turn !== g.turn || frame.phase !== g.phase)) ||
frame.arrivalSignature !== homeworldArrivalSignature(frame) ||
frame.ambassadors!.some((entry) => {
if (entry.terror !== undefined) {
if (tag !== 'revivalEvent') return true;
validateRevivalTerrorPacket({ turn: frame.turn, phase: frame.phase, players: g.players,
revivalPlayer: frame.player }, entry.terror, frame.destination!, frame.event);
}
if (entry.spiritual !== undefined) {
const entrants = g.players.filter((seat) => seat.id === entry.entrant);
const owners = g.players.filter((seat) => seat.id === frame.player);
return tag !== 'revivalEvent' || entrants.length !== 1 ||
entrants[0].faction !== 'beneGesserit' || owners.length !== 1 || owners[0].faction !== 'tleilaxu';
entrants[0].faction !== 'beneGesserit' || owners.length !== 1 ||
(entry.terror === undefined && owners[0].faction !== 'tleilaxu');
}
if (entry.terror !== undefined) {
return !g.players.some(seat => seat.id === entry.entrant && seat.faction === 'moritani');
}
return !g.players.some((seat) => seat.id === entry.entrant &&
(entry.parent === undefined || seat.faction === 'beneGesserit'));
-7
View File
@@ -6,7 +6,6 @@ import { homeworldContext } from './homeworld-game';
import { HOMEWORLD_CARDS } from './homeworld-cards';
import { homeworldForceGroups, quoteHomeworldCustody, HomeworldCustodyError } from './homeworld-custody';
import { quoteNativeReserveWithdrawal } from './homeworld-native-reserves';
import { lowGrummanRevealBlock } from './homeworld-collection';
import type { HomeworldRevivalDeploymentQuote } from './homeworld-revival-deployment';
type Context = Pick<Game, 'advanced' | 'players' | 'homeworlds' | 'storm' | 'mobileStronghold' | 'moritaniTerror'>;
@@ -30,8 +29,6 @@ export function homeworldRevivalDestinations(
const p = g.players.find((seat) => seat.id === player);
if (!p || !g.homeworlds?.custody)
throw new HomeworldCustodyError('Revival placement needs its seated native Homeworld owner.');
const amount = grant.normal + grant.elite;
const moritani = g.players.find(seat => seat.faction === 'moritani');
const destinations: HomeworldRevivalDestination[] = gameTerritories(g).flatMap((t) => {
if (grant.kind === 'fedaykin' && presenceAt(p, t.id) === 0) return [];
return t.sectors.map((sector) => {
@@ -45,10 +42,6 @@ export function homeworldRevivalDestinations(
if (!blocked && t.id === MOBILE_STRONGHOLD)
blocked = 'Revival placement in the Hidden Mobile Stronghold awaits its shipment-classification ruling.';
blocked ??= territoryEntryBlock(g.players, p.id, t.id);
if (!blocked && moritani && p.id !== moritani.id && p.id !== moritani.ally &&
!lowGrummanRevealBlock(g, moritani.id, amount) &&
g.moritaniTerror?.tokens.some(token => token.status === 'placed' && token.location === t.id))
blocked = 'Revival SEND into Terror awaits its original entry continuation.';
return { id: location(t.id, sector), name: `${t.name}${sector ? ` · sector ${sector}` : ''}`,
territory: t.id, sector, blocked };
});
+16 -5
View File
@@ -2,6 +2,7 @@ import { validAmbassadorResume } from './ambassador-resume';
import { validateDiscoveryEntryArrivalChild, validateDiscoveryEntryRound } from './discovery-entry';
import { ambassadorPhaseAllowed } from './ambassador-phase';
import { validateHomeworldRevivalReturn } from './homeworld-revival-return';
import { validateRevivalArrivalSource } from './revival-terror-entry';
import { HomeworldCustodyError } from './homeworld-custody';
import {
validateGuildAmbassadorArrivalContext,
@@ -516,7 +517,13 @@ export function quoteMovementCancellation(
if (response.advisorResume === 'wormRide') return null;
if (response.advisorRevivalEvent !== undefined) {
const parent = g.homeworldRevivalReturn;
try { validateHomeworldRevivalReturn(g, parent); }
const terror = response.advisorRevivalTerror;
try {
validateHomeworldRevivalReturn(g, parent);
if (terror) validateRevivalArrivalSource(g, {event:response.advisorRevivalEvent,terror},
{entrant:terror.owner,territory:terror.territory,sector:terror.sector,
amount:terror.amount,elite:terror.elite,cause:'shipment'});
}
catch (error) {
if (error instanceof HomeworldCustodyError) throw new MovementCancellationError(error.message);
throw error;
@@ -525,17 +532,21 @@ export function quoteMovementCancellation(
parent?.stage === 'arrival' && parent.event === response.advisorRevivalEvent &&
parent.turn === g.turn && parent.phase === g.phase && parent.player !== owner.id &&
typeof parent.destination === 'string' && sectorKey(g, parent.destination) &&
response.location === splitLocation(parent.destination).territory &&
response.location === (terror?.territory ?? splitLocation(parent.destination).territory) &&
response.advisorResume === undefined && response.advisorRemaining === undefined,
'The canceled Intrusion lost its original committed revived-force arrival.');
const followup = response.advisorFollowup;
requireContext(followup === undefined || (parent.quote.kind === 'tleilax' &&
followup.shipment === parent.player && followup.destination === parent.destination &&
followup.revivalEvent === parent.event),
requireContext(followup === undefined || ((terror || parent.quote.kind === 'tleilax') &&
followup.shipment === (terror?.owner ?? parent.player) &&
followup.destination === (terror ? location(terror.territory,terror.sector) : parent.destination) &&
followup.revivalEvent === parent.event &&
JSON.stringify(followup.revivalTerror) === JSON.stringify(terror)),
'The revived Intrusion lost its original Spiritual Advisor followup.');
return { kind: 'advisorFlip', successor: followup &&
homeworldSpiritualAdvisorLimit(g, owner.id, 'polar_sink') > 0 ? 'advisor' : 'none' };
}
requireContext(response.advisorRevivalTerror === undefined,
'A Sneak Intrusion must retain its original revival event.');
requireContext(
g.phase === 5,
'This advisor cancellation is outside its shipment and movement phase.',
+6 -2
View File
@@ -12,6 +12,7 @@ import {
import { TERROR_KINDS } from './moritani-terror';
import { validateTerrorEntrySignature } from './terror-entry-receipt';
import { overlapOwnsWormTerror } from './arrival-overlap';
import { validateRevivalTerrorEntry, type RevivalArrivalContext } from './revival-terror-entry';
export class MoritaniAllianceCancellationError extends Error {
constructor(message: string) {
@@ -30,7 +31,7 @@ export type MoritaniAllianceCancellationContext = Pick<
| 'moritaniTerror'
| 'pendingAmbassador'
| 'ecazAmbassadors'
> & TerrorLocationContext;
> & TerrorLocationContext & RevivalArrivalContext;
export type MoritaniAllianceCancellationQuote = {
kind: 'moritaniAlliance';
owner: string;
@@ -86,6 +87,7 @@ export function quoteMoritaniAllianceCancellation(
const entrants = g.players.filter((p) => p.id === entry.entrant);
try {
validateTerrorEntrySignature(entry);
if (entry.revivalArrival !== undefined) validateRevivalTerrorEntry(g, entry);
} catch (error) {
throw new MoritaniAllianceCancellationError((error as Error).message);
}
@@ -164,7 +166,9 @@ export function quoteMoritaniAllianceCancellation(
'Only an Ambassador entry may carry its event.',
);
requireSource(
entry.cause === 'wormRide'
entry.revivalArrival !== undefined
? entry.resume === 'none'
: entry.cause === 'wormRide'
? g.phase === 1 &&
(entry.resume === 'wormRide' || overlapOwnsWormTerror(g, entry)) &&
entrants[0].faction === 'fremen'
+2 -2
View File
@@ -4576,7 +4576,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Quote a group of normal revivals using population before that group returns. A later group uses the resulting population: a low Fremen group can return four free forces, but returning a smaller group that restores high population can remove the fourth allowance. La La La prevents the full free rate.',
'When Southern Hemisphere supports a revival that leaves Fremen at high population — including a return that first reaches the high side: the population resulting from the return decides the ability — Fremen may place the newly revived starred forces together in one territory already containing their forces, or leave them in reserves. This includes supported ordinary, Emperor-funded and Ghola revivals. Evaluating the resulting population is a visibly provisional composition policy, not a located publisher timing ruling. Basic keeps the starred counters; Advanced still limits Fedaykin revival to one per turn.',
'Under the same provisional resulting-population policy, an ordinary Free Revival that leaves Tleilax at high population may send its newly revived free group together to one permitted territory or Homeworld, or leave it in reserves. This includes the return that first reaches the high side. Paid counters in the same return and Ghola returns do not join this group. An allied Homeworld is forbidden. Placement costs no additional spice and uses no ordinary shipment or movement allowance.',
'The revival placement choice shows unavailable destinations. A return that first restores high population is now admitted under the provisional resulting-population policy above; partial groups, split destinations, Southern storm placement, allied-Homeworld limits and entry effects requiring a shipment classification remain guarded while those rules are unresolved. Independent revival income waits until placement and supported Ambassador reactions finish; refreshing does not repeat the return or payment.',
'The revival placement choice shows unavailable destinations. A return that first restores high population is admitted under the provisional resulting-population policy above. Physical Arrakis entry provisionally earns Intrusion, eligible Terror and Ambassador reactions; off-planet Tleilax placement also earns BG Spiritual Advisors. Southern placement remains on-planet. Original revival income waits for those children, including a committed Sneak Attack reserve group, without another shipment fee or allowance. Preventing a BG power leaves the revived group committed. Terror and Ambassador owners act in storm order when both qualify. Partial groups, split destinations, Southern storm placement and Hidden Mobile Stronghold classification remain guarded; allied Homeworlds remain forbidden. These SEND compositions are explicit provisional interpretations, not a new publisher FAQ.',
'While Tupile has at least eleven native forces, CHOAM cannot discard Worthless cards for spice through either ordinary sale price or a CHOAM Ambassador. Non-Worthless duplicate sales, allied trades and the Advanced CHOAM Karama cash-in retain their own rules. If a Ghola revival raises Tupile before a declared sale settles, that Worthless card stays in hand and earns no spice.',
'At the end of Bidding, high-population Kaitain lets Emperor pay two spice per selected Treachery Card to discard it. This does not play the card or begin another auction. Emperor and CHOAM share the closing opportunity, so sales, allied trades and paid discards can occur in either order. Current hands, spice and native population determine each action. Finish when ready; a new action clears readiness.',
'In supported payment cases, low Kaitain halves Emperor’s Treachery payment income and low Junction halves eligible Guild shipping income, rounded up. The payer still pays the full cost. For example, a five-spice auction payment gives low-population Emperor three spice and leaves two in the bank. The income opportunity shows the original payment, current receipt and bank remainder. A population change before collection updates the split; ordinary Karama can still prevent the separate faction income.',
@@ -4623,7 +4623,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
"All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Richese High now connects actual paid reserve companions, one combined provisional invoice, original marker proof/responses, physical withdrawal and later reserve-capped reveal. Nexus Cunning retains that explicit paid group in its pair receipt, reserving paid counters before immediate materialization; original counter and spent-card outcomes remain. Ix High now returns physical Suboids for paid Cyborgs with provisional additional allowance and shortage handling, retaining typed free allocation, original invoice and free-event income. Southern and Tleilax preserve the exact eligible revival group through one optional placement, with held income and explicit unresolved destination rules. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.",
"All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Richese High now connects actual paid reserve companions, one combined provisional invoice, original marker proof/responses, physical withdrawal and later reserve-capped reveal. Nexus Cunning retains that explicit paid group in its pair receipt, reserving paid counters before immediate materialization; original counter and spent-card outcomes remain. Ix High now returns physical Suboids for paid Cyborgs with provisional additional allowance and shortage handling, retaining typed free allocation, original invoice and free-event income. Southern and Tleilax preserve the exact eligible revival group through one optional placement. Provisional physical entry now opens native BG, Terror and Ambassador responses, retaining the original held income, independent counters and ordered overlap; a committed Sneak group has its own arrival source. Southern storm and Hidden Mobile Stronghold classification remain unresolved. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.",
evidence: [
'game/faction-module-profile.ts',
'game/reinforcements-homeworld.ts',
+182
View File
@@ -0,0 +1,182 @@
import type { Game } from './engine';
import { gameTerritories, location, splitLocation, validLocation } from './board';
import { HomeworldCustodyError } from './homeworld-custody';
import { validateHomeworldRevivalReturn } from './homeworld-revival-return';
import type { SpiritualAdvisorSource } from './spiritual-advisor';
export type RevivalTerrorPacket = {
event: string;
owner: string;
territory: string;
sector: number;
amount: number;
elite: number;
};
export type RevivalEntryParent = {
event: string;
spiritual?: SpiritualAdvisorSource;
terror?: RevivalTerrorPacket;
};
type TerrorEntry = NonNullable<Game['pendingTerrorEntry']>;
export type RevivalArrivalDetails = Pick<TerrorEntry, 'entrant' | 'territory' | 'sector' | 'amount' | 'elite' | 'cause'>;
export type RevivalArrivalContext = Pick<Game, 'status' | 'turn' | 'phase' | 'players' |
'homeworldRevivalReturn' | 'homeworldRevivalProgress' | 'homeworlds' |
'pendingAmbassador' | 'pendingTreacheryDiscard' | 'mobileStronghold' | 'discoveries'>;
type HistoricalContext = Pick<Game, 'turn' | 'phase'> & {
players: readonly Pick<Game['players'][number], 'id' | 'faction'>[];
revivalPlayer: string;
};
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
const text = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0;
const count = (value: unknown, maximum = 20): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) <= maximum;
function exact(value: unknown, names: readonly string[]): value is Record<string, unknown> {
return record(value) && Object.keys(value).length === names.length && names.every(key => Object.hasOwn(value, key));
}
function fail(): never {
throw new HomeworldCustodyError('The revival entry lost its original returned group or committed child source.');
}
function capturedTerror(event: unknown): unknown[] | null {
if (!text(event)) return null;
try {
const value: unknown = JSON.parse(event);
if (!Array.isArray(value) || value.length < 11 ||
!(text(value[0]) || Array.isArray(value[0]) && value[0][0] === 'stack' &&
Array.isArray(value[0][1]) && value[0][1].length >= 2 && value[0][1].every(text) &&
(value[0][2] === null || text(value[0][2]))) ||
!text(value[1]) || !text(value[2]) || !count(value[3], 18) ||
!validLocation(value[2], value[3]) || !count(value[4]) || !count(value[5], value[4]) ||
!text(value[6]) || !Number.isSafeInteger(value[7]) || value[7] < 1 ||
!count(value[8], 8) || !['none', 'wormRide', 'ambassador'].includes(value[9]) ||
!(value[10] === null || text(value[10]))) return null;
return value;
} catch { return null; }
}
/** Nonrecursive historical syntax, usable by the original Ambassador history. */
export function validRevivalTerrorPacket(value: unknown): value is RevivalTerrorPacket {
return exact(value, ['event', 'owner', 'territory', 'sector', 'amount', 'elite']) &&
capturedTerror(value.event) !== null && text(value.owner) && text(value.territory) &&
count(value.sector, 18) && validLocation(value.territory, value.sector) &&
count(value.amount, 5) && value.amount > 0 && value.elite === 0;
}
function capturedParent(value: unknown): RevivalEntryParent | null {
if (!Array.isArray(value) || value.length !== 4 || value[0] !== 'revivalArrival' || !text(value[1])) return null;
const spiritual = value[2], terror = value[3];
if (spiritual !== null && (!Array.isArray(spiritual) || spiritual.length !== 8) ||
terror !== null && (!Array.isArray(terror) || terror.length !== 6)) return null;
const parent = { event: value[1],
...(spiritual === null ? {} : { spiritual: { event: spiritual[0], owner: spiritual[1], turn: spiritual[2],
phase: spiritual[3], territory: spiritual[4], sector: spiritual[5], amount: spiritual[6], revivalEvent: spiritual[7] } }),
...(terror === null ? {} : { terror: { event: terror[0], owner: terror[1], territory: terror[2],
sector: terror[3], amount: terror[4], elite: terror[5] } }),
};
return validRevivalEntryParent(parent) ? parent : null;
}
/** Validate the captured original clock/territory without asking the original
* Terror action to exist again or requiring its later victims to survive. */
export function validateRevivalTerrorPacket(
context: HistoricalContext,
packet: RevivalTerrorPacket,
destination: string,
revivalEvent: string,
depth = 0,
): void {
if (depth > 16 || !validRevivalTerrorPacket(packet) || !text(destination) || !text(revivalEvent) ||
!context.players.some(player => player.id === context.revivalPlayer && ['fremen', 'tleilaxu'].includes(player.faction))) fail();
const point = splitLocation(destination), original = capturedTerror(packet.event)!;
const parent = capturedParent(original.at(-1));
if (destination !== location(point.territory, point.sector) || !validLocation(point.territory, point.sector) ||
!parent || parent.event !== revivalEvent || original[2] !== packet.territory || original[3] !== packet.sector ||
original[7] !== context.turn || original[8] !== context.phase ||
context.players.filter(player => player.id === packet.owner && player.faction === 'moritani').length !== 1 ||
!context.players.some(player => player.id === original[1] && player.faction !== 'moritani')) fail();
if (parent.terror) validateRevivalTerrorPacket(context, parent.terror, destination, revivalEvent, depth + 1);
if (parent.spiritual) {
const source = parent.spiritual, origin = parent.terror?.territory ?? point.territory;
if ((!parent.terror && !context.players.some(player => player.id === context.revivalPlayer && player.faction === 'tleilaxu')) ||
source.turn !== context.turn || source.phase !== context.phase ||
context.players.filter(player => player.id === source.owner && player.faction === 'beneGesserit').length !== 1 ||
(source.territory !== origin && source.territory !== 'polar_sink') ||
original[1] !== source.owner || original[2] !== source.territory || original[3] !== source.sector ||
original[4] !== source.amount || original[5] !== 0 || original[6] !== 'advisor') fail();
} else if (parent.terror || original[1] !== context.revivalPlayer || original[2] !== point.territory || original[3] !== point.sector ||
original[6] !== 'homeworldRevival') fail();
}
function validSpiritual(value: unknown, event: string): value is SpiritualAdvisorSource {
return exact(value, ['event', 'owner', 'turn', 'phase', 'territory', 'sector', 'amount', 'revivalEvent']) &&
text(value.event) && text(value.owner) && Number.isSafeInteger(value.turn) && (value.turn as number) > 0 &&
value.phase === 4 && text(value.territory) && count(value.sector, 18) &&
validLocation(value.territory, value.sector) && (value.amount === 1 || value.amount === 2) && value.revivalEvent === event;
}
/** Basic public metadata shape only. Current original-return validation belongs
* to the entry caller; recorded history can use this without recursion. */
export function validRevivalEntryParent(value: unknown): value is RevivalEntryParent {
return record(value) && Object.hasOwn(value, 'event') && text(value.event) &&
Object.keys(value).every(key => ['event', 'spiritual', 'terror'].includes(key)) &&
(!Object.hasOwn(value, 'spiritual') || validSpiritual(value.spiritual, value.event)) &&
(!Object.hasOwn(value, 'terror') || validRevivalTerrorPacket(value.terror));
}
/** Conditional public signature suffix; old entries never acquire these bytes. */
export function revivalEntryParentTuple(parent: RevivalEntryParent): unknown[] {
if (!validRevivalEntryParent(parent)) fail();
const source = parent.spiritual, terror = parent.terror;
return ['revivalArrival', parent.event,
source ? [source.event, source.owner, source.turn, source.phase, source.territory, source.sector, source.amount, source.revivalEvent] : null,
terror ? [terror.event, terror.owner, terror.territory, terror.sector, terror.amount, terror.elite] : null];
}
/** The public packet is historical. Accepted Terror may already have changed
* pieces, alliances or optional powers; do not re-run a live shipment quote. */
export function validateRevivalArrivalSource(g: RevivalArrivalContext, parent: RevivalEntryParent, details: RevivalArrivalDetails): void {
if (!validRevivalEntryParent(parent)) fail();
const frame = g.homeworldRevivalReturn;
if (g.status !== 'playing' || !frame || frame.stage !== 'arrival' || frame.event !== parent.event ||
frame.turn !== g.turn || frame.phase !== g.phase || !text(frame.destination)) fail();
const continuation = g.pendingTreacheryDiscard?.continuation;
const context = !g.pendingAmbassador && continuation?.kind === 'ambassador'
? { ...g, pendingAmbassador: continuation.entry } : g;
validateHomeworldRevivalReturn(context, frame);
const original = splitLocation(frame.destination);
if (frame.destination !== location(original.territory, original.sector) ||
!gameTerritories(g).some(place => place.id === original.territory && place.sectors.includes(original.sector)) ||
!gameTerritories(g).some(place => place.id === details.territory && place.sectors.includes(details.sector)) ||
!count(details.amount) || details.amount < 1 || !count(details.elite, details.amount)) fail();
if (parent.terror) validateRevivalTerrorPacket({ ...g, revivalPlayer: frame.player }, parent.terror, frame.destination, frame.event);
if (parent.spiritual) {
const source = parent.spiritual;
const origin = parent.terror?.territory ?? original.territory;
if ((!parent.terror && !g.players.some(player => player.id === frame.player && player.faction === 'tleilaxu')) ||
g.players.filter(player => player.id === source.owner && player.faction === 'beneGesserit').length !== 1 ||
source.turn !== g.turn || source.phase !== g.phase || source.owner === frame.player ||
(source.territory !== origin && source.territory !== 'polar_sink') ||
details.entrant !== source.owner || details.cause !== 'advisor' || details.territory !== source.territory ||
details.sector !== source.sector || details.amount !== source.amount || details.elite !== 0) fail();
} else if (parent.terror) {
const packet = parent.terror;
if (details.entrant !== packet.owner || details.cause !== 'shipment' || details.territory !== packet.territory ||
details.sector !== packet.sector || details.amount !== packet.amount || details.elite !== packet.elite) fail();
} else if (details.entrant !== frame.player || details.cause !== 'homeworldRevival' ||
details.territory !== original.territory || details.sector !== original.sector ||
details.amount !== frame.quote.normal + frame.quote.elite || details.elite !== frame.quote.elite) fail();
}
export function validateRevivalTerrorEntry(g: RevivalArrivalContext, entry: TerrorEntry): void {
const parent = entry.revivalArrival;
if (parent === undefined) return;
if (entry.turn !== g.turn || entry.phase !== g.phase) fail();
validateRevivalArrivalSource(g, parent, entry);
}
export function revivalOwnsTerrorEntry(g: RevivalArrivalContext, entry: TerrorEntry): boolean {
if (entry.revivalArrival === undefined) return false;
validateRevivalTerrorEntry(g, entry);
return true;
}
+8 -4
View File
@@ -2,6 +2,7 @@ import type { Game } from './engine';
import { TERRITORIES, validLocation } from './board';
import { HomeworldCustodyError } from './homeworld-custody';
import { TERROR_STRONGHOLDS } from './moritani-terror';
import { revivalEntryParentTuple, validRevivalEntryParent } from './revival-terror-entry';
type TerrorEntry = NonNullable<Game['pendingTerrorEntry']>;
@@ -21,6 +22,7 @@ export function terrorEntrySignature(entry: TerrorEntry): string {
entry.resume,
entry.ambassadorEvent ?? null,
...(entry.nexusPlacements ? [entry.nexusPlacements] : []),
...(entry.revivalArrival === undefined ? [] : [revivalEntryParentTuple(entry.revivalArrival)]),
]);
}
@@ -28,13 +30,14 @@ export function terrorSelectionSignature(entry: TerrorEntry): string {
return JSON.stringify(['terrorSelection', entry.candidates, entry.token, entry.entrant,
entry.territory, entry.sector, entry.amount, entry.elite, entry.cause,
entry.turn, entry.phase, entry.resume, entry.ambassadorEvent ?? null,
...(entry.nexusPlacements ? [entry.nexusPlacements] : [])]);
...(entry.nexusPlacements ? [entry.nexusPlacements] : []),
...(entry.revivalArrival === undefined ? [] : [revivalEntryParentTuple(entry.revivalArrival)])]);
}
/** Older saves without a receipt remain explicit legacy state. Never invent
* their original entry count from the destination's present occupants. */
export function validateTerrorEntrySignature(entry: TerrorEntry): void {
if (entry.entrySignature === undefined && entry.candidates === undefined && entry.selectionSignature === undefined && entry.nexusPlacements === undefined && entry.stage !== 'select') return;
if (entry.entrySignature === undefined && entry.candidates === undefined && entry.selectionSignature === undefined && entry.nexusPlacements === undefined && entry.revivalArrival === undefined && entry.stage !== 'select') return;
if (
typeof entry.entrySignature !== 'string' ||
typeof entry.token !== 'string' ||
@@ -68,14 +71,15 @@ export function validateTerrorEntrySignature(entry: TerrorEntry): void {
!Number.isSafeInteger(entry.phase) ||
entry.phase < 0 ||
entry.phase > 8 ||
![
(entry.revivalArrival !== undefined && !validRevivalEntryParent(entry.revivalArrival)) ||
(![
'shipment',
'movement',
'guildTransport',
'advisor',
'wormRide',
'ambassador',
].includes(entry.cause) ||
].includes(entry.cause) && !(entry.cause === 'homeworldRevival' && entry.revivalArrival !== undefined)) ||
!['none', 'wormRide', 'ambassador'].includes(entry.resume) ||
(entry.ambassadorEvent !== undefined &&
(typeof entry.ambassadorEvent !== 'string' || !entry.ambassadorEvent)) ||
+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);
});