Connect Fremen Nexus protection before original worm devouring
This commit is contained in:
1 parent
8ab827b902
commit
82f1aae51b
17 files changed
+1425
-12
No files matched your search
@@ -0,0 +1,327 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
|
||||
type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { location, splitLocation, territory, validGameLocation } from '../game/board';
|
||||
import { spiceDeck, treacheryDeck, type SpiceCard } from '../game/cards';
|
||||
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
|
||||
import { DISCOVERY_SPICE_CARDS } from '../game/discoveries';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
/** One real native step: the actor is always the seat the engine expects. */
|
||||
export type NexusFremenProtectionStep = { actor: string; action: Action };
|
||||
export type NexusFremenProtectionFixture = {
|
||||
/** Fresh classic lobby and its genuine initialized, undealt setup. */
|
||||
initial: Game;
|
||||
afterSetup: Game;
|
||||
/** Turn-two Shai-Hulud Nexus before the real reciprocal alliance. */
|
||||
beforeNexus: Game;
|
||||
/** Real closing private-card opportunity for the two unallied seats. */
|
||||
beforeClosingDraw: Game;
|
||||
afterClosingDraw: Game;
|
||||
/** Turn-two Shipment and Movement turn of the unallied Nexus holder. */
|
||||
beforeShipment: Game;
|
||||
afterShipment: Game;
|
||||
/** Exact state before the native action that opens the boundary. */
|
||||
beforeWorm: Game;
|
||||
producer: NexusFremenProtectionStep;
|
||||
/** Genuine pre-destruction boundary: the frame is open, nothing is devoured. */
|
||||
game: Game;
|
||||
advanced: boolean;
|
||||
/** Unallied seat that really drew the physical Fremen Nexus card. */
|
||||
holder: string;
|
||||
/** Unallied seat that holds an irrelevant Nexus face. */
|
||||
neutral: string;
|
||||
/** The two seats that formed the real reciprocal alliance. */
|
||||
allies: [string, string];
|
||||
territory: string;
|
||||
sector: number;
|
||||
/** Where the holder's paid reserve force (and any advisor) stands. */
|
||||
location: string;
|
||||
/** The undealt land card the original worm draws after devouring. */
|
||||
replacement: string;
|
||||
actions: NexusFremenProtectionStep[];
|
||||
staging: string[];
|
||||
/** Raw JSON-safe facts for the parent's API and phone proof. */
|
||||
scenario: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function nexusFremenProtectionPlayer(game: Game, id: string): Player {
|
||||
const player = game.players.find(candidate => candidate.id === id);
|
||||
assert.ok(player, `Missing original classic seat ${id}`);
|
||||
return player;
|
||||
}
|
||||
export const nexusFremenProtectionClean = (game: Game): boolean =>
|
||||
!game.phaseOpening && !game.response && !game.decision && !game.truthtrance &&
|
||||
!game.pendingTreacheryDiscard;
|
||||
|
||||
/** Scalar shuffle lotteries preserve printed order; UUID entropy stays native. */
|
||||
function producerEntropy<T>(run: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto);
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = 0xffffffff;
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return run(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Original engine priority is settled before this fixture's own table choices:
|
||||
* responses, private decisions, sealed battle plans and the Nexus card
|
||||
* opportunity all run first. The fixture adds only two deterministic policies:
|
||||
* the acknowledgement answer, and declining a free intrusion or advisor battle
|
||||
* so no seat ever needs an invented sealed plan. */
|
||||
export function nextNexusFremenProtectionNativeStep(game: Game): NexusFremenProtectionStep | null {
|
||||
if (game.status === 'finished') return null;
|
||||
if (game.phaseOpening || game.response || game.truthtrance || game.pendingTreacheryDiscard)
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'nexusFremenProtection')
|
||||
return { actor: decision.player, action: {
|
||||
type: 'nexusFremenProtection', event: decision.event, use: false } };
|
||||
if (decision?.kind === 'advisor' || decision?.kind === 'intrusion' ||
|
||||
decision?.kind === 'advisorBattle')
|
||||
return { actor: decision.player, action: { type: 'decision', accept: false } };
|
||||
if (decision) return nextStrongholdFactionsNativeStep(game);
|
||||
const phase = game.nexusCards?.phase;
|
||||
if (phase?.stage === 'drawing') {
|
||||
const actor = phase.eligible.find(id => !phase.done.includes(id));
|
||||
assert.ok(actor, 'The Nexus draw window must retain an unfinished recipient');
|
||||
const held = game.nexusCards!.cards!.hands[actor];
|
||||
return { actor, action: { type: 'nexusCardChoice', turn: phase.turn, card: held,
|
||||
choice: held ? 'keep' : 'draw', ownRedraws: 0 } };
|
||||
}
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function applyNexusFremenProtectionStep(game: Game, step: NexusFremenProtectionStep,
|
||||
actions?: NexusFremenProtectionStep[]): Game {
|
||||
actions?.push(structuredClone(step));
|
||||
return producerEntropy(() => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
export function advanceNexusFremenProtection(game: Game, until: (game: Game) => boolean,
|
||||
actions?: NexusFremenProtectionStep[]): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original chronology ended early');
|
||||
const step = nextNexusFremenProtectionNativeStep(game);
|
||||
assert.ok(step, `The original continuation needs a sealed plan at ${game.status}/${game.turn}/${game.phase}`);
|
||||
game = applyNexusFremenProtectionStep(game, step, actions);
|
||||
}
|
||||
throw new Error('The original absent-Fremen chronology did not reach its boundary');
|
||||
}
|
||||
|
||||
/** Physical census only: every original Treachery, Spice and Nexus card plus all
|
||||
* twenty physical force counters per seat; no module owner or face is assumed. */
|
||||
export function nexusFremenProtectionInventory(game: Game): void {
|
||||
const treachery = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
|
||||
assert.deepEqual(treachery.map(card => card.id).sort(),
|
||||
treacheryDeck(game.expansions).map(card => card.id).sort());
|
||||
assert.equal(new Set(treachery.map(card => card.id)).size, treachery.length);
|
||||
const spice = [...game.spiceDeck, ...game.spiceDiscard.flat(),
|
||||
...(game.spiceSequence?.skipped ?? [])];
|
||||
const printedSpice = [...spiceDeck(), ...(game.discoveryEnabled
|
||||
? [...DISCOVERY_SPICE_CARDS, { worm: true, greatMaker: true }] : [])];
|
||||
assert.deepEqual(spice.map(card => JSON.stringify(card)).sort(),
|
||||
printedSpice.map(card => JSON.stringify(card)).sort());
|
||||
assert.ok(game.nexusCards?.cards);
|
||||
validateNexusCards(game.nexusCards.cards, game.players);
|
||||
assert.deepEqual([...game.nexusCards.cards.deck, ...game.nexusCards.cards.discard,
|
||||
...Object.values(game.nexusCards.cards.hands).filter(card => card !== null)].sort(),
|
||||
[...NEXUS_FACTIONS].sort());
|
||||
for (const player of game.players)
|
||||
assert.equal(player.reserves + player.tanks +
|
||||
Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20);
|
||||
}
|
||||
/** Public territory presence, including a concealed marker; physical groups stay in forces. */
|
||||
export function nexusFremenProtectionPresence(game: Game, id: string, site: string): number {
|
||||
return Object.entries(nexusFremenProtectionPlayer(game, id).forces)
|
||||
.filter(([key]) => splitLocation(key).territory === site)
|
||||
.reduce((sum, [, amount]) => sum + amount, 0);
|
||||
}
|
||||
|
||||
/** Only an original undealt physical Spice Card is moved to the front of the
|
||||
* physical deck; the census and the remaining printed order are untouched. */
|
||||
function orderUndealtSpice(game: Game, front: readonly string[]): void {
|
||||
const take = (matches: (card: SpiceCard) => boolean): SpiceCard => {
|
||||
const index = game.spiceDeck.findIndex(matches);
|
||||
assert.ok(index >= 0, 'Only an original undealt physical Spice Card may be positioned');
|
||||
return game.spiceDeck.splice(index, 1)[0];
|
||||
};
|
||||
game.spiceDeck.unshift(...front.map(entry => entry === 'worm' || entry === 'greatMaker'
|
||||
? take(card => 'worm' in card && !!card.greatMaker === (entry === 'greatMaker') &&
|
||||
!card.thumper && !card.suppressed)
|
||||
: take(card => 'territory' in card && card.territory === entry && !card.discovery)));
|
||||
}
|
||||
/** Only the original undealt Nexus deck is reordered; no hand is ever granted. */
|
||||
function orderUndealtNexusCards(game: Game): void {
|
||||
const cards = game.nexusCards!.cards!;
|
||||
const front = ['fremen', 'beneGesserit'] as const;
|
||||
cards.deck = [...front, ...cards.deck.filter(card => !front.some(face => face === card))];
|
||||
}
|
||||
|
||||
/** One fixed genuine Basic/Advanced absent-Fremen chronology with no candidate
|
||||
* retries: real setup, a real empty turn-two worm, the real reciprocal alliance
|
||||
* and closing draw that put the physical Fremen Nexus card into the unallied
|
||||
* holder's hand, a real paid reserve shipment into a real sand source, and the
|
||||
* next accepted real natural worm threatening exactly that group. Advanced adds
|
||||
* the native free accompanied Bene Gesserit advisor at the same sector. */
|
||||
export function createNexusFremenProtectionFixture(
|
||||
options: { advanced?: boolean; greatMaker?: boolean } = {},
|
||||
): NexusFremenProtectionFixture {
|
||||
const advanced = options.advanced ?? false;
|
||||
const holder = 'protection-emperor', neutral = 'protection-neutral';
|
||||
const allies: [string, string] = ['protection-guild', 'protection-atreides'];
|
||||
const target = 'hagga_basin', sector = 12, replacement = 'sihaya_ridge';
|
||||
const destination = location(target, sector);
|
||||
const actions: NexusFremenProtectionStep[] = [];
|
||||
const staging = [
|
||||
'Original scalar producer lotteries keep every printed shuffle order; native UUID entropy is untouched.',
|
||||
'Only the undealt physical Spice Deck is ordered: turn-one land, the real empty turn-two worm, the turn-two sand blow the group ships into, the next accepted real worm, then the replacement draw.',
|
||||
'Only the undealt physical Nexus Deck is ordered so the real closing draws give the unallied holder the physical Fremen card and the other unallied seat the physical Bene Gesserit card; no hand is assigned.',
|
||||
'Every storm dialer chooses the printed minimum (0 on turn one, otherwise 1), an original legal dial that keeps the real storm on Meridian at turn one and well clear of the shipped sand source afterwards',
|
||||
advanced
|
||||
? 'The physically dealt Bene Gesserit seat documents the absent-Fremen case: it holds an irrelevant Nexus face and accepts the native free accompanied advisor arrival at the same sector.'
|
||||
: 'No Bene Gesserit is seated; the fourth seat is Harkonnen and holds an irrelevant Nexus face at no other location.',
|
||||
];
|
||||
let game = createGame('NEXUSFREMENPROTECTION', newPlayer(holder, 'Emperor', 'emperor'), advanced);
|
||||
joinGame(game, newPlayer(allies[0], 'Guild', 'guild'));
|
||||
joinGame(game, newPlayer(allies[1], 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer(neutral, advanced ? 'Bene Gesserit' : 'Harkonnen',
|
||||
advanced ? 'beneGesserit' : 'harkonnen'));
|
||||
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
||||
game.nexusCards = { cards: null, phase: null };
|
||||
if (options.greatMaker) game.discoveryEnabled = true;
|
||||
game = producerEntropy(() => initializeNexusGameForAudit(game));
|
||||
const threat = options.greatMaker ? 'greatMaker' : 'worm';
|
||||
orderUndealtSpice(game, advanced
|
||||
? ['broken_land', 'rock_outcroppings', 'worm', target, 'south_mesa', threat, replacement, 'habbanya_erg']
|
||||
: ['broken_land', 'worm', target, threat, replacement]);
|
||||
orderUndealtNexusCards(game);
|
||||
const initial = structuredClone(game);
|
||||
game = advanceNexusFremenProtection(game, state => state.status === 'playing', actions);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.ok(game.players.every(player => player.hand.length > 0 && player.traitors.length > 0),
|
||||
'The real setup must deal traitors and starting Treachery Cards');
|
||||
assert.ok(!game.players.some(player => player.faction === 'fremen'),
|
||||
'The native advantage must stay absent for this protection path');
|
||||
nexusFremenProtectionInventory(game);
|
||||
|
||||
// Real turn-two Shai-Hulud appearance: an accepted natural worm opens the Nexus.
|
||||
game = advanceNexusFremenProtection(game, state => state.turn === 2 && state.phase === 1 &&
|
||||
state.nexus === true && !state.spiceWindow && nexusFremenProtectionClean(state), actions);
|
||||
const beforeNexus = structuredClone(game);
|
||||
assert.equal(game.nexusCards?.phase?.turn, 2);
|
||||
for (const [actor, partner] of [[allies[0], allies[1]], [allies[1], allies[0]]] as const)
|
||||
game = applyNexusFremenProtectionStep(game,
|
||||
{ actor, action: { type: 'alliance', target: partner } }, actions);
|
||||
assert.equal(nexusFremenProtectionPlayer(game, allies[0]).ally, allies[1]);
|
||||
assert.equal(nexusFremenProtectionPlayer(game, allies[1]).ally, allies[0]);
|
||||
assert.equal(nexusFremenProtectionPlayer(game, holder).ally, null);
|
||||
assert.equal(nexusFremenProtectionPlayer(game, neutral).ally, null);
|
||||
|
||||
// Real closing opportunity: both unallied seats choose from their own position.
|
||||
game = advanceNexusFremenProtection(game, state =>
|
||||
state.nexusCards?.phase?.stage === 'drawing' && nexusFremenProtectionClean(state), actions);
|
||||
const beforeClosingDraw = structuredClone(game);
|
||||
assert.deepEqual([...game.nexusCards!.phase!.eligible].sort(), [holder, neutral].sort());
|
||||
assert.ok(nexusFremenProtectionPlayer(game, holder).hand.length > 0,
|
||||
'The real setup must deal the holder a starting Treachery Card');
|
||||
game = advanceNexusFremenProtection(game,
|
||||
state => state.nexusCards?.phase?.stage === 'complete', actions);
|
||||
const afterClosingDraw = structuredClone(game);
|
||||
assert.equal(game.nexusCards!.cards!.hands[holder], 'fremen',
|
||||
'The real closing draw put the physical Fremen Nexus card into the unallied holder hand');
|
||||
assert.equal(game.nexusCards!.cards!.hands[neutral], 'beneGesserit');
|
||||
assert.ok(!game.nexusCards!.cards!.discard.includes('fremen'));
|
||||
|
||||
// Real paid reserve shipment of one force into the sand source, followed in
|
||||
// Advanced by the native free accompanied advisor arrival (never a battle).
|
||||
game = advanceNexusFremenProtection(game, state => state.turn === 2 && state.phase === 5 &&
|
||||
state.active === holder && nexusFremenProtectionClean(state), actions);
|
||||
const beforeShipment = structuredClone(game);
|
||||
assert.equal(territory(target).type, 'sand');
|
||||
assert.ok(validGameLocation(game, target, sector));
|
||||
game = applyNexusFremenProtectionStep(game, { actor: holder, action: {
|
||||
type: 'ship', territory: target, sector, amount: 1, elite: 0, allyPayment: 0 } }, actions);
|
||||
if (advanced) {
|
||||
game = advanceNexusFremenProtection(game, state => !state.phaseOpening && !state.response &&
|
||||
state.decision?.kind === 'advisor' && state.decision.player === neutral &&
|
||||
state.decision.destination === destination, actions);
|
||||
game = applyNexusFremenProtectionStep(game, { actor: neutral, action: {
|
||||
type: 'decision', accept: true, accompany: true, sector, amount: 1 } }, actions);
|
||||
}
|
||||
game = advanceNexusFremenProtection(game, nexusFremenProtectionClean, actions);
|
||||
const afterShipment = structuredClone(game);
|
||||
const shipped = nexusFremenProtectionPlayer(game, holder);
|
||||
assert.equal(shipped.forces[destination], 1, 'The paid force must leave reserves for the sand source');
|
||||
assert.equal(shipped.reserves,
|
||||
nexusFremenProtectionPlayer(beforeShipment, holder).reserves - 1);
|
||||
assert.ok(shipped.spice < nexusFremenProtectionPlayer(beforeShipment, holder).spice,
|
||||
'The reserve shipment must be really paid from the holder’s own spice');
|
||||
assert.equal(nexusFremenProtectionPlayer(game, holder).ally, null);
|
||||
if (advanced) {
|
||||
const advisor = nexusFremenProtectionPlayer(game, neutral);
|
||||
assert.equal(advisor.forces[destination], 1);
|
||||
assert.ok(advisor.advisors?.[target], 'The native arrival is an advisor, not a fighter');
|
||||
assert.equal(advisor.reserves,
|
||||
nexusFremenProtectionPlayer(beforeShipment, neutral).reserves - 1);
|
||||
assert.equal(advisor.spice, nexusFremenProtectionPlayer(beforeShipment, neutral).spice);
|
||||
}
|
||||
|
||||
// Retain the exact native action that opens the new pre-destruction window.
|
||||
let beforeWorm: Game | undefined, producer: NexusFremenProtectionStep | undefined;
|
||||
for (let count = 0; count < 1800 && !beforeWorm; count++) {
|
||||
const next = nextNexusFremenProtectionNativeStep(game);
|
||||
assert.ok(next, `The next accepted natural worm needs a native continuation at ${game.turn}/${game.phase}`);
|
||||
const before = structuredClone(game);
|
||||
game = applyNexusFremenProtectionStep(game, next, actions);
|
||||
if (game.decision?.kind === 'nexusFremenProtection') { beforeWorm = before; producer = next; }
|
||||
else assert.ok(game.turn <= 3, 'The next accepted natural worm must open the protection window');
|
||||
}
|
||||
assert.ok(beforeWorm && producer);
|
||||
const boundary = game.decision, frame = game.nexusFremenProtection;
|
||||
assert.ok(boundary?.kind === 'nexusFremenProtection', 'The real worm must open its protection decision');
|
||||
assert.ok(frame, 'The real worm must open its protection frame');
|
||||
assert.equal(game.turn, 3);
|
||||
assert.equal(game.phase, 1);
|
||||
if (options.greatMaker) assert.equal(game.greatMaker?.stage, 'worm');
|
||||
else assert.equal(game.nexus, true, 'The accepted natural worm keeps its actual Nexus occurrence');
|
||||
assert.equal(frame.territory, target);
|
||||
assert.equal(frame.origin, 'natural');
|
||||
assert.equal(frame.pile, 0);
|
||||
assert.equal(frame.protected, null);
|
||||
assert.equal(nexusFremenProtectionPlayer(game, holder).forces[destination], 1);
|
||||
assert.ok((game.spice[destination] ?? 0) > 0, 'Real collection must leave spice for the real worm to clear');
|
||||
assert.deepEqual(game.wormRides, []);
|
||||
assert.ok(!game.nexusFremenCunningOffer);
|
||||
nexusFremenProtectionInventory(game);
|
||||
const scenario = {
|
||||
code: game.code, advanced, turn: game.turn, phase: game.phase,
|
||||
event: boundary.event, territory: target, sector, location: destination,
|
||||
holder, neutral, allies,
|
||||
currentActor: boundary.player,
|
||||
queue: [...frame.order],
|
||||
nexusCard: 'fremen', neutralNexusCard: 'beneGesserit',
|
||||
origin: 'natural', pile: frame.pile,
|
||||
producer: structuredClone(producer),
|
||||
paid: nexusFremenProtectionPlayer(beforeShipment, holder).spice -
|
||||
nexusFremenProtectionPlayer(afterShipment, holder).spice,
|
||||
holdings: game.players.map(player => ({ id: player.id, faction: player.faction,
|
||||
ally: player.ally, spice: player.spice, reserves: player.reserves, tanks: player.tanks,
|
||||
forces: structuredClone(player.forces),
|
||||
advisors: structuredClone(player.advisors ?? {}),
|
||||
hand: player.hand.map(card => card.id) })),
|
||||
staging: [...staging],
|
||||
limitations: 'Fresh classic audit initializer plus an explicitly staged order of undealt physical cards and two native scalar lotteries; this is not a naturally shuffled or recovered played game, and every force, wallet, phase and alliance below arises from the real engine. The printed independent protection is provisional; no publisher answer is claimed.',
|
||||
};
|
||||
return { initial, afterSetup, beforeNexus, beforeClosingDraw, afterClosingDraw,
|
||||
beforeShipment, afterShipment, beforeWorm, producer, game, advanced, holder, neutral, allies,
|
||||
territory: target, sector, location: destination, replacement, actions, staging, scenario };
|
||||
}
|
||||
@@ -91,6 +91,8 @@ export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactions
|
||||
const decision = game.decision;
|
||||
if (decision) {
|
||||
const actor = decision.player;
|
||||
if (decision.kind === 'nexusFremenProtection')
|
||||
return { actor, action: { type: 'nexusFremenProtection', event: decision.event, use: false } };
|
||||
if (decision.kind === 'ixSetup') return { actor, action: { type: 'decision', card: game.ixSetupCards![0].id } };
|
||||
if (decision.kind === 'mobileStronghold') return { actor, action: decision.placement ? { type: 'decision', location: 'polar_sink:0' } : { type: 'decision', decline: true } };
|
||||
if (decision.kind === 'ixTechnology' || decision.kind === 'ixSubstitution' || decision.kind === 'captureOffer' || decision.kind === 'fullPlanOffer' || decision.kind === 'choamTradeReply')
|
||||
@@ -111,6 +113,9 @@ export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactions
|
||||
const actor = [game.battle.attacker, game.battle.defender].find(id => !game.battle!.preLeader!.ready.includes(id));
|
||||
if (actor) return { actor, action: { type: 'battlePreparationReady', event: game.battle.event } };
|
||||
}
|
||||
if (game.battle?.preparation?.kind === 'nexusVoice')
|
||||
return { actor: game.battle.preparation.owner,
|
||||
action: { type: 'nexusBorrowedVoice', event: game.battle.nexusBorrowedVoice!.event, use: false } };
|
||||
if (game.battle?.preparation) return { actor: game.battle.preparation.owner, action: { type: 'declineBattlePower' } };
|
||||
if (game.battle?.revealed) {
|
||||
const actor = viewGame(game, game.host).battle!.traitorVoters.find(id => game.battle!.traitorCalls[id] === undefined);
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { splitLocation } from '../game/board';
|
||||
import {
|
||||
advanceNexusFremenProtection, applyNexusFremenProtectionStep,
|
||||
createNexusFremenProtectionFixture, nexusFremenProtectionClean,
|
||||
nexusFremenProtectionInventory, nexusFremenProtectionPlayer,
|
||||
nexusFremenProtectionPresence,
|
||||
type NexusFremenProtectionFixture,
|
||||
} from './fixture-nexus-fremen-protection';
|
||||
|
||||
/** Only invalid consumers are tried; physical inventory must be untouched. */
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Rejected declarations cannot spend the card, kill groups or advance the worm');
|
||||
}
|
||||
/** Public holdings only; used to prove nothing resolves before the queue closes. */
|
||||
function holdings(game: Game): { id: string; forces: Record<string, number>; spice: number;
|
||||
reserves: number; tanks: number; ally: string | null }[] {
|
||||
return game.players.map(player => ({ id: player.id, ally: player.ally,
|
||||
forces: structuredClone(player.forces), spice: player.spice,
|
||||
reserves: player.reserves, tanks: player.tanks }));
|
||||
}
|
||||
/** Exactly one owner may be protected; every other group in the territory is devoured. */
|
||||
function assertDevoured(pending: Game, resolved: Game, fixture: NexusFremenProtectionFixture,
|
||||
protectedOwner: string | null): void {
|
||||
for (const player of pending.players) {
|
||||
const before = nexusFremenProtectionPlayer(pending, player.id);
|
||||
const after = nexusFremenProtectionPlayer(resolved, player.id);
|
||||
if (player.id === protectedOwner) {
|
||||
assert.deepEqual(after.forces, before.forces, 'The protected owner keeps its exact physical groups');
|
||||
assert.equal(after.tanks, before.tanks);
|
||||
assert.equal(after.reserves, before.reserves);
|
||||
assert.equal(after.spice, before.spice, 'This printed protection invents no payment, revival or refund');
|
||||
continue;
|
||||
}
|
||||
const consumed = nexusFremenProtectionPresence(pending, player.id, fixture.territory);
|
||||
assert.equal(nexusFremenProtectionPresence(resolved, player.id, fixture.territory), 0,
|
||||
'Every unprotected group in the worm territory is devoured');
|
||||
assert.equal(after.tanks, before.tanks + consumed);
|
||||
assert.equal(after.reserves, before.reserves);
|
||||
assert.equal(after.spice, before.spice);
|
||||
}
|
||||
}
|
||||
function assertSpiceCleared(pending: Game, resolved: Game, fixture: NexusFremenProtectionFixture): void {
|
||||
assert.ok(Object.entries(pending.spice).some(([key, amount]) =>
|
||||
splitLocation(key).territory === fixture.territory && amount > 0),
|
||||
'Real spice from the accepted sand blow awaited the worm');
|
||||
assert.ok(Object.keys(resolved.spice).every(key =>
|
||||
splitLocation(key).territory !== fixture.territory),
|
||||
'The original worm still clears every spice group in its territory');
|
||||
}
|
||||
|
||||
/** Answers the whole original queue before the devouring boundary, asserting the
|
||||
* private consumer contract at every acknowledgement. */
|
||||
function closeProtection(fixture: NexusFremenProtectionFixture, use: boolean): Game {
|
||||
let game = structuredClone(fixture.game);
|
||||
const frame = game.nexusFremenProtection;
|
||||
assert.ok(frame);
|
||||
const originalOrder = [...frame.order], event = frame.event;
|
||||
const before = holdings(game);
|
||||
let used = false;
|
||||
for (const expected of originalOrder) {
|
||||
const decision = game.decision;
|
||||
assert.ok(decision?.kind === 'nexusFremenProtection');
|
||||
assert.equal(decision.event, event);
|
||||
assert.equal(decision.player, expected, 'Neutral acknowledgements keep the original storm order');
|
||||
const offer = viewGame(game, expected).nexusFremenProtection;
|
||||
assert.ok(offer, 'Only the current queue owner receives the private declaration surface');
|
||||
assert.equal(offer.owner, expected);
|
||||
assert.equal(offer.event, event);
|
||||
assert.equal(offer.territory, fixture.territory);
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.equal(offer.canUse, expected === fixture.holder && !used,
|
||||
'Only the real unallied holder that has not spent its card may protect its own group');
|
||||
for (const other of game.players.filter(player => player.id !== expected))
|
||||
assert.equal(viewGame(game, other.id).nexusFremenProtection, null);
|
||||
if (expected !== fixture.holder)
|
||||
reject(game, expected, { type: 'nexusFremenProtection', event, use: true });
|
||||
const answer = expected === fixture.holder && use;
|
||||
game = applyNexusFremenProtectionStep(game, { actor: expected, action: {
|
||||
type: 'nexusFremenProtection', event, use: answer } });
|
||||
used ||= answer;
|
||||
assert.deepEqual(game.nexusFremenProtection!.order, originalOrder);
|
||||
assert.equal(game.nexusFremenProtection!.event, event);
|
||||
assert.equal(game.nexusFremenProtection!.protected, used ? fixture.holder : null);
|
||||
assert.equal(game.nexusCards!.cards!.hands[fixture.holder], used ? null : 'fremen');
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, used ? 1 : 0);
|
||||
if (game.nexusFremenProtection!.stage === 'choose')
|
||||
assert.deepEqual(holdings(game), before,
|
||||
'Even a declaration resolves nothing before the whole original queue closes');
|
||||
reject(game, expected, { type: 'nexusFremenProtection', event, use: answer });
|
||||
}
|
||||
assert.equal(game.nexusFremenProtection!.stage, 'complete');
|
||||
assert.notEqual(game.decision?.kind, 'nexusFremenProtection');
|
||||
assert.deepEqual([...game.nexusFremenProtection!.passed], originalOrder);
|
||||
assert.equal(viewGame(game, fixture.holder).nexusFremenProtection, null);
|
||||
return game;
|
||||
}
|
||||
/** The real later Nexus/closing continuation, from the held card's own position. */
|
||||
function continueToLaterNexus(game: Game): Game {
|
||||
return advanceNexusFremenProtection(game, state => state.turn === 3 &&
|
||||
state.nexusCards?.phase?.stage === 'complete' && nexusFremenProtectionClean(state));
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
const label = advanced ? 'Advanced' : 'Basic';
|
||||
void test(`${label}: the real absent-Fremen Use protects only the shipped group before the devouring and spends one physical card`, () => {
|
||||
const fixture = createNexusFremenProtectionFixture({ advanced });
|
||||
const pending = fixture.game, frame = pending.nexusFremenProtection;
|
||||
assert.ok(frame);
|
||||
assert.equal(frame.stage, 'choose');
|
||||
assert.equal(frame.protected, null);
|
||||
assert.deepEqual(frame.order, pending.order.filter(id =>
|
||||
id === fixture.holder || (advanced && id === fixture.neutral)),
|
||||
'The public queue holds only storm-ordered unallied threatened seats that publicly hold a Nexus');
|
||||
assert.equal(nexusFremenProtectionPlayer(pending, fixture.holder).forces[fixture.location], 1);
|
||||
assert.equal(pending.nexusCards!.cards!.hands[fixture.holder], 'fremen');
|
||||
assert.deepEqual(pending.spiceDiscard[0].at(-1), { worm: true });
|
||||
assert.equal(pending.nexus, true);
|
||||
assert.deepEqual(pending.wormRides, []);
|
||||
assert.ok(!pending.summonedWorm);
|
||||
assert.ok(!pending.players.some(player => player.faction === 'fremen'));
|
||||
nexusFremenProtectionInventory(pending);
|
||||
|
||||
const resolved = closeProtection(fixture, true);
|
||||
assert.equal(resolved.nexusFremenProtection!.stage, 'complete');
|
||||
assert.equal(resolved.nexusFremenProtection!.protected, fixture.holder);
|
||||
assertDevoured(pending, resolved, fixture, fixture.holder);
|
||||
assertSpiceCleared(pending, resolved, fixture);
|
||||
assert.equal(resolved.spiceWindow?.territory, fixture.replacement,
|
||||
'afterWorm resumes the original next undealt spice card');
|
||||
assert.equal(resolved.spiceDeck.length, pending.spiceDeck.length - 1,
|
||||
'The replacement producer runs exactly once');
|
||||
assert.deepEqual(resolved.wormRides, [], 'This printed protection grants no worm riding');
|
||||
assert.ok(!resolved.nexusFremenCunningOffer && !resolved.nexusFremenCunningRides?.length);
|
||||
assert.equal(resolved.nexusCards!.cards!.hands[fixture.holder], null);
|
||||
assert.equal(resolved.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||||
assert.equal(nexusFremenProtectionPlayer(resolved, fixture.holder).faction, 'emperor',
|
||||
'Protection grants no native force identity');
|
||||
nexusFremenProtectionInventory(resolved);
|
||||
|
||||
const closed = continueToLaterNexus(resolved);
|
||||
assert.equal(closed.turn, 3);
|
||||
assert.equal(closed.nexusCards!.phase!.stage, 'complete',
|
||||
'The later native Nexus and its private choices still close');
|
||||
assert.notEqual(closed.nexusCards!.cards!.hands[fixture.holder], 'fremen',
|
||||
'The spent singleton cannot be redrawn from the later real closing');
|
||||
assert.equal(closed.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||||
assert.equal(nexusFremenProtectionPlayer(closed, fixture.holder).forces[fixture.location], 1);
|
||||
assert.equal(nexusFremenProtectionPlayer(closed, fixture.holder).tanks,
|
||||
nexusFremenProtectionPlayer(pending, fixture.holder).tanks);
|
||||
assert.deepEqual(closed.wormRides, []);
|
||||
nexusFremenProtectionInventory(closed);
|
||||
reject(closed, fixture.holder, { type: 'nexusFremenProtection', event: frame.event, use: true });
|
||||
});
|
||||
|
||||
void test(`${label}: Pass leaves the Fremen card held and the original worm sends the group to Tanks before the native closing`, () => {
|
||||
const fixture = createNexusFremenProtectionFixture({ advanced });
|
||||
const pending = fixture.game;
|
||||
const resolved = closeProtection(fixture, false);
|
||||
assert.equal(resolved.nexusFremenProtection!.protected, null);
|
||||
assertDevoured(pending, resolved, fixture, null);
|
||||
assertSpiceCleared(pending, resolved, fixture);
|
||||
assert.equal(resolved.nexusCards!.cards!.hands[fixture.holder], 'fremen',
|
||||
'A Pass keeps holding the physical Fremen card');
|
||||
assert.ok(!resolved.nexusCards!.cards!.discard.includes('fremen'));
|
||||
assert.equal(resolved.spiceWindow?.territory, fixture.replacement);
|
||||
assert.deepEqual(resolved.wormRides, []);
|
||||
nexusFremenProtectionInventory(resolved);
|
||||
const closed = continueToLaterNexus(resolved);
|
||||
assert.equal(closed.nexusCards!.phase!.stage, 'complete');
|
||||
assert.equal(closed.nexusCards!.cards!.hands[fixture.holder], 'fremen');
|
||||
assert.ok(!closed.nexusCards!.cards!.discard.includes('fremen'));
|
||||
assert.equal(nexusFremenProtectionPresence(closed, fixture.holder, fixture.territory), 0);
|
||||
assert.equal(nexusFremenProtectionPlayer(closed, fixture.holder).tanks,
|
||||
nexusFremenProtectionPlayer(pending, fixture.holder).tanks + 1);
|
||||
assert.deepEqual(closed.wormRides, []);
|
||||
nexusFremenProtectionInventory(closed);
|
||||
});
|
||||
|
||||
void test(`${label}: wrong actor, stale event, extra keys, wrong schema and replay stay immutable at the boundary`, () => {
|
||||
const fixture = createNexusFremenProtectionFixture({ advanced });
|
||||
const pending = fixture.game, decision = pending.decision;
|
||||
assert.ok(decision?.kind === 'nexusFremenProtection');
|
||||
const actor = decision.player, event = decision.event;
|
||||
const before = structuredClone(pending);
|
||||
reject(pending, fixture.allies[0], { type: 'nexusFremenProtection', event, use: false });
|
||||
reject(pending, actor, { type: 'nexusFremenProtection', event: 'not-this-occurrence', use: false });
|
||||
reject(pending, actor, { type: 'nexusFremenProtection', event, use: false, territory: fixture.territory });
|
||||
reject(pending, actor, { type: 'nexusFremenProtection', event, use: false, owner: fixture.holder });
|
||||
reject(pending, actor, { type: 'nexusFremenProtection', event, use: false, karama: true });
|
||||
reject(pending, actor, { type: 'decision', event, accept: true });
|
||||
reject(pending, actor, { type: 'nexusFremenProtection', event });
|
||||
reject(pending, actor, { type: 'nexusFremenProtection', use: false });
|
||||
const action: Action = { type: 'nexusFremenProtection', event, use: false };
|
||||
const answered = applyNexusFremenProtectionStep(pending, { actor, action });
|
||||
reject(answered, actor, action);
|
||||
if (answered.decision?.kind === 'nexusFremenProtection') {
|
||||
assert.equal(answered.decision.event, event);
|
||||
assert.notEqual(answered.decision.player, actor);
|
||||
assert.equal(nexusFremenProtectionPlayer(answered, fixture.holder).forces[fixture.location], 1);
|
||||
assert.equal(nexusFremenProtectionPlayer(answered, fixture.neutral).forces[fixture.location], 1);
|
||||
assert.deepEqual(holdings(answered), holdings(before),
|
||||
'No acknowledgement may resolve a group before the queue closes');
|
||||
}
|
||||
nexusFremenProtectionInventory(answered);
|
||||
});
|
||||
|
||||
void test(`${label}: all four legal bot profiles answer only their real queue owner at this boundary`, () => {
|
||||
const fixture = createNexusFremenProtectionFixture({ advanced });
|
||||
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
let game = structuredClone(fixture.game);
|
||||
const before = structuredClone(game);
|
||||
for (const expected of game.nexusFremenProtection!.order) {
|
||||
const decision = game.decision;
|
||||
assert.ok(decision?.kind === 'nexusFremenProtection');
|
||||
assert.equal(decision.player, expected);
|
||||
const view = viewGame(game, expected);
|
||||
view.players.find(player => player.id === expected)!.bot = profile;
|
||||
const snapshot = structuredClone(view);
|
||||
const action = botActions(view).find(candidate => candidate.type === 'nexusFremenProtection');
|
||||
assert.ok(action, `${profile} answers the real worm protection acknowledgement`);
|
||||
assert.deepEqual(view, snapshot, 'Bot policy never mutates its own private projection');
|
||||
assert.equal(action.event, decision.event);
|
||||
assert.equal(action.use, expected === fixture.holder,
|
||||
'The irrelevant Nexus face passes while the actual holder protects its own group');
|
||||
const wrong = viewGame(game, fixture.allies[0]);
|
||||
wrong.players.find(player => player.id === fixture.allies[0])!.bot = profile;
|
||||
assert.ok(botActions(wrong).every(candidate => candidate.type !== 'nexusFremenProtection'),
|
||||
'A seat with no current acknowledgement receives no unsolicited impossible prompt');
|
||||
game = applyNexusFremenProtectionStep(game, { actor: expected, action });
|
||||
nexusFremenProtectionInventory(game);
|
||||
}
|
||||
assert.equal(game.nexusFremenProtection!.stage, 'complete');
|
||||
assert.equal(game.nexusFremenProtection!.protected, fixture.holder);
|
||||
assertDevoured(before, game, fixture, fixture.holder);
|
||||
assert.equal(game.nexusCards!.cards!.hands[fixture.holder], null);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||||
assert.deepEqual(game.wormRides, []);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'}: protection keeps the original Great Maker vote before the replacement blow`, () => {
|
||||
const fixture = createNexusFremenProtectionFixture({ advanced, greatMaker: true });
|
||||
const pending = fixture.game;
|
||||
assert.equal(pending.greatMaker?.stage, 'worm');
|
||||
assert.deepEqual(pending.spiceDiscard[0].at(-1), { worm: true, greatMaker: true });
|
||||
const resolved = closeProtection(fixture, true);
|
||||
assertDevoured(pending, resolved, fixture, fixture.holder);
|
||||
assertSpiceCleared(pending, resolved, fixture);
|
||||
assert.equal(resolved.greatMaker?.stage, 'vote');
|
||||
assert.equal(resolved.greatMaker?.event, pending.greatMaker?.event);
|
||||
assert.equal(resolved.decision?.kind, 'greatMakerVote');
|
||||
assert.equal(resolved.spiceDeck.length, pending.spiceDeck.length,
|
||||
'The original vote must finish before drawing another physical spice card');
|
||||
const voted = advanceNexusFremenProtection(resolved, game =>
|
||||
game.greatMaker?.stage === 'complete' && game.spiceWindow?.territory === fixture.replacement &&
|
||||
nexusFremenProtectionClean(game));
|
||||
assert.equal(voted.spiceDeck.length, pending.spiceDeck.length - 1);
|
||||
assert.deepEqual(voted.wormRides, []);
|
||||
assert.equal(nexusFremenProtectionPlayer(voted, fixture.holder).forces[fixture.location], 1);
|
||||
assert.equal(voted.nexusCards!.cards!.hands[fixture.holder], null);
|
||||
nexusFremenProtectionInventory(voted);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
answerNexusFremenProtection,
|
||||
createNexusFremenProtection,
|
||||
currentNexusFremenProtectionOwner,
|
||||
validateNexusFremenProtection,
|
||||
type NexusFremenProtectionFrame,
|
||||
type NexusFremenProtectionInput,
|
||||
type NexusFremenProtectionPlayer,
|
||||
} from '../game/nexus-fremen-protection';
|
||||
|
||||
const sparseOrder: string[] = [];
|
||||
sparseOrder.length = 3;
|
||||
|
||||
/** Structural public data for the pure module, not a staged engine game. */
|
||||
function occurrence(): NexusFremenProtectionInput {
|
||||
return {
|
||||
event: 'd6d3fb17-14e4-4a31-a717-c041a9b02d7c',
|
||||
turn: 3,
|
||||
territory: 'hagga_basin',
|
||||
origin: 'natural',
|
||||
pile: 0,
|
||||
index: 4,
|
||||
order: ['h', 'r', 'a'],
|
||||
players: [
|
||||
{ id: 'a', faction: 'atreides', hasNexus: true, forces: { 'hagga_basin:12': 2 } },
|
||||
{ id: 'h', faction: 'harkonnen', hasNexus: true, forces: { 'hagga_basin:13': 1 } },
|
||||
{
|
||||
id: 'r', faction: 'richese', hasNexus: true, forces: {},
|
||||
noField: { deployed: { location: { territory: 'hagga_basin', sector: 12 } } },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function created(input = occurrence()): NexusFremenProtectionFrame {
|
||||
const frame = createNexusFremenProtection(input);
|
||||
assert.ok(frame);
|
||||
return frame;
|
||||
}
|
||||
|
||||
void test('public storm order includes only unallied threatened seats holding any Nexus', () => {
|
||||
const input = occurrence();
|
||||
const players: NexusFremenProtectionPlayer[] = [
|
||||
input.players[0],
|
||||
{ ...input.players[1], ally: 'e' },
|
||||
input.players[2],
|
||||
{ id: 'c', faction: 'choam', hasNexus: false, forces: { 'hagga_basin:12': 8 } },
|
||||
{ id: 'e', faction: 'emperor', ally: 'h', hasNexus: true, forces: {} },
|
||||
{ id: 'g', faction: 'guild', hasNexus: true, forces: { 'carthag:11': 3 } },
|
||||
];
|
||||
const publicInput = { ...input, players, order: ['r', 'g', 'h', 'c', 'a', 'e'] };
|
||||
const frame = created(publicInput);
|
||||
assert.deepEqual(frame, {
|
||||
event: input.event, turn: 3, territory: 'hagga_basin', origin: 'natural',
|
||||
pile: 0, index: 4, stage: 'choose', order: ['r', 'a'], passed: [], protected: null,
|
||||
});
|
||||
assert.equal(currentNexusFremenProtectionOwner(frame), 'r');
|
||||
validateNexusFremenProtection(publicInput, frame);
|
||||
assert.equal(createNexusFremenProtection({
|
||||
...publicInput,
|
||||
players: players.map((player) => ({ ...player, hasNexus: false })),
|
||||
}), null);
|
||||
assert.equal(createNexusFremenProtection({
|
||||
...publicInput,
|
||||
players: players.map((player) => ({ ...player, forces: {}, noField: { deployed: null } })),
|
||||
}), null);
|
||||
assert.equal(createNexusFremenProtection({
|
||||
...publicInput,
|
||||
players: players.map((player) => player.id === 'g' ? { ...player, faction: 'fremen' } : player),
|
||||
}), null, 'native Fremen suppresses this independent absent-Fremen queue even away from the worm');
|
||||
});
|
||||
|
||||
void test('unrelated dynamic-board groups do not block protection of the actual printed worm territory', () => {
|
||||
const input = occurrence();
|
||||
const players = input.players.map(player => player.id === 'a' ? {
|
||||
...player, forces: { ...player.forces, 'hidden_mobile_stronghold:0': 2 },
|
||||
noField: { deployed: { location: { territory: 'hidden_mobile_stronghold', sector: 0 } } },
|
||||
} : player);
|
||||
let frame = created({ ...input, players });
|
||||
frame = answerNexusFremenProtection(frame, 'h', true);
|
||||
frame = answerNexusFremenProtection(frame, 'r', false);
|
||||
frame = answerNexusFremenProtection(frame, 'a', false);
|
||||
assert.equal(frame.protected, 'h');
|
||||
assert.equal(frame.stage, 'complete');
|
||||
validateNexusFremenProtection({ ...input, players }, frame);
|
||||
});
|
||||
|
||||
void test('irrelevant faces and hidden No-Field contents are not read at any public transition', () => {
|
||||
const input = occurrence();
|
||||
const hiddenRead = () => { throw new Error('private material was read'); };
|
||||
for (const player of input.players) {
|
||||
Object.defineProperties(player, {
|
||||
card: { enumerable: true, get: hiddenRead },
|
||||
hand: { enumerable: true, get: hiddenRead },
|
||||
nexusFace: { enumerable: true, get: hiddenRead },
|
||||
tokens: { enumerable: true, get: hiddenRead },
|
||||
reserves: { enumerable: true, get: hiddenRead },
|
||||
});
|
||||
}
|
||||
const marker = input.players[2].noField!;
|
||||
Object.defineProperty(marker, 'tokens', { enumerable: true, get: hiddenRead });
|
||||
Object.defineProperties(marker.deployed!, {
|
||||
value: { enumerable: true, get: hiddenRead },
|
||||
token: { enumerable: true, get: hiddenRead },
|
||||
});
|
||||
let frame = created(input);
|
||||
assert.deepEqual(frame.order, ['h', 'r', 'a']);
|
||||
frame = answerNexusFremenProtection(frame, 'h', false);
|
||||
assert.equal(currentNexusFremenProtectionOwner(frame), 'r');
|
||||
frame = answerNexusFremenProtection(frame, 'r', true);
|
||||
assert.equal(frame.protected, 'r');
|
||||
assert.equal(currentNexusFremenProtectionOwner(frame), 'a');
|
||||
frame = answerNexusFremenProtection(frame, 'a', false);
|
||||
assert.equal(frame.stage, 'complete');
|
||||
validateNexusFremenProtection(input, frame);
|
||||
assert.equal(marker.deployed!.location.territory, 'hagga_basin');
|
||||
});
|
||||
|
||||
void test('Use protects just its original actor and leaves every remaining acknowledgement in order', () => {
|
||||
const input = occurrence();
|
||||
const start = created(input);
|
||||
const first = answerNexusFremenProtection(start, 'h', true);
|
||||
assert.equal(first.protected, 'h');
|
||||
assert.deepEqual(first.order, ['h', 'r', 'a']);
|
||||
assert.deepEqual(first.passed, ['h']);
|
||||
assert.equal(first.stage, 'choose');
|
||||
assert.equal(currentNexusFremenProtectionOwner(first), 'r');
|
||||
assert.throws(() => answerNexusFremenProtection(first, 'r', true), /already has its protected owner/);
|
||||
assert.deepEqual(first.passed, ['h'], 'a rejected second use cannot advance the queue');
|
||||
const second = answerNexusFremenProtection(first, 'r', false);
|
||||
assert.equal(currentNexusFremenProtectionOwner(second), 'a');
|
||||
const complete = answerNexusFremenProtection(second, 'a', false);
|
||||
assert.equal(complete.protected, 'h');
|
||||
assert.deepEqual(complete.passed, ['h', 'r', 'a']);
|
||||
assert.equal(complete.stage, 'complete');
|
||||
assert.equal(currentNexusFremenProtectionOwner(complete), null);
|
||||
validateNexusFremenProtection(input, complete);
|
||||
assert.deepEqual(start.passed, []);
|
||||
assert.equal(start.protected, null);
|
||||
});
|
||||
|
||||
void test('wrong actor, repeated answer and nonboolean use cannot consume an acknowledgement', () => {
|
||||
const start = created();
|
||||
for (const actor of ['a', 'r', 'missing', ''])
|
||||
assert.throws(() => answerNexusFremenProtection(start, actor, false), /current original owner/);
|
||||
assert.throws(
|
||||
() => answerNexusFremenProtection(start, 'h', 1 as unknown as boolean),
|
||||
/Use or Pass/,
|
||||
);
|
||||
const next = answerNexusFremenProtection(start, 'h', false);
|
||||
assert.throws(() => answerNexusFremenProtection(next, 'h', false), /current original owner/);
|
||||
assert.equal(currentNexusFremenProtectionOwner(next), 'r');
|
||||
assert.deepEqual(start.passed, []);
|
||||
assert.equal(start.stage, 'choose');
|
||||
});
|
||||
|
||||
void test('all passes close the queue only at the final original owner and completed frames cannot reopen', () => {
|
||||
const input = occurrence();
|
||||
let frame = created(input);
|
||||
for (const [index, actor] of frame.order.entries()) {
|
||||
assert.equal(currentNexusFremenProtectionOwner(frame), actor);
|
||||
frame = answerNexusFremenProtection(frame, actor, false);
|
||||
assert.equal(frame.stage, index === 2 ? 'complete' : 'choose');
|
||||
}
|
||||
assert.equal(frame.protected, null);
|
||||
assert.equal(currentNexusFremenProtectionOwner(frame), null);
|
||||
validateNexusFremenProtection(input, frame);
|
||||
for (const actor of input.order) {
|
||||
assert.throws(() => answerNexusFremenProtection(frame, actor, false), /current original owner/);
|
||||
assert.throws(() => answerNexusFremenProtection(frame, actor, true), /current original owner/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('one eligible original owner can close with Use without needing an impossible extra prompt', () => {
|
||||
const input = occurrence();
|
||||
const alone = {
|
||||
...input,
|
||||
players: input.players.map((player) => ({ ...player, hasNexus: player.id === 'r' })),
|
||||
};
|
||||
const frame = answerNexusFremenProtection(created(alone), 'r', true);
|
||||
assert.equal(frame.stage, 'complete');
|
||||
assert.equal(frame.protected, 'r');
|
||||
assert.deepEqual(frame.order, ['r']);
|
||||
assert.deepEqual(frame.passed, ['r']);
|
||||
assert.equal(currentNexusFremenProtectionOwner(frame), null);
|
||||
validateNexusFremenProtection(alone, frame);
|
||||
});
|
||||
|
||||
void test('source event, turn, territory, origin, pile and original discard index cannot be retargeted', () => {
|
||||
const input = occurrence();
|
||||
const frame = created(input);
|
||||
const changed: Partial<NexusFremenProtectionInput>[] = [
|
||||
{ event: 'another-worm-at-the-same-place' }, { turn: 4 },
|
||||
{ territory: 'the_great_flat' }, { origin: 'additional' }, { pile: 1 }, { index: 5 },
|
||||
];
|
||||
for (const replacement of changed)
|
||||
assert.throws(() => validateNexusFremenProtection({ ...input, ...replacement }, frame), /original occurrence identity/);
|
||||
assert.throws(() => validateNexusFremenProtection({ ...input, order: ['a', 'h', 'r'] }, frame), /original public candidates/);
|
||||
assert.throws(() => validateNexusFremenProtection({
|
||||
...input, players: input.players.map((player) => player.id === 'h' ? { ...player, hasNexus: false } : player),
|
||||
}, frame), /original public candidates/);
|
||||
assert.throws(() => validateNexusFremenProtection({
|
||||
...input, players: input.players.map((player) => ({ ...player, forces: {}, noField: null })),
|
||||
}, frame), /original public candidates/, 'post-devour presence cannot retrofit a queue');
|
||||
});
|
||||
|
||||
void test('additional and summoned worms bind their own source, and printed rock spice territories are valid', () => {
|
||||
const { pile: _pile, index: _index, ...base } = occurrence();
|
||||
const additional = { ...base, origin: 'additional' as const };
|
||||
const additionalFrame = created(additional);
|
||||
assert.equal(Object.hasOwn(additionalFrame, 'pile'), false);
|
||||
assert.equal(Object.hasOwn(additionalFrame, 'index'), false);
|
||||
validateNexusFremenProtection(additional, additionalFrame);
|
||||
const summoned = { ...base, origin: 'summoned' as const, parent: 'special-karama-original' };
|
||||
const summonedFrame = created(summoned);
|
||||
validateNexusFremenProtection(summoned, summonedFrame);
|
||||
assert.throws(() => validateNexusFremenProtection({ ...summoned, parent: 'different-karama' }, summonedFrame), /original occurrence identity/);
|
||||
const rock = {
|
||||
...occurrence(), territory: 'rock_outcroppings',
|
||||
players: base.players.map((player) => ({ ...player, forces: { 'rock_outcroppings:13': 1 }, noField: null })),
|
||||
};
|
||||
assert.deepEqual(created(rock).order, ['h', 'r', 'a']);
|
||||
});
|
||||
|
||||
void test('creation rejects malformed public source and order rather than silently shrinking the queue', () => {
|
||||
const input = occurrence();
|
||||
const malformed: Record<string, unknown>[] = [
|
||||
{ event: '' }, { event: ' ' }, { turn: 0 }, { turn: -1 }, { turn: 1.5 },
|
||||
{ turn: Number.MAX_SAFE_INTEGER + 1 }, { territory: 'not-a-territory' },
|
||||
{ territory: 'arrakeen' }, { territory: 'hidden_mobile_stronghold' }, { origin: 'revived' },
|
||||
{ pile: undefined }, { pile: 2 }, { index: undefined }, { index: -1 },
|
||||
{ index: 1.5 }, { index: NaN }, { index: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ parent: 'unrelated-parent' }, { origin: 'summoned', pile: undefined, index: undefined },
|
||||
{ origin: 'summoned', parent: 'karama', pile: 0, index: undefined },
|
||||
{ origin: 'summoned', parent: 'karama', pile: undefined, index: 0 },
|
||||
{ origin: 'additional', parent: 'karama' },
|
||||
{ order: ['h', 'r'] }, { order: ['h', 'r', 'r'] }, { order: ['h', 'r', 'unknown'] },
|
||||
{ order: ['h', 'r', ''] }, { order: sparseOrder }, { players: null },
|
||||
];
|
||||
for (const replacement of malformed)
|
||||
assert.throws(() => createNexusFremenProtection({ ...input, ...replacement } as NexusFremenProtectionInput));
|
||||
const malformedSeats: Record<string, unknown>[] = [
|
||||
{ id: '' }, { id: 'h' }, { faction: '' }, { hasNexus: 'yes' },
|
||||
{ ally: 'unknown' }, { ally: 'a' }, { forces: null },
|
||||
{ forces: { 'hagga_basin:12': -1 } }, { forces: { 'hagga_basin:12': 1.5 } },
|
||||
{ forces: { 'hagga_basin:99': 1 } }, { forces: { 'hagga_basin:012': 1 } },
|
||||
{ noField: { deployed: {} } },
|
||||
{ noField: { deployed: { location: { territory: 'hagga_basin', sector: 99 } } } },
|
||||
];
|
||||
for (const replacement of malformedSeats)
|
||||
assert.throws(() => createNexusFremenProtection({
|
||||
...input, players: [{ ...input.players[0], ...replacement } as NexusFremenProtectionPlayer, ...input.players.slice(1)],
|
||||
}));
|
||||
});
|
||||
|
||||
void test('saved frames require exact fields, a prefix of original acknowledgements and one acknowledged protected owner', () => {
|
||||
const input = occurrence();
|
||||
const frame = created(input);
|
||||
const malformed: Record<string, unknown>[] = [
|
||||
{ extra: true }, { parent: undefined }, { stage: 'devoured' }, { stage: 'complete' },
|
||||
{ order: [] }, { order: ['h', 'r', 'r'] }, { order: sparseOrder },
|
||||
{ passed: ['r'] }, { passed: ['h', 'a'] }, { passed: ['h', 'h'] },
|
||||
{ passed: ['h', 'r', 'a', 'unknown'] },
|
||||
{ passed: ['h', 'r', 'a'], stage: 'choose' },
|
||||
{ protected: 'h' }, { passed: ['h'], protected: 'r' },
|
||||
{ passed: ['h'], protected: 'unknown' }, { passed: ['h', 'r'], protected: ['h', 'r'] },
|
||||
];
|
||||
for (const replacement of malformed) {
|
||||
const invalid = { ...frame, ...replacement } as NexusFremenProtectionFrame;
|
||||
assert.throws(() => validateNexusFremenProtection(input, invalid));
|
||||
assert.throws(() => currentNexusFremenProtectionOwner(invalid));
|
||||
assert.throws(() => answerNexusFremenProtection(invalid, 'h', false));
|
||||
}
|
||||
assert.throws(() => validateNexusFremenProtection(input, { ...frame, order: ['r', 'h', 'a'] }), /original public candidates/);
|
||||
assert.throws(() => validateNexusFremenProtection(input, { ...frame, order: ['h', 'r'] }), /original public candidates/);
|
||||
});
|
||||
|
||||
void test('frozen public inputs and frames remain untouched and retain independent original queue arrays', () => {
|
||||
const input = occurrence();
|
||||
Object.freeze(input.order);
|
||||
for (const player of input.players) {
|
||||
Object.freeze(player.forces);
|
||||
if (player.noField?.deployed) {
|
||||
Object.freeze(player.noField.deployed.location);
|
||||
Object.freeze(player.noField.deployed);
|
||||
Object.freeze(player.noField);
|
||||
}
|
||||
Object.freeze(player);
|
||||
}
|
||||
Object.freeze(input.players);
|
||||
Object.freeze(input);
|
||||
const frame = created(input);
|
||||
Object.freeze(frame.order);
|
||||
Object.freeze(frame.passed);
|
||||
Object.freeze(frame);
|
||||
const next = answerNexusFremenProtection(frame, 'h', false);
|
||||
assert.deepEqual(frame.passed, []);
|
||||
assert.deepEqual(next.passed, ['h']);
|
||||
validateNexusFremenProtection(input, next);
|
||||
});
|
||||
Reference in new issue
Block a user