141 lines
7.1 KiB
TypeScript
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);
|
|
});
|