100 lines
6.3 KiB
TypeScript
100 lines
6.3 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { emperorNexusPools } from '../game/nexus-emperor-secret-ally';
|
|
import { forceRevivalRemaining, freeRevivalRemaining } from '../game/revival';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
import { advanceSemutaNexusTrade, semutaNexusTradeInventory, swapSemutaNexusTradeIdentity } from './fixture-semuta-nexus-trade';
|
|
|
|
const player = (g: Game, id: string) => { const p = g.players.find(seat => seat.id === id); assert.ok(p); return p; };
|
|
const quiet = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingRevival;
|
|
function front(g: Game, kinds: ('land' | 'worm')[]): void {
|
|
for (const [position, kind] of kinds.entries()) {
|
|
const index = g.spiceDeck.findIndex((card, i) => i >= position && (kind === 'land' ? 'territory' in card : 'worm' in card));
|
|
assert.ok(index >= position);
|
|
g.spiceDeck.splice(position, 0, g.spiceDeck.splice(index, 1)[0]);
|
|
}
|
|
}
|
|
/** Genuine Semuta/Nexus setup and an earned closing Nexus draw. Only original
|
|
* unopened Spice order, conserved board placement, Nexus identity and Tanks
|
|
* counters are staged; no phase, card receipt or money is invented. */
|
|
function fixture(native: 'classic' | 'choam' | 'tleilaxu') {
|
|
const tleilaxu = native === 'tleilaxu';
|
|
let g = createGame('SEMUTANEXUSEMPEROR', newPlayer('r', 'Richese', 'richese'), false,
|
|
tleilaxu ? ['choam', 'ix'] : ['choam']);
|
|
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
|
|
joinGame(g, newPlayer('g', 'Guild', 'guild'));
|
|
joinGame(g, newPlayer('p', native, native === 'classic' ? 'harkonnen' : native));
|
|
g.nexusCards = {cards:null, phase:null};
|
|
for (const p of g.players) g = applyAction(g, p.id, {type:'ready'});
|
|
g = initializeSemutaGameForAudit(g);
|
|
g = advanceSemutaNexusTrade(g, state => state.status === 'playing');
|
|
assert.equal(g.semutaPreview, true);
|
|
assert.ok(Object.values(g.nexusCards!.cards!.hands).every(card => card === null));
|
|
const cards = semutaNexusTradeInventory(g);
|
|
g = advanceSemutaNexusTrade(g, state => state.phase === 1 && quiet(state));
|
|
front(g, ['land', 'land']);
|
|
g = advanceSemutaNexusTrade(g, state => state.phase === 5 && quiet(state));
|
|
for (const p of g.players) {
|
|
p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0); p.forces = {};
|
|
if (p.elites) { p.elites.reserves += Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0); p.elites.forces = {}; }
|
|
}
|
|
g = advanceSemutaNexusTrade(g, state => state.turn === 2 && state.phase === 1 && quiet(state));
|
|
front(g, ['worm', 'land', 'land']);
|
|
g = advanceSemutaNexusTrade(g, state => state.nexus && !state.spiceWindow && quiet(state));
|
|
g = applyAction(g, 'p', {type:'alliance', target:'g'});
|
|
g = applyAction(g, 'g', {type:'alliance', target:'p'});
|
|
g = advanceSemutaNexusTrade(g, state => state.nexusCards?.phase?.stage === 'drawing');
|
|
assert.ok(g.nexusCards!.phase!.eligible.includes('a'));
|
|
g = applyAction(g, 'a', {type:'nexusCardChoice', turn:g.turn, card:null, choice:'draw', ownRedraws:0});
|
|
assert.ok(g.nexusCards!.cards!.hands.a);
|
|
swapSemutaNexusTradeIdentity(g, 'a', 'emperor');
|
|
g = advanceSemutaNexusTrade(g, state => state.phase === 4 && quiet(state));
|
|
const owner = player(g, 'a'); assert.ok(owner.reserves >= 6); owner.reserves -= 6; owner.tanks += 6;
|
|
assert.deepEqual(semutaNexusTradeInventory(g), cards);
|
|
return {game:g, cards};
|
|
}
|
|
|
|
for (const native of ['classic', 'choam', 'tleilaxu'] as const) void test(`Basic Semuta/Nexus preserves Emperor's extra revival with ${native}`, () => {
|
|
const tleilaxu = native === 'tleilaxu';
|
|
const f = fixture(native), g = f.game, owner = player(g, 'a');
|
|
const before = emperorNexusPools(owner), quota = forceRevivalRemaining(g, owner), free = freeRevivalRemaining(g, owner);
|
|
const semutaHeld = g.players.flatMap(p => p.hand).some(card => card.id === SEMUTA_DRUG_ID);
|
|
const offer = viewGame(g, 'a').nexusEmperorSecretAlly; assert.ok(offer);
|
|
assert.equal(offer.revival.blocked, null); assert.equal(offer.revival.amount, 3);
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const view = viewGame(g, 'a'); view.players.find(p => p.id === 'a')!.bot = difficulty;
|
|
const action = botActions(view).find(candidate => candidate.type === 'nexusEmperorRevive');
|
|
assert.ok(action, `${difficulty} must retain a legal Emperor revival`);
|
|
assert.doesNotThrow(() => applyAction(g, 'a', action));
|
|
}
|
|
const otherSpice = tleilaxu ? player(g, 'p').spice : 0;
|
|
let result = applyAction(g, 'a', {type:'nexusEmperorRevive', event:offer.event, elite:0});
|
|
// A held cancellation card can keep native Tleilaxu income pending; finish
|
|
// that original response before checking the credit, without replaying the
|
|
// already-completed Nexus return.
|
|
if (result.response) {
|
|
assert.equal(result.response.kind, 'revivalIncome');
|
|
assert.equal(player(result, 'p').spice, otherSpice);
|
|
result = advanceSemutaNexusTrade(result, state => quiet(state));
|
|
}
|
|
assert.deepEqual(emperorNexusPools(player(result, 'a')), {...before, reserves:before.reserves + 3, tanks:before.tanks - 3});
|
|
assert.equal(forceRevivalRemaining(result, player(result, 'a')), quota);
|
|
assert.equal(freeRevivalRemaining(result, player(result, 'a')), free);
|
|
assert.equal(result.nexusCards!.cards!.hands.a, null);
|
|
assert.equal(result.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
|
|
assert.equal(result.nexusEmperorSecretHistory?.[0]?.stage, 'returned');
|
|
assert.equal(result.pendingRevival, null);
|
|
assert.equal(result.pendingTreacheryDiscard ?? null, null);
|
|
assert.equal(result.semutaPreview, true);
|
|
assert.equal(result.players.flatMap(p => p.hand).some(card => card.id === SEMUTA_DRUG_ID), semutaHeld);
|
|
if (tleilaxu) assert.equal(player(result, 'p').spice, otherSpice + 1);
|
|
assert.deepEqual(semutaNexusTradeInventory(result), f.cards);
|
|
assert.throws(() => applyAction(result, 'a', {type:'nexusEmperorRevive', event:offer.event, elite:0}));
|
|
const continued = advanceSemutaNexusTrade(result, state => state.phase === 5 && quiet(state));
|
|
assert.equal(continued.nexusEmperorSecretHistory?.[0]?.stage, 'returned');
|
|
assert.deepEqual(semutaNexusTradeInventory(continued), f.cards);
|
|
});
|