140 lines
9.1 KiB
TypeScript
140 lines
9.1 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, nextChoamCharityFundingStep,
|
|
type ChoamCharityFundingStep } from './fixture-choam-charity-funding';
|
|
|
|
export type AdvancedCharityOrderStep = ChoamCharityFundingStep;
|
|
export const actAdvancedCharityOrder = actChoamCharityFunding;
|
|
export const advancedCharityOrderReload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
|
|
export const advancedCharityOrderPlayer = (game: Game, actor: string) => {
|
|
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
|
};
|
|
export const advancedCharityOrderClean = (game: Game) => !game.response && !game.phaseOpening &&
|
|
!game.decision && !game.pendingTreacheryDiscard && !game.truthtrance;
|
|
|
|
export function advancedCharityOrderInventory(game: Game) {
|
|
const cards = [...game.deck, ...game.discard, ...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();
|
|
if (traitors.length) 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 };
|
|
}
|
|
|
|
/** Setup, original counters and current Charity Finish only; no automatic claim. */
|
|
export function nextAdvancedCharityOrderStep(game: Game): AdvancedCharityOrderStep | null {
|
|
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
|
|
if (game.status === 'setup' && game.setupStage === 'prediction')
|
|
return { actor: 'beneGesserit', action: { type: 'predict', faction: 'atreides', turn: 10 } };
|
|
if (game.status === 'setup' && game.setupStage === 'forces' && game.advanced &&
|
|
!advancedCharityOrderPlayer(game, 'beneGesserit').advisorSetup)
|
|
return { actor: 'beneGesserit', action: { type: 'advisorSetup', territory: 'polar_sink', sector: 0 } };
|
|
if (game.decision?.kind === 'advisorBattle')
|
|
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
|
|
if (game.phase === 2 && !game.decision && game.charityOrder?.remaining.length)
|
|
return { actor: game.charityOrder.remaining[0], action: { type: 'ready' } };
|
|
}
|
|
return nextChoamCharityFundingStep(game);
|
|
}
|
|
export function advanceAdvancedCharityOrder(game: Game, until: (state: Game) => boolean,
|
|
actions: AdvancedCharityOrderStep[] = []): Game {
|
|
for (let count = 0; count < 600; count++) {
|
|
if (until(game)) return game;
|
|
assert.notEqual(game.status, 'finished', 'The original Charity chronology ended before its requested boundary');
|
|
const next = nextAdvancedCharityOrderStep(game); assert.ok(next);
|
|
game = actAdvancedCharityOrder(game, next.actor, next.action, actions);
|
|
}
|
|
throw Error('The original Charity programme did not reach its finite boundary');
|
|
}
|
|
export function passAdvancedCharityResponses(game: Game, actions: AdvancedCharityOrderStep[] = []): Game {
|
|
for (let count = 0; game.response && count < 40; count++) {
|
|
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
|
game = actAdvancedCharityOrder(game, actor.id, { type: 'passResponse' }, actions);
|
|
}
|
|
assert.equal(game.response ?? null, null); return game;
|
|
}
|
|
export function allowAdvancedCharityOpening(game: Game, actions: AdvancedCharityOrderStep[] = []): Game {
|
|
assert.equal(game.response?.kind, 'choamCharity');
|
|
game = passAdvancedCharityResponses(game, actions);
|
|
return advanceAdvancedCharityOrder(game, state => state.phase === 2 && advancedCharityOrderClean(state), actions);
|
|
}
|
|
export function finishAdvancedCharityTurn(game: Game, actions: AdvancedCharityOrderStep[] = []): Game {
|
|
const actor = game.charityOrder?.remaining[0]; assert.ok(actor);
|
|
return actAdvancedCharityOrder(game, actor, { type: 'ready' }, actions);
|
|
}
|
|
export function finishAdvancedCharityPhase(game: Game, actions: AdvancedCharityOrderStep[] = []): Game {
|
|
return advanceAdvancedCharityOrder(game, state => state.phase !== 2, actions);
|
|
}
|
|
|
|
/** Actual public CHOAM/BG/classic setup. Only original unopened starting-card
|
|
* and land-Spice positions plus scalar lotteries are selected. A real phase1
|
|
* bribe empties Atreides' purse; its recipient retains deferred incoming spice.
|
|
* No purse, phase, order, readiness, claim, force or receipt is installed.
|
|
*/
|
|
export function createAdvancedCharityOrderFixture(options: { advanced?: boolean; lowBG?: boolean } = {}) {
|
|
const advanced = options.advanced ?? true;
|
|
let game = createGame('ADVANCEDCHARITYORDER', newPlayer('atreides', 'Atreides', 'atreides'), advanced, ['choam']);
|
|
joinGame(game, newPlayer('beneGesserit', 'Bene Gesserit', 'beneGesserit'));
|
|
joinGame(game, newPlayer('choam', 'CHOAM', 'choam'));
|
|
const initial = structuredClone(game), actions: AdvancedCharityOrderStep[] = [];
|
|
const act = (actor: string, action: Action) => { game = actAdvancedCharityOrder(game, actor, action, actions); };
|
|
const reach = (until: (state: Game) => boolean) => { game = advanceAdvancedCharityOrder(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 karama = game.deck.find(card => card.effect === 'karama');
|
|
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 2);
|
|
assert.ok(karama); assert.equal(ordinary.length, 2);
|
|
const prefix = [karama, ...ordinary];
|
|
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 = advancedCharityOrderInventory(game);
|
|
const spiceOrder = [0, 1].map(position => {
|
|
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card); assert.ok(from >= position);
|
|
const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card);
|
|
return { position, from, card: structuredClone(card) };
|
|
});
|
|
reach(state => state.phase === 1 && advancedCharityOrderClean(state));
|
|
const beforeBribes = structuredClone(game), bribeFrom = actions.length;
|
|
const low = ['atreides', ...(options.lowBG ? ['beneGesserit'] : [])];
|
|
for (const actor of low) {
|
|
const amount = advancedCharityOrderPlayer(game, actor).spice; assert.ok(amount > 0);
|
|
act(actor, { type: 'bribe', target: 'choam', amount });
|
|
assert.equal(advancedCharityOrderPlayer(game, actor).spice, 0);
|
|
}
|
|
const bribeActions = structuredClone(actions.slice(bribeFrom));
|
|
reach(state => state.phase === 2 && state.response?.kind === 'choamCharity');
|
|
assert.equal(advancedCharityOrderPlayer(game, 'choam').spice, 2);
|
|
assert.equal(advancedCharityOrderPlayer(game, 'atreides').spice, 0);
|
|
assert.ok(advancedCharityOrderPlayer(game, 'choam').bribes > 0);
|
|
assert.deepEqual(advancedCharityOrderInventory(game), original);
|
|
return { initial, lobby, setup, afterSetup, beforeBribes, beforeIncome: structuredClone(game), game,
|
|
advanced, lowBG: options.lowBG ?? false, karama: karama.id, original,
|
|
claimant: 'atreides', bg: 'beneGesserit', provider: 'choam', dealOrder, spiceOrder,
|
|
actions, setupActions, bribeActions,
|
|
sourceCounts: { setup: setupActions.length, bribes: bribeActions.length, source: actions.length },
|
|
sourceDisclosure: 'Original public CHOAM/BeneGesserit/Atreides setup and native first Storm/Spice/closing controls. Advanced uses the explicit original preview start. Only original unopened starting-deal and land-Spice positions plus scalar lotteries selected. Real pre-Charity bribes create actual low claimants and deferred CHOAM incoming receipts; no money, order, phase, readiness or claim marker installed. Source stops at the original first CHOAM opening income response.' };
|
|
}
|
|
|
|
/** Explicit unstamped UNIT supplement; not claimed historical saved-game proof. */
|
|
export function unstampedAdvancedCharitySupplement(game: Game): Game {
|
|
const old = advancedCharityOrderReload(game);
|
|
delete old.charityOrder;
|
|
return old;
|
|
}
|