314 lines
15 KiB
TypeScript
314 lines
15 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
applyAction,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
type Action,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import type { Card } from '../game/cards';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { newRevivalRules } from '../game/revival';
|
|
import {
|
|
assertTleilaxuSkillsCustody as conserved,
|
|
completedTleilaxuSkillsGame,
|
|
reloadTleilaxuSkillsGame as reload,
|
|
rejectTleilaxuSkillsAction as reject,
|
|
tleilaxuSkillsPlayer as player,
|
|
} from './tleilaxu-skills-fixture';
|
|
|
|
const act = (game: Game, owner: string, action: Action): Game =>
|
|
applyAction(reload(game), owner, action);
|
|
|
|
function take(game: Game, owner: string, predicate: (card: Card) => boolean): Card {
|
|
const index = game.deck.findIndex(predicate);
|
|
assert.ok(index >= 0, 'The selected physical Treachery Card must exist in the real deck.');
|
|
const [card] = game.deck.splice(index, 1);
|
|
player(game, owner).hand.push(card);
|
|
return card;
|
|
}
|
|
|
|
function passWindow(state: Game): Game {
|
|
let game = state;
|
|
const kind = game.response!.kind;
|
|
for (let step = 0; game.response?.kind === kind && step < 8; step++) {
|
|
const responder = game.players.find((candidate) => !game.response!.passed.includes(candidate.id));
|
|
assert.ok(responder);
|
|
game = act(game, responder.id, { type: 'passResponse' });
|
|
}
|
|
assert.notEqual(game.response?.kind, kind);
|
|
return game;
|
|
}
|
|
|
|
/** Stage conserved armies, then kill the original skilled leader in a real battle. */
|
|
function killedOriginalSkill(owner: 't' | 'a' = 't'): Game {
|
|
let game = completedTleilaxuSkillsGame({ skillOwner: owner === 't' ? 'tleilaxu' : 'atreides' });
|
|
for (const p of game.players) {
|
|
game.deck.push(...p.hand);
|
|
p.hand = [];
|
|
p.forces = [owner, 'e'].includes(p.id)
|
|
? { 'wind_pass:14': 5, 'carthag:11': 1 }
|
|
: { 'polar_sink:0': 1 };
|
|
p.reserves = [owner, 'e'].includes(p.id) ? 14 : 19;
|
|
p.tanks = 0;
|
|
p.spice = 20;
|
|
}
|
|
Object.assign(game, {
|
|
phase: 6, storm: 18, order: [owner, 'e', owner === 't' ? 'a' : 't'],
|
|
active: owner, ready: [], decision: null, response: null, phaseOpening: null,
|
|
});
|
|
const poison = take(game, 'e', (card) => card.kind === 'poison');
|
|
game = act(game, owner, { type: 'chooseBattle', territory: 'wind_pass', target: 'e' });
|
|
for (let step = 0; step < 20; step++) {
|
|
if (game.response) game = passWindow(game);
|
|
else if (game.decision?.kind === 'mentatQuestion')
|
|
game = act(game, game.decision.player, { type: 'decision', event: game.decision.event, decline: true });
|
|
else if (game.decision?.kind === 'leaderSkillVisibility') {
|
|
const decision = game.decision;
|
|
game = act(game, decision.player, {
|
|
type: 'leaderSkillVisibility', event: decision.event, hide: true,
|
|
});
|
|
} else if (game.battle?.preparation)
|
|
game = act(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
|
|
else break;
|
|
}
|
|
assert.equal(game.decision, null);
|
|
assert.equal(game.battle?.preparation, undefined);
|
|
const leader = player(game, owner).leaders[0].id;
|
|
game = act(game, owner, { type: 'battlePlan', dial: 0, leader });
|
|
game = act(game, 'e', {
|
|
type: 'battlePlan', dial: 1, leader: 'emperor-1', weapon: poison.id,
|
|
});
|
|
game = act(game, owner, { type: 'traitorCall', call: false });
|
|
game = act(game, 'e', { type: 'traitorCall', call: false });
|
|
for (let step = 0; game.decision && step < 10; step++) {
|
|
const decision = game.decision;
|
|
if (decision.kind === 'battleCards')
|
|
game = act(game, decision.player, { type: 'decision', discard: [] });
|
|
else if (decision.kind === 'faceDance')
|
|
game = act(game, decision.player, { type: 'decision', reveal: false });
|
|
else assert.fail(`Unexpected postbattle choice: ${decision.kind}`);
|
|
}
|
|
assert.equal(player(game, owner).leaders[0].dead, true);
|
|
assert.equal(game.leaderSkills!.assignments.some((assignment) => assignment.owner === owner), false);
|
|
assert.equal(game.leaderSkills!.deck.filter((skill) => skill === 'warmaster').length, 1);
|
|
assert.equal(game.leaderSkills!.offers[owner], undefined);
|
|
conserved(game);
|
|
return game;
|
|
}
|
|
|
|
/** A clean Revival boundary retains every card, disc and force from the battle. */
|
|
function revivalGame(owner: 't' | 'a' = 't', heldKarama = false): Game {
|
|
const game = killedOriginalSkill(owner);
|
|
Object.assign(game, {
|
|
phase: 4, active: null, ready: [], decision: null, response: null,
|
|
phaseOpening: null, revivalRules: newRevivalRules(),
|
|
});
|
|
for (const p of game.players) p.leaderRevived = false;
|
|
if (heldKarama) take(game, 'e', (card) => card.effect === 'karama');
|
|
conserved(game);
|
|
return game;
|
|
}
|
|
|
|
function reviveOwn(game: Game): Game {
|
|
return act(game, 't', { type: 'reviveLeader', leader: 'tleilaxu-0' });
|
|
}
|
|
|
|
function negotiatedRevival(game: Game, amount = 7): Game {
|
|
game = act(game, 'a', { type: 'requestLeaderRevival', leader: 'atreides-0' });
|
|
game = act(game, 't', { type: 'quoteLeaderRevival', target: 'a', amount });
|
|
return act(game, 'a', { type: 'acceptLeaderRevival' });
|
|
}
|
|
|
|
function assertOffer(game: Game, owner: 't' | 'a'): string {
|
|
const offer = game.leaderSkills!.offers[owner];
|
|
assert.ok(offer);
|
|
assert.equal(offer.leader, player(game, owner).leaders[0].id);
|
|
assert.deepEqual(offer.cards, []);
|
|
for (const viewer of game.players) {
|
|
const projection = viewGame(reload(game), viewer.id);
|
|
assert.deepEqual(projection.leaderSkills!.offer, viewer.id === owner ? offer : null);
|
|
assert.equal('deck' in projection.leaderSkills!, false);
|
|
assert.equal('offers' in projection.leaderSkills!, false);
|
|
}
|
|
conserved(game);
|
|
return offer.event;
|
|
}
|
|
|
|
void test('genuine Basic Tleilaxu battle death returns the original physical skill exactly once', () => {
|
|
const game = killedOriginalSkill();
|
|
const restored = normalizeAutomaticGame(reload(game));
|
|
assert.deepEqual(restored, game);
|
|
assert.equal(game.log.filter((entry) => entry.text.includes('Warmaster returned')).length, 1);
|
|
assert.equal(player(game, 't').faceDancers!.length, 3);
|
|
assert.deepEqual(player(game, 't').traitors, []);
|
|
conserved(restored);
|
|
});
|
|
|
|
void test('automatic Tleilaxu early permission and discount keep a private undrawn skill offer after own revival', () => {
|
|
const before = revivalGame();
|
|
const previousDeck = [...before.leaderSkills!.deck];
|
|
const original = player(before, 't').leaders[0];
|
|
const cost = Math.ceil(original.strength / 2);
|
|
const game = reviveOwn(before);
|
|
assert.equal(game.response, null);
|
|
assert.equal(game.pendingRevival, null);
|
|
assert.equal(player(game, 't').leaders[0].dead, false);
|
|
assert.equal(player(game, 't').spice, player(before, 't').spice - cost);
|
|
assert.equal(player(game, 'e').spice, player(before, 'e').spice);
|
|
assert.equal(game.decision?.kind, 'leaderSkillRevival');
|
|
assertOffer(game, 't');
|
|
assert.deepEqual(game.leaderSkills!.deck, previousDeck, 'An optional draw must not preview or remove cards.');
|
|
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
|
assert.equal(before.leaderSkills!.offers.t, undefined, 'Applying revival must leave its input unchanged.');
|
|
});
|
|
|
|
void test('held Karama preserves early permission and discount as separate saved windows before the own-leader offer', () => {
|
|
const before = revivalGame('t', true);
|
|
let game = reviveOwn(before);
|
|
assert.equal(game.response?.kind, 'earlyRevival');
|
|
assert.equal(player(game, 't').leaders[0].dead, true);
|
|
assert.equal(game.leaderSkills!.offers.t, undefined);
|
|
assert.equal(player(game, 't').spice, player(before, 't').spice);
|
|
reject(game, 't', { type: 'leaderSkill', event: 'not-yet-created', mode: 'draw' });
|
|
game = passWindow(game);
|
|
assert.equal(game.response?.kind, 'revivalDiscount');
|
|
assert.equal(player(game, 't').leaders[0].dead, true);
|
|
assert.equal(game.leaderSkills!.offers.t, undefined);
|
|
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
|
game = passWindow(game);
|
|
assert.equal(game.response, null);
|
|
assert.equal(game.decision?.kind, 'leaderSkillRevival');
|
|
const event = assertOffer(game, 't');
|
|
const restored = normalizeAutomaticGame(normalizeAutomaticGame(reload(game)));
|
|
assert.equal(restored.leaderSkills!.offers.t.event, event);
|
|
assert.deepEqual(restored, game);
|
|
});
|
|
|
|
void test('declining the optional own-leader skill draw keeps deck order and never reopens after JSON normalization', () => {
|
|
let game = reviveOwn(revivalGame());
|
|
const event = assertOffer(game, 't');
|
|
const deck = [...game.leaderSkills!.deck];
|
|
game = act(game, 't', { type: 'leaderSkill', event, mode: 'decline' });
|
|
assert.equal(game.decision, null);
|
|
assert.equal(game.leaderSkills!.offers.t, undefined);
|
|
assert.deepEqual(game.leaderSkills!.deck, deck);
|
|
assert.equal(game.leaderSkills!.assignments.some((assignment) => assignment.owner === 't'), false);
|
|
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
|
reject(game, 't', { type: 'leaderSkill', event, mode: 'draw' });
|
|
conserved(game);
|
|
});
|
|
|
|
void test('saved optional draw rejects stale, alien and wrong-leader choices and binds one dealt card to the same revived disc', () => {
|
|
let game = reviveOwn(revivalGame());
|
|
const event = assertOffer(game, 't');
|
|
reject(game, 'e', { type: 'leaderSkill', event, mode: 'draw' });
|
|
reject(game, 't', { type: 'leaderSkill', event: `${event}-old`, mode: 'draw' });
|
|
reject(game, 't', { type: 'leaderSkill', event, skill: game.leaderSkills!.deck[0], leader: 'tleilaxu-0' });
|
|
const deck = [...game.leaderSkills!.deck];
|
|
game = act(game, 't', { type: 'leaderSkill', event, mode: 'draw' });
|
|
assert.deepEqual(game.leaderSkills!.offers.t.cards, deck.slice(0, 2));
|
|
assert.deepEqual(game.leaderSkills!.deck, deck.slice(2));
|
|
assert.equal(viewGame(reload(game), 'e').leaderSkills!.offer, null);
|
|
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
|
const [chosen, returned] = game.leaderSkills!.offers.t.cards;
|
|
reject(game, 't', { type: 'leaderSkill', event, mode: 'draw' });
|
|
reject(game, 't', { type: 'leaderSkill', event, mode: 'decline' });
|
|
reject(game, 't', { type: 'leaderSkill', event, skill: chosen, leader: 'tleilaxu-1' });
|
|
reject(game, 't', { type: 'leaderSkill', event, skill: chosen, leader: 'emperor-0' });
|
|
reject(game, 't', { type: 'leaderSkill', event, skill: game.leaderSkills!.deck[0], leader: 'tleilaxu-0' });
|
|
game = act(game, 't', { type: 'leaderSkill', event, skill: chosen, leader: 'tleilaxu-0' });
|
|
assert.equal(game.decision, null);
|
|
assert.equal(game.leaderSkills!.offers.t, undefined);
|
|
assert.deepEqual(game.leaderSkills!.assignments.find((assignment) => assignment.owner === 't'), {
|
|
owner: 't', leader: 'tleilaxu-0', skill: chosen,
|
|
});
|
|
assert.equal(game.leaderSkills!.deck.filter((skill) => skill === returned).length, 1);
|
|
assert.equal(game.leaderSkills!.deck.includes(chosen), false);
|
|
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
|
reject(game, 't', { type: 'leaderSkill', event, skill: chosen, leader: 'tleilaxu-0' });
|
|
conserved(game);
|
|
});
|
|
|
|
for (const heldKarama of [false, true])
|
|
void test(`negotiated original-leader revival retains one offer through native Tleilaxu income ${heldKarama ? 'with held Karama' : 'automatically'}`, () => {
|
|
const before = revivalGame('a', heldKarama);
|
|
let game = negotiatedRevival(before);
|
|
if (heldKarama) {
|
|
assert.equal(game.response?.kind, 'earlyRevival');
|
|
assert.equal(game.leaderSkills!.offers.a, undefined);
|
|
assert.equal(player(game, 'a').leaders[0].dead, true);
|
|
game = passWindow(game);
|
|
assert.equal(game.response?.kind, 'revivalIncome');
|
|
assert.equal(player(game, 't').spice, player(before, 't').spice);
|
|
const event = assertOffer(game, 'a');
|
|
reject(game, 'a', { type: 'leaderSkill', event, mode: 'draw' });
|
|
game = passWindow(game);
|
|
assert.equal(assertOffer(game, 'a'), event);
|
|
}
|
|
assert.equal(game.response, null);
|
|
assert.equal(game.pendingRevival, null);
|
|
assert.equal(game.decision?.kind, 'leaderSkillRevival');
|
|
assert.equal(player(game, 'a').leaders[0].dead, false);
|
|
assert.equal(player(game, 'a').spice, player(before, 'a').spice - 7);
|
|
assert.equal(player(game, 't').spice, player(before, 't').spice + 7);
|
|
assertOffer(game, 'a');
|
|
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
|
conserved(game);
|
|
});
|
|
|
|
for (const owner of ['t', 'a'] as const)
|
|
void test(`Karama cancellation of ${owner === 't' ? 'native Tleilaxu' : 'negotiated opponent'} early permission leaves the leader dead and creates no skill offer`, () => {
|
|
const before = revivalGame(owner, true);
|
|
let game = owner === 't' ? reviveOwn(before) : negotiatedRevival(before);
|
|
assert.equal(game.response?.kind, 'earlyRevival');
|
|
const karama = player(game, 'e').hand.find((card) => card.effect === 'karama')!;
|
|
game = act(game, 'e', { type: 'card', card: karama.id, mode: 'cancel' });
|
|
assert.equal(game.pendingRevival, null);
|
|
assert.equal(game.response, null);
|
|
assert.equal(game.decision, null);
|
|
assert.equal(player(game, owner).leaders[0].dead, true);
|
|
assert.equal(game.leaderSkills!.offers[owner], undefined);
|
|
assert.equal(player(game, owner).spice, player(before, owner).spice);
|
|
assert.equal(player(game, 't').spice, player(before, 't').spice);
|
|
assert.equal(game.discard.filter((card) => card.id === karama.id).length, 1);
|
|
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
|
conserved(game);
|
|
});
|
|
|
|
void test('canceling an unaffordable Tleilaxu discount aborts before revival and cannot create a skill offer', () => {
|
|
const before = revivalGame('t', true);
|
|
player(before, 't').spice = Math.ceil(player(before, 't').leaders[0].strength / 2);
|
|
let game = passWindow(reviveOwn(before));
|
|
assert.equal(game.response?.kind, 'revivalDiscount');
|
|
const karama = player(game, 'e').hand.find((card) => card.effect === 'karama')!;
|
|
game = act(game, 'e', { type: 'card', card: karama.id, mode: 'cancel' });
|
|
assert.equal(game.pendingRevival, null);
|
|
assert.equal(player(game, 't').leaders[0].dead, true);
|
|
assert.equal(player(game, 't').spice, player(before, 't').spice);
|
|
assert.equal(game.leaderSkills!.offers.t, undefined);
|
|
assert.equal(game.decision, null);
|
|
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
|
conserved(game);
|
|
});
|
|
|
|
void test('each AI level can complete the private original-leader draw with legal saved actions', () => {
|
|
const revived = reviveOwn(revivalGame());
|
|
for (const difficulty of DIFFICULTIES) {
|
|
let game = reload(revived);
|
|
player(game, 't').bot = difficulty;
|
|
for (let step = 0; game.leaderSkills!.offers.t && step < 3; step++) {
|
|
const action = botActions(viewGame(reload(game), 't'))[0];
|
|
assert.ok(action);
|
|
assert.equal(action.type, 'leaderSkill');
|
|
game = act(game, 't', action);
|
|
}
|
|
assert.equal(game.leaderSkills!.offers.t, undefined, difficulty);
|
|
assert.equal(game.decision, null);
|
|
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
|
conserved(game);
|
|
}
|
|
});
|