Files
Dune/tests/nexus-fremen-protection-runtime.test.ts

272 lines
16 KiB
TypeScript

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);
});
}