Connect Sapho first and last battle-choice order
Juice of Sapho can change the remaining Battle-phase chooser order at clean boundaries. A chooser may occupy the physical defender slot and select the actual other combatant. Physical storm order, attackers, defenders and tie advantage remain intact. Full-roster phase priority survives aftermath, newly eligible Face Dancers and saved continuation. Controls and all four AI profiles consume the same projected choices. Independent review verified private options, public geometry, exact physical disposal, rejected-action immutability, phase expiry and boundary-bound saved chooser integrity. Actual Basic/Advanced Face Dance regressions verify later replacement priority. Authenticated SQLite tests cover duplicate concurrent card requests, private restore and battle completion. Chooser/aggressor separation and phase duration remain explicit implementation interpretations; the separate aggressor mode and unresolved timing stay gated. Reinforcements remains disabled pending the documented reserve-cost question. Types, lint, 4792 offline tests, build and 43 HTTP tests pass with unchanged source. Six fixed genuine Basic/Advanced faction samples finish in 3672 accepted actions and 97 periodic private JSON restores. They retain 82 rejected candidates at existing guarded arrival combinations; subsequent legal candidates finish. This is bounded playability evidence, not complete compliance or AI calibration. The browser used Sapho first from a later physical circle, chose Emperor, sealed and resolved a battle, then refreshed into its next choice. Emperor retained the tie win. All 9 accepted actions replay exactly using the recorded random battle event; physical custody, all private JSON views and the original seat row pass. A private harness initially needed random-event replay binding and SQLite-row prototype normalization; no production fix or game reset was required. After the reported outage, the server responded and the staged QA game restored exactly. All 970 opening games and their original seat/recovery records remain preserved (1004 after checks), including all 971 rooms present immediately before checks. SQLite integrity and foreign keys pass. No restart, database reset or recurring automation was needed. Verified source tree: b699f80600f8e297595a8e756edf367a607ddd99707474c03231e6479bd2f389
This commit is contained in:
1 parent
04501567d0
commit
cb691e1b14
20 files changed
+1759
-35
No files matched your search
@@ -0,0 +1,237 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeFactionExpansionsGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
|
||||
export const SAPHO_BATTLE_CARD = 'richese-juice-of-sapho';
|
||||
export function takeSaphoBattleCard(
|
||||
g: Game,
|
||||
owner: string,
|
||||
card = SAPHO_BATTLE_CARD,
|
||||
) {
|
||||
for (const zone of [
|
||||
g.deck,
|
||||
g.discard,
|
||||
g.richeseCache ?? [],
|
||||
...g.players.map((p) => p.hand),
|
||||
]) {
|
||||
const at = zone.findIndex((c) => c.id === card);
|
||||
if (at < 0) continue;
|
||||
g.players.find((p) => p.id === owner)!.hand.push(zone.splice(at, 1)[0]);
|
||||
return;
|
||||
}
|
||||
throw new Error(`Missing physical fixture card ${card}`);
|
||||
}
|
||||
/** Genuine faction setup, followed by explicitly conserved hands and multi-battle topology. */
|
||||
export function saphoBattleOrderGame({
|
||||
advanced = false,
|
||||
holder = 'c',
|
||||
seatIds = ['a', 'b', 'c', 'r'] as readonly [string, string, string, string],
|
||||
geometry = 'shared' as 'shared' | 'separate',
|
||||
} = {}): Game {
|
||||
const factions = ['emperor', 'guild', 'atreides', 'richese'] as const;
|
||||
let g = createGame(
|
||||
'SAPHOBAT',
|
||||
newPlayer(seatIds[0], 'Emperor', factions[0]),
|
||||
advanced,
|
||||
['choam'],
|
||||
);
|
||||
for (let i = 1; i < seatIds.length; i++)
|
||||
joinGame(g, newPlayer(seatIds[i], factions[i], factions[i]));
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
g = initializeFactionExpansionsGameForAudit(g);
|
||||
for (let step = 0; g.status === 'setup' && step < 100; step++) {
|
||||
const next = nextSaphoBattleAction(g);
|
||||
assert.ok(next, 'Genuine setup must have an owned action.');
|
||||
g = applyAction(g, next.player, next.action);
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
const shared: Record<string, number>[] = [
|
||||
{ 'arrakeen:10': 3, 'carthag:11': 3 },
|
||||
{ 'arrakeen:10': 3, 'carthag:11': 3, 'imperial_basin:9': 3 },
|
||||
{ 'arrakeen:10': 3, 'imperial_basin:9': 3 },
|
||||
{},
|
||||
];
|
||||
const separate: Record<string, number>[] = [
|
||||
{ 'carthag:11': 4 },
|
||||
{ 'carthag:11': 4 },
|
||||
{ 'arrakeen:10': 4 },
|
||||
{ 'arrakeen:10': 4 },
|
||||
];
|
||||
for (const [index, p] of g.players.entries()) {
|
||||
g.deck.push(...p.hand);
|
||||
p.hand = [];
|
||||
p.forces = { ...(geometry === 'shared' ? shared[index] : separate[index]) };
|
||||
p.reserves = 20 - Object.values(p.forces).reduce((a, b) => a + b, 0);
|
||||
p.tanks = 0;
|
||||
p.spice = 30;
|
||||
p.shipped = false;
|
||||
p.moved = 0;
|
||||
if (p.elites) p.elites = { reserves: 5, tanks: 0, forces: {}, revived: 0 };
|
||||
}
|
||||
const index = ['a', 'b', 'c', 'r'].indexOf(holder);
|
||||
const owner = index >= 0 ? seatIds[index] : holder;
|
||||
takeSaphoBattleCard(g, owner);
|
||||
// Starting from a clean combined-turn boundary avoids inventing completed battles.
|
||||
Object.assign(g, {
|
||||
turn: 2,
|
||||
phase: 5,
|
||||
storm: 18,
|
||||
order: [...seatIds],
|
||||
active: seatIds[0],
|
||||
movementRemaining: [...seatIds],
|
||||
guildTimingLocked: true,
|
||||
guildTimingGranted: false,
|
||||
ready: [],
|
||||
response: null,
|
||||
decision: null,
|
||||
phaseOpening: null,
|
||||
});
|
||||
while (g.phase === 5) g = applyAction(g, g.active!, { type: 'endMovement' });
|
||||
assert.equal(g.phase, 6);
|
||||
saphoBattleCustody(g);
|
||||
return g;
|
||||
}
|
||||
export function saphoBattleOrderAction(
|
||||
g: Game,
|
||||
owner: string,
|
||||
mode: 'first' | 'last',
|
||||
): Action {
|
||||
const option = viewGame(g, owner).saphoOptions.find(
|
||||
(o) => o.scope === 'battleOrder' && o.mode === mode,
|
||||
);
|
||||
assert.ok(option, `Missing ${mode} battle-order option for ${owner}.`);
|
||||
return { type: 'card', card: SAPHO_BATTLE_CARD, ...option };
|
||||
}
|
||||
export function chooseSaphoBattleAction(g: Game): {
|
||||
player: string;
|
||||
action: Action;
|
||||
} {
|
||||
assert.ok(g.active && !g.battle);
|
||||
const choice = viewGame(g, g.active).battleChoices.find(
|
||||
(c) => c.chooser === g.active,
|
||||
);
|
||||
assert.ok(choice);
|
||||
return {
|
||||
player: g.active,
|
||||
action: {
|
||||
type: 'chooseBattle',
|
||||
territory: choice.territory,
|
||||
target: choice.attacker === g.active ? choice.defender : choice.attacker,
|
||||
},
|
||||
};
|
||||
}
|
||||
/** One real owner action, suitable for either applyAction or authenticated room.act. */
|
||||
export function nextSaphoBattleAction(
|
||||
g: Game,
|
||||
): { player: string; action: Action } | null {
|
||||
if (
|
||||
!g.response &&
|
||||
!g.decision &&
|
||||
g.battle?.preparation &&
|
||||
(!g.battle.preLeader || g.battle.preLeader.closed) &&
|
||||
['voice', 'prescience'].includes(g.battle.preparation.kind)
|
||||
)
|
||||
return {
|
||||
player: g.battle.preparation.owner,
|
||||
action: { type: 'declineBattlePower' },
|
||||
};
|
||||
for (const p of g.players) {
|
||||
const view = viewGame(g, p.id);
|
||||
view.players.find((player) => player.id === p.id)!.bot = 'Medium';
|
||||
if (
|
||||
g.battle &&
|
||||
!g.response &&
|
||||
!g.decision &&
|
||||
!g.battle.preparation &&
|
||||
(!g.battle.preLeader || g.battle.preLeader.closed) &&
|
||||
!g.battle.prescience &&
|
||||
!g.battle.nexusInspection &&
|
||||
!g.battle.voice &&
|
||||
[g.battle.attacker, g.battle.defender].includes(p.id) &&
|
||||
!g.battle.plans[p.id]
|
||||
) {
|
||||
const leader = p.leaders.find(
|
||||
(l) =>
|
||||
!l.dead &&
|
||||
!l.capturedBy &&
|
||||
(!l.usedAt || l.usedAt === g.battle!.territory),
|
||||
);
|
||||
if (leader)
|
||||
return {
|
||||
player: p.id,
|
||||
action: {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
support: 0,
|
||||
leader: leader.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
const action = botActions(view)[0];
|
||||
if (action) return { player: p.id, action };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export function resolveSaphoBattle(state: Game): Game {
|
||||
let g = state;
|
||||
assert.ok(g.battle);
|
||||
for (let step = 0; step < 120; step++) {
|
||||
if (
|
||||
!g.battle &&
|
||||
(g.phase !== 6 || viewGame(g, g.players[0].id).battleOrder?.current)
|
||||
)
|
||||
return g;
|
||||
const next = nextSaphoBattleAction(g);
|
||||
assert.ok(
|
||||
next,
|
||||
'The selected battle must retain a finite owned continuation.',
|
||||
);
|
||||
g = applyAction(g, next.player, next.action);
|
||||
}
|
||||
throw new Error('Battle continuation exceeded its fixture limit.');
|
||||
}
|
||||
export function saphoBattleCustody(g: Game) {
|
||||
const physical = [
|
||||
...g.deck,
|
||||
...g.discard,
|
||||
...(g.richeseCache ?? []),
|
||||
...(g.richeseRemoved ?? []),
|
||||
...g.players.flatMap((p) => p.hand),
|
||||
];
|
||||
assert.deepEqual(
|
||||
physical.map((c) => c.id).sort(),
|
||||
[...treacheryDeck(g.expansions), ...richeseCards()].map((c) => c.id).sort(),
|
||||
);
|
||||
assert.deepEqual(
|
||||
[
|
||||
...(g.traitorReserve ?? []),
|
||||
...g.players.flatMap((p) => p.traitors),
|
||||
].sort(),
|
||||
traitorDeck(g.players, false).sort(),
|
||||
);
|
||||
for (const p of g.players) {
|
||||
assert.equal(
|
||||
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
|
||||
20,
|
||||
);
|
||||
if (p.elites)
|
||||
assert.equal(
|
||||
p.elites.reserves +
|
||||
p.elites.tanks +
|
||||
Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
|
||||
5,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeFactionExpansionsGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import {
|
||||
nextSaphoBattleAction,
|
||||
takeSaphoBattleCard,
|
||||
} from './fixture-sapho-battle-order';
|
||||
|
||||
/** Real five-faction Ix/CHOAM setup; later hands, forces and one Face Dancer identity are staged by conserved swaps. */
|
||||
export function saphoFaceDanceGame(advanced = false): {
|
||||
game: Game;
|
||||
winnerLeader: string;
|
||||
loserLeader: string;
|
||||
} {
|
||||
const ids = ['a', 'b', 'c', 'r', 't'];
|
||||
const factions = [
|
||||
'emperor',
|
||||
'guild',
|
||||
'atreides',
|
||||
'richese',
|
||||
'tleilaxu',
|
||||
] as const;
|
||||
let g = createGame(
|
||||
'SAPHOFD',
|
||||
newPlayer('a', 'Emperor', 'emperor'),
|
||||
advanced,
|
||||
['ix', 'choam'],
|
||||
);
|
||||
for (let index = 1; index < ids.length; index++)
|
||||
joinGame(g, newPlayer(ids[index], factions[index], factions[index]));
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
g = initializeFactionExpansionsGameForAudit(g);
|
||||
for (let step = 0; g.status === 'setup' && step < 100; step++) {
|
||||
const next = nextSaphoBattleAction(g);
|
||||
assert.ok(next);
|
||||
g = applyAction(g, next.player, next.action);
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
for (const [index, p] of g.players.entries()) {
|
||||
g.deck.push(...p.hand);
|
||||
p.hand = [];
|
||||
p.forces = index < 3 ? { 'arrakeen:10': 3 } : {};
|
||||
p.reserves = index < 3 ? 17 : 20;
|
||||
p.tanks = 0;
|
||||
p.spice = 20;
|
||||
p.shipped = false;
|
||||
p.moved = 0;
|
||||
if (p.elites) p.elites = { reserves: 5, tanks: 0, forces: {}, revived: 0 };
|
||||
}
|
||||
takeSaphoBattleCard(g, 'a');
|
||||
const winnerLeader = [...g.players[1].leaders].sort(
|
||||
(a, b) => b.strength - a.strength,
|
||||
)[0].id;
|
||||
const loserLeader = [...g.players[2].leaders].sort(
|
||||
(a, b) => a.strength - b.strength,
|
||||
)[0].id;
|
||||
const dancer = g.players[4].faceDancers![0];
|
||||
const old = dancer.leader;
|
||||
const reserve = g.traitorReserve!.indexOf(winnerLeader);
|
||||
if (reserve >= 0) g.traitorReserve![reserve] = old;
|
||||
else {
|
||||
let replaced = false;
|
||||
for (const p of g.players) {
|
||||
const held = p.traitors.indexOf(winnerLeader);
|
||||
if (held >= 0) {
|
||||
p.traitors[held] = old;
|
||||
replaced = true;
|
||||
break;
|
||||
}
|
||||
const face = p.faceDancers?.find((card) => card.leader === winnerLeader);
|
||||
if (face) {
|
||||
face.leader = old;
|
||||
replaced = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(replaced, 'The genuine physical traitor identity must exist.');
|
||||
}
|
||||
dancer.leader = winnerLeader;
|
||||
Object.assign(g, {
|
||||
turn: 2,
|
||||
phase: 5,
|
||||
storm: 18,
|
||||
order: ids,
|
||||
active: 'a',
|
||||
movementRemaining: [...ids],
|
||||
guildTimingLocked: true,
|
||||
guildTimingGranted: false,
|
||||
ready: [],
|
||||
response: null,
|
||||
decision: null,
|
||||
phaseOpening: null,
|
||||
});
|
||||
while (g.phase === 5) g = applyAction(g, g.active!, { type: 'endMovement' });
|
||||
while (g.phaseOpening) {
|
||||
const next = nextSaphoBattleAction(g)!;
|
||||
g = applyAction(g, next.player, next.action);
|
||||
}
|
||||
assert.equal(g.phase, 6);
|
||||
saphoFaceDanceCustody(g);
|
||||
return { game: g, winnerLeader, loserLeader };
|
||||
}
|
||||
export function saphoFaceDanceCustody(g: Game) {
|
||||
assert.deepEqual(
|
||||
[
|
||||
...g.deck,
|
||||
...g.discard,
|
||||
...(g.richeseCache ?? []),
|
||||
...(g.richeseRemoved ?? []),
|
||||
...g.players.flatMap((p) => p.hand),
|
||||
]
|
||||
.map((c) => c.id)
|
||||
.sort(),
|
||||
[...treacheryDeck(g.expansions), ...richeseCards()].map((c) => c.id).sort(),
|
||||
);
|
||||
assert.deepEqual(
|
||||
[
|
||||
...(g.traitorReserve ?? []),
|
||||
...g.players.flatMap((p) => [
|
||||
...p.traitors,
|
||||
...(p.faceDancers ?? []).map((c) => c.leader),
|
||||
]),
|
||||
].sort(),
|
||||
traitorDeck(g.players, true).sort(),
|
||||
);
|
||||
for (const p of g.players) {
|
||||
assert.equal(
|
||||
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
|
||||
20,
|
||||
);
|
||||
if (p.elites)
|
||||
assert.equal(
|
||||
p.elites.reserves +
|
||||
p.elites.tanks +
|
||||
Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
|
||||
5,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { botBattleChoices } from '../game/bot-battle-choices';
|
||||
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
|
||||
import { saphoBattleOrderGame } from './fixture-sapho-battle-order';
|
||||
|
||||
function choices(game: Game, owner: string, level: Difficulty) {
|
||||
const view = viewGame(game, owner);
|
||||
view.players.find(player => player.id === owner)!.bot = level;
|
||||
const before = JSON.stringify(view);
|
||||
const actions = botActions(view);
|
||||
assert.equal(JSON.stringify(view), before, 'policy leaves its private projection unchanged');
|
||||
return actions;
|
||||
}
|
||||
|
||||
void test('all four profiles use battle first and choose the actual opponent from either stable slot', () => {
|
||||
for (const advanced of [false, true]) for (const level of DIFFICULTIES) {
|
||||
const game = saphoBattleOrderGame({ advanced, holder: 'c', geometry: 'shared' });
|
||||
const actions = choices(game, 'c', level);
|
||||
const card = actions.find(action => action.card === 'richese-juice-of-sapho');
|
||||
assert.ok(card, `${level}: offered card action`);
|
||||
assert.equal(card.scope, 'battleOrder');
|
||||
assert.equal(card.mode, 'first');
|
||||
const ordered = applyAction(game, 'c', card);
|
||||
assert.equal(ordered.active, 'c');
|
||||
const plans = botBattleChoices(viewGame(ordered, 'c'));
|
||||
assert.ok(plans.some(action => action.target === 'a'));
|
||||
assert.ok(plans.every(action => action.target !== 'c'));
|
||||
for (const action of plans) {
|
||||
const next = applyAction(ordered, 'c', action);
|
||||
assert.equal(next.battle?.territory, action.territory);
|
||||
assert.equal(next.battle?.defender, 'c');
|
||||
assert.equal(next.battle?.attacker, action.target);
|
||||
}
|
||||
const botPlans = choices(ordered, 'c', level).filter(action => action.type === 'chooseBattle');
|
||||
assert.deepEqual(botPlans, plans);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all profiles can move the first chooser last without changing physical storm order', () => {
|
||||
for (const advanced of [false, true]) for (const level of DIFFICULTIES) {
|
||||
const game = saphoBattleOrderGame({ advanced, holder: 'a', geometry: 'shared' });
|
||||
const action = choices(game, 'a', level).find(action => action.card === 'richese-juice-of-sapho');
|
||||
assert.ok(action);
|
||||
assert.equal(action.scope, 'battleOrder');
|
||||
assert.equal(action.mode, 'last');
|
||||
const next = applyAction(game, 'a', action);
|
||||
assert.deepEqual(next.order, game.order);
|
||||
assert.notEqual(next.active, 'a');
|
||||
assert.deepEqual(botBattleChoices(viewGame(next, 'a')), []);
|
||||
assert.equal(next.discard.filter(card => card.id === 'richese-juice-of-sapho').length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Sapho scheduling policy cannot inspect another seat’s concealed cards or resources', () => {
|
||||
const game = saphoBattleOrderGame({ advanced: true, holder: 'c', geometry: 'shared' });
|
||||
for (const level of DIFFICULTIES) {
|
||||
const view = viewGame(game, 'c');
|
||||
view.players.find(player => player.id === 'c')!.bot = level;
|
||||
const expected = botActions(view);
|
||||
for (const other of view.players.filter(player => player.id !== 'c'))
|
||||
for (const field of ['hand', 'spice', 'traitors', 'faceDancers'])
|
||||
Object.defineProperty(other, field, { get() { throw new Error(`Unauthorized ${field}`); } });
|
||||
assert.deepEqual(botActions(view), expected);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { applyAction, viewGame, type GameView } from '../game/engine';
|
||||
import { JuiceOfSapho } from '../components/juice-of-sapho';
|
||||
import { botBattleChoices } from '../game/bot-battle-choices';
|
||||
import { saphoBattleOrderGame, saphoBattleOrderAction } from './fixture-sapho-battle-order';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
|
||||
},
|
||||
});
|
||||
const { GameTable } = await import('../components/game-table');
|
||||
aliases.deregister();
|
||||
const panel = (game: GameView, busy = false) => renderToStaticMarkup(createElement(JuiceOfSapho, { game, act() {}, busy }));
|
||||
const table = (game: GameView) => renderToStaticMarkup(createElement(GameTable, { game, send: async () => {}, onExit() {}, busy: false }));
|
||||
|
||||
void test('held Sapho exposes the legal battle-order action with its actual timing and limits', () => {
|
||||
for (const [holder, mode] of [['c', 'first'], ['a', 'last']] as const) {
|
||||
const game = saphoBattleOrderGame({ holder, geometry: 'shared' });
|
||||
const view = viewGame(game, holder);
|
||||
const html = panel(view);
|
||||
assert.match(html, new RegExp(`Choose battles ${mode} and`));
|
||||
assert.match(html, /Other players may still choose battles against you/);
|
||||
assert.match(html, /not the aggressor or tie advantage/);
|
||||
assert.match(html, /Battle aggressor[\s\S]*unfinished/);
|
||||
assert.match(panel(view, true), /<button[^>]*disabled=""[^>]*>Choose battles/);
|
||||
const observer = game.players.find(player => player.id !== holder)!;
|
||||
assert.equal(panel(viewGame(game, observer.id)), '');
|
||||
}
|
||||
});
|
||||
|
||||
void test('the actual table offers an earlier physical attacker as the reordered chooser’s opponent', () => {
|
||||
const original = saphoBattleOrderGame({ holder: 'c', geometry: 'shared' });
|
||||
const game = applyAction(original, 'c', saphoBattleOrderAction(original, 'c', 'first'));
|
||||
const view = viewGame(game, 'c');
|
||||
const html = table(view);
|
||||
const opponent = game.players.find(player => player.id === 'a')!;
|
||||
assert.match(html, new RegExp(`Fight ${opponent.name} · Arrakeen`));
|
||||
assert.match(html, /Remaining battle-choice order/);
|
||||
assert.doesNotMatch(html, /Choose battles first and/);
|
||||
const action = botBattleChoices(view).find(candidate => candidate.target === 'a' && candidate.territory === 'arrakeen');
|
||||
assert.ok(action);
|
||||
const opened = applyAction(game, 'c', action);
|
||||
assert.equal(opened.battle?.attacker, 'a');
|
||||
assert.equal(opened.battle?.defender, 'c');
|
||||
});
|
||||
|
||||
void test('a held Sapho explains why ordering must wait through the current battle', () => {
|
||||
const game = saphoBattleOrderGame({ holder: 'c', geometry: 'shared' });
|
||||
const action = botBattleChoices(viewGame(game, game.active!))[0];
|
||||
assert.ok(action);
|
||||
const opened = applyAction(game, game.active!, action);
|
||||
const html = panel(viewGame(opened, 'c'));
|
||||
assert.doesNotMatch(html, /Choose battles (first|last) and/);
|
||||
assert.match(html, /Finish the current (battle|interaction)/);
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { viewGame, type Action, type Game } from "../game/engine";
|
||||
import type { RoomsClock } from "../db/rooms";
|
||||
import { unitStore } from "./fixture-nexus-room-store";
|
||||
import {
|
||||
chooseSaphoBattleAction,
|
||||
nextSaphoBattleAction,
|
||||
SAPHO_BATTLE_CARD,
|
||||
saphoBattleCustody,
|
||||
saphoBattleOrderAction,
|
||||
saphoBattleOrderGame,
|
||||
} from "./fixture-sapho-battle-order";
|
||||
|
||||
const clock: RoomsClock = { now: () => 87000, sleep: async () => {} };
|
||||
const rows = (sqlite: DatabaseSync) => ({
|
||||
rooms: sqlite.prepare("SELECT * FROM rooms ORDER BY code").all(),
|
||||
seats: sqlite.prepare("SELECT * FROM seats ORDER BY player_id").all(),
|
||||
});
|
||||
function barrier() {
|
||||
let release!: () => void;
|
||||
const both = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let entered = 0;
|
||||
return async () => {
|
||||
if (++entered === 2) release();
|
||||
await both;
|
||||
};
|
||||
}
|
||||
function unchangedPlayerState(game: Game) {
|
||||
return game.players.map(({ hand: _hand, ...player }) => player);
|
||||
}
|
||||
|
||||
async function fixture(
|
||||
t: test.TestContext,
|
||||
advanced: boolean,
|
||||
holderIndex: 0 | 2,
|
||||
) {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
const made = await store.rooms.createRoom(
|
||||
"Sapho battle order recovery",
|
||||
"emperor",
|
||||
advanced,
|
||||
["choam"],
|
||||
);
|
||||
const code = made.view.code;
|
||||
const tokens = [
|
||||
made.token,
|
||||
(await store.rooms.joinRoom(code, "Guild", "guild")).token!,
|
||||
(await store.rooms.joinRoom(code, "Atreides", "atreides")).token!,
|
||||
(await store.rooms.joinRoom(code, "Richese", "richese")).token!,
|
||||
];
|
||||
const auths = await Promise.all(
|
||||
tokens.map((token) => store.rooms.authenticate(code, token)),
|
||||
);
|
||||
const ids = auths.map((auth) => auth.playerId) as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
const game = saphoBattleOrderGame({
|
||||
advanced,
|
||||
holder: ids[holderIndex],
|
||||
seatIds: ids,
|
||||
geometry: "shared",
|
||||
});
|
||||
game.code = code;
|
||||
game.version = (await store.rooms.readRoom(code)).version;
|
||||
saphoBattleCustody(game);
|
||||
store.sqlite
|
||||
.prepare("UPDATE rooms SET state=?, version=? WHERE code=?")
|
||||
.run(JSON.stringify(game), game.version, code);
|
||||
store.writes.length = 0;
|
||||
return { ...store, code, tokens, auths, ids, holder: ids[holderIndex] };
|
||||
}
|
||||
type Fixture = Awaited<ReturnType<typeof fixture>>;
|
||||
|
||||
async function act(f: Fixture, player: string, action: Action) {
|
||||
const index = f.ids.indexOf(player);
|
||||
assert.ok(index >= 0);
|
||||
const rooms = f.restart();
|
||||
const auth = await rooms.authenticate(f.code, f.tokens[index]);
|
||||
const before = await rooms.readRoom(f.code);
|
||||
await rooms.act(f.code, auth, before.version, action, clock);
|
||||
const after = await rooms.readRoom(f.code);
|
||||
saphoBattleCustody(after);
|
||||
return after;
|
||||
}
|
||||
|
||||
async function restored(f: Fixture) {
|
||||
const game = await f.restart().readRoom(f.code);
|
||||
const before = rows(f.sqlite);
|
||||
for (const [index, token] of f.tokens.entries()) {
|
||||
const rooms = f.restart();
|
||||
const auth = await rooms.authenticate(f.code, token);
|
||||
const view = await rooms.readSeatView(f.code, auth);
|
||||
assert.deepEqual(view, viewGame(game, f.ids[index]));
|
||||
assert.deepEqual(
|
||||
view,
|
||||
viewGame(JSON.parse(JSON.stringify(game)) as Game, f.ids[index]),
|
||||
);
|
||||
for (const rival of view.players.filter((p) => p.id !== auth.playerId))
|
||||
for (const field of ["hand", "traitors", "spice", "prediction"])
|
||||
assert.equal(field in rival, false);
|
||||
if (auth.playerId !== f.holder)
|
||||
assert.equal(JSON.stringify(view).includes(SAPHO_BATTLE_CARD), false);
|
||||
}
|
||||
assert.deepEqual(rows(f.sqlite), before);
|
||||
saphoBattleCustody(game);
|
||||
return game;
|
||||
}
|
||||
|
||||
async function finishSelectedBattle(f: Fixture) {
|
||||
for (let step = 0; step < 120; step++) {
|
||||
const game = await f.restart().readRoom(f.code);
|
||||
if (
|
||||
!game.battle &&
|
||||
(game.phase !== 6 || viewGame(game, f.ids[0]).battleOrder?.current)
|
||||
)
|
||||
return game;
|
||||
const next = nextSaphoBattleAction(game);
|
||||
assert.ok(next, "The saved battle must retain an owned continuation.");
|
||||
await act(f, next.player, next.action);
|
||||
}
|
||||
assert.fail("The saved battle continuation did not finish.");
|
||||
}
|
||||
|
||||
void test("authenticated first and last claims survive CAS, a defender-owned battle, and the next boundary", async (t) => {
|
||||
const cases = [
|
||||
{ advanced: false, holderIndex: 2 as const, mode: "first" as const },
|
||||
{ advanced: true, holderIndex: 0 as const, mode: "last" as const },
|
||||
];
|
||||
for (const scenario of cases) {
|
||||
const f = await fixture(t, scenario.advanced, scenario.holderIndex);
|
||||
const before = await restored(f);
|
||||
const seatRows = rows(f.sqlite).seats;
|
||||
const stable = {
|
||||
storm: before.storm,
|
||||
order: structuredClone(before.order),
|
||||
players: unchangedPlayerState(before),
|
||||
deck: structuredClone(before.deck),
|
||||
cache: structuredClone(before.richeseCache),
|
||||
forces: structuredClone(before.players.map((p) => p.forces)),
|
||||
};
|
||||
const request = saphoBattleOrderAction(before, f.holder, scenario.mode);
|
||||
const writeStart = f.writes.length;
|
||||
f.hooks.beforeWrite = barrier();
|
||||
const attempts = await Promise.allSettled([
|
||||
f.rooms.act(
|
||||
f.code,
|
||||
f.auths[scenario.holderIndex],
|
||||
before.version,
|
||||
request,
|
||||
clock,
|
||||
),
|
||||
f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.auths[scenario.holderIndex],
|
||||
before.version,
|
||||
request,
|
||||
clock,
|
||||
),
|
||||
]);
|
||||
f.hooks.beforeWrite = undefined;
|
||||
assert.equal(
|
||||
attempts.filter((result) => result.status === "fulfilled").length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
attempts.filter((result) => result.status === "rejected").length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.writes
|
||||
.slice(writeStart)
|
||||
.map((write) => write.changes)
|
||||
.sort((a, b) => a - b),
|
||||
[0, 1],
|
||||
);
|
||||
|
||||
const ordered = await restored(f);
|
||||
assert.equal(ordered.version, before.version + 1);
|
||||
assert.equal(ordered.storm, stable.storm);
|
||||
assert.deepEqual(ordered.order, stable.order);
|
||||
assert.deepEqual(unchangedPlayerState(ordered), stable.players);
|
||||
assert.deepEqual(ordered.deck, stable.deck);
|
||||
assert.deepEqual(ordered.richeseCache, stable.cache);
|
||||
assert.deepEqual(
|
||||
ordered.players.map((p) => p.forces),
|
||||
stable.forces,
|
||||
);
|
||||
assert.equal(
|
||||
ordered.discard.filter((card) => card.id === SAPHO_BATTLE_CARD).length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
ordered.players
|
||||
.flatMap((p) => p.hand)
|
||||
.filter((card) => card.id === SAPHO_BATTLE_CARD).length,
|
||||
0,
|
||||
);
|
||||
assert.deepEqual(
|
||||
ordered.battleOrder?.uses.map((use) => use.mode),
|
||||
[scenario.mode],
|
||||
);
|
||||
assert.equal(
|
||||
viewGame(ordered, f.holder).battleOrder?.current,
|
||||
ordered.active,
|
||||
);
|
||||
|
||||
const choice = chooseSaphoBattleAction(ordered);
|
||||
assert.equal(choice.player, ordered.active);
|
||||
const selected = viewGame(ordered, choice.player).battleChoices.find(
|
||||
(candidate) =>
|
||||
candidate.territory === choice.action.territory &&
|
||||
[candidate.attacker, candidate.defender].includes(
|
||||
String(choice.action.target),
|
||||
),
|
||||
);
|
||||
assert.ok(selected);
|
||||
assert.equal(selected.chooser, choice.player);
|
||||
assert.notEqual(selected.attacker, choice.player);
|
||||
assert.notEqual(choice.action.target, choice.player);
|
||||
assert.equal(choice.action.target, selected.attacker);
|
||||
assert.ok(
|
||||
[selected.attacker, selected.defender].includes(
|
||||
String(choice.action.target),
|
||||
),
|
||||
);
|
||||
await act(f, choice.player, choice.action);
|
||||
const inBattle = await restored(f);
|
||||
assert.equal(inBattle.battle?.chooser, choice.player);
|
||||
assert.notEqual(inBattle.battle?.attacker, choice.player);
|
||||
const battleEvent = inBattle.battle?.event;
|
||||
assert.ok(battleEvent);
|
||||
|
||||
const nextBoundary = await finishSelectedBattle(f);
|
||||
assert.equal(nextBoundary.phase, 6);
|
||||
assert.equal(nextBoundary.battle, null);
|
||||
assert.equal(nextBoundary.battleOrder?.uses.length, 1);
|
||||
assert.equal(nextBoundary.battleOrder?.uses[0].mode, scenario.mode);
|
||||
assert.equal(
|
||||
viewGame(nextBoundary, f.holder).battleOrder?.current,
|
||||
nextBoundary.active,
|
||||
);
|
||||
assert.ok(
|
||||
viewGame(nextBoundary, f.holder).battleOrder!.remaining.length > 0,
|
||||
);
|
||||
assert.notEqual(
|
||||
viewGame(nextBoundary, f.holder).battleOrder?.event,
|
||||
request.event,
|
||||
);
|
||||
assert.equal(nextBoundary.lastBattleContext?.event, battleEvent);
|
||||
|
||||
const beforeReplay = rows(f.sqlite);
|
||||
const replayWrites = f.writes.length;
|
||||
await assert.rejects(
|
||||
f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.auths[scenario.holderIndex],
|
||||
nextBoundary.version,
|
||||
request,
|
||||
clock,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(rows(f.sqlite), beforeReplay);
|
||||
assert.equal(f.writes.length, replayWrites);
|
||||
assert.equal(
|
||||
(await f.restart().readRoom(f.code)).discard.filter(
|
||||
(card) => card.id === SAPHO_BATTLE_CARD,
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(rows(f.sqlite).seats, seatRows);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import {
|
||||
battleChooserEvent,
|
||||
quoteBattleChoosers,
|
||||
reorderBattleChoosers,
|
||||
} from '../game/battle-chooser-order';
|
||||
import {
|
||||
saphoFaceDanceGame,
|
||||
saphoFaceDanceCustody,
|
||||
} from './fixture-sapho-face-dance';
|
||||
import {
|
||||
chooseSaphoBattleAction,
|
||||
nextSaphoBattleAction,
|
||||
resolveSaphoBattle,
|
||||
saphoBattleCustody,
|
||||
saphoBattleOrderAction,
|
||||
saphoBattleOrderGame,
|
||||
takeSaphoBattleCard,
|
||||
SAPHO_BATTLE_CARD,
|
||||
} from './fixture-sapho-battle-order';
|
||||
|
||||
function reject(
|
||||
g: Game,
|
||||
player: string,
|
||||
action: Action,
|
||||
reason = /Sapho|battle|chooser|opportunity|scope|scheduling/i,
|
||||
) {
|
||||
const before = JSON.stringify(g);
|
||||
assert.throws(() => applyAction(g, player, action), reason);
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
}
|
||||
function restore(g: Game) {
|
||||
const reloaded: Game = JSON.parse(JSON.stringify(g));
|
||||
for (const p of g.players)
|
||||
assert.deepEqual(viewGame(reloaded, p.id), viewGame(g, p.id));
|
||||
saphoBattleCustody(reloaded);
|
||||
return reloaded;
|
||||
}
|
||||
function choose(g: Game) {
|
||||
const next = chooseSaphoBattleAction(g);
|
||||
return applyAction(g, next.player, next.action);
|
||||
}
|
||||
const pairs = (g: Game) =>
|
||||
viewGame(g, 'a')
|
||||
.battleChoices.map(({ territory, attacker, defender }) =>
|
||||
JSON.stringify([territory, attacker, defender]),
|
||||
)
|
||||
.sort();
|
||||
|
||||
void test('first and last reorder Basic/Advanced remaining choosers without changing public battle geometry or physical circles', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const [owner, mode] of [
|
||||
['c', 'first'],
|
||||
['a', 'last'],
|
||||
] as const) {
|
||||
const g = saphoBattleOrderGame({ advanced, holder: owner });
|
||||
const before = structuredClone(g),
|
||||
geometry = pairs(g);
|
||||
const next = applyAction(
|
||||
g,
|
||||
owner,
|
||||
saphoBattleOrderAction(g, owner, mode),
|
||||
);
|
||||
assert.deepEqual(g, before);
|
||||
assert.deepEqual(next.order, g.order);
|
||||
assert.deepEqual(next.playerPositions, g.playerPositions);
|
||||
assert.deepEqual(pairs(next), geometry);
|
||||
assert.equal(next.active, mode === 'first' ? 'c' : 'b');
|
||||
for (const p of next.players) {
|
||||
const old = g.players.find((other) => other.id === p.id)!;
|
||||
assert.deepEqual({ ...p, hand: [] }, { ...old, hand: [] });
|
||||
assert.deepEqual(
|
||||
p.hand,
|
||||
old.hand.filter((c) => c.id !== SAPHO_BATTLE_CARD),
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
next.discard.filter((c) => c.id === SAPHO_BATTLE_CARD).length,
|
||||
1,
|
||||
);
|
||||
assert.equal(next.battleOrder!.uses.length, 1);
|
||||
assert.deepEqual(
|
||||
viewGame(next, 'r').battleOrder!.remaining,
|
||||
mode === 'first' ? ['c', 'a', 'b'] : ['b', 'c', 'a'],
|
||||
);
|
||||
restore(next);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a later physical defender chooses its opponent while original combat roles, powers and tie owner stay fixed', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
let g = saphoBattleOrderGame({ advanced });
|
||||
g = applyAction(g, 'c', saphoBattleOrderAction(g, 'c', 'first'));
|
||||
const request = chooseSaphoBattleAction(g);
|
||||
assert.equal(request.player, 'c');
|
||||
assert.equal(request.action.target, 'a');
|
||||
g = applyAction(g, request.player, request.action);
|
||||
assert.equal(g.battle!.chooser, 'c');
|
||||
assert.equal(g.battle!.attacker, 'a');
|
||||
assert.equal(g.battle!.defender, 'c');
|
||||
assert.equal(viewGame(g, 'c').battle!.tieWinner, 'a');
|
||||
assert.ok(g.log.at(-1)!.text.includes('chose the battle between'));
|
||||
assert.ok(g.log.at(-1)!.text.includes('Emperor remains the aggressor'));
|
||||
assert.deepEqual(viewGame(g, 'c').saphoOptions, []);
|
||||
assert.ok(g.battle!.preLeader && !g.battle!.preLeader.closed);
|
||||
// The original Atreides benefit still belongs to the actual Atreides seat.
|
||||
for (
|
||||
let step = 0;
|
||||
g.battle?.preLeader && !g.battle.preLeader.closed && step < 10;
|
||||
step++
|
||||
) {
|
||||
const next = nextSaphoBattleAction(g)!;
|
||||
g = applyAction(g, next.player, next.action);
|
||||
}
|
||||
assert.equal(g.battle?.preparation?.owner, 'c');
|
||||
assert.equal(g.battle?.preparation?.kind, 'prescience');
|
||||
g = resolveSaphoBattle(restore(g));
|
||||
const completed = JSON.stringify([
|
||||
g.lastBattleContext!.territory,
|
||||
...g.lastBattleContext!.combatants,
|
||||
]);
|
||||
assert.ok(!pairs(g).includes(completed));
|
||||
assert.equal(g.active, viewGame(g, 'a').battleOrder!.current);
|
||||
restore(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('accepted phase priority survives every remaining battle and expires only on leaving Battle', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
let g = saphoBattleOrderGame({ advanced, holder: 'a' });
|
||||
g = applyAction(g, 'a', saphoBattleOrderAction(g, 'a', 'last'));
|
||||
const phaseOrder = [...g.battleOrder!.priority],
|
||||
events: string[] = [],
|
||||
completed = new Set<string>();
|
||||
for (let count = 0; g.phase === 6 && count < 12; count++) {
|
||||
const before = viewGame(g, 'a').battleOrder!;
|
||||
assert.deepEqual(g.battleOrder!.priority, phaseOrder);
|
||||
assert.equal(g.active, before.current);
|
||||
assert.equal(
|
||||
before.remaining.includes('a') ? before.remaining.at(-1) : 'a',
|
||||
'a',
|
||||
);
|
||||
events.push(before.event);
|
||||
g = choose(g);
|
||||
const identity = JSON.stringify([
|
||||
g.battle!.territory,
|
||||
g.battle!.attacker,
|
||||
g.battle!.defender,
|
||||
]);
|
||||
assert.ok(
|
||||
!completed.has(identity),
|
||||
'A completed pair cannot be reopened.',
|
||||
);
|
||||
completed.add(identity);
|
||||
g = resolveSaphoBattle(restore(g));
|
||||
restore(g);
|
||||
}
|
||||
assert.equal(g.phase, 7);
|
||||
assert.ok(completed.size >= 3);
|
||||
assert.equal(new Set(events).size, events.length);
|
||||
assert.equal(g.battleOrder, undefined);
|
||||
assert.equal(g.battleOrderUseEvents, undefined);
|
||||
assert.equal(viewGame(g, 'a').battleOrder, null);
|
||||
assert.deepEqual(g.order, ['a', 'b', 'c', 'r']);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Nullentropy can recover the physical Sapho for a fresh opportunity without replaying the consumed event', () => {
|
||||
let g = saphoBattleOrderGame();
|
||||
const box = g.richeseCache!.find((c) => c.effect === 'nullentropyBox')!;
|
||||
takeSaphoBattleCard(g, 'c', box.id);
|
||||
// A second conserved, staged discard makes the Box selection a real choice.
|
||||
g.discard.push(g.deck.pop()!);
|
||||
const old = saphoBattleOrderAction(g, 'c', 'first');
|
||||
g = applyAction(g, 'c', old);
|
||||
const savedOrder = structuredClone(g.battleOrder);
|
||||
g = applyAction(g, 'c', { type: 'card', card: box.id });
|
||||
assert.ok(g.pendingNullentropy);
|
||||
assert.deepEqual(viewGame(g, 'c').saphoOptions, []);
|
||||
g = applyAction(restore(g), 'c', {
|
||||
type: 'decision',
|
||||
event: g.pendingNullentropy.event,
|
||||
card: SAPHO_BATTLE_CARD,
|
||||
});
|
||||
assert.deepEqual(g.battleOrder, savedOrder);
|
||||
assert.ok(g.players[2].hand.some((c) => c.id === SAPHO_BATTLE_CARD));
|
||||
reject(g, 'c', old);
|
||||
const fresh = saphoBattleOrderAction(g, 'c', 'last');
|
||||
assert.notEqual(fresh.event, old.event);
|
||||
g = applyAction(g, 'c', fresh);
|
||||
assert.equal(g.battleOrder!.uses.length, 2);
|
||||
assert.equal(g.battleOrder!.priority.at(-1), 'c');
|
||||
assert.equal(g.players[2].spice, 28);
|
||||
assert.equal(g.discard.filter((c) => c.id === SAPHO_BATTLE_CARD).length, 1);
|
||||
restore(g);
|
||||
});
|
||||
|
||||
void test('a clean later boundary accepts remaining battles without reopening the already completed pair', () => {
|
||||
let g = saphoBattleOrderGame({ holder: 'b' });
|
||||
const expired = saphoBattleOrderAction(g, 'b', 'first');
|
||||
g = choose(g);
|
||||
const current = structuredClone(g.battle);
|
||||
reject(g, 'b', expired);
|
||||
assert.deepEqual(g.battle, current);
|
||||
g = resolveSaphoBattle(g);
|
||||
assert.ok(g.players[1].hand.some((c) => c.id === SAPHO_BATTLE_CARD));
|
||||
reject(g, 'b', expired);
|
||||
const completed = structuredClone(g.lastBattleContext);
|
||||
const mode = viewGame(g, 'b').saphoOptions.find(
|
||||
(o) => o.scope === 'battleOrder',
|
||||
)!.mode;
|
||||
g = applyAction(g, 'b', saphoBattleOrderAction(g, 'b', mode));
|
||||
assert.deepEqual(g.lastBattleContext, completed);
|
||||
restore(g);
|
||||
});
|
||||
|
||||
void test('only a currently involved owner sees available order actions; private rival cards cannot affect eligibility', () => {
|
||||
const g = saphoBattleOrderGame();
|
||||
const changed = structuredClone(g);
|
||||
takeSaphoBattleCard(changed, 'b', changed.deck[0].id);
|
||||
for (const id of ['a', 'b', 'c', 'r']) {
|
||||
assert.deepEqual(
|
||||
viewGame(g, id).saphoOptions,
|
||||
viewGame(changed, id).saphoOptions,
|
||||
);
|
||||
assert.deepEqual(
|
||||
viewGame(g, id).battleOrder,
|
||||
viewGame(changed, id).battleOrder,
|
||||
);
|
||||
assert.deepEqual(
|
||||
viewGame(g, id).battleChoices,
|
||||
viewGame(changed, id).battleChoices,
|
||||
);
|
||||
}
|
||||
const ownerless = saphoBattleOrderGame({ holder: 'r' });
|
||||
assert.deepEqual(viewGame(ownerless, 'r').saphoOptions, []);
|
||||
reject(ownerless, 'r', {
|
||||
type: 'card',
|
||||
card: SAPHO_BATTLE_CARD,
|
||||
scope: 'battleOrder',
|
||||
mode: 'first',
|
||||
event: viewGame(ownerless, 'r').battleOrder!.event,
|
||||
});
|
||||
const action = saphoBattleOrderAction(g, 'c', 'first');
|
||||
reject(g, 'a', action, /card|hand|Sapho|opportunity/i);
|
||||
reject(g, 'c', { ...action, extra: true });
|
||||
reject(g, 'c', { ...action, mode: 'aggressor' });
|
||||
});
|
||||
|
||||
void test('malformed order, markers, physical custody and selected chooser reject every read and continuation immutably', () => {
|
||||
let g = saphoBattleOrderGame();
|
||||
g = applyAction(g, 'c', saphoBattleOrderAction(g, 'c', 'first'));
|
||||
const bad: Game[] = [];
|
||||
const mutate = (change: (state: Game) => void) => {
|
||||
const state = structuredClone(g);
|
||||
change(state);
|
||||
bad.push(state);
|
||||
};
|
||||
mutate((s) => {
|
||||
s.battleOrder!.priority.reverse();
|
||||
});
|
||||
mutate((s) => {
|
||||
delete s.battleOrder;
|
||||
});
|
||||
mutate((s) => {
|
||||
delete s.battleOrderUseEvents;
|
||||
});
|
||||
mutate((s) => {
|
||||
s.battleOrder!.uses[0].eligible.push('foreign');
|
||||
});
|
||||
mutate((s) => {
|
||||
s.battleOrder!.uses[0].event = 'expired';
|
||||
});
|
||||
mutate((s) => {
|
||||
s.turn++;
|
||||
});
|
||||
mutate((s) => {
|
||||
s.phase = 7;
|
||||
});
|
||||
mutate((s) => {
|
||||
s.active = 'b';
|
||||
});
|
||||
mutate((s) => {
|
||||
s.discard.push({ ...s.discard.find((c) => c.id === SAPHO_BATTLE_CARD)! });
|
||||
});
|
||||
g = choose(g);
|
||||
mutate((s) => {
|
||||
delete s.battle!.chooser;
|
||||
});
|
||||
mutate((s) => {
|
||||
s.battle!.chooser = 'a';
|
||||
});
|
||||
mutate((s) => {
|
||||
s.battle!.chooserEvent = 'expired';
|
||||
});
|
||||
for (const state of bad) {
|
||||
const before = JSON.stringify(state);
|
||||
for (const p of state.players) assert.throws(() => viewGame(state, p.id));
|
||||
assert.throws(() => normalizeAutomaticGame(state));
|
||||
reject(
|
||||
state,
|
||||
'c',
|
||||
{ type: 'setAutopilot', difficulty: 'Easy' },
|
||||
/Sapho|battle|chooser|opportunity|scheduling|physical/i,
|
||||
);
|
||||
reject(
|
||||
state,
|
||||
'c',
|
||||
{ type: 'advanceBots' },
|
||||
/Sapho|battle|chooser|opportunity|scheduling|physical/i,
|
||||
);
|
||||
assert.equal(JSON.stringify(state), before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('full phase priority keeps a last holder behind a newly battle-relevant Face Dancer seat', () => {
|
||||
const physicalOrder = ['a', 'b', 't'];
|
||||
const state = reorderBattleChoosers({
|
||||
physicalOrder,
|
||||
turn: 2,
|
||||
afterBattle: null,
|
||||
pairs: [{ territory: 'arrakeen', attacker: 'a', defender: 'b' }],
|
||||
event: battleChooserEvent(2, null, 0),
|
||||
player: 'a',
|
||||
mode: 'last',
|
||||
});
|
||||
assert.deepEqual(state.priority, ['b', 't', 'a']);
|
||||
const afterFaceDance = quoteBattleChoosers(
|
||||
[{ territory: 'arrakeen', attacker: 'a', defender: 't' }],
|
||||
physicalOrder,
|
||||
state.priority,
|
||||
);
|
||||
assert.equal(afterFaceDance.current, 't');
|
||||
assert.deepEqual(afterFaceDance.choices[0], {
|
||||
territory: 'arrakeen',
|
||||
attacker: 'a',
|
||||
defender: 't',
|
||||
chooser: 't',
|
||||
});
|
||||
assert.deepEqual(quoteBattleChoosers([], physicalOrder, state.priority), {
|
||||
choices: [],
|
||||
remaining: [],
|
||||
current: null,
|
||||
});
|
||||
});
|
||||
|
||||
void test('actual Basic/Advanced Face Dance brings a new chooser ahead of the surviving Sapho-last bystander', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const fixture = saphoFaceDanceGame(advanced);
|
||||
let g = fixture.game;
|
||||
g = applyAction(g, 'a', saphoBattleOrderAction(g, 'a', 'last'));
|
||||
assert.equal(g.active, 'b');
|
||||
assert.ok(!viewGame(g, 'a').battleOrder!.remaining.includes('t'));
|
||||
g = applyAction(g, 'b', {
|
||||
type: 'chooseBattle',
|
||||
territory: 'arrakeen',
|
||||
target: 'c',
|
||||
});
|
||||
for (let step = 0; g.decision?.kind !== 'faceDance' && step < 60; step++) {
|
||||
const b = g.battle;
|
||||
if (
|
||||
b &&
|
||||
!g.response &&
|
||||
!g.decision &&
|
||||
!b.preparation &&
|
||||
(!b.preLeader || b.preLeader.closed)
|
||||
) {
|
||||
const unsealed = [b.attacker, b.defender].find((id) => !b.plans[id]);
|
||||
if (unsealed) {
|
||||
g = applyAction(g, unsealed, {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
support: 0,
|
||||
leader:
|
||||
unsealed === 'b' ? fixture.winnerLeader : fixture.loserLeader,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const voter = [b.attacker, b.defender].find(
|
||||
(id) => b.traitorCalls[id] === undefined,
|
||||
);
|
||||
if (voter) {
|
||||
g = applyAction(g, voter, { type: 'traitorCall', call: false });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const next = nextSaphoBattleAction(g);
|
||||
assert.ok(next);
|
||||
g = applyAction(g, next.player, next.action);
|
||||
}
|
||||
assert.equal(g.decision?.kind, 'faceDance');
|
||||
assert.equal(g.lastBattleContext!.winner, 'b');
|
||||
assert.equal(viewGame(g, 'a').battleOrder!.current, null);
|
||||
assert.deepEqual(viewGame(g, 'a').saphoOptions, []);
|
||||
const event = g.lastBattleContext!.event,
|
||||
priority = [...g.battleOrder!.priority];
|
||||
for (const p of g.players)
|
||||
assert.deepEqual(
|
||||
viewGame(JSON.parse(JSON.stringify(g)), p.id),
|
||||
viewGame(g, p.id),
|
||||
);
|
||||
g = applyAction(JSON.parse(JSON.stringify(g)), 't', {
|
||||
type: 'decision',
|
||||
reveal: true,
|
||||
sources: { reserves: 1 },
|
||||
sector: 10,
|
||||
});
|
||||
assert.equal(g.players[0].forces['arrakeen:10'], 3);
|
||||
assert.equal(g.players[1].reserves, 20);
|
||||
assert.equal(g.players[4].forces['arrakeen:10'], 1);
|
||||
assert.equal(g.players[4].reserves, 19);
|
||||
assert.equal(g.active, 't');
|
||||
assert.deepEqual(g.battleOrder!.priority, priority);
|
||||
assert.equal(g.lastBattleContext!.event, event);
|
||||
assert.deepEqual(viewGame(g, 'a').battleOrder!.remaining, ['t', 'a']);
|
||||
const next = viewGame(g, 't').battleChoices.find(
|
||||
(choice) => choice.chooser === 't',
|
||||
)!;
|
||||
assert.deepEqual(next, {
|
||||
territory: 'arrakeen',
|
||||
attacker: 'a',
|
||||
defender: 't',
|
||||
chooser: 't',
|
||||
});
|
||||
saphoFaceDanceCustody(g);
|
||||
for (const p of g.players)
|
||||
assert.deepEqual(
|
||||
viewGame(JSON.parse(JSON.stringify(g)), p.id),
|
||||
viewGame(g, p.id),
|
||||
);
|
||||
g = applyAction(g, 't', {
|
||||
type: 'chooseBattle',
|
||||
territory: 'arrakeen',
|
||||
target: 'a',
|
||||
});
|
||||
assert.equal(g.battle!.chooser, 't');
|
||||
assert.equal(g.battle!.attacker, 'a');
|
||||
assert.equal(g.battle!.defender, 't');
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user