Route Emperor Face Dance returns to typed native reserves
This commit is contained in:
1 parent
4ee51917bc
commit
2c3dde51d4
14 files changed
+1346
-98
No files matched your search
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user