518 lines
15 KiB
TypeScript
518 lines
15 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import { mock } from 'node:test';
|
||
import test from 'node:test';
|
||
import { baseDeck, type Card } from '../game/cards';
|
||
import {
|
||
applyAction,
|
||
createGame,
|
||
initializeLeaderSkillsGameForAudit,
|
||
joinGame,
|
||
newPlayer,
|
||
viewGame,
|
||
type Game,
|
||
} from '../game/engine';
|
||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||
import { placeFixtureHand } from './fixture-hand';
|
||
import { botActions } from '../game/bots';
|
||
|
||
const SEATS = [
|
||
['e', 'Emperor seat', 'emperor'],
|
||
['h', 'Harkonnen seat', 'harkonnen'],
|
||
['a', 'Atreides seat', 'atreides'],
|
||
['b', 'Bene Gesserit seat', 'beneGesserit'],
|
||
['f', 'Fremen seat', 'fremen'],
|
||
['g', 'Guild seat', 'guild'],
|
||
] as const;
|
||
|
||
function assertSkillCustody(g: Game) {
|
||
assert.ok(g.leaderSkills);
|
||
const physical = [
|
||
...g.leaderSkills.deck,
|
||
...g.leaderSkills.assignments.map((assignment) => assignment.skill),
|
||
...Object.values(g.leaderSkills.offers).flatMap((offer) => offer.cards),
|
||
];
|
||
assert.equal(physical.length, 14);
|
||
assert.equal(new Set(physical).size, 14);
|
||
assert.deepEqual(
|
||
[...physical].sort(),
|
||
LEADER_SKILL_CARDS.map((card) => card.id).sort(),
|
||
);
|
||
}
|
||
|
||
/** Put Warmaster in the Emperor's genuine two-card offer. */
|
||
function beginSetup(): Game {
|
||
let g = createGame(
|
||
'SKILLCAP',
|
||
newPlayer(SEATS[0][0], SEATS[0][1], SEATS[0][2]),
|
||
);
|
||
for (const [id, name, faction] of SEATS.slice(1))
|
||
joinGame(g, newPlayer(id, name, faction));
|
||
for (const [id] of SEATS) g = applyAction(g, id, { type: 'ready' });
|
||
|
||
const targetIndex = LEADER_SKILL_CARDS.findIndex(
|
||
(card) => card.id === 'warmaster',
|
||
);
|
||
let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
|
||
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
|
||
array[0] = shuffleIndex-- === targetIndex ? 0 : 0xffffffff;
|
||
return array;
|
||
});
|
||
try {
|
||
g = initializeLeaderSkillsGameForAudit(g);
|
||
} finally {
|
||
mock.restoreAll();
|
||
}
|
||
|
||
assert.equal(g.setupStage, 'prediction');
|
||
g = applyAction(g, 'b', {
|
||
type: 'predict',
|
||
faction: 'atreides',
|
||
turn: 4,
|
||
});
|
||
assert.equal(g.setupStage, 'leaderSkills');
|
||
assert.ok(g.leaderSkills?.offers.e.cards.includes('warmaster'));
|
||
assertSkillCustody(g);
|
||
return g;
|
||
}
|
||
|
||
function finishSetup(
|
||
state: Game,
|
||
emperorSkillLeader: 'emperor-1' | 'emperor-2',
|
||
): Game {
|
||
let g = state;
|
||
for (const [id] of SEATS) {
|
||
const view = viewGame(g, id);
|
||
const offer = view.leaderSkills!.offer!;
|
||
g = applyAction(g, id, {
|
||
type: 'leaderSkill',
|
||
event: offer.event,
|
||
skill: id === 'e' ? 'warmaster' : offer.cards[0],
|
||
leader:
|
||
id === 'e'
|
||
? emperorSkillLeader
|
||
: view.leaderSkills!.eligibleLeaders[0].id,
|
||
});
|
||
}
|
||
assert.equal(g.setupStage, 'traitors');
|
||
while (g.setupStage === 'traitors') {
|
||
const owner = g.players.find((player) => player.traitorChoices.length);
|
||
assert.ok(owner);
|
||
g = applyAction(g, owner.id, {
|
||
type: 'traitor',
|
||
leader: owner.traitorChoices[0],
|
||
});
|
||
}
|
||
assert.equal(g.setupStage, 'forces');
|
||
g = applyAction(g, 'f', {
|
||
type: 'fremenSetup',
|
||
placements: { sietch_tabr: 10 },
|
||
});
|
||
assert.equal(g.status, 'playing');
|
||
assertSkillCustody(g);
|
||
return g;
|
||
}
|
||
|
||
function takeCard(kind: Card['kind'], excluded: readonly string[] = []): Card {
|
||
const card = baseDeck().find(
|
||
(candidate) => candidate.kind === kind && !excluded.includes(candidate.id),
|
||
);
|
||
assert.ok(card, `expected a physical ${kind} card`);
|
||
return card;
|
||
}
|
||
|
||
function passResponses(state: Game): Game {
|
||
let g = state;
|
||
while (g.response) {
|
||
const owner = g.players.find(
|
||
(player) => !g.response!.passed.includes(player.id),
|
||
);
|
||
assert.ok(owner);
|
||
g = applyAction(g, owner.id, { type: 'passResponse' });
|
||
}
|
||
return g;
|
||
}
|
||
|
||
function clearBattlePreparation(state: Game): Game {
|
||
let g = state;
|
||
while (
|
||
g.decision?.kind === 'leaderSkillVisibility' ||
|
||
g.decision?.kind === 'mentatQuestion' ||
|
||
g.decision?.kind === 'fullPlanOffer' ||
|
||
g.battle?.preparation
|
||
)
|
||
if (g.decision?.kind === 'leaderSkillVisibility')
|
||
g = applyAction(g, g.decision.player, {
|
||
type: 'leaderSkillVisibility',
|
||
event: g.decision.event,
|
||
hide: false,
|
||
});
|
||
else if (g.decision?.kind === 'mentatQuestion')
|
||
g = applyAction(g, g.decision.player, {
|
||
type: 'decision',
|
||
event: g.decision.event,
|
||
decline: true,
|
||
});
|
||
else if (g.decision?.kind === 'fullPlanOffer')
|
||
g = applyAction(g, g.decision.player, {
|
||
type: 'decision',
|
||
decline: true,
|
||
});
|
||
else
|
||
g = applyAction(g, g.battle!.preparation!.owner, {
|
||
type: 'declineBattlePower',
|
||
});
|
||
return g;
|
||
}
|
||
|
||
function resolveBattle(state: Game): Game {
|
||
let g = applyAction(state, 'h', { type: 'traitorCall', call: false });
|
||
g = applyAction(g, 'e', { type: 'traitorCall', call: false });
|
||
while (
|
||
g.decision?.kind === 'battleLosses' ||
|
||
g.decision?.kind === 'battleCards'
|
||
)
|
||
g = applyAction(
|
||
g,
|
||
g.decision.player,
|
||
g.decision.kind === 'battleLosses'
|
||
? { type: 'decision', choice: 0 }
|
||
: { type: 'decision', discard: [] },
|
||
);
|
||
return g;
|
||
}
|
||
|
||
function captureOffer(
|
||
emperorSkillLeader: 'emperor-1' | 'emperor-2' = 'emperor-1',
|
||
): Game {
|
||
let g = finishSetup(beginSetup(), emperorSkillLeader);
|
||
const emperor = g.players.find((player) => player.id === 'e')!;
|
||
const harkonnen = g.players.find((player) => player.id === 'h')!;
|
||
for (const player of g.players) {
|
||
g.deck.push(...player.hand);
|
||
player.hand = [];
|
||
player.traitors = [];
|
||
}
|
||
Object.assign(g, {
|
||
advanced: true,
|
||
phase: 6,
|
||
active: 'h',
|
||
order: ['h', 'e', 'a', 'b', 'f', 'g'],
|
||
ready: [],
|
||
storm: 18,
|
||
decision: null,
|
||
response: null,
|
||
phaseOpening: null,
|
||
});
|
||
harkonnen.forces = { 'red_chasm:7': 10 };
|
||
harkonnen.reserves = 10;
|
||
emperor.forces = { 'red_chasm:7': 10 };
|
||
emperor.reserves = 10;
|
||
for (const leader of emperor.leaders.slice(2)) {
|
||
leader.dead = true;
|
||
leader.deaths = 1;
|
||
}
|
||
if (emperorSkillLeader === 'emperor-2') {
|
||
emperor.leaders[2].dead = false;
|
||
emperor.leaders[2].deaths = 0;
|
||
emperor.leaders[2].usedAt = 'arrakeen';
|
||
}
|
||
const poison = takeCard('poison');
|
||
placeFixtureHand(g, 1, [poison]);
|
||
|
||
g = applyAction(g, 'h', {
|
||
type: 'chooseBattle',
|
||
territory: 'red_chasm',
|
||
target: 'e',
|
||
});
|
||
g = clearBattlePreparation(g);
|
||
assert.equal(g.decision, null, JSON.stringify(g.decision));
|
||
g = applyAction(g, 'h', {
|
||
type: 'battlePlan',
|
||
dial: 0,
|
||
leader: 'harkonnen-1',
|
||
weapon: poison.id,
|
||
});
|
||
g = applyAction(g, 'e', {
|
||
type: 'battlePlan',
|
||
dial: 0,
|
||
leader: 'emperor-0',
|
||
});
|
||
g = resolveBattle(g);
|
||
assert.equal(g.decision?.kind, 'captureOffer');
|
||
return g;
|
||
}
|
||
|
||
function acceptCapture(state: Game): Game {
|
||
let g = applyAction(state, 'h', { type: 'decision', accept: true });
|
||
g = passResponses(g);
|
||
assert.equal(g.decision?.kind, 'capturedLeader');
|
||
return g;
|
||
}
|
||
|
||
void test('the genuine audit setup offers eligible leaders to all six base factions including Harkonnen', () => {
|
||
const g = beginSetup();
|
||
assert.deepEqual(
|
||
new Set(g.players.map((player) => player.faction)),
|
||
new Set(SEATS.map(([, , faction]) => faction)),
|
||
);
|
||
for (const [id, , faction] of SEATS) {
|
||
const view = viewGame(g, id);
|
||
assert.equal(view.leaderSkills?.offer?.cards.length, 2, faction);
|
||
assert.equal(view.leaderSkills?.eligibleLeaders.length, 5, faction);
|
||
assert.ok(
|
||
view.leaderSkills!.eligibleLeaders.every((leader) =>
|
||
leader.id.startsWith(`${faction}-`),
|
||
),
|
||
faction,
|
||
);
|
||
}
|
||
assert.doesNotThrow(() => finishSetup(g, 'emperor-1'));
|
||
});
|
||
|
||
void test('a captured skilled leader and attached card become public while replacement revival remains rejected atomically', () => {
|
||
const g = acceptCapture(captureOffer());
|
||
for (const [viewer] of SEATS) {
|
||
const view = viewGame(g, viewer);
|
||
assert.deepEqual(
|
||
view.leaderSkills!.assignments.find(
|
||
(assignment) => assignment.owner === 'e',
|
||
),
|
||
{
|
||
owner: 'e',
|
||
leader: 'emperor-1',
|
||
skill: 'warmaster',
|
||
controller: 'h',
|
||
captured: true,
|
||
faceUp: false,
|
||
},
|
||
);
|
||
assert.equal(
|
||
view.players
|
||
.find((player) => player.id === 'e')!
|
||
.leaders.find((leader) => leader.id === 'emperor-1')!.capturedBy,
|
||
'h',
|
||
);
|
||
assert.equal(
|
||
view.decision?.kind === 'capturedLeader' && view.decision.leader,
|
||
'emperor-1',
|
||
);
|
||
}
|
||
assert.ok(
|
||
g.log.some(
|
||
(entry) =>
|
||
entry.text.includes('Captain Aramsham') &&
|
||
entry.text.includes('Warmaster'),
|
||
),
|
||
);
|
||
|
||
const kept = applyAction(g, 'h', { type: 'decision', mode: 'keep' });
|
||
kept.phase = 4;
|
||
kept.decision = null;
|
||
kept.response = null;
|
||
const emperor = kept.players.find((player) => player.id === 'e')!;
|
||
emperor.spice = 20;
|
||
emperor.leaderRevived = false;
|
||
const before = structuredClone(kept);
|
||
assert.throws(
|
||
() =>
|
||
applyAction(kept, 'e', {
|
||
type: 'reviveLeader',
|
||
leader: 'emperor-0',
|
||
}),
|
||
/replacement-skill ruling/,
|
||
);
|
||
assert.deepEqual(kept, before);
|
||
assertSkillCustody(kept);
|
||
});
|
||
|
||
void test('the captor gets only the lower battle bonus and a surviving captive returns with the same skill', () => {
|
||
let g = applyAction(acceptCapture(captureOffer()), 'h', {
|
||
type: 'decision',
|
||
mode: 'keep',
|
||
});
|
||
const emperor = g.players.find((player) => player.id === 'e')!;
|
||
const harkonnen = g.players.find((player) => player.id === 'h')!;
|
||
emperor.leaders[2].dead = false;
|
||
emperor.leaders[2].deaths = 0;
|
||
delete emperor.leaders[2].usedAt;
|
||
Object.assign(g, {
|
||
phase: 6,
|
||
active: 'h',
|
||
ready: [],
|
||
decision: null,
|
||
response: null,
|
||
phaseOpening: null,
|
||
});
|
||
harkonnen.forces = { 'red_chasm:7': 10 };
|
||
harkonnen.reserves = 10;
|
||
emperor.forces = { 'red_chasm:7': 10 };
|
||
emperor.reserves = 10;
|
||
for (const player of g.players) {
|
||
g.deck.push(...player.hand);
|
||
player.hand = [];
|
||
player.traitors = [];
|
||
}
|
||
const firstWorthless = takeCard('worthless');
|
||
const secondWorthless = takeCard('worthless', [firstWorthless.id]);
|
||
placeFixtureHand(g, 1, [firstWorthless]);
|
||
placeFixtureHand(g, 0, [secondWorthless]);
|
||
|
||
g = applyAction(g, 'h', {
|
||
type: 'chooseBattle',
|
||
territory: 'red_chasm',
|
||
target: 'e',
|
||
});
|
||
g = clearBattlePreparation(g);
|
||
g = applyAction(g, 'h', {
|
||
type: 'battlePlan',
|
||
dial: 0,
|
||
leader: 'emperor-1',
|
||
weapon: firstWorthless.id,
|
||
});
|
||
g = applyAction(g, 'e', {
|
||
type: 'battlePlan',
|
||
dial: 0,
|
||
leader: 'emperor-2',
|
||
weapon: secondWorthless.id,
|
||
});
|
||
g = resolveBattle(g);
|
||
|
||
assert.ok(
|
||
g.log.some((entry) =>
|
||
entry.text.includes('Harkonnen won in Red Chasm (8–3)'),
|
||
),
|
||
);
|
||
const skillLogs = g.log.filter(
|
||
(entry) => entry.automatic?.name === 'Leader Skill',
|
||
);
|
||
assert.ok(
|
||
skillLogs.some(
|
||
(entry) =>
|
||
entry.text.includes('Warmaster') &&
|
||
entry.text.includes('gained 3 battle strength'),
|
||
),
|
||
);
|
||
assert.ok(
|
||
skillLogs.every(
|
||
(entry) =>
|
||
!entry.text.includes('Warmaster') ||
|
||
!entry.text.includes('gained 1 battle strength'),
|
||
),
|
||
);
|
||
assert.equal(
|
||
g.players
|
||
.find((player) => player.id === 'e')!
|
||
.leaders.find((leader) => leader.id === 'emperor-1')!.capturedBy,
|
||
undefined,
|
||
);
|
||
for (const [viewer] of SEATS)
|
||
assert.deepEqual(
|
||
viewGame(g, viewer).leaderSkills!.assignments.find(
|
||
(assignment) => assignment.owner === 'e',
|
||
),
|
||
{
|
||
owner: 'e',
|
||
leader: 'emperor-1',
|
||
skill: 'warmaster',
|
||
controller: 'e',
|
||
captured: false,
|
||
faceUp: true,
|
||
},
|
||
);
|
||
assertSkillCustody(g);
|
||
});
|
||
|
||
void test('executing the known skilled captive returns exactly that card and keeps the inferred identity public', () => {
|
||
let g = acceptCapture(captureOffer());
|
||
g = applyAction(g, 'h', { type: 'decision', mode: 'execute' });
|
||
assert.equal(
|
||
g.leaderSkills!.assignments.some(
|
||
(assignment) => assignment.leader === 'emperor-1',
|
||
),
|
||
false,
|
||
);
|
||
assert.equal(
|
||
g.leaderSkills!.deck.filter((skill) => skill === 'warmaster').length,
|
||
1,
|
||
);
|
||
for (const [viewer] of SEATS) {
|
||
const leader = viewGame(g, viewer)
|
||
.players.find((player) => player.id === 'e')!
|
||
.leaders.find((candidate) => candidate.id === 'emperor-1')!;
|
||
assert.equal(leader.dead, true);
|
||
assert.equal(leader.deaths, 2);
|
||
}
|
||
assert.ok(
|
||
g.log.some(
|
||
(entry) =>
|
||
entry.automatic?.name === 'Leader Skill lost' &&
|
||
entry.text.includes('Warmaster'),
|
||
),
|
||
);
|
||
assertSkillCustody(g);
|
||
});
|
||
|
||
void test('capturing an unskilled leader preserves the existing private identity boundary', () => {
|
||
const offered = captureOffer('emperor-2');
|
||
const outsiderBefore = viewGame(offered, 'g').players.find(
|
||
(player) => player.id === 'e',
|
||
)!.leaders;
|
||
const g = acceptCapture(offered);
|
||
assert.equal(g.players[0].leaders[1].capturedBy, 'h');
|
||
assert.deepEqual(
|
||
viewGame(g, 'g').players.find((player) => player.id === 'e')!.leaders,
|
||
outsiderBefore,
|
||
);
|
||
const outsiderDecision = viewGame(g, 'g').decision;
|
||
const captorDecision = viewGame(g, 'h').decision;
|
||
assert.equal(
|
||
outsiderDecision?.kind === 'capturedLeader' && outsiderDecision.leader,
|
||
'',
|
||
);
|
||
assert.equal(
|
||
captorDecision?.kind === 'capturedLeader' && captorDecision.leader,
|
||
'emperor-1',
|
||
);
|
||
assert.deepEqual(
|
||
viewGame(g, 'g').leaderSkills!.assignments.find(
|
||
(assignment) => assignment.owner === 'e',
|
||
),
|
||
{
|
||
owner: 'e',
|
||
leader: 'emperor-2',
|
||
skill: 'warmaster',
|
||
controller: 'e',
|
||
captured: false,
|
||
faceUp: true,
|
||
},
|
||
);
|
||
assert.ok(g.log.every((entry) => !entry.text.includes('Captain Aramsham')));
|
||
assertSkillCustody(g);
|
||
});
|
||
|
||
void test('captured training excludes blocked Ghola and ordinary leader revivals while preserving a legal Collection pass', () => {
|
||
const kept = applyAction(acceptCapture(captureOffer()), 'h', { type: 'decision', mode: 'keep' });
|
||
kept.phase = 7;
|
||
kept.decision = null;
|
||
kept.response = null;
|
||
kept.phaseOpening = null;
|
||
kept.ready = kept.players.filter(player => player.id !== 'e').map(player => player.id);
|
||
const emperor = kept.players.find(player => player.id === 'e')!;
|
||
const ghola = baseDeck().find(card => card.effect === 'ghola')!;
|
||
placeFixtureHand(kept, kept.players.indexOf(emperor), [ghola]);
|
||
emperor.bot = 'Medium';
|
||
const before = structuredClone(kept);
|
||
const view = viewGame(kept, 'e');
|
||
assert.deepEqual(view.ghola.leaders, []);
|
||
assert.deepEqual(view.revival.leaders, []);
|
||
assert.equal(view.ghola.available, true);
|
||
assert.equal(view.ghola.maxForces, 5, 'Captured training does not prohibit physical force Ghola');
|
||
const action = botActions(view).find(action => action.type === 'ready');
|
||
assert.ok(action);
|
||
const done = applyAction(kept, 'e', action);
|
||
assert.equal(done.phase, 8);
|
||
assert.equal(done.players.find(player => player.id === 'e')!.hand.some(card => card.id === ghola.id), true);
|
||
assert.deepEqual(kept, before);
|
||
assertSkillCustody(done);
|
||
});
|