Support earlier Basic Truthtrance shipment promises

Accept current-turn shipment and mixed questions before Movement in bare classic Basic games. Project remaining owned Charity/normal Revival, net current auction invoices and unused outgoing refunds; do not invent future purchases, incoming Bidding aid or rival income. Physical Karama payment and shipping discounts require distinct cards. Reopen owned Charity/Revival readiness when binding and prevent voluntary spending or closure from evading an answer. Preserve unresolved foreign invoices until their actual outcome; preflight Emperor gifts and extra ally revival costs before responses.

Connect persistent earlier-phase guidance and current preparation to the actual table and retain phase5 shipping timing. Minimal required-delivery policies preserve a legal route; nonrestrictive answers retain ordinary legal payment. Document the explicit provisional witness/readiness interpretation and inherited profile boundaries.

Verify focused rules/controls, types/lint/build, two original public Basic Storm/Charity question programmes with actual purchase2 and shipment8, and four finite minimal legal policy continuations. Full rules, wider earlier-phase configurations, comprehensive assurance and deployed acceptance remain open. No strategic calibration, saved-room operation, reset, restart or deployment.
This commit is contained in:
Quintine committed 2026-10-24 13:28:06 +11:00
1 parent 6d30e7c5e1
commit 16f654071a
17 files changed
+799 -48

No files matched your search

@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action } from '../game/engine';
import type { TruthQuestion } from '../game/truthtrance';
import {
actFutureShipment, advanceFutureShipment, futureShipmentClean, futureShipmentPlayer,
nextFutureShipmentStep, type FutureShipmentStep,
} from './fixture-truthtrance-future-shipment';
export type TruthtranceEarlyShipmentStep = FutureShipmentStep;
export const actTruthtranceEarlyShipment = actFutureShipment;
export const advanceTruthtranceEarlyShipment = advanceFutureShipment;
export const nextTruthtranceEarlyShipmentStep = nextFutureShipmentStep;
export const truthtranceEarlyShipmentClean = futureShipmentClean;
export const truthtranceEarlyShipmentPlayer = futureShipmentPlayer;
/** Genuine public Basic classic setup, original five-card deal and original
* Storm/Spice controls. Stop at clean Charity BEFORE playing Truthtrance or
* asking the new early question. Only unopened deck positions/scalar lotteries
* are selected; no dealt card, force, wallet, clock or promise is installed. */
export function createTruthtranceEarlyShipmentFixture() {
const asker = 'atreides', target = 'emperor', counterHolder = 'harkonnen';
let game = createGame('TRUTHEARLYSHIPMENT', newPlayer(asker, 'Atreides', 'atreides'), false);
for (const faction of ['emperor', 'harkonnen', 'guild'] as const)
joinGame(game, newPlayer(faction, faction, faction));
const actions: TruthtranceEarlyShipmentStep[] = [];
const act = (actor: string, action: Action) => {
game = actTruthtranceEarlyShipment(game, actor, action, actions);
};
const reach = (until: Parameters<typeof advanceTruthtranceEarlyShipment>[1]) => {
game = advanceTruthtranceEarlyShipment(game, until, actions);
};
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, false);
assert.deepEqual(game.expansions, []);
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => player.hand.length === 0));
const originalTreachery = game.deck.map(card => card.id).sort();
assert.equal(new Set(originalTreachery).size, originalTreachery.length);
const truth = game.deck.find(card => card.effect === 'truthtrance');
const karama = game.deck.find(card => card.effect === 'karama');
const filler = [game.deck.find(card => card.name === 'Baliset'),
game.deck.find(card => card.kind === 'shield'), game.deck.find(card => card.kind === 'snooper')];
assert.ok(truth && karama && filler.every(Boolean));
// The Harkonnen receives two original consecutive cards, all other seats one.
const deal = [truth.id, filler[0]!.id, karama.id, filler[1]!.id, filler[2]!.id];
const dealOrder = deal.map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: id };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game);
assert.ok(truthtranceEarlyShipmentPlayer(game, asker).hand.some(card => card.id === truth.id));
assert.ok(truthtranceEarlyShipmentPlayer(game, counterHolder).hand.some(card => card.id === karama.id));
assert.ok(truthtranceEarlyShipmentPlayer(game, target).hand.some(card => card.id === filler[0]!.id));
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const beforeSpiceOrder = structuredClone(game);
const from = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(from >= 0);
const land = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.unshift(land);
const spiceOrder = [{ position: 0, from, card: structuredClone(land) }];
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
reach(state => state.turn === 1 && state.phase === 2 && truthtranceEarlyShipmentClean(state));
assert.equal(game.status, 'playing'); assert.equal(game.advanced, false);
assert.deepEqual(game.ready, []); assert.equal(game.auction ?? null, null);
assert.equal(game.truthtrance ?? null, null);
const respondent = truthtranceEarlyShipmentPlayer(game, target);
assert.equal(respondent.spice, 10); assert.equal(respondent.reserves, 20);
assert.equal(respondent.tanks, 0); assert.deepEqual(respondent.forces, {});
assert.equal(respondent.shipped, false); assert.equal(respondent.moved, 0);
assert.ok(truthtranceEarlyShipmentPlayer(game, asker).hand.some(card => card.id === truth.id));
assert.ok(truthtranceEarlyShipmentPlayer(game, counterHolder).hand.some(card => card.id === karama.id));
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)]
.map(card => card.id).sort(), originalTreachery);
const question: TruthQuestion = { kind: 'shipment', target, territory: 'carthag', minimum: 8 };
const declarationStep: TruthtranceEarlyShipmentStep = { actor: asker,
action: { type: 'card', card: truth.id } };
const askStep: TruthtranceEarlyShipmentStep = { actor: asker, action: { type: 'truthAsk', question } };
return { game, lobby, setup, beforeStartingDeal, afterSetup, beforeSpiceOrder, spiceOrder,
deal, dealOrder, originalTreachery, originalSpice, asker, target, counterHolder, guild: 'guild',
card: truth.id, karama: karama.id, question, declarationStep, askStep,
territory: 'carthag', sector: 11, location: 'carthag:11', minimum: 8,
advanced: false as const, sourceActionCount: actions.length, actions,
staging: ['Only five unopened original starting-deal positions, one unopened ordinary land Spice position and original scalar lotteries are selected. Public Basic Atreides/Emperor/Harkonnen/Guild setup actually deals Truthtrance to Atreides, Baliset to Emperor, Karama and Shield to Harkonnen, and Snooper to Guild. Original traitor choices, Storm dials, Spice Blow and readiness reach clean turn-1 Charity before Bidding. Emperor retains its printed 10 spice, 20 reserve forces and unused shipment; the held physical Truthtrance and Karama are unchanged. The constructor stops before the Truthtrance declaration and proposed eight-force Carthag question. No postdeal hand, army, wallet, phase, turn, queue or promise is installed. The Fremen storm-loss source is outside this bounded programme.'] };
}
export type TruthtranceEarlyShipmentFixture = ReturnType<typeof createTruthtranceEarlyShipmentFixture>;
+2 -2
View File
@@ -536,7 +536,7 @@ void test('malformed questions and unsupported public scopes reject without disc
]) {
const bad = structuredClone(g);
if (change === 'expansion') bad.expansions = ['ix'];
if (change === 'phase') bad.phase = 4;
if (change === 'phase') bad.phase = 6;
if (change === 'spent') bad.players[1].shipped = true;
if (change === 'inactive') { bad.active = 'a'; bad.movementRemaining = ['a', 'o']; }
reject(bad, 'a', {
@@ -603,7 +603,7 @@ void test('saved malformed promise bindings reject before action or normalizatio
g.shipmentPromises![0].released = true;
},
(g) => {
g.phase = 4;
g.phase = 6;
},
(g) => {
g.active = 'a';
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Children, createElement, isValidElement, type ButtonHTMLAttributes, type MouseEvent, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { ShipmentPromises } from '../components/shipment-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import { baseDeck } from '../game/cards';
const shipment: Action = { type: 'ship', territory: 'carthag', sector: 11, amount: 3, elite: 0 };
type Props = Parameters<typeof ShipmentPromises>[0];
/** Controlled personalized UI projection, not an earned early Truthtrance or
* preparation history. Native current-phase legality is separate engine proof. */
function controlledView(phase = 3, active: string | null = 'p'): GameView {
const source = createGame('EARLYTRUTHCONTROLS', newPlayer('p', 'Respondent', 'emperor'));
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
const game = viewGame(source, 'p');
game.status = 'playing'; game.turn = 2; game.phase = phase; game.active = active;
game.players.find(player => player.id === 'p')!.spice = 6;
game.shipmentPromises = [{ player: 'p', asker: 'a', turn: 2, territory: 'carthag', minimum: 3, answer: true }];
game.shipmentCompletion = null;
game.shipmentPreparation = null;
return game;
}
const markup = (game: GameView, busy = false) => renderToStaticMarkup(createElement(ShipmentPromises, {
game, busy, act() { assert.fail('Rendering must not submit current preparation or early shipment'); },
}));
function actionButton(props: Props, label: string): ReactElement<ButtonHTMLAttributes<HTMLButtonElement>> {
function find(node: ReactNode): ReactElement<ButtonHTMLAttributes<HTMLButtonElement>> | undefined {
if (!isValidElement<{ children?: ReactNode; onClick?: unknown }>(node)) return;
if (node.props.children === label && typeof node.props.onClick === 'function')
return node as ReactElement<ButtonHTMLAttributes<HTMLButtonElement>>;
return Children.toArray(node.props.children).map(find).find(Boolean);
}
const button = find(ShipmentPromises(props)); assert.ok(button, label); return button;
}
const click = (button: ReactElement<ButtonHTMLAttributes<HTMLButtonElement>>) =>
button.props.onClick!({} as MouseEvent<HTMLButtonElement>);
void test('early active and waiting respondents see later-this-turn guidance without executable future shipment', () => {
for (const phase of [0, 1, 2, 3, 4]) for (const active of ['p', 'a', null]) {
const game = controlledView(phase, active);
game.shipmentCompletion = { get actions(): Action[] { throw new Error('Early UI must not inspect future executable shipment actions'); } };
const html = markup(game);
assert.match(html, /shipment opportunity is later this turn, during Shipment and Movement/);
assert.match(html, /needed spice, forces and cards available/);
assert.match(html, /continue playing the current phase normally/);
assert.match(html, /During your later Shipment and Movement turn/);
assert.doesNotMatch(html, /other players finish|Suggested next step|<button|Cost:/);
assert.match(html, /ship at least 3 physical forces from your reserves to Carthag/);
}
});
void test('projected current charity forwards its exact action once and busy controls cannot submit', () => {
const game = controlledView(2, null), charity: Action = { type: 'charity' };
game.shipmentPreparation = { actions: [charity] };
const sent: Action[] = [], props = { game, busy: false, act: (action: Action) => sent.push(action) };
const html = markup(game);
assert.match(html, /aria-label="Current shipment preparation"/);
assert.match(html, /This preparation is available in the current phase/);
assert.doesNotMatch(html, /Suggested next step|Ship 3 forces|Cost:/);
const available = actionButton(props, 'Claim charity');
assert.equal(available.props.disabled, false);
click(available);
assert.equal(sent.length, 1);
assert.equal(sent[0], charity);
const busy = actionButton({ ...props, busy: true }, 'Claim charity');
assert.equal(busy.props.disabled, true);
click(busy);
assert.equal(sent.length, 1);
});
void test('current Revival preparation retains its projected force count and elite payload without revealing a future route', () => {
const game = controlledView(4), revival: Action = { type: 'revive', amount: 3, elite: 1 };
game.shipmentPreparation = { actions: [revival] };
const sent: Action[] = [], props = { game, busy: false, act: (action: Action) => sent.push(action) };
assert.match(markup(game), /Revive 3 forces in Revival/);
assert.doesNotMatch(markup(game), /Ship 3 forces|Suggested next step|Cost:/);
click(actionButton(props, 'Revive 3 forces in Revival'));
assert.equal(sent[0], revival);
assert.deepEqual(sent, [{ type: 'revive', amount: 3, elite: 1 }]);
const busy = actionButton({ ...props, busy: true }, 'Revive 3 forces in Revival');
assert.equal(busy.props.disabled, true); click(busy);
assert.equal(sent.length, 1);
game.shipmentPreparation = { actions: [shipment] };
assert.doesNotMatch(markup(game), /<button|Current shipment preparation/,
'An accidental future shipment in the preparation field must not become a current control');
game.shipmentPreparation = { actions: [{ type: 'endMovement' }] };
assert.doesNotMatch(markup(game), /<button|Current shipment preparation/);
});
void test('an owned preparation card is named and preserves its projected action while foreign or unknown cards stay unavailable', () => {
const game = controlledView(4), card = baseDeck().find(card => card.effect === 'ghola'); assert.ok(card);
const own = game.players.find(player => player.id === game.me)!;
own.hand = [card];
const action: Action = { type: 'card', card: card.id, amount: 3, elite: 1 };
game.shipmentPreparation = { actions: [action] };
const sent: Action[] = [], props = { game, busy: false, act: (value: Action) => sent.push(value) };
assert.match(markup(game), new RegExp(`Play ${card.name}`));
click(actionButton(props, `Play ${card.name}`));
assert.equal(sent[0], action);
own.hand = [];
assert.doesNotMatch(markup(game), /<button|Current shipment preparation/);
});
void test('phase-five shipment guidance retains its original private completion and ignores obsolete early preparation', () => {
const game = controlledView(5);
game.shipmentCompletion = { actions: [shipment] } as NonNullable<GameView['shipmentCompletion']>;
game.shipmentPreparation = { actions: [{ type: 'charity' }] };
const sent: Action[] = [], props = { game, busy: false, act: (action: Action) => sent.push(action) };
const html = markup(game);
assert.match(html, /Suggested next step · private/);
assert.match(html, /Ship 3 forces to Carthag, sector 11/);
assert.match(html, /Cost: 3 spice/);
assert.doesNotMatch(html, /Current shipment preparation|Claim charity|opportunity is later this turn/);
click(actionButton(props, 'Ship 3 forces to Carthag, sector 11'));
assert.equal(sent[0], shipment);
game.active = 'a'; game.shipmentCompletion = null;
assert.match(markup(game), /Your shipment turn is still ahead/);
assert.doesNotMatch(markup(game), /<button|Suggested next step/);
});
void test('early grouped promises retain the original question and own-answer filtering without exposing normalized private facts', () => {
const game = controlledView();
game.shipmentPromises = [
{ player: 'p', asker: 'a', turn: 2, answer: true,
claim: { op: 'and', terms: [{ constant: 'yes' }, { territory: 'carthag', minimum: 3 }] },
mixed: { kind: 'and', terms: [
{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 1 } },
{ kind: 'shipment', territory: 'carthag', minimum: 3 },
] } },
{ player: 'a', asker: 'p', turn: 2, answer: false, territory: 'arrakeen', minimum: 19 },
];
Object.defineProperty(game.players.find(player => player.id === 'a')!, 'hand', {
get() { assert.fail('Shipment guidance must not inspect another player hand'); },
});
const html = markup(game);
assert.match(html, /You answered Yes to/);
assert.match(html, /AND/); assert.match(html, /Carthag/);
assert.doesNotMatch(html, /constant|Arrakeen|19 physical|You answered No/);
assert.doesNotMatch(html, /<button|Suggested next step/);
});
@@ -0,0 +1,210 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { createTruthtranceEarlyShipmentFixture, actTruthtranceEarlyShipment as act,
advanceTruthtranceEarlyShipment as advance, truthtranceEarlyShipmentClean as clean } from './fixture-truthtrance-early-shipment';
import { botActions } from '../game/bots';
function bound(control?: (game: Game) => void, minimum = 8, phase = 2) {
const f = createTruthtranceEarlyShipmentFixture();
const initial = phase === 2 ? f.game : advance(f.game, g => g.phase === phase && clean(g));
control?.(initial);
let game = act(initial, f.declarationStep.actor, f.declarationStep.action);
while (game.truthtrance?.stage === 'priority')
game = act(game, game.players.find(p => !game.truthtrance!.passed.includes(p.id))!.id, { type: 'truthPass' });
assert.ok(viewGame(game, f.asker).truthShipmentTargets.includes(f.target));
game = act(game, f.askStep.actor, { ...f.askStep.action, question: { ...f.question, minimum } });
assert.ok(viewGame(game, f.target).truthShipmentAnswers!.includes('yes'));
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
return { f, game };
}
const reject = (g: Game, actor: string, action: Parameters<typeof applyAction>[2]) => {
const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action)); assert.deepEqual(g, before);
};
const player = (g: Game, id = 'emperor') => g.players.find(p => p.id === id)!;
function giveKarama(g: Game, count = 1) {
const own = player(g);
for (let n = 0; n < count; n++) {
const holder = g.players.find(p => p.id !== own.id && p.hand.some(c => c.effect === 'karama'));
if (holder) own.hand.push(holder.hand.splice(holder.hand.findIndex(c => c.effect === 'karama'), 1)[0]);
else {
const index = g.deck.findIndex(c => c.effect === 'karama'); assert.ok(index >= 0);
own.hand.push(g.deck.splice(index, 1)[0]);
}
}
}
function allies(g: Game) {
player(g).ally = 'harkonnen'; player(g, 'harkonnen').ally = 'emperor';
}
void test('an original Basic Charity question binds the later eight-force shipment without granting an early action', () => {
const { f, game } = bound();
assert.equal(game.phase, 2); assert.equal(game.shipmentPromises!.length, 1);
assert.equal(game.shipmentPromises![0].player, f.target);
assert.equal(game.shipmentPromises![0].released, undefined);
assert.equal(viewGame(game, f.target).shipmentCompletion, null);
reject(game, f.target, { type: 'ship', territory: 'carthag', sector: 11, amount: 8 });
});
void test('a pre-Bidding commitment reserves the winning invoice before payment and fulfills after the real purchase', () => {
const { f, game: initial } = bound();
let game = advance(initial, g => g.phase === 3 && clean(g) && g.auction?.active === f.target);
const initialCard = game.auction!.cards[game.auction!.index].id;
reject(game, f.target, { type: 'bid', amount: 3 });
game = act(game, f.target, { type: 'bid', amount: 2 });
assert.equal(game.shipmentPromises![0].released, undefined);
game = advance(game, g => g.phase === 4 && clean(g));
assert.equal(game.players.find(p => p.id === f.target)!.spice, 8);
assert.ok(game.players.find(p => p.id === f.target)!.hand.some(c => c.id === initialCard));
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target);
const shipment = viewGame(game, f.target).shipmentCompletion!.actions.at(-1)!;
assert.equal(shipment.type, 'ship'); assert.equal(shipment.amount, 8);
game = act(game, f.target, shipment);
while (game.response) game = act(game, game.players.find(p => !game.response!.passed.includes(p.id))!.id, { type: 'passResponse' });
assert.equal(game.shipmentPromises![0].fulfilled, true);
assert.equal(game.players.find(p => p.id === f.target)!.spice, 0);
assert.equal(game.players.find(p => p.id === f.target)!.forces['carthag:11'], 8);
});
void test('controlled early resources include owned Charity and normal Revival, reopen readiness and forbid skipping required preparation', () => {
const { f, game: initial } = bound(g => {
const p = g.players.find(p => p.id === 'emperor')!;
p.spice = 0; p.reserves = 0; p.tanks = 3; p.forces = { 'polar_sink:0': 17 };
g.ready = [p.id];
}, 1);
let game = initial;
assert.equal(game.ready.includes(f.target), false);
assert.deepEqual(viewGame(game, f.target).shipmentPreparation?.actions, [{ type: 'charity' }]);
reject(game, f.target, { type: 'ready' });
game = act(game, f.target, { type: 'charity' });
game = advance(game, g => g.phase === 4 && clean(g));
assert.equal(viewGame(game, f.target).shipmentPreparation?.actions[0].type, 'revive');
reject(game, f.target, { type: 'ready' });
reject(game, f.target, { type: 'revive', amount: 2 });
game = act(game, f.target, { type: 'revive', amount: 1 });
assert.equal(game.shipmentPromises![0].released, undefined);
assert.equal(game.players.find(p => p.id === f.target)!.reserves, 1);
assert.equal(viewGame(game, f.target).shipmentPreparation, null);
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target);
assert.ok(viewGame(game, f.target).shipmentCompletion);
});
void test('all existing profiles retain minimal legal participation during early Bidding', () => {
const { f, game: boundGame } = bound();
const game = advance(boundGame, g => g.phase === 3 && clean(g) && g.auction?.active === f.target);
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
const action = botActions(view)[0]; assert.equal(action.type, 'passBid');
const done = applyAction(game, f.target, action);
assert.equal(done.shipmentPromises![0].released, undefined);
}
});
void test('a controlled retained Karama pays the actual winning invoice without spending the reserved shipping spice', () => {
const { f, game: initial } = bound(g => giveKarama(g));
let game = advance(initial, g => g.phase === 3 && clean(g) && g.auction?.active === f.target);
const card = player(game).hand.find(c => c.effect === 'karama')!.id;
game = act(game, f.target, { type: 'bid', amount: 7 });
game = advance(game, g => g.decision?.kind === 'auctionPayment' && g.decision.player === f.target);
assert.equal(viewGame(game, f.target).shipmentCompletion, null);
reject(game, f.target, { type: 'decision', karama: false });
assert.deepEqual(viewGame(game, f.target).shipmentPromisePolicy, [{ type: 'decision', karama: true, card }]);
game = act(game, f.target, { type: 'decision', karama: true, card });
game = advance(game, g => g.phase === 4 && clean(g));
assert.equal(player(game).spice, 10);
assert.equal(game.discard.filter(c => c.id === card).length, 1);
assert.equal(game.shipmentPromises![0].released, undefined);
});
void test('controlled auction and shipping discounts need distinct physical Karamas', () => {
for (const count of [1, 2]) {
const { f, game: initial } = bound(g => { player(g).spice = 4; giveKarama(g, count); });
let game = advance(initial, g => g.phase === 3 && clean(g) && g.auction?.active === f.target);
if (count === 1) reject(game, f.target, { type: 'bid', amount: 1 });
else {
game = act(game, f.target, { type: 'bid', amount: 1 });
game = advance(game, g => g.decision?.kind === 'auctionPayment' && g.decision.player === f.target);
const policy = viewGame(game, f.target).shipmentPromisePolicy!;
assert.equal(policy[0].karama, true);
game = act(game, f.target, policy[0]);
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target);
assert.equal(player(game).hand.filter(c => c.effect === 'karama').length, 1);
const price = viewGame(game, f.target).shipmentCompletion!.actions[0];
assert.equal(price.type, 'card'); assert.notEqual(price.card, policy[0].card);
}
}
});
void test('controlled Bidding escrow retains a promise through an unresolved ally bid and releases only its settled shortfall', () => {
for (const canceled of [false, true]) {
const { f, game: initial } = bound(allies);
let game = advance(initial, g => g.phase === 3 && clean(g) && g.auction?.active === f.target);
game = act(game, f.target, { type: 'pledgeAid', amount: 8 });
assert.equal(player(game).spice, 2);
assert.equal(game.shipmentPromises![0].released, undefined);
game = advance(game, g => g.phase === 3 && clean(g) && g.auction?.active === 'harkonnen');
game = act(game, 'harkonnen', { type: 'bid', amount: 3, allyPayment: 3 });
assert.equal(game.shipmentPromises![0].released, undefined);
game = advance(game, g => g.response?.kind === 'emperorIncome');
assert.equal(game.shipmentPromises![0].released, undefined);
if (canceled) game = act(game, 'harkonnen', { type: 'card',
card: player(game, 'harkonnen').hand.find(c => c.effect === 'karama')!.id, mode: 'cancel' });
game = advance(game, g => g.phase === 4 && clean(g));
assert.equal(player(game).spice, canceled ? 7 : 10);
assert.equal(game.shipmentPromises![0].released, canceled ? true : undefined);
}
});
void test('controlled incoming Bidding aid expires before shipment and cannot fund an early Yes', () => {
const f = createTruthtranceEarlyShipmentFixture();
let game = advance(f.game, g => g.phase === 3 && clean(g));
allies(game); player(game).spice = 0;
game = act(game, 'harkonnen', { type: 'pledgeAid', amount: 8 });
game = act(game, f.asker, f.declarationStep.action);
game = advance(game, g => g.truthtrance?.stage === 'ask');
game = act(game, f.asker, { ...f.askStep.action, question: { ...f.question, minimum: 1 } });
assert.equal(viewGame(game, f.target).truthShipmentAnswers!.includes('yes'), false);
assert.ok(viewGame(game, f.target).truthShipmentAnswers!.includes('no'));
});
void test('controlled extra ally revival cannot defer a voluntary promise-breaking cost to another responder', () => {
const { f, game: initial } = bound(g => {
allies(g); const ally = player(g, 'harkonnen'); ally.reserves--; ally.tanks++;
}, 10);
const game = advance(initial, g => g.phase === 4 && clean(g));
reject(game, f.target, { type: 'emperorRevival', amount: 1 });
});
void test('a controlled Revival answer reopens Ready and uses only the remaining current ordinary quota', () => {
const { f, game: initial } = bound(g => {
const p = player(g); p.spice = 1; p.reserves = 0; p.tanks = 3; p.forces = { 'polar_sink:0': 17 };
g.ready = [p.id];
}, 1, 4);
assert.equal(initial.ready.includes(f.target), false);
assert.deepEqual(viewGame(initial, f.target).shipmentPreparation?.actions, [{ type: 'revive', amount: 1 }]);
reject(initial, f.target, { type: 'ready' });
let game = act(initial, f.target, { type: 'revive', amount: 1 });
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target);
game = act(game, f.target, viewGame(game, f.target).shipmentCompletion!.actions[0]);
game = advance(game, clean);
assert.equal(game.shipmentPromises![0].fulfilled, true);
assert.equal(player(game).spice, 0);
});
void test('a controlled No answer retains the native legal Karama overbid payment for every existing profile', () => {
const f = createTruthtranceEarlyShipmentFixture();
giveKarama(f.game);
let game = act(f.game, f.asker, f.declarationStep.action);
game = advance(game, g => g.truthtrance?.stage === 'ask');
game = act(game, f.asker, f.askStep.action);
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
game = advance(game, g => g.phase === 3 && clean(g) && g.auction?.active === f.target);
game = act(game, f.target, { type: 'bid', amount: 11 });
game = advance(game, g => g.decision?.kind === 'auctionPayment' && g.decision.player === f.target);
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
const action = botActions(view)[0];
assert.equal(action.karama, true);
assert.doesNotThrow(() => applyAction(game, f.target, action));
}
});
+7 -2
View File
@@ -66,13 +66,18 @@ function reject(g: Game, actor: string, action: Action) {
const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action)); assert.deepEqual(g, before);
}
void test('same-phase target eligibility requires an unused active or original remaining turn and evaluates the actual Semuta respondent', () => {
void test('current-turn target timing distinguishes earlier phases from unused movement turns and evaluates the actual Semuta respondent', () => {
const g = fixture();
assert.equal(shipmentPromiseTurnAvailable(g, 'a', true), true);
assert.equal(shipmentPromiseTurnAvailable(g, 'p', true), true);
assert.equal(shipmentPromiseTurnAvailable(g, 'p', false), false);
assert.equal(shipmentPromiseTurnAvailable({ ...g, movementRemaining: ['a'] }, 'p', true), false);
assert.equal(shipmentPromiseTurnAvailable({ ...g, phase: 4 }, 'p', true), false);
assert.equal(shipmentPromiseTurnAvailable({ ...g, phase: 4 }, 'p', true), true);
assert.equal(shipmentPromiseTurnAvailable({ ...g, phase: 2 }, 'p', false), true,
'A prior-turn shipped flag does not consume the future current-turn opportunity');
assert.equal(shipmentPromiseModeSupported({ ...g, phase: 4 }, 'p'), true);
assert.equal(shipmentPromiseModeSupported({ ...g, phase: 4, advanced: true }, 'p'), false);
assert.equal(shipmentPromiseModeSupported({ ...g, phase: 2, techTokens: {} }, 'p'), false);
const mode = { ...g, advanced: true, active: 'r', expansions: ['choam'], semutaPreview: true,
players: [{ id: 'r', faction: 'richese' }, { id: 'p', faction: 'emperor' }] };
assert.equal(shipmentPromiseModeSupported(mode, 'p'), true);