Connect physical Jacurutu allocation and cumulative station choices
This commit is contained in:
1 parent
373badd560
commit
554f2bf9e9
22 files changed
+1943
-129
No files matched your search
@@ -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 }],
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user