130 lines
5.9 KiB
TypeScript
130 lines
5.9 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction, normalizeAutomaticGame, viewGame, type Action, type Game,
|
|
} from '../game/engine';
|
|
import { botActions, runBots } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { nexusInventory, nexusPlayer, nexusReload } from './fixture-nexus-cards';
|
|
import {
|
|
fremenBetrayalFixture as setup,
|
|
fremenBetrayalMovement as movement,
|
|
useLegacyFremenMovementReceipt,
|
|
} from './fixture-nexus-fremen-betrayal';
|
|
|
|
const fremen = 'f';
|
|
const ally = 'a';
|
|
const holder = 'h';
|
|
|
|
function rejected(g: Game, id: string, action: Action) {
|
|
const before = structuredClone(g);
|
|
assert.throws(() => applyAction(g, id, action));
|
|
assert.deepEqual(g, before);
|
|
}
|
|
|
|
for (const advanced of [false, true]) {
|
|
void test(`${advanced ? 'Advanced' : 'Basic'} spent Betrayal suppresses only the native range through its turn and reload`, () => {
|
|
const { g, play } = setup(advanced);
|
|
assert.equal(viewGame(g, fremen).nexusFremenBetrayal, null);
|
|
assert.equal(viewGame(g, ally).nexusFremenBetrayal, null);
|
|
assert.equal(viewGame(g, holder).nexusCards?.card, 'fremen');
|
|
rejected(g, fremen, play);
|
|
rejected(g, holder, { ...play, event: 'stale' });
|
|
rejected(g, holder, { ...play, target: fremen });
|
|
assert.deepEqual(botActions(viewGame(g, holder))[0], play);
|
|
let state = applyAction(nexusReload(g), holder, play);
|
|
nexusInventory(state);
|
|
assert.equal(state.nexusCards!.cards!.hands[holder], null);
|
|
assert.equal(state.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
|
assert.equal(nexusPlayer(state, fremen).fremenNexusMovementBlockedTurn, state.turn);
|
|
assert.equal(viewGame(state, fremen).players.find(p => p.id === fremen)!.fremenMovementBlocked, true);
|
|
assert.equal(viewGame(state, fremen).players.find(p => p.id === fremen)!.fremenNexusMovementBlocked, true);
|
|
rejected(state, holder, play);
|
|
rejected(state, fremen, movement(2));
|
|
state = applyAction(nexusReload(state), fremen, movement(1));
|
|
assert.equal(state.pendingFremenMove ?? null, null);
|
|
assert.equal(state.response?.kind, undefined);
|
|
assert.equal(nexusPlayer(state, fremen).moved, 1);
|
|
assert.equal(nexusPlayer(state, fremen).fremenNexusMovementBlockedTurn, state.turn);
|
|
const restored = normalizeAutomaticGame(nexusReload(state));
|
|
assert.equal(nexusPlayer(restored, fremen).fremenNexusMovementBlockedTurn, restored.turn);
|
|
assert.deepEqual(restored.nexusFremenBetrayalHistory, state.nexusFremenBetrayalHistory);
|
|
});
|
|
}
|
|
|
|
void test('all four bot holders spend the card before an active Fremen bot moves', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const { g, play } = setup();
|
|
nexusPlayer(g, holder).bot = difficulty;
|
|
nexusPlayer(g, fremen).bot = difficulty;
|
|
assert.deepEqual(botActions(viewGame(g, holder))[0], play);
|
|
const first = runBots(g, 1);
|
|
assert.equal(first.nexusCards!.cards!.hands[holder], null);
|
|
assert.equal(nexusPlayer(first, fremen).moved, 0);
|
|
assert.equal(nexusPlayer(first, fremen).fremenNexusMovementBlockedTurn, first.turn);
|
|
}
|
|
});
|
|
|
|
void test('independent city ornithopters retain their range despite spent Betrayal', () => {
|
|
const { g, play } = setup();
|
|
const state = applyAction(g, holder, play);
|
|
const f = nexusPlayer(state, fremen);
|
|
f.reserves--;
|
|
f.forces['arrakeen:10'] = 1;
|
|
const moved = applyAction(state, fremen, movement(2));
|
|
assert.equal(moved.pendingFremenMove ?? null, null);
|
|
assert.equal(nexusPlayer(moved, fremen).moved, 1);
|
|
assert.equal(nexusPlayer(moved, fremen).fremenNexusMovementBlockedTurn, moved.turn);
|
|
});
|
|
|
|
void test('turn-scoped suppression expires without returning the physical card', () => {
|
|
const { g, play } = setup();
|
|
const next = nexusReload(applyAction(g, holder, play));
|
|
next.turn++;
|
|
next.active = fremen;
|
|
next.movementRemaining = [...next.order];
|
|
nexusPlayer(next, fremen).moved = 0;
|
|
const view = viewGame(next, fremen);
|
|
assert.equal(view.players.find(p => p.id === fremen)!.fremenNexusMovementBlocked, false);
|
|
assert.equal(view.players.find(p => p.id === fremen)!.fremenMovementBlocked, false);
|
|
const moved = applyAction(next, fremen, movement(2));
|
|
assert.equal(nexusPlayer(moved, fremen).moved, 1);
|
|
assert.equal(moved.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
|
});
|
|
|
|
void test('changed saved turn, target or suppression rejects before advancing movement', () => {
|
|
const { g, play } = setup();
|
|
const played = applyAction(g, holder, play);
|
|
for (const change of [
|
|
(state: Game) => { state.nexusFremenBetrayalHistory![0].target = ally; },
|
|
(state: Game) => { state.nexusFremenBetrayalHistory![0].turn--; },
|
|
(state: Game) => { delete nexusPlayer(state, fremen).fremenNexusMovementBlockedTurn; },
|
|
(state: Game) => { state.nexusCards!.cards!.discard = []; },
|
|
]) {
|
|
const damaged = nexusReload(played);
|
|
change(damaged);
|
|
rejected(damaged, fremen, movement(1));
|
|
}
|
|
});
|
|
|
|
void test('validated movement-only receipts remain readable and executable after JSON restoration', () => {
|
|
const { g, play } = setup();
|
|
const played = applyAction(g, holder, play);
|
|
useLegacyFremenMovementReceipt(played);
|
|
const saved = nexusReload(played);
|
|
assert.equal(viewGame(saved, fremen).players.find(p => p.id === fremen)!.fremenMovementBlocked, true);
|
|
const restored = normalizeAutomaticGame(nexusReload(saved));
|
|
assert.deepEqual(restored.nexusFremenBetrayalHistory, saved.nexusFremenBetrayalHistory);
|
|
const moved = applyAction(restored, fremen, movement(1));
|
|
assert.equal(nexusPlayer(moved, fremen).moved, 1);
|
|
for (const corrupt of [
|
|
(state: Game) => { state.nexusFremenBetrayalHistory![0].event = 'forged'; },
|
|
(state: Game) => { state.nexusFremenBetrayalHistory![0].signature = 'forged'; },
|
|
]) {
|
|
const damaged = nexusReload(saved);
|
|
corrupt(damaged);
|
|
assert.throws(() => viewGame(damaged, fremen));
|
|
rejected(damaged, fremen, movement(1));
|
|
}
|
|
});
|