Allow Fremen Nexus Betrayal before native worm rides
This commit is contained in:
1 parent
78bcf22bca
commit
725672364d
19 files changed
+1251
-20
No files matched your search
@@ -214,6 +214,10 @@ export function finishFremenBetrayalWorm(fixture: FremenBetrayalModulesFixture,
|
||||
} else if (!game.response && !game.phaseOpening && decision?.kind === 'nexusFremenCunningOffer') {
|
||||
cunningOffers.push(structuredClone(game));
|
||||
step = { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
|
||||
} else if (!game.response && !game.phaseOpening && decision?.kind === 'nexusFremenRideBetrayal') {
|
||||
// This unspent control deliberately permits its original native ride;
|
||||
// do not let a generic bot policy spend the held prevention card.
|
||||
step = { actor: decision.player, action: { type: 'decision', event: decision.event, use: false } };
|
||||
} else if (!game.response && !game.phaseOpening && decision?.kind === 'greatMakerVote') {
|
||||
step = { actor: decision.player, action: { type: 'decision', event: decision.event, yes: true } };
|
||||
votes.push(structuredClone(step));
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { botActions } from '../game/bots';
|
||||
import { greatMakerRideAction } from '../game/great-maker-options';
|
||||
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
|
||||
import { actFremenBetrayalModules, fremenBetrayalModulesClean,
|
||||
type FremenBetrayalModulesStep } from './fixture-fremen-betrayal-modules';
|
||||
import { nextE3IndependentDukeStep } from './fixture-e3-independent-nexus-duke';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type NexusFremenReactiveBetrayalStep = FremenBetrayalModulesStep;
|
||||
export type NexusFremenReactiveBetrayalOptions = {
|
||||
advanced?: boolean;
|
||||
kind?: 'ordinary' | 'greatMaker';
|
||||
};
|
||||
export const actNexusFremenReactiveBetrayal = actFremenBetrayalModules;
|
||||
export const nexusFremenReactiveBetrayalClean = fremenBetrayalModulesClean;
|
||||
export function nexusFremenReactiveBetrayalPlayer(game: Game, id: string) {
|
||||
const player = game.players.find(player => player.id === id);
|
||||
assert.ok(player, `The original ${id} seat must remain present`);
|
||||
return player;
|
||||
}
|
||||
export function nexusFremenReactiveBetrayalPhysical(game: Game) {
|
||||
return game.players.map(player => ({ id: player.id, forces: structuredClone(player.forces),
|
||||
reserves: player.reserves, tanks: player.tanks, spice: player.spice,
|
||||
elites: structuredClone(player.elites), shipped: player.shipped, moved: player.moved }));
|
||||
}
|
||||
export function nexusFremenReactiveBetrayalInventory(game: Game) {
|
||||
assert.ok(game.nexusCards?.cards);
|
||||
validateNexusCards(game.nexusCards.cards, game.players);
|
||||
const nexus = [...game.nexusCards.cards.deck, ...game.nexusCards.cards.discard,
|
||||
...Object.values(game.nexusCards.cards.hands).filter(card => card !== null)].sort();
|
||||
assert.deepEqual(nexus, [...NEXUS_FACTIONS].sort());
|
||||
const treachery = [...game.deck, ...game.discard, ...(game.auction?.cards.slice(game.auction.index) ?? []),
|
||||
...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
|
||||
assert.deepEqual(treachery, baseDeck().map(card => card.id).sort());
|
||||
for (const player of game.players) {
|
||||
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20);
|
||||
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
|
||||
Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0), player.faction === 'emperor' ? 5 : 3);
|
||||
}
|
||||
return { nexus, treachery };
|
||||
}
|
||||
|
||||
/** Only live native choices are answered. Great Maker votes deliberately earn
|
||||
* its reserve ride; a later unused ride is declined by the ordinary driver. */
|
||||
export function nextNexusFremenReactiveBetrayalStep(game: Game): NexusFremenReactiveBetrayalStep | null {
|
||||
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'nexusFremenRideBetrayal') return { actor: decision.player,
|
||||
action: { type: 'decision', event: decision.event, use: false } };
|
||||
if (decision?.kind === 'greatMakerVote') return { actor: decision.player,
|
||||
action: { type: 'decision', event: decision.event, yes: true } };
|
||||
if (decision?.kind === 'nexusFremenCunningOffer') return { actor: decision.player,
|
||||
action: { type: 'decision', event: decision.event, accept: false } };
|
||||
}
|
||||
return nextE3IndependentDukeStep(game);
|
||||
}
|
||||
export function advanceNexusFremenReactiveBetrayal(game: Game, until: (state: Game) => boolean,
|
||||
actions: NexusFremenReactiveBetrayalStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The native reactive-worm source ended before its boundary');
|
||||
const step = nextNexusFremenReactiveBetrayalStep(game);
|
||||
assert.ok(step, `The actual ${game.turn}/${game.phase}/${game.decision?.kind} needs an owned native action`);
|
||||
game = actNexusFremenReactiveBetrayal(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The native reactive-worm source did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** Select only still-unopened physical Spice Cards, as in the original native
|
||||
* Fremen modules programme. No discard, encounter or force is installed. */
|
||||
function orderSpice(game: Game, names: readonly string[]) {
|
||||
const cards = names.map(name => {
|
||||
const at = game.spiceDeck.findIndex(card => name === 'worm'
|
||||
? 'worm' in card && !card.greatMaker && !card.thumper && !card.suppressed
|
||||
: name === 'maker' ? 'worm' in card && !!card.greatMaker
|
||||
: 'territory' in card && !card.discovery && card.territory === name);
|
||||
assert.ok(at >= 0, `The original ${name} card must still be unopened`);
|
||||
return game.spiceDeck.splice(at, 1)[0];
|
||||
});
|
||||
game.spiceDeck.unshift(...cards);
|
||||
}
|
||||
|
||||
/** Genuine classic Nexus setup, real turn-two alliance/draw, real free Fremen
|
||||
* reinforcement, and the following natural worm. The Discovery variant uses
|
||||
* the already-approved Advanced Great Maker programme; neither mode is opened
|
||||
* by altering a played game. Only unopened deck/scalar positions are selected. */
|
||||
export function createNexusFremenReactiveBetrayalFixture(options: NexusFremenReactiveBetrayalOptions = {}) {
|
||||
const kind = options.kind ?? 'ordinary', advanced = options.advanced ?? kind === 'greatMaker';
|
||||
assert.ok(kind !== 'greatMaker' || advanced, 'This original Great Maker source uses its approved Advanced Discovery start');
|
||||
let game = withClassicDiscoveryNexusLottery(() => createGame('REACTIVEFREMENBETRAYAL',
|
||||
newPlayer('fremen', 'Fremen', 'fremen'), advanced, []));
|
||||
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
|
||||
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
|
||||
const actions: NexusFremenReactiveBetrayalStep[] = [];
|
||||
const act = (actor: string, action: Action) => {
|
||||
game = actNexusFremenReactiveBetrayal(game, actor, action, actions);
|
||||
};
|
||||
const reach = (until: (state: Game) => boolean) => {
|
||||
game = advanceNexusFremenReactiveBetrayal(game, until, actions);
|
||||
};
|
||||
act(game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true });
|
||||
if (kind === 'greatMaker') act(game.host, { type: 'moduleOption', module: 'discoveries', enabled: true });
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const initial = structuredClone(game);
|
||||
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
for (let count = 0; game.status === 'setup' && count < 120; count++) {
|
||||
let step: NexusFremenReactiveBetrayalStep | undefined;
|
||||
if (game.setupStage === 'forces' && nexusFremenReactiveBetrayalPlayer(game, 'fremen').reserves === 20)
|
||||
step = { actor: 'fremen', action: { type: 'fremenSetup', placements: { 'sietch_tabr:14': 10 },
|
||||
...(advanced ? { elitePlacements: { 'sietch_tabr:14': 1 } } : {}) } };
|
||||
else for (const player of game.players) {
|
||||
const view = viewGame(game, player.id);
|
||||
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
if (action) { step = { actor: player.id, action }; break; }
|
||||
}
|
||||
assert.ok(step, 'Original private setup must expose a legal native next action');
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const afterSetup = structuredClone(game), originalInventory = nexusFremenReactiveBetrayalInventory(game);
|
||||
orderSpice(game, ['broken_land', ...(advanced ? ['south_mesa'] : [])]);
|
||||
reach(state => state.turn === 2 && state.phase === 1 && nexusFremenReactiveBetrayalClean(state));
|
||||
const afterFirstTurn = structuredClone(game);
|
||||
orderSpice(game, ['worm', 'hagga_basin', ...(advanced ? ['red_chasm'] : [])]);
|
||||
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && nexusFremenReactiveBetrayalClean(state));
|
||||
const beforeAlliance = structuredClone(game);
|
||||
act('fremen', { type: 'alliance', target: 'emperor' });
|
||||
act('emperor', { type: 'alliance', target: 'fremen' });
|
||||
reach(state => state.nexusCards?.phase?.stage === 'drawing' && nexusFremenReactiveBetrayalClean(state));
|
||||
const cards = game.nexusCards!.cards!, at = cards.deck.indexOf('fremen');
|
||||
assert.ok(at >= 0, 'The Fremen Nexus singleton must remain in the original undrawn deck');
|
||||
cards.deck.unshift(cards.deck.splice(at, 1)[0]);
|
||||
const beforeDraw = structuredClone(game), draw: NexusFremenReactiveBetrayalStep = { actor: 'guild',
|
||||
action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands.guild, choice: 'draw', ownRedraws: 0 } };
|
||||
act(draw.actor, draw.action);
|
||||
const afterDraw = structuredClone(game);
|
||||
assert.equal(afterDraw.nexusCards!.cards!.hands.guild, 'fremen');
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === 'fremen' && nexusFremenReactiveBetrayalClean(state));
|
||||
const beforeShipment = structuredClone(game), shipment: NexusFremenReactiveBetrayalStep = { actor: 'fremen',
|
||||
action: { type: 'ship', territory: 'hagga_basin', sector: 12, amount: 2, elite: advanced ? 1 : 0, allyPayment: 0 } };
|
||||
act(shipment.actor, shipment.action);
|
||||
reach(nexusFremenReactiveBetrayalClean);
|
||||
const afterShipment = structuredClone(game);
|
||||
reach(state => state.turn === 3 && state.phase === 1 && nexusFremenReactiveBetrayalClean(state));
|
||||
const beforeAppearance = structuredClone(game);
|
||||
orderSpice(game, [kind === 'greatMaker' ? 'maker' : 'worm', 'habbanya_erg', ...(advanced ? ['oh_gap'] : [])]);
|
||||
reach(state => state.decision?.kind === 'nexusFremenRideBetrayal');
|
||||
const frame = game.nexusFremenRideBetrayal;
|
||||
assert.ok(frame && game.decision?.kind === 'nexusFremenRideBetrayal');
|
||||
assert.equal(game.decision.player, 'guild');
|
||||
assert.equal(frame.source.kind, kind === 'greatMaker' ? 'greatMakerRide' : 'wormRide');
|
||||
assert.equal(nexusFremenReactiveBetrayalPlayer(game, 'guild').ally, null);
|
||||
assert.equal(nexusFremenReactiveBetrayalPlayer(game, 'fremen').forces['hagga_basin:12'], 2);
|
||||
const action: Action = { type: 'decision', event: game.decision.event, use: true };
|
||||
return { game, kind, advanced, holder: 'guild', rider: 'fremen', action,
|
||||
initial, setup, afterSetup, afterFirstTurn, beforeAlliance, beforeDraw, draw, afterDraw,
|
||||
beforeShipment, shipment, afterShipment, beforeAppearance,
|
||||
originalFrame: structuredClone(frame), originalDecision: structuredClone(frame.source),
|
||||
originalPhysical: nexusFremenReactiveBetrayalPhysical(game), originalInventory,
|
||||
actions, sourceActionCount: actions.length,
|
||||
staging: ['Only original unopened Spice/Nexus positions and scalar setup lotteries are selected. All setup, alliance, draw, reinforcement, worm, Nexus and Great Maker votes are native actions.'] };
|
||||
}
|
||||
export type NexusFremenReactiveBetrayalFixture = ReturnType<typeof createNexusFremenReactiveBetrayalFixture>;
|
||||
|
||||
export function passNexusFremenReactiveBetrayal(game: Game,
|
||||
actions: NexusFremenReactiveBetrayalStep[] = []): Game {
|
||||
for (let count = 0; game.decision?.kind === 'nexusFremenRideBetrayal' && count < 7; count++) {
|
||||
const decision = game.decision;
|
||||
game = actNexusFremenReactiveBetrayal(game, decision.player,
|
||||
{ type: 'decision', event: decision.event, use: false }, actions);
|
||||
}
|
||||
assert.notEqual(game.decision?.kind, 'nexusFremenRideBetrayal');
|
||||
return game;
|
||||
}
|
||||
export function useNexusFremenReactiveBetrayal(fixture: NexusFremenReactiveBetrayalFixture,
|
||||
game = structuredClone(fixture.game), actions: NexusFremenReactiveBetrayalStep[] = []) {
|
||||
return actNexusFremenReactiveBetrayal(game, fixture.holder, fixture.action, actions);
|
||||
}
|
||||
export function rideNexusFremenReactiveBetrayal(fixture: NexusFremenReactiveBetrayalFixture,
|
||||
game: Game, actions: NexusFremenReactiveBetrayalStep[] = []) {
|
||||
let action: Action;
|
||||
if (fixture.kind === 'greatMaker') {
|
||||
const native = greatMakerRideAction(viewGame(game, fixture.rider), 'polar_sink', 0, 1, 1);
|
||||
assert.ok(native, 'The restored native Great Maker control must supply its original typed reserve ride');
|
||||
action = native;
|
||||
} else {
|
||||
assert.equal(game.decision?.kind, 'wormRide');
|
||||
action = { type: 'decision', accept: true, territory: 'polar_sink', sector: 0,
|
||||
forces: { 'hagga_basin:12': 2 }, ...(fixture.advanced ? { eliteForces: { 'hagga_basin:12': 1 } } : {}) };
|
||||
}
|
||||
const before = structuredClone(game);
|
||||
game = actNexusFremenReactiveBetrayal(game, fixture.rider, action, actions);
|
||||
return { game, before, action, after: structuredClone(game) };
|
||||
}
|
||||
export function finishNexusFremenReactiveBetrayal(fixture: NexusFremenReactiveBetrayalFixture,
|
||||
game: Game, actions: NexusFremenReactiveBetrayalStep[] = []) {
|
||||
return advanceNexusFremenReactiveBetrayal(game, state => state.turn === fixture.game.turn &&
|
||||
state.phase === 2 && nexusFremenReactiveBetrayalClean(state), actions);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
actNexusFremenReactiveBetrayal, createNexusFremenReactiveBetrayalFixture,
|
||||
finishNexusFremenReactiveBetrayal, nexusFremenReactiveBetrayalInventory,
|
||||
nexusFremenReactiveBetrayalPhysical, nexusFremenReactiveBetrayalPlayer as player,
|
||||
passNexusFremenReactiveBetrayal, rideNexusFremenReactiveBetrayal,
|
||||
useNexusFremenReactiveBetrayal, type NexusFremenReactiveBetrayalFixture,
|
||||
} from './fixture-nexus-fremen-reactive-betrayal';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => actNexusFremenReactiveBetrayal(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function completedReceipt(game: Game, f: NexusFremenReactiveBetrayalFixture, usedBy: string | null) {
|
||||
const receipt = game.nexusFremenRideBetrayal;
|
||||
assert.ok(receipt);
|
||||
assert.equal(receipt.stage, 'complete');
|
||||
assert.equal(receipt.usedBy, usedBy);
|
||||
assert.equal(receipt.event, f.originalFrame.event);
|
||||
assert.equal(receipt.turn, f.originalFrame.turn);
|
||||
assert.deepEqual(receipt.source, f.originalDecision);
|
||||
assert.deepEqual(receipt.order, f.originalFrame.order);
|
||||
assert.ok(receipt.signature);
|
||||
assert.notEqual(receipt.signature, f.originalFrame.signature);
|
||||
if (usedBy === null) assert.deepEqual(receipt.passed, receipt.order);
|
||||
for (const seat of game.players)
|
||||
assert.equal(viewGame(game, seat.id).nexusFremenRideBetrayal ?? null, null);
|
||||
}
|
||||
function source(f: NexusFremenReactiveBetrayalFixture) {
|
||||
assert.equal(f.game.status, 'playing');
|
||||
assert.equal(f.game.turn, 3);
|
||||
assert.equal(f.game.phase, 1);
|
||||
assert.equal(f.game.response ?? null, null);
|
||||
assert.equal(f.game.phaseOpening ?? null, null);
|
||||
assert.ok(f.game.decision?.kind === 'nexusFremenRideBetrayal');
|
||||
assert.equal(f.game.decision.player, f.holder);
|
||||
assert.equal(f.beforeDraw.nexusCards!.cards!.hands[f.holder], null);
|
||||
assert.equal(f.afterDraw.nexusCards!.cards!.hands[f.holder], 'fremen');
|
||||
assert.equal(f.game.nexusCards!.cards!.hands[f.holder], 'fremen');
|
||||
assert.equal(player(f.game, f.holder).ally, null);
|
||||
assert.equal(player(f.game, f.rider).ally, 'emperor');
|
||||
assert.equal(player(f.game, 'emperor').ally, f.rider);
|
||||
assert.equal(f.draw.action.type, 'nexusCardChoice');
|
||||
assert.equal(f.shipment.action.type, 'ship');
|
||||
assert.equal(f.shipment.action.amount, 2);
|
||||
assert.equal(player(f.afterShipment, f.rider).reserves, player(f.beforeShipment, f.rider).reserves - 2);
|
||||
assert.equal(player(f.afterShipment, f.rider).spice, player(f.beforeShipment, f.rider).spice);
|
||||
assert.equal(player(f.game, f.rider).forces['hagga_basin:12'], 2);
|
||||
assert.deepEqual(player(f.game, f.rider).forces, player(f.beforeAppearance, f.rider).forces,
|
||||
'The real worm and neutral counter window have not transported the native survivors');
|
||||
assert.equal(f.game.nexusCards!.phase!.occurred, true);
|
||||
assert.ok(f.actions.some(step => step.actor === f.rider && step.action.type === 'alliance'));
|
||||
assert.ok(f.actions.some(step => step.actor === f.holder && step.action.type === 'nexusCardChoice' && step.action.choice === 'draw'));
|
||||
assert.ok(!f.actions.some(step => step.action.type === 'nexusFremenBetrayal'), 'No advance Betrayal substitutes for the reactive source');
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
assert.deepEqual(nexusFremenReactiveBetrayalInventory(f.game), f.originalInventory);
|
||||
assert.equal(f.game.homeworlds ?? null, null);
|
||||
assert.equal(f.game.leaderSkills ?? null, null);
|
||||
assert.equal(f.game.techTokens ?? null, null);
|
||||
if (f.kind === 'greatMaker') {
|
||||
assert.ok(f.game.discoveries);
|
||||
assert.equal(f.originalDecision.kind, 'greatMakerRide');
|
||||
assert.equal(f.game.greatMaker!.stage, 'ride');
|
||||
assert.deepEqual(f.game.greatMaker!.votes.map(vote => vote.yes), f.game.order.map(() => true));
|
||||
} else {
|
||||
assert.equal(f.game.discoveries ?? null, null);
|
||||
assert.deepEqual(f.originalDecision, { kind: 'wormRide', player: f.rider, territory: 'hagga_basin' });
|
||||
assert.ok(f.actions.some(step => step.action.type === 'ready'), 'Actual native readiness completes the Nexus before its pending ride');
|
||||
}
|
||||
}
|
||||
|
||||
const variants = [
|
||||
{ kind: 'ordinary', advanced: false, label: 'Basic ordinary' },
|
||||
{ kind: 'ordinary', advanced: true, label: 'Advanced ordinary' },
|
||||
{ kind: 'greatMaker', advanced: true, label: 'Advanced Great Maker' },
|
||||
] as const;
|
||||
|
||||
for (const variant of variants) {
|
||||
void test(`genuine ${variant.label} reactive Fremen Betrayal spends the native draw and stops only the unmade ride`, () => {
|
||||
const f = createNexusFremenReactiveBetrayalFixture(variant);
|
||||
source(f);
|
||||
const physical = nexusFremenReactiveBetrayalPhysical(f.game);
|
||||
let game = useNexusFremenReactiveBetrayal(f);
|
||||
completedReceipt(game, f, f.holder);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.holder], null);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||||
assert.equal(player(game, f.rider).fremenNexusWormBlockedTurn, game.turn);
|
||||
assert.deepEqual(nexusFremenReactiveBetrayalPhysical(game), physical,
|
||||
'The response spends its original Nexus card without moving or killing surviving riders');
|
||||
assert.equal(game.nexusFremenBetrayalHistory!.at(-1)!.mode, 'worm');
|
||||
assert.equal(game.nexusFremenBetrayalHistory!.at(-1)!.owner, f.holder);
|
||||
assert.deepEqual(game.wormRides, []);
|
||||
rejected(game, f.holder, f.action);
|
||||
game = finishNexusFremenReactiveBetrayal(f, game);
|
||||
assert.equal(game.phase, 2);
|
||||
assert.equal(game.nexusCards!.phase!.stage, 'complete');
|
||||
assert.equal(player(game, f.rider).forces['hagga_basin:12'], 2);
|
||||
assert.equal(player(game, f.rider).forces['polar_sink:0'] ?? 0, 0);
|
||||
assert.equal(player(game, f.rider).reserves, player(f.game, f.rider).reserves);
|
||||
assert.equal(game.decision?.kind === 'wormRide' || game.decision?.kind === 'greatMakerRide', false);
|
||||
assert.equal(game.nexusFremenBetrayalHistory!.filter(row => row.turn === f.game.turn).length, 1);
|
||||
assert.deepEqual(nexusFremenReactiveBetrayalInventory(game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test(`genuine ${variant.label} all-pass restores the exact native ride and actually transports its original counters`, () => {
|
||||
const f = createNexusFremenReactiveBetrayalFixture(variant);
|
||||
source(f);
|
||||
const queue = structuredClone(f.game.wormRides), maker = structuredClone(f.game.greatMaker);
|
||||
const passed = passNexusFremenReactiveBetrayal(structuredClone(f.game));
|
||||
completedReceipt(passed, f, null);
|
||||
assert.deepEqual(passed.decision, f.originalDecision);
|
||||
assert.deepEqual(passed.wormRides, queue);
|
||||
assert.deepEqual(passed.greatMaker, maker);
|
||||
assert.deepEqual(nexusFremenReactiveBetrayalPhysical(passed), f.originalPhysical);
|
||||
assert.equal(passed.nexusCards!.cards!.hands[f.holder], 'fremen');
|
||||
assert.equal(passed.nexusFremenBetrayalHistory?.length ?? 0, 0);
|
||||
const ride = rideNexusFremenReactiveBetrayal(f, passed);
|
||||
const before = player(ride.before, f.rider), after = player(ride.after, f.rider);
|
||||
const amount = f.kind === 'greatMaker' ? 1 : 2;
|
||||
assert.equal(after.forces['polar_sink:0'], amount);
|
||||
assert.equal(after.spice, before.spice);
|
||||
assert.equal(after.shipped, before.shipped);
|
||||
assert.equal(after.moved, before.moved);
|
||||
if (f.kind === 'greatMaker') {
|
||||
assert.equal(after.reserves, before.reserves - 1);
|
||||
assert.equal(after.elites!.reserves, before.elites!.reserves - 1);
|
||||
assert.equal(after.forces['hagga_basin:12'], 2);
|
||||
} else {
|
||||
assert.equal(after.reserves, before.reserves);
|
||||
assert.equal(after.forces['hagga_basin:12'] ?? 0, 0);
|
||||
}
|
||||
if (f.advanced) assert.equal(after.elites!.forces['polar_sink:0'], 1);
|
||||
const game = finishNexusFremenReactiveBetrayal(f, ride.game);
|
||||
assert.equal(game.phase, 2);
|
||||
assert.equal(game.nexusCards!.phase!.stage, 'complete');
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.holder], 'fremen');
|
||||
assert.equal(game.nexusFremenBetrayalHistory?.length ?? 0, 0);
|
||||
assert.equal(player(game, f.rider).forces['polar_sink:0'], amount, 'Only the original accepted ride moved these counters');
|
||||
assert.deepEqual(nexusFremenReactiveBetrayalInventory(game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test(`genuine ${variant.label} reactive window rejects foreign, stale and underlying ride replies without spending`, () => {
|
||||
const f = createNexusFremenReactiveBetrayalFixture(variant);
|
||||
rejected(f.game, f.rider, f.action);
|
||||
rejected(f.game, 'emperor', f.action);
|
||||
rejected(f.game, f.holder, { ...f.action, event: 'stale' });
|
||||
rejected(f.game, f.holder, { type: 'decision', event: f.action.event, use: 'yes' });
|
||||
rejected(f.game, f.rider, f.kind === 'greatMaker'
|
||||
? { type: 'decision', event: f.game.greatMaker!.event, accept: false }
|
||||
: { type: 'decision', accept: false });
|
||||
assert.equal(f.game.nexusCards!.cards!.hands[f.holder], 'fremen');
|
||||
assert.deepEqual(nexusFremenReactiveBetrayalPhysical(f.game), f.originalPhysical);
|
||||
assert.deepEqual(nexusFremenReactiveBetrayalInventory(f.game), f.originalInventory);
|
||||
});
|
||||
}
|
||||
|
||||
void test('four minimal profiles answer the genuine reactive Fremen ride window with an exact legal native decision', () => {
|
||||
for (const variant of variants) {
|
||||
const f = createNexusFremenReactiveBetrayalFixture(variant);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(f.game, f.holder);
|
||||
view.players.find(seat => seat.id === f.holder)!.bot = difficulty;
|
||||
const action = botActions(view)[0];
|
||||
assert.ok(action, `${variant.label}/${difficulty}`);
|
||||
assert.equal(action.type, 'decision');
|
||||
assert.equal(action.event, f.action.event);
|
||||
assert.equal(typeof action.use, 'boolean');
|
||||
const game = actNexusFremenReactiveBetrayal(structuredClone(f.game), f.holder, action);
|
||||
assert.notEqual(game.decision?.kind, 'nexusFremenRideBetrayal');
|
||||
assert.deepEqual(nexusFremenReactiveBetrayalPhysical(game), f.originalPhysical);
|
||||
assert.deepEqual(nexusFremenReactiveBetrayalInventory(game), f.originalInventory);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Children, createElement, isValidElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { NexusFremenRideBetrayal } from '../components/nexus-fremen-ride-betrayal';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
|
||||
type Source = NonNullable<GameView['nexusFremenRideBetrayal']>['source'];
|
||||
// Controlled projection fixtures isolate the actual hook-free callbacks. The
|
||||
// separate native source suite earns and restores the worm/Cunning/Maker ride;
|
||||
// these controls tests install no played Game custody or pending decision.
|
||||
function projected(kind: Source['kind'] = 'wormRide'): GameView {
|
||||
const game = createGame('NEXUSFREMENRIDECONTROLS', newPlayer('a', 'Atreides', 'atreides'), false);
|
||||
joinGame(game, newPlayer('f', 'Fremen', 'fremen'));
|
||||
joinGame(game, newPlayer('g', 'Guild', 'guild'));
|
||||
const view = viewGame(game, 'a'); view.status = 'playing'; view.phase = 1;
|
||||
const source: Source = kind === 'wormRide'
|
||||
? { kind, player: 'f', territory: 'hagga_basin' }
|
||||
: { kind, player: 'f', event: 'controlled-original-ride-event' };
|
||||
view.nexusFremenRideBetrayal = {
|
||||
event: 'controlled-before-ride-event', player: 'a', source, canUse: true, passed: [],
|
||||
};
|
||||
return view;
|
||||
}
|
||||
function markup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(NexusFremenRideBetrayal, { game, busy, act() {} }));
|
||||
}
|
||||
function click(node: ReactNode, choice: 'continue' | 'prevent'): boolean {
|
||||
for (const child of Children.toArray(node)) {
|
||||
if (!isValidElement<{
|
||||
children?: ReactNode; onClick?: () => void; 'data-testid'?: string;
|
||||
}>(child)) continue;
|
||||
if (child.props['data-testid'] === `nexus-fremen-ride-${choice}` && child.props.onClick) {
|
||||
child.props.onClick(); return true;
|
||||
}
|
||||
if (click(child.props.children, choice)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function choose(game: GameView, choice: 'continue' | 'prevent', busy = false): Action[] {
|
||||
const actions: Action[] = [];
|
||||
click(NexusFremenRideBetrayal({ game, busy, act(action) { actions.push(action); } }), choice);
|
||||
return actions;
|
||||
}
|
||||
|
||||
void test('the current responder sends exact before-ride Continue and prevention actions for all three sources', () => {
|
||||
for (const kind of ['wormRide', 'nexusFremenCunningRide', 'greatMakerRide'] as const) {
|
||||
const game = projected(kind), event = game.nexusFremenRideBetrayal!.event;
|
||||
assert.deepEqual(choose(game, 'continue'), [{ type: 'decision', event, use: false }]);
|
||||
assert.deepEqual(choose(game, 'prevent'), [{ type: 'decision', event, use: true }]);
|
||||
assert.equal(Object.keys(choose(game, 'prevent')[0]).sort().join(','), 'event,type,use');
|
||||
assert.doesNotMatch(markup(game), /controlled-original-ride-event/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('the neutral panel names each public ride source without a secret card face', () => {
|
||||
assert.match(markup(projected()), /Fremen has a worm ride from Hagga Basin waiting/);
|
||||
assert.match(markup(projected('nexusFremenCunningRide')), /Fremen has a Fremen Nexus Cunning ride waiting/);
|
||||
assert.match(markup(projected('greatMakerRide')), /Fremen has a Great Maker ride waiting/);
|
||||
assert.match(markup(projected()), /aria-label="Fremen Nexus before-ride Betrayal"/);
|
||||
});
|
||||
|
||||
void test('a projected no-Use responder still has the actual Continue callback', () => {
|
||||
const game = projected(); game.nexusFremenRideBetrayal!.canUse = false;
|
||||
const html = markup(game);
|
||||
assert.match(html, /Continue · allow rides/); assert.doesNotMatch(html, /data-testid="nexus-fremen-ride-prevent"/);
|
||||
assert.deepEqual(choose(game, 'continue'), [{ type: 'decision', event: 'controlled-before-ride-event', use: false }]);
|
||||
assert.deepEqual(choose(game, 'prevent'), []);
|
||||
assert.match(html, /without revealing anyone’s secret Nexus identity/);
|
||||
});
|
||||
|
||||
void test('waiting and already-passed viewers retain the public guide without another response button', () => {
|
||||
const waiting = projected(); waiting.me = 'g';
|
||||
assert.match(markup(waiting), /Waiting for Atreides to respond before the ride/);
|
||||
assert.doesNotMatch(markup(waiting), /<button\b/);
|
||||
assert.deepEqual(choose(waiting, 'continue'), []); assert.deepEqual(choose(waiting, 'prevent'), []);
|
||||
const passed = projected(); passed.nexusFremenRideBetrayal!.passed = ['a'];
|
||||
assert.match(markup(passed), /You have continued. Waiting for the remaining ride responses/);
|
||||
assert.match(markup(passed), /prevent the remaining rides this turn/);
|
||||
assert.doesNotMatch(markup(passed), /<button\b/);
|
||||
assert.deepEqual(choose(passed, 'continue'), []); assert.deepEqual(choose(passed, 'prevent'), []);
|
||||
});
|
||||
|
||||
void test('busy and finished views guard both actual callbacks without removing the explanation', () => {
|
||||
const game = projected();
|
||||
assert.deepEqual(choose(game, 'continue', true), []); assert.deepEqual(choose(game, 'prevent', true), []);
|
||||
assert.equal((markup(game, true).match(/<button\b[^>]*\sdisabled=""/g) ?? []).length, 2);
|
||||
assert.match(markup(game, true), /The worm, Spice Blow and any Nexus that have already resolved stay resolved/);
|
||||
game.status = 'finished';
|
||||
assert.deepEqual(choose(game, 'continue'), []); assert.deepEqual(choose(game, 'prevent'), []);
|
||||
});
|
||||
|
||||
void test('the persistent guide preserves completed effects and describes prevention without moving forces or replaying payment', () => {
|
||||
const game = projected();
|
||||
for (let render = 0; render < 3; render++) {
|
||||
const html = markup(game);
|
||||
assert.match(html, /Continue to allow the original ride choices/);
|
||||
assert.match(html, /prevent the remaining rides this turn/);
|
||||
assert.match(html, /The worm, Spice Blow and any Nexus that have already resolved stay resolved/);
|
||||
assert.match(html, /Prevention does not move or destroy forces/);
|
||||
assert.match(html, /Previous payments and effects are not repeated/);
|
||||
assert.doesNotMatch(html, /countdown|automatically continue|seconds remaining|Confirm|Acknowledge/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a missing reaction renders nothing and private hands or wallets cannot determine Use', () => {
|
||||
const absent = projected(); absent.nexusFremenRideBetrayal = null;
|
||||
assert.equal(markup(absent), ''); assert.deepEqual(choose(absent, 'continue'), []);
|
||||
const game = projected();
|
||||
Object.defineProperty(game, 'nexusCards', { get() { throw Error('The response control inspected a private Nexus face'); } });
|
||||
for (const player of game.players) for (const field of ['hand', 'spice', 'forces', 'elites', 'traitors'])
|
||||
Object.defineProperty(player, field, { get() { throw Error(`The response control read ${field}`); } });
|
||||
assert.match(markup(game), /Use Fremen Nexus · prevent remaining rides/);
|
||||
assert.deepEqual(choose(game, 'prevent'), [{ type: 'decision', event: 'controlled-before-ride-event', use: true }]);
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGame, joinGame, newPlayer, type Game } from '../game/engine';
|
||||
import { createNexusCards, discardNexusCard, validateNexusCards } from '../game/nexus-cards';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { greatMakerSignature } from '../game/great-maker';
|
||||
import { quoteFremenCunningRide } from '../game/nexus-fremen-cunning';
|
||||
import {
|
||||
createFremenRideBetrayal as create, fremenRideBetrayalCurrentOwner as owner,
|
||||
passFremenRideBetrayal as pass, useFremenRideBetrayal as use,
|
||||
validateFremenRideBetrayal as validate, FremenRideBetrayalError,
|
||||
type FremenRideBetrayalFrame as Frame, type FremenRideBetrayalParent as Parent,
|
||||
} from '../game/nexus-fremen-ride-betrayal';
|
||||
|
||||
const EVENT = '4b673e18-f130-4133-b93d-f044ecae3370';
|
||||
const nativeParent: Parent = { kind: 'wormRide', player: 'fremen', territory: 'hagga_basin' };
|
||||
/** Explicitly controlled pure producer inputs, not a native acquisition or
|
||||
* appearance programme. The real twelve singleton cards are conserved. */
|
||||
function position(advanced = false): Game {
|
||||
const g = createGame('FREMENRIDEQUOTES', newPlayer('fremen', 'Fremen', 'fremen'), advanced);
|
||||
for (const faction of ['atreides', 'emperor', 'guild'] as const) joinGame(g, newPlayer(faction, faction, faction));
|
||||
g.status = 'playing'; g.turn = 2; g.phase = 1; g.storm = 1;
|
||||
g.order = ['emperor', 'fremen', 'atreides', 'guild'];
|
||||
g.nexusCards = { cards: createNexusCards(g.players, () => 0.9), phase: null };
|
||||
g.players.find(p => p.id === 'fremen')!.forces = { 'hagga_basin:13': 2 };
|
||||
g.players.find(p => p.id === 'fremen')!.reserves = 18;
|
||||
g.spice = { 'oh_gap:10': 6 }; g.wormRides = ['false_wall_south'];
|
||||
g.decision = structuredClone(nativeParent);
|
||||
give(g, 'emperor', 'fremen'); give(g, 'atreides', 'richese');
|
||||
return g;
|
||||
}
|
||||
function give(g: Game, actor: string, face: FactionId) {
|
||||
const cards = g.nexusCards!.cards!;
|
||||
const old = cards.hands[actor]; if (old) cards.deck.push(old);
|
||||
const index = cards.deck.indexOf(face); assert.ok(index >= 0, 'Only a real singleton Nexus card can move to this pure hand');
|
||||
cards.deck.splice(index, 1); cards.hands[actor] = face; validateNexusCards(cards, g.players);
|
||||
}
|
||||
function wrapper(g: Game, frame: Frame) {
|
||||
const actor = owner(frame); assert.ok(actor);
|
||||
g.decision = { kind: 'nexusFremenRideBetrayal', player: actor, event: frame.event };
|
||||
}
|
||||
function open(g: Game, parent: Parent = nativeParent): Frame {
|
||||
g.decision = structuredClone(parent);
|
||||
const frame = create(g, parent, EVENT); assert.ok(frame); return frame;
|
||||
}
|
||||
function maker(g: Game): Parent {
|
||||
g.greatMaker = { arrival: null, event: 'original-maker-encounter', turn: g.turn, pile: 0, index: 0,
|
||||
territory: null, stage: 'ride', order: [...g.order],
|
||||
votes: g.order.map(player => ({ player, yes: true })), ridesBefore: [], signature: '' };
|
||||
g.greatMaker.signature = greatMakerSignature(g.greatMaker);
|
||||
return { kind: 'greatMakerRide', player: 'fremen', event: g.greatMaker.event };
|
||||
}
|
||||
function resign(frame: Frame): void {
|
||||
frame.signature = JSON.stringify(['nexusFremenRideBetrayal', frame.event, frame.turn,
|
||||
[frame.source.kind, frame.source.player, frame.source.kind === 'wormRide' ? frame.source.territory : frame.source.event],
|
||||
frame.stage, frame.order, frame.passed, frame.usedBy]);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
const band = advanced ? 'Advanced' : 'Basic';
|
||||
void test(`${band} before-ride Betrayal uses public Nexus backs and original storm order without moving anything`, () => {
|
||||
const g = position(advanced), before = structuredClone(g), frame = open(g);
|
||||
assert.deepEqual(frame.order, ['emperor', 'atreides']); assert.deepEqual(frame.passed, []);
|
||||
assert.equal(owner(frame), 'emperor'); assert.equal(frame.stage, 'choose'); assert.equal(frame.usedBy, null);
|
||||
assert.deepEqual(frame.source, nativeParent); assert.equal(frame.event, EVENT); validate(g, frame);
|
||||
assert.deepEqual(g, before); assert.notStrictEqual(frame.source, g.decision);
|
||||
const hidden = structuredClone(g);
|
||||
give(hidden, 'emperor', 'ixians'); give(hidden, 'atreides', 'harkonnen');
|
||||
const other = create(hidden, nativeParent, EVENT); assert.deepEqual(other, frame, 'Different hidden faces cannot change the public pause');
|
||||
give(hidden, 'guild', 'fremen');
|
||||
assert.deepEqual(create(hidden, nativeParent, EVENT)!.order, ['emperor', 'atreides', 'guild']);
|
||||
});
|
||||
|
||||
void test(`${band} every original acknowledgement passes once and returns the exact source decision`, () => {
|
||||
const g = position(advanced); let frame = open(g); const before = structuredClone(g);
|
||||
wrapper(g, frame); validate(g, frame);
|
||||
frame = pass(g, frame, 'emperor', EVENT); assert.equal(owner(frame), 'atreides');
|
||||
assert.deepEqual(frame.passed, ['emperor']); wrapper(g, frame); validate(g, frame);
|
||||
frame = pass(g, frame, 'atreides', EVENT);
|
||||
assert.equal(frame.stage, 'complete'); assert.equal(owner(frame), null); assert.equal(frame.usedBy, null);
|
||||
assert.deepEqual(frame.passed, frame.order); assert.deepEqual(frame.source, before.decision);
|
||||
g.decision = structuredClone(frame.source); validate(g, frame);
|
||||
assert.deepEqual(g, before, 'All-pass bookkeeping does not replay payment, spice, destruction or a ride');
|
||||
assert.throws(() => pass(g, frame, 'atreides', EVENT), FremenRideBetrayalError);
|
||||
const saved: Frame = JSON.parse(JSON.stringify(frame)); validate(g, saved); assert.deepEqual(saved.source, nativeParent);
|
||||
});
|
||||
|
||||
void test(`${band} a current responder completes Use before the engine spends its actual Fremen card`, () => {
|
||||
const g = position(advanced), frame = open(g); wrapper(g, frame);
|
||||
const before = structuredClone(g), used = use(g, frame, 'emperor', EVENT);
|
||||
assert.equal(used.stage, 'complete'); assert.equal(used.usedBy, 'emperor'); assert.deepEqual(used.passed, []);
|
||||
assert.equal(owner(used), null); assert.deepEqual(g, before, 'The pure wrapper spends no card and changes no physical state');
|
||||
assert.equal(g.nexusCards!.cards!.hands.emperor, 'fremen');
|
||||
g.nexusCards!.cards = discardNexusCard(g.nexusCards!.cards!, 'emperor', g.players);
|
||||
g.players.find(p => p.id === 'fremen')!.fremenNexusWormBlockedTurn = g.turn;
|
||||
g.decision = null; g.wormRides = []; validate(g, used);
|
||||
assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||||
assert.throws(() => use(g, used, 'emperor', EVENT), FremenRideBetrayalError);
|
||||
});
|
||||
}
|
||||
|
||||
void test('only unallied non-Fremen seats with a publicly present Nexus back participate', () => {
|
||||
const g = position(); give(g, 'guild', 'ecaz');
|
||||
g.players.find(p => p.id === 'emperor')!.ally = 'guild';
|
||||
g.players.find(p => p.id === 'guild')!.ally = 'emperor';
|
||||
assert.deepEqual(open(g).order, ['atreides']);
|
||||
const none = position();
|
||||
none.nexusCards!.cards = discardNexusCard(none.nexusCards!.cards!, 'atreides', none.players);
|
||||
// Fremen can hold an irrelevant physical card, but is not a Betrayal responder.
|
||||
give(none, 'fremen', 'tleilaxu');
|
||||
none.nexusCards!.cards!.deck.push(none.nexusCards!.cards!.hands.emperor!);
|
||||
none.nexusCards!.cards!.hands.emperor = null;
|
||||
validateNexusCards(none.nexusCards!.cards!, none.players);
|
||||
assert.equal(create(none, nativeParent, EVENT), null);
|
||||
});
|
||||
|
||||
void test('public Fremen discard, an earlier prevention marker or unsupported profile creates no pointless pause', () => {
|
||||
const discarded = position();
|
||||
discarded.nexusCards!.cards = discardNexusCard(discarded.nexusCards!.cards!, 'emperor', discarded.players);
|
||||
assert.equal(create(discarded, nativeParent, EVENT), null);
|
||||
const blocked = position(); blocked.players.find(p => p.id === 'fremen')!.fremenNexusWormBlockedTurn = blocked.turn;
|
||||
assert.equal(create(blocked, nativeParent, EVENT), null);
|
||||
const wrongPhase = position(); wrongPhase.phase = 5; assert.equal(create(wrongPhase, nativeParent, EVENT), null);
|
||||
const unsupported = position(); unsupported.strongholdCards = { owners: {
|
||||
arrakeen: null, carthag: null, habbanya_ridge_sietch: null, sietch_tabr: null, tueks_sietch: null,
|
||||
hidden_mobile_stronghold: null,
|
||||
}, claimedTurn: 0 };
|
||||
assert.equal(create(unsupported, nativeParent, EVENT), null, 'Basic Strongholds are outside the existing native profile');
|
||||
});
|
||||
|
||||
void test('current actor, exact event, ordered prefix and published wrapper identity remain authoritative', () => {
|
||||
const g = position(), frame = open(g); wrapper(g, frame); const before = structuredClone(g);
|
||||
assert.throws(() => pass(g, frame, 'atreides', EVENT), FremenRideBetrayalError);
|
||||
assert.throws(() => use(g, frame, 'fremen', EVENT), FremenRideBetrayalError);
|
||||
assert.throws(() => pass(g, frame, 'emperor', 'stale-event'), FremenRideBetrayalError);
|
||||
const advanced = pass(g, frame, 'emperor', EVENT);
|
||||
assert.throws(() => validate(g, advanced), FremenRideBetrayalError, 'Old wrapper owner cannot own the next acknowledgement');
|
||||
wrapper(g, advanced); validate(g, advanced);
|
||||
const wrong = structuredClone(frame); wrong.passed = ['atreides']; resign(wrong);
|
||||
assert.throws(() => validate(g, wrong), FremenRideBetrayalError);
|
||||
assert.deepEqual(g.players, before.players); assert.deepEqual(g.nexusCards, before.nexusCards);
|
||||
});
|
||||
|
||||
void test('creation and active validation bind the actual ordinary source after it has left the pending ride queue', () => {
|
||||
const g = position(); assert.equal(g.wormRides.includes('hagga_basin'), false);
|
||||
const frame = open(g); wrapper(g, frame); validate(g, frame);
|
||||
const foreign = structuredClone(g); foreign.decision = { ...nativeParent, player: 'atreides' };
|
||||
assert.throws(() => create(foreign, foreign.decision as Parent, EVENT), FremenRideBetrayalError);
|
||||
const empty = structuredClone(g); empty.players.find(p => p.id === 'fremen')!.forces = {};
|
||||
assert.throws(() => validate(empty, frame), FremenRideBetrayalError);
|
||||
const storm = structuredClone(g); storm.storm = 13; assert.throws(() => validate(storm, frame), FremenRideBetrayalError);
|
||||
const cleared = structuredClone(g); cleared.decision = null;
|
||||
assert.throws(() => validate(cleared, frame), FremenRideBetrayalError);
|
||||
const other = structuredClone(frame); other.source = { ...nativeParent, territory: 'oh_gap' };
|
||||
assert.throws(() => validate(g, other), FremenRideBetrayalError);
|
||||
});
|
||||
|
||||
void test('an actual Great Maker ride frame retains its event, native reserve source and completed votes', () => {
|
||||
const g = position(true), parent = maker(g), frame = open(g, parent), before = structuredClone(g.greatMaker);
|
||||
wrapper(g, frame); validate(g, frame);
|
||||
const passed = pass(g, frame, 'emperor', EVENT); wrapper(g, passed);
|
||||
const completed = pass(g, passed, 'atreides', EVENT);
|
||||
assert.deepEqual(completed.source, parent); assert.deepEqual(g.greatMaker, before);
|
||||
const stale = structuredClone(g); stale.greatMaker!.stage = 'complete';
|
||||
assert.throws(() => validate(stale, passed), FremenRideBetrayalError);
|
||||
const other = structuredClone(g); other.greatMaker!.event = 'different-maker';
|
||||
assert.throws(() => validate(other, passed), FremenRideBetrayalError);
|
||||
const empty = structuredClone(g); empty.players.find(p => p.id === 'fremen')!.reserves = 0;
|
||||
assert.throws(() => validate(empty, passed), FremenRideBetrayalError);
|
||||
});
|
||||
|
||||
void test('valid pending Cunning already exposes the sole Fremen card in discard and skips the neutral window', () => {
|
||||
const g = position();
|
||||
// Conserved pure producer input: its original Cunning cost is already spent,
|
||||
// so no opposing seat can simultaneously hold a second Fremen card.
|
||||
give(g, 'emperor', 'ecaz'); give(g, 'fremen', 'fremen');
|
||||
g.nexusCards!.cards = discardNexusCard(g.nexusCards!.cards!, 'fremen', g.players);
|
||||
const occurrence = { event: 'original-cunning-appearance', turn: g.turn, territory: 'oh_gap',
|
||||
origin: 'natural' as const, pile: 0 as const, initiallyEmpty: true };
|
||||
const authorization = { event: occurrence.event, turn: g.turn, owner: 'fremen' };
|
||||
g.nexusFremenCunningRides = [{ occurrence, authorization, stage: 'select' }];
|
||||
const parent: Parent = { kind: 'nexusFremenCunningRide', player: 'fremen', event: occurrence.event };
|
||||
g.decision = parent;
|
||||
assert.ok(quoteFremenCunningRide(g, 'fremen', occurrence, authorization));
|
||||
assert.equal(create(g, parent, EVENT), null); validateNexusCards(g.nexusCards!.cards!, g.players);
|
||||
assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||||
});
|
||||
|
||||
void test('changed public order, lost Nexus back, alliance or turn cannot continue an old active acknowledgement', () => {
|
||||
const g = position(), frame = open(g); wrapper(g, frame);
|
||||
const reordered = structuredClone(g); reordered.order.reverse();
|
||||
assert.throws(() => validate(reordered, frame), FremenRideBetrayalError);
|
||||
const absent = structuredClone(g);
|
||||
absent.nexusCards!.cards = discardNexusCard(absent.nexusCards!.cards!, 'atreides', absent.players);
|
||||
assert.throws(() => validate(absent, frame), FremenRideBetrayalError);
|
||||
const allied = structuredClone(g); allied.players.find(p => p.id === 'atreides')!.ally = 'guild';
|
||||
allied.players.find(p => p.id === 'guild')!.ally = 'atreides';
|
||||
assert.throws(() => validate(allied, frame), FremenRideBetrayalError);
|
||||
const later = structuredClone(g); later.turn++; assert.throws(() => validate(later, frame), FremenRideBetrayalError);
|
||||
});
|
||||
|
||||
void test('signed completed progress is detached from later native physical continuation', () => {
|
||||
const g = position(), frame = open(g); wrapper(g, frame);
|
||||
const completed = use(g, frame, 'emperor', EVENT), saved: Frame = JSON.parse(JSON.stringify(completed));
|
||||
g.nexusCards!.cards = discardNexusCard(g.nexusCards!.cards!, 'emperor', g.players);
|
||||
g.decision = null; g.phase = 2; g.wormRides = []; g.turn++;
|
||||
validate(g, saved); assert.equal(owner(saved), null); assert.deepEqual(saved.source, nativeParent);
|
||||
const changed = structuredClone(saved); changed.source = { ...nativeParent, territory: 'oh_gap' };
|
||||
assert.throws(() => validate(g, changed), FremenRideBetrayalError);
|
||||
});
|
||||
Reference in new issue
Block a user