Add bids and ordinary Revival to combined turn commitments
This commit is contained in:
1 parent
0d72a33ee5
commit
921b4fe1e3
15 files changed
+1255
-65
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user