Files
Dune/tests/nexus-tleilaxu-secret-ally-runtime.test.ts

164 lines
9.9 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { forceRevivalQuote } from '../game/revival';
import {
NEXUS_TLEILAXU_FORCE_GROUP_CAP, tleilaxuSecretAllyEvent,
} from '../game/nexus-tleilaxu-revival';
import {
advanceNexusTleilaxuSecretAlly, createNexusTleilaxuSecretAllyFixture,
nexusTleilaxuSecretAllyClean, nexusTleilaxuSecretAllyInventory,
nexusTleilaxuSecretAllyPlayer as player,
type NexusTleilaxuSecretAllyFixture,
} from './fixture-nexus-tleilaxu-secret-ally';
function secretAllyOffer(game:Game,id:string) {
return viewGame(game,id).nexusTleilaxu?.secretAlly??null;
}
/** The exact committed action shape of this card, not a fixture-level stand-in. */
const cardAction = (event: string, leader: string, amount: number, elite = 0): Action => ({
type: 'nexusTleilaxuRevival', event, leader, amount, elite,
});
function rejected(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'A refused action cannot move counters, spice, cards or the leader');
}
function quietRevival(game: Game, fixture: NexusTleilaxuSecretAllyFixture): Game {
return advanceNexusTleilaxuSecretAlly(game, state =>
state.turn === fixture.game.turn && state.phase === 4 && nexusTleilaxuSecretAllyClean(state));
}
for (const advanced of [false, true]) {
const band = advanced ? 'Advanced' : 'Basic';
void test(`${band} absent-Tleilaxu Nexus: one own dead leader returns with paid counters exactly once`, () => {
const f = createNexusTleilaxuSecretAllyFixture({ advanced });
assert.equal(secretAllyOffer(f.game,f.opponent),null);
assert.equal(secretAllyOffer(f.game,f.partner),null);
const offer = secretAllyOffer(f.game, f.owner)!;
const before = player(f.game, f.owner);
const returned = applyAction(f.game, f.owner, cardAction(offer.event, f.deadLeader.id, 2));
const holder = player(returned, f.owner);
const leader = holder.leaders.find(l => l.id === f.deadLeader.id)!;
assert.equal(leader.dead, false, 'The card returns the offered own disc');
assert.equal(leader.deaths, 1, 'The original death history is preserved');
assert.equal(holder.tanks, before.tanks - 2);
assert.equal(holder.reserves, before.reserves + 2, 'Returned counters enter the ordinary reserves');
assert.equal(holder.spice, before.spice - 2, 'One bank spice per exact returned counter, leader included for nothing');
assert.equal(returned.nexusCards!.cards!.hands[f.owner], null);
assert.equal(returned.nexusCards!.cards!.discard.filter(card => card === 'tleilaxu').length, 1);
assert.equal(secretAllyOffer(returned, f.owner), null, 'The spent physical card cannot offer again');
rejected(returned, f.owner, cardAction(offer.event, f.deadLeader.id, 2));
nexusTleilaxuSecretAllyInventory(returned);
});
void test(`${band} absent-Tleilaxu Nexus: the shared five ceiling counts earlier ordinary returns instead of adding five more`, () => {
const f = createNexusTleilaxuSecretAllyFixture({ advanced });
const before = player(f.game, f.owner);
let game = applyAction(f.game, f.owner, { type: 'revive', amount: 3 });
game = quietRevival(game, f);
const ordinary = player(game, f.owner);
assert.equal(ordinary.revived, 3);
assert.equal(ordinary.tanks, before.tanks - 3);
assert.equal(ordinary.spice, before.spice - forceRevivalQuote(f.game, before, 3).cost);
const offer = secretAllyOffer(game, f.owner)!;
assert.equal(offer.blocked, null);
assert.equal(offer.maxForces, NEXUS_TLEILAXU_FORCE_GROUP_CAP - ordinary.revived,
'Three ordinary counters leave only two card counters of the shared five');
rejected(game, f.owner, cardAction(offer.event, f.deadLeader.id, 5));
rejected(game, f.owner, cardAction(offer.event, f.deadLeader.id, 3));
const paid = applyAction(game, f.owner, cardAction(offer.event, f.deadLeader.id, 2));
const holder = player(paid, f.owner);
assert.equal(holder.tanks, before.tanks - 5, 'Five physical counters return, never three plus five');
assert.equal(holder.reserves, before.reserves + 5);
assert.equal(holder.spice, ordinary.spice - 2);
nexusTleilaxuSecretAllyInventory(paid);
});
void test(`${band} absent-Tleilaxu Nexus: card-first and ordinary-first share the same five-return ceiling`,()=>{
const f=createNexusTleilaxuSecretAllyFixture({advanced});
const offer=secretAllyOffer(f.game,f.owner)!;
const card=applyAction(f.game,f.owner,cardAction(offer.event,f.deadLeader.id,1));
const price=forceRevivalQuote(card,player(card,f.owner),4).cost;
const done=quietRevival(applyAction(card,f.owner,{type:'revive',amount:4}),f);
assert.equal(player(done,f.owner).revived,5);
assert.equal(player(done,f.owner).tanks,3);
assert.equal(player(done,f.owner).spice,player(card,f.owner).spice-price);
assert.equal(player(done,f.owner).leaderRevived,true);
rejected(done,f.owner,{type:'revive',amount:1});
});
void test(`${band} absent-Tleilaxu Nexus: zero counters still returns exactly one own leader free`, () => {
const f = createNexusTleilaxuSecretAllyFixture({ advanced });
const offer = secretAllyOffer(f.game, f.owner)!;
const before = player(f.game, f.owner);
const returned = applyAction(f.game, f.owner, cardAction(offer.event, f.deadLeader.id, 0));
const holder = player(returned, f.owner);
assert.equal(holder.leaders.find(l => l.id === f.deadLeader.id)!.dead, false);
assert.equal(holder.tanks, before.tanks);
assert.equal(holder.reserves, before.reserves);
assert.equal(holder.spice, before.spice, 'The leader is free; only exact counters are priced');
assert.equal(returned.nexusCards!.cards!.discard.filter(card => card === 'tleilaxu').length, 1);
rejected(returned, f.owner, cardAction(offer.event, f.deadLeader.id, 0));
nexusTleilaxuSecretAllyInventory(returned);
});
void test(`${band} absent-Tleilaxu Nexus: foreign actor, stale event, living leader, overage and wrong phase leave everything untouched`, () => {
const f = createNexusTleilaxuSecretAllyFixture({ advanced });
const offer = secretAllyOffer(f.game, f.owner)!;
const living = player(f.game, f.owner).leaders.find(l => !l.dead)!.id;
for (const actor of [f.opponent, f.partner])
rejected(f.game, actor, cardAction(offer.event, f.deadLeader.id, 1));
rejected(f.game, f.owner, cardAction(tleilaxuSecretAllyEvent({ turn: f.game.turn + 1, phase: 4 }, player(f.game, f.owner)), f.deadLeader.id, 1));
rejected(f.game, f.owner, cardAction('stale', f.deadLeader.id, 1));
rejected(f.game, f.owner, cardAction(offer.event, living, 0));
rejected(f.game, f.owner, cardAction(offer.event, f.deadLeader.id, NEXUS_TLEILAXU_FORCE_GROUP_CAP + 1));
rejected(f.game, f.owner, cardAction(offer.event, f.deadLeader.id, -1));
rejected(f.game, f.owner, cardAction(offer.event, f.deadLeader.id, 1, 2));
rejected(f.game, f.owner, cardAction(offer.event, f.deadLeader.id, NEXUS_TLEILAXU_FORCE_GROUP_CAP + 0.5));
// An unused physical card stays held, so the next quiet Revival opens a new opportunity.
const laterTurn = advanceNexusTleilaxuSecretAlly(f.game, state =>
state.turn === 3 && state.phase === 4 && nexusTleilaxuSecretAllyClean(state));
assert.equal(laterTurn.nexusCards!.cards!.hands[f.owner], 'tleilaxu');
assert.equal(player(laterTurn, f.owner).tanks, 8);
const later = secretAllyOffer(laterTurn, f.owner);
assert.ok(later);
assert.equal(later.blocked, null);
assert.equal(later.event, tleilaxuSecretAllyEvent(laterTurn, player(laterTurn, f.owner)));
assert.notEqual(later.event, offer.event, 'A later quiet Revival is its own opportunity');
rejected(laterTurn, f.owner, cardAction(offer.event, f.deadLeader.id, 1));
const settled = applyAction(laterTurn, f.owner, cardAction(later.event, f.deadLeader.id, 1));
assert.equal(player(settled, f.owner).leaders.find(l => l.id === f.deadLeader.id)!.dead, false);
assert.equal(player(settled, f.owner).tanks, 7);
assert.equal(settled.nexusCards!.cards!.discard.filter(card => card === 'tleilaxu').length, 1);
nexusTleilaxuSecretAllyInventory(settled);
});
}
void test('absent-Tleilaxu Nexus: the spent card continues the real turn and the next Revival unchanged', () => {
const f = createNexusTleilaxuSecretAllyFixture({ advanced: false });
const offer = secretAllyOffer(f.game, f.owner)!;
const applied = applyAction(f.game, f.owner, cardAction(offer.event, f.deadLeader.id, 1));
assert.equal(player(applied, f.owner).leaders.find(l => l.id === f.deadLeader.id)!.dead, false);
assert.equal(player(applied, f.owner).tanks, 7);
const quiet = nexusTleilaxuSecretAllyClean(applied) ? applied : advanceNexusTleilaxuSecretAlly(applied,
state => state.turn === 2 && state.phase === 4 && nexusTleilaxuSecretAllyClean(state));
assert.equal(quiet.turn, 2);
assert.equal(quiet.phase, 4,
'The spent card never rewrites the current Revival phase');
const next = advanceNexusTleilaxuSecretAlly(quiet, state =>
state.turn === 3 && state.phase === 4 && nexusTleilaxuSecretAllyClean(state));
assert.equal(next.phase, 4);
const holder = player(next, f.owner);
assert.equal(holder.leaders.find(l => l.id === f.deadLeader.id)!.dead, false, 'Revival survives Movement, Battle, Collection and the next turn');
assert.equal(holder.revived, 0, 'The real turn boundary resets the ordinary ledger');
assert.equal(holder.tanks, 7);
assert.equal(next.nexusCards!.cards!.hands[f.owner], null);
assert.equal(next.nexusCards!.cards!.discard.filter(card => card === 'tleilaxu').length, 1,
'Exactly one physical Tleilaxu card was spent');
assert.equal(secretAllyOffer(next, f.owner), null);
assert.ok(Number.isSafeInteger(holder.spice) && holder.spice >= 0, 'The wallet remains a valid bank balance');
nexusTleilaxuSecretAllyInventory(next);
});