143 lines
10 KiB
TypeScript
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>;
|