147 lines
11 KiB
TypeScript
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>;
|