Files
Dune/tests/tleilaxu-skills-review.test.ts

141 lines
7.1 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, type Action, type Game } from '../game/engine';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import {
assertTleilaxuSkillsCustody as custody,
completedTleilaxuSkillsGame,
reloadTleilaxuSkillsGame as reload,
rejectTleilaxuSkillsAction as reject,
tleilaxuSkillsPlayer as player,
} from './tleilaxu-skills-fixture';
const act = (g: Game, owner: string, action: Action) => applyAction(reload(g), owner, action);
/** Conserved battle positions after genuine setup, with a second battle keeping phase 6 open. */
function stage(g: Game, owner = 't') {
for (const p of g.players) {
g.deck.push(...p.hand); p.hand = [];
p.forces = [owner, 'e'].includes(p.id) ? { 'wind_pass:14': 6, 'carthag:11': 1 } : {};
p.reserves = [owner, 'e'].includes(p.id) ? 13 : 20;
p.tanks = 0; p.spice = 20;
}
Object.assign(g, { phase: 6, active: owner, storm: 18,
order: [owner, 'e', ...g.players.map(p => p.id).filter(id => id !== owner && id !== 'e')],
ready: [], decision: null, response: null, phaseOpening: null, spice: {} });
return g;
}
function open(g: Game, owner = 't') {
g = act(g, owner, { type: 'chooseBattle', territory: 'wind_pass', target: 'e' });
for (let i = 0; i < 30; i++) {
if (g.response) g = act(g, g.players.find(p => !g.response!.passed.includes(p.id))!.id, { type: 'passResponse' });
else if (g.decision?.kind === 'mentatQuestion')
g = act(g, g.decision.player, { type: 'decision', event: g.decision.event, decline: true });
else if (g.decision?.kind === 'leaderSkillVisibility')
g = act(g, g.decision.player, { type: 'leaderSkillVisibility', event: g.decision.event, hide: true });
else if (g.battle?.preparation) g = act(g, g.battle.preparation.owner, { type: 'declineBattlePower' });
else break;
}
assert.equal(g.decision, null); assert.equal(g.battle?.preparation, undefined);
return g;
}
function resolve(g: Game, owner = 't') {
g = act(g, owner, { type: 'traitorCall', call: false });
return act(g, 'e', { type: 'traitorCall', call: false });
}
function assignPhysicalSkill(g: Game, owner: string, skill: LeaderSkillId) {
const target = g.leaderSkills!.assignments.find(a => a.owner === owner)!;
const previous = g.leaderSkills!.assignments.find(a => a.skill === skill);
if (previous) [target.skill, previous.skill] = [previous.skill, target.skill];
else {
const index = g.leaderSkills!.deck.indexOf(skill);
assert.ok(index >= 0);
[target.skill, g.leaderSkills!.deck[index]] = [g.leaderSkills!.deck[index], target.skill];
}
custody(g);
}
function matchingFaceDancer(g: Game, target: string) {
const dancers = player(g, 't').faceDancers!;
if (dancers.some(card => card.leader === target)) return;
const former = dancers[0].leader;
const index = g.traitorReserve!.indexOf(target);
if (index >= 0) g.traitorReserve![index] = former;
else {
const owner = g.players.find(p => p.traitors.includes(target));
assert.ok(owner);
owner.traitors[owner.traitors.indexOf(target)] = former;
}
dancers[0].leader = target;
}
void test('Zoal Smuggler copies the opposing disc without the surviving Mentat battle bonus', () => {
let g = stage(completedTleilaxuSkillsGame({ requestedSkill: 'smuggler' }));
// This swaps the two actual cards; it creates neither a duplicate nor a reduced deck.
assignPhysicalSkill(g, 'e', 'mentat');
const printed = player(g, 'e').leaders.find(leader => leader.id === 'emperor-0')!.strength;
g.spice = { 'wind_pass:14': 9 };
g = open(g);
g = act(g, 't', { type: 'battlePlan', dial: 4, leader: 'tleilaxu-0' });
assert.equal(g.battle!.smugglerCollection, undefined, 'A sealed first plan cannot inspect the opposing disc.');
g = act(g, 'e', { type: 'battlePlan', dial: 0, leader: 'emperor-0' });
assert.equal(g.battle!.smugglerCollection!.strength, printed);
assert.equal(g.battle!.smugglerCollection!.amount, printed);
g = resolve(g);
assert.ok(g.log.some(entry => /Emperor gained 2 battle strength from Mentat/.test(entry.text)));
assert.equal(g.lastBattleContext!.winner, 't');
assert.equal(g.lastBattleContext!.smugglerCollection!.strength, printed);
assert.equal(player(g, 't').spice, 20 + printed);
assert.equal(g.spice['wind_pass:14'], 9 - printed);
custody(g);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
});
void test('Rihani keeps live custody through winner cleanup then survives all-three Face Dancer replacement as immutable history', () => {
let g = stage(completedTleilaxuSkillsGame({ requestedSkill: 'rihani-decipherer', skillOwner: 'atreides' }), 'a');
matchingFaceDancer(g, 'atreides-0');
// Two earlier reveals are explicitly staged; the current Face Dance triggers the native reset.
for (const card of player(g, 't').faceDancers!) card.revealed = card.leader !== 'atreides-0';
const index = g.deck.findIndex(card => card.kind === 'worthless');
assert.ok(index >= 0);
const worthless = g.deck.splice(index, 1)[0]; player(g, 'a').hand.push(worthless);
custody(g);
g = open(g, 'a');
g = act(g, 'a', { type: 'battlePlan', dial: 4, leader: 'atreides-0', weapon: worthless.id });
g = act(g, 'e', { type: 'battlePlan', dial: 0, leader: 'emperor-1' });
g = resolve(g, 'a');
const event = g.rihaniHistory!.at(-1)!.event;
g = act(g, 'a', { type: 'decision', event, draw: false });
assert.equal(g.decision?.kind, 'battleCards');
assert.equal(g.lastBattleContext!.rihani!.faceDanceStarted, undefined);
const shuffledEarly = reload(g);
shuffledEarly.traitorReserve!.reverse();
reject(shuffledEarly, 'a', { type: 'decision', discard: [] }, /current Traitor custody/);
const prematureRelease = reload(g);
prematureRelease.lastBattleContext!.rihani!.faceDanceStarted = true;
reject(prematureRelease, 'a', { type: 'decision', discard: [] }, /obligation changed/);
g = act(g, 'a', { type: 'decision', discard: [] });
assert.equal(g.decision?.kind, 'faceDance');
assert.equal(g.lastBattleContext!.rihani!.faceDanceStarted, true);
const history = structuredClone(g.rihaniHistory);
const unmarked = reload(g);
delete unmarked.lastBattleContext!.rihani!.faceDanceStarted;
reject(unmarked, 't', { type: 'decision', reveal: false }, /obligation changed/);
const changedHistory = reload(g);
changedHistory.rihaniHistory![0].peeked.reverse();
reject(changedHistory, 't', { type: 'decision', reveal: false }, /receipt is invalid/);
const duplicated = reload(g);
duplicated.traitorReserve!.push(player(duplicated, 't').faceDancers![0].leader);
reject(duplicated, 't', { type: 'decision', reveal: false }, /census/);
g = act(g, 't', { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 14 });
assert.equal(g.phase, 6);
assert.equal(g.pendingFaceDance, null);
assert.equal(player(g, 't').faceDancers!.length, 3);
assert.ok(player(g, 't').faceDancers!.every(card => !card.revealed));
assert.deepEqual(g.rihaniHistory, history);
assert.equal(g.leaderSkills!.deck.filter(skill => skill === 'rihani-decipherer').length, 1);
custody(g);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
});