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

147 lines
11 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 { richeseCards } from '../game/richese-cards';
import { traitorDeck } from '../game/traitors';
import type { NoFieldValue } from '../game/richese-no-field';
import { actFamilyAtomics, familyAtomicsPlayer, familyAtomicsReload } from './fixture-family-atomics';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import type { StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type FamilyAtomicsNoFieldStep = StrongholdFactionsNativeStep;
export const actFamilyAtomicsNoField = actFamilyAtomics;
export const familyAtomicsNoFieldPlayer = familyAtomicsPlayer;
export const familyAtomicsNoFieldReload = familyAtomicsReload;
export const familyAtomicsNoFieldClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingShipment;
export const nextFamilyAtomicsNoFieldStep = nextRicheseStrongholdsNativeStep;
export function advanceFamilyAtomicsNoField(game: Game, until: (state: Game) => boolean,
actions: FamilyAtomicsNoFieldStep[] = []): Game {
for (let count = 0; count < 1500; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original marker chronology ended before its requested boundary');
const step = nextFamilyAtomicsNoFieldStep(game); assert.ok(step, 'Complete the actual native pending control');
game = actFamilyAtomicsNoField(game, step.actor, step.action, actions);
}
throw Error('The original Atomics/No-Field source did not reach its finite boundary');
}
export function finishFamilyAtomicsNoFieldStorm(game: Game, actions: FamilyAtomicsNoFieldStep[] = []): Game {
return advanceFamilyAtomicsNoField(game, state => state.phase !== 0 && familyAtomicsNoFieldClean(state), actions);
}
export function familyAtomicsNoFieldInventory(game: Game) {
const cards = [...game.deck, ...game.discard, ...(game.removedTreachery ?? []), ...(game.richeseCache ?? []),
...(game.richeseRemoved ?? []), ...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
.map(card => card.id).sort();
assert.deepEqual(cards, [...treacheryDeck(game.expansions), ...richeseCards()].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 };
}
/** Fresh public Richese/classics with no optional modules. A real single-token
* shipment supplies the only Richese Arrakis presence. The hidden denomination
* is selected through the owned native packet, never installed into a marker.
*/
export function createFamilyAtomicsNoFieldFixture(options: {
advanced?: boolean; value?: NoFieldValue; geometry?: 'wall' | 'adjacent' | 'blocked'; foreignOwner?: boolean;
} = {}) {
const advanced = options.advanced ?? true, value = options.value ?? 0;
const geometry = options.geometry ?? 'wall', foreignOwner = options.foreignOwner ?? false;
assert.ok(!foreignOwner || geometry === 'wall', 'The bounded foreign-cost source destroys a Wall marker');
const richese = 'richese', owner = foreignOwner ? 'emperor' : richese;
let game = createGame('ATOMICSNOFIELD', newPlayer(richese, 'Richese', 'richese'), advanced, ['choam']);
joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
const initial = structuredClone(game), actions: FamilyAtomicsNoFieldStep[] = [];
const act = (actor: string, action: Action) => { game = actFamilyAtomicsNoField(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceFamilyAtomicsNoField(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.find(card => card.kind === 'projectile'); assert.ok(atomics && weather && ordinary);
const deal = foreignOwner ? [weather, ordinary, atomics] : [atomics, ordinary, weather];
const dealOrder = deal.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)), deal.map(card => card.id));
assert.deepEqual(familyAtomicsNoFieldPlayer(game, richese).forces, {});
const original = familyAtomicsNoFieldInventory(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 firstDialsFrom = actions.length;
for (const [index, actor] of game.stormDialers.entries())
act(actor, { type: 'stormDial', amount: geometry === 'blocked' && index === 0 ? 9 : 0 });
const firstDials = structuredClone(actions.slice(firstDialsFrom));
reach(state => state.turn === 1 && state.phase === 5 && familyAtomicsNoFieldClean(state));
const destination = geometry === 'wall' ? { territory: 'shield_wall', sector: 9 }
: { territory: 'imperial_basin', sector: 11 };
let shipment: { before: Game; steps: FamilyAtomicsNoFieldStep[]; after: Game } | null = null;
let physicalShipment: { before: Game; steps: FamilyAtomicsNoFieldStep[]; after: Game } | null = null;
let token = '', shipmentEvent = '';
for (let count = 0; game.phase === 5 && count < 80; count++) {
if (familyAtomicsNoFieldClean(game) && game.active === richese && !familyAtomicsNoFieldPlayer(game, richese).shipped) {
const own = familyAtomicsNoFieldPlayer(game, richese), selected = own.noField!.tokens.find(token => token.value === value);
assert.ok(selected && own.noFieldEvent); token = selected.id; shipmentEvent = own.noFieldEvent;
const before = structuredClone(game), from = actions.length;
act(richese, { type: 'ship', noField: token, event: shipmentEvent, ...destination }); reach(familyAtomicsNoFieldClean);
shipment = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
assert.equal(familyAtomicsNoFieldPlayer(before, richese).spice - familyAtomicsNoFieldPlayer(game, richese).spice, 2);
assert.equal(familyAtomicsNoFieldPlayer(before, richese).reserves, familyAtomicsNoFieldPlayer(game, richese).reserves);
continue;
}
if (foreignOwner && familyAtomicsNoFieldClean(game) && game.active === owner && !familyAtomicsNoFieldPlayer(game, owner).shipped) {
const before = structuredClone(game), from = actions.length;
act(owner, { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 1, elite: 0, allyPayment: 0 });
reach(familyAtomicsNoFieldClean);
physicalShipment = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
assert.equal(familyAtomicsNoFieldPlayer(before, owner).spice - familyAtomicsNoFieldPlayer(game, owner).spice, 2);
continue;
}
const step = nextFamilyAtomicsNoFieldStep(game); assert.ok(step); act(step.actor, step.action);
}
assert.ok(shipment); assert.ok(!foreignOwner || physicalShipment);
reach(state => state.turn === 2 && state.phase === 0 && familyAtomicsNoFieldClean(state));
assert.equal(game.storm, geometry === 'blocked' ? 10 : 1);
assert.equal(game.stormPending, advanced ? 1 : null);
const own = familyAtomicsNoFieldPlayer(game, richese);
assert.deepEqual(own.forces, {}); assert.equal(own.tanks, 0);
assert.equal(own.noField!.deployed!.tokenId, token);
assert.deepEqual(own.noField!.deployed!.location, destination);
const deployedEvent = familyAtomicsNoFieldPlayer(shipment.after, richese).noFieldEvent;
assert.notEqual(deployedEvent, shipmentEvent, 'The accepted native shipment rotates its consumed offer event');
assert.equal(own.noFieldEvent, deployedEvent, 'Native intervening phases retain the accepted concealed deployment event');
assert.ok(familyAtomicsNoFieldPlayer(game, owner).hand.some(card => card.id === atomics.id));
assert.deepEqual(familyAtomicsNoFieldInventory(game), original);
return { game, sourceGame: structuredClone(game), initial, lobby, setup, afterSetup, advanced, value, geometry,
foreignOwner, owner, richese, card: atomics.id, weather: weather.id, token, event: own.noFieldEvent,
destination, marker: structuredClone(own.noField), action: { type: 'card', card: atomics.id } satisfies Action,
shipment, physicalShipment, original, dealOrder, spiceOrder, firstDials, setupActions, actions,
sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { setup: setupActions.length, firstDials: firstDials.length, shipment: shipment.steps.length,
physicalShipment: physicalShipment?.steps.length ?? 0, source: actions.length },
sourceDisclosure: `Actual public ${advanced ? 'Advanced preview' : 'Basic'} Richese/Atreides/Emperor +CHOAM-deck setup, original private Traitors and original three No-Field tokens. ` +
'No Skills, Homeworlds, Nexus Cards or other optional modules. Only original unopened starting-card/land-Spice positions and scalar lotteries selected. ' +
`Richese actually ships its owned ${value} marker for two spice to ${destination.territory}:${destination.sector}; all physical reserves stay in supply while concealed. ` +
(foreignOwner ? 'Emperor separately ships an actual one-counter adjacent Basin9 qualifier for two spice and holds the originally dealt Atomics. ' : 'Richese itself holds the originally dealt Atomics; the marker is its only Arrakis presence. ') +
`Native first dials ${geometry === 'blocked' ? '9+0 earn Storm10' : '0+0 retain Storm1'}, then actual auctions/turns/phases reach original Storm2. ` +
(advanced ? 'Its calculated card1 is automatically revealed by the actual turn opening. ' : 'The original Basic second dial window remains uncalculated. ') +
'Every count derives from recorded actions. No held hand, purse, physical force, phase, queue, token deployment, marker event, wall state or casualty is installed.' };
}
export type FamilyAtomicsNoFieldFixture = ReturnType<typeof createFamilyAtomicsNoFieldFixture>;