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

+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);
}