487 lines
17 KiB
TypeScript
487 lines
17 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
applyAction,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
type Action,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
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('clean between-battle Sapho discard offers Semuta before chooser acts', () => {
|
|
for (const [owner, mode, nextChooser] of [
|
|
['c', 'first', 'c'],
|
|
['a', 'last', 'b'],
|
|
] as const) {
|
|
let g = saphoBattleOrderGame({ holder: owner, semutaPreview: true });
|
|
takeSaphoBattleCard(g, 'r', SEMUTA_DRUG_ID);
|
|
const initialPairs = pairs(g);
|
|
g = applyAction(g, owner, saphoBattleOrderAction(g, owner, mode));
|
|
const event = g.pendingTreacheryDiscard!.batch.event;
|
|
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
|
assert.equal(g.active, nextChooser);
|
|
assert.equal(g.battleOrder?.uses.length, 1);
|
|
assert.deepEqual(pairs(g), initialPairs);
|
|
assert.equal(viewGame(g, 'r').semutaReaction?.canCommit, true);
|
|
assert.equal(viewGame(g, 'b').semutaReaction?.canCommit, false);
|
|
assert.deepEqual(normalizeAutomaticGame(restore(g)), restore(g));
|
|
let declined = restore(g);
|
|
for (const id of ['a', 'b', 'c', 'r'])
|
|
declined = applyAction(declined, id, { type: 'semutaPass', event });
|
|
assert.equal(declined.pendingTreacheryDiscard, null);
|
|
assert.equal(declined.battleOrder?.uses.length, 1);
|
|
const claimed = applyAction(restore(g), 'r', { type: 'semutaCommit', event });
|
|
assert.equal(claimed.pendingTreacheryDiscard, null);
|
|
assert.equal(claimed.players.find(p => p.id === 'r')!.hand.filter(c => c.id === SAPHO_BATTLE_CARD).length, 1);
|
|
assert.equal(claimed.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, 1);
|
|
assert.deepEqual(claimed.battleOrder, declined.battleOrder);
|
|
const chosen = choose(claimed);
|
|
assert.ok(chosen.battle);
|
|
assert.equal(chosen.battleOrder?.uses.length, 1);
|
|
reject(claimed, 'r', { type: 'semutaCommit', event }, /fresh|available/i);
|
|
}
|
|
});
|
|
|
|
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);
|
|
// This human saves Sapho for the next boundary instead of taking the new AI aggressor option.
|
|
g = applyAction(g, 'b', {type:'battlePreparationReady', event:g.battle!.event});
|
|
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;
|
|
assert.ok(mode === 'first' || mode === 'last');
|
|
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');
|
|
}
|
|
});
|