Add bids and ordinary Revival to combined turn commitments

This commit is contained in:
Quintine committed 2026-10-26 21:42:45 +11:00
1 parent 0d72a33ee5
commit 921b4fe1e3
15 files changed
+1255 -65

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { RevivalExpressionLeaf } from '../game/revival-expression-promises';
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
import {
actTruthBid, createTruthBidFixture, nextTruthBid, truthBidClean, truthBidInventory, truthBidPlayer,
type TruthBidStep,
} from './fixture-truth-bid';
import { createTruthMixedRevivalFixture, nextTruthMixedRevival } from './fixture-truth-mixed-revival';
import { createTruthRevivalEarlyFixture, reachTruthRevivalEarlyOpportunity } from './fixture-truth-revival-early';
import type { TruthTurnActionClaim } from './fixture-truth-turn-action';
export type TruthTurnPreparationLeaf = TruthTurnActionClaim | { kind: 'bid'; lot: number; minimum: number }
| RevivalExpressionLeaf;
export type TruthTurnPreparationExpression = MixedActionExpression<TruthTurnPreparationLeaf>;
export type TruthTurnPreparationStep = TruthBidStep;
export const actTruthTurnPreparation = actTruthBid;
export const truthTurnPreparationInventory = truthBidInventory;
export const truthTurnPreparationPlayer = truthBidPlayer;
export const truthTurnPreparationClean = (game: Game) => truthBidClean(game) && !game.pendingShipment && !game.pendingRevival;
export const truthTurnPreparationFact = (fact: TruthFact): TruthTurnPreparationExpression => ({ kind: 'fact', fact });
export const truthTurnPreparationBid = (lot = 1, minimum = 3): TruthTurnPreparationExpression =>
({ kind: 'action', action: { kind: 'bid', lot, minimum } });
export const truthTurnPreparationForce = (minimum = 2, counter: 'total' | 'normal' | 'elite' = 'total'):
TruthTurnPreparationExpression => ({ kind: 'action', action: { kind: 'forces', counter, minimum } });
export const truthTurnPreparationLeader = (leader: string): TruthTurnPreparationExpression =>
({ kind: 'action', action: { kind: 'leader', leader } });
export const truthTurnPreparationPayment = (recipient: string, minimum = 2): TruthTurnPreparationExpression =>
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
export const truthTurnPreparationShip = (territory = 'polar_sink', minimum = 1): TruthTurnPreparationExpression =>
({ kind: 'action', action: { kind: 'ship', territory, minimum } });
export const truthTurnPreparationGroup = (kind: 'and' | 'or', ...terms: TruthTurnPreparationExpression[]):
TruthTurnPreparationExpression => ({ kind, terms });
/** Existing genuinely dealt two-Truth sources only. The bid adapter adds a
* real Harkonnen bid and Atreides pass so the Emperor can declare while a real
* opposing bidder still owns the next turn. Force casualties and Hidar's death
* remain the source's actual shipment/Storm and original projectile history. */
export function createTruthTurnPreparationFixture(options: {
source?: 'bid' | 'forces' | 'leader'; advanced?: boolean; mixed?: TruthTurnPreparationExpression;
beforeRevival?: boolean; targetKarama?: boolean;
} = {}) {
const sourceKind = options.source ?? 'bid';
const bidSource = sourceKind === 'bid' ? createTruthBidFixture({ window: 'waiting', advanced: options.advanced, targetKarama: options.targetKarama }) : null;
const forceSource = sourceKind === 'forces' ? createTruthRevivalEarlyFixture({ phase: 3, advanced: options.advanced, targetKarama: options.targetKarama }) : null;
const leaderSource = sourceKind === 'leader' ? createTruthMixedRevivalFixture({ advanced: options.advanced }) : null;
const original = bidSource ?? forceSource ?? leaderSource!;
const actions = structuredClone(original.sourceActions), inheritedCount = actions.length;
let game = structuredClone(original.game);
if (bidSource) {
game = actTruthTurnPreparation(game, bidSource.asker, { type: 'bid', amount: 1, allyPayment: 0 }, actions);
game = actTruthTurnPreparation(game, bidSource.opponent, { type: 'passBid' }, actions);
assert.equal(game.auction?.active, bidSource.target); assert.ok(!game.auction?.passed.includes(bidSource.asker));
}
if (forceSource && !options.beforeRevival) game = reachTruthRevivalEarlyOpportunity(game, forceSource.target, actions);
const asker = original.asker, target = original.target, recipient = asker;
const observer = game.players.find(candidate => candidate.id !== asker && candidate.id !== target)!.id;
const truths = structuredClone(original.truths), lot = game.auction ? game.auction.index + 1 : null;
const mixed = structuredClone(options.mixed ?? (bidSource
? truthTurnPreparationGroup('and', truthTurnPreparationBid(lot!), truthTurnPreparationPayment(recipient), truthTurnPreparationShip())
: forceSource ? truthTurnPreparationGroup('and', truthTurnPreparationForce(), truthTurnPreparationPayment(recipient))
: truthTurnPreparationGroup('and', truthTurnPreparationLeader(leaderSource!.leader), truthTurnPreparationPayment(recipient))));
const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
assert.ok(truthTurnPreparationClean(game)); assert.equal(game.turnActionPromises?.length ?? 0, 0);
assert.ok(truths.every(truth => truthBidPlayer(game, asker).hand.some(card => card.id === truth)));
assert.deepEqual(truthBidInventory(game), original.originalCards);
return {
game, source: structuredClone(game), sourceKind, bidSource, forceSource, leaderSource,
asker, target, recipient, observer, truths, truth: truths[0], lot, mixed, question,
setup: original.setup, afterSetup: original.afterSetup, beforeStartingDeal: original.beforeStartingDeal,
originalCards: original.originalCards, originalSpice: original.originalSpice,
actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { inherited: inheritedCount, nativeContinuation: actions.length - inheritedCount, selected: actions.length },
bid: { type: 'bid', amount: 3, allyPayment: 0 } satisfies Action,
payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action,
shipment: { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 } satisfies Action,
forceAction: { type: 'revive', amount: 1, elite: 0 } satisfies Action,
leader: leaderSource?.leader ?? null,
leaderAction: leaderSource ? structuredClone(leaderSource.revivalAction) : null,
sourceDisclosure: `${original.sourceDisclosure} This adapter retains only native recorded continuations and changes the raw question. ` +
'Bid maxima describe actual future declarations in this original ordinary lot, and Revival counts describe actual ordinary future returns. ' +
'No played hand, card, cash, force, death, clock, promise, frozen fact or event progress is installed.',
};
}
export type TruthTurnPreparationFixture = ReturnType<typeof createTruthTurnPreparationFixture>;
export function declareTruthTurnPreparation(f: TruthTurnPreparationFixture, source = f.game, copy: 0 | 1 = 0,
actions: TruthTurnPreparationStep[] = []) {
const before = structuredClone(source), from = actions.length;
let game = actTruthTurnPreparation(source, f.asker, { type: 'card', card: f.truths[copy] }, actions);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id)); assert.ok(actor);
game = actTruthTurnPreparation(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
return { game, before, actions, actionCount: actions.length - from };
}
export function askTruthTurnPreparation(game: Game, asker: string, target: string, mixed: TruthTurnPreparationExpression,
actions: TruthTurnPreparationStep[] = []): Game {
return actTruthTurnPreparation(game, asker, { type: 'truthAsk', question: {
kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed),
} }, actions);
}
export function answerTruthTurnPreparation(game: Game, target: string, answer: 'yes' | 'no',
actions: TruthTurnPreparationStep[] = []): Game {
return actTruthTurnPreparation(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthTurnPreparation(f: TruthTurnPreparationFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed,
source = f.game, copy: 0 | 1 = 0, actions: TruthTurnPreparationStep[] = []) {
const from = actions.length, declared = declareTruthTurnPreparation(f, source, copy, actions);
const asked = askTruthTurnPreparation(declared.game, f.asker, f.target, mixed, actions);
const game = answerTruthTurnPreparation(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length - from };
}
export function truthTurnPreparationPromise(game: Game, target = game.turnActionPromises?.[0]?.player ?? game.players[0].id) {
const promise = viewGame(game, target).turnActionPromises.find(row => row.player === target); assert.ok(promise); return promise;
}
export function nextTruthTurnPreparationNative(game: Game): TruthTurnPreparationStep | null {
return game.expansions.includes('ix') ? nextTruthMixedRevival(game) : nextTruthBid(game);
}
export function nextTruthTurnPreparation(game: Game): TruthTurnPreparationStep | null {
if (game.truthtrance) return null;
for (const player of game.players) {
const guide = viewGame(game, player.id).turnActionGuide;
if (guide) return { actor: player.id, action: guide.action };
}
return nextTruthTurnPreparationNative(game);
}
export function advanceTruthTurnPreparation(game: Game, until: (state: Game) => boolean,
actions: TruthTurnPreparationStep[] = [], nativeOnly = false): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original native preparation source ended before its requested boundary');
const step = nativeOnly ? nextTruthTurnPreparationNative(game) : nextTruthTurnPreparation(game);
assert.ok(step, 'Supply the real current Truthtrance question or answer before continuing');
game = actTruthTurnPreparation(game, step.actor, step.action, actions);
}
throw Error('The genuine preparation source did not reach its finite boundary');
}
export function settleTruthTurnPreparation(game: Game, actions: TruthTurnPreparationStep[] = []): Game {
return advanceTruthTurnPreparation(game, truthTurnPreparationClean, actions);
}
export function finishTruthTurnPreparation(game: Game, actions: TruthTurnPreparationStep[] = []): Game {
const turn = game.turn;
return advanceTruthTurnPreparation(game, state => state.turn === turn + 1 && state.phase === 0 && truthTurnPreparationClean(state), actions);
}
+85 -5
View File
@@ -21,6 +21,10 @@ const fact = (value: TruthFact = { kind: 'hand', name: 'Shield' }): Expression =
const physical = (kind: 'ship' | 'move' = 'ship', minimum = 1, territory = 'carthag'): Expression =>
({ kind: 'action', action: { kind, territory, minimum } });
const payment = (minimum = 3, recipient = 'r'): Expression => ({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
const bid = (minimum = 3, lot = 2): Expression => ({ kind: 'action', action: { kind: 'bid', lot, minimum } });
const forceRevival = (counter: 'total' | 'normal' | 'elite' = 'total', minimum = 1): Expression =>
({ kind: 'action', action: { kind: 'forces', counter, minimum } });
const leaderRevival = (leader: string): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
const claim = (): Expression => ({ kind: 'or', terms: [payment(), { kind: 'and', terms: [fact(), physical('ship', 3), physical('move', 4, 'arrakeen')] }] });
function elements(node: ReactNode): Element[] {
@@ -66,6 +70,7 @@ function projected(phase = 5, next: Action = { type: 'move', forces: { 'arrakeen
}
function auction(next: Action = { type: 'passBid' }): GameView {
const game = projected(3, next);
game.bidPromiseLot = { turn: 2, lot: 2 };
game.auction = { bid: 7, bidder: next.type === 'decision' ? 'p' : 'a', active: 'p', remaining: 2, inspectionBlocked: null, card: null };
if (next.type === 'decision') { game.auction.active = 'a'; game.decision = { kind: 'auctionPayment', player: 'p' }; }
return game;
@@ -121,15 +126,20 @@ void test('turn-action editor keeps all action families and optional facts in th
});
void test('turn-action editor preserves sixteen clauses and four shared grouping levels without truncation', () => {
const e = editor({ kind: 'or', terms: [payment(), physical('move', 4)] });
for (let count = 3; count <= 16; count++) { click(control(e.tree(), 'add-action-1')); minimum(e.tree(), `1.${count}`, count); }
const game = auction(), e = editor({ kind: 'or', terms: [payment(), physical('move', 4)] }, false, game);
for (let count = 3; count <= 16; count++) {
click(control(e.tree(), 'add-action-1')); minimum(e.tree(), `1.${count}`, count);
if (count % 4 === 0) select(e.tree(), `1.${count}`, 'action', 'forces');
if (count % 4 === 1) select(e.tree(), `1.${count}`, 'action', 'leader');
if (count % 4 === 2) select(e.tree(), `1.${count}`, 'action', 'bid');
}
assert.equal(e.error(), null); assert.match(e.html(), /16 \/ 16 conditions/);
const full = structuredClone(e.value), changes = e.changes;
for (const key of ['add-action-1', 'add-fact-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'or-1']) {
assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
}
assert.deepEqual(e.value, full); assert.equal(e.changes, changes);
const deep = editor({ kind: 'and', terms: [fact(), payment()] });
const deep = editor({ kind: 'and', terms: [fact(), forceRevival()] });
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
assert.match(deep.html(), /4 \/ 4 levels of grouping/);
assert.equal(control(deep.tree(), 'and-1.2.1.1.1').props.disabled, true);
@@ -138,7 +148,7 @@ void test('turn-action editor preserves sixteen clauses and four shared grouping
});
void test('turn-action inputs keep invalid drafts repairable and reject disabled callbacks', () => {
for (const expression of [physical(), payment()]) {
for (const expression of [physical(), payment(), forceRevival()]) {
for (const invalid of [NaN, 0, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
const e = editor(expression); minimum(e.tree(), '1', invalid); assert.ok(e.error());
if (!Number.isFinite(invalid)) assert.match(e.html(), /value=""/);
@@ -173,6 +183,74 @@ void test('turn-action fields use public recipients and printed destinations wit
assert.match(editor(physical(), false, game).html(), /Carthag/);
});
void test('turn-action bid leaves use only the current public ordinary lot and retain repairable drafts', () => {
const game = auction(), e = editor(payment(), false, game);
Object.defineProperty(game.auction!, 'card', { get() { throw Error('No hidden lot face inspection'); }, configurable: true });
select(e.tree(), '1', 'action', 'bid'); assert.deepEqual(e.value, bid()); assert.equal(e.error(), null);
assert.match(e.html(), /Turn 2 · Ordinary lot 2/);
for (const invalid of [NaN, 0, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
minimum(e.tree(), '1', invalid); assert.ok(e.error()); minimum(e.tree(), '1', 3); assert.equal(e.error(), null);
}
minimum(e.tree(), '1', Number.MAX_SAFE_INTEGER); assert.equal(e.error(), null);
select(e.tree(), '1', 'lot', ''); assert.ok(e.error()); assert.deepEqual(e.value, bid(Number.MAX_SAFE_INTEGER, NaN));
select(e.tree(), '1', 'lot', '999'); assert.deepEqual(e.value, bid(Number.MAX_SAFE_INTEGER, NaN));
select(e.tree(), '1', 'lot', '2'); assert.equal(e.error(), null);
game.bidPromiseLot = { turn: 2, lot: 3 }; assert.ok(e.error()); assert.match(e.html(), /aria-invalid="true"/);
assert.deepEqual(elements(find(e.tree(), 'id', 'truth-turn-action-1-lot')).filter(element => element.type === 'option')
.map(element => element.props.value), ['', 3]);
select(e.tree(), '1', 'lot', '3'); assert.equal(e.error(), null);
game.bidPromiseLot = { turn: 1, lot: 3 }; assert.ok(e.error()); assert.match(e.html(), /No public ordinary lot is currently available/);
game.bidPromiseLot = null; assert.ok(e.error()); select(e.tree(), '1', 'action', 'forces');
minimum(e.tree(), '1', 2); assert.equal(e.error(), null);
});
void test('turn-action Revival leaves use public faction names and physical counters without casualties, custody or private preparation', () => {
const game = auction(); game.advanced = true; game.players.find(player => player.id === 'p')!.faction = 'atreides';
const ownLeader = game.allLeaders.find(leader => leader.faction === 'atreides'); assert.ok(ownLeader);
const foreignLeader = game.allLeaders.find(leader => leader.faction === 'emperor'); assert.ok(foreignLeader);
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves', 'leaders', 'tanks', 'capturedLeaders'])
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
Object.defineProperty(game, 'revival', { get() { throw Error('No private Revival eligibility inspection'); }, configurable: true });
const e = editor(forceRevival('total', 20), false, game); assert.equal(e.error(), null);
select(e.tree(), '1', 'counter', 'normal'); assert.deepEqual(e.value, forceRevival('normal', 20));
select(e.tree(), '1', 'counter', 'elite'); assert.deepEqual(e.value, forceRevival('elite', 20));
minimum(e.tree(), '1', 21); assert.ok(e.error()); minimum(e.tree(), '1', 1); assert.equal(e.error(), null);
select(e.tree(), '1', 'action', 'leader'); assert.deepEqual(e.value, leaderRevival(ownLeader.id));
const choices = elements(find(e.tree(), 'id', 'truth-turn-action-1-leader')).filter(element => element.type === 'option')
.map(element => element.props.value);
assert.ok(choices.includes('kwisatz')); assert.ok(choices.includes(ownLeader.id)); assert.ok(!choices.includes(foreignLeader.id));
select(e.tree(), '1', 'leader', foreignLeader.id); assert.deepEqual(e.value, leaderRevival(ownLeader.id));
assert.ok(turnActionInputError(leaderRevival(foreignLeader.id), game, 'p'));
select(e.tree(), '1', 'leader', ''); assert.ok(e.error()); select(e.tree(), '1', 'leader', 'kwisatz'); assert.equal(e.error(), null);
assert.match(e.html(), /Kwisatz Haderach/); game.advanced = false; assert.ok(e.error());
select(e.tree(), '1', 'leader', ownLeader.id); assert.equal(e.error(), null);
for (const initial of [bid(), forceRevival(), leaderRevival(ownLeader.id)]) {
const frozen = editor(initial, true, game), original = structuredClone(frozen.value);
select(frozen.tree(), '1', 'action', 'payment');
if (initial.kind === 'action' && initial.action.kind === 'bid') { select(frozen.tree(), '1', 'lot', ''); minimum(frozen.tree(), '1', 7); }
if (initial.kind === 'action' && initial.action.kind === 'forces') { select(frozen.tree(), '1', 'counter', 'elite'); minimum(frozen.tree(), '1', 7); }
if (initial.kind === 'action' && initial.action.kind === 'leader') select(frozen.tree(), '1', 'leader', '');
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, original);
}
});
void test('turn-action preparation history and guides use original public leaves without reading private stage progress', () => {
const game = auction({ type: 'bid', amount: 8, allyPayment: 0 }), named = game.allLeaders.find(leader => leader.faction === 'emperor');
assert.ok(named);
game.turnActionPromises[0].mixed = { kind: 'and', terms: [bid(8), forceRevival('elite', 2), leaderRevival(named.id), payment(), physical(), fact()] };
for (const key of ['preparation', 'compiled', 'progress', 'payments'])
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
const html = markup(game);
assert.match(html, /ordinary lot 2/); assert.match(html, /8 spice/); assert.match(html, /elite forces/); assert.match(html, new RegExp(named.name));
assert.match(html, /Bid conditions close with their named ordinary lot/); assert.match(html, /Revival conditions close with your native Revival opportunity/);
assert.doesNotMatch(html, /compiled|bidProgress|revivalProgress/);
const sent: Action[] = []; press(guide({ game, busy: false, act(value) { sent.push(value); } }));
assert.equal(sent[0], game.turnActionGuide!.action);
const answerGame = answering(3); answerGame.truthtrance!.question = { kind: 'turnActions', target: 'p', turn: 2, mixed: game.turnActionPromises[0].mixed };
const answerMarkup = renderToStaticMarkup(createElement(TurnActionAnswers, { game: answerGame, busy: false, act() {} }));
assert.match(answerMarkup, /ordinary force returns add together/); assert.match(answerMarkup, /Bids close with their lot/);
});
void test('turn-action history retains original own whole questions and terminal status without private compiled branches', () => {
const game = projected(); game.turnActionGuide = null;
game.turnActionPromises.push(
@@ -181,13 +259,14 @@ void test('turn-action history retains original own whole questions and terminal
{ ...game.turnActionPromises[0], event: 'controlled-release', released: true },
{ ...game.turnActionPromises[0], event: 'controlled-foreign', player: 'a', mixed: { kind: 'and', terms: [fact({ kind: 'hand', name: 'Poison Blade' }), physical('ship', 19, 'sietch_tabr')] } },
);
for (const promise of game.turnActionPromises) for (const key of ['compiled', 'binding', 'factAnswers', 'residual', 'route', 'payments', 'progress'])
for (const promise of game.turnActionPromises) for (const key of ['compiled', 'binding', 'factAnswers', 'residual', 'route', 'payments', 'progress', 'preparation', 'prep', 'bids', 'returns', 'leaders'])
Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
const html = markup(game);
for (const text of [/Turn 1/, /You answered Yes/, /You answered No/, /you hold Shield/, / OR /, / AND /,
/Fulfilled: this whole commitment is complete/, /Released: this whole commitment is no longer possible/,
/No negates the whole expression, not each condition separately/, /Separate No answers each create their own restriction/,
/maximum physical forces in one actual future move/, /Individual fact answers and any reduced future branch remain private/,
/highest accepted future declaration/, /cumulative future ordinary returns/, /Revival conditions close with your native Revival opportunity/,
/ordinary payments remain possible until the actual turn ends/]) assert.match(html, text);
assert.doesNotMatch(html, /Poison Blade|19 physical|controlled-foreign|controlled-complete|factAnswers/);
assert.equal(TurnActionPromises({ game: { ...game, me: 'missing' }, busy: false, act() {} }), null);
@@ -293,6 +372,7 @@ void test('turn-action native guides reject stale auction, invoice, Revival and
[() => auction({ type: 'bid', amount: 8, allyPayment: 0 }), [
game => { game.auction = { ...game.auction! }; }, game => { game.auction!.active = 'a'; },
game => { game.auction!.bid = 8; }, game => { game.auction!.remaining = 1; },
game => { game.bidPromiseLot = { turn: 2, lot: 3 }; },
]],
[() => auction({ type: 'decision', karama: false }), [
game => { game.decision = { ...game.decision! }; }, game => { game.decision = null; },
@@ -0,0 +1,328 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game } from '../game/engine';
import {
actTruthTurnPreparation as act, advanceTruthTurnPreparation as advance, answerTruthTurnPreparation as answer,
askTruthTurnPreparation as ask, bindTruthTurnPreparation as bind, createTruthTurnPreparationFixture as fixture,
declareTruthTurnPreparation as declare, finishTruthTurnPreparation as finish,
nextTruthTurnPreparationNative as nativeStep, settleTruthTurnPreparation as settle,
truthTurnPreparationBid as bid, truthTurnPreparationClean as clean, truthTurnPreparationForce as force,
truthTurnPreparationGroup as group, truthTurnPreparationInventory as inventory, truthTurnPreparationLeader as leader,
truthTurnPreparationPayment as payment, truthTurnPreparationPlayer as player, truthTurnPreparationPromise as promise,
type TruthTurnPreparationFixture, type TruthTurnPreparationStep,
} from './fixture-truth-turn-preparation';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function physical(game: Game, f: TruthTurnPreparationFixture) {
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
assert.ok(player(game, f.asker).hand.some(card => card.id === f.truths[1]));
assert.deepEqual(inventory(game), f.originalCards);
}
function closeLot(game: Game, lot: number, actions: TruthTurnPreparationStep[] = []) {
return advance(game, state => state.phase !== 3 || !state.auction || state.auction.index + 1 !== lot, actions, true);
}
void test('native preparation adapters retain original two-Truth deals and programmatic source prefixes', () => {
for (const source of ['bid', 'forces', 'leader'] as const) {
const f = fixture({ source });
assert.equal(f.setup.status, 'setup'); assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
assert.equal(f.setup.deck.filter(card => card.effect === 'truthtrance').length, 2);
assert.deepEqual(player(f.afterSetup, f.asker).hand.map(card => card.id), f.truths);
assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths);
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceActionCount, f.actions.length);
assert.equal(f.sourceCounts.selected, f.sourceActionCount);
assert.equal(f.sourceCounts.inherited + f.sourceCounts.nativeContinuation, f.sourceActionCount);
assert.equal(f.game.turnActionPromises?.length ?? 0, 0); assert.equal(f.game.truthHistory?.length ?? 0, 0);
assert.deepEqual(inventory(f.game), f.originalCards);
if (source === 'bid') {
assert.equal(f.sourceActionCount, 22); assert.equal(f.sourceCounts.nativeContinuation, 2);
assert.equal(f.game.phase, 3); assert.equal(player(f.game, f.target).spice, 10);
assert.equal(f.game.auction?.bidder, f.asker); assert.equal(f.game.auction?.active, f.target);
} else {
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 4); assert.equal(player(f.game, f.target).spice, 4);
assert.equal(player(f.game, f.target).tanks, source === 'forces' ? 3 : 1);
if (source === 'leader') {
assert.equal(f.sourceActionCount, 102); assert.equal(f.originalCards.length, 47);
assert.equal(player(f.game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, true);
}
}
}
});
void test('native AND declares a real bid before its invoice and shares current cash with payment and later shipment', () => {
const f = fixture(), actions: TruthTurnPreparationStep[] = [];
let game = bind(f, 'yes', f.mixed, f.game, 0, actions).game;
rejected(game, f.target, { type: 'passBid' });
game = act(game, f.target, f.payment, actions); assert.equal(player(game, f.target).spice, 8);
game = act(game, f.target, f.bid, actions);
assert.equal(player(game, f.target).spice, 8, 'Actual declaration records a bid without paying the later invoice');
assert.equal(game.auction?.active, f.asker); assert.equal(game.auction?.bidder, f.target);
assert.equal(game.turnActionPromises![0].preparation?.bid?.maximum, 3);
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target).released);
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), actions);
assert.equal(player(game, f.target).spice, 5); assert.equal(player(game, f.target).reserves, 20);
assert.ok(player(game, f.target).hand.some(card => card.id === f.bidSource!.lotCard));
rejected(game, f.target, { type: 'endMovement' });
game = settle(act(game, f.target, f.shipment, actions), actions);
assert.equal(player(game, f.target).spice, 3); assert.equal(player(game, f.target).reserves, 19);
assert.equal(player(game, f.target).forces['polar_sink:0'], 1); assert.equal(player(game, f.recipient).bribes, 2);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
assert.ok(actions.some(step => step.action.type === 'bid'));
// This original winner holds no Karama choice: the cash-only invoice
// settles automatically. The real card and three-spice debit above prove it.
});
void test('native bid OR payment Yes preserves both manual routes and bid declarations are independent of an eventual invoice', () => {
const f = fixture(), mixed = group('or', bid(f.lot!), payment(f.recipient)), bound = bind(f, 'yes', mixed).game;
for (const action of [f.bid, f.payment]) {
const game = act(bound, f.target, action);
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(promise(game, f.target).mixed, mixed);
assert.equal(game.turnActionPromises![0].preparation?.bid?.maximum, action.type === 'bid' ? 3 : 0);
assert.equal(player(game, f.target).spice, action.type === 'bid' ? 10 : 8);
assert.equal(player(game, f.recipient).bribes, action.type === 'bribe' ? 2 : 0); physical(game, f);
}
});
void test('native pass and actual lot close fold a missing bid false while the ordinary payment OR survives', () => {
const f = fixture(), mixed = group('or', bid(f.lot!), payment(f.recipient));
let game = bind(f, 'yes', mixed).game;
game = act(game, f.target, { type: 'passBid' }); game = closeLot(game, f.lot!);
assert.equal(game.turnActionPromises![0].preparation?.bid?.closed, true);
assert.equal(game.turnActionPromises![0].preparation?.bid?.maximum, 0);
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target).released);
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'bribe'); assert.equal(guide.action.turnActionPromisePhase, game.phase);
game = act(game, f.target, f.payment); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
});
for (const first of ['bid', 'payment'] as const)
void test(`native whole AND No permits its ${first} event and rejects only completion of the whole bid/payment tree`, () => {
const f = fixture(), mixed = group('and', bid(f.lot!), payment(f.recipient));
let game = bind(f, 'no', mixed).game;
game = act(game, f.target, first === 'bid' ? f.bid : f.payment);
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target).released);
rejected(game, f.target, first === 'bid' ? f.payment : f.bid);
if (first === 'payment') game = act(game, f.target, { type: 'passBid' });
game = closeLot(game, f.lot!); assert.ok(!promise(game, f.target).released);
if (first === 'bid') {
game = act(game, f.target, { ...f.payment, amount: 1 });
rejected(game, f.target, { ...f.payment, amount: 1 });
}
game = finish(game); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
});
void test('native repeated ordinary declarations retain the largest bid rather than adding bid amounts', () => {
const f = fixture(), mixed = group('and', bid(f.lot!, 5), payment(f.recipient));
let game = bind(f, 'no', mixed).game;
game = act(game, f.target, f.payment);
game = act(game, f.target, { ...f.bid, amount: 2 });
game = act(game, f.asker, { type: 'bid', amount: 3, allyPayment: 0 });
assert.equal(game.auction?.active, f.bidSource!.opponent);
game = act(game, f.bidSource!.opponent, { type: 'passBid' });
game = act(game, f.target, { ...f.bid, amount: 4 });
assert.equal(game.turnActionPromises![0].preparation?.bid?.maximum, 4);
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target).released);
game = closeLot(game, f.lot!); game = finish(game);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
});
void test('native bid No survives a pass reset and reentry until the same physical lot closes', () => {
const f = fixture(); let game = bind(f, 'no', bid(f.lot!, 5)).game;
game = act(game, f.target, { ...f.bid, amount: 2 });
game = act(game, f.asker, { type: 'passBid' });
game = act(game, f.observer, { type: 'bid', amount: 3, allyPayment: 0 });
assert.equal(game.auction?.active, f.target);
game = act(game, f.target, { type: 'passBid' });
assert.equal(game.turnActionPromises![0].preparation?.bid?.closed, false);
assert.ok(!promise(game, f.target).fulfilled);
game = act(game, f.asker, { type: 'bid', amount: 4, allyPayment: 0 });
game = act(game, f.observer, { type: 'passBid' });
assert.equal(game.auction?.active, f.target); assert.ok(!game.auction?.passed.includes(f.target));
rejected(game, f.target, { ...f.bid, amount: 5 });
game = closeLot(game, f.lot!);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
});
void test('native one question jointly funds its original bid force return and ordinary payment', () => {
const f = fixture({ source: 'forces', beforeRevival: true }), actions: TruthTurnPreparationStep[] = [];
assert.equal(f.game.phase, 3); assert.equal(player(f.game, f.target).spice, 4);
const mixed = group('and', bid(f.lot!, 1), force(2), payment(f.recipient, 1));
let game = bind(f, 'yes', mixed, f.game, 0, actions).game;
game = advance(game, state => promise(state, f.target).fulfilled, actions);
assert.equal(player(game, f.target).spice, 0);
assert.equal(player(game, f.recipient).bribes, 1);
assert.equal(game.turnActionPromises![0].preparation?.bid?.maximum, 1);
assert.equal(game.turnActionPromises![0].preparation?.revival?.total, 2);
assert.equal(player(game, f.target).reserves, 19); physical(game, f);
});
void test('native Revival Karama invoice guide waits for the actual owned payment decision', () => {
const f = fixture({ source: 'forces', beforeRevival: true, targetKarama: true }), actions: TruthTurnPreparationStep[] = [];
let game = advance(f.game, state => state.auction?.active === f.asker, actions, true);
game = act(game, f.asker, { type: 'bid', amount: 1, allyPayment: 0 }, actions);
assert.equal(game.auction?.active, f.observer);
game = act(game, f.observer, { type: 'passBid' }, actions);
assert.equal(game.auction?.active, f.target);
const karama = player(game, f.target).hand.find(card => card.effect === 'karama')!; assert.ok(karama);
game = bind(f, 'yes', force(3), game, 0, actions).game;
game = act(game, f.target, { type: 'bid', amount: 2, allyPayment: 0 }, actions);
assert.equal(game.auction?.active, f.asker); assert.equal(game.decision, null);
assert.equal(viewGame(game, f.target).turnActionGuide, null);
game = advance(game, state => state.decision?.kind === 'auctionPayment' && state.decision.player === f.target, actions, true);
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'decision'); assert.equal(guide.action.karama, true); assert.equal(guide.action.card, karama.id);
game = act(game, f.target, guide.action, actions);
assert.equal(player(game, f.target).spice, 4); assert.ok(game.discard.some(card => card.id === karama.id));
game = advance(game, state => promise(state, f.target).fulfilled, actions);
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).reserves, 20);
assert.equal(game.turnActionPromises![0].preparation?.revival?.total, 3); physical(game, f);
});
void test('native owned Karama overbid preserves original cash for its required future ordinary Revival', () => {
const f = fixture({ source: 'forces', beforeRevival: true, targetKarama: true }), actions: TruthTurnPreparationStep[] = [];
let game = advance(f.game, state => state.auction?.active === f.asker, actions, true);
game = act(game, f.asker, { type: 'bid', amount: 1, allyPayment: 0 }, actions);
game = act(game, f.observer, { type: 'passBid' }, actions);
assert.equal(player(game, f.target).spice, 4);
const karama = player(game, f.target).hand.find(card => card.effect === 'karama')!; assert.ok(karama);
game = bind(f, 'yes', group('and', bid(f.lot!, 10), force(3)), game, 0, actions).game;
game = act(game, f.target, { type: 'bid', amount: 10, allyPayment: 0 }, actions);
assert.equal(game.turnActionPromises![0].preparation?.bid?.maximum, 10);
assert.equal(player(game, f.target).spice, 4); assert.ok(!promise(game, f.target).fulfilled);
game = advance(game, state => promise(state, f.target).fulfilled, actions);
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).reserves, 20);
assert.equal(game.turnActionPromises![0].preparation?.revival?.total, 3);
assert.equal(game.discard.filter(card => card.id === karama.id).length, 1);
assert.ok(actions.some(step => step.action.type === 'decision' && step.action.karama === true));
physical(game, f);
});
void test('native cumulative typed force returns and payment share one original four-spice purse before own Revival Ready', () => {
const f = fixture({ source: 'forces' }); let game = bind(f).game;
rejected(game, f.target, { type: 'ready' });
game = settle(act(game, f.target, f.forceAction));
assert.equal(player(game, f.target).spice, 4); assert.equal(player(game, f.target).reserves, 18);
assert.equal(game.turnActionPromises![0].preparation?.revival?.total, 1);
assert.equal(game.turnActionPromises![0].preparation?.revival?.normal, 1);
game = act(game, f.target, f.payment); assert.equal(player(game, f.target).spice, 2);
rejected(game, f.target, { type: 'ready' }); game = settle(act(game, f.target, f.forceAction));
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).reserves, 19);
assert.equal(player(game, f.target).tanks, 1);
assert.equal(game.turnActionPromises![0].preparation?.revival?.total, 2);
assert.equal(game.turnActionPromises![0].preparation?.revival?.normal, 2);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
});
void test('native force OR payment Yes may finish its actual Revival opportunity and pay in late Mentat', () => {
const f = fixture({ source: 'forces' }), mixed = group('or', force(), payment(f.recipient));
let game = bind(f, 'yes', mixed).game;
game = act(game, f.target, { type: 'ready' }); assert.ok(game.ready.includes(f.target));
// Basic No retains its cap until phase close if a later Truth reopens Ready.
// The actual Ready flag currently prevents another ordinary return.
assert.equal(game.turnActionPromises![0].preparation?.revival?.closed, false);
assert.equal(player(game, f.target).revived, 0); assert.ok(!promise(game, f.target).fulfilled);
game = advance(game, state => state.phase === 8 && clean(state), [], true);
assert.equal(game.turnActionPromises![0].preparation?.revival?.closed, true);
assert.ok(!promise(game, f.target).released); rejected(game, f.target, { type: 'ready' });
game = act(game, f.target, f.payment); assert.equal(promise(game, f.target).fulfilled, true);
game = act(game, f.target, { type: 'ready' }); assert.ok(game.ready.includes(f.target)); physical(game, f);
});
void test('native Advanced ordered Revival finish closes only its own preparation stage before a later payment', () => {
const f = fixture({ source: 'forces', advanced: true }), mixed = group('or', force(2), payment(f.recipient));
assert.ok(f.game.advanced); assert.ok(f.game.revivalOrder); assert.equal(f.game.active, f.target);
let game = bind(f, 'yes', mixed).game;
game = act(game, f.target, { type: 'ready' });
assert.equal(game.turnActionPromises![0].preparation?.revival?.closed, true);
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!game.revivalOrder?.remaining.includes(f.target));
game = act(game, f.target, f.payment);
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(player(game, f.target).spice, 2);
physical(game, f);
});
void test('native personal leader and free force returns retain actual income and costs alongside an ordinary payment', () => {
const f = fixture({ source: 'leader' }); assert.ok(f.leader && f.leaderAction);
const mixed = group('and', leader(f.leader), force(1), payment(f.recipient));
let game = bind(f, 'yes', mixed).game;
rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, f.payment);
assert.equal(player(game, f.target).spice, 2); game = settle(act(game, f.target, f.forceAction));
assert.equal(player(game, f.target).spice, 3); assert.equal(player(game, f.target).tanks, 0);
assert.equal(game.turnActionPromises![0].preparation?.revival?.total, 1);
rejected(game, f.target, { type: 'ready' }); game = settle(act(game, f.target, f.leaderAction));
assert.equal(player(game, f.target).spice, 1);
assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, false);
assert.deepEqual(game.turnActionPromises![0].preparation?.revival?.leaders, [f.leader]);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
});
void test('native ordinary force returns before answering do not count toward the new preparation interval', () => {
const f = fixture({ source: 'leader' }); assert.ok(f.leader && f.leaderAction);
let game = settle(act(f.game, f.target, f.forceAction));
assert.equal(player(game, f.target).revived, 1); assert.equal(player(game, f.target).tanks, 0);
assert.equal(player(game, f.target).spice, 5);
const mixed = group('and', group('or', force(1), leader(f.leader)), payment(f.recipient));
game = bind(f, 'yes', mixed, game).game;
assert.equal(game.turnActionPromises![0].preparation?.revival?.total, 0);
assert.deepEqual(game.turnActionPromises![0].preparation?.revival?.leaders, []);
game = act(game, f.target, f.payment); assert.ok(!promise(game, f.target).fulfilled);
rejected(game, f.target, { type: 'ready' }); game = settle(act(game, f.target, f.leaderAction));
assert.equal(game.turnActionPromises![0].preparation?.revival?.total, 0);
assert.deepEqual(game.turnActionPromises![0].preparation?.revival?.leaders, [f.leader]);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
});
void test('native preparation guides keep exact phase/event packets and raw own history without private preparation progress', () => {
const f = fixture({ source: 'forces' }), declared = declare(f);
let game = answer(ask(declared.game, f.asker, f.target, f.mixed), f.target, 'yes');
const row = promise(game, f.target), allowed = ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'];
assert.ok(Object.keys(row).every(key => allowed.includes(key)));
assert.deepEqual(row.mixed, f.mixed); assert.deepEqual(viewGame(game, f.target).truthHistory.at(-1)?.question, f.question);
for (const actor of [f.asker, f.observer]) {
assert.deepEqual(viewGame(game, actor).turnActionPromises, []); assert.equal(viewGame(game, actor).turnActionGuide, null);
}
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.event, row.event); assert.equal(guide.action.turnActionPromiseEvent, row.event);
assert.equal(guide.action.turnActionPromisePhase, 4);
rejected(game, f.asker, guide.action);
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-preparation-event' });
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 3 });
rejected(game, f.target, { ...guide.action, amount: Number(guide.action.amount) + 1 });
const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, wire));
rejected(game, f.target, wire);
game = advance(game, state => promise(state, f.target).fulfilled === true);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
});
void test('all four minimal profiles actually submit current native bid and Revival preparation guides through fulfillment', () => {
for (const source of ['bid', 'forces'] as const) {
const f = fixture({ source }), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthTurnPreparationStep[] = [];
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const action = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(action, difficulty);
assert.equal(action.answer, 'yes'); game = act(game, f.target, action, actions);
let guided = 0;
for (let count = 0; count < 100 && !promise(game, f.target).fulfilled; count++) {
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const guide = view.turnActionGuide;
if (guide) {
const next = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.event);
assert.ok(next, `${source}/${difficulty}`); assert.deepEqual(next, guide.action);
game = settle(act(game, f.target, next, actions), actions); guided++;
} else {
const next = nativeStep(game); assert.ok(next, `${source}/${difficulty}`);
game = act(game, next.actor, next.action, actions);
}
}
assert.equal(promise(game, f.target).fulfilled, true, `${source}/${difficulty}`); assert.ok(guided > 0);
assert.ok(actions.some(step => step.action.type === (source === 'bid' ? 'bid' : 'revive')));
assert.ok(actions.some(step => step.action.type === 'bribe'));
if (source === 'bid') assert.ok(actions.some(step => step.action.type === 'ship'));
physical(game, f);
}
}
});
+180
View File
@@ -8,13 +8,17 @@ import {
TURN_ACTION_MAX_DEPTH,
TURN_ACTION_MAX_LEAVES,
TurnActionError,
closeTurnActionBidding,
closeTurnActionRevival,
closeTurnActionTransport,
finishTurnActionTurn,
liveTurnActionPromises,
parseTurnActionLeaf,
recordTurnActionBid,
recordTurnActionMovement,
recordTurnActionPayment,
recordTurnActionShipment,
recordTurnActionRevival,
turnActionBranches,
turnActionHasPayments,
turnActionValue,
@@ -30,6 +34,10 @@ const travel = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3): Expr
({ kind: 'action', action: { kind, territory, minimum } });
const pay = (recipient = 'recipient', minimum = 3): Expression =>
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
const bid = (minimum = 3, lot = 1): Expression => ({ kind: 'action', action: { kind: 'bid', lot, minimum } });
const forces = (minimum = 3, counter: 'total' | 'normal' | 'elite' = 'total'): Expression =>
({ kind: 'action', action: { kind: 'forces', counter, minimum } });
const leader = (leader = 'atreides-0'): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
const signedTravel = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3,
@@ -47,6 +55,12 @@ function promise(mixed: Expression, answer = true, id = 'turn-action:1'): TurnAc
};
}
const branches = (record: TurnActionPromise) => [...turnActionBranches(record)];
function prepared(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActionPromise {
return { ...promise(mixed, answer, id), preparation: {
bid: { lot: 1, source: 'original-lot:1', maximum: 0, closed: false },
revival: { total: 0, normal: 0, elite: 0, leaders: [], closed: false },
} };
}
void test('turn-action leaves retain exact native fields, safe payment minima and no payment twenty-force cap', () => {
for (const kind of ['ship', 'move'] as const) {
@@ -377,3 +391,169 @@ void test('stored rows reject missing or extra metadata, foreign private leaves
{ ...payment(), recipient: '' }, { ...payment(), received: true }])
assert.throws(() => recordTurnActionPayment([original], 'respondent', 1, invalid as never), TurnActionError);
});
void test('new preparation leaves reuse strict native bid and typed Revival parsers without changing old rows', () => {
for (const leaf of [{ kind: 'bid', lot: 1, minimum: Number.MAX_SAFE_INTEGER },
{ kind: 'forces', counter: 'elite', minimum: 20 }, { kind: 'leader', leader: 'atreides-0' }]) {
assert.deepEqual(parseTurnActionLeaf(leaf), leaf);
assert.notEqual(parseTurnActionLeaf(leaf), leaf);
}
for (const leaf of [{ kind: 'bid', lot: 0, minimum: 1 }, { kind: 'bid', lot: 1, minimum: 0 },
{ kind: 'bid', lot: 1, minimum: 1, source: 'hidden' }, { kind: 'bid', lot: 1, minimum: 1, progress: 0 },
{ kind: 'forces', counter: 'normal', minimum: 21 }, { kind: 'forces', counter: 'other', minimum: 1 },
{ kind: 'forces', counter: 'normal', minimum: 1, amount: 1 },
{ kind: 'leader', leader: '' }, { kind: 'leader', leader: 'atreides-0', answer: true }])
assert.throws(() => parseTurnActionLeaf(leaf), TurnActionError);
const old = promise(group('and', pay(), travel('move')));
validateTurnActionPromises([old], seats);
assert.equal('preparation' in recordTurnActionPayment([old], 'respondent', 1, payment())[0], false);
for (const mixed of [bid(), forces(), leader()])
assert.throws(() => turnActionValue(promise(mixed)), TurnActionError);
const folded = prepared(group('or', fact(), bid()));
delete folded.preparation;
assert.throws(() => turnActionValue(folded), TurnActionError, 'Folded original bid leaves still retain their physical source');
});
void test('bid progress retains a maximum accepted declaration from one future original lot and answer interval', () => {
const original = prepared(group('and', bid(7), pay()));
const once = recordTurnActionBid([original], 'respondent', 1, { lot: 1, source: 'original-lot:1', amount: 3 });
const twice = recordTurnActionBid(once, 'respondent', 1, { lot: 1, source: 'original-lot:1', amount: 5 });
assert.equal(twice[0].preparation!.bid!.maximum, 5);
assert.equal(turnActionValue(twice[0]), false, 'Three and five never combine into an eight-spice bid');
assert.deepEqual(branches(twice[0]), [[{ kind: 'bid', lot: 1, source: 'original-lot:1', minimum: 7, answer: true }, signedPay()]]);
for (const event of [{ lot: 1, source: 'other-lot', amount: 9 }, { lot: 2, source: 'original-lot:1', amount: 9 },
{ lot: 1, source: 'original-lot:1', amount: 4 }])
assert.equal(recordTurnActionBid(twice, 'respondent', 1, event)[0], twice[0]);
const fresh = prepared(group('and', bid(7), pay()), true, 'turn-action:2');
const combined = recordTurnActionBid([twice[0], fresh], 'respondent', 1, { lot: 1, source: 'original-lot:1', amount: 4 });
assert.equal(combined[0].preparation!.bid!.maximum, 5);
assert.equal(combined[1].preparation!.bid!.maximum, 4);
const accepted = recordTurnActionBid(combined, 'respondent', 1, { lot: 1, source: 'original-lot:1', amount: 7 });
assert.deepEqual(branches(accepted[0]), [[signedPay()]]);
assert.equal(original.preparation!.bid!.maximum, 0);
assert.equal(recordTurnActionBid(accepted, 'other', 1, { lot: 1, source: 'original-lot:1', amount: 20 })[0], accepted[0]);
assert.equal(recordTurnActionBid(accepted, 'respondent', 2, { lot: 1, source: 'original-lot:1', amount: 20 })[0], accepted[0]);
});
void test('ordinary Revival accumulates typed physical returns and records each personal leader identity once', () => {
const original = prepared(group('and', forces(5), forces(3, 'normal'), forces(2, 'elite'), leader(), pay()));
const once = recordTurnActionRevival([original], 'respondent', 1, { kind: 'forces', amount: 3, elite: 1 });
assert.deepEqual(once[0].preparation!.revival, { total: 3, normal: 2, elite: 1, leaders: [], closed: false });
assert.deepEqual(branches(once[0]), [[
{ kind: 'forces', counter: 'total', minimum: 5, progress: 3, answer: true },
{ kind: 'forces', counter: 'normal', minimum: 3, progress: 2, answer: true },
{ kind: 'forces', counter: 'elite', minimum: 2, progress: 1, answer: true },
{ kind: 'leader', leader: 'atreides-0', answer: true }, signedPay(),
]]);
const twice = recordTurnActionRevival(once, 'respondent', 1, { kind: 'forces', amount: 2, elite: 1 });
const returned = recordTurnActionRevival(twice, 'respondent', 1, { kind: 'leader', leader: 'atreides-0' });
assert.deepEqual(returned[0].preparation!.revival, { total: 5, normal: 3, elite: 2, leaders: ['atreides-0'], closed: false });
assert.equal(recordTurnActionRevival(returned, 'respondent', 1, { kind: 'leader', leader: 'atreides-0' })[0], returned[0]);
assert.deepEqual(branches(returned[0]), [[signedPay()]]);
assert.equal(recordTurnActionPayment(returned, 'respondent', 1, payment())[0].fulfilled, true);
assert.equal(original.preparation!.revival!.total, 0);
const fresh = prepared(group('and', forces(5), pay()), true, 'turn-action:2');
const intervals = recordTurnActionRevival([returned[0], fresh], 'respondent', 1, { kind: 'forces', amount: 1, elite: 0 });
assert.equal(intervals[0].preparation!.revival!.total, 6);
assert.equal(intervals[1].preparation!.revival!.total, 1);
});
void test('cross-preparation whole No uses De Morgan and rejects the actual event completing its aggregate', () => {
const original = prepared(group('and', bid(4), group('or', forces(2), leader())), false);
assert.deepEqual(branches(original), [[{ kind: 'bid', lot: 1, source: 'original-lot:1', minimum: 4, answer: false }],
[{ kind: 'forces', counter: 'total', minimum: 2, progress: 0, answer: false },
{ kind: 'leader', leader: 'atreides-0', answer: false }]]);
const accepted = recordTurnActionBid([original], 'respondent', 1, { lot: 1, source: 'original-lot:1', amount: 4 });
const before = structuredClone(accepted);
assert.throws(() => recordTurnActionRevival(accepted, 'respondent', 1, { kind: 'forces', amount: 2, elite: 0 }), TurnActionError);
assert.throws(() => recordTurnActionRevival(accepted, 'respondent', 1, { kind: 'leader', leader: 'atreides-0' }), TurnActionError);
assert.deepEqual(accepted, before);
const orNo = prepared(group('or', bid(4), leader()), false);
assert.throws(() => recordTurnActionBid([orNo], 'respondent', 1, { lot: 1, source: 'original-lot:1', amount: 4 }), TurnActionError);
assert.throws(() => recordTurnActionRevival([orNo], 'respondent', 1, { kind: 'leader', leader: 'atreides-0' }), TurnActionError);
});
void test('Bidding and Revival close independently while transport closure preserves both preparation alternatives', () => {
const original = prepared(group('or', bid(), forces(), pay()));
const transport = closeTurnActionTransport([original], 'respondent', 1);
assert.equal(transport[0].preparation!.bid!.closed, false);
assert.equal(transport[0].preparation!.revival!.closed, false);
const wrong = closeTurnActionBidding(transport, 1, 'other-lot');
assert.equal(wrong[0], transport[0]);
assert.equal(closeTurnActionBidding(transport, 1, 'original-lot:1', 'other')[0], transport[0]);
const bidding = closeTurnActionBidding(transport, 1, 'original-lot:1', 'respondent');
assert.equal(bidding[0].preparation!.bid!.closed, true);
assert.equal(bidding[0].preparation!.revival!.closed, false);
const revival = closeTurnActionRevival(bidding, 'respondent', 1);
assert.deepEqual(branches(revival[0]), [[signedPay()]]);
assert.equal(revival[0].released, false);
assert.equal(recordTurnActionBid(revival, 'respondent', 1, { lot: 1, source: 'original-lot:1', amount: 20 })[0], revival[0]);
assert.equal(recordTurnActionRevival(revival, 'respondent', 1, { kind: 'forces', amount: 20, elite: 0 })[0], revival[0]);
const no = prepared(group('and', bid(), leader()), false);
const bidClosed = closeTurnActionBidding([no], 1, 'original-lot:1');
assert.equal(bidClosed[0].fulfilled, false);
assert.equal(closeTurnActionRevival(bidClosed, 'respondent', 1)[0].fulfilled, true);
const paidOnlyClosed = { ...original, transportClosed: true, paymentClosed: true,
shipment: { pending: false, event: null } };
validateTurnActionPromises([paidOnlyClosed], seats);
});
void test('same-source bid and force bounds merge strongest thresholds without combining distinct typed counters', () => {
const mixed = group('and', bid(3), bid(5), forces(2, 'normal'), forces(4, 'normal'), forces(1, 'elite'), leader(), leader());
assert.deepEqual(branches(prepared(mixed)), [[
{ kind: 'bid', lot: 1, source: 'original-lot:1', minimum: 5, answer: true },
{ kind: 'forces', counter: 'normal', minimum: 4, progress: 0, answer: true },
{ kind: 'forces', counter: 'elite', minimum: 1, progress: 0, answer: true },
{ kind: 'leader', leader: 'atreides-0', answer: true },
]]);
const no = prepared(group('or', bid(3), bid(5), forces(2), forces(4)), false);
assert.deepEqual(branches(no), [[
{ kind: 'bid', lot: 1, source: 'original-lot:1', minimum: 3, answer: false },
{ kind: 'forces', counter: 'total', minimum: 2, progress: 0, answer: false },
]]);
});
void test('full expiry closes every preparation stage while terminal rows preserve their original progress and deadlines', () => {
const original = prepared(group('and', bid(), forces(), leader(), pay()));
const no = prepared(group('and', bid(), forces(), leader(), pay()), false, 'turn-action:2');
const finished = finishTurnActionTurn([original, no], 'respondent', 1);
assert.equal(finished[0].released, true);
assert.equal(finished[1].fulfilled, true);
for (const row of finished) {
assert.equal(row.preparation!.bid!.closed, true);
assert.equal(row.preparation!.revival!.closed, true);
}
for (const transform of [
(rows: TurnActionPromise[]) => recordTurnActionBid(rows, 'respondent', 1, { lot: 1, source: 'original-lot:1', amount: 20 }),
(rows: TurnActionPromise[]) => recordTurnActionRevival(rows, 'respondent', 1, { kind: 'forces', amount: 20, elite: 0 }),
(rows: TurnActionPromise[]) => closeTurnActionBidding(rows, 1, 'original-lot:1'),
(rows: TurnActionPromise[]) => closeTurnActionRevival(rows, 'respondent', 1),
]) assert.ok(transform(finished).every((row, index) => row === finished[index]));
validateTurnActionPromises(finished, seats);
});
void test('preparation metadata and actual events reject foreign sources, mismatched stages and impossible typed totals', () => {
const original = prepared(group('and', bid(), forces(), leader()));
for (const preparation of [undefined, null, {}, { bid: null, revival: null },
{ ...original.preparation, hidden: true },
{ ...original.preparation, bid: { ...original.preparation!.bid!, lot: 2 } },
{ ...original.preparation, bid: { ...original.preparation!.bid!, source: '' } },
{ ...original.preparation, bid: { ...original.preparation!.bid!, maximum: -1 } },
{ ...original.preparation, bid: { ...original.preparation!.bid!, maximum: Number.MAX_SAFE_INTEGER + 1 } },
{ ...original.preparation, revival: { ...original.preparation!.revival!, total: 2, normal: 1 } },
{ ...original.preparation, revival: { ...original.preparation!.revival!, leaders: ['atreides-0', 'atreides-0'] } },
{ ...original.preparation, revival: { ...original.preparation!.revival!, closed: 'yes' } }])
assert.throws(() => turnActionValue({ ...original, preparation } as TurnActionPromise), TurnActionError);
assert.throws(() => turnActionValue(prepared(group('or', bid(1, 1), bid(1, 2)))), TurnActionError);
for (const invalid of [{ lot: 0, source: 'original-lot:1', amount: 1 }, { lot: 1, source: '', amount: 1 },
{ lot: 1, source: 'original-lot:1', amount: 0 }, { lot: 1, source: 'original-lot:1', amount: 1, answer: true }])
assert.throws(() => recordTurnActionBid([original], 'respondent', 1, invalid), TurnActionError);
for (const invalid of [{ kind: 'forces', amount: 2, elite: 3 }, { kind: 'forces', amount: 21, elite: 0 },
{ kind: 'forces', amount: 2, elite: 0, source: 'ghola' }, { kind: 'leader', leader: 'atreides-0', card: 'ghola' }])
assert.throws(() => recordTurnActionRevival([original], 'respondent', 1, invalid as never), TurnActionError);
const full = { ...original, preparation: { ...original.preparation!,
revival: { total: 20, normal: 20, elite: 0, leaders: [], closed: false } } };
const before = structuredClone(full);
assert.throws(() => recordTurnActionRevival([full], 'respondent', 1, { kind: 'forces', amount: 1, elite: 0 }), TurnActionError);
assert.deepEqual(full, before);
});