Files
Dune/tests/nexus-ecaz-betrayal.test.ts
T

203 lines
9.2 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 { botActions, runBots } from '../game/bots';
import {
finishNexusSpice, nexusAllow, nexusInventory, nexusPlayer, nexusReady,
nexusReject, nexusReload, nexusTurnTwo, orderNexusSpice,
} from './fixture-nexus-cards';
const owner = 'h';
const ecaz = 'f';
const ally = 'a';
const location = 'wind_pass';
function setup(): { game: Game; action: Action } {
let g = nexusTurnTwo({ hostFaction: 'ecaz', secondFaction: 'emperor', advanced: true });
orderNexusSpice(g, ['worm', 'land', 'land']);
g = nexusReady(nexusReady(g));
assert.equal(g.nexus, true);
// A reciprocal alliance is negotiated in the actual second-turn Nexus.
g = applyAction(g, ecaz, { type: 'alliance', target: ally });
g = applyAction(g, ally, { type: 'alliance', target: ecaz });
assert.equal(nexusPlayer(g, ecaz).ally, ally);
assert.equal(nexusPlayer(g, ally).ally, ecaz);
g = finishNexusSpice(g);
assert.equal(g.nexusCards!.phase!.stage, 'drawing');
const cards = g.nexusCards!.cards!;
assert.ok(cards.deck.includes('ecaz'));
cards.deck = ['ecaz', ...cards.deck.filter(card => card !== 'ecaz')];
g = applyAction(g, owner, {
type: 'nexusCardChoice', turn: g.turn, card: null, choice: 'draw', ownRedraws: 0,
});
assert.equal(g.phase, 2);
assert.equal(g.nexusCards!.cards!.hands[owner], 'ecaz');
g = nexusReady(g);
for (let step = 0; g.phase === 3 && step < 30; step++) {
if (g.phaseOpening) {
const id = g.players.find(p => !g.phaseOpening!.passed.includes(p.id))!.id;
g = applyAction(g, id, { type: 'ready' });
} else if (g.auction) {
g = applyAction(g, g.auction.active, { type: 'passBid' });
} else {
g = nexusReady(g);
}
}
assert.equal(g.phase, 4, 'actual auction must reach revival');
for (let step = 0; g.phase === 4 && step < 10; step++) {
if (g.decision?.kind === 'ecazPlacement')
g = applyAction(g, g.decision.player, { type: 'decision', decline: true });
else if (g.response) g = nexusAllow(g);
else g = nexusReady(g);
}
assert.equal(g.phase, 5, 'actual revival must reach shipment opening');
assert.equal(g.active, g.order[0]);
assert.deepEqual(g.movementRemaining, g.order);
// Reallocate only existing physical counters from the genuine Emperor seat.
const target = nexusPlayer(g, ally);
target.reserves += Object.values(target.forces).reduce((sum, n) => sum + n, 0);
target.elites!.reserves += Object.values(target.elites!.forces).reduce((sum, n) => sum + n, 0);
target.forces = { 'wind_pass:14': 3, 'wind_pass:15': 2, 'carthag:11': 1 };
target.elites!.forces = { 'wind_pass:14': 1, 'wind_pass:15': 1 };
target.reserves -= 6;
target.elites!.reserves -= 2;
const native = nexusPlayer(g, ecaz);
native.reserves -= 2;
native.forces['wind_pass:14'] = (native.forces['wind_pass:14'] ?? 0) + 2;
nexusInventory(g);
const offer = viewGame(g, owner).nexusEcazBetrayal!;
assert.equal(offer.blocked, null);
assert.equal(offer.ally, ally);
assert.deepEqual(offer.territories.find(row => row.territory === location), {
territory: location,
sectors: {
'wind_pass:14': { normal: 2, elite: 1 },
'wind_pass:15': { normal: 1, elite: 1 },
},
total: { normal: 3, elite: 2 },
});
return { game: g, action: { type: 'nexusEcazBetrayal', event: offer.event, territory: location } };
}
function karama(g: Game): string {
const index = g.deck.findIndex(card => card.effect === 'karama');
assert.ok(index >= 0, 'a physical Karama remains in the treachery deck');
const card = g.deck.splice(index, 1)[0];
nexusPlayer(g, ecaz).hand.push(card);
nexusInventory(g);
return card.id;
}
void test('real Nexus card spends once and returns every typed sector without using shipment or movement', () => {
const { game, action } = setup();
karama(game); // Keep the ordinary response open until every player passes.
const before = nexusReload(game);
const target = nexusPlayer(before, ally);
const projected = viewGame(before, owner).nexusEcazBetrayal!;
for (const id of [ecaz, ally]) {
assert.equal(viewGame(before, id).nexusEcazBetrayal, null);
assert.equal(viewGame(before, id).nexusCards?.card, null);
}
assert.equal(projected.territories.length > 0, true);
nexusReject(game, ecaz, action, /Ecaz|Nexus/);
nexusReject(game, ally, action, /Ecaz|Nexus/);
nexusReject(game, owner, { ...action, event: 'stale' }, /Ecaz|Nexus/);
nexusReject(game, owner, { ...action, territory: 'polar_sink' }, /Ecaz|Nexus/);
assert.deepEqual(game, before);
let pending = applyAction(nexusReload(game), owner, action);
assert.equal(pending.response?.kind, 'nexusEcazBetrayal');
assert.equal(pending.nexusCards!.cards!.hands[owner], null);
assert.deepEqual(pending.nexusCards!.cards!.discard, ['ecaz']);
assert.equal(pending.nexusEcazBetrayalHistory?.[0].stage, 'pending');
assert.deepEqual(nexusPlayer(pending, ally).forces, target.forces);
nexusReject(pending, owner, action, /Ecaz|Nexus/);
pending = nexusReload(pending);
assert.deepEqual(normalizeAutomaticGame(nexusReload(pending)), pending);
for (const counter of ['reserves', 'eliteReserves'] as const) {
const corrupted = nexusReload(pending);
const affected = nexusPlayer(corrupted, ally);
if (counter === 'reserves') affected.reserves = '14' as never;
else affected.elites!.reserves = '3' as never;
const saved = nexusReload(corrupted);
assert.throws(() => nexusAllow(corrupted), /valid reserve and elite counters/);
assert.deepEqual(corrupted, saved);
}
const result = nexusAllow(pending);
const returned = nexusPlayer(result, ally);
assert.equal(returned.forces['wind_pass:14'], undefined);
assert.equal(returned.forces['wind_pass:15'], undefined);
assert.equal(returned.elites!.forces['wind_pass:14'], undefined);
assert.equal(returned.elites!.forces['wind_pass:15'], undefined);
assert.equal(returned.reserves, target.reserves + 5);
assert.equal(returned.elites!.reserves, target.elites!.reserves + 2);
assert.equal(returned.forces['carthag:11'], 1);
assert.deepEqual(nexusPlayer(result, ecaz).forces, nexusPlayer(before, ecaz).forces);
assert.equal(result.phase, 5);
assert.equal(result.active, before.active);
assert.deepEqual(result.movementRemaining, before.movementRemaining);
for (const p of result.players) {
assert.equal(p.shipped, false);
assert.equal(p.moved, 0);
}
assert.equal(result.nexusEcazBetrayalHistory?.[0].stage, 'complete');
assert.equal(result.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
nexusReject(result, owner, action, /Ecaz|Nexus/);
nexusInventory(result);
});
void test('Karama cancellation after saved JSON leaves both force groups in place but spends both physical cards', () => {
const { game, action } = setup();
const card = karama(game);
const original = nexusReload(game);
let pending = applyAction(nexusReload(game), owner, action);
pending = nexusReload(pending);
assert.equal(pending.nexusEcazBetrayalHistory?.[0].stage, 'pending');
let canceled = applyAction(pending, ecaz, { type: 'card', card, mode: 'cancel' });
canceled = nexusAllow(nexusReload(canceled));
assert.equal(canceled.response, null);
assert.equal(canceled.nexusEcazBetrayalHistory?.[0].stage, 'complete');
for (const id of [ally, ecaz]) {
assert.deepEqual(nexusPlayer(canceled, id).forces, nexusPlayer(original, id).forces);
assert.deepEqual(nexusPlayer(canceled, id).elites, nexusPlayer(original, id).elites);
assert.equal(nexusPlayer(canceled, id).reserves, nexusPlayer(original, id).reserves);
}
assert.equal(canceled.discard.filter(item => item.id === card).length, 1);
assert.equal(canceled.nexusCards!.cards!.discard.filter(item => item === 'ecaz').length, 1);
assert.equal(canceled.phase, 5);
assert.equal(canceled.active, original.active);
assert.deepEqual(canceled.movementRemaining, original.movementRemaining);
nexusReject(canceled, owner, action, /Ecaz|Nexus/);
nexusInventory(canceled);
});
void test('a bot holding the real card proposes a legal opening action from its private view', () => {
const { game } = setup();
karama(game); // The bot's declaration opens a real Karama response.
const view = viewGame(game, owner);
view.players.find(p => p.id === owner)!.bot = 'Easy';
const action = botActions(view).find(candidate => candidate.type === 'nexusEcazBetrayal');
assert.ok(action, 'bot should act on a legal Ecaz Betrayal offer');
assert.equal(action.territory, location);
const pending = applyAction(game, owner, action);
assert.equal(pending.response?.kind, 'nexusEcazBetrayal');
nexusInventory(nexusAllow(pending));
});
void test('an automated holder spends Betrayal before a seated-earlier opening movement bot', () => {
const { game } = setup();
game.order = [ecaz, ally, owner];
game.movementRemaining = [...game.order];
game.active = ecaz;
nexusPlayer(game, ecaz).bot = 'Easy';
nexusPlayer(game, owner).bot = 'Easy';
karama(game);
const next = runBots(game, 1);
assert.equal(next.response?.kind, 'nexusEcazBetrayal');
assert.equal(next.nexusEcazBetrayalHistory?.[0].stage, 'pending');
assert.equal(next.nexusCards!.cards!.hands[owner], null);
nexusInventory(next);
});