248 lines
8.8 KiB
TypeScript
248 lines
8.8 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
normalizeAutomaticGame,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { baseDeck, type Card } from '../game/cards';
|
|
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
function fixture(moritani = false, twoBattles = false) {
|
|
const g = createGame('CONTEXTLIFE', newPlayer('w', 'Winner', 'guild'));
|
|
g.players.push(newPlayer('l', 'Opponent', 'emperor'));
|
|
if (moritani) g.players.push(newPlayer('m', 'Moritani', 'moritani'));
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 6,
|
|
turn: 2,
|
|
storm: 18,
|
|
active: 'w',
|
|
order: g.players.map((p) => p.id),
|
|
deck: baseDeck(),
|
|
});
|
|
for (const p of g.players) {
|
|
p.hand = [];
|
|
p.spice = 20;
|
|
p.traitors = [];
|
|
p.forces = p.id === 'm' ? {} : { 'arrakeen:10': 3 };
|
|
if (twoBattles && p.id !== 'm') p.forces['carthag:11'] = 3;
|
|
p.reserves = 20 - Object.values(p.forces).reduce((a, b) => a + b, 0);
|
|
for (const leader of p.leaders) leader.strength = 0;
|
|
}
|
|
if (moritani) {
|
|
g.players[1].ally = 'm';
|
|
g.players[2].ally = 'l';
|
|
}
|
|
return g;
|
|
}
|
|
function hold(g: Game, owner: string, kind: Card['kind']) {
|
|
const at = g.deck.findIndex((c) => c.kind === kind);
|
|
assert.ok(at >= 0);
|
|
const card = g.deck.splice(at, 1)[0];
|
|
g.players.find((p) => p.id === owner)!.hand.push(card);
|
|
return card.id;
|
|
}
|
|
function inventory(g: Game) {
|
|
const all = [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
|
|
.map((c) => c.id)
|
|
.sort();
|
|
assert.equal(new Set(all).size, all.length);
|
|
return all;
|
|
}
|
|
function fight(
|
|
state: Game,
|
|
territory = 'arrakeen',
|
|
winnerDefense?: string,
|
|
loserDefense?: string,
|
|
) {
|
|
let g = applyAction(state, 'w', {
|
|
type: 'chooseBattle',
|
|
territory,
|
|
target: 'l',
|
|
});
|
|
while (g.response || g.battle?.preparation) {
|
|
if (g.response)
|
|
g = applyAction(
|
|
g,
|
|
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
|
|
{ type: 'passResponse' },
|
|
);
|
|
else
|
|
g = applyAction(g, g.battle!.preparation!.owner, {
|
|
type: 'declineBattlePower',
|
|
});
|
|
}
|
|
const event = g.battle!.event;
|
|
for (const id of ['w', 'l']) {
|
|
const p = g.players.find((p) => p.id === id)!;
|
|
const leader = p.leaders.find(
|
|
(l) => !l.dead && (!l.usedAt || l.usedAt === territory),
|
|
)!;
|
|
assert.ok(leader);
|
|
g = applyAction(g, id, {
|
|
type: 'battlePlan',
|
|
dial: id === 'w' ? 1 : 0,
|
|
leader: leader.id,
|
|
defense: id === 'w' ? winnerDefense : loserDefense,
|
|
});
|
|
}
|
|
g = applyAction(g, 'w', { type: 'traitorCall', call: false });
|
|
g = applyAction(g, 'l', { type: 'traitorCall', call: false });
|
|
assert.equal(g.battle, null);
|
|
assert.equal(g.lastBattleContext!.event, event);
|
|
return g;
|
|
}
|
|
function resources(g: Game) {
|
|
return g.players.map((p) => ({
|
|
id: p.id,
|
|
spice: p.spice,
|
|
tanks: p.tanks,
|
|
forces: p.forces,
|
|
leaders: p.leaders,
|
|
}));
|
|
}
|
|
|
|
void test('a genuine winner-card cleanup saved without context reconstructs one legacy receipt without replaying battle effects', () => {
|
|
const initial = fixture();
|
|
const shield = hold(initial, 'w', 'shield');
|
|
const cards = inventory(initial);
|
|
let g = fight(initial, 'arrakeen', shield);
|
|
assert.equal(g.decision?.kind, 'battleCards');
|
|
const settled = resources(g);
|
|
delete g.lastBattleContext;
|
|
g = reload(g);
|
|
assert.equal(normalizeAutomaticGame(g).lastBattleContext, undefined);
|
|
g = applyAction(g, 'w', { type: 'decision', discard: [shield] });
|
|
assert.equal(g.lastBattleContext!.result, 'legacy');
|
|
assert.equal(g.lastBattleContext!.territory, 'arrakeen');
|
|
assert.deepEqual(g.lastBattleContext!.combatants, ['w', 'l']);
|
|
assert.equal(g.lastBattleContext!.winner, 'w');
|
|
assert.equal(g.lastBattleContext!.turn, 2);
|
|
assert.deepEqual(resources(g), settled);
|
|
assert.deepEqual(inventory(g), cards);
|
|
assert.equal(g.discard.filter((c) => c.id === shield).length, 1);
|
|
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
|
|
assert.throws(() =>
|
|
applyAction(g, 'w', { type: 'decision', discard: [shield] }),
|
|
);
|
|
});
|
|
|
|
void test('legacy winner and Moritani cleanups share one resolved battle receipt across separate discard continuations', () => {
|
|
for (const winnerCard of [false, true]) {
|
|
const initial = fixture(true);
|
|
const shield = winnerCard ? hold(initial, 'w', 'shield') : undefined;
|
|
const snooper = hold(initial, 'l', 'snooper');
|
|
const cards = inventory(initial);
|
|
let g = fight(initial, 'arrakeen', shield, snooper);
|
|
const settled = resources(g);
|
|
delete g.lastBattleContext;
|
|
if (winnerCard) {
|
|
assert.equal(g.decision?.kind, 'battleCards');
|
|
g = applyAction(reload(g), 'w', { type: 'decision', discard: [shield!] });
|
|
assert.equal(g.lastBattleContext!.result, 'legacy');
|
|
}
|
|
assert.equal(g.decision?.kind, 'moritaniRetention');
|
|
const event = g.lastBattleContext?.event;
|
|
g = applyAction(reload(g), 'l', { type: 'decision', keep: null });
|
|
assert.equal(g.moritaniRetention, null);
|
|
assert.equal(g.lastBattleContext!.result, 'legacy');
|
|
if (event) assert.equal(g.lastBattleContext!.event, event);
|
|
assert.deepEqual(g.lastBattleContext!.combatants, ['w', 'l']);
|
|
assert.equal(g.lastBattleContext!.winner, 'w');
|
|
assert.deepEqual(resources(g), settled);
|
|
assert.deepEqual(inventory(g), cards);
|
|
assert.equal(g.discard.filter((c) => c.id === snooper).length, 1);
|
|
assert.equal(g.resolvedTreacheryDiscardSequence, winnerCard ? 2 : 1);
|
|
}
|
|
});
|
|
|
|
void test('a second genuine battle in the same phase replaces the first context before its own card cleanup', () => {
|
|
const initial = fixture(false, true);
|
|
const shield = hold(initial, 'w', 'shield');
|
|
const firstDefense = hold(initial, 'l', 'snooper');
|
|
const secondDefense = hold(initial, 'l', 'snooper');
|
|
const cards = inventory(initial);
|
|
let g = fight(initial, 'arrakeen', shield, firstDefense);
|
|
const first = structuredClone(g.lastBattleContext!);
|
|
g = applyAction(g, 'w', { type: 'decision', discard: [] });
|
|
assert.equal(g.phase, 6);
|
|
g = fight(reload(g), 'carthag', shield, secondDefense);
|
|
assert.notEqual(g.lastBattleContext!.event, first.event);
|
|
assert.equal(g.lastBattleContext!.territory, 'carthag');
|
|
assert.equal(g.lastBattleContext!.turn, first.turn);
|
|
assert.equal(g.lastBattleContext!.result, 'normal');
|
|
const second = structuredClone(g.lastBattleContext!);
|
|
g = applyAction(g, 'w', { type: 'decision', discard: [shield] });
|
|
assert.deepEqual(g.lastBattleContext, second);
|
|
assert.deepEqual(inventory(g), cards);
|
|
assert.equal(g.resolvedTreacheryDiscardSequence, 3);
|
|
assert.equal(
|
|
g.discard.filter((c) =>
|
|
[shield, firstDefense, secondDefense].includes(c.id),
|
|
).length,
|
|
3,
|
|
);
|
|
});
|
|
|
|
void test('a later-turn genuine battle replaces a retained previous-turn context even in the same territory', () => {
|
|
const initial = fixture();
|
|
const shield = hold(initial, 'w', 'shield');
|
|
let g = fight(initial, 'arrakeen', shield);
|
|
g = applyAction(g, 'w', { type: 'decision', discard: [] });
|
|
const first = structuredClone(g.lastBattleContext!);
|
|
assert.equal(g.phase, 7);
|
|
// Advance the actual Collection and Mentat boundaries into the next turn.
|
|
for (let phase = 0; phase < 2; phase++)
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
assert.equal(g.turn, 3);
|
|
assert.deepEqual(g.lastBattleContext, first);
|
|
// Stage the later battle's forces after intervening phases; both battles and
|
|
// their cleanup execute real public actions. No prior context is normalized.
|
|
g.phase = 6;
|
|
g.active = 'w';
|
|
g.response = null;
|
|
g.decision = null;
|
|
g.phaseOpening = null;
|
|
g.players[1].forces = { 'arrakeen:10': 1 };
|
|
g.players[1].reserves--;
|
|
g = fight(reload(g), 'arrakeen', shield);
|
|
assert.equal(g.lastBattleContext!.turn, 3);
|
|
assert.equal(g.lastBattleContext!.territory, 'arrakeen');
|
|
assert.notEqual(g.lastBattleContext!.event, first.event);
|
|
assert.equal(g.lastBattleContext!.result, 'normal');
|
|
g = applyAction(g, 'w', { type: 'decision', discard: [shield] });
|
|
assert.equal(g.discard.filter((c) => c.id === shield).length, 1);
|
|
assert.equal(g.lastBattleContext!.turn, 3);
|
|
});
|
|
|
|
void test('existing mismatched context is rejected rather than being silently replaced with a legacy receipt', () => {
|
|
const initial = fixture();
|
|
const shield = hold(initial, 'w', 'shield');
|
|
const resolved = fight(initial, 'arrakeen', shield);
|
|
for (const corrupt of [
|
|
(g: Game) => {
|
|
g.lastBattleContext!.turn--;
|
|
},
|
|
(g: Game) => {
|
|
g.lastBattleContext!.territory = 'carthag';
|
|
},
|
|
(g: Game) => {
|
|
g.lastBattleContext!.winner = 'l';
|
|
},
|
|
(g: Game) => {
|
|
g.lastBattleContext!.combatants.reverse();
|
|
},
|
|
]) {
|
|
const g = reload(resolved);
|
|
corrupt(g);
|
|
const before = reload(g);
|
|
assert.throws(() =>
|
|
applyAction(g, 'w', { type: 'decision', discard: [shield] }),
|
|
);
|
|
assert.deepEqual(g, before);
|
|
}
|
|
});
|