Connect physical Jacurutu allocation and cumulative station choices

This commit is contained in:
Quintine committed 2026-10-14 14:49:07 +11:00
1 parent 373badd560
commit 554f2bf9e9
22 files changed
+1943 -129

No files matched your search

+122 -3
View File
@@ -1,8 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
jacurutuDialSelection,
quoteJacurutuBattleIncome,
JACURUTU_BATTLE_INCOME_RULE,
type JacurutuDialOffer,
} from '../game/discovery-battle';
import { applyAction, normalizeAutomaticGame, type Game } from '../game/engine';
import { JACURUTU_SIETCH } from '../game/discoveries';
@@ -11,7 +12,6 @@ import { discoveryBattleBeforeFinalVote } from './fixture-discovery-battle';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
void test('the source-bound contract quotes only opposing undialed counters actually sent to the Tanks', () => {
assert.match(JACURUTU_BATTLE_INCOME_RULE, /opposing undialed force/);
const ordinary = {
normal: 5,
elite: 0,
@@ -148,7 +148,6 @@ void test('real Basic and Advanced battles pay once from the losing plan and add
).length,
1,
);
assert.match(done.log.at(-1)!.text, /Jacurutu|battle|Ready|won/i);
const stable = reload(done);
assert.deepEqual(normalizeAutomaticGame(reload(stable)), stable);
}
@@ -198,3 +197,123 @@ void test('a real traitor winner still receives only the opposing undialed Tanks
1,
);
});
const mixedDialInput = {
territory: JACURUTU_SIETCH,
jacurutuRevealed: true,
winner: 'winner',
opponent: 'emperor',
opponentForces: {
normal: 1,
elite: 1,
eliteStrength: 2 as const,
freeSupport: false,
},
opponentDial: 2,
opponentSupport: 1,
sentToTanks: { normal: 1, elite: 1 },
};
void test('the defeated mixed dial offers both physical readings and the index resolver returns them', () => {
const pending = quoteJacurutuBattleIncome(mixedDialInput);
assert.equal(pending?.kind, 'allocationRequired');
if (pending?.kind !== 'allocationRequired') return;
assert.deepEqual(
pending.choices.map((choice) => choice.amount),
[1, 0],
);
assert.deepEqual(
pending.choices.map((choice) => choice.dialed),
[
{ normal: 0, elite: 1, paidNormal: 0, paidElite: 1 },
{ normal: 1, elite: 1, paidNormal: 1, paidElite: 0 },
],
);
assert.deepEqual(pending.sentToTanks, { normal: 1, elite: 1 });
const offer: JacurutuDialOffer = {
event: 'event',
owner: 'emperor',
winner: 'winner',
choices: pending.choices,
};
assert.deepEqual(jacurutuDialSelection(offer, 0), {
normal: 0,
elite: 1,
paidNormal: 0,
paidElite: 1,
});
assert.deepEqual(jacurutuDialSelection(offer, 1), {
normal: 1,
elite: 1,
paidNormal: 1,
paidElite: 0,
});
for (const allocation of [2, -1, 0.5, Number.NaN])
assert.equal(jacurutuDialSelection(offer, allocation), null);
for (const [allocation, amount] of [
[0, 1],
[1, 0],
] as const)
assert.deepEqual(
quoteJacurutuBattleIncome({
...mixedDialInput,
selectedDial: pending.choices[allocation].dialed,
}),
{
kind: 'income',
winner: 'winner',
opponent: 'emperor',
amount,
sentToTanks: { normal: 1, elite: 1 },
dialed: [pending.choices[allocation].dialed],
},
);
});
void test('a selected reading must be a legal allocation of the revealed dial and never exceed the Tanks', () => {
// The same counters with the other paid distribution describe a different
// committed strength and are not a reading of this dial and support.
assert.throws(
() =>
quoteJacurutuBattleIncome({
...mixedDialInput,
selectedDial: { normal: 1, elite: 1, paidNormal: 0, paidElite: 1 },
}),
/legal physical allocation/,
);
// The revealed plan and support cannot have dialed only the ordinary counter.
assert.throws(
() =>
quoteJacurutuBattleIncome({
...mixedDialInput,
selectedDial: { normal: 1, elite: 0, paidNormal: 1, paidElite: 0 },
}),
/legal physical allocation/,
);
// A real reading of the plan that the settled Tanks losses cannot contain.
assert.throws(
() =>
quoteJacurutuBattleIncome({
...mixedDialInput,
sentToTanks: { normal: 0, elite: 1 },
selectedDial: { normal: 1, elite: 1, paidNormal: 1, paidElite: 0 },
}),
/exceeds/,
);
// Withdrawal leaves the committed Sardaukar reading and a zero reward.
assert.deepEqual(
quoteJacurutuBattleIncome({
...mixedDialInput,
sentToTanks: { normal: 0, elite: 1 },
selectedDial: { normal: 0, elite: 1, paidNormal: 0, paidElite: 1 },
}),
{
kind: 'income',
winner: 'winner',
opponent: 'emperor',
amount: 0,
sentToTanks: { normal: 0, elite: 1 },
dialed: [{ normal: 0, elite: 1, paidNormal: 0, paidElite: 1 }],
},
);
});
+186 -7
View File
@@ -11,10 +11,13 @@ import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { discoveryStormActions } from '../game/discovery-storm-options';
import {
createDiscoveryStorm,
createStormSource,
discoveryStormDistance,
discoveryStormOffer,
discoveryStormSignature,
} from '../game/discovery-storm';
import { fighterCount } from '../game/advisors';
import {
discoveryStormFixture,
confirmDiscoveryStorm,
@@ -90,25 +93,20 @@ void test('Weather Control replaces natural provenance and bypasses the station,
g = applyAction(g, 'a', { type: 'card', card: card.id, amount });
assert.equal(g.stormMovementSource?.kind, 'weather');
assert.deepEqual(g.ready, []);
assert.match(discoveryStormOffer(g)!.blocked!, /Weather Control/);
g = confirmDiscoveryStorm(reload(g));
assert.equal(g.ecologicalStorm, undefined);
assert.equal(g.storm, 6 + amount);
assert.equal(g.discard.filter((c) => c.id === card.id).length, 1);
}
});
void test('unoccupied, contested and legacy unknown-source storms continue without inventing entitlement or changing distance', () => {
for (const mode of ['empty', 'contested', 'legacy']) {
void test('unoccupied and legacy unknown-source storms continue without inventing entitlement or changing distance', () => {
for (const mode of ['empty', 'legacy']) {
let g = discoveryStormFixture();
if (mode === 'empty') {
g.players[0].reserves +=
g.players[0].forces['ecological-testing-station:0'];
delete g.players[0].forces['ecological-testing-station:0'];
}
if (mode === 'contested') {
g.players[1].reserves--;
g.players[1].forces['ecological-testing-station:0'] = 1;
}
if (mode === 'legacy') delete g.stormMovementSource;
g = confirmDiscoveryStorm(g);
assert.equal(g.ecologicalStorm, undefined);
@@ -198,3 +196,184 @@ void test('adjusted Advanced storm retains its receipt through Fremen protection
assert.equal(g.ecologicalStorm?.stage, 'complete');
assert.equal(g.storm, 9);
});
const stationKey = 'ecological-testing-station:0';
/** A second distinct non-advisor occupant beside the actual entrant. */
function contest(g: Game, second = 'g'): Game {
const player = g.players.find((candidate) => candidate.id === second)!;
player.reserves -= 2;
player.forces[stationKey] = (player.forces[stationKey] ?? 0) + 2;
return g;
}
const occupants = (g: Game) =>
g.order.filter((id) => {
const player = g.players.find((candidate) => candidate.id === id)!;
return fighterCount(player, 'ecological-testing-station') > 0;
});
const control = (g: Game, id: string, delta: number) => {
const action = discoveryStormActions(viewGame(g, id)).find(
(candidate) => candidate.delta === delta,
);
assert.ok(action, `${id} must receive a legal ${delta} station control.`);
return action;
};
void test('two original non-advisor occupants each choose once in original storm order with one cumulative traversal', () => {
let g = confirmDiscoveryStorm(contest(discoveryStormFixture()));
const before = reload(g);
const order = occupants(g);
assert.equal(order.length, 2);
assert.notEqual(order[0], order[1]);
assert.equal(g.decision?.kind, 'ecologicalStorm');
assert.equal(g.decision?.player, order[0]);
assert.equal(g.ecologicalStorm?.owner, order[0]);
assert.deepEqual(g.ecologicalStorm?.queue, { order, choices: [] });
assert.equal(g.storm, 6);
const event = g.decision!.event;
assert.equal(viewGame(g, order[0]).ecologicalStorm?.base, 2);
assert.equal(viewGame(g, order[1]).ecologicalStorm, null);
assert.deepEqual(discoveryStormActions(viewGame(g, order[1])), []);
reject(g, order[1], control(g, order[0], 1));
reject(g, order[0], { ...control(g, order[0], 1), event: `${event}:1` });
reject(g, order[0], { ...control(g, order[0], 1), distance: 1 });
for (const delta of [-2, 2, 0.5, NaN])
reject(g, order[0], { ...control(g, order[0], 0), delta });
assert.deepEqual(g, before);
g = applyAction(reload(g), order[0], control(g, order[0], 1));
assert.equal(g.ecologicalStorm?.stage, 'choose');
assert.equal(g.ecologicalStorm?.owner, order[1]);
assert.equal(g.decision?.player, order[1]);
assert.ok(g.decision?.kind === 'ecologicalStorm');
assert.equal(g.decision?.event, `${event}:1`);
assert.equal(discoveryStormDistance(g.ecologicalStorm!), 3);
assert.equal(g.stormPending, 3);
assert.equal(g.storm, 6);
assert.equal(g.phase, 0);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
reject(g, order[0], control(before, order[0], 1));
reject(g, order[1], { ...control(g, order[1], 0), event });
const second = viewGame(g, order[1]).ecologicalStorm!;
assert.deepEqual(second.options.map((option) => option.distance), [2, 3, 4]);
assert.equal(viewGame(g, order[0]).ecologicalStorm, null);
g = applyAction(reload(g), order[1], control(g, order[1], -1));
assert.equal(g.ecologicalStorm?.stage, 'complete');
assert.deepEqual(g.ecologicalStorm?.queue?.choices, [
{ owner: order[0], delta: 1 },
{ owner: order[1], delta: -1 },
]);
assert.equal(g.storm, 8);
assert.equal(g.phase, 1);
assert.equal(g.stormMovementSource, undefined);
assert.equal(g.stormResolution, null);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
reject(g, order[1], { type: 'decision', event: `${event}:1`, delta: 0 });
reject(g, order[0], { type: 'decision', event, delta: 1 });
});
void test('a contested station shares one cumulative bound: two decreases reach zero and opposite choices cancel', () => {
let g = contest(discoveryStormFixture());
g.stormPending = 3;
g.stormMovementSource = createStormSource(g.turn, 'dials', 3);
g = confirmDiscoveryStorm(g);
const order = occupants(g);
let floored = applyAction(reload(g), order[0], control(g, order[0], -1));
assert.equal(discoveryStormDistance(floored.ecologicalStorm!), 2);
assert.deepEqual(
discoveryStormOffer(floored, floored.ecologicalStorm!)!.options.map(
(option) => option.distance,
),
[1, 2, 3],
);
floored = applyAction(reload(floored), order[1], control(floored, order[1], -1));
assert.equal(discoveryStormDistance(floored.ecologicalStorm!), 1);
const single = contest(discoveryStormFixture());
single.stormPending = 1;
single.stormMovementSource = createStormSource(single.turn, 'dials', 1);
let one = confirmDiscoveryStorm(single);
const oneOrder = occupants(one);
one = applyAction(reload(one), oneOrder[0], control(one, oneOrder[0], -1));
assert.deepEqual(
discoveryStormOffer(one, one.ecologicalStorm!)!.options.map(
(option) => option.delta,
),
[0, 1],
);
reject(one, oneOrder[1], {
type: 'decision',
event: one.ecologicalStorm!.event,
delta: -1,
});
one = applyAction(reload(one), oneOrder[1], control(one, oneOrder[1], 0));
assert.equal(discoveryStormDistance(one.ecologicalStorm!), 0);
assert.equal(one.storm, 6);
assert.equal(one.phase, 1);
const ceiling = contest(discoveryStormFixture());
ceiling.storm = 6;
ceiling.stormPending = 40;
ceiling.stormMovementSource = createStormSource(ceiling.turn, 'dials', 40);
let high = confirmDiscoveryStorm(ceiling);
const highOrder = occupants(high);
assert.deepEqual(
discoveryStormOffer(high, high.ecologicalStorm!)!.options.map(
(option) => option.delta,
),
[-1, 0],
);
reject(high, highOrder[0], {
type: 'decision',
event: high.ecologicalStorm!.event,
delta: 1,
});
high = applyAction(reload(high), highOrder[0], control(high, highOrder[0], -1));
assert.equal(discoveryStormDistance(high.ecologicalStorm!), 39);
high = applyAction(reload(high), highOrder[1], control(high, highOrder[1], 1));
assert.equal(discoveryStormDistance(high.ecologicalStorm!), 40);
assert.equal(high.storm, ((6 - 1 + 40) % 18) + 1);
});
void test('a lone Bene Gesserit advisor group is not a station occupant and never receives a contested choice', () => {
const g = contest(discoveryStormFixture());
const advisor = g.players[1];
advisor.faction = 'beneGesserit';
advisor.advisors = { 'ecological-testing-station': {} };
assert.equal(occupants(g).includes(advisor.id), false);
assert.deepEqual(occupants(g), [g.players[0].id]);
const frame = createDiscoveryStorm(g, 'advisor-exclusion');
assert.equal(frame.queue, undefined);
assert.equal(frame.owner, g.players[0].id);
assert.equal(discoveryStormOffer(g, frame)!.owner, g.players[0].id);
});
void test('Weather Control denies every occupant of a contested station', () => {
for (const advanced of [false, true]) {
let g = contest(discoveryStormFixture(advanced));
const card = holdStormCard(g, 'a', 'weather');
g = applyAction(g, 'g', { type: 'ready' });
g = applyAction(g, 'a', { type: 'card', card: card.id, amount: 2 });
assert.equal(g.stormMovementSource?.kind, 'weather');
assert.equal(discoveryStormActions(viewGame(g, occupants(g)[0])).length, 0);
g = confirmDiscoveryStorm(reload(g));
assert.equal(g.ecologicalStorm, undefined);
assert.equal(g.storm, 8);
}
});
void test('every AI level submits the second occupant’s remaining cumulative choice', () => {
const g = confirmDiscoveryStorm(contest(discoveryStormFixture(true)));
const order = occupants(g);
const committed = applyAction(
reload(g),
order[0],
control(g, order[0], 0),
);
for (const level of DIFFICULTIES) {
const view = viewGame(committed, order[1]);
view.players.find((player) => player.id === order[1])!.bot = level;
const actions = botActions(view);
assert.ok(actions.length);
for (const action of actions)
assert.doesNotThrow(() => applyAction(reload(committed), order[1], action));
}
assert.equal(committed.decision?.player, order[1]);
assert.equal(discoveryStormDistance(committed.ecologicalStorm!), 2);
});
+281
View File
@@ -0,0 +1,281 @@
import assert from 'node:assert/strict';
import {
applyAction,
battles,
createGame,
initializeDiscoveryGameForAudit,
joinGame,
newPlayer,
viewGame,
type Action,
type Game,
type Player,
} from '../game/engine';
import {
DISCOVERY_CARD_PLACEMENTS,
JACURUTU_SIETCH,
type DiscoveryOpaqueTokenId,
} from '../game/discoveries';
import type { JacurutuDialOffer } from '../game/discovery-battle';
import type { Casualties } from '../game/combat';
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
export type JacurutuMixedDialStep = { actor: string; action: Action };
export type JacurutuMixedDialOptions = {
/** The defeated Emperor's actual revealed plan. Dial 2 with support 1 leaves
* two legal physical readings of the same wheel; dial 1 with support 0
* leaves exactly one. */
dial?: number;
support?: number;
};
export type JacurutuMixedDialFixture = {
/** Authenticated fresh lobby before any deal. */
initial: Game;
/** The original physical Discovery setup. */
setup: Game;
/** The genuine battle in Jacurutu Sietch before its final traitor vote. */
game: Game;
actions: JacurutuMixedDialStep[];
staging: string[];
/** Actual battle winner (Atreides). */
attacker: string;
/** Actual battle loser (Emperor) holding one ordinary and one Sardaukar. */
defender: string;
observer: string;
territory: string;
/** The surrounding territory the defeated forces entered from. */
parent: string;
token: string;
/** The last original traitor vote that completes the reveal. */
finalVote: JacurutuMixedDialStep;
defenderPlan: Action;
attackerPlan: Action;
defenderLeader: string;
attackerLeader: string;
/** Physical counters the defeated army actually sent to the Tanks. */
defenderTanks: { normal: number; elite: number };
dial: number;
support: number;
};
/** Engine surface owned by the parent integration; asserted here as a contract. */
export type JacurutuBattleReceipt = JacurutuDialOffer & {
selectedDial?: Casualties | null;
};
export type JacurutuDialDecision = { kind: 'jacurutuDial'; player: string; event: string };
export function jacurutuBattleReceipt(game: Game): JacurutuBattleReceipt | null {
const battle = game.battle as (Game['battle'] & { jacurutuDial?: JacurutuBattleReceipt }) | null;
return battle?.jacurutuDial ? structuredClone(battle.jacurutuDial) : null;
}
export function jacurutuCurrentDecision(game: Game): JacurutuDialDecision | null {
const decision = game.decision as JacurutuDialDecision | null;
return decision?.kind === 'jacurutuDial' ? decision : null;
}
const EMPEROR = 'emperor';
const ATREIDES = 'atreides';
type JacurutuProgress = { token: DiscoveryOpaqueTokenId | null; emperorShipped: boolean; atreidesShipped: boolean };
function clean(game: Game) {
return !game.phaseOpening && !game.response && !game.decision;
}
/** Genuine native policy plus the four real actions this scenario needs. */
function nextJacurutuMixedDialStep(game: Game, progress: JacurutuProgress): JacurutuMixedDialStep {
if (game.decision?.kind === 'discoveryEntry') {
const owner = game.decision.player;
const offer = viewGame(game, owner).discoveryEntry!;
if (owner === EMPEROR && !offer.blocked)
return { actor: owner, action: { type: 'decision', event: game.decision.event, accept: true,
groups: offer.sources.map(({ source, normal, elite }) => ({ source, normal, elite })) } };
return { actor: owner, action: { type: 'decision', event: game.decision.event, accept: false } };
}
// Collection: the sole surrounding occupant inspects then reveals the token.
if (game.phase === 7 && clean(game) && progress.token) {
const discoveries = viewGame(game, EMPEROR).discoveries!;
if (discoveries.canInspect.includes(progress.token))
return { actor: EMPEROR, action: { type: 'discovery', token: progress.token, reveal: false } };
if (discoveries.canReveal.includes(progress.token))
return { actor: EMPEROR, action: { type: 'discovery', token: progress.token, reveal: true } };
}
if (game.phase === 5 && clean(game)) {
if (game.turn === 1 && game.active === EMPEROR && !progress.emperorShipped) {
progress.emperorShipped = true;
return { actor: EMPEROR, action: { type: 'ship', territory: DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'].territory,
sector: DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'].sector, amount: 2, elite: 1, allyPayment: 0 } };
}
if (game.turn === 2 && game.active === ATREIDES && !progress.atreidesShipped) {
progress.atreidesShipped = true;
return { actor: ATREIDES, action: { type: 'ship', territory: JACURUTU_SIETCH, sector: 0,
amount: 3, elite: 0, allyPayment: 0 } };
}
return { actor: game.active!, action: { type: 'endMovement' } };
}
if (game.phase === 6 && !game.battle && clean(game)) {
const pending = battles(game);
if (pending.length) {
const choice = pending[0];
assert.equal(choice.territory, JACURUTU_SIETCH, 'Only the revealed nested location may hold this battle.');
const chooser = choice.chooser ?? choice.attacker;
return { actor: chooser, action: { type: 'chooseBattle', territory: choice.territory,
target: choice.attacker === chooser ? choice.defender : choice.attacker } };
}
}
const next = nextSpiceBankerIncomeNativeStep(game);
assert.ok(next, `No genuine Jacurutu dial step at ${game.status}/${game.turn}/${game.phase}.`);
return next;
}
function advance(
state: Game,
until: (game: Game) => boolean,
progress: JacurutuProgress,
actions: JacurutuMixedDialStep[],
): Game {
let game = state;
for (let step = 0; step < 1500 && !until(game); step++) {
assert.notEqual(game.status, 'finished');
const next = nextJacurutuMixedDialStep(game, progress);
actions.push(next);
game = applyAction(game, next.actor, next.action);
}
assert.ok(until(game), 'The genuine Jacurutu dial policy did not reach its requested window.');
return game;
}
/** Control only the actual placement lottery of the real Discovery blow. */
function withPlacement<T>(game: Game, operation: () => T): T {
const eligible = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === 'hiereg');
const selected = eligible.findIndex(token => token.face === JACURUTU_SIETCH);
assert.ok(selected >= 0, 'The original Hiereg supply must still hold Jacurutu Sietch.');
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1)
array[0] = Math.floor((selected + 0.5) / eligible.length * 0x100000000);
else Reflect.apply(original, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function leaderOf(game: Game, id: string, strongest: boolean): Player['leaders'][number] {
const player = game.players.find(seat => seat.id === id)!;
const leader = player.leaders.filter(candidate => !candidate.dead && candidate.usedAt === undefined)
.sort((left, right) => strongest ? right.strength - left.strength : left.strength - right.strength)[0];
assert.ok(leader, `Faction ${player.faction} must still hold an unused leader.`);
return leader;
}
function applyStep(game: Game, step: JacurutuMixedDialStep, actions: JacurutuMixedDialStep[]): Game {
actions.push(step);
return applyAction(game, step.actor, step.action);
}
/**
* Genuine Advanced Discovery runtime: original setup, first real Storm, the
* actual Hiereg Discovery blow placing Jacurutu Sietch at its printed
* destination, a real Emperor shipment beside it, the real private inspect and
* reveal, the real next-turn free entry, a real Atreides shipment into the
* revealed location and the real declared battle. The only staging is the
* order of four original unplayed Spice Cards and the placement lottery; no
* card, wallet, force, phase, hand or receipt is written directly.
*/
export function createJacurutuMixedDialFixture(options: JacurutuMixedDialOptions = {}): JacurutuMixedDialFixture {
const dial = options.dial ?? 2;
const support = options.support ?? 1;
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
assert.equal(placement.type, 'hiereg');
let game = createGame('JACURUTUDIAL', newPlayer(EMPEROR, 'Emperor', 'emperor'), true, []);
joinGame(game, newPlayer(ATREIDES, 'Atreides', 'atreides'));
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
assert.deepEqual(game.expansions, []);
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
game.discoveryEnabled = true;
const initial = structuredClone(game);
game = initializeDiscoveryGameForAudit(game);
const setup = structuredClone(game);
const actions: JacurutuMixedDialStep[] = [];
const staging: string[] = [];
// 1. The original setup and the first real Storm.
game = advance(game, state => state.status === 'playing', { token: null, emperorShipped: false, atreidesShipped: false }, actions);
const discovery = game.spiceDeck.findIndex(card => 'discovery' in card && card.discovery === 'discovery-hagga-basin');
assert.ok(discovery >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(discovery, 1)[0]);
for (let position = 1; position < 4; position++) {
const ordinary = game.spiceDeck.findIndex((card, index) => index >= position && card !== undefined &&
'territory' in card && !card.discovery && card.territory !== placement.territory);
assert.ok(ordinary >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(ordinary, 1)[0]);
}
staging.push('Reordered four original unplayed Spice Cards so the real first blow is the Hiereg Discovery card; selected only the actual placement lottery.');
const progress: JacurutuProgress = { token: null, emperorShipped: false, atreidesShipped: false };
game = advance(game, state => state.phase === 1 && clean(state), progress, actions);
game = withPlacement(game, () => advance(game,
state => state.discoveries!.tokens.some(token => token.face === JACURUTU_SIETCH && token.status === 'placed'),
progress, actions));
const token = game.discoveries!.tokens.find(candidate => candidate.face === JACURUTU_SIETCH)!;
assert.equal(token.status, 'placed');
assert.equal(token.territory, placement.territory);
assert.equal(token.sector, placement.sector);
assert.notEqual(token.sector, game.storm);
progress.token = token.id;
// 2. The real Emperor shipment in his own first Movement turn beside the
// face-down token, then the real private inspect and reveal in that turn's
// Collection. The storm order never decides this sequence.
game = advance(game, state => state.phase === 5 && clean(state) && state.active === EMPEROR, progress, actions);
game = advance(game, state => state.discoveries!.tokens.find(candidate => candidate.id === token.id)!.revealedTurn === 1, progress, actions);
// 3. The real next-turn free entry into the revealed location.
game = advance(game, state => state.decision?.kind === 'discoveryEntry' && state.decision.player === EMPEROR, progress, actions);
game = applyStep(game, nextJacurutuMixedDialStep(game, progress), actions);
const entered = game.players.find(player => player.id === EMPEROR)!;
const enteredKey = `${JACURUTU_SIETCH}:0`;
assert.equal(entered.forces[enteredKey], 2, 'The real free entry must move both undialed counters.');
assert.equal(entered.elites!.forces[enteredKey], 1);
// 4. The real Atreides shipment into the revealed location and the battle.
game = advance(game, state => state.phase === 6 && !!state.battle && clean(state) && !state.battle.preparation, progress, actions);
const defenderLeader = leaderOf(game, EMPEROR, false);
const attackerLeader = leaderOf(game, ATREIDES, true);
assert.ok(attackerLeader.strength >= defenderLeader.strength + 3, 'The real strength comparison must leave no tie.');
const defenderPlan: Action = { type: 'battlePlan', leader: defenderLeader.id, dial, support };
const attackerPlan: Action = { type: 'battlePlan', leader: attackerLeader.id, dial: 2, support: 2 };
for (const [actor, plan] of [[EMPEROR, defenderPlan], [ATREIDES, attackerPlan]] as const) {
for (let step = 0; step < 20 && (!clean(game) || game.battle?.preparation); step++)
game = applyStep(game, nextJacurutuMixedDialStep(game, progress), actions);
game = applyStep(game, { actor, action: plan }, actions);
}
const defender = game.players.find(player => player.id === EMPEROR)!;
assert.equal(game.battle!.plans[EMPEROR]!.dial, dial);
assert.equal(game.battle!.plans[EMPEROR]!.support, support);
assert.equal(game.battle!.plans[ATREIDES]!.dial, 2);
assert.equal(defender.forces[enteredKey], 2);
assert.equal(defender.elites!.forces[enteredKey], 1);
// Hold back the final original traitor vote so the caller completes the reveal.
for (let step = 0; step < 20; step++) {
const voters = viewGame(game, game.host).battle!.traitorVoters
.filter(id => game.battle!.traitorCalls[id] === undefined);
if (voters.length <= 1) break;
game = applyStep(game, { actor: voters[0], action: { type: 'traitorCall', call: false } }, actions);
}
const voters = viewGame(game, game.host).battle!.traitorVoters
.filter(id => game.battle!.traitorCalls[id] === undefined);
assert.equal(voters.length, 1, 'One original traitor vote must still be pending.');
const finalVote: JacurutuMixedDialStep = { actor: voters[0], action: { type: 'traitorCall', call: false } };
return {
initial, setup, game, actions, staging,
attacker: ATREIDES, defender: EMPEROR, observer: 'guild',
territory: JACURUTU_SIETCH, parent: `${placement.territory}:${placement.sector}`, token: token.id,
finalVote, defenderPlan, attackerPlan,
defenderLeader: defenderLeader.id, attackerLeader: attackerLeader.id,
defenderTanks: { normal: 1, elite: 1 }, dial, support,
};
}
+442
View File
@@ -0,0 +1,442 @@
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
initializeDiscoveryGameForAudit,
joinGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { fighterCount } from '../game/advisors';
import { DISCOVERY_CARD_PLACEMENTS, type DiscoveryTokenFace } from '../game/discoveries';
import { discoveryStormActions } from '../game/discovery-storm-options';
import { PLAYER_CIRCLE_SECTORS } from '../game/player-positions';
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
export type TestingStationContestStep = { actor: string; action: Action };
export type TestingStationContestFixture = {
initial: Game;
setup: Game;
afterSetup: Game;
/** Natural turn-3 Storm phase with two actual occupants and the ordinary source. */
game: Game;
/** The same turn-3 Storm phase before any dial, card or ready: the ordinary window. */
beforeStorm: Game;
actions: TestingStationContestStep[];
staging: string[];
owner: string;
second: string;
token: string;
parent: string;
parentSector: number;
source: { turn: number; kind: string; distance: number };
/** The two occupants in original storm order. */
order: [string, string];
event: string;
};
const STATION = 'ecological-testing-station';
/** Printed player-circle storm order for the board's current storm position. */
export function testingStationStormOrder(
game: Game,
ids: readonly string[],
): string[] {
const distance = (id: string) =>
(PLAYER_CIRCLE_SECTORS[game.playerPositions![id] - 1] - game.storm + 18) %
18 || 18;
return [...ids].sort((a, b) => distance(a) - distance(b));
}
export function testingStationOccupants(game: Game): string[] {
return game.order.filter((id) => {
const player = game.players.find((candidate) => candidate.id === id);
return !!player && fighterCount(player, STATION) > 0;
});
}
const clean = (game: Game) =>
!game.response && !game.phaseOpening && !game.decision;
/** Real native continuations; only the target turn-three station window stays open. */
export function nextTestingStationContestStep(
game: Game,
): TestingStationContestStep | null {
if (game.status === 'finished') return null;
if (game.decision?.kind === 'ecologicalStorm') {
if (game.turn >= 3) return null;
const actor = game.decision.player;
const action = discoveryStormActions(viewGame(game,actor)).find(choice => choice.delta === 0);
assert.ok(action, 'Earlier natural station opportunities must preserve the ordinary distance.');
return {actor,action};
}
if (game.decision?.kind === 'discoveryEntry') {
const decision = game.decision;
const offer = viewGame(game, decision.player).discoveryEntry;
return {
actor: decision.player,
action:
offer && !offer.blocked && offer.sources.length
? {
type: 'decision',
event: decision.event,
accept: true,
groups: offer.sources,
}
: { type: 'decision', event: decision.event, accept: false },
};
}
// Optional native offers stay declined so no unrelated ride, Ambassador or
// Battle can change the two-occupant board under audit.
if (game.decision?.kind === 'wormRide')
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
if (
game.decision?.kind === 'ecazPlacement' ||
game.decision?.kind === 'ecazAmbassador'
)
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
return nextSpiceBankerIncomeNativeStep(game);
}
function step(
game: Game,
actions?: TestingStationContestStep[],
): Game {
const next = nextTestingStationContestStep(game);
assert.ok(
next,
`Original Discovery/Ecaz continuation stopped at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`,
);
actions?.push(structuredClone(next));
return applyAction(game, next.actor, next.action);
}
export function advanceTestingStationContest(
state: Game,
until: (game: Game) => boolean,
actions?: TestingStationContestStep[],
): Game {
let game = state;
for (let n = 0; n < 2600; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
game = step(game, actions);
}
throw Error('Original Discovery/Ecaz chronology did not reach its boundary.');
}
/** Conserved staging only: original unplayed Spice Cards move to the front. */
function frontCard(
game: Game,
position: number,
matches: (card: Game['spiceDeck'][number]) => boolean,
): void {
const index = game.spiceDeck.findIndex(
(card, i) => i >= position && matches(card),
);
assert.ok(
index >= position,
'The original unplayed Spice Card must remain available.',
);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
/** Only the actual Hiereg supply lottery is selected; token identity stays real. */
function selectedTokenLottery<T>(
game: Game,
face: DiscoveryTokenFace,
operation: () => T,
): T {
const pool = game
.discoveries!.tokens.filter(
(token) => token.status === 'supply' && token.type === 'hiereg',
)
.map((token) => token.face);
const index = pool.indexOf(face);
assert.ok(index >= 0, 'The station Hiereg token must remain in supply.');
const scalar = Math.floor(((index + 0.5) / pool.length) * 0x100000000);
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] = scalar;
return array;
}
return Reflect.apply(native, crypto, [array]) as V;
};
try {
return operation();
} finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Held-card staging only: a real unplayed Weather Control card moves to a hand. */
export function holdTestingStationStormCard(
game: Game,
owner: string,
effect: string,
) {
const card = [...game.deck, ...game.discard, ...game.players.flatMap((p) => p.hand)]
.find((candidate) => candidate.effect === effect);
assert.ok(card, 'The original Treachery deck must still hold that card.');
game.deck = game.deck.filter((candidate) => candidate.id !== card.id);
game.discard = game.discard.filter((candidate) => candidate.id !== card.id);
for (const player of game.players)
player.hand = player.hand.filter((candidate) => candidate.id !== card.id);
game.players.find((player) => player.id === owner)!.hand.push(card);
return card;
}
/** Original fresh Ecaz/Guild/Fremen/Emperor Discovery setup. Two distinct
* non-advisor occupants reach the revealed station through real controls: the
* first free-enters from the printed Hagga Basin destination, a real worm Nexus
* forms the reciprocal alliance, and the ally then pays the ordinary stronghold
* rate to ship in. No force, phase, source, token or frame is fabricated. */
export function createTestingStationContestFixture(
options: { advanced?: boolean } = {},
): TestingStationContestFixture {
const advanced = options.advanced ?? false;
let game = createGame(
'TESTINGSTATIONCONTEST',
newPlayer('ecaz', 'Ecaz', 'ecaz'),
advanced,
['ecaz'],
);
for (const faction of ['guild', 'fremen', 'emperor'] as const)
joinGame(game, newPlayer(faction, faction, faction));
game.discoveryEnabled = true;
for (const player of game.players)
if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
const ecaz = game.players.find((player) => player.faction === 'ecaz')!.id;
const guild = game.players.find((player) => player.faction === 'guild')!.id;
assert.notEqual(ecaz, guild);
const initial = structuredClone(game);
const actions: TestingStationContestStep[] = [];
const staging: string[] = [];
game = initializeDiscoveryGameForAudit(game);
const setup = structuredClone(game);
game = advanceTestingStationContest(game, (state) => state.status === 'playing', actions);
// Printed Hagga Basin Discovery blow on turn one; a real ordinary worm on turn
// two; everything else stays an original unplayed card of the same deck.
const wormPosition = advanced ? 2 : 1;
frontCard(game, 0, (card) => 'territory' in card && card.discovery === 'discovery-hagga-basin');
if (advanced)
frontCard(game, 1, (card) => 'territory' in card && !card.discovery);
frontCard(game, wormPosition, (card) => 'worm' in card && !card.greatMaker);
for (let position = wormPosition + 1; position < wormPosition + 3; position++)
frontCard(game, position, (card) => 'territory' in card && !card.discovery);
staging.push(
'Reordered original unplayed Spice Cards only: the printed Hagga Basin Discovery first, then an ordinary Shai-Hulud for turn two.',
);
const afterSetup = structuredClone(game);
game = selectedTokenLottery(game, 'ecological-testing-station', () =>
advanceTestingStationContest(
game,
(state) =>
state.discoveries!.tokens.some(
(token) => token.face === 'ecological-testing-station' && token.status === 'placed',
),
actions,
),
);
const token = game.discoveries!.tokens.find(
(candidate) => candidate.face === 'ecological-testing-station',
)!;
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
assert.equal(token.status, 'placed');
assert.equal(token.territory, placement.territory);
assert.equal(token.sector, placement.sector);
staging.push(
'Selected the actual Hiereg supply lottery; the station token itself was placed by the printed card.',
);
// Turn one: the collector ships to the token's printed destination, then
// inspects and reveals the token during the ordinary Collection phase.
game = advanceTestingStationContest(
game,
(state) => state.phase === 5 && state.active === ecaz && clean(state),
actions,
);
const shipment: TestingStationContestStep = {
actor: ecaz,
action: {
type: 'ship',
territory: placement.territory,
sector: placement.sector,
amount: 2,
elite: 0,
allyPayment: 0,
},
};
actions.push(structuredClone(shipment));
game = applyAction(game, shipment.actor, shipment.action);
game = advanceTestingStationContest(
game,
(state) => state.phase === 7 && clean(state) && !state.pendingShipment,
actions,
);
assert.equal(game.turn, 1);
assert.ok(viewGame(game, ecaz).discoveries!.canInspect.includes(token.id));
const inspect: TestingStationContestStep = {
actor: ecaz,
action: { type: 'discovery', token: token.id, reveal: false },
};
const reveal: TestingStationContestStep = {
actor: ecaz,
action: { type: 'discovery', token: token.id, reveal: true },
};
for (const next of [inspect, reveal]) {
actions.push(structuredClone(next));
game = applyAction(game, next.actor, next.action);
}
assert.equal(
game.discoveries!.tokens.find((candidate) => candidate.id === token.id)!.revealedTurn,
1,
);
staging.push(
'Ordinary paid shipment to the printed destination followed by that seat’s real Collection inspection and public reveal.',
);
// Turn two: the location's own next-turn free entry, then a real worm Nexus
// and reciprocal alliance, then the ally's ordinary paid shipment.
game = advanceTestingStationContest(
game,
(state) =>
state.turn === 2 &&
state.decision?.kind === 'discoveryEntry' &&
state.decision.player === ecaz,
actions,
);
const entry = nextTestingStationContestStep(game);
assert.ok(entry, 'The station occupant must receive its real free-entry choice.');
assert.equal(entry.action.type, 'decision');
assert.equal(entry.action.accept, true);
actions.push(structuredClone(entry));
game = applyAction(game, entry.actor, entry.action);
assert.equal(fighterCount(game.players.find((p) => p.id === ecaz)!, STATION), 2);
staging.push(
'The station occupant entered through the printed next-turn free entry; no force was placed there directly.',
);
game = advanceTestingStationContest(
game,
(state) =>
state.turn === 2 &&
state.phase === 1 &&
!!state.nexus &&
!state.spiceWindow &&
!state.spiceResolution &&
clean(state),
actions,
);
const offer: TestingStationContestStep = { actor: ecaz, action: { type: 'alliance', target: guild } };
const accept: TestingStationContestStep = { actor: guild, action: { type: 'alliance', target: ecaz } };
for (const next of [offer, accept]) {
actions.push(structuredClone(next));
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.players.find((player) => player.id === ecaz)!.ally, guild);
assert.equal(game.players.find((player) => player.id === guild)!.ally, ecaz);
game = advanceTestingStationContest(
game,
(state) => state.phase === 5 && state.active === guild && clean(state),
actions,
);
const secondShipment: TestingStationContestStep = {
actor: guild,
action: {
type: 'ship',
territory: STATION,
sector: 0,
amount: 1,
elite: 0,
allyPayment: 0,
},
};
actions.push(structuredClone(secondShipment));
game = applyAction(game, secondShipment.actor, secondShipment.action);
game = advanceTestingStationContest(game,clean,actions);
assert.equal(fighterCount(game.players.find((p) => p.id === guild)!, STATION), 1);
staging.push(
'A real worm Nexus produced the reciprocal Ecaz/Guild alliance; the ally then used its own ordinary paid shipment into the occupied revealed location.',
);
game = advanceTestingStationContest(
game,
(state) => state.turn === 3 && state.phase === 0 && clean(state),
actions,
);
const beforeStorm = structuredClone(game);
game = advanceTestingStationContest(
game,
(state) => state.turn === 3 && state.phase === 0 && state.decision?.kind === 'ecologicalStorm',
actions,
);
const frame = game.ecologicalStorm!;
assert.ok(
actions.some((next) => next.action.type === 'ship') &&
actions.some((next) => next.action.type === 'alliance') &&
actions.some((next) => next.action.type === 'discovery') &&
actions.some(
(next) => next.action.type === 'decision' && next.action.accept === true,
),
'The contested station must be reached through real controls only.',
);
const occupants = testingStationOccupants(game);
assert.equal(occupants.length, 2);
assert.deepEqual(frame.queue?.order, occupants);
assert.deepEqual(
occupants,
testingStationStormOrder(game, occupants),
'Both occupants must queue in original storm order.',
);
const seats = game.players
.map((player) => player.id)
.filter((id) => occupants.includes(id));
if (JSON.stringify(seats) !== JSON.stringify(testingStationStormOrder(game, seats)))
assert.notDeepEqual(
occupants,
seats,
'The contested queue must use the printed storm order, never seat order.',
);
assert.equal(frame.owner, occupants[0]);
assert.equal(game.decision!.player, frame.owner);
assert.ok(game.decision?.kind === 'ecologicalStorm');
assert.equal(frame.event, game.decision!.event);
assert.notEqual(game.stormMovementSource?.kind, 'weather');
assert.equal(game.stormMovementSource?.turn, 3);
assert.equal(game.stormPending, game.stormMovementSource!.distance);
assert.equal(game.ecologicalStorm!.base, game.stormPending);
return {
initial,
setup,
afterSetup,
game,
beforeStorm,
actions,
staging,
owner: occupants[0],
second: occupants[1],
token: token.id,
parent: placement.territory,
parentSector: placement.sector,
source: {
turn: game.stormMovementSource!.turn,
kind: game.stormMovementSource!.kind,
distance: game.stormMovementSource!.distance,
},
order: [occupants[0], occupants[1]],
event: frame.event,
};
}
+182
View File
@@ -0,0 +1,182 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import {
createJacurutuMixedDialFixture,
jacurutuBattleReceipt,
jacurutuCurrentDecision,
} from './fixture-jacurutu-mixed-dial';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
const boardCount = (game: Game, id: string) =>
Object.values(game.players.find(player => player.id === id)!.forces)
.reduce((sum, count) => sum + count, 0);
const settlementLogs = (game: Game) =>
game.log.filter(entry => entry.automatic?.name === 'Jacurutu Sietch income');
/** The genuine fixture advanced to its single pending physical dial choice. */
function pausedDialChoice(options?: { dial?: number; support?: number }) {
const fixture = createJacurutuMixedDialFixture(options);
const game = applyAction(reload(fixture.game), fixture.finalVote.actor, fixture.finalVote.action);
return { fixture, game };
}
void test('the genuine Advanced battle is reached through real blows, shipments and free entry', () => {
const fixture = createJacurutuMixedDialFixture();
const token = fixture.game.discoveries!.tokens.find(candidate => candidate.id === fixture.token)!;
assert.equal(token.face, 'jacurutu-sietch');
assert.equal(token.status, 'placed');
assert.equal(token.revealedTurn, 1);
assert.equal(`${token.territory}:${token.sector}`, fixture.parent);
assert.equal(fixture.parent, 'gara_kulon:8');
const emperor = fixture.game.players.find(player => player.id === fixture.defender)!;
assert.equal(emperor.faction, 'emperor');
assert.equal(fixture.game.battle!.territory, fixture.territory);
assert.equal(emperor.forces['jacurutu-sietch:0'], 2);
assert.equal(emperor.elites!.forces['jacurutu-sietch:0'], 1);
for (const player of fixture.game.players) {
assert.equal(player.reserves + player.tanks + boardCount(fixture.game, player.id), 20);
assert.ok(player.reserves >= (player.elites?.reserves ?? 0));
assert.ok(player.tanks >= (player.elites?.tanks ?? 0));
}
assert.equal(jacurutuBattleReceipt(fixture.game), null);
assert.equal(jacurutuCurrentDecision(fixture.game), null);
});
void test('the defeated owner alone sees both real physical readings of the mixed dial', () => {
const { fixture, game } = pausedDialChoice();
const decision = jacurutuCurrentDecision(game);
assert.ok(decision);
assert.equal(decision.player, fixture.defender);
const receipt = jacurutuBattleReceipt(game)!;
assert.equal(receipt.event, decision.event);
assert.equal(receipt.owner, fixture.defender);
assert.equal(receipt.winner, fixture.attacker);
assert.deepEqual(receipt.choices.map(choice => choice.amount), [1, 0]);
assert.deepEqual(receipt.choices.map(choice => choice.dialed), [
{ normal: 0, elite: 1, paidNormal: 0, paidElite: 1 },
{ normal: 1, elite: 1, paidNormal: 1, paidElite: 0 },
]);
assert.deepEqual(receipt.selectedDial ?? null, null);
assert.equal(viewGame(game, fixture.attacker).jacurutuDial, null);
const ownerView = viewGame(game, fixture.defender).jacurutuDial!;
assert.deepEqual(ownerView.choices.map(choice => choice.amount), [1, 0]);
// The gate pauses before any settlement: no payment, casualty or reward yet.
for (const player of game.players) {
assert.equal(player.spice, fixture.game.players.find(candidate => candidate.id === player.id)!.spice);
assert.equal(player.tanks, fixture.game.players.find(candidate => candidate.id === player.id)!.tanks);
}
assert.equal(game.players.find(player => player.id === fixture.defender)!.forces['jacurutu-sietch:0'], 2);
assert.equal(settlementLogs(game).length, 0);
});
void test('either real reading settles the same original battle once and differs only by the Bank reward', () => {
const { fixture, game } = pausedDialChoice();
const before = reload(game);
const decision = jacurutuCurrentDecision(game)!;
const spiceBefore = Object.fromEntries(game.players.map(player => [player.id, player.spice]));
const first = applyAction(reload(game), fixture.defender, {
type: 'decision', event: decision.event, allocation: 0,
});
assert.deepEqual(game, before, 'A valid choice must not mutate the caller’s paused battle.');
const second = applyAction(reload(game), fixture.defender, {
type: 'decision', event: decision.event, allocation: 1,
});
const spiceOf = (settled: Game, id: string) => settled.players.find(player => player.id === id)!.spice;
// The original battle completion also opens ordinary Collection; its city
// income remains independent of Jacurutu's reward and both support payments.
const collection = Object.fromEntries(quoteSpiceCollection(game).receipts.map(receipt =>
[receipt.player,receipt.strongholds+receipt.collected]));
assert.equal(first.phase,7);
assert.equal(spiceOf(first, fixture.defender), spiceBefore[fixture.defender] - 1 + collection[fixture.defender]);
assert.equal(spiceOf(second, fixture.defender), spiceBefore[fixture.defender] - 1 + collection[fixture.defender]);
assert.equal(spiceOf(first, fixture.attacker), spiceBefore[fixture.attacker] - 2 + 1 + collection[fixture.attacker]);
assert.equal(spiceOf(second, fixture.attacker), spiceBefore[fixture.attacker] - 2 + collection[fixture.attacker]);
assert.equal(spiceOf(first, fixture.observer), spiceBefore[fixture.observer] + collection[fixture.observer]);
// Everything except the Bank reward is the identical single continuation.
for (const [index, player] of first.players.entries())
assert.deepEqual({ ...player, spice: 0 }, { ...second.players[index], spice: 0 });
assert.equal(settlementLogs(first).length, 1);
assert.equal(settlementLogs(second).length, 0);
assert.equal(first.log.filter(entry => entry.automatic?.name === 'Battle').length, 1);
assert.equal(first.log.filter(entry => entry.automatic?.name === 'Jacurutu physical dial').length, 1);
assert.equal(second.log.filter(entry => entry.automatic?.name === 'Jacurutu physical dial').length, 1);
const emperor = first.players.find(player => player.id === fixture.defender)!;
assert.equal(emperor.tanks, fixture.defenderTanks.normal + fixture.defenderTanks.elite);
assert.equal(emperor.elites!.tanks, fixture.defenderTanks.elite);
assert.equal(emperor.forces['jacurutu-sietch:0'], undefined);
const atreides = first.players.find(player => player.id === fixture.attacker)!;
assert.equal(atreides.tanks, 2, 'The winning Advanced commitment settles its own losses once.');
assert.equal(atreides.forces['jacurutu-sietch:0'], 1);
for (const player of first.players)
assert.equal(player.reserves + player.tanks + boardCount(first, player.id), 20);
assert.equal(first.battle, null);
assert.equal(jacurutuCurrentDecision(first), null);
assert.equal(jacurutuBattleReceipt(first), null);
assert.equal(first.lastBattleContext?.winner, fixture.attacker);
const stable = reload(first);
assert.deepEqual(normalizeAutomaticGame(reload(stable)), stable);
assert.throws(() => applyAction(reload(first), fixture.defender, {
type: 'decision', event: decision.event, allocation: 0,
}), /not available|pending decision/);
});
void test('a unique reading of the same real battle pays automatically without a choice', () => {
const fixture = createJacurutuMixedDialFixture({ dial: 1, support: 0 });
assert.equal(fixture.game.battle!.plans[fixture.defender]!.dial, fixture.dial);
assert.equal(fixture.game.battle!.plans[fixture.defender]!.support, fixture.support);
const spiceBefore = Object.fromEntries(fixture.game.players.map(player => [player.id, player.spice]));
const done = applyAction(reload(fixture.game), fixture.finalVote.actor, fixture.finalVote.action);
assert.equal(jacurutuCurrentDecision(done), null);
assert.equal(jacurutuBattleReceipt(done), null);
assert.equal(done.battle, null);
const collection = Object.fromEntries(quoteSpiceCollection(fixture.game).receipts.map(receipt =>
[receipt.player,receipt.strongholds+receipt.collected]));
assert.equal(done.phase,7);
assert.equal(done.players.find(player => player.id === fixture.attacker)!.spice, spiceBefore[fixture.attacker] - 2 + 1 + collection[fixture.attacker]);
assert.equal(done.players.find(player => player.id === fixture.defender)!.spice, spiceBefore[fixture.defender] + collection[fixture.defender]);
assert.equal(settlementLogs(done).length, 1);
const emperor = done.players.find(player => player.id === fixture.defender)!;
assert.equal(emperor.tanks, 2);
assert.equal(emperor.elites!.tanks, 1);
assert.equal(emperor.forces['jacurutu-sietch:0'], undefined);
for (const player of done.players)
assert.equal(player.reserves + player.tanks + boardCount(done, player.id), 20);
const stable = reload(done);
assert.deepEqual(normalizeAutomaticGame(reload(stable)), stable);
});
void test('a wrong actor, stale event or stale index never changes the paused battle', () => {
const { fixture, game } = pausedDialChoice();
const before = reload(game);
const decision = jacurutuCurrentDecision(game)!;
assert.throws(() => applyAction(reload(game), fixture.attacker, {
type: 'decision', event: decision.event, allocation: 0,
}), /pending decision/);
assert.throws(() => applyAction(reload(game), fixture.observer, {
type: 'decision', event: decision.event, allocation: 0,
}), /pending decision/);
for (const action of [
{ type: 'decision', event: 'stale-event', allocation: 0 },
{ type: 'decision', event: decision.event, allocation: 2 },
{ type: 'decision', event: decision.event, allocation: -1 },
{ type: 'decision', event: decision.event, allocation: 0.5 },
{ type: 'decision', event: decision.event },
{ type: 'decision', event: decision.event, allocation: 0, dial: 2 },
] as Action[])
assert.throws(() => applyAction(reload(game), fixture.defender, action),
/Jacurutu dial/);
assert.deepEqual(game, before);
const receipt = jacurutuBattleReceipt(game)!;
assert.deepEqual(receipt.selectedDial ?? null, null);
assert.deepEqual(viewGame(game, fixture.defender).jacurutuDial!.choices.map(choice => choice.amount), [1, 0]);
});
@@ -0,0 +1,238 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { discoveryStormActions } from '../game/discovery-storm-options';
import { discoveryStormDistance, discoveryStormOffer } from '../game/discovery-storm';
import { fighterCount } from '../game/advisors';
import { stormSectorAfter } from '../game/disaster-rules';
import {
advanceTestingStationContest,
createTestingStationContestFixture,
holdTestingStationStormCard,
testingStationOccupants,
testingStationStormOrder,
} from './fixture-testing-station-contest';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
/** Normalization must accept the signed queue and stay stable afterwards. */
function normalized(game: Game): Game {
const once = normalizeAutomaticGame(reload(game));
assert.deepEqual(normalizeAutomaticGame(reload(once)), once);
return once;
}
const controls = (game: Game, actor: string) =>
discoveryStormActions(viewGame(game, actor));
const chosen = (game: Game, actor: string, delta: number) => {
const action = controls(game, actor).find((candidate) => candidate.delta === delta);
assert.ok(action, `${actor} must receive the real ${delta} station control.`);
return action;
};
const at = (game: Game, actor: string) =>
game.players.find((player) => player.id === actor)!;
void test('two real occupants of the revealed station each choose once in original storm order through the ordinary controls', () => {
const fixture = createTestingStationContestFixture();
const g = fixture.game,
source = g.stormMovementSource!;
assert.equal(g.status, 'playing');
assert.equal(g.turn, 3);
assert.equal(g.phase, 0);
assert.notEqual(source.kind, 'weather');
assert.equal(source.turn, 3);
assert.equal(source.distance, g.stormPending);
assert.deepEqual(fixture.source, {
turn: source.turn,
kind: source.kind,
distance: source.distance,
});
const token = g.discoveries!.tokens.find(
(candidate) => candidate.id === fixture.token,
)!;
assert.equal(token.face, 'ecological-testing-station');
assert.equal(token.status, 'placed');
assert.equal(token.revealedTurn, 1);
assert.deepEqual([token.territory, token.sector], [fixture.parent, fixture.parentSector]);
assert.deepEqual(testingStationOccupants(g), fixture.order);
assert.deepEqual(fixture.order, testingStationStormOrder(g, fixture.order));
assert.equal(fixture.order.length, 2);
for (const id of fixture.order)
assert.ok(fighterCount(at(g, id), 'ecological-testing-station') > 0);
assert.equal(g.decision!.player, fixture.owner);
assert.equal(g.ecologicalStorm!.queue!.order.join(','), fixture.order.join(','));
assert.deepEqual(g.ecologicalStorm!.queue!.choices, []);
// Privacy: only the current occupant receives the projected offer.
assert.equal(viewGame(g, fixture.owner).ecologicalStorm!.owner, fixture.owner);
assert.equal(viewGame(g, fixture.second).ecologicalStorm, null);
assert.deepEqual(controls(g, fixture.second), []);
for (const player of g.players)
if (!fixture.order.includes(player.id))
assert.equal(viewGame(g, player.id).ecologicalStorm, null);
const pending = normalized(g);
assert.deepEqual(pending.ecologicalStorm?.queue?.order, fixture.order);
assert.equal(pending.decision?.player, fixture.owner);
const afterFirst = applyAction(reload(g), fixture.owner, chosen(g, fixture.owner, 1));
assert.equal(afterFirst.ecologicalStorm!.stage, 'choose');
assert.equal(afterFirst.decision!.player, fixture.second);
assert.ok(afterFirst.decision?.kind === 'ecologicalStorm');
assert.equal(afterFirst.decision!.event, `${fixture.event}:1`);
assert.equal(afterFirst.ecologicalStorm!.owner, fixture.second);
assert.deepEqual(afterFirst.ecologicalStorm!.queue!.choices, [
{ owner: fixture.owner, delta: 1 },
]);
assert.equal(discoveryStormDistance(afterFirst.ecologicalStorm!), source.distance + 1);
assert.equal(afterFirst.stormPending, source.distance + 1);
assert.equal(afterFirst.storm, g.storm);
assert.equal(afterFirst.phase, 0);
assert.equal(afterFirst.stormResolution, null);
const queued = normalized(afterFirst);
assert.equal(queued.ecologicalStorm?.owner, fixture.second);
assert.equal(queued.stormPending, source.distance + 1);
const done = applyAction(reload(afterFirst), fixture.second, chosen(afterFirst, fixture.second, -1));
assert.equal(done.ecologicalStorm!.stage, 'complete');
assert.deepEqual(done.ecologicalStorm!.queue!.choices, [
{ owner: fixture.owner, delta: 1 },
{ owner: fixture.second, delta: -1 },
]);
assert.equal(discoveryStormDistance(done.ecologicalStorm!), source.distance);
assert.equal(done.storm, stormSectorAfter(g.storm, source.distance));
assert.equal(done.phase, 1);
assert.equal(done.stormMovementSource, undefined);
assert.equal(done.stormResolution, null);
const settled = normalized(done);
assert.equal(settled.ecologicalStorm?.stage, 'complete');
assert.equal(settled.storm, stormSectorAfter(g.storm, source.distance));
// The committed queue cannot be replayed by either original occupant.
reject(done, fixture.owner, chosen(g, fixture.owner, 1));
reject(done, fixture.second, chosen(afterFirst, fixture.second, -1));
});
void test('the same native two-occupant proof holds for the Advanced revealed Storm Card source', () => {
const fixture = createTestingStationContestFixture({ advanced: true });
const g = fixture.game;
assert.equal(g.stormMovementSource?.kind, 'card');
assert.equal(fixture.source.kind, 'card');
assert.equal(fixture.source.turn, 3);
assert.equal(g.stormPending, fixture.source.distance);
assert.deepEqual(testingStationOccupants(g), fixture.order);
assert.deepEqual(fixture.order, testingStationStormOrder(g, fixture.order));
assert.equal(g.ecologicalStorm!.queue!.order.join(','), fixture.order.join(','));
assert.equal(g.ecologicalStorm!.base, fixture.source.distance);
assert.equal(viewGame(g, fixture.second).ecologicalStorm, null);
const first = applyAction(reload(g), fixture.owner, chosen(g, fixture.owner, 1));
assert.equal(first.storm, g.storm);
assert.equal(first.phase, 0);
assert.equal(first.decision!.player, fixture.second);
assert.equal(first.stormPending, fixture.source.distance + 1);
const done = applyAction(reload(first), fixture.second, chosen(first, fixture.second, -1));
assert.equal(done.ecologicalStorm!.stage, 'complete');
assert.equal(done.storm, stormSectorAfter(g.storm, fixture.source.distance));
assert.equal(done.phase, 1);
});
void test('one shared cumulative bound: two real decreases reach zero while opposite choices cancel to the ordinary distance', () => {
const fixture = createTestingStationContestFixture();
const base = fixture.game,
[first, second] = fixture.order,
pending = base.stormPending!;
const decrease = applyAction(reload(base), first, chosen(base, first, -1));
assert.equal(discoveryStormDistance(decrease.ecologicalStorm!), pending - 1);
const remaining = controls(decrease, second)
.map((action) => action.delta as number)
.sort((a, b) => a - b);
assert.deepEqual(remaining, pending - 1 > 0 ? [-1, 0, 1] : [0, 1]);
const floor = applyAction(
reload(decrease),
second,
chosen(decrease, second, pending - 1 > 0 ? -1 : 0),
);
assert.equal(
discoveryStormDistance(floor.ecologicalStorm!),
Math.max(0, pending - 2),
);
assert.equal(floor.storm, stormSectorAfter(base.storm, Math.max(0, pending - 2)));
assert.equal(floor.phase, 1);
if (pending - 1 === 0) {
reject(decrease, second, { type: 'decision', event: decrease.ecologicalStorm!.event, delta: -1 });
assert.deepEqual(
discoveryStormOffer(decrease, decrease.ecologicalStorm!)!.options.map(
(option) => option.distance,
),
[0, 1],
);
}
const up = applyAction(reload(base), first, chosen(base, first, 1));
const cancel = applyAction(
reload(up),
second,
chosen(up, second, -1),
);
assert.equal(discoveryStormDistance(cancel.ecologicalStorm!), pending);
assert.equal(cancel.storm, stormSectorAfter(base.storm, pending));
});
void test('wrong owner, stale event, injected fields and replayed choices fail closed on the signed queue', () => {
const fixture = createTestingStationContestFixture();
const base = fixture.game,
[first, second] = fixture.order;
const firstAction = chosen(base, first, 1);
reject(base, second, firstAction);
reject(base, first, { ...firstAction, event: `${fixture.event}:1` });
reject(base, first, { ...firstAction, distance: 1 });
for (const delta of [-2, 2, 0.5, NaN]) reject(base, first, { ...firstAction, delta });
const committed = applyAction(reload(base), first, firstAction);
reject(committed, first, firstAction);
reject(committed, second, firstAction);
const secondAction = chosen(committed, second, 0);
reject(committed, first, secondAction);
const done = applyAction(reload(committed), second, secondAction);
assert.equal(done.ecologicalStorm!.stage, 'complete');
reject(done, second, secondAction);
});
void test('a real Weather Control play replaces the ordinary source and denies both occupants any station change', () => {
const fixture = createTestingStationContestFixture();
const [first] = fixture.order,
before = fixture.beforeStorm;
assert.equal(before.stormPending, null);
let g = structuredClone(before);
const holder = g.players.find((player) => player.id !== first)!.id;
const card = holdTestingStationStormCard(g, first, 'weather');
g = advanceTestingStationContest(
g,
(state) =>
state.phase === 0 &&
state.stormPending !== null &&
state.stormMovementSource?.kind === 'dials',
);
// One ordinary ready opens the storm-card window; only the real card play follows.
g = applyAction(g, holder, { type: 'ready' });
g = applyAction(g, first, { type: 'card', card: card.id, amount: 2 });
assert.equal(g.stormMovementSource?.kind, 'weather');
assert.equal(g.stormPending, 2);
assert.equal(
viewGame(g, first).ecologicalStorm,
null,
'Weather Control must deny the current occupant its station choice.',
);
assert.equal(viewGame(g,fixture.second).ecologicalStorm,null);
g = advanceTestingStationContest(g, (state) => state.phase === 1);
assert.equal(g.storm, stormSectorAfter(before.storm, 2));
assert.equal(g.decision, null);
assert.equal(fighterCount(at(g, fixture.second), 'ecological-testing-station'),
fighterCount(at(before, fixture.second), 'ecological-testing-station'));
assert.equal(g.discard.filter((candidate) => candidate.id === card.id).length, 1);
});