Files
Dune/tests/leader-skills-engine.test.ts

577 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import { mock } from 'node:test';
import test from 'node:test';
import type { Card } from '../game/cards';
import {
applyAction,
createGame,
initializeLeaderSkillsGameForAudit,
joinGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import {
LEADER_SKILL_CARDS,
type LeaderSkillId,
} from '../game/leader-skill-cards';
import { newRevivalRules } from '../game/revival';
const DIRECT_SKILLS = [
'warmaster',
'master-of-assassins',
'swordmaster-of-ginaz',
'killer-medic',
'prana-bindu-adept',
] as const;
function rejectUnchanged(g: Game, id: string, action: Action, message: RegExp) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, id, action), message);
assert.deepEqual(g, before);
}
/** Put one requested physical skill first without bypassing the real setup deal. */
function initialized(target: LeaderSkillId = 'warmaster'): Game {
let g = createGame('SKILLENG', newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
for (const player of g.players)
g = applyAction(g, player.id, { type: 'ready' });
const targetIndex = LEADER_SKILL_CARDS.findIndex(
(card) => card.id === target,
);
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.leaderSkills!.offers.a.cards[0], target);
return g;
}
function initializedVoiceGame(): Game {
let g = createGame('SKILLVOICE', newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
for (const player of g.players)
g = applyAction(g, player.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();
}
g = applyAction(g, 'b', {
type: 'predict',
faction: 'atreides',
turn: 3,
});
for (const id of ['a', 'b', 'e']) {
const skills = viewGame(g, id).leaderSkills!;
g = applyAction(g, id, {
type: 'leaderSkill',
event: skills.offer!.event,
skill: skills.offer!.cards[0],
leader: skills.eligibleLeaders[0].id,
});
}
return finishSetup(g);
}
function assignSetup(state: Game, skill: LeaderSkillId): Game {
let g = state;
const atreides = g.leaderSkills!.offers.a;
g = applyAction(g, 'a', {
type: 'leaderSkill',
event: atreides.event,
skill,
leader: 'atreides-0',
});
const emperor = g.leaderSkills!.offers.e;
g = applyAction(g, 'e', {
type: 'leaderSkill',
event: emperor.event,
skill: emperor.cards[0],
leader: 'emperor-0',
});
return g;
}
function finishSetup(state: Game): Game {
let g = state;
for (let step = 0; step < 8 && g.status === 'setup'; step += 1) {
const owner = g.players.find((player) => player.traitorChoices.length > 0);
assert.ok(owner, `setup stalled at ${g.setupStage}`);
g = applyAction(g, owner.id, {
type: 'traitor',
leader: owner.traitorChoices[0],
});
}
assert.equal(g.status, 'playing');
return g;
}
function takeCard(g: Game, owner: string, predicate: (card: Card) => boolean) {
const index = g.deck.findIndex(predicate);
assert.ok(index >= 0, 'the requested physical battle card must exist');
const [card] = g.deck.splice(index, 1);
g.players.find((player) => player.id === owner)!.hand.push(card);
return card;
}
function battleFor(
skill: (typeof DIRECT_SKILLS)[number],
hide: boolean,
onlySkilledLeader = false,
) {
const g = finishSetup(assignSetup(initialized(skill), skill));
const attacker = g.players.find((player) => player.id === 'a')!;
const defender = g.players.find((player) => player.id === 'e')!;
attacker.forces = { 'arrakeen:10': 5 };
attacker.reserves = 15;
defender.forces = { 'arrakeen:10': 5 };
defender.reserves = 15;
Object.assign(g, {
phase: 6,
storm: 18,
order: ['a', 'e'],
active: 'a',
ready: [],
decision: null,
response: null,
phaseOpening: null,
});
for (const player of g.players) {
g.deck.push(...player.hand);
player.hand = [];
}
if (onlySkilledLeader)
for (const leader of attacker.leaders.filter(
(leader) => leader.id !== 'atreides-0',
)) {
leader.dead = true;
leader.deaths = 1;
}
const role =
skill === 'warmaster'
? takeCard(g, 'a', (card) => card.kind === 'worthless')
: skill === 'master-of-assassins'
? takeCard(g, 'a', (card) => card.kind === 'poison')
: skill === 'swordmaster-of-ginaz'
? takeCard(g, 'a', (card) => card.kind === 'projectile')
: skill === 'killer-medic'
? takeCard(g, 'a', (card) => card.kind === 'snooper')
: takeCard(g, 'a', (card) => card.kind === 'shield');
let next = applyAction(g, 'a', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'e',
});
if (next.decision?.kind === 'mentatQuestion')
next = applyAction(next, next.decision.player, {
type: 'decision',
event: next.decision.event,
decline: true,
});
while (next.decision?.kind === 'leaderSkillVisibility') {
const decision = next.decision;
next = applyAction(next, decision.player, {
type: 'leaderSkillVisibility',
event: decision.event,
hide: decision.player === 'a' ? hide : false,
});
}
while (next.battle?.preparation)
next = applyAction(next, next.battle.preparation.owner, {
type: 'declineBattlePower',
});
return { g: next, role };
}
function resolvePlans(state: Game, attackerLeader: string, role: Card): Game {
const slot =
role.kind === 'snooper' || role.kind === 'shield' ? 'defense' : 'weapon';
let g = applyAction(state, 'a', {
type: 'battlePlan',
dial: 0,
leader: attackerLeader,
[slot]: role.id,
});
g = applyAction(g, 'e', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-1',
});
g = applyAction(g, 'a', { type: 'traitorCall', call: false });
return applyAction(g, 'e', { type: 'traitorCall', call: false });
}
function killedSkillBattle(): Game {
const { g: battle, role } = battleFor('master-of-assassins', true);
const poison = takeCard(
battle,
'e',
(card) => card.kind === 'poison' && card.id !== role.id,
);
let g = applyAction(battle, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'atreides-0',
weapon: role.id,
});
g = applyAction(g, 'e', {
type: 'battlePlan',
dial: 1,
leader: 'emperor-1',
weapon: poison.id,
});
g = applyAction(g, 'a', { type: 'traitorCall', call: false });
return applyAction(g, 'e', { type: 'traitorCall', call: false });
}
void test('real setup deals private choices before traitors, then publishes only completed assignments across JSON views', () => {
let g = initialized();
assert.equal(g.setupStage, 'leaderSkills');
assert.ok(g.players.every((player) => player.hand.length === 1));
assert.ok(g.players.every((player) => player.traitorChoices.length === 0));
const aView = viewGame(g, 'a');
const eView = viewGame(g, 'e');
assert.equal(aView.leaderSkills!.offer!.cards.length, 2);
assert.equal(eView.leaderSkills!.offer!.cards.length, 2);
assert.notDeepEqual(
aView.leaderSkills!.offer!.cards,
eView.leaderSkills!.offer!.cards,
);
assert.equal('deck' in aView, false);
assert.equal('traitorReserve' in aView, false);
const event = g.leaderSkills!.offers.a.event;
rejectUnchanged(
g,
'e',
{
type: 'leaderSkill',
event,
skill: 'warmaster',
leader: 'atreides-0',
},
/current setup step|current Leader Skill cards/,
);
g = applyAction(g, 'a', {
type: 'leaderSkill',
event,
skill: 'warmaster',
leader: 'atreides-0',
});
for (const viewer of ['a', 'e']) {
const view = viewGame(JSON.parse(JSON.stringify(g)), viewer);
assert.deepEqual(view.leaderSkills!.assignments, [
{
owner: 'a',
leader: 'atreides-0',
skill: 'warmaster',
controller: 'a',
captured: false,
faceUp: true,
},
]);
assert.equal(
view.leaderSkills!.offer?.event ?? null,
g.leaderSkills!.offers[viewer]?.event ?? null,
);
}
g = assignSetup(initialized(), 'warmaster');
assert.equal(g.setupStage, 'traitors');
});
void test('each connected physical card role grants +3 only after its skilled leader moves behind the shield', () => {
for (const skill of DIRECT_SKILLS) {
const { g, role } = battleFor(skill, true);
assert.equal(
viewGame(g, 'a').leaderSkills!.assignments.find(
(assignment) => assignment.owner === 'a',
)!.faceUp,
false,
);
const resolved = resolvePlans(g, 'atreides-0', role);
const defenderScore =
skill === 'master-of-assassins' || skill === 'swordmaster-of-ginaz'
? 0
: 5;
assert.ok(
resolved.log.some((entry) =>
entry.text.includes(`Atreides won in Arrakeen (8–${defenderScore})`),
),
`${skill} did not add three through the real engine battle`,
);
assert.ok(
resolved.log.some(
(entry) =>
entry.automatic?.name === 'Leader Skill' &&
entry.text.includes('gained 3 battle strength'),
),
);
}
});
void test('a face-up skilled leader is inadmissible while another leader and Cheap Hero receive the normal +1', () => {
const { g, role } = battleFor('warmaster', false);
rejectUnchanged(
g,
'a',
{
type: 'battlePlan',
dial: 0,
leader: 'atreides-0',
weapon: role.id,
},
/behind the shield/,
);
const resolved = resolvePlans(g, 'atreides-1', role);
assert.ok(
resolved.log.some((entry) =>
entry.text.includes('Atreides won in Arrakeen (6–5)'),
),
);
const heroBattle = battleFor('warmaster', false);
const hero = takeCard(heroBattle.g, 'a', (card) => card.kind === 'hero');
const heroResolved = resolvePlans(heroBattle.g, hero.id, heroBattle.role);
assert.ok(
heroResolved.log.some((entry) =>
entry.text.includes('Emperor won in Arrakeen (1–5)'),
),
);
const mandatory = battleFor('warmaster', true, true);
assert.equal(
viewGame(mandatory.g, 'a').leaderSkills!.assignments.find(
(assignment) => assignment.owner === 'a',
)!.faceUp,
false,
);
assert.doesNotThrow(() =>
resolvePlans(mandatory.g, 'atreides-0', mandatory.role),
);
});
void test('a Voice that forbids the last alternate Cheap Hero automatically conceals the only skilled leader without replaying powers', () => {
let g = initializedVoiceGame();
for (const player of g.players) {
g.deck.push(...player.hand);
player.hand = [];
player.forces = {};
player.reserves = 20;
}
const heroIndex = g.deck.findIndex((card) => card.kind === 'hero');
assert.ok(heroIndex >= 0);
const [hero] = g.deck.splice(heroIndex, 1);
g.players.find((player) => player.id === 'a')!.hand.push(hero);
const atreides = g.players.find((player) => player.id === 'a')!;
atreides.forces = { 'arrakeen:10': 5 };
atreides.reserves = 15;
g.players.find((player) => player.id === 'b')!.forces = {
'arrakeen:10': 5,
};
g.players.find((player) => player.id === 'b')!.reserves = 15;
for (const leader of atreides.leaders.filter(
(leader) => leader.id !== 'atreides-0',
)) {
leader.dead = true;
leader.deaths = 1;
}
Object.assign(g, {
phase: 6,
storm: 18,
order: ['a', 'b', 'e'],
active: 'a',
ready: [],
decision: null,
response: null,
phaseOpening: null,
});
g = applyAction(g, 'a', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'b',
});
if (g.decision?.kind === 'mentatQuestion')
g = applyAction(g, g.decision.player, {
type: 'decision',
event: g.decision.event,
decline: true,
});
while (g.decision?.kind === 'leaderSkillVisibility')
g = applyAction(g, g.decision.player, {
type: 'leaderSkillVisibility',
event: g.decision.event,
hide: false,
});
assert.equal(g.battle!.preparation?.kind, 'voice');
g = applyAction(g, 'b', { type: 'voice', kind: 'hero', must: false });
assert.equal(g.battle!.leaderSkillHidden?.a, true);
assert.equal(
g.log.filter(
(entry) =>
entry.automatic?.name === 'Leader Skill' &&
entry.text.includes('behind the shield'),
).length,
1,
);
while (g.battle?.preparation)
g = applyAction(g, g.battle.preparation.owner, {
type: 'declineBattlePower',
});
g = applyAction(g, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'atreides-0',
});
assert.equal(g.battle!.plans.a.leader, 'atreides-0');
assert.equal(g.battle!.voice?.kind, 'hero');
});
void test('a killed skilled leader loses its card before scoring and a real revival privately offers a new draw', () => {
let g = killedSkillBattle();
assert.ok(
g.log.some((entry) => entry.text.includes('Emperor won in Arrakeen (0–1)')),
);
assert.equal(
g.leaderSkills!.assignments.some((assignment) => assignment.owner === 'a'),
false,
);
assert.ok(g.leaderSkills!.deck.includes('master-of-assassins'));
const atreides = g.players.find((player) => player.id === 'a')!;
for (const leader of atreides.leaders) {
leader.dead = true;
leader.deaths = Math.max(1, leader.deaths);
}
atreides.spice = 20;
atreides.leaderRevived = false;
g.phase = 4;
g.revivalRules = newRevivalRules();
g.decision = null;
g.response = null;
g = applyAction(g, 'a', {
type: 'reviveLeader',
leader: 'atreides-0',
});
assert.equal(g.decision?.kind, 'leaderSkillRevival');
assert.deepEqual(viewGame(g, 'a').leaderSkills!.offer!.cards, []);
assert.equal(viewGame(g, 'e').leaderSkills!.offer, null);
const event = g.decision!.event;
g = applyAction(g, 'a', {
type: 'leaderSkill',
event,
mode: 'draw',
});
const privateOffer = viewGame(JSON.parse(JSON.stringify(g)), 'a')
.leaderSkills!.offer!;
assert.equal(privateOffer.cards.length, 2);
assert.equal(viewGame(g, 'e').leaderSkills!.offer, null);
g = applyAction(g, 'a', {
type: 'leaderSkill',
event,
skill: privateOffer.cards[0],
leader: 'atreides-0',
});
assert.equal(g.leaderSkills!.offers.a, undefined);
assert.equal(
g.leaderSkills!.assignments.find((assignment) => assignment.owner === 'a')
?.leader,
'atreides-0',
);
assert.doesNotThrow(() => viewGame(JSON.parse(JSON.stringify(g)), 'e'));
});
void test('Ghola revival during an unfinished battle preserves its power stage through the private draw and new visibility choice', () => {
let g = killedSkillBattle();
const atreides = g.players.find((player) => player.id === 'a')!;
const emperor = g.players.find((player) => player.id === 'e')!;
atreides.forces['carthag:11'] = 1;
atreides.reserves -= 1;
emperor.forces['carthag:11'] = 1;
emperor.reserves -= 1;
const ghola = takeCard(g, 'a', (card) => card.effect === 'ghola');
Object.assign(g, {
phase: 6,
storm: 18,
order: ['a', 'e'],
active: 'a',
ready: [],
decision: null,
response: null,
phaseOpening: null,
});
g = applyAction(g, 'a', {
type: 'chooseBattle',
territory: 'carthag',
target: 'e',
});
if (g.decision?.kind === 'mentatQuestion')
g = applyAction(g, g.decision.player, {
type: 'decision',
event: g.decision.event,
decline: true,
});
while (g.decision?.kind === 'leaderSkillVisibility')
g = applyAction(g, g.decision.player, {
type: 'leaderSkillVisibility',
event: g.decision.event,
hide: false,
});
assert.ok(g.battle!.preparation);
const preparation = structuredClone(g.battle!.preparation);
g = applyAction(g, 'a', {
type: 'card',
card: ghola.id,
leader: 'atreides-0',
});
assert.equal(g.decision?.kind, 'leaderSkillRevival');
assert.deepEqual(g.battle!.preparation, preparation);
const event = g.decision!.event;
g = applyAction(g, 'a', {
type: 'leaderSkill',
event,
mode: 'draw',
});
const offer = viewGame(g, 'a').leaderSkills!.offer!;
g = applyAction(g, 'a', {
type: 'leaderSkill',
event,
skill: offer.cards[0],
leader: 'atreides-0',
});
assert.equal(g.decision?.kind, 'leaderSkillVisibility');
assert.equal(g.decision!.resumePowers, false);
assert.deepEqual(g.battle!.preparation, preparation);
g = applyAction(g, 'a', {
type: 'leaderSkillVisibility',
event: g.decision!.event,
hide: true,
});
assert.equal(g.decision, null);
assert.deepEqual(g.battle!.preparation, preparation);
g = applyAction(g, preparation!.owner, { type: 'declineBattlePower' });
assert.notDeepEqual(g.battle?.preparation, preparation);
assert.equal(g.discard.filter((card) => card.id === ghola.id).length, 1);
});