Route Emperor Face Dance returns to typed native reserves

This commit is contained in:
Quintine committed 2026-10-26 09:19:54 +11:00
1 parent 4ee51917bc
commit 2c3dde51d4
14 files changed
+1346 -98

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
'use client';
import { fighterCount } from '@/game/advisors';
import { validLocation } from '@/game/board';
import type { Action, GameView } from '@/game/engine';
import { Button } from './ui/button';
export interface FaceDanceActionsProps {
game: GameView;
sources: Record<string, number>;
sector: number;
busy: boolean;
act: (action: Action) => void;
}
/** The existing public survivor count and the caller's own selectable forces. */
export function faceDanceForceChoices(game: GameView) {
const d = game.decision;
const me = game.players.find((player) => player.id === game.me);
const world =
d?.kind === 'faceDance' && d.player === game.me
? game.combatLocations?.find(
(place) => place.id === d.territory && place.kind === 'homeworld',
)
: undefined;
const foreign = d?.kind === 'faceDance' ? world?.forces?.[d.winner] : undefined;
const maximum =
d?.kind === 'faceDance' && d.player === game.me && me
? world
? (foreign?.normal ?? 0) + (foreign?.elite ?? 0)
: d.winners
? game.players.reduce(
(sum, player) =>
sum +
(d.winners!.includes(player.id)
? fighterCount(player, d.territory)
: 0),
0,
)
: fighterCount(
game.players.find((player) => player.id === d.winner)!,
d.territory,
)
: 0;
const availableSources: Record<string, number> =
d?.kind === 'faceDance' && d.player === game.me && me
? world
? me.forces
: { reserves: me.reserves, ...me.forces }
: {};
return { world, maximum, availableSources };
}
/** Hook-free so the actual guarded callbacks can be exercised directly. */
export function FaceDanceActions({
game,
sources,
sector,
busy,
act,
}: FaceDanceActionsProps) {
const d = game.decision;
const owner = d?.kind === 'faceDance' && d.player === game.me;
const me = owner ? game.players.find((player) => player.id === game.me) : undefined;
const held = me?.faceDancers?.some(
(card) => !card.revealed && d?.kind === 'faceDance' && card.leader === d.identity,
);
const { world, maximum, availableSources } = faceDanceForceChoices(game);
const selected =
sources && typeof sources === 'object' && !Array.isArray(sources)
? Object.entries(sources)
: null;
const validCounts =
selected !== null &&
selected.every(
([key, count]) =>
Object.hasOwn(availableSources, key) &&
Number.isSafeInteger(count) &&
count >= 0 &&
count <= availableSources[key],
);
const total = validCounts ? selected.reduce((sum, [, count]) => sum + count, 0) : null;
const validSources = validCounts &&
Number.isSafeInteger(total) &&
total! <= maximum;
const revealDisabled =
busy ||
!owner ||
!me ||
!held ||
!!(d?.kind === 'faceDance' && d.blocked) ||
!validSources ||
(!world &&
!(d?.kind === 'faceDance' && Number.isSafeInteger(sector) && validLocation(d.territory, sector)));
const declineDisabled = busy || !owner || !me;
return (
<>
<Button
data-testid="face-dance-reveal"
disabled={revealDisabled}
onClick={() => {
if (!revealDisabled)
act({
type: 'decision',
reveal: true,
sources,
...(world ? {} : { sector }),
});
}}
>
Reveal Face Dancer
</Button>
<Button
data-testid="face-dance-decline"
variant="outline"
disabled={declineDisabled}
onClick={() => {
if (!declineDisabled) act({ type: 'decision', reveal: false });
}}
>
Decline Face Dancer
</Button>
</>
);
}
+30 -58
View File
@@ -2,37 +2,31 @@
import { useState } from 'react';
import { territory, splitLocation } from '@/game/board';
import type { Action, GameView } from '@/game/engine';
import { fighterCount } from '@/game/advisors';
import { Button } from './ui/button';
import { FaceDanceActions, faceDanceForceChoices } from './face-dance-actions';
import { Input } from './ui/input';
export function FaceDanceDecision({
type Props = { game: GameView; act: (action: Action) => void; busy: boolean };
export function FaceDanceDecision(props: Props) {
const { game } = props;
const d = game.decision;
if (
d?.kind !== 'faceDance' ||
d.player !== game.me ||
!game.players.some((player) => player.id === game.me)
)
return null;
return <FaceDanceChoice key={`${d.territory}:${d.winner}:${d.identity}`} {...props} />;
}
function FaceDanceChoice({
game: g,
act,
busy,
}: {
game: GameView;
act: (a: Action) => void;
busy: boolean;
}) {
}: Props) {
const d = g.decision;
const me = g.players.find((p) => p.id === g.me)!;
const world =
d?.kind === 'faceDance'
? g.combatLocations?.find(
(place) => place.id === d.territory && place.kind === 'homeworld',
)
: undefined;
const foreign =
d?.kind === 'faceDance' ? world?.forces?.[d.winner] : undefined;
const maximum =
d?.kind === 'faceDance'
? world
? (foreign?.normal ?? 0) + (foreign?.elite ?? 0)
: d.winners
? g.players.reduce((sum, player) => sum +
(d.winners!.includes(player.id) ? fighterCount(player, d.territory) : 0), 0)
: fighterCount(g.players.find((player) => player.id === d.winner)!, d.territory)
: 0;
const { world, maximum, availableSources } = faceDanceForceChoices(g);
const [sources, setSources] = useState<Record<string, number>>(
world
? {}
@@ -48,9 +42,9 @@ export function FaceDanceDecision({
(c) => !c.revealed && c.leader === d.identity,
);
const total = Object.values(sources).reduce((sum, n) => sum + n, 0);
const availableSources = world
? me.forces
: { reserves: me.reserves, ...me.forces };
const emperorReturn = g.advanced && !!g.homeworlds?.worlds &&
g.players.some((player) => player.faction === 'emperor' &&
(player.id === d.winner || d.winners?.includes(player.id)));
return (
<>
<h2>Face Dancer opportunity</h2>
@@ -64,6 +58,13 @@ export function FaceDanceDecision({
{d.blocked}
</output>
)}
{emperorReturn && (
<p className="fine" aria-label="Provisional Emperor Face Dance return">
Provisional return: the Emperor’s normal forces return to Kaitain;
Sardaukar return to Salusa Secundus. This is a reserve return with no
revival fee or income, new battle or additional battle reward.
</p>
)}
{held ? (
<>
<p>
@@ -115,40 +116,11 @@ export function FaceDanceDecision({
<p>
{total} / {maximum} replacement forces selected
</p>
<Button
disabled={
busy ||
!!d.blocked ||
total > maximum ||
Object.entries(sources).some(
([key, n]) =>
n < 0 ||
!Number.isSafeInteger(n) ||
n > (availableSources[key] ?? 0),
)
}
onClick={() =>
act({
type: 'decision',
reveal: true,
sources,
...(world ? {} : { sector }),
})
}
>
Reveal Face Dancer
</Button>
</>
) : (
<p>You have no unrevealed Face Dancer matching the winning leader.</p>
)}
<Button
variant="outline"
disabled={busy}
onClick={() => act({ type: 'decision', reveal: false })}
>
Decline Face Dancer
</Button>
<FaceDanceActions game={g} sources={sources} sector={sector} busy={busy} act={act} />
</>
);
}
+37 -8
View File
@@ -16,6 +16,34 @@ open mode gates.
## Current checkpoint and work
**Typed Emperor Face Dance return — 26 October. Prototyped / Partial.**
Advanced/Homeworld returns from Arrakis and visitor battles now use existing
native reserve quotes: normal survivors to Kaitain, Sardaukar to Salusa. This
selected composition is visibly provisional. Tleilaxu Arrakis replacements
withdraw actual native stocks; at Tleilax only external replacements add forces.
Occupied-home immunity, original victory/rewards, casualties, one leader death
and aftermath remain. Reveal/decline controls use shared projected choices and
guard their actual callbacks. No revival/income, saved field or mode gate opens.
Three disjoint native/Homeworld/control lanes and root integration pass
**13 files,118/118**, types/lint/build and **sixteen engine/GameTable SSR/actual
reveal-or-decline callback paths** on unchanged source. Nine genuine public
Advanced E/T/A/Ix/Homeworld routes earn mixed, normal-only and Sardaukar-only
armies through63 setup/shipment/battle actions, then take four continuation
actions through Collection. Seven explicitly controlled conserved visitor routes
use actual battle and return/decline; six finish Collection, while decline stops
before the legitimate next battle. Each retains all47 Treachery and16 Traitor/
Face Dancer cards. Feedback corrected test expansion/sector/type fixtures and
an unused import; no application sector guard was relaxed.
Frozen v4 report/logs:
`/home/quintine/.local/share/dune/checkpoints/20261026-emperor-face-dance-return-proof-v4/`;
journeys/HTML: adjacent `20261026-emperor-face-dance-return-runtime/`.
No saved-room/D1/server, reset/restart, deployment or recovery operation occurs.
SSR/callbacks do not prove DOM/hydration, browser/mobile/deployed acceptance or
complete Homeworlds/modes/combinations. Final assurance remains open.
[Source, physical contract and evidence](HOMEWORLD_REPLACEMENT_RULES.md#typed-emperor-face-dance-return--26-october-2026).
**Remaining structured Truthtrance authoring — 26 October. Prototyped / Partial.**
Battle-plan and pure-shipment forms now expose their full existing grouped
inputs. Add/wrap/nest, switch AND/OR, remove/collapse and whole-field validation
@@ -46,14 +74,15 @@ deployed acceptance. Full Truthtrance, modes/combinations and final assurance
remain open. [Battle controls](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md#grouped-battle-plan-authoring--26-october-2026);
[shipment controls](COMPOUND_SHIPMENT_PROMISES.md#full-pure-shipment-authoring--26-october-2026).
Next concrete independent rule gap: Advanced/Homeworld Face Dance blocks an
Emperor winner's army return. Scope exact typed normal→Kaitain and Sardaukar→
Salusa return through the existing native reserve helpers, separately from
revival/payment/income. The live guard and both Arrakis/Homeworld return paths
need integration; removing its message alone misroutes elites. See
`faceDanceReturnBlock`, `addNativeReserves`, `game/homeworld-native-reserves.ts`
and [the replacement contract](HOMEWORLD_REPLACEMENT_RULES.md). This next scope
does not establish Basic completion or authorize a custody/recovery campaign.
Next scoped interaction: Planetologist's ordinary extra range for Richese's own
concealed No-Field marker. The live combined-movement fence still rejects it;
the printed marker-as-one-force and ordinary-range rules support a narrow
composition, not a located joint FAQ. Start with marker-only ordinary movement,
existing token/history and native trained-leader setup. Keep gather, flight,
Ornithopter, Guild and Homeworld routes outside that first slice. See
[No-Field source](RICHESE_NO_FIELD_RULES.md) and
[Planetologist contract](PLANETOLOGIST_RULES.md). This scope does not establish
Basic completion or authorize a combination/recovery campaign.
**Truthtrance compound current-fact authoring — 26 October. Prototyped / Partial.**
The form now exposes two-through-eight-term AND/OR groups and nesting, with
+62 -10
View File
@@ -1,8 +1,59 @@
# Homeworld replacement: source audit and adapter contract
Audited 9 September 2026. Read alongside [Homeworld rules](HOMEWORLD_RULES.md).
This document authorizes no runtime activation and adds no occupation or Ghola
ruling. No production files changed in this audit.
Original audit 9 September 2026; typed Emperor return follow-up 26 October.
Read alongside [Homeworld rules](HOMEWORLD_RULES.md). The historical source
audit below adds no occupation or Ghola ruling; current execution is recorded
separately here and does not certify full Homeworlds or expansion modes.
## Typed Emperor Face Dance return — 26 October 2026
The Advanced/Homeworld Emperor assignment fence is removed. Both Arrakis and
visitor-Homeworld returns now use the existing native reserve quotes: living
normal survivors return to Kaitain and Sardaukar to Salusa Secundus. Their
actual types, battle casualties and original reserve pools remain distinct.
This destination composition is explicitly provisional under the 14 October
choose-recommended authority, not an express publisher Face Dance assignment.
The authorized supplied rulebook's physical38,A.1–3 retains the original victory,
sends the leader to Tanks without another bounty and returns surviving
non-advisor forces to reserves. E3p10's initial/revival split supports the
selected routing by composition; it does not prescribe historical-origin return.
`addNativeReserves` commits `quoteNativeReserveDeposit` for Arrakis or
`quoteHomeworldReserveReturn` for the exact visitor pool. Arrakis counters leave
their actual sectors before deposit; a Homeworld return withdraws its exact
typed visitors and deposits to the appropriate native stocks. This is a live
reserve return, with no revival cost, allowance or income. Other winning-side
return targets and original victory/rewards retain their existing contract.
Tleilax's occupied-face protection for its occupier/ally remains authoritative.
Tleilaxu Arrakis replacements withdraw the selected actual native reserves;
board-source counters move from their original positions. At Tleilax, already
present native reserve selections remain a physical no-op and only selected
external counters add to that pool. Unselected native/third-party forces stay.
Zero replacements and decline remain usable. Leader death, revelation/cycling,
advisor follow-ups and original aftermath are not replayed as another battle.
The owner has persistent provisional routing guidance and the same reveal/
decline action shape. Shared projected survivor/source choices drive the
selectors and guarded actual callbacks. No new confirmation, saved field,
rule-module admission, provider/deployment action or revival permission is added.
**Frozen evidence:** v4 passes13 files,118/118, types/lint/build and sixteen
engine/GameTable SSR/actual action-callback paths. Nine genuine public Advanced
E/T/A/Ix/Homeworld routes earn mixed/normal/Sardaukar armies through63 setup/
shipment/battle actions and use four continuation actions through Collection.
Seven explicitly controlled conserved visitor routes retain their disclosed
position/card exchange; six return paths reach Collection and decline stops
before a legitimate next battle. All47 Treachery and16 Traitor/Face Dancer cards
remain in each path. Four minimal profiles and invalid/stale/source/ownership
cases have focused test coverage. Feedback corrected fixture expansion, sector
and typing errors plus an unused import; the sector guard remains.
Report/logs: private `20261026-emperor-face-dance-return-proof-v4/` under
`/home/quintine/.local/share/dune/checkpoints/`; journeys/HTML: adjacent
`20261026-emperor-face-dance-return-runtime/`. No saved-room/D1/server, reset/
restart, deployment or recovery operation. SSR/callbacks do not certify DOM/
hydration, browser/mobile/deployed acceptance, complete combinations or modes.
## Sources and explicit rules
@@ -32,8 +83,9 @@ Sardaukar and revived Sardaukar go to Salusa, revived normal forces to Kaitain.
An expressly permitted transfer can subsequently mix the types. Those placement
sentences do not expressly assign forces returned by Face Dance. The
[designer's expansion page](https://futurepastimes.com/dune-ecaz-moritani)
supplies no additional replacement ruling. No community compilation was adopted
as an erratum.
supplies no additional replacement ruling. That original audit adopted no
community compilation; the 1 October user authorization of the identified local
Advanced rulebook supersedes its old source restriction, as applied above.
## Physical interpretation and remaining assignment boundary
@@ -52,11 +104,11 @@ include Tleilaxu garrisons on other foreign Homeworlds.
**Returning winner:** preserve every normal/special identity and return only
that winning army, not third parties. A single native Homeworld has an
unambiguous destination. Advanced Emperor's allocation remains unverified:
neither arbitrary player choice, universal Salusa routing for stars, nor
restoration to a historical shipment origin is expressly prescribed for this
effect. Keep this assignment boundary visible rather than presenting a revival
helper's default as a confirmed Face Dance rule.
unambiguous destination. Advanced Emperor's allocation now uses the visible
26 October provisional normal→Kaitain/star→Salusa composition above. The source
still does not expressly prescribe a destination for this effect; this is not
arbitrary player choice, restoration to a historical shipment origin or a claim
that the revival helper proves a publisher Face Dance ruling.
**Ixian adapter:** treating substitution as ordinary battle cleanup at a
Homeworld is an application of existing combat mechanics, not a new shipment
+30
View File
@@ -1,5 +1,35 @@
# Dune implementation status
## 26 October 2026 — Typed Emperor Face Dance reserve return
**Prototyped / Partial.** Remove the Advanced/Homeworld assignment fence and
route actual normal survivors to Kaitain and Sardaukar to Salusa through native
reserve quotes. This selected composition is explicit and provisional, not a
publisher Face Dance destination ruling. Arrakis replacements withdraw real
Tleilaxu native stock; at Tleilax selected native reserves stay put and only
external counters add. Original victory, rewards, casualties, leader death and
aftermath remain, with existing occupied-home immunity. Persistent routing
guidance and shared projected source/survivor choices feed guarded actual
reveal/decline callbacks. No revival fee/income, action, saved field or mode opens.
Frozen **13-file118/118**, types/lint/build and **sixteen engine/GameTable SSR/
actual callback paths** pass. Nine genuine public Advanced E/T/A/Ix/Homeworld
routes use63 setup/shipment/battle actions, mixed/normal/Sardaukar survivors and
four continuation actions through Collection. Seven labelled conserved visitor
routes use actual battle and return/decline; six reach Collection, decline stops
before another legal battle. Each retains47 Treachery and16 Traitor/Face Dancer
cards. Native and visitor tests also cover zero/native/external selections,
rejections and four minimal profiles. Feedback corrected fixture expansion,
sector and TypeScript issues plus an unused engine import. The legal sector
check remains; earlier failed reports are retained.
Proof: `/home/quintine/.local/share/dune/checkpoints/20261026-emperor-face-dance-return-proof-v4/`;
journeys/HTML: adjacent `20261026-emperor-face-dance-return-runtime/`.
No saved-room/D1/server, reset/restart, deployment or recovery operation.
SSR/callbacks do not prove DOM/hydration, browser/mobile/deployed acceptance or
complete Homeworlds/modes/combinations. Final assurance remains due.
[Source and contract](HOMEWORLD_REPLACEMENT_RULES.md#typed-emperor-face-dance-return--26-october-2026).
## 26 October 2026 — Grouped battle-plan and full pure shipment authoring
**Prototyped / Partial.** Deliver the remaining structured Truthtrance form
+12
View File
@@ -1,5 +1,17 @@
# Rule decision index
## Typed Emperor Face Dance reserve return — 26 October 2026
Under 14 October choose-recommended authority, route Advanced/Homeworld
Emperor's exact normal survivors to Kaitain and Sardaukar to Salusa from either
Arrakis or a visitor Homeworld battle. This is a visible provisional composition
of authorized physical38,A.1–3's reserve return and E3p10's initial/revival split,
not an express Face Dance destination ruling. Preserve occupied-face immunity,
original victory/rewards, actual casualties and one leader death/no new bounty.
No revival cost/allowance/income or historical-origin reconstruction occurs.
Native Tleilaxu replacements use their actual stocks; already-present natives at
Tleilax remain a no-op. [Source and execution](HOMEWORLD_REPLACEMENT_RULES.md#typed-emperor-face-dance-return--26-october-2026).
## Remaining structured Truthtrance authoring — 26 October 2026
Expose the existing full grouped battle-plan and pure-shipment inputs rather
+15 -21
View File
@@ -105,7 +105,7 @@ import { createHomeworldOccupationHistory, observeHomeworldOccupation, validateH
import { tupileIntelligenceTargets, quoteTupileIntelligenceRequest, type TupileIntelligenceCategory } from './tupile-intelligence';
import { quoteTupileIntelligenceAnswer } from './tupile-intelligence-answer';
import { createTupileIntelligenceState, appendTupileIntelligenceObservation, validateTupileIntelligenceState, type TupileIntelligenceState } from './tupile-intelligence-state';
import { homeworldForceGroups, quoteHomeworldCustody } from './homeworld-custody';
import { homeworldForceGroups } from './homeworld-custody';
import { quoteHomeworldShipment, type HomeworldShipmentIntent } from './homeworld-shipment';
import { quoteGuildHomeworldShipment } from './guild-homeworld-shipment';
import { quoteJunctionTransport } from './junction-transport';
@@ -9311,8 +9311,7 @@ function faceDanceReturnBlock(g: Game, winner: string): string | null {
const quote = homeworldRule(() => quoteOccupiedHomeworldFaceDancer(g, target));
if (quote.blocked) return quote.blocked;
}
return g.advanced && g.homeworlds && getPlayer(g, winner).faction === 'emperor'
? 'Face Dance returning the Emperor’s army awaits the Kaitain/Salusa reserve-placement ruling.' : null;
return null;
}
function homeworldSubstitutionIntegrity(g: Game) {
const pending = g.pendingIxSubstitution;
@@ -36150,7 +36149,11 @@ function applyActionInner(
requireRule(home || validGameLocation(g, decision.territory, sector), 'Choose a sector in the battle territory.');
// This is replacement, not shipment or movement: no transport cost or worm/storm transit.
for (const source of selected) {
if (source.key === 'reserves') { if (!home) p.reserves -= source.count; }
if (source.key === 'reserves') {
// Native reserves already at the Homeworld stay there. An Arrakis
// replacement withdraws the actual stock before placing it.
if (!home) withdrawNativeReserves(g, p, source.count, 0);
}
else {
p.forces[source.key] -= source.count;
if (!p.forces[source.key]) delete p.forces[source.key];
@@ -36163,30 +36166,21 @@ function applyActionInner(
: winningArmies.map(owner => [owner.id, at(owner, decision.territory)] as const))
.filter(([, count]) => count > 0));
if (home) {
const destination = `homeworld:${winner.faction}`;
const external = selected.filter((source) => source.key !== 'reserves').reduce((n, source) => n + source.count, 0);
const quote = quoteHomeworldCustody(homeworldContext(g), g.homeworlds!.custody!, [
{ homeworld: home.id, player: winner.id, withdraw: army, deposit: {normal: 0, elite: 0} },
{ homeworld: destination, player: winner.id, withdraw: {normal: 0, elite: 0}, deposit: army },
{ homeworld: home.id, player: p.id, withdraw: {normal: 0, elite: 0}, deposit: {normal: external, elite: 0} },
]);
g.homeworlds!.custody = quote.state;
for (const update of quote.players) {
const owner = getPlayer(g, update.id);
owner.reserves = update.reserves;
if (owner.elites) owner.elites.reserves = update.eliteReserves;
}
// Selected provisional composition: return each physical type to its
// native stock (Emperor normal→Kaitain, Sardaukar→Salusa). This is not
// revival and does not restore a historical shipment origin.
addNativeReserves(g, winner, army.normal + army.elite, army.elite, home.id);
if (external) addNativeReserves(g, p, external, 0);
} else {
for (const owner of winningArmies)
for (const key of Object.keys(owner.forces).filter(
(key) => splitLocation(key).territory === decision.territory,
)) {
owner.reserves += owner.forces[key];
if (owner.elites) {
owner.elites.reserves += owner.elites.forces[key] ?? 0;
delete owner.elites.forces[key];
}
const amount = owner.forces[key], elite = owner.elites?.forces[key] ?? 0;
if (owner.elites) delete owner.elites.forces[key];
delete owner.forces[key];
addNativeReserves(g, owner, amount, elite);
}
}
const leader = g.players
+2 -1
View File
@@ -164,7 +164,8 @@ function nativeReserveDeposits(
/** Authorized native-reserve deposits: E3 p.10 restores Advanced Emperor's
* normal forces to Kaitain and Sardaukar to Salusa. Used by revival and by the
* Nexus Secret Ally reserve return (user ruling 7 October 2026); it does not
* Nexus Secret Ally reserve return (user ruling 7 October 2026) and selected
* provisional Face Dance return composition (26 October 2026); it does not
* validate Tanks, cost, revival allowances or route permission. */
export function quoteNativeReserveDeposit(
context: HomeworldCustodyContext,
+13
View File
@@ -2224,12 +2224,25 @@ export const RULE_TOPICS: RuleTopic[] = [
'Tleilaxu take no ordinary traitors. Once everyone else has selected their traitors, shuffle the unused cards and deal Tleilaxu three private Face Dancers.',
'After another faction wins, that winner resolves losses, leader bounties, card choices and tech-token rewards. Tleilaxu then have a separate opportunity to reveal an unrevealed Face Dancer matching the winning leader.',
'Return the winner’s surviving forces to reserves, then replace up to that number with Tleilaxu forces from reserves or other board locations. The winning leader goes to the tanks with no additional bounty. A leader already killed does not die again.',
'With Advanced Homeworlds, the Emperor’s normal survivors provisionally return to Kaitain and Sardaukar to Salusa Secundus, preserving their exact types and existing casualties. This selected reserve-return composition is not revival and grants no revival income, fee or second reward. The original victory remains the winner’s.',
'Arrakis replacements withdraw actual Tleilaxu native stocks. On Tleilax, existing native forces remain and selecting those reserves adds no counters; only selected external Arrakis replacements increase that pool. Occupied-Tleilax protection for its occupier or ally still blocks a reveal. Zero replacements or decline remain available.',
'A winning Cheap Hero may be a Face Dancer. Kwisatz Haderach does not protect an accompanying leader against Face Dancing. Karama cannot cancel the reveal or the replacement army.',
'Keep revealed Face Dancers face up until all three have been used. Shuffle those cards back into the unused deck and draw three new ones. During Mentat Pause, you may instead replace one unrevealed Face Dancer; this once-per-turn exchange has a Karama response.',
'Zoal has variable battle strength: he copies the opposing leader disc, without any KH bonus, or counts zero against a Cheap Hero. His death bounty uses that copied strength. His ordinary revival value is three spice before discounts.',
'After completed replacement, advisors left alone at remote sources or a zero-replacement target have a separate BG conversion response. Face Dance remains complete and immune; prevention keeps its returns, replacement, leader death and revelation/cycle, then resumes the original aftermath once.',
'The core setup, reveal sequence, card cycling, Zoal effects and AI controls are tested. Tleilaxu revival commerce also has tested support. Foreign ghola acquisition and buyback controls are also implemented. Their special Karama now also has tested support; its timing and the complete expansion interaction audit are still unfinished, so starts remain labelled previews.',
],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Advanced Emperor typed returns use native reserve quotes from Arrakis and visitor Homeworlds. Actual replacement stocks, existing immunity, original victory and single leader death remain. Broader combinations stay open.' },
{ area: 'Player controls', status: 'Partial', detail: 'Persistent provisional routing, public survivor maximum and own selectable sources feed the original choices and guarded reveal/decline callbacks.' },
{ area: 'AI', status: 'Partial', detail: 'All four minimal profiles use the unblocked projected opportunity and legal native replacement sources; no strategic refinement or calibration.' },
{ area: 'Documentation', status: 'Partial', detail: 'Distinguishes selected normal/Kaitain and Sardaukar/Salusa reserve return from revival, historical origin and a confirmed publisher destination ruling.' },
{ area: 'Verification', status: 'Partial', detail: 'Genuine public Advanced shipment/battle sources, labelled controlled Homeworld visitor cases and control supplements cover the route. Full modes, browser/mobile/deployed and final assurance remain due.', evidence: [
'game/homeworld-native-reserves.ts', 'components/face-dance-actions.tsx', 'components/face-dance-decision.tsx',
'tests/fixture-emperor-face-dance-return.ts', 'tests/emperor-face-dance-return-engine.test.ts',
'tests/fixture-emperor-face-dance-homeworld.ts', 'tests/emperor-face-dance-homeworld.test.ts', 'tests/emperor-face-dance-return-controls.test.ts', 'docs/HOMEWORLD_REPLACEMENT_RULES.md',
] },
],
related: [
'battle',
'cheap-hero-traitor',
+152
View File
@@ -0,0 +1,152 @@
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 {
createEmperorFaceDanceHomeworldFixture as fixture,
actEmperorFaceDanceHomeworld as act,
finishEmperorFaceDanceHomeworld as finish,
emperorFaceDanceHomeworldPlayer as player,
emperorFaceDanceHomeworldArmy as army,
emperorFaceDanceHomeworldInventory as inventory,
emperorFaceDanceHomeworldTraitors as traitors,
type EmperorFaceDanceHomeworldFixture as Fixture,
type EmperorFaceDanceHomeworldStep as Step,
} from './fixture-emperor-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 allowances(game: Game) {
return {
players: game.players.map(p => ({ id: p.id, spice: p.spice, shipped: p.shipped, moved: p.moved,
revived: p.revived, freeForcesRevived: p.freeForcesRevived, leaderRevived: p.leaderRevived,
revivalCycle: p.revivalCycle, eliteRevived: p.elites?.revived, battleLosses: p.battleLosses })),
revivalRules: structuredClone(game.revivalRules), revivalRequests: structuredClone(game.revivalRequests),
revivalFreeIncome: structuredClone(game.revivalFreeIncome), pendingRevival: structuredClone(game.pendingRevival),
};
}
function source(f: Fixture) {
assert.equal(f.lobby.status, 'lobby'); assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true);
assert.deepEqual(f.setup.expansions, ['ix']); assert.equal(f.afterSetup.status, 'playing');
assert.equal(f.game.homeworldOccupationPreview, f.afterSetup.homeworldOccupationPreview);
assert.match(f.staging[1], /CONTROLLED/); assert.match(f.staging[1], /not native invasion evidence/);
assert.match(f.staging[2], /CONTROLLED/); assert.equal(f.sourceActionCount, f.actions.length);
assert.ok(f.sourceActionCount > 4); assert.deepEqual(f.rawGame, f.game);
assert.equal(player(f.afterSetup, f.tleilaxu).reserves, 20);
assert.equal(player(f.game, f.emperor).reserves, 17);
assert.equal(player(f.game, f.emperor).elites!.reserves, 5 - f.visitors.elite);
assert.equal(player(f.game, f.emperor).tanks, 0); assert.equal(player(f.game, f.emperor).elites!.tanks, 0);
assert.deepEqual(army(f.game, f.emperor), f.visitors);
assert.deepEqual(army(f.game, f.emperor, 'homeworld:emperor'), { normal: 15 - f.visitors.normal, elite: 0 });
assert.deepEqual(army(f.game, f.emperor, 'homeworld:emperor:salusa'), { normal: 0, elite: 5 - f.visitors.elite });
assert.equal(player(f.game, f.tleilaxu).reserves, 10); assert.equal(player(f.game, f.tleilaxu).tanks, 0);
assert.equal(player(f.game, f.tleilaxu).forces['polar_sink:0'], 10);
assert.deepEqual(army(f.game, f.tleilaxu), { normal: 10, elite: 0 });
assert.equal(player(f.game, f.atreides).tanks, 3); assert.deepEqual(army(f.game, f.atreides), { normal: 0, elite: 0 });
assert.equal(f.game.decision?.kind, 'faceDance'); assert.equal(f.game.decision?.player, f.tleilaxu);
assert.equal(f.game.pendingFaceDance!.identity, f.winnerLeader);
assert.equal(f.game.lastBattleContext!.winner, f.emperor); assert.equal(f.game.lastBattleContext!.result, 'normal');
assert.equal(f.beforePreparation.battle!.attacker, f.emperor);
assert.equal(f.beforePreparation.battle!.defender, f.atreides);
assert.equal(player(f.game, f.emperor).leaders.find(disc => disc.id === f.winnerLeader)!.dead, false);
const view = viewGame(f.game, f.tleilaxu); assert.ok(view.decision?.kind === 'faceDance');
assert.equal(view.decision.blocked ?? null, null);
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(disc => disc.id).sort() })), f.originalLeaders);
}
function returned(game: Game, f: Fixture, external: number, beforeLeader = player(f.game, f.emperor)
.leaders.find(disc => disc.id === f.winnerLeader)!) {
assert.equal(player(game, f.emperor).reserves, 20); assert.equal(player(game, f.emperor).elites!.reserves, 5);
assert.deepEqual(army(game, f.emperor), { normal: 0, elite: 0 });
assert.deepEqual(army(game, f.emperor, 'homeworld:emperor'), { normal: 15, elite: 0 });
assert.deepEqual(army(game, f.emperor, 'homeworld:emperor:salusa'), { normal: 0, elite: 5 });
assert.equal(player(game, f.emperor).tanks, 0); assert.equal(player(game, f.emperor).elites!.tanks, 0);
assert.deepEqual(player(game, f.emperor).forces, player(f.game, f.emperor).forces);
assert.deepEqual(player(game, f.emperor).elites!.forces, player(f.game, f.emperor).elites!.forces);
assert.equal(player(game, f.tleilaxu).reserves, 10 + external);
assert.deepEqual(army(game, f.tleilaxu), { normal: 10 + external, elite: 0 });
assert.equal(player(game, f.tleilaxu).forces['polar_sink:0'], 10 - external);
assert.equal(player(game, f.tleilaxu).tanks, 0);
assert.equal(game.homeworlds!.custody!.visitors[f.territory]?.[f.tleilaxu], undefined);
assert.deepEqual(player(game, f.atreides), player(f.game, f.atreides), 'The actual defeated visitor and its off-battle army stay unchanged');
assert.deepEqual(game.lastBattleContext, f.originalContext, 'Face Dance preserves original sides, victory, casualties and rewards');
assert.deepEqual(allowances(game), allowances(f.game), 'Replacement creates no payment, revival quota, income or new revival state');
const leader = player(game, f.emperor).leaders.find(disc => disc.id === f.winnerLeader)!;
assert.equal(leader.dead, true); assert.equal(leader.deaths, beforeLeader.deaths + 1);
assert.equal(player(game, f.tleilaxu).faceDancers!.find(card => card.leader === f.winnerLeader)!.revealed, true);
assert.equal(game.pendingFaceDance ?? null, null);
assert.deepEqual(inventory(game), f.originalInventory); assert.deepEqual(traitors(game), f.originalTraitors);
}
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(done.lastBattleContext!.winner, f.emperor);
assert.deepEqual(inventory(done), f.originalInventory); assert.deepEqual(traitors(done), f.originalTraitors);
reject(done, f.tleilaxu, f.faceDanceAction); return done;
}
for (const composition of ['mixed', 'normal', 'star'] as const) {
void test(`Advanced controlled ${composition} Emperor visitors return to typed native homes after a real Homeworld victory`, () => {
const f = fixture({ composition }); source(f); const actions: Step[] = [];
const game = act(f.game, f.tleilaxu, f.faceDanceAction, actions); returned(game, f, 3);
const done = completed(game, f, actions);
assert.equal(player(done, f.emperor).reserves, 20); assert.equal(player(done, f.emperor).elites!.reserves, 5);
assert.equal(player(done, f.tleilaxu).reserves, 13);
assert.equal(actions.filter(step => step.action.reveal === true).length, 1);
assert.equal(actions.some(step => step.action.type === 'revive'), false);
});
void test(`Advanced controlled ${composition} Emperor visitor victory preserves its army and native caller when Face Dance is declined`, () => {
const f = fixture({ composition }); source(f);
const players = structuredClone(f.game.players), custody = structuredClone(f.game.homeworlds!.custody);
const game = act(f.game, f.tleilaxu, f.faceDanceDecline);
assert.deepEqual(game.players, players); assert.deepEqual(game.homeworlds!.custody, custody);
assert.deepEqual(game.lastBattleContext, f.originalContext); assert.equal(game.pendingFaceDance ?? null, null);
assert.equal(game.decision ?? null, null); assert.equal(game.battle ?? null, null); assert.equal(game.phase, 6);
assert.equal(player(game, f.emperor).leaders.find(disc => disc.id === f.winnerLeader)!.dead, false);
assert.equal(player(game, f.tleilaxu).faceDancers!.find(card => card.leader === f.winnerLeader)!.revealed, false);
assert.deepEqual(inventory(game), f.originalInventory); assert.deepEqual(traitors(game), f.originalTraitors);
reject(game, f.tleilaxu, f.faceDanceAction);
});
}
void test('Advanced controlled Homeworld reveal allows zero external replacement and treats selected native reserves as a physical no-op', () => {
const f = fixture(); source(f);
for (const action of [f.faceDanceZeroAction, f.faceDanceNativeAction,
{ type: 'decision', reveal: true, sources: { reserves: 2, 'polar_sink:0': 1 } } as Action]) {
const external = (action.sources as Record<string, number>)['polar_sink:0'] ?? 0;
const game = act(f.game, f.tleilaxu, action); returned(game, f, external); completed(game, f);
}
});
void test('Advanced controlled Emperor Homeworld reveal rejects foreign owners, excess replacement and unavailable physical sources before mutation', () => {
const f = fixture(); source(f);
reject(f.game, f.emperor, f.faceDanceAction); reject(f.game, f.atreides, f.faceDanceAction);
for (const sources of [
{ 'polar_sink:0': 4 }, { 'polar_sink:0': 11 }, { reserves: 11 },
{ reserves: 2, 'polar_sink:0': 2 }, { 'hagga_basin:12': 1 },
{ 'homeworld:tleilaxu': 1 }, { 'polar_sink:0': -1 }, { 'polar_sink:0': 1.5 },
]) reject(f.game, f.tleilaxu, { ...f.faceDanceAction, sources });
completed(act(f.game, f.tleilaxu, f.faceDanceAction), f);
});
void test('all four minimal profiles use legal controls for the controlled Advanced Emperor visitor Homeworld Face Dance', () => {
const f = fixture(); source(f);
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(f.game, f.tleilaxu); view.players.find(p => p.id === f.tleilaxu)!.bot = profile;
const choices = botActions(view); assert.ok(choices.length);
for (const action of choices) {
const game = act(f.game, f.tleilaxu, action);
if (action.reveal) {
returned(game, f, Object.entries((action.sources ?? {}) as Record<string, number>).filter(([key]) => key !== 'reserves')
.reduce((sum, [, amount]) => sum + amount, 0));
completed(game, f);
} else {
assert.deepEqual(game.players, f.game.players); assert.deepEqual(game.homeworlds!.custody, f.game.homeworlds!.custody);
assert.equal(game.pendingFaceDance ?? null, null); assert.equal(game.phase, 6);
}
}
}
});
@@ -0,0 +1,248 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { FaceDanceActions, faceDanceForceChoices, type FaceDanceActionsProps } from '../components/face-dance-actions';
import { FaceDanceDecision } from '../components/face-dance-decision';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
// These are controlled entitled projections, supplementing the separate native
// Arrakis and controlled conserved Homeworld engine sources. No pending receipt,
// winning army or private card is installed in an authoritative Game here.
// React renders the stateful parent; direct calls enter only the hook-free leaf.
function projected(world = false): GameView {
const game = createGame('EMPERORFACEDANCECONTROLS', newPlayer('t', 'Tleilaxu', 'tleilaxu'), true, ['ix', 'ecaz']);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
joinGame(game, newPlayer('c', 'Ecaz', 'ecaz'));
const view = viewGame(game, 't');
view.status = 'playing'; view.phase = 6;
// Presence of the public Homeworld table identifies the selected module. Its
// populations are unnecessary to this explanation and never become custody.
view.homeworlds = { worlds: [] };
const caller = view.players.find(player => player.id === 't')!;
caller.reserves = 5;
caller.forces = { 'arrakeen:10': 2, 'sietch_tabr:14': 3 };
caller.faceDancers = [{ leader: 'emperor-0', revealed: false }];
view.players.find(player => player.id === 'e')!.forces = { 'hagga_basin:12': 2, 'hagga_basin:13': 2 };
view.players.find(player => player.id === 'c')!.forces = { 'hagga_basin:13': 2 };
view.decision = {
kind: 'faceDance', player: 't', winner: 'e', leader: 'emperor-0', identity: 'emperor-0',
territory: world ? 'homeworld:tleilaxu' : 'hagga_basin',
};
if (world) view.combatLocations.push({
id: 'homeworld:tleilaxu', name: 'Tleilax', kind: 'homeworld', native: 't',
forces: { t: { normal: 5, elite: 0 }, e: { normal: 2, elite: 2 }, a: { normal: 1, elite: 0 } },
});
return view;
}
function markup(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(FaceDanceDecision, { game, busy, act() {} }));
}
function button(html: string, action: 'reveal' | 'decline') {
const found = html.match(new RegExp(`<button\\b(?=[^>]*data-testid="face-dance-${action}")[^>]*>[\\s\\S]*?<\\/button>`))?.[0];
assert.ok(found, `The Face Dance ${action} action is present`);
return found;
}
function propsFor(game: GameView, sources: Record<string, number> = {}): FaceDanceActionsProps {
return { game, sources, sector: 12, busy: false, act() {} };
}
type ActionButton = { props: { 'data-testid': string; disabled: boolean; onClick: () => void } };
function click(props: FaceDanceActionsProps, action: 'reveal' | 'decline'): Action[] {
const actions: Action[] = [];
const leaf = FaceDanceActions({ ...props, act(value) { actions.push(value); } });
const found = (leaf.props.children as ActionButton[]).find(child => child.props['data-testid'] === `face-dance-${action}`);
assert.ok(found);
found.props.onClick();
return actions;
}
function provisional(html: string) {
assert.match(html, /aria-label="Provisional Emperor Face Dance return"/);
assert.match(html, /Provisional return: the Emperor’s normal forces return to Kaitain; Sardaukar return to Salusa Secundus/);
assert.match(html, /reserve return with no revival fee or income, new battle or additional battle reward/);
assert.doesNotMatch(html, /Confirm|Acknowledge|countdown/);
}
void test('Advanced Homeworld Emperor returns show persistent provisional typed guidance on Arrakis and a visitor Homeworld', () => {
for (const world of [false, true]) {
const game = projected(world), before = structuredClone(game);
for (let render = 0; render < 3; render++) {
const html = markup(game); provisional(html);
assert.match(html, /Their battle rewards remain theirs/);
assert.match(html, /winner’s 4 remaining forces/);
assert.match(html, /selected winning leader goes to the tanks without a spice bounty/);
assert.match(html, /Arrakeen · sector 10/);
assert.match(html, /Sietch Tabr · sector 14/);
assert.doesNotMatch(button(html, 'reveal'), /\sdisabled=/);
assert.doesNotMatch(button(html, 'decline'), /\sdisabled=/);
if (world) {
assert.match(html, /Emperor won in Tleilax/);
assert.match(html, /native forces already at Tleilax remain there/);
assert.match(html, /0 \/ 4 replacement forces selected/);
assert.doesNotMatch(html, /face-source-reserves|face-sector|Replacement sector/);
} else {
assert.match(html, /Emperor won in Hagga Basin/);
assert.match(html, /face-source-reserves|Replacement sector/);
assert.match(html, /4 \/ 4 replacement forces selected/);
for (const sector of [12, 13]) assert.match(html, new RegExp(`<option value="${sector}"`));
}
}
assert.deepEqual(game, before);
}
});
void test('an Emperor winning co-side receives the public note and the existing combined fighter maximum', () => {
const game = projected();
assert.equal(game.decision?.kind, 'faceDance');
if (game.decision?.kind !== 'faceDance') return;
game.decision.winner = 'c'; game.decision.winners = ['c', 'e'];
const html = markup(game); provisional(html);
assert.match(html, /winning allied side’s 6 remaining forces/);
assert.match(html, /5 \/ 6 replacement forces selected/);
assert.equal(faceDanceForceChoices(game).maximum, 6);
assert.deepEqual(click(propsFor(game, { reserves: 4, 'arrakeen:10': 2 }), 'reveal'), [
{ type: 'decision', reveal: true, sources: { reserves: 4, 'arrakeen:10': 2 }, sector: 12 },
]);
assert.deepEqual(click(propsFor(game, { reserves: 5, 'arrakeen:10': 2 }), 'reveal'), []);
});
void test('Basic, absent Homeworld and non-Emperor winners receive no misleading typed-return note', () => {
const basic = projected(); basic.advanced = false;
const noWorlds = projected(); noWorlds.homeworlds = null;
const nonEmperor = projected();
assert.equal(nonEmperor.decision?.kind, 'faceDance');
if (nonEmperor.decision?.kind === 'faceDance') {
nonEmperor.decision.winner = 'c'; nonEmperor.decision.winners = ['c', 'a'];
}
for (const game of [basic, noWorlds, nonEmperor]) {
const html = markup(game);
assert.doesNotMatch(html, /Provisional Emperor Face Dance return|normal forces return to Kaitain|Sardaukar return to Salusa/);
}
});
void test('occupied-home immunity follows the projected block while retaining the original decline', () => {
for (const world of [false, true]) {
const game = projected(world);
const reason = 'The occupied native Homeworld protects this winner from Face Dancers.';
assert.equal(game.decision?.kind, 'faceDance');
if (game.decision?.kind === 'faceDance') game.decision.blocked = reason;
const html = markup(game); provisional(html);
assert.ok(html.includes(reason));
assert.match(html, /<output\b/);
assert.match(button(html, 'reveal'), /\sdisabled=/);
assert.doesNotMatch(button(html, 'decline'), /\sdisabled=/);
assert.deepEqual(click(propsFor(game), 'reveal'), []);
assert.deepEqual(click(propsFor(game), 'decline'), [{ type: 'decision', reveal: false }]);
}
});
void test('actual Arrakis reveal callbacks retain exact zero partial and full own-source payloads', () => {
const game = projected(), before = structuredClone(game);
for (const sources of [{}, { reserves: 1 }, { 'arrakeen:10': 2 }, { reserves: 1, 'sietch_tabr:14': 3 }] as Record<string, number>[]) {
const actions = click(propsFor(game, sources), 'reveal');
assert.deepEqual(actions, [{ type: 'decision', reveal: true, sources, sector: 12 }]);
assert.deepEqual(Object.keys(actions[0]).sort(), ['reveal', 'sector', 'sources', 'type']);
}
assert.deepEqual(click(propsFor(game), 'decline'), [{ type: 'decision', reveal: false }]);
assert.deepEqual(game, before);
});
void test('Homeworld callbacks omit sector and reserve sources while retaining zero and actual Arrakis replacement sources', () => {
const game = projected(true), before = structuredClone(game);
for (const sources of [{}, { 'arrakeen:10': 2 }, { 'arrakeen:10': 1, 'sietch_tabr:14': 3 }] as Record<string, number>[]) {
const actions = click({ ...propsFor(game, sources), sector: Number.NaN }, 'reveal');
assert.deepEqual(actions, [{ type: 'decision', reveal: true, sources }]);
assert.deepEqual(Object.keys(actions[0]).sort(), ['reveal', 'sources', 'type']);
}
for (const sources of [{ reserves: 1 }, { reserves: 0 }, { 'homeworld:tleilaxu': 1 }] as Record<string, number>[])
assert.deepEqual(click(propsFor(game, sources), 'reveal'), []);
assert.deepEqual(game, before);
});
void test('invalid source objects counts own bounds and current winner maximum guard the actual reveal callback', () => {
const game = projected();
const invalid: unknown[] = [
null, undefined, [], 1, 'reserves',
{ reserves: -1 }, { reserves: 0.5 }, { reserves: Number.NaN }, { reserves: Infinity },
{ reserves: Number.MAX_SAFE_INTEGER + 1 }, { reserves: '1' }, { reserves: null }, { reserves: BigInt(1) },
{ reserves: 6 }, { 'arrakeen:10': 3 }, { 'sietch_tabr:14': 4 },
{ reserves: 3, 'arrakeen:10': 2 }, { 'hagga_basin:10': 1 }, { invented: 0 }, { toString: 0 },
];
for (const sources of invalid) {
const props = { ...propsFor(game), sources: sources as Record<string, number> };
assert.deepEqual(click(props, 'reveal'), []);
const html = renderToStaticMarkup(createElement(FaceDanceActions, props));
assert.match(button(html, 'reveal'), /\sdisabled=/);
assert.doesNotMatch(button(html, 'decline'), /\sdisabled=/);
assert.deepEqual(click(props, 'decline'), [{ type: 'decision', reveal: false }]);
}
});
void test('Arrakis sector bounds and integers guard callbacks while every offered sector remains usable', () => {
const game = projected();
for (const sector of [-1, 0, 10, 11, 19, 10.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])
assert.deepEqual(click({ ...propsFor(game), sector }, 'reveal'), []);
for (const sector of [12, 13])
assert.deepEqual(click({ ...propsFor(game), sector }, 'reveal'), [{ type: 'decision', reveal: true, sources: {}, sector }]);
});
void test('matching identity and unrevealed state guard reveal without restricting decline or public provisional guidance', () => {
for (const cards of [undefined, [], [{ leader: 'atreides-0', revealed: false }], [{ leader: 'emperor-0', revealed: true }]]) {
const game = projected(); game.players.find(player => player.id === game.me)!.faceDancers = cards;
const html = markup(game); provisional(html);
assert.match(html, /You have no unrevealed Face Dancer matching the winning leader/);
assert.match(button(html, 'reveal'), /\sdisabled=/);
assert.doesNotMatch(button(html, 'decline'), /\sdisabled=/);
assert.deepEqual(click(propsFor(game), 'reveal'), []);
assert.deepEqual(click(propsFor(game), 'decline'), [{ type: 'decision', reveal: false }]);
}
});
void test('busy guards both actual callbacks and buttons while leaving guidance and selectors visible', () => {
for (const world of [false, true]) {
const game = projected(world), props = { ...propsFor(game), busy: true };
const html = markup(game, true); provisional(html);
assert.match(button(html, 'reveal'), /\sdisabled=/);
assert.match(button(html, 'decline'), /\sdisabled=/);
assert.match(html, /face-source-arrakeen:10/);
assert.deepEqual(click(props, 'reveal'), []);
assert.deepEqual(click(props, 'decline'), []);
}
});
void test('foreign absent and unrelated decisions expose no parent controls and make both actual callbacks no-ops', () => {
const foreign = projected(); foreign.me = 'a';
const absent = projected(); absent.decision = null;
const unrelated = projected(); unrelated.decision = { kind: 'mobileStronghold', player: 't', placement: false };
for (const game of [foreign, absent, unrelated]) {
for (const player of game.players) for (const field of ['faceDancers', 'reserves', 'forces'])
Object.defineProperty(player, field, { get() { throw Error(`Unowned controls read ${field}`); } });
assert.equal(markup(game), '');
for (const action of ['reveal', 'decline'] as const) {
assert.deepEqual(click(propsFor(game), action), []);
assert.match(button(renderToStaticMarkup(createElement(FaceDanceActions, propsFor(game))), action), /\sdisabled=/);
}
}
});
void test('the parent and real callbacks read only public opponent forces metadata and the caller’s matching private card and local sources', () => {
for (const world of [false, true]) {
const game = projected(world);
for (const field of ['battle', 'pendingFaceDance', 'homeworldOccupationHistory', 'discard', 'response', 'truthtrance'])
Object.defineProperty(game, field, { get() { throw Error(`Face Dance controls read raw or unrelated ${field}`); } });
Object.defineProperty(game.homeworlds!, 'custody', { get() { throw Error('Face Dance controls read raw custody'); } });
for (const player of game.players) {
const fields = player.id === game.me
? ['hand', 'traitors', 'spice', 'leaders', 'elites']
: ['hand', 'traitors', 'faceDancers', 'spice', 'leaders', 'reserves', 'elites', ...(world ? ['forces'] : [])];
for (const field of fields)
Object.defineProperty(player, field, { get() { throw Error(`Face Dance controls read private ${player.id}.${field}`); } });
}
const html = markup(game); provisional(html);
assert.doesNotMatch(button(html, 'reveal'), /\sdisabled=/);
assert.deepEqual(click(propsFor(game, { 'arrakeen:10': 2 }), 'reveal'), [
{ type: 'decision', reveal: true, sources: { 'arrakeen:10': 2 }, ...(world ? {} : { sector: 12 }) },
]);
assert.deepEqual(click(propsFor(game), 'decline'), [{ type: 'decision', reveal: false }]);
}
});
@@ -0,0 +1,167 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
actEmperorFaceDanceReturn as act, commitEmperorFaceDanceReturn as commit,
createEmperorFaceDanceReturnFixture as fixture, finishEmperorFaceDanceReturn as finish,
emperorFaceDanceReturnHomes as homes, emperorFaceDanceReturnInventory as inventory,
emperorFaceDanceReturnPhysical as physical, emperorFaceDanceReturnPlayer as player,
type EmperorFaceDanceReturnFixture,
} from './fixture-emperor-face-dance-return';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function revival(game: Game) {
return { freeRevival: game.freeRevival, emperorExtra: game.emperorExtra,
players: game.players.map(seat => ({ id: seat.id, revived: seat.revived,
eliteRevived: seat.elites?.revived, freeForcesRevived: seat.freeForcesRevived,
leaderRevived: seat.leaderRevived, revivalCycle: seat.revivalCycle })) };
}
function source(f: EmperorFaceDanceReturnFixture) {
assert.equal(f.game.status, 'playing'); assert.equal(f.game.advanced, true);
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 6);
assert.deepEqual(f.game.expansions, ['ix']);
assert.deepEqual(f.game.players.map(seat => seat.faction), ['emperor', 'tleilaxu', 'atreides']);
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 === 'faceDance');
assert.equal(f.game.decision.player, f.tleilaxu);
assert.equal(f.game.decision.winner, f.emperor);
assert.equal(f.game.decision.identity, f.winnerLeader);
assert.equal(f.game.response ?? null, null); assert.equal(f.game.phaseOpening ?? null, null);
assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
const offer = viewGame(f.game, f.tleilaxu).decision;
assert.ok(offer?.kind === 'faceDance');
assert.equal(offer.blocked ?? null, null,
'The original Emperor opportunity must project as available after typed return integration');
assert.equal(f.shipmentStep.actor, f.emperor);
assert.equal(f.shipmentStep.action.amount, f.amount);
assert.equal(f.shipmentStep.action.elite, f.elite);
assert.deepEqual(f.shipmentStep.action.homeworldSources, {
'homeworld:emperor': { normal: f.normal, elite: 0 },
'homeworld:emperor:salusa': { normal: 0, elite: f.elite },
});
assert.equal(player(f.afterShipment, f.emperor).forces[f.location], f.amount);
assert.equal(player(f.afterShipment, f.emperor).elites!.forces[f.location] ?? 0, f.elite);
assert.deepEqual(homes(f.game)['homeworld:emperor'], { normal: 15 - f.normal, elite: 0 });
assert.deepEqual(homes(f.game)['homeworld:emperor:salusa'], { normal: 0, elite: 5 - f.elite });
assert.equal(player(f.game, f.emperor).forces[f.location], f.survivorCount);
assert.equal(player(f.game, f.emperor).tanks, f.dial);
assert.equal(player(f.game, f.emperor).elites!.tanks, 0);
assert.equal(player(f.game, f.opponent).tanks, 10);
assert.equal(player(f.game, f.opponent).forces[f.location] ?? 0, 0);
assert.equal(f.game.lastBattleContext!.winner, f.emperor);
assert.equal(f.game.lastBattleContext!.result, 'normal');
assert.equal(f.game.lastBattleContext!.event, f.event);
assert.deepEqual([...f.faceDancerDeal.order].sort(), [...f.faceDancerDeal.original].sort());
assert.equal(f.faceDancerDeal.scalars.length, f.faceDancerDeal.original.length - 1);
assert.equal(f.faceDancerDeal.order[0], f.winnerLeader);
assert.ok(player(f.afterSetup, f.tleilaxu).faceDancers!.some(card => card.leader === f.winnerLeader && !card.revealed));
assert.equal(f.sourceActionCount, f.actions.length);
assert.deepEqual(inventory(f.game), f.originalInventory);
}
function returned(f: EmperorFaceDanceReturnFixture, game: Game, replacement: number) {
assert.equal(player(game, f.emperor).forces[f.location] ?? 0, 0);
assert.equal(player(game, f.emperor).elites!.forces[f.location] ?? 0, 0);
assert.equal(player(game, f.emperor).reserves, player(f.game, f.emperor).reserves + f.survivorCount);
assert.equal(player(game, f.emperor).elites!.reserves, player(f.game, f.emperor).elites!.reserves + f.survivors.elite);
assert.deepEqual(homes(game)['homeworld:emperor'], { normal: 15 - f.dial, elite: 0 },
'Only the exact live normal counters return to Kaitain');
assert.deepEqual(homes(game)['homeworld:emperor:salusa'], { normal: 0, elite: 5 },
'Only the exact live Sardaukar return to Salusa');
assert.equal(player(game, f.emperor).tanks, f.dial);
assert.equal(player(game, f.emperor).elites!.tanks, 0);
assert.equal(player(game, f.emperor).battleLosses, player(f.game, f.emperor).battleLosses);
assert.equal(player(game, f.tleilaxu).forces[f.location] ?? 0, replacement);
assert.equal(player(game, f.tleilaxu).reserves, player(f.game, f.tleilaxu).reserves - replacement);
assert.deepEqual(homes(game)['homeworld:tleilaxu'], { normal: 20 - replacement, elite: 0 },
'Replacement withdraws the same physical native reserve counters');
const leader = player(game, f.emperor).leaders.find(candidate => candidate.id === f.winnerLeader)!;
assert.equal(leader.dead, true);
assert.equal(leader.deaths, player(f.game, f.emperor).leaders.find(candidate => candidate.id === f.winnerLeader)!.deaths + 1);
assert.equal(player(game, f.tleilaxu).faceDancers!.filter(card => card.revealed).length, 1);
assert.equal(player(game, f.tleilaxu).faceDancers!.find(card => card.leader === f.winnerLeader)!.revealed, true);
assert.equal(game.pendingFaceDance ?? null, null);
assert.notEqual(game.decision?.kind, 'faceDance');
assert.deepEqual(game.lastBattleContext, f.originalContext,
'Face Dance preserves the completed original victory instead of resolving a second battle');
assert.deepEqual(inventory(game), f.originalInventory);
}
void test('public Advanced Emperor shipment, original Face Dancer deal and real normal casualty earn an unblocked offer', () => {
const f = fixture(); source(f);
assert.deepEqual(f.survivors, { normal: 2, elite: 2 });
assert.deepEqual(physical(f.game), f.originalPhysical);
});
for (const army of ['mixed', 'normal', 'elite'] as const) for (const zero of [false, true]) {
void test(`native Advanced Emperor ${army} survivors return to exact Homeworlds with ${zero ? 'zero' : 'positive'} replacement`, () => {
const f = fixture({ army }); source(f);
const before = structuredClone(f.game), amount = zero ? 0 : f.survivorCount;
let game = commit(f, f.game, zero ? f.zero : f.action);
assert.deepEqual(f.game, before, 'Submitting the original action does not mutate its input');
returned(f, game, amount);
assert.deepEqual(game.players.map(seat => seat.spice), before.players.map(seat => seat.spice),
'The return grants no bounty, income, revival or transport charge');
assert.deepEqual(revival(game), revival(before), 'Returning live survivors consumes no revival allowance');
const counters = physical(game);
rejected(game, f.tleilaxu, f.action);
game = finish(game);
assert.equal(game.phase, 7); returned(f, game, amount);
assert.deepEqual(physical(game), counters);
assert.deepEqual(revival(game), revival(before));
});
}
void test('declining the original Advanced Emperor Face Dance preserves the typed army, native pools and living leader', () => {
const f = fixture(); source(f);
const before = physical(f.game), wallets = f.game.players.map(seat => seat.spice);
let game = commit(f, f.game, f.decline);
assert.deepEqual(physical(game), before);
assert.deepEqual(game.players.map(seat => seat.spice), wallets);
assert.equal(player(game, f.emperor).leaders.find(leader => leader.id === f.winnerLeader)!.dead, false);
assert.equal(player(game, f.tleilaxu).faceDancers!.find(card => card.leader === f.winnerLeader)!.revealed, false);
assert.equal(game.pendingFaceDance ?? null, null);
game = finish(game);
assert.equal(game.phase, 7); assert.deepEqual(physical(game), before);
assert.deepEqual(game.lastBattleContext, f.originalContext);
assert.deepEqual(inventory(game), f.originalInventory);
rejected(game, f.tleilaxu, f.action);
});
void test('native Emperor Face Dance rejects foreign, stale and excessive replacement sources before its outcome', () => {
const f = fixture(); source(f);
rejected(f.game, f.emperor, f.action);
rejected(f.game, f.opponent, f.action);
rejected(f.game, f.tleilaxu, { ...f.action, sources: { 'homeworld:emperor': 1 } });
rejected(f.game, f.tleilaxu, { ...f.action, sources: { [f.location]: 1 } });
rejected(f.game, f.tleilaxu, { ...f.action, sources: { reserves: f.survivorCount + 1 } });
rejected(f.game, f.tleilaxu, { ...f.action, sources: { reserves: player(f.game, f.tleilaxu).reserves + 1 } });
rejected(f.game, f.tleilaxu, { ...f.action, sources: { reserves: -1 } });
rejected(f.game, f.tleilaxu, { ...f.action, sector: 9 });
assert.deepEqual(physical(f.game), f.originalPhysical);
assert.deepEqual(inventory(f.game), f.originalInventory);
const committed = commit(f);
rejected(committed, f.tleilaxu, f.action);
returned(f, committed, f.survivorCount);
});
void test('all four minimal profiles use the real unblocked Emperor offer and exact native replacement source', () => {
const f = fixture(); source(f);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(f.game, f.tleilaxu);
view.players.find(seat => seat.id === f.tleilaxu)!.bot = difficulty;
const action = botActions(view)[0];
assert.deepEqual(action, f.action, `${difficulty} submits the original projected Face Dance action`);
let game = act(f.game, f.tleilaxu, action);
returned(f, game, f.survivorCount);
game = finish(game);
assert.equal(game.phase, 7); returned(f, game, f.survivorCount);
}
});
@@ -0,0 +1,236 @@
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 { baseDeck, ixDeck } from '../game/cards';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import {
homeworldForceGroups, quoteHomeworldCustody, type HomeworldForces,
} from '../game/homeworld-custody';
import { quoteNativeReserveWithdrawal } from '../game/homeworld-native-reserves';
export type EmperorFaceDanceHomeworldStep = { actor: string; action: Action };
export type EmperorFaceDanceHomeworldComposition = 'mixed' | 'normal' | 'star';
export const emperorFaceDanceHomeworldPlayer = (game: Game, actor = 'e') => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export function emperorFaceDanceHomeworldPools(game: Game) {
return homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
}
export function emperorFaceDanceHomeworldArmy(game: Game, actor: string, homeworld = 'homeworld:tleilaxu') {
const home = emperorFaceDanceHomeworldPools(game).find(home => home.id === homeworld); assert.ok(home);
return structuredClone(home.forces[actor] ?? { normal: 0, elite: 0 });
}
export function emperorFaceDanceHomeworldInventory(game: Game) {
homeworldGameIntegrity(game);
const ids = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)]
.map(card => card.id).sort();
assert.deepEqual(ids, [...baseDeck(), ...ixDeck()].map(card => card.id).sort());
assert.equal(new Set(ids).size, ids.length, 'All original Treachery cards occupy unique physical slots');
return ids;
}
export function emperorFaceDanceHomeworldTraitors(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, 'All original Traitor identities occupy unique physical slots');
const discs = game.players.flatMap(player => player.leaders.map(leader => leader.id));
assert.equal(new Set(discs).size, discs.length, 'All original leader discs occupy unique physical slots');
return ids.sort();
}
export function emperorFaceDanceHomeworldPhysical(game: Game) {
return {
players: game.players.map(player => ({ id: player.id, reserves: player.reserves, tanks: player.tanks,
forces: structuredClone(player.forces), elites: structuredClone(player.elites), spice: player.spice,
shipped: player.shipped, moved: player.moved, battleLosses: player.battleLosses })),
custody: structuredClone(game.homeworlds!.custody),
};
}
export function actEmperorFaceDanceHomeworld(game: Game, actor: string, action: Action,
actions: EmperorFaceDanceHomeworldStep[] = []): Game {
const next = applyAction(game, actor, action); actions.push({ actor, action: structuredClone(action) }); return next;
}
/** Follow only original preparation and aftermath controls. The reveal stays caller-owned. */
export function nextEmperorFaceDanceHomeworld(game: Game): EmperorFaceDanceHomeworldStep | 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 === '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 advanceEmperorFaceDanceHomeworld(game: Game, until: (state: Game) => boolean,
actions: EmperorFaceDanceHomeworldStep[] = []): Game {
for (let count = 0; count < 160; count++) {
if (until(game)) return game;
const next = nextEmperorFaceDanceHomeworld(game);
assert.ok(next, `Choose the original Face Dance or plan control at ${game.phase}/${game.decision?.kind}`);
game = actEmperorFaceDanceHomeworld(game, next.actor, next.action, actions);
}
throw Error('The original Emperor visitor battle did not reach its bounded continuation');
}
/** Stop at the original battle's clean continuation. Decline may leave a new
* optional battle against the noncombatant native, which this helper never chooses. */
export function finishEmperorFaceDanceHomeworld(game: Game, actions: EmperorFaceDanceHomeworldStep[] = []): Game {
return advanceEmperorFaceDanceHomeworld(game,
state => !state.battle && !state.response && !state.decision && !state.phaseOpening, actions);
}
function applyCustody(game: Game, quote: ReturnType<typeof quoteHomeworldCustody>) {
game.homeworlds!.custody = quote.state;
for (const update of quote.players) {
const player = emperorFaceDanceHomeworldPlayer(game, update.id);
player.reserves = update.reserves;
if (player.elites) player.elites.reserves = update.eliteReserves;
}
}
/** CONTROLLED conserved placement, not evidence of a legal invasion route. */
function placeVisitor(game: Game, actor: string, territory: string, forces: HomeworldForces) {
const native = emperorFaceDanceHomeworldPools(game).filter(home => home.native === actor);
const changes = native.flatMap(home => {
const amount = home.secondary ? { normal: 0, elite: forces.elite }
: { normal: forces.normal, elite: native.some(candidate => candidate.secondary) ? 0 : forces.elite };
return amount.normal + amount.elite ? [{ homeworld: home.id, player: actor,
withdraw: amount, deposit: { normal: 0, elite: 0 } }] : [];
});
applyCustody(game, quoteHomeworldCustody(homeworldContext(game), game.homeworlds!.custody!, [
...changes, { homeworld: territory, player: actor, withdraw: { normal: 0, elite: 0 }, deposit: forces },
]));
emperorFaceDanceHomeworldInventory(game);
}
/** Actual original Advanced E/A/T Ix/Homeworld audit setup, then an explicitly
* CONTROLLED later position and conserved matching physical Traitor exchange.
* The visitor battle, original victory and pending native Face Dance are real
* actions. This is not a native invasion programme or a new public start gate. */
export function createEmperorFaceDanceHomeworldFixture(options: {
composition?: EmperorFaceDanceHomeworldComposition;
} = {}) {
const composition = options.composition ?? 'mixed';
const emperor = 'e', atreides = 'a', tleilaxu = 't', territory = 'homeworld:tleilaxu';
const visitors: HomeworldForces = composition === 'normal' ? { normal: 3, elite: 0 }
: composition === 'star' ? { normal: 0, elite: 3 } : { normal: 2, elite: 1 };
const actions: EmperorFaceDanceHomeworldStep[] = [];
let game = createGame('EMPERORFACEDANCEHOMEWORLD', newPlayer(emperor, 'Emperor', 'emperor'), true, ['ix']);
joinGame(game, newPlayer(atreides, 'Atreides', 'atreides'));
joinGame(game, newPlayer(tleilaxu, 'Tleilaxu', 'tleilaxu'));
const act = (actor: string, action: Action) => { game = actEmperorFaceDanceHomeworld(game, actor, action, actions); };
act(emperor, { 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: EmperorFaceDanceHomeworldStep | 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 = emperorFaceDanceHomeworldInventory(game);
const originalTraitors = emperorFaceDanceHomeworldTraitors(game);
const originalLeaders = game.players.map(player => ({ player: player.id, ids: player.leaders.map(leader => leader.id).sort() }));
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: emperor, ready: [], order: [emperor, atreides, tleilaxu] });
// Native T counters move to the controlled Arrakis source through the exact
// existing withdrawal quote; its other ten native reserves remain in place.
applyCustody(game, quoteNativeReserveWithdrawal(homeworldContext(game), game.homeworlds!.custody!,
tleilaxu, { normal: 10, elite: 0 }));
emperorFaceDanceHomeworldPlayer(game, tleilaxu).forces['polar_sink:0'] = 10;
emperorFaceDanceHomeworldInventory(game);
placeVisitor(game, emperor, territory, visitors);
placeVisitor(game, atreides, territory, { normal: 3, elite: 0 });
const winner = emperorFaceDanceHomeworldPlayer(game, emperor), opponent = emperorFaceDanceHomeworldPlayer(game, atreides);
const native = emperorFaceDanceHomeworldPlayer(game, tleilaxu);
const winnerDisc = [...winner.leaders].filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => b.strength - a.strength)[0];
const loserDisc = [...opponent.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(emperorFaceDanceHomeworldInventory(game), originalInventory);
assert.deepEqual(emperorFaceDanceHomeworldTraitors(game), originalTraitors);
const battleAction: Action = { type: 'chooseBattle', territory, target: atreides };
act(emperor, battleAction); const beforePreparation = structuredClone(game);
game = advanceEmperorFaceDanceHomeworld(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: EmperorFaceDanceHomeworldStep[] = [
{ actor: emperor, action: { type: 'battlePlan', leader: winnerLeader, dial: 0, support: 0, weapon: null, defense: null } },
{ actor: atreides, 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 = advanceEmperorFaceDanceHomeworld(game, state => state.decision?.kind === 'faceDance' &&
!state.response && !state.phaseOpening, actions);
assert.equal(game.lastBattleContext!.winner, emperor); assert.equal(game.lastBattleContext!.result, 'normal');
assert.equal(game.decision!.player, tleilaxu); assert.equal(game.pendingFaceDance!.identity, winnerLeader);
assert.deepEqual(emperorFaceDanceHomeworldArmy(game, emperor), visitors);
assert.deepEqual(emperorFaceDanceHomeworldArmy(game, tleilaxu), { normal: 10, elite: 0 });
assert.equal(emperorFaceDanceHomeworldPlayer(game, atreides).tanks, 3);
const faceDanceAction: Action = { type: 'decision', reveal: true, sources: { 'polar_sink:0': 3 } };
const faceDanceZeroAction: Action = { type: 'decision', reveal: true, sources: {} };
const faceDanceNativeAction: Action = { type: 'decision', reveal: true, sources: { reserves: 3 } };
const faceDanceDecline: Action = { type: 'decision', reveal: false };
const rawGame = structuredClone(game), currentPools = emperorFaceDanceHomeworldPools(game);
return { game, rawGame, source: structuredClone(game), advanced: true as const, composition, visitors,
emperor, atreides, opponent: atreides, tleilaxu, dancer: tleilaxu, actor: tleilaxu, winner: emperor,
owner: tleilaxu, territory, destination: territory, winnerLeader, loserLeader,
event: game.lastBattleContext!.event, action: faceDanceAction, faceDanceAction, faceDanceZeroAction,
faceDanceNativeAction, faceDanceDecline, battleAction, planSteps, lobby, setup, afterSetup,
controlled, exchange, beforePreparation, beforePlans, revealed, currentPools,
originalInventory, originalTraitors, originalLeaders, originalPhysical: emperorFaceDanceHomeworldPhysical(game),
originalContext: structuredClone(game.lastBattleContext), originalPendingFaceDance: structuredClone(game.pendingFaceDance),
actions, steps: actions, publicActions: actions, sourceActionCount: actions.length,
staging: [
'Actual original Advanced E/A/T Ix47 Homeworld audit setup and logged legal native setup choices; the original initializer retains its existing preview/public-start boundaries.',
'CONTROLLED conserved later phase-six position: all original dealt Treachery return to the original deck. Exact existing custody quotes move Emperor typed visitors and A3 to Tleilax; the native withdrawal quote moves T10 to Polar, leaving T10 at its home. Original other armies, typed pools, cards and discs remain physical. This is not native invasion evidence.',
'CONTROLLED matching Face Dancer availability exchanges one original Traitor identity with its actual reserve/held slot when necessary. All original Traitors and leader discs stay unique; no reveal, victory or casualty is installed.',
'Actual chooseBattle against the visiting Atreides, preparation/response passes, zero-dial unarmed leader plans, outcome readiness and declined Traitor votes create a normal Emperor victory and A3 Tanks. Tleilaxu is a noncombatant native: its ten native reserves and ten external counters remain untouched. Stop at the original native Face Dance; reveal remains caller-owned.',
],
};
}
export type EmperorFaceDanceHomeworldFixture = ReturnType<typeof createEmperorFaceDanceHomeworldFixture>;
+217
View File
@@ -0,0 +1,217 @@
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 {
actAdvisorFaceDanceFlip, advisorFaceDanceFlipClean, advisorFaceDanceFlipInventory,
advisorFaceDanceFlipTraitors, nextAdvisorFaceDanceFlip,
} from './fixture-advisor-face-dance-flip';
export type EmperorFaceDanceReturnStep = { actor: string; action: Action };
export const actEmperorFaceDanceReturn = actAdvisorFaceDanceFlip;
export const emperorFaceDanceReturnClean = advisorFaceDanceFlipClean;
export function emperorFaceDanceReturnPlayer(game: Game, id: string) {
const player = game.players.find(candidate => candidate.id === id);
assert.ok(player, `The original ${id} seat must remain present`);
return player;
}
export function emperorFaceDanceReturnInventory(game: Game) {
return { treachery: advisorFaceDanceFlipInventory(game), traitors: advisorFaceDanceFlipTraitors(game) };
}
/** Exact public native pools, including the physical secondary Salusa holding. */
export function emperorFaceDanceReturnHomes(game: Game) {
assert.ok(game.homeworlds?.custody);
return Object.fromEntries(homeworldForceGroups(homeworldContext(game), game.homeworlds.custody)
.map(home => [home.id, structuredClone(home.forces[home.native])])) as
Record<string, { normal: number; elite: number }>;
}
export function emperorFaceDanceReturnPhysical(game: Game) {
return { homes: emperorFaceDanceReturnHomes(game), custody: structuredClone(game.homeworlds!.custody),
players: game.players.map(player => ({ id: player.id, reserves: player.reserves, tanks: player.tanks,
forces: structuredClone(player.forces), elites: structuredClone(player.elites),
battleLosses: player.battleLosses, leaders: structuredClone(player.leaders) })) };
}
export function nextEmperorFaceDanceReturnStep(game: Game): EmperorFaceDanceReturnStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
if (game.decision?.kind === 'faceDance') return null;
if (game.status === 'setup' && game.setupStage === 'traitors') {
const player = game.players.find(candidate => candidate.traitorChoices.length);
assert.ok(player);
const leader = player.traitorChoices.find(identity => identity !== 'emperor-0');
assert.ok(leader, 'A real alternative Traitor choice must preserve the original Emperor Face Dancer');
return { actor: player.id, action: { type: 'traitor', leader } };
}
if (game.decision?.kind === 'battleLosses') {
const choice = game.decision.options.findIndex(option => option.normal === 1 && option.elite === 0);
assert.ok(choice >= 0, 'Use the original paid normal casualty rather than spending a Sardaukar');
return { actor: game.decision.player, action: { type: 'decision', choice } };
}
}
return nextAdvisorFaceDanceFlip(game);
}
export function advanceEmperorFaceDanceReturn(game: Game, until: (state: Game) => boolean,
actions: EmperorFaceDanceReturnStep[] = []): Game {
for (let count = 0; count < 1400; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Emperor/Face Dance chronology ended before its owned boundary');
const step = nextEmperorFaceDanceReturnStep(game);
assert.ok(step, `Choose the real ${game.turn}/${game.phase}/${game.decision?.kind}/${game.battle?.preparation?.kind} before continuing`);
game = actEmperorFaceDanceReturn(game, step.actor, step.action, actions);
}
throw Error('The native Emperor/Face Dance source did not reach its finite boundary');
}
/** Select only the real first Face Dancer permutation in the final native
* Traitor choice. No held card, played identity or revealed flag is assigned. */
function originalEmperorFaceDancerDeal(game: Game, step: EmperorFaceDanceReturnStep,
actions: EmperorFaceDanceReturnStep[]) {
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('emperor-0'));
const first = ['emperor-0', ...original.filter(identity => identity !== 'emperor-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');
const 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 original Face Dancer shuffle consumes these selected scalars');
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 original Advanced E/T/A + Ix/Homeworlds. Native shipment withdraws
* exact Kaitain/Salusa types; actual unarmed plans and normal casualties earn
* the original private Face Dance offer. Only unopened positions/scalars vary. */
export function createEmperorFaceDanceReturnFixture(options: { army?: 'mixed' | 'normal' | 'elite' } = {}) {
const army = options.army ?? 'mixed', emperor = 'e', tleilaxu = 't', opponent = 'a';
const normal = army === 'elite' ? 0 : 3, elite = army === 'normal' ? 0 : 2;
const dial = normal ? 1 : 0, support = dial, amount = normal + elite;
const territory = 'arrakeen', sector = 10, location = `${territory}:${sector}`;
let game = createGame('EMPERORFACEDANCERETURN', newPlayer(emperor, 'Emperor', 'emperor'), true, ['ix']);
joinGame(game, newPlayer(tleilaxu, 'Tleilaxu', 'tleilaxu'));
joinGame(game, newPlayer(opponent, 'Atreides', 'atreides'));
const actions: EmperorFaceDanceReturnStep[] = [];
const act = (actor: string, action: Action) => { game = actEmperorFaceDanceReturn(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceEmperorFaceDanceReturn(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, { type: 'start', advancedPreview: true });
const setup = structuredClone(game), originalInventory = emperorFaceDanceReturnInventory(game);
let faceDancerDeal: ReturnType<typeof originalEmperorFaceDancerDeal> | null = null;
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const step = nextEmperorFaceDanceReturnStep(game); assert.ok(step);
if (game.setupStage === 'traitors' && step.action.type === 'traitor' &&
game.players.filter(player => player.traitorChoices.length > 0).length === 1) {
faceDancerDeal = originalEmperorFaceDancerDeal(game, step, actions); game = faceDancerDeal.game;
} else act(step.actor, step.action);
}
assert.equal(game.status, 'playing'); assert.ok(faceDancerDeal);
const afterSetup = structuredClone(game), initialHomes = emperorFaceDanceReturnHomes(game);
assert.deepEqual(initialHomes['homeworld:emperor'], { normal: 15, elite: 0 });
assert.deepEqual(initialHomes['homeworld:emperor:salusa'], { normal: 0, elite: 5 });
assert.equal(emperorFaceDanceReturnPlayer(game, opponent).forces[location], 10);
assert.ok(emperorFaceDanceReturnPlayer(game, tleilaxu).faceDancers!.some(card => card.leader === 'emperor-0' && !card.revealed));
assert.deepEqual(emperorFaceDanceReturnInventory(game), originalInventory);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position);
const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
return { position, from, card: structuredClone(card) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
reach(state => state.phase === 5 && state.active === emperor && emperorFaceDanceReturnClean(state));
const homeworldSources = nativeShipmentSources(viewGame(game, emperor), amount, elite);
assert.ok(homeworldSources);
const beforeShipment = structuredClone(game), shipmentStep: EmperorFaceDanceReturnStep = { actor: emperor,
action: { type: 'ship', territory, sector, amount, elite, allyPayment: 0, homeworldSources } };
act(emperor, shipmentStep.action); reach(emperorFaceDanceReturnClean);
const afterShipment = structuredClone(game);
assert.equal(emperorFaceDanceReturnPlayer(game, emperor).forces[location], amount);
assert.equal(emperorFaceDanceReturnPlayer(game, emperor).elites!.forces[location] ?? 0, elite);
assert.equal(emperorFaceDanceReturnPlayer(beforeShipment, emperor).spice - emperorFaceDanceReturnPlayer(game, emperor).spice, amount);
reach(state => state.phase === 6 && emperorFaceDanceReturnClean(state));
const beforeBattle = structuredClone(game);
const choice = game.players.flatMap(player => viewGame(game, player.id).battleChoices)
.find(candidate => candidate.territory === territory); assert.ok(choice);
const battleStep: EmperorFaceDanceReturnStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
act(battleStep.actor, battleStep.action);
reach(state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && emperorFaceDanceReturnClean(state));
const beforePlans = structuredClone(game);
const winningLeader = emperorFaceDanceReturnPlayer(game, emperor).leaders.find(leader => leader.id === 'emperor-0');
const losingLeader = [...emperorFaceDanceReturnPlayer(game, opponent).leaders]
.filter(leader => !leader.dead).sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
assert.ok(winningLeader && losingLeader && winningLeader.strength > losingLeader.strength);
const plans: EmperorFaceDanceReturnStep[] = [
{ actor: emperor, action: { type: 'battlePlan', leader: winningLeader.id, dial, support, weapon: null, defense: null } },
{ actor: opponent, action: { type: 'battlePlan', leader: losingLeader.id, dial: 0, support: 0, weapon: null, defense: null } },
];
for (const step of plans) act(step.actor, step.action);
const sealed = structuredClone(game);
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'faceDance');
assert.equal(game.decision!.player, tleilaxu);
assert.ok(game.decision?.kind === 'faceDance');
assert.equal(game.decision.winner, emperor); assert.equal(game.decision.identity, winningLeader.id);
const survivors = { normal: normal - dial, elite }, survivorCount = survivors.normal + survivors.elite;
assert.equal(emperorFaceDanceReturnPlayer(game, emperor).forces[location], survivorCount);
assert.equal(emperorFaceDanceReturnPlayer(game, emperor).elites!.forces[location] ?? 0, survivors.elite);
assert.equal(emperorFaceDanceReturnPlayer(game, emperor).tanks, dial);
assert.equal(emperorFaceDanceReturnPlayer(game, emperor).elites!.tanks, 0);
assert.deepEqual(emperorFaceDanceReturnInventory(game), originalInventory);
const sources = { reserves: survivorCount };
const action: Action = { type: 'decision', reveal: true, sources, sector };
const decline: Action = { type: 'decision', reveal: false };
const zero: Action = { type: 'decision', reveal: true, sources: {}, sector };
return { game, source: structuredClone(game), initial, setup, afterSetup, faceDancerDeal, initialHomes,
beforeShipment, shipmentStep, afterShipment, beforeBattle, battleStep, beforePlans, plans, sealed,
army, advanced: true as const, emperor, winner: emperor, holder: emperor, owner: tleilaxu, actor: tleilaxu,
tleilaxu, dancer: tleilaxu, opponent, territory, battleTerritory: territory, sector, location,
leader: winningLeader.id, winnerLeader: winningLeader.id, loserLeader: losingLeader.id,
event: game.lastBattleContext!.event, normal, elite, dial, support, amount, survivors, survivorCount,
sources, action, faceDanceAction: action, decline, zero, originalInventory, originalSpice, spiceOrder,
originalContext: structuredClone(game.lastBattleContext), originalPhysical: emperorFaceDanceReturnPhysical(game),
actions, sourceActionCount: actions.length,
staging: [
'Public original Advanced Emperor/Tleilaxu/Atreides + Ix/Homeworlds ready/start. Actual private Traitor choices retain an alternative to Emperor0; selected scalars in the original final choice produce the actual first three unrevealed Face Dancers with Emperor0 included.',
'Only original scalar shuffle draws and two original unopened ordinary Spice positions are selected. No held hand, force, wallet, leader, phase, Face Dancer or receipt is installed.',
'Actual paid Emperor shipment uses nativeShipmentSources to withdraw the precise normal/Sardaukar counters from Kaitain/Salusa and place them into native Arrakeen10 with Atreides ten. Real movement endings reach an original battle choice.',
normal ? 'Real unarmed Emperor0 dial/support1 and weakest Atreides dial0 plans, declined original Traitor calls, outcome acknowledgements and a normal-only casualty earn the surviving army and private Face Dance offer.'
: 'Real unarmed Emperor0 and weakest Atreides dial0 plans, declined original Traitor calls and outcome acknowledgements earn the unchanged Sardaukar army and private Face Dance offer.',
'Stop before the actual Face Dance action. Positive/zero replacement or decline, typed reserve return, original winner leader death and continuation remain caller-owned outcomes. This is Advanced preview evidence only.',
] };
}
export type EmperorFaceDanceReturnFixture = ReturnType<typeof createEmperorFaceDanceReturnFixture>;
export function commitEmperorFaceDanceReturn(fixture: EmperorFaceDanceReturnFixture,
game = structuredClone(fixture.game), action: Action = fixture.action,
actions: EmperorFaceDanceReturnStep[] = []) {
assert.equal(game.decision?.kind, 'faceDance');
return actEmperorFaceDanceReturn(game, fixture.tleilaxu, action, actions);
}
export function finishEmperorFaceDanceReturn(game: Game, actions: EmperorFaceDanceReturnStep[] = []) {
return advanceEmperorFaceDanceReturn(game, state => !state.battle && !state.pendingFaceDance &&
state.phase >= 7 && state.phase <= 8 && emperorFaceDanceReturnClean(state), actions);
}