Allow early Advanced Truthtrance shipment promises
This commit is contained in:
1 parent
99d442fc68
commit
8053e1fb26
13 files changed
+926
-12
No files matched your search
@@ -0,0 +1,180 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { TruthQuestion } from '../game/truthtrance';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { actFutureShipment, nextFutureShipmentStep, futureShipmentClean, futureShipmentPlayer } from './fixture-truthtrance-future-shipment';
|
||||
import { truthMovementInventory } from './fixture-truth-movement';
|
||||
|
||||
export type TruthtranceAdvancedShipmentPreparationKind = 'bgCharity' | 'elite';
|
||||
export type TruthtranceAdvancedShipmentPreparationStep = { actor: string; action: Action };
|
||||
export const actTruthtranceAdvancedShipmentPreparation = actFutureShipment;
|
||||
export const truthtranceAdvancedShipmentPreparationPlayer = futureShipmentPlayer;
|
||||
export const truthtranceAdvancedShipmentPreparationInventory = truthMovementInventory;
|
||||
export const truthtranceAdvancedShipmentPreparationClean = futureShipmentClean;
|
||||
|
||||
/** Actual public setup/readiness controls. An earned owned preparation stays
|
||||
* caller-controlled instead of being skipped by a generic Ready action. */
|
||||
export function nextTruthtranceAdvancedShipmentPreparation(game: Game): TruthtranceAdvancedShipmentPreparationStep | null {
|
||||
if (!game.response && !game.phaseOpening && !game.truthtrance && !game.pendingTreacheryDiscard) {
|
||||
if (game.status === 'setup' && game.setupStage === 'prediction')
|
||||
return { actor: 'beneGesserit', action: { type: 'predict', faction: 'emperor', turn: 10 } };
|
||||
const bg = game.players.find(p => p.faction === 'beneGesserit');
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && bg && !bg.advisorSetup &&
|
||||
viewGame(game, bg.id).setupPending.includes(bg.id))
|
||||
return { actor: bg.id, action: { type: 'advisorSetup', territory: 'arrakeen', sector: 10 } };
|
||||
if (game.decision?.kind === 'advisorBattle' || game.decision?.kind === 'advisor' || game.decision?.kind === 'intrusion')
|
||||
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
|
||||
if (game.status === 'playing' && game.phase === 0 && game.stormPending === null) {
|
||||
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
|
||||
if (actor) return { actor, action: { type: 'stormDial',
|
||||
amount: game.turn === 1 ? actor === game.stormDialers[0] ? 11 : 0 : 1 } };
|
||||
}
|
||||
if ([2, 4].includes(game.phase) && game.players.some(p => viewGame(game, p.id).shipmentPreparation?.actions.length)) return null;
|
||||
}
|
||||
return nextFutureShipmentStep(game);
|
||||
}
|
||||
export const nextTruthtranceAdvancedShipmentPreparationStep = nextTruthtranceAdvancedShipmentPreparation;
|
||||
export function advanceTruthtranceAdvancedShipmentPreparation(game: Game, until: (state: Game) => boolean,
|
||||
actions: TruthtranceAdvancedShipmentPreparationStep[] = []): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextTruthtranceAdvancedShipmentPreparation(game);
|
||||
assert.ok(step, `Choose the original question, preparation or shipment at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actTruthtranceAdvancedShipmentPreparation(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original Advanced shipment-preparation chronology did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** Public Advanced-only sources. BG's real wealthy Charity and Emperor's
|
||||
* real elite-only Tanks are earned without assigning played resource state. */
|
||||
export function createTruthtranceAdvancedShipmentPreparationFixture(options: { kind: TruthtranceAdvancedShipmentPreparationKind }) {
|
||||
const { kind } = options, asker = 'atreides', target = kind === 'bgCharity' ? 'beneGesserit' : 'emperor';
|
||||
const roster = kind === 'bgCharity' ? ['beneGesserit', 'emperor'] as const : ['emperor', 'harkonnen', 'guild'] as const;
|
||||
let game = createGame('TRUTHADVANCEDSHIPMENTPREPARATION', newPlayer(asker, 'Atreides', 'atreides'), false);
|
||||
for (const faction of roster) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: TruthtranceAdvancedShipmentPreparationStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actTruthtranceAdvancedShipmentPreparation(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceTruthtranceAdvancedShipmentPreparation(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), at = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||
};
|
||||
const rules = transition(() => { act(game.host, { type: 'rules', advanced: true }); });
|
||||
for (const p of game.players) act(p.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.advancedPreview, true);
|
||||
assert.deepEqual(game.expansions, []); assert.ok(game.players.every(p => p.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && !game.strongholdCards &&
|
||||
!game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = truthtranceAdvancedShipmentPreparationInventory(game); assert.equal(originalInventory.length, 33);
|
||||
const truth = game.deck.find(card => card.effect === 'truthtrance');
|
||||
const printed = game.deck.find(card => card.effect === 'karama');
|
||||
const shield = game.deck.find(card => card.kind === 'shield'), snooper = game.deck.find(card => card.kind === 'snooper');
|
||||
const ordinary = game.deck.find(card => card.kind === 'projectile'), auctionFace = game.deck.find(card => card.name === 'Baliset');
|
||||
assert.ok(truth && printed && shield && snooper && ordinary && auctionFace);
|
||||
const selected = kind === 'bgCharity' ? [truth, shield, printed] : [truth, printed, shield, snooper, ordinary];
|
||||
const deckSelection = [...selected, ...(kind === 'elite' ? [auctionFace] : [])];
|
||||
const dealOrder = deckSelection.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 };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
let advisorSetup: { before: Game; steps: TruthtranceAdvancedShipmentPreparationStep[]; after: Game } | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const step = nextTruthtranceAdvancedShipmentPreparation(game); assert.ok(step);
|
||||
const before = step.action.type === 'advisorSetup' ? structuredClone(game) : null;
|
||||
act(step.actor, step.action);
|
||||
if (before) advisorSetup = { before, steps: [structuredClone(step)], after: structuredClone(game) };
|
||||
}
|
||||
assert.equal(game.status, 'playing'); const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
for (let position = 0; position < (kind === 'elite' ? 4 : 2); position++) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
|
||||
!['hagga_basin', 'carthag', 'arrakeen', 'tueks_sietch'].includes(card.territory));
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
spiceOrder.push({ position, from, card: structuredClone(card) });
|
||||
}
|
||||
const firstStorm = transition(() => { reach(state => state.turn === 1 && state.phase === 1); });
|
||||
assert.equal(game.storm, 12);
|
||||
let purchase: ReturnType<typeof transition> | null = null, beforeBid: Game | null = null;
|
||||
let shipment: ReturnType<typeof transition> | null = null, beforeStormMovement: Game | null = null;
|
||||
let stormLoss: ReturnType<typeof transition> | null = null, originalStormSource: Game['stormMovementSource'] | null = null;
|
||||
let auctionCard: string | null = null;
|
||||
if (kind === 'elite') {
|
||||
reach(state => state.turn === 1 && state.phase === 3 && truthtranceAdvancedShipmentPreparationClean(state) &&
|
||||
!!state.auction && state.auction.active === asker && state.auction.bid === 0);
|
||||
beforeBid = structuredClone(game); auctionCard = game.auction!.cards[game.auction!.index].id;
|
||||
assert.equal(auctionCard, auctionFace.id);
|
||||
purchase = transition(() => {
|
||||
act(asker, { type: 'bid', amount: 4 });
|
||||
reach(state => state.turn === 1 && state.phase === 4 && truthtranceAdvancedShipmentPreparationClean(state));
|
||||
});
|
||||
assert.equal(truthtranceAdvancedShipmentPreparationPlayer(game, 'emperor').spice, 14);
|
||||
assert.equal(truthtranceAdvancedShipmentPreparationPlayer(game, asker).spice, 6);
|
||||
assert.ok(truthtranceAdvancedShipmentPreparationPlayer(game, asker).hand.some(card => card.id === auctionCard));
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === target && truthtranceAdvancedShipmentPreparationClean(state));
|
||||
shipment = transition(() => {
|
||||
act(target, { type: 'ship', territory: 'hagga_basin', sector: 13, amount: 2, elite: 2, allyPayment: 0 });
|
||||
reach(truthtranceAdvancedShipmentPreparationClean);
|
||||
});
|
||||
assert.equal(truthtranceAdvancedShipmentPreparationPlayer(shipment.before, target).spice - truthtranceAdvancedShipmentPreparationPlayer(game, target).spice, 4);
|
||||
assert.equal(truthtranceAdvancedShipmentPreparationPlayer(game, target).spice, 10);
|
||||
assert.equal(truthtranceAdvancedShipmentPreparationPlayer(game, target).elites?.forces['hagga_basin:13'], 2);
|
||||
reach(state => state.turn === 2 && state.phase === 0 && !state.response && !state.phaseOpening && state.stormPending === 1);
|
||||
beforeStormMovement = structuredClone(game); originalStormSource = structuredClone(game.stormMovementSource);
|
||||
stormLoss = transition(() => { reach(state => state.turn === 2 && state.phase === 1); });
|
||||
assert.equal(game.storm, 13); assert.equal(truthtranceAdvancedShipmentPreparationPlayer(game, target).elites?.tanks, 2);
|
||||
reach(state => state.turn === 2 && state.phase === 4 && truthtranceAdvancedShipmentPreparationClean(state));
|
||||
const p = truthtranceAdvancedShipmentPreparationPlayer(game, target);
|
||||
assert.equal(p.spice, 10); assert.equal(p.reserves, 18); assert.equal(p.tanks, 2);
|
||||
assert.equal(p.elites?.reserves, 3); assert.equal(p.elites?.tanks, 2); assert.equal(p.elites?.revived, 0);
|
||||
assert.ok(p.hand.some(card => card.id === printed.id));
|
||||
} else {
|
||||
reach(state => state.turn === 1 && state.phase === 2 && truthtranceAdvancedShipmentPreparationClean(state));
|
||||
const p = truthtranceAdvancedShipmentPreparationPlayer(game, target); assert.ok(advisorSetup);
|
||||
assert.equal(p.spice, 5); assert.equal(p.reserves, 19); assert.equal(p.tanks, 0);
|
||||
assert.equal(p.forces['arrakeen:10'], 1); assert.equal(isAdvisor(p, 'arrakeen'), true);
|
||||
assert.ok(!p.hand.some(card => card.effect === 'karama'));
|
||||
}
|
||||
const source = structuredClone(game), minimum = kind === 'bgCharity' ? 7 : 19;
|
||||
const question: TruthQuestion = { kind: 'shipment', target, territory: 'carthag', minimum };
|
||||
const declarationStep: TruthtranceAdvancedShipmentPreparationStep = { actor: asker, action: { type: 'card', card: truth.id } };
|
||||
const askStep: TruthtranceAdvancedShipmentPreparationStep = { actor: asker, action: { type: 'truthAsk', question } };
|
||||
const charityAction: Action = { type: 'charity' }, revivalAction: Action = { type: 'revive', amount: 1, elite: 1, freeElite: 1 };
|
||||
const rateAction: Action | null = kind === 'elite' ? { type: 'card', card: printed.id, mode: 'shipment', target } : null;
|
||||
const shipmentAction: Action = { type: 'ship', territory: 'carthag', sector: 11, amount: minimum,
|
||||
elite: kind === 'elite' ? 4 : 0, allyPayment: 0 };
|
||||
assert.ok(truthtranceAdvancedShipmentPreparationPlayer(game, asker).hand.some(card => card.id === truth.id));
|
||||
assert.deepEqual(truthtranceAdvancedShipmentPreparationInventory(game), originalInventory);
|
||||
return { game, source, beforeQuestion: source, advanced: true as const, kind, asker, target, emperor: 'emperor', guild: kind === 'elite' ? 'guild' : null,
|
||||
card: truth.id, truth: truth.id, truthtrance: truth.id, karama: printed.id,
|
||||
targetKarama: kind === 'elite' ? printed.id : null, counterHolder: kind === 'bgCharity' ? 'emperor' : null,
|
||||
question, minimum, impossibleMinimum: minimum + 1, territory: 'carthag', sector: 11, location: 'carthag:11',
|
||||
declarationStep, askStep, charityAction, revivalAction, rateAction, shipmentAction,
|
||||
initial, rules, lobby, setup, beforeStartingDeal, afterSetup, advisorSetup,
|
||||
deal: selected.map(card => card.id), dealOrder, originalInventory, originalCards: originalInventory, originalSpice, spiceOrder,
|
||||
firstStorm, beforeBid, auctionCard, purchase, shipment, beforeStormMovement, originalStormSource, stormLoss,
|
||||
balances: Object.fromEntries(game.players.map(p => [p.id, p.spice])),
|
||||
actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Actual public classic lobby rules advanced:true, real Ready and start advancedPreview:true, original base33 and no modules. Only original unopened starting/auction deck positions, safe original Spice positions and native scalar lotteries are selected. No played hand, wallet, phase, force, Tank, elite or promise is installed.',
|
||||
kind === 'bgCharity'
|
||||
? 'Original A/BG/E deal gives A Truthtrance, BG an ordinary card and Emperor printed Karama. BG really places its starting advisor beside native A10 Arrakeen, retaining five spice and nineteen reserves. Stop at original first Charity, before wealthy Advanced BG actually claims two. The seven-force city question needs that real owned preparation; no Karama is held by its target. The independently held original Emperor card can actually cancel that Charity.'
|
||||
: 'Original A/E/H/G deal gives A Truthtrance, E printed Karama, H its two ordinary cards and G an ordinary card. An actual A bid4 buys the selected original ordinary first auction lot and pays E four through native seller income. E then really pays four for two Sardaukar at Hagga13 after first dials11+0→12. Original next StormCard1 reaches13 and kills both. Native turn2 phases/auction passes reach Revival with E10 cash, eighteen reserves and two actual elite-only Tanks. One free elite return plus the held original Karama can fund nineteen city counters; the one-elite limit prevents a twentieth.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type TruthtranceAdvancedShipmentPreparationFixture = ReturnType<typeof createTruthtranceAdvancedShipmentPreparationFixture>;
|
||||
|
||||
export function askTruthtranceAdvancedShipmentPreparation(game: Game, fixture: TruthtranceAdvancedShipmentPreparationFixture,
|
||||
minimum = fixture.minimum, actions: TruthtranceAdvancedShipmentPreparationStep[] = []): Game {
|
||||
game = actTruthtranceAdvancedShipmentPreparation(game, fixture.asker, fixture.declarationStep.action, actions);
|
||||
game = advanceTruthtranceAdvancedShipmentPreparation(game, state => state.truthtrance?.stage === 'ask', actions);
|
||||
return actTruthtranceAdvancedShipmentPreparation(game, fixture.asker,
|
||||
{ type: 'truthAsk', question: { ...fixture.question, minimum } }, actions);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import type { TruthQuestion } from '../game/truthtrance';
|
||||
import {
|
||||
actFutureShipment, advanceFutureShipment, nextFutureShipmentStep, futureShipmentClean, futureShipmentPlayer,
|
||||
type FutureShipmentStep,
|
||||
} from './fixture-truthtrance-future-shipment';
|
||||
|
||||
export type TruthtranceEarlyAdvancedShipmentPhase = 'storm' | 'charity' | 'bidding';
|
||||
export type TruthtranceEarlyAdvancedShipmentStep = FutureShipmentStep;
|
||||
export const actTruthtranceEarlyAdvancedShipment = actFutureShipment;
|
||||
export const nextTruthtranceEarlyAdvancedShipmentStep = nextFutureShipmentStep;
|
||||
export const nextTruthtranceEarlyAdvancedShipment = nextFutureShipmentStep;
|
||||
export const advanceTruthtranceEarlyAdvancedShipment = advanceFutureShipment;
|
||||
export const truthtranceEarlyAdvancedShipmentPlayer = futureShipmentPlayer;
|
||||
export const truthtranceEarlyAdvancedShipmentClean = futureShipmentClean;
|
||||
export function truthtranceEarlyAdvancedShipmentInventory(game: Game) {
|
||||
const ids = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
|
||||
.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'Every original base33 Treachery Card retains one physical slot'); return ids;
|
||||
}
|
||||
export const truthtranceEarlyAdvancedShipmentPhysical = (game: Game) => ({
|
||||
turn: game.turn, phase: game.phase, storm: game.storm, order: game.order, active: game.active,
|
||||
ready: game.ready, movementRemaining: game.movementRemaining,
|
||||
players: game.players.map(player => ({ id: player.id, spice: player.spice, forces: player.forces,
|
||||
elites: player.elites, reserves: player.reserves, tanks: player.tanks, shipped: player.shipped, moved: player.moved })),
|
||||
});
|
||||
|
||||
/** Actual public Advanced classic start and original five-card deal. Only
|
||||
* unopened starting/first-auction/dual-Spice positions and scalar lotteries
|
||||
* are selected. Stop before Truthtrance; no played state is installed. */
|
||||
export function createTruthtranceEarlyAdvancedShipmentFixture(options: {
|
||||
phase?: TruthtranceEarlyAdvancedShipmentPhase;
|
||||
} = {}) {
|
||||
const phase = options.phase ?? 'charity', phaseNumber = phase === 'storm' ? 0 : phase === 'charity' ? 2 : 3;
|
||||
const asker = 'atreides', target = 'emperor', counterHolder = 'harkonnen', guild = 'guild';
|
||||
let game = createGame('TRUTHEARLYADVANCEDSHIPMENT', newPlayer(asker, 'Atreides', 'atreides'), true);
|
||||
for (const faction of [target, counterHolder, guild] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: TruthtranceEarlyAdvancedShipmentStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actTruthtranceEarlyAdvancedShipment(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceTruthtranceEarlyAdvancedShipment(game, until, actions); };
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, { type: 'start', advancedPreview: true });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.deepEqual(game.expansions, []);
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
const originalInventory = truthtranceEarlyAdvancedShipmentInventory(game); assert.equal(originalInventory.length, 33);
|
||||
const truth = game.deck.find(card => card.effect === 'truthtrance'), karama = game.deck.find(card => card.effect === 'karama');
|
||||
const baliset = game.deck.find(card => card.name === 'Baliset'), shield = game.deck.find(card => card.kind === 'shield'),
|
||||
snooper = game.deck.find(card => card.kind === 'snooper');
|
||||
assert.ok(truth && karama && baliset && shield && snooper);
|
||||
const deal = [truth.id, baliset.id, karama.id, shield.id, snooper.id];
|
||||
const dealOrder = deal.map((id, position) => {
|
||||
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: id };
|
||||
});
|
||||
// A genuinely bought ordinary weapon cannot introduce a second Karama
|
||||
// coupon into the respondent's later funding witness.
|
||||
const ordinaryLot = game.deck.find(card => !deal.includes(card.id) && card.kind === 'projectile' && !card.effect);
|
||||
assert.ok(ordinaryLot);
|
||||
const auctionFrom = game.deck.findIndex(card => card.id === ordinaryLot.id); assert.ok(auctionFrom >= deal.length);
|
||||
[game.deck[deal.length], game.deck[auctionFrom]] = [game.deck[auctionFrom], game.deck[deal.length]];
|
||||
const auctionOrder = { position: deal.length, from: auctionFrom, card: ordinaryLot.id };
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game), afterSetupActionCount = actions.length;
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(), beforeSpiceOrder = structuredClone(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)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
reach(state => state.turn === 1 && state.phase === phaseNumber && futureShipmentClean(state) &&
|
||||
(phaseNumber !== 3 || !!state.auction));
|
||||
const respondent = futureShipmentPlayer(game, target);
|
||||
assert.equal(respondent.spice, 10); assert.equal(respondent.reserves, 20); assert.equal(respondent.tanks, 0);
|
||||
assert.deepEqual(respondent.forces, {}); assert.equal(respondent.shipped, false); assert.equal(respondent.moved, 0);
|
||||
assert.ok(futureShipmentPlayer(game, asker).hand.some(card => card.id === truth.id));
|
||||
assert.ok(futureShipmentPlayer(game, counterHolder).hand.some(card => card.id === karama.id));
|
||||
assert.ok(respondent.hand.some(card => card.id === baliset.id));
|
||||
assert.equal(game.truthtrance ?? null, null); assert.equal(game.shipmentPromises, undefined);
|
||||
assert.deepEqual(truthtranceEarlyAdvancedShipmentInventory(game), originalInventory);
|
||||
const question: TruthQuestion = { kind: 'shipment', target, territory: 'carthag', minimum: 8 };
|
||||
const declarationStep: TruthtranceEarlyAdvancedShipmentStep = { actor: asker, action: { type: 'card', card: truth.id } };
|
||||
const askStep: TruthtranceEarlyAdvancedShipmentStep = { actor: asker, action: { type: 'truthAsk', question } };
|
||||
const shipment: Action = { type: 'ship', territory: 'carthag', sector: 11, amount: 8, elite: 0, allyPayment: 0 };
|
||||
return { game, source: structuredClone(game), phase, phaseNumber, advanced: true as const,
|
||||
asker, target, counterHolder, guild, card: truth.id, truth: truth.id, karama: karama.id, baliset: baliset.id,
|
||||
question, declarationStep, askStep, territory: 'carthag', sector: 11, location: 'carthag:11', minimum: 8,
|
||||
shipment, bid: { type: 'bid', amount: 2, allyPayment: 0 } satisfies Action,
|
||||
bigBid: { type: 'bid', amount: 3, allyPayment: 0 } satisfies Action,
|
||||
bigBribe: { type: 'bribe', target: asker, amount: 3 } satisfies Action,
|
||||
purchaseCost: 2, shipmentCost: 8, purchaseCard: ordinaryLot.id,
|
||||
initial, lobby, setup, beforeStartingDeal, afterSetup, afterSetupActionCount,
|
||||
beforeSpiceOrder, spiceOrder, deal, dealOrder, auctionOrder, originalSpice,
|
||||
originalInventory, originalTreachery: originalInventory,
|
||||
physical: structuredClone(truthtranceEarlyAdvancedShipmentPhysical(game)),
|
||||
originalPhysical: structuredClone(truthtranceEarlyAdvancedShipmentPhysical(game)),
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Actual public Advanced A/E/H/G ready/start with advancedPreview:true, original base33 and no expansion or optional module. Every private Traitor and original setup control is an actual native action.',
|
||||
'Select five original unopened starting-card positions: A Truthtrance, E Baliset, H printed Karama plus Shield, G Snooper. Select one still-unopened ordinary first-auction weapon and two original ordinary Spice positions; physical inventory remains all33 originals.',
|
||||
phase === 'storm'
|
||||
? 'Stop at clean original first Storm before its actual secret dials. The proposed commitment uses the current board, not an assumed future Storm outcome.'
|
||||
: phase === 'charity'
|
||||
? 'Actually complete first Storm dials and both Advanced Spice blows, then stop at clean original Charity before Bidding.'
|
||||
: 'Actually complete first Storm, both Advanced Spice blows and Charity plus native auction-peek responses, then stop at the clean first ordinary lot before any bid.',
|
||||
'E retains its actual printed ten spice, twenty native reserves, no board forces and unused shipment. Truthtrance declaration and eight-force Carthag question are returned unplayed. No played hand, wallet, army, clock, promise or response is installed.',
|
||||
'The optional continuation genuinely buys the original weapon for two, uses actual Guild TAKE or WAIT/allowance controls, and ships at E actual native turn. It resolves original Guild interception/income and ends only E original turn, without a fabricated early shipment.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type TruthtranceEarlyAdvancedShipmentFixture = ReturnType<typeof createTruthtranceEarlyAdvancedShipmentFixture>;
|
||||
|
||||
/** Native paid purchase, original Guild timing, and exact reserve delivery.
|
||||
* The caller has already asked/answered; this helper creates no promise. */
|
||||
export function finishTruthtranceEarlyAdvancedShipment(f: TruthtranceEarlyAdvancedShipmentFixture, source: Game,
|
||||
options: { guildTake?: boolean; amount?: number } = {}) {
|
||||
const amount = options.amount ?? 8, take = options.guildTake ?? false;
|
||||
let game = source;
|
||||
const actions: TruthtranceEarlyAdvancedShipmentStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actTruthtranceEarlyAdvancedShipment(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceTruthtranceEarlyAdvancedShipment(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), start = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
assert.equal(game.truthtrance ?? null, null);
|
||||
reach(state => state.phase === 3 && futureShipmentClean(state) && state.auction?.active === f.target);
|
||||
assert.equal(game.auction?.index, 0); assert.equal(game.auction?.cards[0].id, f.purchaseCard);
|
||||
const purchase = transition(() => {
|
||||
act(f.target, f.bid);
|
||||
reach(state => state.phase === 4 && futureShipmentClean(state));
|
||||
});
|
||||
assert.equal(futureShipmentPlayer(purchase.before, f.target).spice, 10);
|
||||
assert.equal(futureShipmentPlayer(game, f.target).spice, 8);
|
||||
assert.ok(futureShipmentPlayer(game, f.target).hand.some(card => card.id === f.purchaseCard));
|
||||
reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'guildTiming');
|
||||
const timing: ReturnType<typeof transition>[] = [];
|
||||
const guildTurn: ReturnType<typeof transition>[] = [];
|
||||
const chooseTiming = (choice: boolean) => {
|
||||
assert.equal(game.decision?.kind, 'guildTiming');
|
||||
timing.push(transition(() => {
|
||||
act(f.guild, { type: 'decision', take: choice });
|
||||
reach(state => futureShipmentClean(state));
|
||||
}));
|
||||
};
|
||||
chooseTiming(take);
|
||||
if (take) {
|
||||
assert.equal(game.active, f.guild);
|
||||
guildTurn.push(transition(() => { act(f.guild, { type: 'endMovement' }); }));
|
||||
}
|
||||
reach(state => state.phase === 5 && futureShipmentClean(state) && state.active === f.asker);
|
||||
const askerEnd = transition(() => { act(f.asker, { type: 'endMovement' }); });
|
||||
if (!take) {
|
||||
reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'guildTiming');
|
||||
chooseTiming(false);
|
||||
}
|
||||
reach(state => state.phase === 5 && futureShipmentClean(state) && state.active === f.target);
|
||||
const beforeShipment = structuredClone(game), shipmentAction: Action = { ...f.shipment, amount };
|
||||
const start = actions.length;
|
||||
act(f.target, shipmentAction);
|
||||
const declared = structuredClone(game), originalControls: Game[] = [];
|
||||
for (let count = 0; !futureShipmentClean(game) || !!game.pendingShipment; count++) {
|
||||
assert.ok(count < 100, 'Resolve only the original shipment permission/payment/income');
|
||||
originalControls.push(structuredClone(game));
|
||||
const step = nextFutureShipmentStep(game); assert.ok(step);
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
const delivered = structuredClone(game), shipment = { before: beforeShipment, action: shipmentAction,
|
||||
declared, controls: originalControls, steps: structuredClone(actions.slice(start)), after: delivered };
|
||||
assert.equal(futureShipmentPlayer(game, f.target).forces[f.location], amount);
|
||||
assert.equal(futureShipmentPlayer(game, f.target).reserves, 20 - amount);
|
||||
assert.equal(futureShipmentPlayer(game, f.target).spice, 8 - amount);
|
||||
const end = transition(() => { act(f.target, { type: 'endMovement' }); });
|
||||
assert.deepEqual(truthtranceEarlyAdvancedShipmentInventory(game), f.originalInventory);
|
||||
return { game, purchase, timing, guildTurn, askerEnd, beforeShipment, shipmentAction, shipment, declared,
|
||||
delivered, end, actions, amount, guildTake: take };
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import {
|
||||
createTruthtranceAdvancedShipmentPreparationFixture as fixture,
|
||||
askTruthtranceAdvancedShipmentPreparation as ask, actTruthtranceAdvancedShipmentPreparation as act,
|
||||
advanceTruthtranceAdvancedShipmentPreparation as advance, truthtranceAdvancedShipmentPreparationClean as clean,
|
||||
truthtranceAdvancedShipmentPreparationPlayer as player, truthtranceAdvancedShipmentPreparationInventory as inventory,
|
||||
type TruthtranceAdvancedShipmentPreparationFixture,
|
||||
} from './fixture-truthtrance-advanced-shipment-preparation';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function nativeSource(f: TruthtranceAdvancedShipmentPreparationFixture) {
|
||||
assert.equal(f.initial.advanced, false); assert.notEqual(f.initial.status, 'playing');
|
||||
assert.deepEqual(f.rules.steps, [{ actor: f.asker, action: { type: 'rules', advanced: true } }]);
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.advancedPreview, true);
|
||||
assert.deepEqual(f.setup.expansions, []); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
|
||||
assert.equal(player(f.afterSetup, f.asker).hand[0].id, f.truthtrance);
|
||||
assert.ok(player(f.afterSetup, f.emperor).hand.some(card => card.id === f.karama && card.effect === 'karama'));
|
||||
assert.deepEqual(f.firstStorm.steps.filter(step => step.action.type === 'stormDial').map(step => step.action.amount), [11, 0]);
|
||||
assert.equal(f.firstStorm.after.storm, 12); assert.equal(f.game.advanced, true);
|
||||
assert.equal(f.game.truthtrance ?? null, null); assert.equal(f.game.shipmentPromises?.length ?? 0, 0);
|
||||
if (f.kind === 'bgCharity') {
|
||||
assert.ok(f.advisorSetup); assert.deepEqual(f.advisorSetup.steps[0].action, { type: 'advisorSetup', territory: 'arrakeen', sector: 10 });
|
||||
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 2); assert.equal(f.counterHolder, f.emperor);
|
||||
assert.equal(player(f.game, f.target).spice, 5); assert.equal(player(f.game, f.target).reserves, 19);
|
||||
assert.equal(player(f.game, f.target).forces['arrakeen:10'], 1); assert.equal(isAdvisor(player(f.game, f.target), 'arrakeen'), true);
|
||||
assert.equal(player(f.game, f.asker).forces['arrakeen:10'], 10); assert.equal(f.targetKarama, null);
|
||||
assert.equal(player(f.game, f.target).hand.some(card => card.effect === 'karama'), false);
|
||||
} else {
|
||||
assert.ok(f.purchase && f.beforeBid && f.shipment && f.beforeStormMovement && f.stormLoss && f.guild);
|
||||
assert.deepEqual(f.purchase.steps[0], { actor: f.asker, action: { type: 'bid', amount: 4 } });
|
||||
assert.equal(player(f.purchase.before, f.emperor).spice, 10); assert.equal(player(f.purchase.after, f.emperor).spice, 14);
|
||||
assert.equal(player(f.purchase.after, f.asker).spice, 6);
|
||||
assert.ok(player(f.purchase.after, f.asker).hand.some(card => card.id === f.auctionCard));
|
||||
assert.deepEqual(f.shipment.steps[0], { actor: f.target,
|
||||
action: { type: 'ship', territory: 'hagga_basin', sector: 13, amount: 2, elite: 2, allyPayment: 0 } });
|
||||
assert.equal(player(f.shipment.before, f.target).spice - player(f.shipment.after, f.target).spice, 4);
|
||||
assert.equal(player(f.shipment.after, f.target).elites?.forces['hagga_basin:13'], 2);
|
||||
assert.equal(f.beforeStormMovement.storm, 12); assert.equal(f.beforeStormMovement.stormPending, 1);
|
||||
assert.equal(f.stormLoss.after.storm, 13); assert.equal(player(f.stormLoss.after, f.target).elites?.tanks, 2);
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 4);
|
||||
const p = player(f.game, f.target); assert.equal(p.spice, 10); assert.equal(p.reserves, 18); assert.equal(p.tanks, 2);
|
||||
assert.equal(p.elites?.reserves, 3); assert.equal(p.elites?.tanks, 2); assert.equal(p.elites?.revived, 0);
|
||||
assert.equal(p.revived, 0); assert.equal(p.freeForcesRevived, 0); assert.equal(f.targetKarama, f.karama);
|
||||
}
|
||||
assert.equal(f.originalInventory.length, 33); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function bound(f: TruthtranceAdvancedShipmentPreparationFixture, game = structuredClone(f.game)) {
|
||||
game = ask(game, f);
|
||||
assert.ok(viewGame(game, f.asker).truthShipmentTargets.includes(f.target));
|
||||
assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['yes', 'no']);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.equal(game.truthtrance ?? null, null); assert.equal(game.shipmentPromises?.length, 1);
|
||||
assert.equal(game.shipmentPromises![0].answer, true); assert.equal(game.shipmentPromises![0].released, undefined);
|
||||
return game;
|
||||
}
|
||||
function prepare(game: Game, f: TruthtranceAdvancedShipmentPreparationFixture, action?: Action) {
|
||||
const guide = viewGame(game, f.target).shipmentPreparation; assert.ok(guide?.actions.length);
|
||||
assert.equal(guide.actions[0].type, f.kind === 'bgCharity' ? 'charity' : 'revive');
|
||||
reject(game, f.target, { type: 'ready' });
|
||||
if (f.kind === 'elite') {
|
||||
assert.equal(guide.actions[0].amount, 1);
|
||||
reject(game, f.target, { type: 'revive', amount: 2, elite: 2, freeElite: 1 });
|
||||
}
|
||||
game = act(game, f.target, action ?? guide.actions[0]);
|
||||
if (f.kind === 'bgCharity') {
|
||||
assert.equal(game.response?.kind, 'bgCharity'); assert.equal(player(game, f.target).spice, 5);
|
||||
}
|
||||
game = advance(game, clean);
|
||||
assert.equal(viewGame(game, f.target).shipmentPreparation, null);
|
||||
assert.equal(game.shipmentPromises![0].released, undefined);
|
||||
if (f.kind === 'bgCharity') assert.equal(player(game, f.target).spice, 7);
|
||||
else {
|
||||
const p = player(game, f.target); assert.equal(p.spice, 10); assert.equal(p.reserves, 19); assert.equal(p.tanks, 1);
|
||||
assert.equal(p.elites?.reserves, 4); assert.equal(p.elites?.tanks, 1); assert.equal(p.elites?.revived, 1);
|
||||
assert.equal(p.revived, 1); assert.equal(p.freeForcesRevived, 1);
|
||||
reject(game, f.target, { type: 'revive', amount: 1, elite: 1, freeElite: 0 });
|
||||
}
|
||||
return game;
|
||||
}
|
||||
function ship(game: Game, f: TruthtranceAdvancedShipmentPreparationFixture) {
|
||||
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
|
||||
assert.equal(game.shipmentPromises![0].released, undefined); assert.equal(game.shipmentPromises![0].fulfilled, undefined);
|
||||
const before = structuredClone(game), guildBefore = f.guild ? player(game, f.guild).spice : null;
|
||||
if (f.kind === 'elite') {
|
||||
const rate = viewGame(game, f.target).shipmentCompletion?.actions[0]; assert.ok(rate);
|
||||
assert.equal(rate.type, 'card'); assert.equal(rate.card, f.karama); assert.equal(rate.mode, 'shipment');
|
||||
assert.ok(f.rateAction); game = act(game, f.target, f.rateAction);
|
||||
assert.equal(player(game, f.target).spice, 10); assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
}
|
||||
const completion = viewGame(game, f.target).shipmentCompletion; assert.ok(completion);
|
||||
const shipment = completion.actions.find(action => action.type === 'ship'); assert.ok(shipment);
|
||||
assert.equal(shipment.amount, f.minimum); assert.equal(shipment.territory, f.territory);
|
||||
if (f.kind === 'elite') assert.equal(shipment.elite, 4);
|
||||
reject(game, f.target, { ...f.shipmentAction, amount: f.minimum - 1 });
|
||||
game = act(game, f.target, f.shipmentAction); game = advance(game, clean);
|
||||
const p = player(game, f.target); assert.equal(p.spice, 0); assert.equal(p.forces[f.location], f.minimum);
|
||||
assert.equal(player(before, f.target).spice - p.spice, f.kind === 'elite' ? 10 : 7);
|
||||
assert.equal(p.shipped, true); assert.equal(p.moved, 0); assert.equal(game.shipmentPromises![0].fulfilled, true);
|
||||
assert.equal(game.shipmentPromises![0].released, undefined);
|
||||
assert.equal(p.reserves, player(before, f.target).reserves - f.minimum);
|
||||
if (f.kind === 'elite') {
|
||||
assert.equal(p.elites?.forces[f.location], 4); assert.equal(p.elites?.reserves, 0); assert.equal(p.elites?.tanks, 1);
|
||||
assert.equal(player(game, f.guild!).spice, guildBefore, 'The real purchased rate sends its invoice to the bank');
|
||||
} else {
|
||||
assert.equal(p.forces['arrakeen:10'], 1); assert.equal(isAdvisor(p, 'arrakeen'), true); assert.equal(isAdvisor(p, 'carthag'), false);
|
||||
assert.equal(p.reserves, 12);
|
||||
}
|
||||
assert.deepEqual(inventory(game), f.originalInventory); reject(game, f.target, f.shipmentAction); return game;
|
||||
}
|
||||
|
||||
void test('native Advanced wealthy BG Charity prepares and pays the real seven-counter city commitment', () => {
|
||||
const f = fixture({ kind: 'bgCharity' }); nativeSource(f); ship(prepare(bound(f), f), f);
|
||||
});
|
||||
|
||||
void test('native Advanced original Emperor Karama prevents wealthy BG Charity and releases the otherwise impossible shipment once', () => {
|
||||
const f = fixture({ kind: 'bgCharity' }); nativeSource(f);
|
||||
let game = bound(f); reject(game, f.target, { type: 'ready' });
|
||||
game = act(game, f.target, f.charityAction); assert.equal(game.response?.kind, 'bgCharity');
|
||||
assert.equal(player(game, f.target).spice, 5); assert.ok(f.counterHolder);
|
||||
game = act(game, f.counterHolder, { type: 'card', card: f.karama, mode: 'cancel' }); game = advance(game, clean);
|
||||
assert.equal(player(game, f.target).spice, 5); assert.equal(game.shipmentPromises![0].released, true);
|
||||
assert.equal(game.shipmentPromises![0].fulfilled, undefined); assert.equal(viewGame(game, f.target).shipmentPreparation, null);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
const released = structuredClone(game.shipmentPromises); game = act(game, f.target, { type: 'ready' });
|
||||
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
|
||||
reject(game, f.target, f.shipmentAction);
|
||||
game = act(game, f.target, { ...f.shipmentAction, amount: 5 }); game = advance(game, clean);
|
||||
assert.equal(player(game, f.target).forces[f.location], 5); assert.equal(player(game, f.target).spice, 0);
|
||||
assert.deepEqual(game.shipmentPromises, released); assert.deepEqual(inventory(game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('native Advanced wealthy BG source cannot promise eight without an owned funding source', () => {
|
||||
const f = fixture({ kind: 'bgCharity' }), game = ask(structuredClone(f.game), f, 8);
|
||||
assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['no']);
|
||||
reject(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.doesNotThrow(() => act(game, f.target, { type: 'truthAnswer', answer: 'no' }));
|
||||
});
|
||||
|
||||
void test('native Advanced elite-only Storm losses prepare exactly one Sardaukar then original Karama pays nineteen city counters', () => {
|
||||
const f = fixture({ kind: 'elite' }); nativeSource(f); ship(prepare(bound(f), f), f);
|
||||
});
|
||||
|
||||
void test('native Advanced elite-only source cannot promise twenty or revive its second Sardaukar this turn', () => {
|
||||
const f = fixture({ kind: 'elite' }); nativeSource(f);
|
||||
const game = ask(structuredClone(f.game), f, 20); assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['no']);
|
||||
reject(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
reject(f.game, f.target, { type: 'revive', amount: 2, elite: 2, freeElite: 1 });
|
||||
const prepared = prepare(bound(f), f); assert.equal(player(prepared, f.target).elites?.revived, 1);
|
||||
reject(prepared, f.target, { type: 'revive', amount: 1, elite: 1 });
|
||||
});
|
||||
|
||||
void test('a genuine earlier Advanced Ready is reopened by either required owned preparation answer', () => {
|
||||
for (const kind of ['bgCharity', 'elite'] as const) {
|
||||
const f = fixture({ kind }); let game = act(structuredClone(f.game), f.target, { type: 'ready' });
|
||||
assert.equal(game.ready.includes(f.target), true); game = bound(f, game);
|
||||
assert.equal(game.ready.includes(f.target), false); assert.ok(viewGame(game, f.target).shipmentPreparation?.actions.length);
|
||||
prepare(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Advanced pending shipment questions reject foreign answers and premature shipment or phase skipping', () => {
|
||||
for (const kind of ['bgCharity', 'elite'] as const) {
|
||||
const f = fixture({ kind }), game = ask(structuredClone(f.game), f);
|
||||
reject(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); reject(game, f.target, f.shipmentAction);
|
||||
reject(game, f.target, { type: 'ready' }); reject(game, f.asker, f.askStep.action);
|
||||
const done = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
reject(done, f.target, { type: 'truthAnswer', answer: 'yes' }); assert.deepEqual(inventory(done), f.originalInventory);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles choose legal original Advanced answers and execute the required owned preparation', () => {
|
||||
for (const kind of ['bgCharity', 'elite'] as const) {
|
||||
const f = fixture({ kind }), asked = ask(structuredClone(f.game), f), promised = bound(f);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const answering = viewGame(asked, f.target); answering.players.find(p => p.id === f.target)!.bot = difficulty;
|
||||
const answer = botActions(answering)[0]; assert.ok(answer); assert.equal(answer.type, 'truthAnswer');
|
||||
assert.ok(answer.answer === 'yes' || answer.answer === 'no'); assert.doesNotThrow(() => act(asked, f.target, answer));
|
||||
const own = viewGame(promised, f.target); own.players.find(p => p.id === f.target)!.bot = difficulty;
|
||||
const proposed = botActions(own)[0]; assert.ok(proposed);
|
||||
assert.equal(proposed.type, kind === 'bgCharity' ? 'charity' : 'revive');
|
||||
ship(prepare(structuredClone(promised), f, proposed), f);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
createTruthtranceEarlyAdvancedShipmentFixture as fixture, actTruthtranceEarlyAdvancedShipment as act,
|
||||
advanceTruthtranceEarlyAdvancedShipment as advance, finishTruthtranceEarlyAdvancedShipment as finish,
|
||||
truthtranceEarlyAdvancedShipmentClean as clean, truthtranceEarlyAdvancedShipmentPlayer as player,
|
||||
truthtranceEarlyAdvancedShipmentInventory as inventory, truthtranceEarlyAdvancedShipmentPhysical as physical,
|
||||
type TruthtranceEarlyAdvancedShipmentFixture,
|
||||
} from './fixture-truthtrance-early-advanced-shipment';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function nativeSource(f: TruthtranceEarlyAdvancedShipmentFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.deepEqual(f.setup.expansions, []);
|
||||
assert.ok(!f.setup.homeworlds && !f.setup.nexusCards && !f.setup.leaderSkills && !f.setup.techTokens &&
|
||||
!f.setup.strongholdCards && !f.setup.discoveryEnabled && !f.setup.ecazTreachery);
|
||||
assert.ok(f.actions.some(step => step.actor === f.lobby.host && step.action.type === 'start' && step.action.advancedPreview === true));
|
||||
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
|
||||
assert.deepEqual(f.afterSetup.players.flatMap(player => player.hand.map(card => card.id)), f.deal);
|
||||
assert.ok(player(f.afterSetup, f.asker).hand.some(card => card.id === f.card && card.effect === 'truthtrance'));
|
||||
assert.ok(player(f.afterSetup, f.counterHolder).hand.some(card => card.id === f.karama && card.effect === 'karama'));
|
||||
assert.ok(player(f.afterSetup, f.target).hand.some(card => card.id === f.baliset && card.name === 'Baliset'));
|
||||
assert.equal(player(f.game, f.target).spice, 10); assert.equal(player(f.game, f.target).reserves, 20);
|
||||
assert.deepEqual(player(f.game, f.target).forces, {}); assert.equal(player(f.game, f.target).tanks, 0);
|
||||
assert.equal(player(f.game, f.target).shipped, false); assert.equal(player(f.game, f.target).moved, 0);
|
||||
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, f.phaseNumber); assert.ok(clean(f.game));
|
||||
assert.equal(f.game.truthtrance ?? null, null); assert.equal(f.game.shipmentPromises, undefined);
|
||||
assert.equal(f.originalInventory.length, 33); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
assert.deepEqual(physical(f.game), f.originalPhysical); assert.equal(f.sourceActionCount, f.actions.length);
|
||||
if (f.phase === 'storm') assert.deepEqual(f.game.stormDials, {});
|
||||
else {
|
||||
assert.equal(f.actions.filter(step => step.action.type === 'stormDial').length, 2);
|
||||
assert.equal(f.game.spiceSequence ?? null, null);
|
||||
assert.ok(f.game.spiceDiscard[0].some(card => 'territory' in card));
|
||||
assert.ok(f.game.spiceDiscard[1].some(card => 'territory' in card));
|
||||
}
|
||||
}
|
||||
function asked(f: TruthtranceEarlyAdvancedShipmentFixture) {
|
||||
const actions: { actor: string; action: Action }[] = [];
|
||||
let game = act(structuredClone(f.game), f.declarationStep.actor, f.declarationStep.action, actions);
|
||||
const declared = structuredClone(game);
|
||||
game = advance(game, state => state.truthtrance?.stage === 'ask', actions);
|
||||
assert.ok(viewGame(game, f.asker).truthShipmentTargets.includes(f.target));
|
||||
const beforeQuestion = structuredClone(game);
|
||||
game = act(game, f.askStep.actor, f.askStep.action, actions);
|
||||
assert.equal(game.truthtrance?.stage, 'answer');
|
||||
assert.ok(viewGame(game, f.target).truthShipmentAnswers?.includes('yes'));
|
||||
assert.ok(viewGame(game, f.target).truthShipmentAnswers?.includes('no'));
|
||||
return { game, declared, beforeQuestion, actions };
|
||||
}
|
||||
function bound(f: TruthtranceEarlyAdvancedShipmentFixture, answer: 'yes' | 'no' = 'yes') {
|
||||
const question = asked(f), game = act(question.game, f.target, { type: 'truthAnswer', answer }, question.actions);
|
||||
assert.equal(game.truthtrance ?? null, null); assert.equal(game.shipmentPromises?.length, 1);
|
||||
assert.equal(game.shipmentPromises![0].player, f.target);
|
||||
assert.equal(game.shipmentPromises![0].answer, answer === 'yes');
|
||||
assert.equal(game.shipmentPromises![0].released, undefined);
|
||||
assert.equal(game.shipmentPromises![0].fulfilled, undefined);
|
||||
assert.equal(game.phase, f.phaseNumber); assert.deepEqual(physical(game), f.originalPhysical);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
|
||||
for (const phase of ['storm', 'charity', 'bidding'] as const) void test(
|
||||
`public Advanced ${phase} Truthtrance binds an eight-force future shipment without granting an early action`, () => {
|
||||
const f = fixture({ phase }); nativeSource(f); const game = bound(f);
|
||||
const view = viewGame(game, f.target);
|
||||
assert.equal(view.shipmentCompletion, null); assert.equal(view.shipmentPreparation, null);
|
||||
reject(game, f.target, f.shipment); reject(game, f.target, { type: 'endMovement' });
|
||||
reject(game, f.target, f.bigBribe);
|
||||
assert.equal(game.shipmentPromises![0].released, undefined);
|
||||
});
|
||||
|
||||
for (const guildTake of [false, true]) void test(
|
||||
`public Advanced early shipment Yes retains purchase2 and native Guild ${guildTake ? 'TAKE' : 'WAIT'} through actual eight-force delivery`, () => {
|
||||
const f = fixture(); nativeSource(f); const game = bound(f);
|
||||
const completed = finish(f, game, { guildTake });
|
||||
reject(completed.purchase.before, f.target, f.bigBid);
|
||||
assert.equal(player(completed.purchase.before, f.target).spice, 10);
|
||||
assert.equal(player(completed.purchase.after, f.target).spice, 8);
|
||||
assert.ok(player(completed.purchase.after, f.target).hand.some(card => card.id === f.purchaseCard));
|
||||
assert.equal(completed.purchase.after.shipmentPromises![0].released, undefined);
|
||||
assert.ok(completed.timing.some(record => record.steps.some(step => step.actor === f.guild &&
|
||||
step.action.type === 'decision' && step.action.take === guildTake)));
|
||||
if (guildTake) {
|
||||
assert.equal(completed.guildTurn.length, 1);
|
||||
assert.deepEqual(completed.guildTurn[0].steps, [{ actor: f.guild, action: { type: 'endMovement' } }]);
|
||||
} else assert.equal(completed.guildTurn.length, 0);
|
||||
assert.equal(completed.beforeShipment.active, f.target);
|
||||
assert.equal(viewGame(completed.beforeShipment, f.target).shipmentCompletion?.actions.at(-1)?.amount, 8);
|
||||
assert.ok(completed.shipment.controls.some(control => control.decision?.kind === 'guildShipment'));
|
||||
assert.ok(completed.shipment.controls.some(control => control.response?.kind === 'guildIncome'));
|
||||
assert.equal(player(completed.delivered, f.guild).spice, player(completed.beforeShipment, f.guild).spice + 8);
|
||||
assert.equal(player(completed.delivered, f.target).spice, 0);
|
||||
assert.equal(player(completed.delivered, f.target).forces[f.location], 8);
|
||||
assert.equal(player(completed.delivered, f.target).reserves, 12);
|
||||
assert.equal(completed.delivered.shipmentPromises![0].fulfilled, true);
|
||||
assert.equal(completed.delivered.shipmentPromises![0].released, undefined);
|
||||
assert.ok(player(completed.game, f.counterHolder).hand.some(card => card.id === f.karama));
|
||||
assert.equal(completed.game.discard.filter(card => card.id === f.card).length, 1);
|
||||
reject(completed.delivered, f.target, f.shipment);
|
||||
assert.deepEqual(inventory(completed.game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('public Advanced early shipment No allows voluntary costs and a real smaller Carthag shipment', () => {
|
||||
const f = fixture({ phase: 'bidding' }); nativeSource(f); const game = bound(f, 'no');
|
||||
const voluntary = act(structuredClone(game), f.target, f.bigBribe);
|
||||
assert.equal(player(voluntary, f.target).spice, 7); assert.equal(voluntary.shipmentPromises![0].released, undefined);
|
||||
const completed = finish(f, game, { amount: 7, guildTake: true });
|
||||
reject(completed.beforeShipment, f.target, f.shipment);
|
||||
assert.equal(player(completed.delivered, f.target).forces[f.location], 7);
|
||||
assert.equal(player(completed.delivered, f.target).spice, 1);
|
||||
assert.equal(completed.delivered.shipmentPromises![0].fulfilled, true);
|
||||
assert.equal(completed.delivered.shipmentPromises![0].released, undefined);
|
||||
assert.deepEqual(inventory(completed.game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('an actual first Storm releases an early Advanced shipment only after its current-board route becomes unavailable', () => {
|
||||
const f = fixture({ phase: 'storm' }); nativeSource(f); let game = bound(f);
|
||||
const initialStorm = game.storm, distance = (f.sector - initialStorm + 18) % 18;
|
||||
assert.ok(distance > 0 && distance <= 20); assert.equal(game.stormDialers.length, 2);
|
||||
for (const [index, actor] of game.stormDialers.entries())
|
||||
game = act(game, actor, { type: 'stormDial', amount: index === 0 ? distance : 0 });
|
||||
assert.equal(game.storm, initialStorm); assert.equal(game.stormPending, distance);
|
||||
assert.equal(game.shipmentPromises![0].released, undefined, 'Recorded future distance is not a future-board promise prediction');
|
||||
game = advance(game, state => state.phase === 2 && clean(state));
|
||||
assert.equal(game.storm, f.sector); assert.equal(game.shipmentPromises![0].released, true);
|
||||
const spent = act(game, f.target, f.bigBribe);
|
||||
assert.equal(player(spent, f.target).spice, 7);
|
||||
assert.deepEqual(inventory(spent), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('the genuine early Advanced Truth question rejects foreign and underlying replies before its native answer', () => {
|
||||
const f = fixture(), question = asked(f), game = question.game;
|
||||
reject(game, f.asker, { type: 'truthAnswer', answer: 'yes' });
|
||||
reject(game, f.target, { type: 'ready' }); reject(game, f.target, f.shipment);
|
||||
reject(game, f.target, f.bigBribe); reject(game, f.asker, f.askStep.action);
|
||||
const done = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
reject(done, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
reject(done, f.asker, f.askStep.action);
|
||||
assert.deepEqual(physical(done), f.originalPhysical); assert.deepEqual(inventory(done), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('all four minimal policies participate legally in the actual early Advanced bid while retaining shipment funds', () => {
|
||||
const f = fixture(), boundGame = bound(f);
|
||||
const game = advance(boundGame, state => state.phase === 3 && clean(state) && state.auction?.active === f.target);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, f.target); view.players.find(player => player.id === f.target)!.bot = difficulty;
|
||||
const before = structuredClone(view), action = botActions(view)[0]; assert.ok(action, difficulty);
|
||||
assert.deepEqual(view, before);
|
||||
const done = act(structuredClone(game), f.target, action);
|
||||
assert.equal(done.shipmentPromises![0].released, undefined);
|
||||
assert.ok(player(done, f.target).spice >= 8);
|
||||
assert.deepEqual(inventory(done), f.originalInventory);
|
||||
}
|
||||
});
|
||||
@@ -76,7 +76,8 @@ void test('current-turn target timing distinguishes earlier phases from unused m
|
||||
assert.equal(shipmentPromiseTurnAvailable({ ...g, phase: 2 }, 'p', false), true,
|
||||
'A prior-turn shipped flag does not consume the future current-turn opportunity');
|
||||
assert.equal(shipmentPromiseModeSupported({ ...g, phase: 4 }, 'p'), true);
|
||||
assert.equal(shipmentPromiseModeSupported({ ...g, phase: 4, advanced: true }, 'p'), false);
|
||||
assert.equal(shipmentPromiseModeSupported({ ...g, phase: 4, advanced: true }, 'p'), true);
|
||||
assert.equal(shipmentPromiseModeSupported({ ...g, phase: 4, advanced: true, homeworlds: {} }, 'p'), false);
|
||||
assert.equal(shipmentPromiseModeSupported({ ...g, phase: 2, techTokens: {} }, 'p'), false);
|
||||
const mode = { ...g, advanced: true, active: 'r', expansions: ['choam'], semutaPreview: true,
|
||||
players: [{ id: 'r', faction: 'richese' }, { id: 'p', faction: 'emperor' }] };
|
||||
|
||||
Reference in new issue
Block a user