Allow Caladan reinforcement before Face Dance

This commit is contained in:
Quintine committed 2026-10-26 07:58:51 +11:00
1 parent 18bf5d4423
commit cab6b41c67
20 files changed
+1272 -118

No files matched your search

+6
View File
@@ -40,6 +40,12 @@ function ReinforcementChoice({ game, act, busy }: Props) {
<p>
Add one reserve force at the battle location, or leave it in reserves.
</p>
<p className="fine" aria-label="Provisional Face Dance order">
Provisional order: this optional force joins the original victorious
army before any later Face Dance. If replacement occurs, it returns with
the surviving army and counts toward the current replacement limit.
This does not create another victory or grant another Caladan reinforcement.
</p>
<label className="block space-y-1">
<span>Reinforcement destination</span>
<select
+33 -6
View File
@@ -16,6 +16,39 @@ open mode gates.
## Current checkpoint and work
**Caladan reinforcement before Face Dance — 26 October. Prototyped / Partial.**
The original winning army may now take its optional reinforcement before native
replacement. The later reveal uses the live10/11 Arrakis or3/4 Homeworld force
limit and returns the actual army once; a return to High Caladan creates no
second grant. The priority is visibly provisional. Only the exact former
ordering refusal on an existing signed offer is retired; other restrictions stay.
Three disjoint native/controlled-Homeworld/control lanes and root integration
pass **15 files,189/189**, types/lint/build and **sixteen engine/GameTable SSR
journeys** on unchanged source. Eight journeys use genuine public Basic59/
Advanced124-action setup/shipment/battle sources, all47 Treachery and16 original
Traitor/Face Dancer identities. Six separate controlled Homeworld routes prove
actual native replacement/typed return. Two controlled Caladan/Guild/Spiritual
routes repair an observed Battle-phase continuation failure: the unchanged
source binds its original Caladan arrival, owned frame and actual Guild receipt,
including automatic Polar conversion. Advisors correctly leave the Fremen
Ambassador untriggered. All four existing profiles use legal choices.
Frozen v2 report/logs:
`/home/quintine/.local/share/dune/checkpoints/20261026-caladan-face-dance-proof-v2/`;
journeys/HTML: adjacent `20261026-caladan-face-dance-runtime/`.
No saved-room/D1/server, reset/restart, deployment or recovery operation occurs.
No clicked-control/DOM/hydration or browser/mobile/deployed acceptance is claimed.
Complete rules/combinations, mode gates and final assurance remain open.
[Source, policy and child scope](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md#reinforcement-before-face-dance--26-october-2026).
Next concrete Basic function gap: an existing Ecaz ally still cannot receive the
triggered Ecaz Ambassador's living Duke acquisition. Printed sharing supports
that branch; scope direct-acquisition tenure separately from the new-alliance
turn loan, preserving occupied-Ecaz priority and the user's Ecaz-only revival.
See [the Ambassador audit](ECAZ_AMBASSADOR_EFFECTS_AUDIT.md),
`ambassadorEffectBlock`, `game/ecaz-duke-acquisition.ts` and `components/ecaz-entry.tsx`.
**Residual Poison after Cunning — 26 October. Prototyped / Partial.**
The printed pre-leader window now stays available after two completed nonleader
Cunning inspections. Resolve one actual random leader death/discard, retain an
@@ -45,12 +78,6 @@ No DOM/hydration, browser/mobile or real clicked-control acceptance is claimed.
Complete rules/combinations, final assurance and mode gates remain open.
[Source and selected priority](NEXUS_ATREIDES_RULES.md#residual-poison-and-two-inspected-commitments).
Next concrete function gap: the Caladan High victory reinforcement is still
decline-only when native Face Dance is pending. Scope its original victory
army and subsequent physical replacement through
[the reinforcement contract](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md),
`openHomeworldVictoryReturn` and `game/homeworld-victory-reinforcement.ts`.
**Box shipping-cost recovery — 26 October. Prototyped / Partial.**
Paid Box search now permits an active purchased-shipment cost in discard.
Exact cost recovery preserves its original price/creator/beneficiary and ends
@@ -2,8 +2,9 @@
Historical pre-integration audit. The subsequent
[runtime checkpoint](HOMEWORLD_VICTORY_REINFORCEMENT_RUNTIME.md) records the
implemented hook and evidence. Face Dancer overlap remains guarded pending a
ruling; the structural recommendation below does not establish its rules order.
implemented hook and evidence. The [26 October selected before-Face-Dance order](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md#reinforcement-before-face-dance--26-october-2026)
supersedes this audit’s earlier ordering fence; the remaining historical structural
recommendations do not establish further rulings.
10 September 2026. Read-only, bounded runtime audit. This identifies integration
points for the high-Caladan optional reserve force; it does not establish new
+61 -7
View File
@@ -32,15 +32,43 @@ the allied-Homeworld prohibition remain in force. Arrival at an Ambassador
stronghold uses the existing entry predicate and a source-bound continuation;
it does not create a second battle victory.
## Reinforcement before Face Dance — 26 October 2026
Under the 14 October choose-recommended authority, select the original winning
Atreides army's Caladan reinforcement before any later native Face Dance. This
uses the existing winner-cleanup position; it is a provisional composition, not
a retrieved publisher example ordering the later Homeworld card against E1.
The earlier pending priority question no longer blocks the prototype.
The owned choice still uses current high population and original surviving
forces. Giving1 from6 to5 is legal. The force joins the original victorious
army; a later reveal calculates its replacement maximum from the current army,
including that surviving reinforcement, and returns those actual counters to
Caladan once. Declining either opportunity retains its separate ordinary effect.
The original winner/leader identity and completed victory obligation stay the
same; a return that raises Caladan to High does not create another reinforcement,
battle reward or victory. Homeworld Face Dance still belongs only to its native
faction and keeps the existing physical source/return rules.
An existing signed offer carrying only the former exact ordering refusal also
uses the current legal destination quote. Other stored/destination restrictions
remain authoritative; no saved physical state or completed receipt is rewritten.
This is retirement of the old prototype fence, not a recovery certification.
## Battle-phase Guild child and Spiritual Advisors
A legal Guild Ambassador shipment caused by the Caladan arrival may also offer
Advanced Spiritual Advisors during Battle. The same saved source uses its
original chooser/frame, Guild shipment receipt and current Caladan arrival
parent; accompaniment targets that shipment territory or Polar Sink. It does
not create an ordinary Battle-phase shipment or a free generic Spiritual source.
Existing Intrusion and fighter/advisor arrival rules still apply. The source
retains its existing payload shape, allowing an already-open exact child to
continue without rewriting its parent or creating another reinforcement.
## Unresolved ordering and entry combinations
The E1 Face Dancer sequence preserves the original winner and its ordinary
battle consequences before replacing surviving forces. No retrieved official
example orders that replacement against the later Caladan Homeworld card.
The coordinator asked the user whether reinforcement precedes or follows Face
Dance; the answer remains pending. The engine displays that boundary when the
two opportunities overlap. It supports an explicit decline without choosing
the disputed placement order; this does not certify the combined interaction.
The selected Face Dance order above supersedes the earlier decline-only guard.
Storm-sector placement, Hidden Mobile Stronghold classification, and otherwise
relevant Intrusion/Terror reactions remain explicit source boundaries.
@@ -53,3 +81,29 @@ battle cleanup and persistence. The
[runtime checkpoint](HOMEWORLD_VICTORY_REINFORCEMENT_RUNTIME.md) records the
implemented flow and verification. No complete Homeworld, Advanced or expansion
release is claimed.
## Focused before-Face-Dance evidence — 26 October 2026
Frozen15-file189/189, types/lint/build and sixteen engine/GameTable SSR journeys
pass. Eight genuine public Basic59/Advanced124-action setup/shipment/battle
sources keep all47 Treachery and16 original Traitor/Face Dancer identities;
all give/leave and later reveal/decline combinations use the actual10/11 cap.
Six separately controlled native-Homeworld sources prove3/4 replacement and
Caladan9 return with existing occupation-profile bounds intact. Two controlled
Ambassador journeys prove both legal accompaniment after Intrusion and automatic
Polar conversion, preserving the original victory and leaving the Fremen token
untouched by advisors. Controlled seams are not original public acquisition proof.
[Native cases](../tests/caladan-face-dance-engine.test.ts),
[Homeworld cases](../tests/caladan-face-dance-homeworld.test.ts),
[controls](../tests/caladan-face-dance-controls.test.ts),
[Battle-phase source bounds](../tests/spiritual-advisor-victory.test.ts) and
[actual Ambassador continuations](../tests/caladan-ambassador-engine.test.ts)
separate those evidence scopes. Existing old-offer metadata has one bounded
current-legality regression, without a save/recovery assurance campaign.
Report/logs: `/home/quintine/.local/share/dune/checkpoints/20261026-caladan-face-dance-proof-v2/`;
journeys/HTML: adjacent `20261026-caladan-face-dance-runtime/`.
No saved-room/D1/server, deployment, restart or recovery operation. No clicked-
control/DOM/hydration, browser/mobile or exact-revision deployed acceptance;
complete rules/combinations and final assurance remain due.
@@ -1,5 +1,9 @@
# Caladan victory reinforcement runtime
The 10 September checkpoint below is historical. The [26 October before-Face-Dance composition](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md#reinforcement-before-face-dance--26-october-2026)
supersedes its decline-only overlap. Use [current status](CURRENT_STATUS.md)
for present preview admission and proof; this old checkpoint does not certify it.
Development checkpoint, 10 September 2026. This implements the bounded behavior
in the [source contract](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md), not complete
Homeworld or expansion support. Advanced and unfinished module starts remain
+33
View File
@@ -1,5 +1,38 @@
# Dune implementation status
## 26 October 2026 — Caladan reinforcement before Face Dance
**Prototyped / Partial.** Select optional original-winner reinforcement before
native Face Dance under the 14 October recommended-policy authority. Replacement
uses the current army, including the surviving added counter, and returns it
once. Original winner/leader identity and the completed Caladan obligation remain;
no second reward follows the return to High. Retire only the exact prior ordering
refusal on a signed pending offer through current destination legality. Other
destination, occupation, physical-source and public mode restrictions remain.
The related legal Guild/Intrusion/Spiritual path exposed a Battle-phase source
validator failure. Add only its existing Caladan-arrival/frame/Guild-receipt chain,
with unchanged source payload, and admit its automatic Polar conversion. Keep
the old source phases intact. Actual Intrusion must settle before Advanced
accompaniment; advisors do not trigger the waiting Fremen Ambassador. Updated
old tests distinguish that legal path and normal later Collection income from
the free Face Dance effect; Advanced's real source reaches turn2.
Frozen **15-file189/189**, types/lint/build and **sixteen engine/GameTable SSR
journeys** pass. Eight genuine public Basic59/Advanced124-action routes cover
all give/leave and reveal/decline choices with47 Treachery and16 original Traitor/
Face Dancer identities. Six explicitly controlled native-Homeworld routes prove
3/4 physical return/replacement; two controlled Ambassador routes prove original
Battle-phase Spiritual continuation and Polar fighter conversion. Minimal four-
profile participation and current live caps are included, without AI tuning.
Proof: `/home/quintine/.local/share/dune/checkpoints/20261026-caladan-face-dance-proof-v2/`;
journeys/HTML: adjacent `20261026-caladan-face-dance-runtime/`.
No saved-room/D1/server, reset/restart, deployment or recovery operation. No
clicked-control/DOM/hydration or browser/mobile/deployed acceptance. Complete
rules/combinations, mode gates and final assurance remain open.
[Source and selected policy](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md#reinforcement-before-face-dance--26-october-2026).
## 26 October 2026 — Residual Poison after completed Cunning
**Prototyped / Partial.** Remove the blanket pre-leader refusal after two
+17
View File
@@ -1,5 +1,22 @@
# Rule decision index
## Caladan reinforcement before Face Dance — 26 October 2026
Under the 14 October choose-recommended authority, grant the original winning
Atreides army its optional high-Caladan reinforcement before any later native
Face Dance. This selects the existing winner-cleanup position provisionally;
no retrieved publisher example resolves that cross-expansion priority. Replacement
uses the current surviving army, including the added counter, and returns the
actual army once. Original winner/leader identity, one completed grant and other
destination/source restrictions remain. Raising Caladan through the return adds
no second victory or entitlement. The exact former ordering-only refusal on a
signed pending offer is retired through current destination legality as well.
The observed legal Guild child can finish its Battle-phase Spiritual reaction
through its original Caladan arrival/frame/receipt. This repairs continuation
legality without granting an ordinary Battle-phase shipment or changing the
source payload shape.
[Source, selected order and boundaries](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md#reinforcement-before-face-dance--26-october-2026).
## Residual Poison after answered Cunning — 26 October 2026
Under the 14 October choose-recommended authority, remove the product refusal
+2 -1
View File
@@ -116,6 +116,7 @@ export type AutomaticAdvisorFlipContext = AdvisorSettlementContext & MobileBoard
turn: number;
phase: number;
homeworldRevivalReturn?: SpiritualAdvisorContext['homeworldRevivalReturn'];
homeworldVictoryReinforcement?: SpiritualAdvisorContext['homeworldVictoryReinforcement'];
pendingSpiritualAdvisor?: SpiritualAdvisorContext['pendingSpiritualAdvisor'];
pendingAmbassador?: SpiritualAdvisorContext['pendingAmbassador'];
choamMentatPending?: boolean;
@@ -490,7 +491,7 @@ export function validateAutomaticAdvisorFlip(
(source.producer === 'specialRelocation'
? record(source.relocation) && (source.relocation.kind === 'discovery'
? source.phase === 0 : source.relocation.kind === 'ambassador' && [1, 3, 5, 8].includes(source.phase))
: source.producer === 'spiritualArrival' ? [1, 3, 4, 5, 8].includes(source.phase)
: source.producer === 'spiritualArrival' ? [1, 3, 4, 5, 6, 8].includes(source.phase)
: source.phase === (source.producer === 'storm' ? 0 : source.producer === 'gamont' ? 8 :
source.producer === 'battleLosses' || source.producer === 'faceDance' ? 6 : 5)) &&
currentWindow(ctx) && source.phase === ctx.phase,
+12 -7
View File
@@ -28706,7 +28706,13 @@ function stampHomeworldVictoryStage(g: Game) {
context.caladanReinforcement.signature = homeworldVictoryObligationSignature(context,
context.caladanReinforcement.completed, frame.stage);
}
function openHomeworldVictoryReturn(g: Game, faceDancePending: boolean) {
/** Retire only the former prototype ordering fence on an existing signed offer. */
function homeworldVictoryStoredBlock(frame: HomeworldVictoryReturn) {
const blocked = frame.offer?.blocked;
return blocked === 'Caladan reinforcement and Face Dancer replacement await an ordering ruling. You may leave the reserve force at home.'
? null : blocked ?? null;
}
function openHomeworldVictoryReturn(g: Game) {
const frame = g.homeworldVictoryReinforcement;
if (!frame || frame.stage === 'complete' || frame.event !== g.lastBattleContext?.event) return false;
requireRule(frame.stage === 'waiting', 'Finish the Caladan reinforcement before resuming battle cleanup.');
@@ -28719,9 +28725,7 @@ function openHomeworldVictoryReturn(g: Game, faceDancePending: boolean) {
log(g, `${getPlayer(g, frame.player).name} received no Caladan victory reinforcement: ${quote.blocked}`);
return false;
}
const blocked = faceDancePending
? 'Caladan reinforcement and Face Dancer replacement await an ordering ruling. You may leave the reserve force at home.'
: quote.blocked;
const blocked = quote.blocked;
const offer = { population: quote.population, survivors: quote.survivors, blocked };
frame.offer = { ...offer, signature: homeworldVictoryOfferSignature(frame, offer) };
frame.stage = 'choice';
@@ -28743,7 +28747,8 @@ function decideHomeworldVictoryReturn(g: Game, p: Player, action: Action) {
finishBattle(g);
return;
}
requireRule(!frame.offer?.blocked, frame.offer?.blocked ?? 'This reinforcement needs its ordering ruling.');
const blocked = homeworldVictoryStoredBlock(frame);
requireRule(!blocked, blocked ?? 'This reinforcement is unavailable.');
requireRule((action.amount === undefined || action.amount === 1) && typeof action.destination === 'string',
'Caladan adds exactly one reserve force to the battle location.');
const quote = homeworldRule(() => quoteHomeworldVictoryReinforcementDestination(g,
@@ -28779,7 +28784,7 @@ function projectedHomeworldVictoryReturn(g: Game, player: string) {
if (frame?.stage !== 'choice') return null;
const quote = homeworldRule(() => quoteHomeworldVictoryReinforcement(g, homeworldVictoryWitness(frame)));
return { event: frame.event, player: frame.player, territory: frame.territory, amount: 1 as const,
blocked: frame.offer?.blocked ?? quote.blocked,
blocked: homeworldVictoryStoredBlock(frame) ?? quote.blocked,
destinations: player === frame.player ? quote.destinations : [],
};
}
@@ -28810,7 +28815,7 @@ function finishBattle(g: Game) {
rihani.signature = rihaniObligationSignature(g.lastBattleContext!);
}
if ((next.kind === 'faceDance' || next.kind === 'board') &&
openHomeworldVictoryReturn(g, next.kind === 'faceDance')) return;
openHomeworldVictoryReturn(g)) return;
const choamInspection = g.lastBattleContext?.nexusChoamInspection;
if (next.kind === 'board' && choamInspection?.stage === 'pending') {
choamInspection.stage = 'offer';
+6 -1
View File
@@ -4714,7 +4714,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Low Caladan prevents the phase-opening Spice Blow peek. Low Wallach IX prevents free Spiritual Advisors, while high Wallach IX allows one or two to Polar Sink after another faction’s qualifying shipment. Accompanying elsewhere still uses one. Low Ix prevents moving the mobile stronghold; low Richese prevents moving a No-Field token. Physical-force movement and ordinary paid shipments retain their separate rules.',
'Newly revived eligible placement permits Advanced BG Intrusion when entering fighters. Under the provisional SEND composition, Tleilax free forces sent from off-planet to Arrakis also earn ordinary Spiritual accompaniment; Southern on-planet placement does not. The original group stays placed through independent BG counters, and original revival income waits until arrival finishes. No fee, ordinary allowance, Guild stop or Revival-phase industry is added. Phase-four Spiritual sources retain the original return or actual Guild child. Terror-entry continuations remain unfinished.',
'After a supported Atreides victory, Caladan with at least six native reserves may add one reserve force to the battle location if an Atreides force survives there. The owned choice follows ordinary battle cleanup and offers an eligible sector or the original foreign Homeworld. This spends no spice or shipment allowance. Leaving the force in reserves is optional; a battle on Caladan itself cannot create an extra counter from reserves already there.',
'Caladan reinforcement keeps the original battle identity through refresh and supported Ambassador reactions, so the same victory cannot add a second force. Placement involving Face Dancer ordering, storm, the mobile stronghold or unresolved entry reactions remains guarded. These guards do not certify those combined rules.',
'Caladan reinforcement keeps the original battle identity through refresh and supported Ambassador reactions, so the same victory cannot add a second force. Under the provisional order, reinforcement joins the original winning army before any later Face Dance; its surviving counter counts in the current replacement limit and returns with that army. A return that raises Caladan does not create a second grant. Storm, mobile stronghold and unresolved entry restrictions remain.',
'At low population, Tupile lets CHOAM learn an opposing faction’s current spice and either its weapon count or defense count, once per faction for the entire game. CHOAM must have forces on that faction’s Homeworld, or that faction must have forces on Tupile. Either Emperor Homeworld supplies the same single entitlement. The answer is recorded privately as a historical observation and does not update when that faction’s hand or spice changes.',
'Choose weapons or defenses in the private Tupile panel at a settled action boundary. The server answers immediately without an opponent confirmation, and does not expose individual cards. Count physical held Treachery cards by their default category: Weirding Way is a weapon, Chemistry is a defense, while Worthless cards and leader substitutes count as neither. Selecting the other category later cannot renew a faction’s used entitlement.',
'Occupation removes Tupile’s low advantage. New Homeworld games record qualifying foreign presence from setup, including brief sole occupation during ordered battle losses and presence at turn boundaries. Earlier saves without that history, or histories with a Tupile qualifier whose continuing entitlement is unresolved, cannot make a new intelligence request. Already learned observations remain readable. Recorded qualification does not itself award occupied income or settle its expiry.',
@@ -4952,6 +4952,11 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/homeworld-revival-deployment-controls.test.ts',
'tests/homeworld-revival-deployment-recovery.test.ts',
'tests/homeworld-victory-reinforcement.test.ts',
'tests/caladan-face-dance-engine.test.ts',
'tests/fixture-caladan-face-dance.ts',
'tests/caladan-face-dance-homeworld.test.ts',
'tests/fixture-caladan-face-dance-homeworld.ts',
'tests/caladan-face-dance-controls.test.ts',
'tests/caladan-reinforcement-controls.test.ts',
'tests/homeworld-victory-reinforcement-recovery.test.ts',
'tests/tupile-intelligence.test.ts',
+30 -2
View File
@@ -30,10 +30,12 @@ export type SpiritualAdvisorContext = SpiritualAdvisorBoardContext & MobileBoard
turn: number;
phase: number;
homeworldRevivalReturn?: Pick<NonNullable<Game['homeworldRevivalReturn']>, 'event' | 'turn' | 'phase' | 'stage' | 'destination'>;
homeworldVictoryReinforcement?: Pick<NonNullable<Game['homeworldVictoryReinforcement']>, 'event' | 'turn' | 'phase' | 'stage'>;
pendingSpiritualAdvisor?: Pick<NonNullable<Game['pendingSpiritualAdvisor']>, 'event' | 'turn' | 'phase'> & {
decision: Pick<NonNullable<Game['pendingSpiritualAdvisor']>['decision'], 'player' | 'destination' | 'ambassadorEvent' | 'revivalEvent'>;
};
pendingAmbassador?: Pick<NonNullable<Game['pendingAmbassador']>, 'event' | 'revivalEvent' | 'turn' | 'phase' | 'stage'> | null;
pendingAmbassador?: Pick<NonNullable<Game['pendingAmbassador']>,
'event' | 'revivalEvent' | 'victoryEvent' | 'turn' | 'phase' | 'stage' | 'effect' | 'shipmentReceipt'> | null;
};
export type SpiritualAdvisorTarget = string | { territory: string; sector?: number };
@@ -120,7 +122,7 @@ export function validateSpiritualAdvisorSource(ctx: SpiritualAdvisorContext, sou
Object.keys(source).sort().join(',') === (source.phase === 4
? 'amount,event,owner,phase,revivalEvent,sector,territory,turn' : 'amount,event,owner,phase,sector,territory,turn') &&
identifier(source.event) && identifier(source.owner) && positive(source.turn) && source.turn === ctx.turn &&
source.phase === ctx.phase && [1, 3, 4, 5, 8].includes(source.phase) && ctx.status === 'playing' &&
source.phase === ctx.phase && [1, 3, 4, 5, 6, 8].includes(source.phase) && ctx.status === 'playing' &&
identifier(source.territory) && Number.isSafeInteger(source.sector) && source.sector >= 0 &&
(source.amount === 1 || source.amount === 2),
'Spiritual Advisors lost their exact current-turn public shipment source.');
@@ -128,6 +130,32 @@ export function validateSpiritualAdvisorSource(ctx: SpiritualAdvisorContext, sou
requireSpiritual(gameTerritories(ctx).some((place) =>
place.id === source.territory && place.sectors.includes(source.sector)),
'Spiritual Advisors need their current board destination and sector.');
if (source.phase === 6) {
const parent = ctx.homeworldVictoryReinforcement;
requireSpiritual(record(parent) && identifier(parent.event) && parent.turn === ctx.turn &&
parent.phase === 6 && parent.stage === 'arrival',
'Victory Spiritual Advisors need their original current-turn Caladan arrival parent.');
const frame = ctx.pendingSpiritualAdvisor, ambassador = ctx.pendingAmbassador;
const decision = frame?.decision;
requireSpiritual(record(frame) && frame.event === source.event && frame.turn === ctx.turn && frame.phase === 6 &&
record(decision) && decision.player === source.owner && identifier(decision.ambassadorEvent) &&
decision.revivalEvent === undefined && record(ambassador) && ambassador.event === decision.ambassadorEvent &&
ambassador.victoryEvent === parent.event && ambassador.revivalEvent === undefined &&
ambassador.turn === ctx.turn && ambassador.phase === 6 && ambassador.stage === 'arrival' && ambassador.effect === 'guild',
'Victory Spiritual Advisors need their original owned Guild Ambassador child.');
const receipt = ambassador.shipmentReceipt;
requireSpiritual(record(receipt) && receipt.next === 'advisor' && record(receipt.order) &&
identifier(receipt.order.territory) && Number.isSafeInteger(receipt.order.sector) &&
identifier(decision.destination),
'Victory Spiritual Advisors need their original committed Guild shipment destination.');
const destination = splitLocation(decision.destination);
requireSpiritual(decision.destination === `${destination.territory}:${destination.sector}` &&
decision.destination === `${receipt.order.territory}:${receipt.order.sector}` &&
gameTerritories(ctx).some(place => place.id === destination.territory && place.sectors.includes(destination.sector)),
'Victory Spiritual Advisors need their original board Guild shipment destination.');
requireSpiritual(source.territory === destination.territory || source.territory === 'polar_sink',
'Victory Spiritual Advisors need the original Guild shipment territory or Polar Sink.');
}
if (source.phase === 4) {
const parent = ctx.homeworldRevivalReturn;
requireSpiritual(identifier(source.revivalEvent) && record(parent) &&
+49 -71
View File
@@ -16,13 +16,19 @@ import {
} from './fixture-caladan-victory';
import { caladanAmbassadorFixture as fixture } from './fixture-caladan-ambassador';
import { fighterCount } from '../game/advisors';
function open(native = 6) {
function open(native = 6, polarAlone = false) {
const initial = fixture();
if (native !== 6) {
p(initial, 'a').reserves = native;
p(initial, 'a').forces['polar_sink:0'] -= native - 6;
}
if (polarAlone) {
// Conserved controlled initial position for the automatic Polar conversion.
p(initial, 'a').forces['hagga_basin:12'] = p(initial, 'a').forces['polar_sink:0'];
delete p(initial, 'a').forces['polar_sink:0'];
}
let g = finishVictoryCalls(
commitVictoryPlans(prepareVictoryBattle(initial, 'arrakeen')),
);
@@ -53,87 +59,59 @@ function allow(g: Game) {
return g;
}
void test('actual Caladan victory reinforces once and preserves its battle through Guild, BG and Fremen Ambassador children', () => {
void test('actual Caladan victory keeps its Guild and Advanced Spiritual child without triggering an Ambassador for advisors', () => {
let g = open();
const event = g.homeworldVictoryReinforcement!.event;
const parent = g.pendingAmbassador!.event;
assert.equal(p(g, 'a').reserves, 5);
assert.equal(p(g, 'a').forces['arrakeen:10'], 3);
g = applyAction(reload(g), 'ec', {
type: 'decision',
event: parent,
trigger: true,
beneficiary: 'h',
});
g = applyAction(reload(g), 'h', {
type: 'decision',
event: parent,
amount: 2,
territory: 'sietch_tabr',
sector: 14,
});
const event = g.homeworldVictoryReinforcement!.event, parent = g.pendingAmbassador!.event;
assert.equal(p(g, 'a').reserves, 5); assert.equal(p(g, 'a').forces['arrakeen:10'], 3);
g = applyAction(g, 'ec', { type: 'decision', event: parent, trigger: true, beneficiary: 'h' });
g = applyAction(g, 'h', { type: 'decision', event: parent, amount: 2, territory: 'sietch_tabr', sector: 14 });
assert.equal(g.decision?.kind, 'intrusion');
g = applyAction(reload(g), 'bg', { type: 'decision', accept: false });
g = applyAction(reload(g), 'bg', {
type: 'decision',
accept: true,
accompany: true,
});
// Resolve real Intrusion before legal Advanced accompaniment into this territory.
g = allow(applyAction(g, 'bg', { type: 'decision', accept: true }));
assert.equal(fighterCount(p(g, 'bg'), 'sietch_tabr'), 0);
g = applyAction(g, 'bg', { type: 'decision', accept: true, accompany: true });
assert.equal(g.response?.kind, 'advisor');
assert.equal(g.pendingSpiritualAdvisor!.source!.phase, 6);
assert.equal(g.pendingAmbassador!.victoryEvent, event);
g = allow(g);
assert.equal(g.pendingAmbassador?.entrant, 'bg');
assert.equal(g.pendingAmbassador?.victoryEvent, event);
assert.equal(g.pendingAmbassador?.revivalEvent, undefined);
assert.deepEqual(
g.homeworldVictoryReinforcement!.ambassadors!.map(
(entry) => entry.completed,
),
[true, false],
);
assert.equal(g.lastBattleContext!.caladanReinforcement!.completed, false);
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
const child = g.pendingAmbassador!.event;
g = applyAction(reload(g), 'ec', {
type: 'decision',
event: child,
trigger: true,
beneficiary: 'ec',
});
g = applyAction(reload(g), 'ec', {
type: 'decision',
event: child,
forces: { 'red_chasm:7': 1 },
territory: 'carthag',
sector: 11,
});
// Advisors are excluded from Ambassador triggers, including the waiting Fremen token.
assert.equal(g.pendingAmbassador, null);
assert.equal(g.pendingSpiritualAdvisor ?? null, null);
assert.equal(g.homeworldVictoryReinforcement!.stage, 'complete');
assert.equal(g.lastBattleContext!.caladanReinforcement!.completed, true);
assert.deepEqual(
g.homeworldVictoryReinforcement!.ambassadors!.map(
(entry) => entry.completed,
),
[true, true],
);
assert.deepEqual(g.homeworldVictoryReinforcement!.ambassadors!.map(entry => entry.completed), [true]);
assert.equal(g.ecazAmbassadors!.tokens.find(token => token.effect === 'fremen')!.zone, 'placed');
assert.equal(g.homeworldRevivalReturn, undefined);
assert.equal(p(g, 'a').reserves, 5);
assert.equal(p(g, 'a').forces['arrakeen:10'], 3);
assert.equal(p(g, 'a').reserves, 5); assert.equal(p(g, 'a').forces['arrakeen:10'], 3);
assert.equal(p(g, 'h').forces['sietch_tabr:14'], 2);
assert.equal(p(g, 'bg').forces['sietch_tabr:14'], 2);
assert.equal(p(g, 'ec').forces['carthag:11'], 1);
assert.equal(fighterCount(p(g, 'bg'), 'sietch_tabr'), 0);
assert.equal(p(g, 'ec').forces['red_chasm:7'], 1);
inventory(g); assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
assert.throws(() => applyAction(g, 'a', { type: 'decision', event, destination: 'arrakeen:10' }));
assert.throws(() => applyAction(g, 'ec', { type: 'decision', event: parent, trigger: true, beneficiary: 'h' }));
});
void test('the Battle-phase Guild child can send Spiritual Advisors to Polar and complete their automatic fighter conversion', () => {
let g = open(6, true);
const event = g.homeworldVictoryReinforcement!.event, parent = g.pendingAmbassador!.event;
g = applyAction(g, 'ec', { type: 'decision', event: parent, trigger: true, beneficiary: 'h' });
g = applyAction(g, 'h', { type: 'decision', event: parent, amount: 2, territory: 'sietch_tabr', sector: 14 });
g = applyAction(g, 'bg', { type: 'decision', accept: false });
g = applyAction(g, 'bg', { type: 'decision', accept: true, accompany: false });
assert.equal(g.pendingSpiritualAdvisor!.source!.phase, 6);
assert.equal(g.pendingSpiritualAdvisor!.source!.territory, 'polar_sink');
g = allow(g);
assert.equal(p(g, 'bg').forces['polar_sink:0'], 1);
assert.equal(fighterCount(p(g, 'bg'), 'polar_sink'), 1);
assert.equal(p(g, 'bg').forces['sietch_tabr:14'], 1);
assert.equal(fighterCount(p(g, 'bg'), 'sietch_tabr'), 1);
assert.equal(g.pendingSpiritualAdvisor ?? null, null);
assert.equal(g.pendingAmbassador, null);
assert.equal(g.homeworldVictoryReinforcement!.event, event);
assert.equal(g.homeworldVictoryReinforcement!.stage, 'complete');
assert.deepEqual(g.homeworldVictoryReinforcement!.ambassadors!.map(entry => entry.completed), [true]);
inventory(g);
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
for (const action of [
{ type: 'decision', event, destination: 'arrakeen:10' },
{
type: 'decision',
event: child,
forces: { 'red_chasm:7': 1 },
territory: 'carthag',
sector: 11,
},
])
assert.throws(() => applyAction(g, 'a', action));
});
void test('a real victory arrival cannot lose its child, source tag or independent original obligation during recovery', () => {
+121
View File
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { CaladanReinforcement } from '../components/caladan-reinforcement';
import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
import {
caladanReinforcementActions, caladanReinforcementCanAct, caladanReinforcementChoice,
} from '../game/caladan-reinforcement-options';
// Controlled public offer projections supplement genuine Caladan/Face Dance
// source tests. No victorious army, reserve, dancer identity or pending Game
// receipt is installed. The stateful component is entered only through React.
function projected(): GameView {
const game = createGame('CALADANFACEDANCECONTROLS', newPlayer('a', 'Atreides', 'atreides'), true, ['ix']);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
joinGame(game, newPlayer('t', 'Tleilaxu', 'tleilaxu'));
const view = viewGame(game, 'a'); view.status = 'playing'; view.phase = 6;
view.decision = { kind: 'caladanReinforcement', player: 'a', event: 'controlled-original-caladan-event' };
view.caladanReinforcement = {
event: 'controlled-original-caladan-event', player: 'a', territory: 'hagga_basin', amount: 1, blocked: null,
destinations: [
{ id: 'hagga_basin:10', name: 'Hagga Basin · sector 10', sector: 10, blocked: 'Storm entry is unavailable.' },
{ id: 'hagga_basin:12', name: 'Hagga Basin · sector 12', sector: 12, blocked: null },
{ id: 'hagga_basin:13', name: 'Hagga Basin · sector 13', sector: 13, blocked: null },
],
};
return view;
}
function markup(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(CaladanReinforcement, { game, busy, act() {} }));
}
function button(html: string, label: string) {
const found = html.match(new RegExp(`<button\\b[^>]*>${label}<\\/button>`))?.[0];
assert.ok(found, label); return found;
}
function order(html: string) {
assert.match(html, /aria-label="Provisional Face Dance order"/);
assert.match(html, /this optional force joins the original victorious army before any later Face Dance/);
assert.match(html, /If replacement occurs, it returns with the surviving army and counts toward the current replacement limit/);
assert.match(html, /does not create another victory or grant another Caladan reinforcement/);
}
void test('Caladan persistently explains the provisional reinforcement-before-Face-Dance order', () => {
const game = projected();
for (let render = 0; render < 3; render++) {
const html = markup(game); order(html);
assert.match(html, /Add one reserve force at the battle location, or leave it in reserves/);
assert.doesNotMatch(html, /holds the winning identity|will reveal a Face Dancer|Confirm|Acknowledge|countdown/);
}
});
void test('the original pure action helper retains the exact event destination and fixed one-force amount', () => {
const game = projected(), before = structuredClone(game);
assert.equal(caladanReinforcementCanAct(game), true);
for (const destination of ['hagga_basin:12', 'hagga_basin:13']) {
assert.deepEqual(caladanReinforcementChoice(game, destination), {
blocked: null, action: { type: 'decision', event: 'controlled-original-caladan-event', destination, amount: 1 },
});
}
assert.equal(caladanReinforcementChoice(game, 'invented_destination').action, null);
const html = markup(game); order(html);
assert.doesNotMatch(button(html, 'Add one reserve force'), /\sdisabled=/);
assert.doesNotMatch(button(html, 'Leave in reserves'), /\sdisabled=/);
assert.doesNotMatch(html, /type="number"|type="range"/);
assert.deepEqual(game, before);
});
void test('busy rendering disables both original choices while preserving the persistent order explanation', () => {
const html = markup(projected(), true); order(html);
assert.match(button(html, 'Add one reserve force'), /\sdisabled=""/);
assert.match(button(html, 'Leave in reserves'), /\sdisabled=""/);
assert.match(html, /<select\b[^>]*\sdisabled=/);
});
void test('separate destination blocks remain authoritative without removing the original decline', () => {
const game = projected();
game.caladanReinforcement!.destinations[1].blocked = 'A separate arrival rule still blocks sector 12.';
game.caladanReinforcement!.destinations[2].blocked = 'A separate arrival rule still blocks sector 13.';
assert.equal(caladanReinforcementChoice(game, 'hagga_basin:10').blocked, 'Storm entry is unavailable.');
assert.equal(caladanReinforcementChoice(game, 'hagga_basin:12').action, null);
assert.deepEqual(caladanReinforcementActions(game), [{ type: 'decision', event: 'controlled-original-caladan-event', decline: true }]);
const html = markup(game); order(html);
assert.match(html, /Storm entry is unavailable/);
assert.match(html, /A separate arrival rule still blocks sector 12/);
assert.match(html, /A separate arrival rule still blocks sector 13/);
assert.match(html, /No legal reinforcement destination/);
assert.match(button(html, 'Add one reserve force'), /\sdisabled=/);
assert.doesNotMatch(button(html, 'Leave in reserves'), /\sdisabled=/);
});
void test('an independently blocked offer is still declinable and never promises that Face Dance makes its destination legal', () => {
const game = projected(); game.caladanReinforcement!.blocked = 'This destination requires a separate arrival ruling.';
for (let render = 0; render < 2; render++) {
const html = markup(game); order(html);
assert.match(html, /This destination requires a separate arrival ruling/);
assert.match(button(html, 'Add one reserve force'), /\sdisabled=/);
assert.doesNotMatch(button(html, 'Leave in reserves'), /\sdisabled=/);
}
assert.equal(caladanReinforcementChoice(game, 'hagga_basin:12').action, null);
assert.deepEqual(caladanReinforcementActions(game), [{ type: 'decision', event: 'controlled-original-caladan-event', decline: true }]);
});
void test('foreign stale and absent offers retain their existing control boundaries', () => {
const foreign = projected(); foreign.me = 'e'; assert.equal(markup(foreign), '');
const absent = projected(); absent.caladanReinforcement = null; assert.equal(markup(absent), '');
const noDecision = projected(); noDecision.decision = null; assert.equal(markup(noDecision), '');
const stale = projected(); stale.decision = { kind: 'caladanReinforcement', player: 'a', event: 'old-event' };
assert.equal(caladanReinforcementCanAct(stale), false); assert.equal(caladanReinforcementChoice(stale, 'hagga_basin:12').action, null);
const html = markup(stale); order(html);
assert.match(button(html, 'Add one reserve force'), /\sdisabled=/);
assert.match(button(html, 'Leave in reserves'), /\sdisabled=/);
});
void test('the general owned offer explanation never reads a private Face Dancer identity or opposing hand', () => {
const game = projected();
for (const player of game.players) for (const field of ['faceDancers', 'traitors', 'hand', 'spice'])
Object.defineProperty(player, field, { get() { throw Error(`Caladan guide read private ${field}`); } });
const html = markup(game); order(html);
assert.doesNotMatch(button(html, 'Add one reserve force'), /\sdisabled=/);
});
+159
View File
@@ -0,0 +1,159 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
actCaladanFaceDance, caladanFaceDanceInventory, caladanFaceDancePhysical,
caladanFaceDancePlayer as player, caladanFaceDancePopulation, createCaladanFaceDanceFixture,
faceDanceCaladanFaceDance, finishCaladanFaceDance, reinforceCaladanFaceDance,
type CaladanFaceDanceFixture,
} from './fixture-caladan-face-dance';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => actCaladanFaceDance(game, actor, action));
assert.deepEqual(game, before);
}
function source(f: CaladanFaceDanceFixture) {
assert.equal(f.game.status, 'playing');
assert.equal(f.game.turn, f.beforeBattle.turn);
assert.equal(f.game.phase, 6);
assert.deepEqual(f.game.expansions, ['ix']);
assert.ok(f.game.homeworlds?.custody);
assert.ok(!f.game.nexusCards && !f.game.leaderSkills && !f.game.techTokens &&
!f.game.strongholdCards && !f.game.discoveryEnabled && !f.game.ecazTreachery);
assert.ok(f.game.decision?.kind === 'caladanReinforcement');
assert.equal(f.game.decision.player, f.owner);
assert.equal(f.game.decision.event, f.event);
assert.equal(f.game.response ?? null, null);
assert.equal(f.game.phaseOpening ?? null, null);
assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
assert.equal(f.game.pendingFaceDance!.player, f.dancer);
assert.equal(f.game.pendingFaceDance!.winner, f.owner);
assert.equal(f.game.pendingFaceDance!.identity, f.leader);
assert.equal(f.game.homeworldVictoryReinforcement!.stage, 'choice');
assert.equal(f.game.homeworldVictoryReinforcement!.offer!.blocked, null);
assert.equal(f.game.homeworldVictoryReinforcement!.offer!.population, 6);
assert.equal(f.game.homeworldVictoryReinforcement!.offer!.survivors, 10);
assert.equal(caladanFaceDancePopulation(f.afterSetup), 10);
assert.equal(caladanFaceDancePopulation(f.game), 6);
assert.equal(player(f.game, f.owner).forces[f.location], 10);
assert.equal(player(f.game, f.owner).forces['polar_sink:0'], 4);
assert.equal(player(f.game, f.dancer).reserves, 20);
assert.equal(f.shipments.length, 2);
assert.equal(f.shipments[0].step.actor, f.owner);
assert.equal(f.shipments[0].step.action.amount, 4);
assert.equal(f.shipments[0].step.action.territory, 'polar_sink');
assert.ok(f.shipments[0].step.action.homeworldSources);
assert.equal(f.shipments[1].step.actor, f.opponent);
assert.equal(f.shipments[1].step.action.amount, 1);
assert.equal(f.shipments[1].step.action.territory, f.territory);
assert.ok(player(f.afterSetup, f.dancer).faceDancers!.some(card => card.leader === f.leader && !card.revealed));
assert.ok(!player(f.game, f.owner).leaders.find(leader => leader.id === f.leader)!.dead);
assert.equal(f.game.lastBattleContext!.winner, f.owner);
assert.equal(f.game.lastBattleContext!.event, f.event);
assert.equal(f.sourceActionCount, f.actions.length);
assert.deepEqual(caladanFaceDanceInventory(f.game), f.originalInventory);
}
function completedGrant(f: CaladanFaceDanceFixture, game: Game, give: boolean) {
assert.equal(game.homeworldVictoryReinforcement!.event, f.event);
assert.equal(game.homeworldVictoryReinforcement!.stage, 'complete');
assert.equal(game.homeworldVictoryReinforcement!.destination, give ? f.destination : 'decline');
assert.equal(game.lastBattleContext!.caladanReinforcement!.event, f.event);
assert.equal(game.lastBattleContext!.caladanReinforcement!.completed, true);
}
for (const advanced of [false, true]) {
for (const give of [true, false]) for (const reveal of [true, false]) {
void test(`${advanced ? 'Advanced' : 'Basic'} native Caladan ${give ? 'reinforces before' : 'declines before'} Face Dance ${reveal ? 'reveal' : 'decline'}`, () => {
const f = createCaladanFaceDanceFixture({ advanced });
source(f);
let game = reinforceCaladanFaceDance(f, structuredClone(f.game), give);
const amount = give ? 11 : 10;
assert.equal(game.decision?.kind, 'faceDance');
assert.deepEqual(game.pendingFaceDance, f.originalFaceDance);
assert.equal(player(game, f.owner).forces[f.location], amount);
assert.equal(caladanFaceDancePopulation(game), give ? 5 : 6);
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice);
assert.equal(player(game, f.owner).shipped, player(f.game, f.owner).shipped);
assert.equal(player(game, f.owner).moved, player(f.game, f.owner).moved);
completedGrant(f, game, give);
rejected(game, f.owner, f.action);
rejected(game, f.dancer, { type: 'decision', reveal: true, sources: { reserves: amount + 1 }, sector: 10 });
const beforeDance = structuredClone(game);
game = faceDanceCaladanFaceDance(f, game, reveal);
assert.equal(player(game, f.owner).spice, player(beforeDance, f.owner).spice);
assert.equal(player(game, f.dancer).spice, player(beforeDance, f.dancer).spice);
// Later collection may pay the original or replacement stronghold owner.
game = finishCaladanFaceDance(f, game);
completedGrant(f, game, give);
assert.equal(game.pendingFaceDance ?? null, null);
assert.notEqual(game.decision?.kind, 'caladanReinforcement');
assert.notEqual(game.decision?.kind, 'faceDance');
assert.equal(player(game, f.owner).forces['polar_sink:0'], 4);
if (reveal) {
assert.equal(player(game, f.owner).forces[f.location] ?? 0, 0);
assert.equal(player(game, f.dancer).forces[f.location], amount);
assert.equal(player(game, f.dancer).reserves, player(beforeDance, f.dancer).reserves - amount);
assert.equal(caladanFaceDancePopulation(game), 16,
'All live winning counters, including the granted one, return to the same native Caladan pool');
assert.equal(player(game, f.owner).leaders.find(leader => leader.id === f.leader)!.dead, true);
assert.equal(player(game, f.owner).leaders.find(leader => leader.id === f.leader)!.deaths,
player(f.game, f.owner).leaders.find(leader => leader.id === f.leader)!.deaths + 1);
assert.equal(player(game, f.dancer).faceDancers!.filter(card => card.leader === f.leader && card.revealed).length, 1);
} else {
assert.equal(player(game, f.owner).forces[f.location], amount);
assert.equal(player(game, f.dancer).forces[f.location] ?? 0, 0);
assert.equal(player(game, f.dancer).reserves, 20);
assert.equal(caladanFaceDancePopulation(game), give ? 5 : 6);
assert.equal(player(game, f.owner).leaders.find(leader => leader.id === f.leader)!.dead, false);
assert.ok(player(game, f.dancer).faceDancers!.some(card => card.leader === f.leader && !card.revealed));
}
rejected(game, f.owner, f.action);
assert.deepEqual(caladanFaceDanceInventory(game), f.originalInventory);
});
}
void test(`${advanced ? 'Advanced' : 'Basic'} native Caladan pending Face Dance rejects stale, foreign and early reveal choices`, () => {
const f = createCaladanFaceDanceFixture({ advanced });
source(f);
rejected(f.game, f.dancer, f.action);
rejected(f.game, f.opponent, f.action);
rejected(f.game, f.owner, { ...f.action, event: 'stale' });
rejected(f.game, f.owner, { ...f.action, amount: 2 });
rejected(f.game, f.dancer, { type: 'decision', reveal: true, sources: { reserves: 11 }, sector: 10 });
assert.deepEqual(caladanFaceDancePhysical(f.game), f.originalPhysical);
assert.deepEqual(f.game.pendingFaceDance, f.originalFaceDance);
assert.equal(f.game.homeworldVictoryReinforcement!.stage, 'choice');
assert.deepEqual(caladanFaceDanceInventory(f.game), f.originalInventory);
});
}
void test('four minimal profiles choose the real Caladan reinforcement and native Face Dance without a second grant', () => {
for (const advanced of [false, true]) {
const f = createCaladanFaceDanceFixture({ advanced });
for (const difficulty of DIFFICULTIES) {
const ownerView = viewGame(f.game, f.owner);
ownerView.players.find(seat => seat.id === f.owner)!.bot = difficulty;
const reinforcement = botActions(ownerView)[0];
assert.deepEqual(reinforcement, f.action, `${advanced}/${difficulty} reinforces the actual surviving army`);
let game = actCaladanFaceDance(structuredClone(f.game), f.owner, reinforcement);
assert.equal(player(game, f.owner).forces[f.location], 11);
assert.equal(game.decision?.kind, 'faceDance');
const dancerView = viewGame(game, f.dancer);
dancerView.players.find(seat => seat.id === f.dancer)!.bot = difficulty;
const dance = botActions(dancerView)[0];
assert.ok(dance);
assert.equal(dance.type, 'decision');
assert.equal(dance.reveal, true);
assert.deepEqual(dance.sources, { reserves: 11 });
game = actCaladanFaceDance(game, f.dancer, dance);
game = finishCaladanFaceDance(f, game);
assert.equal(player(game, f.dancer).forces[f.location], 11);
assert.equal(caladanFaceDancePopulation(game), 16);
completedGrant(f, game, true);
assert.deepEqual(caladanFaceDanceInventory(game), f.originalInventory);
}
}
});
+151
View File
@@ -0,0 +1,151 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import {
createCaladanFaceDanceHomeworldFixture as fixture,
actCaladanFaceDanceHomeworld as act,
finishCaladanFaceDanceHomeworld as finish,
caladanFaceDanceHomeworldPlayer as player,
caladanFaceDanceHomeworldArmy as army,
caladanFaceDanceHomeworldInventory as inventory,
caladanFaceDanceHomeworldTraitors as traitors,
type CaladanFaceDanceHomeworldFixture as Fixture,
type CaladanFaceDanceHomeworldStep as Step,
} from './fixture-caladan-face-dance-homeworld';
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function source(f: Fixture) {
assert.equal(f.lobby.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.equal(f.setup.advanced, f.advanced); assert.deepEqual(f.setup.expansions, ['ix']);
assert.equal(f.afterSetup.status, 'playing'); assert.ok(f.afterSetup.homeworlds?.custody);
assert.equal(f.game.homeworldOccupationPreview, f.afterSetup.homeworldOccupationPreview);
assert.match(f.staging[1], /CONTROLLED/); assert.match(f.staging[2], /CONTROLLED/);
assert.equal(f.sourceActionCount, f.actions.length); assert.ok(f.sourceActionCount > 4);
assert.equal(player(f.controlled, f.atreides).reserves, 6); assert.equal(army(f.controlled, f.atreides), 3);
assert.equal(player(f.controlled, f.atreides).forces['polar_sink:0'], 11);
assert.equal(player(f.controlled, f.tleilaxu).reserves, 2);
assert.equal(player(f.controlled, f.tleilaxu).forces['polar_sink:0'], 18);
assert.equal(player(f.game, f.atreides).reserves, 6); assert.equal(army(f.game, f.atreides), 3);
assert.equal(player(f.game, f.atreides).tanks, 0); assert.equal(player(f.game, f.tleilaxu).tanks, 2);
assert.equal(player(f.game, f.tleilaxu).reserves, 0); assert.equal(army(f.game, f.tleilaxu), 0);
assert.equal(f.game.decision?.kind, 'caladanReinforcement'); assert.equal(f.game.battle ?? null, null);
assert.equal(f.game.pendingFaceDance!.player, f.tleilaxu); assert.equal(f.game.pendingFaceDance!.identity, f.winnerLeader);
assert.equal(player(f.game, f.atreides).leaders.find(leader => leader.id === f.winnerLeader)!.dead, false);
assert.equal(f.game.lastBattleContext!.winner, f.atreides); assert.equal(f.game.lastBattleContext!.result, 'normal');
assert.deepEqual(inventory(f.game), f.originalInventory); assert.equal(f.originalInventory.length, 47);
assert.deepEqual(traitors(f.game), f.originalTraitors);
assert.deepEqual(f.game.players.map(p => ({ player: p.id, ids: p.leaders.map(leader => leader.id).sort() })), f.originalLeaders);
}
function give(f: Fixture, actions: Step[] = []) {
source(f);
const offer = viewGame(f.game, f.atreides).caladanReinforcement!;
assert.equal(offer.blocked, null); assert.equal(offer.territory, f.territory);
assert.deepEqual(offer.destinations.map(destination => ({ id: destination.id, blocked: destination.blocked })),
[{ id: f.destination, blocked: null }]);
const game = act(f.game, f.atreides, f.reinforcementAction, actions);
assert.equal(player(game, f.atreides).reserves, 5); assert.equal(army(game, f.atreides), 4);
assert.equal(player(game, f.atreides).forces['polar_sink:0'], 11); assert.equal(player(game, f.atreides).tanks, 0);
assert.deepEqual(game.pendingFaceDance, f.originalPendingFaceDance);
assert.equal(game.decision?.kind, 'faceDance'); assert.equal(game.decision?.player, f.tleilaxu);
assert.equal(game.homeworldVictoryReinforcement!.stage, 'complete');
assert.equal(game.lastBattleContext!.caladanReinforcement!.completed, true);
assert.deepEqual(inventory(game), f.originalInventory); return game;
}
function completed(game: Game, f: Fixture, actions: Step[] = []) {
const done = finish(game, actions);
assert.equal(done.phase, 7); assert.equal(done.battle ?? null, null); assert.equal(done.decision ?? null, null);
assert.equal(done.pendingFaceDance ?? null, null); assert.equal(viewGame(done, f.atreides).caladanReinforcement, null);
assert.equal(done.homeworldVictoryReinforcement!.stage, 'complete');
assert.equal(done.lastBattleContext!.event, f.event); assert.equal(done.lastBattleContext!.winner, f.atreides);
assert.equal(done.lastBattleContext!.caladanReinforcement!.completed, true);
assert.deepEqual(inventory(done), f.originalInventory); assert.deepEqual(traitors(done), f.originalTraitors);
const history = f.game.homeworldOccupationHistory;
if (history) {
const actual = done.homeworldOccupationHistory; assert.ok(actual);
assert.deepEqual(actual.sources.slice(0, history.sources.length), history.sources);
assert.deepEqual(actual.snapshots.slice(0, history.snapshots.length), history.snapshots);
}
reject(done, f.atreides, f.reinforcementAction);
reject(done, f.tleilaxu, f.faceDanceAction); return done;
}
for (const advanced of [false, true]) {
const band = advanced ? 'Advanced' : 'Basic';
void test(`${band} controlled native Homeworld victory reinforces before Face Dance and returns all four winning visitors once`, () => {
const f = fixture({ advanced }), actions: Step[] = [];
let game = give(f, actions);
const wallets = game.players.map(p => ({ id: p.id, spice: p.spice, shipped: p.shipped, moved: p.moved }));
const beforeLeader = structuredClone(player(game, f.atreides).leaders.find(leader => leader.id === f.winnerLeader)!);
game = act(game, f.tleilaxu, f.faceDanceAction, actions);
assert.equal(player(game, f.atreides).reserves, 9); assert.equal(army(game, f.atreides), 0);
assert.equal(player(game, f.atreides).forces['polar_sink:0'], 11); assert.equal(player(game, f.atreides).tanks, 0);
assert.equal(player(game, f.tleilaxu).reserves, 4); assert.equal(player(game, f.tleilaxu).forces['polar_sink:0'], 14);
assert.equal(player(game, f.tleilaxu).tanks, 2);
const dead = player(game, f.atreides).leaders.find(leader => leader.id === f.winnerLeader)!;
assert.equal(dead.dead, true); assert.equal(dead.deaths, beforeLeader.deaths + 1);
assert.equal(player(game, f.tleilaxu).faceDancers!.find(card => card.leader === f.winnerLeader)!.revealed, true);
assert.deepEqual(game.players.map(p => ({ id: p.id, spice: p.spice, shipped: p.shipped, moved: p.moved })), wallets);
game = completed(game, f, actions);
assert.equal(actions.filter(step => step.action.reveal === true).length, 1);
assert.equal(actions.filter(step => step.action.destination === f.destination).length, 1);
assert.equal(player(game, f.atreides).reserves, 9); assert.equal(player(game, f.tleilaxu).reserves, 4);
});
void test(`${band} controlled native Homeworld victory keeps its fourth visitor when Tleilaxu declines Face Dance`, () => {
const f = fixture({ advanced }), actions: Step[] = [];
let game = give(f, actions);
const before = structuredClone(game.players), custody = structuredClone(game.homeworlds!.custody);
game = act(game, f.tleilaxu, f.faceDanceDecline, actions); game = completed(game, f, actions);
assert.deepEqual(game.players, before); assert.deepEqual(game.homeworlds!.custody, custody);
assert.equal(player(game, f.atreides).reserves, 5); assert.equal(army(game, f.atreides), 4);
assert.equal(player(game, f.tleilaxu).reserves, 0); assert.equal(player(game, f.tleilaxu).forces['polar_sink:0'], 18);
assert.equal(player(game, f.atreides).leaders.find(leader => leader.id === f.winnerLeader)!.dead, false);
assert.equal(player(game, f.tleilaxu).faceDancers!.find(card => card.leader === f.winnerLeader)!.revealed, false);
});
void test(`${band} controlled native Homeworld reinforcement decline leaves the original three-counter Face Dance cap`, () => {
const f = fixture({ advanced }); source(f);
let game = act(f.game, f.atreides, f.reinforcementDecline);
assert.equal(game.decision?.kind, 'faceDance'); assert.equal(player(game, f.atreides).reserves, 6);
assert.equal(army(game, f.atreides), 3); assert.deepEqual(game.pendingFaceDance, f.originalPendingFaceDance);
reject(game, f.tleilaxu, f.faceDanceAction);
game = act(game, f.tleilaxu, { ...f.faceDanceAction, sources: { 'polar_sink:0': 3 } });
game = completed(game, f);
assert.equal(player(game, f.atreides).reserves, 9); assert.equal(army(game, f.atreides), 0);
assert.equal(player(game, f.tleilaxu).reserves, 3); assert.equal(player(game, f.tleilaxu).forces['polar_sink:0'], 15);
assert.equal(player(game, f.tleilaxu).tanks, 2);
});
void test(`${band} controlled Homeworld reinforcement and subsequent Face Dance reject stale, foreign, quantity and live-source over-cap actions`, () => {
const f = fixture({ advanced }); source(f);
reject(f.game, f.tleilaxu, f.reinforcementAction);
reject(f.game, f.atreides, { ...f.reinforcementAction, event: `${f.event}:stale` });
reject(f.game, f.atreides, { ...f.reinforcementAction, amount: 2 });
reject(f.game, f.atreides, { ...f.reinforcementAction, destination: 'homeworld:guild' });
const game = give(f);
reject(game, f.atreides, f.reinforcementAction);
reject(game, f.atreides, f.faceDanceAction);
reject(game, f.tleilaxu, { ...f.faceDanceAction, sources: { 'polar_sink:0': 5 } });
reject(game, f.tleilaxu, { ...f.faceDanceAction, sources: { 'polar_sink:0': 19 } });
reject(game, f.tleilaxu, { ...f.faceDanceAction, sources: { 'hagga_basin:12': 1 } });
completed(act(game, f.tleilaxu, f.faceDanceAction), f);
});
}
void test('all four minimal profiles use legal Caladan and native Homeworld Face Dance controls at the controlled Advanced source', () => {
const f = fixture({ advanced: true }); source(f);
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(f.game, f.atreides); view.players.find(p => p.id === f.atreides)!.bot = profile;
const choices = botActions(view); assert.ok(choices.length);
for (const action of choices) {
const game = act(f.game, f.atreides, action);
assert.equal(game.decision?.kind, 'faceDance');
const tleilaxu = viewGame(game, f.tleilaxu); tleilaxu.players.find(p => p.id === f.tleilaxu)!.bot = profile;
const dance = botActions(tleilaxu); assert.ok(dance.length);
for (const choice of dance) completed(act(game, f.tleilaxu, choice), f);
}
}
});
@@ -0,0 +1,190 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeHomeworldGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { homeworldContext } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { positionVictoryArmy, positionVictoryInvader, victoryInventory } from './fixture-caladan-victory';
export type CaladanFaceDanceHomeworldStep = { actor: string; action: Action };
export const caladanFaceDanceHomeworldPlayer = (game: Game, actor = 'a') => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export function caladanFaceDanceHomeworldInventory(game: Game) {
victoryInventory(game);
return [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)]
.map(card => card.id).sort();
}
export function caladanFaceDanceHomeworldTraitors(game: Game) {
const ids = [...(game.traitorReserve ?? []), ...game.players.flatMap(player => [
...player.traitors, ...player.traitorChoices, ...(player.faceDancers ?? []).map(card => card.leader),
])];
assert.equal(new Set(ids).size, ids.length, 'Each original Traitor identity occupies one physical slot');
return ids.sort();
}
export function caladanFaceDanceHomeworldPhysical(game: Game) {
return {
players: game.players.map(player => ({ id: player.id, reserves: player.reserves, tanks: player.tanks,
forces: structuredClone(player.forces), spice: player.spice, shipped: player.shipped, moved: player.moved,
battleLosses: player.battleLosses })),
custody: structuredClone(game.homeworlds!.custody),
};
}
export function caladanFaceDanceHomeworldArmy(game: Game, actor: string, homeworld = 'homeworld:tleilaxu') {
const home = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
.find(home => home.id === homeworld); assert.ok(home);
return home.forces[actor]?.normal ?? 0;
}
export function actCaladanFaceDanceHomeworld(game: Game, actor: string, action: Action,
actions: CaladanFaceDanceHomeworldStep[] = []): Game {
const next = applyAction(game, actor, action); actions.push({ actor, action: structuredClone(action) }); return next;
}
/** Original preparation and aftermath only. Both optional choices remain owned. */
export function nextCaladanFaceDanceHomeworld(game: Game): CaladanFaceDanceHomeworldStep | null {
if (game.response) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
return { actor: actor.id, action: { type: 'passResponse' } };
}
if (game.phaseOpening) {
const actor = game.players.find(player => !game.phaseOpening!.passed.includes(player.id)); assert.ok(actor);
return { actor: actor.id, action: { type: 'ready' } };
}
if (game.decision?.kind === 'caladanReinforcement' || game.decision?.kind === 'faceDance') return null;
if (game.decision?.kind === 'fullPlanOffer')
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
if (game.decision?.kind === 'battleCards')
return { actor: game.decision.player, action: { type: 'decision', discard: [] } };
if (game.decision?.kind === 'battleLosses')
return { actor: game.decision.player, action: { type: 'decision', choice: 0 } };
const battle = game.battle;
if (battle?.preLeader && !battle.preLeader.closed) {
const actor = [battle.attacker, battle.defender].find(id => !battle.preLeader!.ready.includes(id)); assert.ok(actor);
return { actor, action: { type: 'battlePreparationReady', event: battle.event } };
}
if (battle?.preparation) return { actor: battle.preparation.owner, action: { type: 'declineBattlePower' } };
if (battle?.outcomeWindow && !battle.outcomeWindow.closed) {
const actor = [battle.attacker, battle.defender].find(id => !battle.outcomeWindow!.ready.includes(id)); assert.ok(actor);
return { actor, action: { type: 'battleOutcomeReady', event: battle.event } };
}
if (battle?.revealed) {
const actor = viewGame(game, battle.attacker).battle!.traitorVoters
.find(id => battle.traitorCalls[id] === undefined);
if (actor) return { actor, action: { type: 'traitorCall', call: false } };
}
return null;
}
export function advanceCaladanFaceDanceHomeworld(game: Game, until: (state: Game) => boolean,
actions: CaladanFaceDanceHomeworldStep[] = []): Game {
for (let count = 0; count < 160; count++) {
if (until(game)) return game;
const next = nextCaladanFaceDanceHomeworld(game);
assert.ok(next, `Choose the original Caladan, Face Dance or plan control at ${game.phase}/${game.decision?.kind}`);
game = actCaladanFaceDanceHomeworld(game, next.actor, next.action, actions);
}
throw Error('The original Homeworld battle did not reach its bounded continuation');
}
/** Finish only this battle; retain the original Collection opening. */
export function finishCaladanFaceDanceHomeworld(game: Game, actions: CaladanFaceDanceHomeworldStep[] = []): Game {
return advanceCaladanFaceDanceHomeworld(game,
state => state.phase === 7 && !state.battle && !state.response && !state.decision, actions);
}
/** Genuine original Ix/Homeworld audit setup, then an explicitly CONTROLLED
* conserved invasion and matching Traitor exchange. Battle and both subsequent
* printed choices are actual actions. This is not a native invasion programme. */
export function createCaladanFaceDanceHomeworldFixture(options: { advanced?: boolean } = {}) {
const advanced = options.advanced ?? false, atreides = 'a', tleilaxu = 't', observer = 'g';
const territory = 'homeworld:tleilaxu', destination = territory;
let game = createGame('CALADANFACEDANCEHOMEWORLD', newPlayer(atreides, 'Atreides', 'atreides'), advanced, ['ix']);
joinGame(game, newPlayer(observer, 'Guild', 'guild')); joinGame(game, newPlayer(tleilaxu, 'Tleilaxu', 'tleilaxu'));
const actions: CaladanFaceDanceHomeworldStep[] = [];
const act = (actor: string, action: Action) => { game = actCaladanFaceDanceHomeworld(game, actor, action, actions); };
act(atreides, { type: 'homeworlds', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game); game = initializeHomeworldGameForAudit(game);
const setup = structuredClone(game);
for (let count = 0; game.status === 'setup' && count < 100; count++) {
let next: CaladanFaceDanceHomeworldStep | null = null;
for (const player of game.players) {
const view = viewGame(game, player.id); view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0]; if (action) { next = { actor: player.id, action }; break; }
}
assert.ok(next, 'Original Ix/Homeworld setup requires a legal native action'); act(next.actor, next.action);
}
assert.equal(game.status, 'playing'); const afterSetup = structuredClone(game);
const originalInventory = caladanFaceDanceHomeworldInventory(game);
const originalTraitors = caladanFaceDanceHomeworldTraitors(game);
const originalLeaders = game.players.map(player => ({ player: player.id, ids: player.leaders.map(leader => leader.id).sort() }));
// The same labelled seam as fixture-caladan-victory: retain original cards,
// native discs/custody profile, but select a conserved later battle position.
for (const player of game.players) game.deck.push(...player.hand.splice(0));
Object.assign(game, { phase: 6, turn: 2, storm: 18, phaseOpening: null, response: null,
decision: null, active: atreides, ready: [], order: [atreides, observer, tleilaxu] });
positionVictoryArmy(game, atreides, 9, 0);
positionVictoryArmy(game, observer, 20, 0);
positionVictoryArmy(game, tleilaxu, 2, 0);
positionVictoryInvader(game, atreides, territory, 3);
const winner = caladanFaceDanceHomeworldPlayer(game, atreides), native = caladanFaceDanceHomeworldPlayer(game, tleilaxu);
const winnerDisc = [...winner.leaders].filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => b.strength - a.strength)[0];
const loserDisc = [...native.leaders].filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => a.strength - b.strength)[0]; assert.ok(winnerDisc && loserDisc && winnerDisc.strength > loserDisc.strength);
const winnerLeader = winnerDisc.id, loserLeader = loserDisc.id;
let exchange: { from: string; returned: string; selected: string } | null = null;
if (!native.faceDancers!.some(card => card.leader === winnerLeader)) {
const dancer = native.faceDancers!.find(card => !card.revealed); assert.ok(dancer);
const returned = dancer.leader, index = game.traitorReserve!.indexOf(winnerLeader);
if (index >= 0) {
game.traitorReserve![index] = returned; exchange = { from: 'traitorReserve', returned, selected: winnerLeader };
} else {
const holder = game.players.find(player => player.traitors.includes(winnerLeader)); assert.ok(holder);
holder.traitors[holder.traitors.indexOf(winnerLeader)] = returned;
exchange = { from: holder.id, returned, selected: winnerLeader };
}
dancer.leader = winnerLeader;
}
const controlled = structuredClone(game);
assert.deepEqual(caladanFaceDanceHomeworldInventory(game), originalInventory);
assert.deepEqual(caladanFaceDanceHomeworldTraitors(game), originalTraitors);
const battleAction: Action = { type: 'chooseBattle', territory, target: tleilaxu };
act(atreides, battleAction); const beforePreparation = structuredClone(game);
game = advanceCaladanFaceDanceHomeworld(game, state => !!state.battle && !state.response && !state.decision &&
!state.phaseOpening && !state.battle.preparation && state.battle.preLeader?.closed !== false, actions);
const beforePlans = structuredClone(game);
const planSteps: CaladanFaceDanceHomeworldStep[] = [
{ actor: atreides, action: { type: 'battlePlan', leader: winnerLeader, dial: 0, support: 0, weapon: null, defense: null } },
{ actor: tleilaxu, action: { type: 'battlePlan', leader: loserLeader, dial: 0, support: 0, weapon: null, defense: null } },
];
for (const step of planSteps) act(step.actor, step.action);
const revealed = structuredClone(game);
game = advanceCaladanFaceDanceHomeworld(game, state => state.decision?.kind === 'caladanReinforcement' &&
!state.response && !state.phaseOpening, actions);
assert.equal(game.lastBattleContext!.winner, atreides); assert.equal(game.lastBattleContext!.result, 'normal');
assert.equal(caladanFaceDanceHomeworldPlayer(game, atreides).reserves, 6);
assert.equal(caladanFaceDanceHomeworldArmy(game, atreides), 3);
assert.equal(caladanFaceDanceHomeworldPlayer(game, atreides).tanks, 0);
assert.equal(caladanFaceDanceHomeworldPlayer(game, tleilaxu).tanks, 2);
assert.ok(game.pendingFaceDance && game.pendingFaceDance.identity === winnerLeader);
const event = game.homeworldVictoryReinforcement!.event;
const reinforcementAction: Action = { type: 'decision', event, destination, amount: 1 };
const reinforcementDecline: Action = { type: 'decision', event, decline: true };
const faceDanceAction: Action = { type: 'decision', reveal: true, sources: { 'polar_sink:0': 4 } };
const faceDanceDecline: Action = { type: 'decision', reveal: false };
return { game, source: structuredClone(game), advanced, atreides, winner: atreides, owner: atreides, tleilaxu,
observer, actor: atreides, territory, destination, event, winnerLeader, loserLeader,
action: reinforcementAction, reinforcementAction, reinforcementDecline, faceDanceAction, faceDanceDecline,
battleAction, planSteps, lobby, setup, afterSetup, controlled, exchange, beforePreparation, beforePlans, revealed,
originalInventory, originalTraitors, originalLeaders, originalPendingFaceDance: structuredClone(game.pendingFaceDance),
originalPhysical: caladanFaceDanceHomeworldPhysical(game), originalContext: structuredClone(game.lastBattleContext),
actions, steps: actions, sourceActionCount: actions.length,
staging: [
'Actual original A/G/Tleilaxu Ix47 Homeworld audit setup and logged native setup choices; Basic/Advanced is selected before the original initializer. This unchanged fixture-caladan-victory profile does not enable occupation-preview benefits or a new public start gate.',
'CONTROLLED conserved later phase-six position: original dealt cards return to their original deck; A has Caladan6, three visitors on Tleilax and Polar11; Tleilaxu has native2 and Polar18; Guild has native20. The exact original custody quote moves A3 from Caladan9 to the visitor pool. No extra counter, card or disc is created.',
'CONTROLLED matching Face Dancer availability exchanges one original Traitor identity with its actual reserve/held slot when necessary. All original Traitors/discs stay unique; no replacement or reveal status is installed.',
'Actual chooseBattle, preparation/response passes, zero-dial unarmed native-leader plans, outcome readiness and declined Traitor votes create a normal A victory, no A ordinary casualties and Tleilaxu2 Tanks. Stop at the original Caladan choice before native Face Dance. Optional transfer and reveal remain caller-owned.',
],
};
}
export type CaladanFaceDanceHomeworldFixture = ReturnType<typeof createCaladanFaceDanceHomeworldFixture>;
+209
View File
@@ -0,0 +1,209 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { traitorDeck } from '../game/traitors';
import { homeworldContext } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { nativeShipmentSources } from '../game/homeworld-options';
import { caladanReinforcementChoice } from '../game/caladan-reinforcement-options';
import { actAdvisorFaceDanceFlip, advisorFaceDanceFlipClean, advisorFaceDanceFlipInventory,
advisorFaceDanceFlipTraitors, nextAdvisorFaceDanceFlip } from './fixture-advisor-face-dance-flip';
export type CaladanFaceDanceStep = { actor: string; action: Action };
export const actCaladanFaceDance = actAdvisorFaceDanceFlip;
export const caladanFaceDanceClean = advisorFaceDanceFlipClean;
export function caladanFaceDancePlayer(game: Game, id: string) {
const player = game.players.find(player => player.id === id);
assert.ok(player, `The original ${id} seat must remain present`);
return player;
}
export function caladanFaceDanceInventory(game: Game) {
return { treachery: advisorFaceDanceFlipInventory(game), traitors: advisorFaceDanceFlipTraitors(game) };
}
export function caladanFaceDancePopulation(game: Game) {
assert.ok(game.homeworlds?.custody);
const caladan = homeworldForceGroups(homeworldContext(game), game.homeworlds.custody)
.find(world => world.id === 'homeworld:atreides');
assert.ok(caladan);
const pool = caladan.forces.a;
assert.ok(pool);
return pool.normal + pool.elite;
}
export function caladanFaceDancePhysical(game: Game) {
return { population: caladanFaceDancePopulation(game), custody: structuredClone(game.homeworlds!.custody),
players: game.players.map(player => ({ id: player.id, spice: player.spice, reserves: player.reserves,
tanks: player.tanks, forces: structuredClone(player.forces), elites: structuredClone(player.elites),
shipped: player.shipped, moved: player.moved })) };
}
export function nextCaladanFaceDanceStep(game: Game): CaladanFaceDanceStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
if (game.decision?.kind === 'caladanReinforcement' || game.decision?.kind === 'faceDance') return null;
if (game.status === 'setup' && game.setupStage === 'traitors') {
const player = game.players.find(player => player.traitorChoices.length);
assert.ok(player);
const leader = player.traitorChoices.find(identity => identity !== 'atreides-0');
assert.ok(leader, 'A real alternative initial Traitor choice must preserve the original winning Face Dancer');
return { actor: player.id, action: { type: 'traitor', leader } };
}
}
return nextAdvisorFaceDanceFlip(game);
}
export function advanceCaladanFaceDance(game: Game, until: (state: Game) => boolean,
actions: CaladanFaceDanceStep[] = []): Game {
for (let count = 0; count < 1400; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The native Caladan/Face Dance source ended before its owned boundary');
const step = nextCaladanFaceDanceStep(game);
assert.ok(step, `Choose the real ${game.turn}/${game.phase}/${game.decision?.kind}/${game.battle?.preparation?.kind}/${game.battle?.preLeader?.closed} before continuing`);
game = actCaladanFaceDance(game, step.actor, step.action, actions);
}
throw Error('The native Caladan/Face Dance source did not reach its finite boundary');
}
/** Select only the first native Face Dancer shuffle inside the actual final
* Traitor choice. The winning identity is never installed in a played hand. */
function originalFaceDancerDeal(game: Game, step: CaladanFaceDanceStep, actions: CaladanFaceDanceStep[]) {
assert.equal(game.setupStage, 'traitors');
assert.equal(step.action.type, 'traitor');
assert.equal(game.players.filter(player => player.traitorChoices.length > 0).length, 1);
assert.ok(game.players.every(player => !player.faceDancers));
const held = new Set([...game.players.flatMap(player => player.traitors), String(step.action.leader)]);
const original = traitorDeck(game.players, true).filter(identity => !held.has(identity));
assert.ok(original.includes('atreides-0'));
const first = ['atreides-0', ...original.filter(identity => identity !== 'atreides-0').slice(0, 2)];
const order = [...first, ...original.filter(identity => !first.includes(identity))];
const current = [...original], scalars: number[] = [];
for (let index = current.length - 1; index > 0; index--) {
const from = current.indexOf(order[index]); assert.ok(from >= 0 && from <= index);
scalars.push(Math.floor((from + 0.5) * 4294967296 / (index + 1)));
[current[index], current[from]] = [current[from], current[index]];
}
assert.deepEqual(current, order);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'), native = crypto.getRandomValues.bind(crypto);
let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) {
assert.ok(cursor < scalars.length, 'Only the actual initial Face Dancer permutation uses these scalar choices');
array[0] = scalars[cursor++];
} else Reflect.apply(native, crypto, [array]);
return array;
};
try {
const next = applyAction(game, step.actor, step.action);
assert.equal(cursor, scalars.length);
actions.push(structuredClone(step));
return { game: next, original, order, scalars, step: structuredClone(step) };
} finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Public native A/T/G with original Ix deck and Homeworlds. Real Caladan4
* shipment and Guild1 invasion lead to the original ten-survivor victory.
* Only unopened deal/Spice positions and original scalar lotteries are chosen. */
export function createCaladanFaceDanceFixture(options: { advanced?: boolean } = {}) {
const advanced = options.advanced ?? false;
let game = createGame('CALADANFACEDANCE', newPlayer('a', 'Atreides', 'atreides'), advanced, ['ix']);
joinGame(game, newPlayer('t', 'Tleilaxu', 'tleilaxu'));
joinGame(game, newPlayer('g', 'Guild', 'guild'));
const actions: CaladanFaceDanceStep[] = [];
const act = (actor: string, action: Action) => { game = actCaladanFaceDance(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceCaladanFaceDance(game, until, actions); };
act(game.host, { type: 'homeworlds', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const initial = structuredClone(game);
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game), originalInventory = caladanFaceDanceInventory(game);
let faceDancerDeal: ReturnType<typeof originalFaceDancerDeal> | null = null;
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const step = nextCaladanFaceDanceStep(game);
assert.ok(step);
if (game.setupStage === 'traitors' && step.action.type === 'traitor' &&
game.players.filter(player => player.traitorChoices.length > 0).length === 1) {
faceDancerDeal = originalFaceDancerDeal(game, step, actions);
game = faceDancerDeal.game;
} else act(step.actor, step.action);
}
assert.equal(game.status, 'playing'); assert.ok(faceDancerDeal);
const afterSetup = structuredClone(game);
assert.equal(caladanFaceDancePopulation(game), 10);
assert.equal(caladanFaceDancePlayer(game, 'a').forces['arrakeen:10'], 10);
assert.ok(caladanFaceDancePlayer(game, 't').faceDancers!.some(card => card.leader === 'atreides-0' && !card.revealed));
assert.deepEqual(caladanFaceDanceInventory(game), originalInventory);
const front: Game['spiceDeck'] = [];
for (let count = 0; count < (advanced ? 2 : 1); count++) {
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
assert.ok(at >= 0); front.push(game.spiceDeck.splice(at, 1)[0]);
}
game.spiceDeck.unshift(...front);
const shipments: { before: Game; step: CaladanFaceDanceStep; after: Game }[] = [];
const ship = (actor: string, territory: string, sector: number, amount: number) => {
reach(state => state.phase === 5 && state.active === actor && caladanFaceDanceClean(state));
const homeworldSources = nativeShipmentSources(viewGame(game, actor), amount, 0);
assert.ok(homeworldSources);
const before = structuredClone(game), step: CaladanFaceDanceStep = { actor,
action: { type: 'ship', territory, sector, amount, elite: 0, allyPayment: 0, homeworldSources } };
act(actor, step.action); reach(caladanFaceDanceClean);
shipments.push({ before, step, after: structuredClone(game) });
};
ship('a', 'polar_sink', 0, 4);
assert.equal(caladanFaceDancePopulation(game), 6);
ship('g', 'arrakeen', 10, 1);
reach(state => state.phase === 6 && caladanFaceDanceClean(state));
assert.ok(game.active === 'a' || game.active === 'g');
const beforeBattle = structuredClone(game), chooser = game.active;
const choice: CaladanFaceDanceStep = { actor: chooser, action: { type: 'chooseBattle', territory: 'arrakeen',
target: chooser === 'a' ? 'g' : 'a' } };
act(choice.actor, choice.action);
reach(state => !!state.battle && (!state.battle.preLeader || state.battle.preLeader.closed) &&
!state.battle.preparation && caladanFaceDanceClean(state));
const beforePlans = structuredClone(game), winningLeader = caladanFaceDancePlayer(game, 'a').leaders.find(leader => leader.id === 'atreides-0');
const losingLeader = [...caladanFaceDancePlayer(game, 'g').leaders]
.filter(leader => !leader.dead).sort((a, b) => a.strength - b.strength)[0];
assert.ok(winningLeader && losingLeader && winningLeader.strength > losingLeader.strength);
const plans: CaladanFaceDanceStep[] = [
{ actor: 'a', action: { type: 'battlePlan', leader: winningLeader.id, dial: 0, support: 0 } },
{ actor: 'g', action: { type: 'battlePlan', leader: losingLeader.id, dial: 0, support: 0 } },
];
for (const step of plans) act(step.actor, step.action);
const sealed = structuredClone(game);
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'caladanReinforcement');
assert.equal(game.decision!.player, 'a');
assert.ok(game.pendingFaceDance);
assert.equal(game.pendingFaceDance.identity, winningLeader.id);
assert.equal(caladanFaceDancePopulation(game), 6);
assert.equal(caladanFaceDancePlayer(game, 'a').forces['arrakeen:10'], 10);
const offer = viewGame(game, 'a').caladanReinforcement;
assert.ok(offer && !offer.blocked, offer?.blocked ?? 'The original victory must allow Caladan before its pending Face Dance');
const target = offer.destinations.find(destination => destination.sector === 10 && !destination.blocked);
assert.ok(target, 'The real surviving Arrakeen army must be a reinforcement destination');
const quoted = caladanReinforcementChoice(viewGame(game, 'a'), target.id);
assert.ok(quoted.action, quoted.blocked ?? undefined);
const frame = structuredClone(game.homeworldVictoryReinforcement!);
return { game, advanced, owner: 'a', holder: 'a', dancer: 't', opponent: 'g', leader: winningLeader.id,
territory: 'arrakeen', location: 'arrakeen:10', event: frame.event, destination: target.id, action: quoted.action,
initial, setup, afterSetup, faceDancerDeal, shipments, beforeBattle, choice, beforePlans, plans, sealed,
originalInventory, originalPhysical: caladanFaceDancePhysical(game), originalFrame: frame,
originalFaceDance: structuredClone(game.pendingFaceDance), actions, sourceActionCount: actions.length,
staging: ['Only original unopened scalar/deck positions are selected. Public original Homeworld setup, private Traitor choices and native Face Dancer deal, Caladan4→Polar, Guild1→Arrakeen, real unarmed plans, votes and outcome acknowledgements produce this source. No played hand, force, wallet, leader, clock or receipt is installed.'] };
}
export type CaladanFaceDanceFixture = ReturnType<typeof createCaladanFaceDanceFixture>;
export function reinforceCaladanFaceDance(fixture: CaladanFaceDanceFixture, game = structuredClone(fixture.game), give = true,
actions: CaladanFaceDanceStep[] = []) {
const action: Action = give ? fixture.action : { type: 'decision', event: fixture.event, decline: true };
return actCaladanFaceDance(game, fixture.owner, action, actions);
}
export function faceDanceCaladanFaceDance(fixture: CaladanFaceDanceFixture, game: Game, reveal = true,
amount = caladanFaceDancePlayer(game, fixture.owner).forces[fixture.location] ?? 0,
actions: CaladanFaceDanceStep[] = []) {
assert.equal(game.decision?.kind, 'faceDance');
return actCaladanFaceDance(game, fixture.dancer, reveal
? { type: 'decision', reveal: true, sources: { reserves: amount }, sector: 10 }
: { type: 'decision', reveal: false }, actions);
}
export function finishCaladanFaceDance(fixture: CaladanFaceDanceFixture, game: Game,
actions: CaladanFaceDanceStep[] = []) {
return advanceCaladanFaceDance(game, state => !state.battle && !state.pendingFaceDance &&
state.homeworldVictoryReinforcement?.event === fixture.event &&
state.homeworldVictoryReinforcement.stage === 'complete' && caladanFaceDanceClean(state), actions);
}
+2 -2
View File
@@ -387,9 +387,9 @@ function actualFaceDance() {
g = commit(reload(g), 'guild');
assert.equal(g.lastBattleContext!.winner, 'atreides');
// This fixture tests Face Dance after the winner explicitly declines its
// new Caladan option; it does not resolve the pending placement-order ruling.
// Caladan option, keeping the original replacement-only baseline.
assert.equal(g.decision?.kind, 'caladanReinforcement');
assert.match(viewGame(g, 'atreides').caladanReinforcement!.blocked!, /ordering ruling/);
assert.equal(viewGame(g, 'atreides').caladanReinforcement!.blocked, null);
const beforeReinforcement = structuredClone(g.players);
g = applyAction(reload(g), 'atreides', {
type: 'decision',
@@ -14,6 +14,7 @@ import {
} from '../game/engine';
import { botActions } from '../game/bots';
import { caladanReinforcementActions } from '../game/caladan-reinforcement-options';
import { homeworldVictoryOfferSignature } from '../game/homeworld-victory-return';
import {
caladanVictoryFixture,
prepareVictoryBattle,
@@ -411,7 +412,7 @@ void test('a waiting Caladan victory cannot lose its real optional Shield cleanu
stable(valid);
});
void test('all profiles consume the real owned event and observers receive no destinations; Face Dance ordering stays blocked but declinable', () => {
void test('all profiles consume the real owned event and the original reinforcement precedes Face Dance', () => {
const g = victory();
const observer = viewGame(g, 'g');
assert.deepEqual(observer.caladanReinforcement!.destinations, []);
@@ -428,25 +429,11 @@ void test('all profiles consume the real owned event and observers receive no de
}
let contested = victory(caladanVictoryFixture({ tleilaxu: true }));
const offer = viewGame(contested, 'a').caladanReinforcement!;
assert.match(offer.blocked!, /Face Dancer.*ordering ruling/);
reject(
contested,
'a',
{
type: 'decision',
event: offer.event,
amount: 1,
destination: 'hagga_basin:12',
},
/ordering ruling/,
);
assert.equal(offer.blocked, null);
const before = structuredClone(p(contested, 'a'));
contested = applyAction(reload(contested), 'a', {
type: 'decision',
event: offer.event,
decline: true,
});
assert.deepEqual(p(contested, 'a'), before);
contested = applyAction(contested, 'a', reinforce(contested));
assert.equal(p(contested, 'a').reserves, before.reserves - 1);
assert.equal(p(contested, 'a').forces['hagga_basin:12'], before.forces['hagga_basin:12'] + 1);
assert.equal(contested.homeworldVictoryReinforcement!.stage, 'complete');
if (contested.decision?.kind === 'faceDance')
contested = applyAction(contested, 't', {
@@ -455,3 +442,20 @@ void test('all profiles consume the real owned event and observers receive no de
});
stable(contested);
});
void test('an existing signed ordering-only refusal can use the current legal Caladan destination', () => {
// Controlled historical metadata, not a restored-room or recovery proof.
let game = victory(caladanVictoryFixture({ tleilaxu: true }));
const frame = game.homeworldVictoryReinforcement!, offer = frame.offer!;
offer.blocked = 'Caladan reinforcement and Face Dancer replacement await an ordering ruling. You may leave the reserve force at home.';
offer.signature = homeworldVictoryOfferSignature(frame, offer);
assert.equal(viewGame(game, 'a').caladanReinforcement!.blocked, null);
const before = structuredClone(p(game, 'a'));
game = applyAction(game, 'a', reinforce(game));
assert.equal(p(game, 'a').reserves, before.reserves - 1);
assert.equal(p(game, 'a').forces['hagga_basin:12'], before.forces['hagga_basin:12'] + 1);
assert.equal(game.homeworldVictoryReinforcement!.stage, 'complete');
assert.equal(game.decision?.kind, 'faceDance');
game = applyAction(game, 't', { type: 'decision', reveal: false });
stable(game);
});
+161
View File
@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
SpiritualAdvisorError, validateSpiritualAdvisorSource,
type SpiritualAdvisorContext, type SpiritualAdvisorSource,
} from '../game/spiritual-advisor';
/** Controlled public parent/source context only. Native Caladan, consumed
* Ambassador token, exact physical shipment and BG resources remain the engine
* producer's duty; no played Game or arrival is manufactured by these quotes. */
function context(): SpiritualAdvisorContext {
return {
advanced: true, status: 'playing', turn: 3, phase: 6,
players: [
{ id: 'bg', faction: 'beneGesserit', forces: {} },
{ id: 'h', faction: 'harkonnen', forces: { 'sietch_tabr:14': 2 } },
{ id: 'a', faction: 'atreides', forces: { 'arrakeen:10': 3 } },
{ id: 'ec', faction: 'ecaz', forces: {} },
],
homeworldVictoryReinforcement: { event: 'original-caladan-victory', turn: 3, phase: 6, stage: 'arrival' },
pendingSpiritualAdvisor: {
event: 'original-spiritual-choice', turn: 3, phase: 6,
decision: { player: 'bg', destination: 'sietch_tabr:14', ambassadorEvent: 'original-guild-child' },
},
pendingAmbassador: {
event: 'original-guild-child', victoryEvent: 'original-caladan-victory', effect: 'guild',
turn: 3, phase: 6, stage: 'arrival',
shipmentReceipt: {
next: 'advisor',
order: { player: 'h', territory: 'sietch_tabr', sector: 14, amount: 2, elite: 0,
advisors: false, cost: 0, allyPayment: 0 },
},
},
};
}
function source(polar = false): SpiritualAdvisorSource {
return { event: 'original-spiritual-choice', owner: 'bg', turn: 3, phase: 6,
territory: polar ? 'polar_sink' : 'sietch_tabr', sector: polar ? 0 : 14, amount: 1 };
}
function rejected(ctx: SpiritualAdvisorContext, receipt: SpiritualAdvisorSource) {
const before = structuredClone({ ctx, receipt });
assert.throws(() => validateSpiritualAdvisorSource(ctx, receipt), error =>
error instanceof SpiritualAdvisorError && error.name === 'SpiritualAdvisorError');
assert.deepEqual({ ctx, receipt }, before);
}
void test('phase-six Spiritual accompaniment and Polar sources retain the original Caladan Guild chain with exactly seven fields', () => {
for (const polar of [false, true]) for (const amount of [1, 2]) {
const ctx = context(), receipt = { ...source(polar), amount }, before = structuredClone({ ctx, receipt });
assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, receipt));
assert.deepEqual({ ctx, receipt }, before);
assert.deepEqual(Object.keys(receipt).sort(), ['amount', 'event', 'owner', 'phase', 'sector', 'territory', 'turn']);
}
});
void test('missing stale wrong-clock or completed Caladan parents reject accompanying and Polar packets equally', () => {
const changes: ((ctx: SpiritualAdvisorContext) => void)[] = [
ctx => { delete ctx.homeworldVictoryReinforcement; },
ctx => { ctx.homeworldVictoryReinforcement!.event = 'another-victory'; },
ctx => { ctx.homeworldVictoryReinforcement!.turn = 2; },
ctx => { Object.assign(ctx.homeworldVictoryReinforcement!, { phase: 5 }); },
...(['waiting', 'choice', 'complete'] as const).map(stage =>
(ctx: SpiritualAdvisorContext) => { ctx.homeworldVictoryReinforcement!.stage = stage; }),
];
for (const change of changes) for (const polar of [false, true]) {
const ctx = context(); change(ctx); rejected(ctx, source(polar));
}
});
void test('phase-six packets require the same owned Spiritual frame and original Guild child before choosing either target', () => {
const changes: ((ctx: SpiritualAdvisorContext) => void)[] = [
ctx => { delete ctx.pendingSpiritualAdvisor; },
ctx => { ctx.pendingSpiritualAdvisor!.event = 'another-choice'; },
ctx => { ctx.pendingSpiritualAdvisor!.turn = 2; },
ctx => { ctx.pendingSpiritualAdvisor!.phase = 5; },
ctx => { ctx.pendingSpiritualAdvisor!.decision.player = 'h'; },
ctx => { delete ctx.pendingSpiritualAdvisor!.decision.ambassadorEvent; },
ctx => { ctx.pendingSpiritualAdvisor!.decision.ambassadorEvent = 'another-child'; },
ctx => { ctx.pendingSpiritualAdvisor!.decision.revivalEvent = 'unrelated-revival'; },
ctx => { delete ctx.pendingAmbassador; },
ctx => { ctx.pendingAmbassador!.event = 'another-child'; },
ctx => { delete ctx.pendingAmbassador!.victoryEvent; },
ctx => { ctx.pendingAmbassador!.victoryEvent = 'another-victory'; },
ctx => { ctx.pendingAmbassador!.revivalEvent = 'unrelated-revival'; },
ctx => { ctx.pendingAmbassador!.turn = 2; },
ctx => { ctx.pendingAmbassador!.phase = 5; },
ctx => { ctx.pendingAmbassador!.stage = 'offer'; },
ctx => { ctx.pendingAmbassador!.effect = 'fremen'; },
ctx => { delete ctx.pendingAmbassador!.effect; },
];
for (const change of changes) for (const polar of [false, true]) {
const ctx = context(); change(ctx); rejected(ctx, source(polar));
}
});
void test('the captured chooser destination must be canonical current board and equal the committed Guild receipt', () => {
const changes: ((ctx: SpiritualAdvisorContext) => void)[] = [
ctx => { delete ctx.pendingAmbassador!.shipmentReceipt; },
ctx => { ctx.pendingAmbassador!.shipmentReceipt!.next = 'finish'; },
ctx => { ctx.pendingAmbassador!.shipmentReceipt!.order.territory = 'carthag'; },
ctx => { ctx.pendingAmbassador!.shipmentReceipt!.order.sector = 0; },
ctx => { delete ctx.pendingSpiritualAdvisor!.decision.destination; },
...['sietch_tabr:014', 'sietch_tabr:0', 'homeworld:atreides', 'missing:14', 'carthag:11'].map(destination =>
(ctx: SpiritualAdvisorContext) => { ctx.pendingSpiritualAdvisor!.decision.destination = destination; }),
];
for (const change of changes) for (const polar of [false, true]) {
const ctx = context(); change(ctx); rejected(ctx, source(polar));
}
const ctx = context();
for (const receipt of [
{ ...source(), territory: 'arrakeen', sector: 10 },
{ ...source(), territory: 'carthag', sector: 11 },
{ ...source(), sector: 0 },
{ ...source(true), sector: 1 },
]) rejected(ctx, receipt);
});
void test('phase-six source metadata keeps current native BG identity clock and strict legacy keys', () => {
const ctx = context();
for (const patch of [
{ event: 'another-choice' }, { owner: 'h' }, { turn: 2 }, { phase: 5 },
{ amount: 0 }, { amount: 3 }, { amount: 1.5 },
{ revivalEvent: 'not-a-revival-source' }, { victoryEvent: 'not-a-source-field' }, { card: 'private-cost' },
]) rejected(ctx, { ...source(), ...patch } as SpiritualAdvisorSource);
for (const change of [
(g: SpiritualAdvisorContext) => { g.status = 'setup'; },
(g: SpiritualAdvisorContext) => { g.phase = 5; },
(g: SpiritualAdvisorContext) => { g.turn = 4; },
(g: SpiritualAdvisorContext) => { g.players[0].faction = 'atreides'; },
]) { const changed = context(); change(changed); rejected(changed, source()); }
});
void test('ordinary seven-field and revival eight-field sources retain their existing shapes and phase boundaries', () => {
for (const phase of [1, 3, 5, 8]) {
const ctx = context(); ctx.phase = phase;
for (const field of ['homeworldVictoryReinforcement', 'pendingAmbassador', 'pendingSpiritualAdvisor'])
Object.defineProperty(ctx, field, { get() { throw Error(`Ordinary source read ${field}`); } });
assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, { ...source(true), phase }));
}
const ctx = context(); ctx.phase = 4;
delete ctx.pendingAmbassador; delete ctx.pendingSpiritualAdvisor;
ctx.homeworldRevivalReturn = { event: 'original-revival', turn: 3, phase: 4, stage: 'arrival', destination: 'arrakeen:10' };
Object.defineProperty(ctx, 'homeworldVictoryReinforcement', { get() { throw Error('Revival read a victory parent'); } });
assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, {
...source(true), phase: 4, revivalEvent: 'original-revival',
}));
for (const phase of [0, 2, 7]) {
const changed = context(); changed.phase = phase;
rejected(changed, { ...source(true), phase });
}
});
void test('victory source binding reads no wallets hands private source selection or future controls', () => {
const ctx = context();
for (const player of ctx.players) for (const field of ['hand', 'spice', 'leaders', 'reserves', 'traitors'])
Object.defineProperty(player, field, { get() { throw Error(`Victory Spiritual source read ${field}`); } });
for (const field of ['pendingShipment', 'stormCard', 'stormForecast', 'homeworlds', 'battle'])
Object.defineProperty(ctx, field, { get() { throw Error(`Victory Spiritual source read ${field}`); } });
assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, source()));
assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, source(true)));
});