202 lines
15 KiB
TypeScript
202 lines
15 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { applyAction, createGame, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { treacheryDeck } from '../game/cards';
|
|
import { STRONGHOLD_CARDS, strongholdControllers } from '../game/stronghold-cards';
|
|
import { assassinationPhysical } from './moritani-assassinate-fixture';
|
|
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
|
|
import {
|
|
advanceMoritaniStrongholds, createMoritaniStrongholdsFixture, holdMoritaniStrongholdsTraitor,
|
|
moritaniStrongholdsPlayer as player, moritaniStrongholdsPolicy, nextMoritaniStrongholdsStep,
|
|
resolveMoritaniStrongholdsBattle, stageMoritaniStrongholdsBattle,
|
|
} from './fixture-moritani-strongholds';
|
|
|
|
function custody(game: Game) {
|
|
const cards = [...game.deck, ...game.discard, ...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
|
|
assert.deepEqual(cards, treacheryDeck(['ecaz']).map(c => c.id).sort());
|
|
assert.equal(new Set(cards).size, cards.length);
|
|
for (const p of game.players) {
|
|
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20);
|
|
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0), p.faction === 'emperor' ? 5 : 3);
|
|
}
|
|
const traitors = assassinationPhysical(game).traitors;
|
|
assert.equal(new Set(traitors).size, traitors.length);
|
|
}
|
|
function revealAssassination(game: Game, card: string) {
|
|
const d = game.decision; assert.equal(d?.kind, 'moritaniAssassinate');
|
|
if (d?.kind !== 'moritaniAssassinate') throw Error('Original loss decision is required');
|
|
return applyAction(game, d.player, { type: 'decision', event: d.event, card });
|
|
}
|
|
function finishBattle(game: Game) {
|
|
return advanceMoritaniStrongholds(game, state => !state.battle && !state.decision && !state.response && !state.pendingTreacheryDiscard);
|
|
}
|
|
|
|
void test('standalone native Advanced Moritani preserves authenticated seats, exact ecaz deck and six real first-Mentat card claims', () => {
|
|
let lobby = createGame('AUTHMORITANI', newPlayer('authenticated-m', 'Original Moritani', 'moritani'), true, ['ecaz']);
|
|
joinGame(lobby, newPlayer('authenticated-g', 'Original Guild', 'guild'));
|
|
joinGame(lobby, newPlayer('authenticated-e', 'Original Emperor', 'emperor'));
|
|
for (const p of lobby.players) lobby = applyAction(lobby, p.id, { type: 'ready' });
|
|
const original = structuredClone(lobby), f = createMoritaniStrongholdsFixture({ initial: lobby });
|
|
assert.deepEqual(lobby, original);
|
|
assert.deepEqual(f.afterSetup.players.map(p => ({ id: p.id, name: p.name, faction: p.faction })), original.players.map(p => ({ id: p.id, name: p.name, faction: p.faction })));
|
|
for (const game of [f.initial, f.afterSetup, f.afterFirstMentat, f.pending]) custody(game);
|
|
assert.equal(f.afterSetup.leaderSkills, undefined);
|
|
assert.equal(f.afterSetup.players.some(p => p.faction === 'ecaz' || p.faction === 'harkonnen'), false);
|
|
assert.equal(f.initial.strongholdCards!.claimedTurn, 0);
|
|
assert.deepEqual(Object.keys(f.initial.strongholdCards!.owners), STRONGHOLD_CARDS.map(c => c.id));
|
|
assert.ok(Object.values(f.initial.strongholdCards!.owners).every(id => id === null));
|
|
assert.equal(f.beforeFirstMentat.phase, 8);
|
|
const claimed = applyAction(structuredClone(f.beforeFirstMentat), f.firstMentatStep.actor, f.firstMentatStep.action);
|
|
assert.equal(claimed.turn, 2);
|
|
assert.deepEqual(claimed.strongholdCards, f.afterFirstMentat.strongholdCards);
|
|
assert.deepEqual(claimed.strongholdCards!.owners, strongholdControllers(f.beforeFirstMentat.players, false));
|
|
assert.deepEqual(f.pending.strongholdCards, claimed.strongholdCards, 'A real loss retains recorded cards until the next end-Mentat checkpoint');
|
|
assert.equal(f.pending.strongholdCards!.owners.tueks_sietch, f.moritani);
|
|
assert.equal(strongholdControllers(f.pending.players, false).tueks_sietch, f.opponent);
|
|
});
|
|
|
|
void test('Tuek pays its actual losing holder for both played Worthless cards, then original assassination pays one printed bounty and one private next-Mentat replacement', () => {
|
|
const f = createMoritaniStrongholdsFixture(), pending = f.pending;
|
|
assert.equal(pending.decision?.kind, 'moritaniAssassinate');
|
|
assert.equal(pending.lastBattleContext?.winner, f.opponent);
|
|
const quote = quoteStrongholdFactionsBattle(f.revealed);
|
|
assert.deepEqual(quote.strongholdIncome, [{ player: f.moritani, amount: 4 }]);
|
|
assert.equal(quote.bounty?.amount ?? 0, 0);
|
|
const moritaniBefore = player(f.revealed, f.moritani).spice, opponentBefore = player(f.revealed, f.opponent).spice;
|
|
assert.equal(player(pending, f.moritani).spice, moritaniBefore - 1 + 4, 'Native wallet minus one actual support plus two printed two-spice Worthless payouts');
|
|
assert.equal(player(pending, f.opponent).spice, opponentBefore - 3, 'The unheld winner Worthless card earns no Tuek payout');
|
|
assert.equal(player(pending, f.moritani).leaders.find(l => l.id === f.moritaniLeader)!.dead, false);
|
|
assert.equal(player(pending, f.opponent).leaders.find(l => l.id === f.opponentLeader)!.dead, false);
|
|
const target = structuredClone(player(pending, f.opponent).leaders.find(l => l.id === f.target)!);
|
|
assert.notEqual(target.id, f.opponentLeader);
|
|
assert.equal(target.dead, false);
|
|
const physical = assassinationPhysical(pending), reserve = [...pending.traitorReserve!], held = [...player(pending, f.moritani).traitors];
|
|
const deaths = pending.players.flatMap(p => p.leaders).map(l => ({ id: l.id, deaths: l.deaths }));
|
|
const tanks = pending.players.map(p => ({ id: p.id, tanks: p.tanks, elites: p.elites?.tanks }));
|
|
let game = revealAssassination(structuredClone(pending), f.target);
|
|
assert.equal(player(game, f.moritani).spice, moritaniBefore - 1 + 4 + target.strength);
|
|
assert.equal(player(game, f.opponent).spice, opponentBefore - 3);
|
|
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.deaths, target.deaths + 1);
|
|
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.bounty, target.strength);
|
|
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed');
|
|
assert.deepEqual(game.traitorReserve, reserve);
|
|
assert.deepEqual(player(game, f.moritani).traitors, held, 'Revealed physical card remains held until original Mentat replacement');
|
|
assert.equal(game.decision?.kind, 'battleCards', 'Original saved winner-card cleanup resumes after reveal');
|
|
assert.equal(game.decision?.player, f.opponent);
|
|
game = applyAction(game, f.opponent, { type: 'decision', discard: f.opponentCards });
|
|
game = finishBattle(game);
|
|
for (const card of [...f.moritaniCards, ...f.opponentCards]) assert.equal(game.discard.filter(c => c.id === card).length, 1);
|
|
assert.deepEqual(normalizeAutomaticGame(structuredClone(game)), game);
|
|
game = advanceMoritaniStrongholds(game, state => state.phase === 8);
|
|
const receipt = game.moritaniAssassinate!.opportunities.at(-1)!;
|
|
assert.equal(receipt.stage, 'replaced');
|
|
assert.equal(receipt.replacement, reserve[0]);
|
|
assert.deepEqual(game.traitorReserve, reserve.slice(1));
|
|
assert.deepEqual(player(game, f.moritani).traitors, [...held.filter(id => id !== f.target), reserve[0]]);
|
|
assert.equal(game.players.flatMap(p => p.traitors).includes(f.target), false);
|
|
assert.equal(game.traitorReserve!.includes(f.target), false);
|
|
assert.equal(player(game, f.moritani).spice, moritaniBefore - 1 + 4 + target.strength, 'Neither support debit, Stronghold income nor printed assassination bounty replays');
|
|
assert.deepEqual(game.players.map(p => ({ id: p.id, tanks: p.tanks, elites: p.elites?.tanks })),
|
|
tanks.map(entry => entry.id === f.opponent ? { ...entry, tanks: entry.tanks + 3 } : entry),
|
|
'Original winning Guild dial3 casualties finish after assassination and winner-card cleanup, once only');
|
|
for (const old of deaths) assert.equal(game.players.flatMap(p => p.leaders).find(l => l.id === old.id)!.deaths, old.deaths + (old.id === f.target ? 1 : 0));
|
|
assert.deepEqual(assassinationPhysical(game), physical);
|
|
const rivalReceipt = viewGame(game, f.opponent).moritaniAssassinate!.history.at(-1)!;
|
|
assert.equal(rivalReceipt.card, f.target);
|
|
assert.equal('replacement' in rivalReceipt, false);
|
|
custody(game);
|
|
const suffix = advanceMoritaniStrongholds(structuredClone(game), state => state.turn === 3);
|
|
assert.equal(suffix.traitorReserve!.length, reserve.length - 1, 'End-Mentat card claim does not draw a second replacement');
|
|
assert.equal(player(suffix, f.moritani).spice, player(game, f.moritani).spice);
|
|
assert.equal(suffix.strongholdCards!.claimedTurn, 2);
|
|
assert.equal(suffix.strongholdCards!.owners.tueks_sietch, f.opponent);
|
|
assert.deepEqual(Object.keys(suffix.strongholdCards!.owners), STRONGHOLD_CARDS.map(c => c.id));
|
|
});
|
|
|
|
void test('Tuek follows the actual holder, including a winning Guild plan, rather than paying every native participant', () => {
|
|
const f = createMoritaniStrongholdsFixture({ holder: 'guild' });
|
|
const quote = quoteStrongholdFactionsBattle(f.revealed);
|
|
assert.deepEqual(quote.strongholdIncome, [{ player: f.opponent, amount: 2 }]);
|
|
assert.equal(player(f.pending, f.moritani).spice, player(f.revealed, f.moritani).spice - 1);
|
|
assert.equal(player(f.pending, f.opponent).spice, player(f.revealed, f.opponent).spice - 3 + 2);
|
|
assert.equal(f.pending.decision?.kind, 'moritaniAssassinate');
|
|
assert.equal(player(f.pending, f.moritani).leaders.find(l => l.id === f.moritaniLeader)!.dead, false);
|
|
custody(f.pending);
|
|
});
|
|
|
|
void test('Carthag gives the native holder-plan Shield its printed Snooper property, preserving an ordinary losing Moritani leader with no invented death bounty', () => {
|
|
const f = createMoritaniStrongholdsFixture({ kind: 'carthag' });
|
|
assert.equal(f.pending.lastBattleContext?.winner, f.opponent);
|
|
assert.equal(f.pending.decision?.kind, 'moritaniAssassinate');
|
|
assert.equal(player(f.pending, f.moritani).leaders.find(l => l.id === f.moritaniLeader)!.dead, false);
|
|
assert.equal(player(f.pending, f.opponent).leaders.find(l => l.id === f.opponentLeader)!.dead, false);
|
|
assert.equal(quoteStrongholdFactionsBattle(f.revealed).bounty?.amount ?? 0, 0);
|
|
assert.equal(player(f.pending, f.moritani).spice, player(f.revealed, f.moritani).spice - 1);
|
|
assert.equal(player(f.pending, f.opponent).spice, player(f.revealed, f.opponent).spice - 3);
|
|
const declared = f.pending.decision!;
|
|
let declined = applyAction(f.pending, f.moritani, { type: 'decision', event: declared.kind === 'moritaniAssassinate' ? declared.event : '', decline: true });
|
|
declined = finishBattle(declined);
|
|
assert.equal(declined.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined');
|
|
assert.equal(player(declined, f.moritani).leaders.find(l => l.id === f.moritaniLeader)!.dead, false);
|
|
custody(declined);
|
|
const unheld = createMoritaniStrongholdsFixture({ kind: 'carthag', holder: 'guild' });
|
|
const killed = player(unheld.pending, unheld.moritani).leaders.find(l => l.id === unheld.moritaniLeader)!;
|
|
assert.equal(killed.dead, true, 'Opponent card custody does not lend Snooper coverage to the losing Shield plan');
|
|
assert.equal(player(unheld.pending, unheld.opponent).spice, player(unheld.revealed, unheld.opponent).spice - 3 + killed.strength, 'An actual poison death, unlike mere defeat, pays its printed bounty');
|
|
custody(unheld.pending);
|
|
});
|
|
|
|
void test('a native normal Traitor reveal forfeits assassination across the actual next turn and a different classic opposing faction', () => {
|
|
const f = createMoritaniStrongholdsFixture({ normalCall: true });
|
|
assert.equal(f.pending.lastBattleContext?.winner, f.moritani);
|
|
assert.equal(f.pending.moritaniAssassinate!.normalTraitorCall, true);
|
|
assert.equal(f.pending.moritaniAssassinate!.opportunities.length, 0);
|
|
assert.equal(player(f.pending, f.opponent).leaders.find(l => l.id === f.opponentLeader)!.dead, true);
|
|
let game = advanceMoritaniStrongholds(f.pending, state => state.turn === 3 && state.phase === 5 && !state.phaseOpening && !state.response && !state.decision);
|
|
const emperor = game.players.find(p => p.faction === 'emperor')!.id;
|
|
stageMoritaniStrongholdsBattle(game, f.moritani, emperor, 'tueks_sietch');
|
|
const loser = player(game, f.moritani).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
|
|
const winner = player(game, emperor).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
|
|
const target = player(game, emperor).leaders.find(l => !l.dead && l.id !== winner.id)!;
|
|
holdMoritaniStrongholdsTraitor(game, f.moritani, target.id);
|
|
game = advanceMoritaniStrongholds(game, state => state.phase === 6 && !state.phaseOpening && !state.response && !state.decision);
|
|
const actor = game.active!; assert.ok([f.moritani, emperor].includes(actor));
|
|
game = applyAction(game, actor, { type: 'chooseBattle', territory: 'tueks_sietch', target: actor === f.moritani ? emperor : f.moritani });
|
|
game = advanceMoritaniStrongholds(game, state => !!state.battle && !state.battle.revealed && !nextMoritaniStrongholdsStep(state));
|
|
game = applyAction(game, f.moritani, { type: 'battlePlan', leader: loser.id, dial: 0, support: 0 });
|
|
game = applyAction(game, emperor, { type: 'battlePlan', leader: winner.id, dial: 2, support: 2 });
|
|
game = resolveMoritaniStrongholdsBattle(game, f.moritani);
|
|
assert.equal(game.lastBattleContext?.winner, emperor);
|
|
assert.equal(game.moritaniAssassinate!.normalTraitorCall, true);
|
|
assert.equal(game.moritaniAssassinate!.opportunities.length, 0);
|
|
assert.notEqual(game.decision?.kind, 'moritaniAssassinate');
|
|
assert.equal(player(game, emperor).leaders.find(l => l.id === target.id)!.dead, false);
|
|
assert.equal(player(game, f.moritani).leaders.find(l => l.id === loser.id)!.dead, false);
|
|
custody(game);
|
|
});
|
|
|
|
void test('all four minimal native policies perform original Terror choices and loss reveal/decline without a Terror fee or premature card transfer', () => {
|
|
const f = createMoritaniStrongholdsFixture();
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const placement = moritaniStrongholdsPolicy(f.beforePlacement, f.moritani, difficulty).find(a => a.type === 'decision'); assert.ok(placement);
|
|
const placed = applyAction(structuredClone(f.beforePlacement), f.moritani, placement);
|
|
assert.deepEqual(placed.players.map(p => ({ id: p.id, spice: p.spice })), f.beforePlacement.players.map(p => ({ id: p.id, spice: p.spice })), 'Printed Terror placement has no fee');
|
|
assert.deepEqual(placed.strongholdCards, f.beforePlacement.strongholdCards);
|
|
const action = moritaniStrongholdsPolicy(f.pending, f.moritani, difficulty)[0]; assert.ok(action);
|
|
const revealed = applyAction(structuredClone(f.pending), f.moritani, action);
|
|
assert.equal(revealed.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed');
|
|
assert.equal(revealed.moritaniAssassinate!.opportunities.at(-1)!.card, f.target);
|
|
// Private eligibility is varied by a conserved swap before actual plan
|
|
// submission, never by modifying an original assassination receipt.
|
|
let empty = structuredClone(f.game);
|
|
holdMoritaniStrongholdsTraitor(empty, f.moritani, player(empty, f.moritani).leaders[0].id);
|
|
for (const plan of f.planActions) empty = applyAction(empty, plan.actor, plan.action);
|
|
empty = resolveMoritaniStrongholdsBattle(empty, f.moritani);
|
|
const decline = moritaniStrongholdsPolicy(empty, f.moritani, difficulty)[0]; assert.ok(decline);
|
|
const declined = applyAction(empty, f.moritani, decline);
|
|
assert.equal(declined.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined');
|
|
custody(placed); custody(revealed); custody(declined);
|
|
}
|
|
});
|