Files
Dune/tests/fixture-family-atomics.ts

143 lines
10 KiB
TypeScript

import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { treacheryDeck } from '../game/cards';
import { traitorDeck } from '../game/traitors';
import { actChoamCharityFunding } from './fixture-choam-charity-funding';
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type FamilyAtomicsStep = StrongholdFactionsNativeStep;
export const actFamilyAtomics = actChoamCharityFunding;
export const familyAtomicsReload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
export const familyAtomicsPlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const familyAtomicsClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.pendingTreacheryDiscard && !game.truthtrance;
export const nextFamilyAtomicsStep = nextStrongholdFactionsNativeStep;
export function advanceFamilyAtomics(game: Game, until: (state: Game) => boolean,
actions: FamilyAtomicsStep[] = []): Game {
for (let count = 0; count < 1200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Atomics chronology ended before its requested boundary');
const next = nextFamilyAtomicsStep(game); assert.ok(next, 'The quiet native source needs an actual control');
game = actFamilyAtomics(game, next.actor, next.action, actions);
}
throw Error('The original Atomics chronology did not reach its finite boundary');
}
export function passFamilyAtomicsResponses(game: Game, actions: FamilyAtomicsStep[] = []): Game {
while (game.response) {
const next = nextFamilyAtomicsStep(game); assert.ok(next); assert.equal(next.action.type, 'passResponse');
game = actFamilyAtomics(game, next.actor, next.action, actions);
}
return game;
}
export function finishFamilyAtomicsStorm(game: Game, actions: FamilyAtomicsStep[] = []): Game {
return advanceFamilyAtomics(game, state => state.phase !== 0 && familyAtomicsClean(state), actions);
}
export function familyAtomicsInventory(game: Game) {
const cards = [...game.deck, ...game.discard, ...(game.removedTreachery ?? []),
...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])]
.map(card => card.id).sort();
assert.deepEqual(cards, treacheryDeck(game.expansions).map(card => card.id).sort());
const traitors = [...(game.traitorReserve ?? []), ...game.players.flatMap(player =>
[...player.traitors, ...player.traitorChoices])].sort();
assert.deepEqual(traitors, traitorDeck(game.players, false).sort());
const spice = [...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort();
for (const player of game.players)
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20);
return { cards, traitors, spice };
}
/** Original bare A/H/E setup and one paid two-counter shipment. Only original
* unopened Treachery/Spice positions and scalar lotteries are selected. The
* blocked route earns Storm10 through the actual FIRST dials (9 + 0), leaving
* real Imperial Basin11 fighters beyond Storm10 from the Wall's touching9.
*/
export function createFamilyAtomicsFixture(options: {
advanced?: boolean; geometry?: 'wall' | 'adjacent' | 'blocked'; window?: 'beforeDistance' | 'calculated';
} = {}) {
const advanced = options.advanced ?? true, geometry = options.geometry ?? 'wall';
const window = options.window ?? 'calculated';
assert.ok(!advanced || window === 'calculated', 'Advanced Storm2 reveals its distance immediately; use the original preceding Mentat snapshot');
const owner = 'harkonnen', observer = 'emperor';
let game = createGame('FAMILYATOMICS', newPlayer('atreides', 'Atreides', 'atreides'), advanced);
joinGame(game, newPlayer(owner, 'Harkonnen', 'harkonnen'));
joinGame(game, newPlayer(observer, 'Emperor', 'emperor'));
const initial = structuredClone(game), actions: FamilyAtomicsStep[] = [];
const act = (actor: string, action: Action) => { game = actFamilyAtomics(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceFamilyAtomics(game, until, actions); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => !player.hand.length));
const atomics = game.deck.find(card => card.effect === 'atomics');
const weather = game.deck.find(card => card.effect === 'weather');
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 2);
assert.ok(atomics && weather); assert.equal(ordinary.length, 2);
const prefix = [ordinary[0], atomics, ordinary[1], weather];
const dealOrder = prefix.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), setupActions = structuredClone(actions);
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), prefix.map(card => card.id));
const original = familyAtomicsInventory(game);
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card);
return { position, from, card: structuredClone(card) };
});
const firstBeforeDistance = structuredClone(game), firstDialsFrom = actions.length;
assert.equal(game.stormPending, null);
for (const [index, actor] of game.stormDialers.entries())
act(actor, { type: 'stormDial', amount: geometry === 'blocked' && index === 0 ? 9 : 0 });
const firstAfterDistance = structuredClone(game), firstDials = structuredClone(actions.slice(firstDialsFrom));
assert.equal(game.stormPending, geometry === 'blocked' ? 9 : 0);
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && familyAtomicsClean(state));
const shipmentBefore = structuredClone(game), shipmentFrom = actions.length;
const destination = geometry === 'wall' ? { territory: 'shield_wall', sector: 9 }
: { territory: 'imperial_basin', sector: 11 };
const shipmentAction: Action = { type: 'ship', ...destination, amount: 2, elite: 0, allyPayment: 0 };
act(owner, shipmentAction); reach(familyAtomicsClean);
const shipment = { before: shipmentBefore, steps: structuredClone(actions.slice(shipmentFrom)), after: structuredClone(game) };
assert.equal(familyAtomicsPlayer(shipmentBefore, owner).spice - familyAtomicsPlayer(game, owner).spice, 4);
const location = `${destination.territory}:${destination.sector}`;
assert.equal(familyAtomicsPlayer(game, owner).forces[location], 2);
act(owner, { type: 'endMovement' });
reach(state => state.turn === 1 && state.phase === 8 && familyAtomicsClean(state) &&
state.ready.length === state.players.length - 1);
const beforeSecondStorm = structuredClone(game), beforeSecondStormActions = structuredClone(actions);
const revealFrom = actions.length;
reach(state => state.turn === 2 && state.phase === 0 && familyAtomicsClean(state));
const afterSecondStormOpening = structuredClone(game), secondStormOpening = structuredClone(actions.slice(revealFrom));
if (!advanced && window === 'calculated') reach(state => state.stormPending !== null && familyAtomicsClean(state));
assert.equal(game.turn, 2); assert.equal(game.phase, 0); assert.equal(game.shieldWallDestroyed, false);
assert.equal(game.storm, geometry === 'blocked' ? 10 : 1);
if (advanced) assert.equal(game.stormPending, 1, 'The original Advanced card lottery is revealed immediately at turn start');
else assert.equal(game.stormPending, window === 'beforeDistance' ? null : 2);
assert.equal(familyAtomicsPlayer(game, owner).forces[location], 2);
assert.equal(familyAtomicsPlayer(game, owner).tanks, 0);
assert.ok(familyAtomicsPlayer(game, owner).hand.some(card => card.id === atomics.id));
assert.deepEqual(familyAtomicsInventory(game), original);
return { game, initial, lobby, setup, afterSetup, firstBeforeDistance, firstAfterDistance,
firstDials, beforeSecondStorm, beforeSecondStormActions, afterSecondStormOpening, secondStormOpening,
owner, observer, advanced, geometry, window, location, destination, card: atomics.id, weather: weather.id,
action: { type: 'card', card: atomics.id } satisfies Action,
shipmentAction, shipment, original, dealOrder, spiceOrder, actions, setupActions,
sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { setup: setupActions.length, firstDials: firstDials.length, shipment: shipment.steps.length,
beforeSecondStorm: beforeSecondStormActions.length, secondStormOpening: secondStormOpening.length, source: actions.length },
sourceDisclosure: `Actual public ${advanced ? 'Advanced preview' : 'Basic'} Atreides/Harkonnen/Emperor base setup, native Traitors and starting armies. ` +
'Select only original unopened Treachery/land-Spice positions and scalar lotteries. Harkonnen actually pays four spice to ship two original reserve counters; every intervening turn, auction and phase control is native. ' +
(geometry === 'blocked' ? 'Actual first Storm dials9+0 earn Storm10; the surviving shipment is Imperial Basin11. '
: `Actual first Storm dials0+0 retain Storm1; the surviving shipment is ${location}. `) +
(advanced ? 'The final actual Mentat Ready automatically opens Storm2 and reveals its original one-sector card; no quiet Advanced turn2/null-distance state is claimed. '
: 'Basic Storm2 retains its native dial window and is optionally actually calculated. ') +
'All counts derive from recorded arrays. No held card, force, purse, phase, storm distance, wall, removal or discard is installed.' };
}
export type FamilyAtomicsFixture = ReturnType<typeof createFamilyAtomicsFixture>;