Files
Dune/tests/fixture-nexus-fremen-betrayal.ts

120 lines
5.1 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { TERRITORIES, distance, location, splitLocation } from '../game/board';
import {
enterNexusSpice, finishNexusSpice, nexusInventory, nexusPlayer, nexusReady,
nexusTurnTwo, orderNexusSpice,
} from './fixture-nexus-cards';
export const fremenBetrayalSource = 'red_chasm:7';
const destinations = TERRITORIES.filter(t => t.type === 'sand').flatMap(t =>
t.sectors.filter(sector => sector !== 18).map(sector => location(t.id, sector)));
export function fremenBetrayalMovement(range: number): Action {
const destination = destinations.find(to =>
distance(fremenBetrayalSource, to, key => splitLocation(key).sector === 18) === range);
assert.ok(destination, `range ${range} from ${fremenBetrayalSource}`);
const to = splitLocation(destination);
return {
type: 'move', from: fremenBetrayalSource, amount: 4,
territory: to.territory, sector: to.sector,
};
}
/** Actual setup, second-turn Nexus draw, Bidding and Revival; only final board
* force positions are chosen for a deterministic native movement boundary. */
export function fremenBetrayalFixture(
advanced = false,
ids: [string, string, string] = ['f', 'a', 'h'],
) {
const [riderId, allyId, holderId] = ids;
let g = nexusTurnTwo({ seatIds: ids, hostFaction: 'fremen', secondFaction: 'atreides', advanced });
orderNexusSpice(g, ['worm', 'land', 'land']);
g = nexusReady(nexusReady(g));
assert.equal(g.nexus, true);
g = applyAction(g, riderId, { type: 'alliance', target: allyId });
g = applyAction(g, allyId, { type: 'alliance', target: riderId });
g = finishNexusSpice(g);
assert.equal(g.nexusCards!.phase!.stage, 'drawing');
const cards = g.nexusCards!.cards!;
cards.deck = ['fremen', ...cards.deck.filter(card => card !== 'fremen')];
g = applyAction(g, holderId, {
type: 'nexusCardChoice', turn: g.turn, card: null, choice: 'draw', ownRedraws: 0,
});
assert.equal(g.nexusCards!.cards!.hands[holderId], 'fremen');
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);
for (let step = 0; g.phase === 4 && step < 20; step++) {
if (g.response) {
const id = g.players.find(p => !g.response!.passed.includes(p.id))!.id;
g = applyAction(g, id, { type: 'passResponse' });
} else g = nexusReady(g);
}
assert.equal(g.phase, 5);
const rider = nexusPlayer(g, riderId);
const total = rider.reserves + Object.values(rider.forces).reduce((sum, count) => sum + count, 0);
rider.forces = { [fremenBetrayalSource]: 4 };
rider.reserves = total - 4;
if (rider.elites) {
const eliteTotal = rider.elites.reserves +
Object.values(rider.elites.forces).reduce((sum, count) => sum + count, 0);
rider.elites.forces = {};
rider.elites.reserves = eliteTotal;
}
rider.shipped = true;
rider.moved = 0;
g.active = riderId;
nexusInventory(g);
const offer = viewGame(g, holderId).nexusFremenBetrayal!;
assert.equal(offer.blocked, null);
return { g, play: { type: 'nexusFremenBetrayal', event: offer.event } as Action };
}
/** Preserve the genuinely drawn card into the next turn's clean pre-blow window. */
export function fremenBetrayalWormFixture(
advanced = false,
ids: [string, string, string] = ['f', 'a', 'h'],
) {
const previous = fremenBetrayalFixture(advanced, ids);
const g = enterNexusSpice(previous.g, true);
assert.equal(g.phase, 1);
const offer = viewGame(g, ids[2]).nexusFremenBetrayal!;
assert.equal(offer.mode, 'worm');
assert.equal(offer.blocked, null);
return { g, play: { type: 'nexusFremenBetrayal', event: offer.event } as Action };
}
/** Preserve the physical Spice Deck census while choosing a real next appearance. */
export function fremenBetrayalNextWorm(g: Game, target: string) {
const cards = [...g.spiceDeck, ...g.spiceDiscard.flat()];
const landIndex = cards.findIndex(card => 'territory' in card && card.territory === target);
assert.ok(landIndex >= 0, `Spice deck needs ${target}`);
const land = cards.splice(landIndex, 1)[0];
const wormIndex = cards.findIndex(card => 'worm' in card && !card.suppressed);
assert.ok(wormIndex >= 0, 'Spice deck needs a natural worm');
const worm = cards.splice(wormIndex, 1)[0];
g.spiceDeck = [worm, ...cards];
g.spiceDiscard = [[land], []];
}
/** Recreate the exact six-field receipt persisted by the movement-only checkpoint. */
export function useLegacyFremenMovementReceipt(g: Game) {
const history = g.nexusFremenBetrayalHistory!;
const record = history.at(-1)!;
assert.equal(record.mode, 'movement');
const legacy = {
event: JSON.stringify(['nexusFremenBetrayal', record.turn, record.owner, record.target]),
owner: record.owner, target: record.target, turn: record.turn,
phase: 5 as const, signature: '',
};
legacy.signature = JSON.stringify({ ...legacy, signature: undefined });
history[history.length - 1] = legacy;
}