Bind Truthtrance Karama promises to shipment beneficiaries

This commit is contained in:
Quintine committed 2026-10-27 03:41:27 +11:00
1 parent fd0b2f0fa5
commit fd38c51ffd
19 files changed
+1148 -41

No files matched your search

+7 -2
View File
@@ -54,12 +54,14 @@ export function finishTruthBid(game: Game, actions: TruthBidStep[] = []): Game {
export function createTruthBidFixture(options: {
advanced?: boolean; window?: 'waiting' | 'active'; targetKarama?: boolean;
firstLotEffect?: 'hajr';
extraGuild?: boolean;
} = {}) {
const advanced = options.advanced ?? false, window = options.window ?? 'waiting';
const asker = 'harkonnen', opponent = 'atreides', target = 'emperor';
let game = createGame('TRUTHBIDSOURCE', newPlayer(asker, 'Harkonnen', 'harkonnen'), advanced);
joinGame(game, newPlayer(opponent, 'Atreides', 'atreides'));
joinGame(game, newPlayer(target, 'Emperor', 'emperor'));
if (options.extraGuild) joinGame(game, newPlayer('guild', 'Guild', 'guild'));
const initial = structuredClone(game), actions: TruthBidStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthBid(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthBid(game, until, actions); };
@@ -75,7 +77,9 @@ export function createTruthBidFixture(options: {
const lotCards = [game.deck.find(card => options.firstLotEffect ? card.effect === options.firstLotEffect : card.kind === 'projectile'), game.deck.find(card => card.kind === 'poison'),
game.deck.find(card => card.kind === 'snooper')];
assert.ok(filler && targetCard && lotCards.every(Boolean));
const deal = [truths[0].id, truths[1].id, filler.id, targetCard.id];
const guildCard = options.extraGuild ? game.deck.find(card => card.kind === 'hero') : null;
assert.ok(!options.extraGuild || guildCard);
const deal = [truths[0].id, truths[1].id, filler.id, targetCard.id, ...(guildCard ? [guildCard.id] : [])];
const prefix = [...deal, ...lotCards.map(card => card!.id)];
const dealOrder = prefix.map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
@@ -88,6 +92,7 @@ export function createTruthBidFixture(options: {
assert.deepEqual(truthBidPlayer(game, asker).hand.map(card => card.id), truths.map(card => card.id));
assert.deepEqual(truthBidPlayer(game, opponent).hand.map(card => card.id), [filler.id]);
assert.deepEqual(truthBidPlayer(game, target).hand.map(card => card.id), [targetCard.id]);
if (guildCard) assert.deepEqual(truthBidPlayer(game, 'guild').hand.map(card => card.id), [guildCard.id]);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
@@ -111,7 +116,7 @@ export function createTruthBidFixture(options: {
lotCard: game.auction!.cards[game.auction!.index].id, lotCards: lotCards.map(card => card!.id),
deal, dealOrder, spiceOrder, originalSpice, originalCards, originalInventory: originalCards,
actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
staging: [] as string[], sourceDisclosure: 'Genuine public H/A/E base33 setup. Only original unopened four-card starting deal, three ordinary lot positions, two ordinary Spice positions and scalar lotteries are selected. Harkonnen owns both physical Truthtrances; active Emperor additionally follows real H/A passes. No played hand, wallet, force, lot, Truthtrance or promise is installed.' };
staging: [] as string[], sourceDisclosure: `Genuine public ${options.extraGuild ? 'H/A/E/G' : 'H/A/E'} base33 setup. Only original unopened ${options.extraGuild ? 'five' : 'four'}-card starting deal, three ordinary lot positions, two ordinary Spice positions and scalar lotteries are selected. Harkonnen owns both physical Truthtrances; active Emperor additionally follows real H/A passes. No played hand, wallet, force, lot, Truthtrance or promise is installed.` };
}
export type TruthBidFixture = ReturnType<typeof createTruthBidFixture>;
+120
View File
@@ -0,0 +1,120 @@
import assert from 'node:assert/strict';
import { type Action, type Game } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
import { windowCardOpportunity } from '../game/window-card-play';
import {
actTruthTurnKarama, advanceTruthTurnKarama, closeTruthTurnKaramaLot, createTruthTurnKaramaFixture,
declareTruthTurnKarama, finishTruthTurnKarama, nextTruthTurnKarama, nextTruthTurnKaramaNative,
settleTruthTurnKarama, truthTurnKaramaClean, truthTurnKaramaInventory, truthTurnKaramaPlayer,
truthTurnKaramaPromise, truthTurnKaramaTraitors, type TruthTurnKaramaLeaf, type TruthTurnKaramaStep,
} from './fixture-truth-turn-karama';
export type TruthTurnKaramaShipmentLeaf = TruthTurnKaramaLeaf |
{ kind: 'windowCardPlay'; effect: 'karamaShipment'; source: string };
export type TruthTurnKaramaShipmentExpression = MixedActionExpression<TruthTurnKaramaShipmentLeaf>;
export type TruthTurnKaramaShipmentStep = TruthTurnKaramaStep;
export const actTruthTurnKaramaShipment = actTruthTurnKarama;
export const truthTurnKaramaShipmentClean = truthTurnKaramaClean;
export const truthTurnKaramaShipmentPlayer = truthTurnKaramaPlayer;
export const truthTurnKaramaShipmentInventory = truthTurnKaramaInventory;
export const truthTurnKaramaShipmentTraitors = truthTurnKaramaTraitors;
export const truthTurnKaramaShipmentPromise = truthTurnKaramaPromise;
export const nextTruthTurnKaramaShipment = nextTruthTurnKarama;
export const nextTruthTurnKaramaShipmentNative = nextTruthTurnKaramaNative;
export const advanceTruthTurnKaramaShipment = advanceTruthTurnKarama;
export const settleTruthTurnKaramaShipment = settleTruthTurnKarama;
export const finishTruthTurnKaramaShipment = finishTruthTurnKarama;
export const truthTurnKaramaShipmentFact = (fact: TruthFact): TruthTurnKaramaShipmentExpression => ({ kind: 'fact', fact });
export const truthTurnKaramaShipmentPlay = (source: string): TruthTurnKaramaShipmentExpression =>
({ kind: 'action', action: { kind: 'windowCardPlay', effect: 'karamaShipment', source } });
export const truthTurnKaramaShipmentPayment = (recipient: string, minimum = 2): TruthTurnKaramaShipmentExpression =>
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
export const truthTurnKaramaShipmentShip = (territory = 'polar_sink', minimum = 1): TruthTurnKaramaShipmentExpression =>
({ kind: 'action', action: { kind: 'ship', territory, minimum } });
export const truthTurnKaramaShipmentGroup = (kind: 'and' | 'or', ...terms: TruthTurnKaramaShipmentExpression[]): TruthTurnKaramaShipmentExpression =>
({ kind, terms });
/** Original H/A/E two-Truth source, followed only by native passes/readiness
* and actual prior Movement endings. The optional earlier printed auction use
* really spends that original card; it supplies no shipment-role receipt. */
export function createTruthTurnKaramaShipmentFixture(options: {
beneficiary?: 'own' | 'foreign'; advanced?: boolean; priorAuctionSpend?: boolean;
} = {}) {
const base = createTruthTurnKaramaFixture({ advanced: options.advanced, window: 'active' });
const kind = options.beneficiary ?? 'own', beneficiary = kind === 'own' ? base.target : base.asker;
const priorAuctionSpend = options.priorAuctionSpend ?? false;
let game = structuredClone(base.game); const actions = structuredClone(base.sourceActions), inherited = actions.length;
let auctionCost: { before: Game; steps: TruthTurnKaramaShipmentStep[]; after: Game } | null = null;
if (priorAuctionSpend) {
const before = structuredClone(game), from = actions.length;
game = actTruthTurnKaramaShipment(game, base.target, base.purchase, actions);
game = settleTruthTurnKaramaShipment(game, actions);
auctionCost = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
assert.equal(game.discard.filter(card => card.id === base.karama).length, 1);
assert.ok(truthTurnKaramaShipmentPlayer(game, base.target).hand.some(card => card.id === base.lotCard));
}
const beforeAuctionClose = structuredClone(game), closeAt = actions.length;
game = closeTruthTurnKaramaLot(game, base, actions);
const auctionClose = { before: beforeAuctionClose, steps: structuredClone(actions.slice(closeAt)), after: structuredClone(game) };
game = advanceTruthTurnKaramaShipment(game,
state => state.phase === 5 && state.active === beneficiary && truthTurnKaramaShipmentClean(state), actions, true);
const opportunity = windowCardOpportunity(game, 'karamaShipment'); assert.ok(opportunity?.effect === 'karamaShipment');
assert.equal(opportunity.recipient, beneficiary);
assert.equal(opportunity.source, JSON.stringify(['karamaShipment', game.turn, beneficiary, 'original']));
assert.equal(truthTurnKaramaShipmentPlayer(game, beneficiary).shipped, false); assert.equal(game.karamaShipping, null);
assert.deepEqual(truthTurnKaramaShipmentPlayer(game, base.asker).hand.map(card => card.id), base.truths);
assert.equal(truthTurnKaramaShipmentPlayer(game, base.target).hand.some(card => card.id === base.karama), !priorAuctionSpend);
assert.deepEqual(truthTurnKaramaShipmentInventory(game), base.originalCards);
assert.deepEqual(truthTurnKaramaShipmentTraitors(game), base.originalTraitors);
const source = opportunity.source, asker = base.asker, target = base.target, recipient = asker;
const mixed = truthTurnKaramaShipmentGroup('and', truthTurnKaramaShipmentPlay(source), truthTurnKaramaShipmentPayment(recipient));
const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthTurnKaramaShipmentPlayer(game, target).spice };
const grant: Action = { type: 'card', card: base.karama, mode: 'shipment', target: beneficiary };
const shipment: Action = { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 };
return { game, base, initial: base.initial, setup: base.setup, afterSetup: base.afterSetup,
beforeStartingDeal: base.beforeStartingDeal, asker, target, recipient, observer: base.observer,
truths: base.truths, truth: base.truth, karama: base.karama, card: base.karama,
beneficiary, kind, priorAuctionSpend, opportunity, source, effect: 'karamaShipment' as const,
mixed, question, spiceFact, grant, shipment, ownGrant: { ...grant, target } satisfies Action,
payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action,
endMovement: { type: 'endMovement' } satisfies Action,
auctionCost, beforeAuctionClose, auctionClose, originalCards: base.originalCards,
originalTraitors: base.originalTraitors, originalSpice: base.originalSpice,
actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { inherited, nativeContinuation: actions.length - inherited, selected: actions.length,
actualPriorAuctionCost: auctionCost?.steps.length ?? 0, actualAuctionClose: auctionClose.steps.length },
sourceDisclosure: `${base.sourceDisclosure} Actually finish the ordinary auction using native passes, readiness and any original responses; ` +
`stop at ${kind === 'own' ? 'Emperor' : 'Harkonnen'}'s real original unused Shipment turn. ` +
(priorAuctionSpend ? 'Before advancing, Emperor actually spends its original printed Karama for the original free auction card; the later Shipment source still has no future printed card or receipt. '
: 'Emperor retains its original printed Karama; Harkonnen retains both actual opening Truthtrances. ') +
'Only original array-recorded actions are continued. A later actual printed price spend is independent of delivery; its native desert tariff halves from two to one. No hand, wallet, army, phase, shipment, price grant, cost or promise is installed.',
};
}
export type TruthTurnKaramaShipmentFixture = ReturnType<typeof createTruthTurnKaramaShipmentFixture>;
export function declareTruthTurnKaramaShipment(f: TruthTurnKaramaShipmentFixture, source = f.game, copy: 0 | 1 = 0,
actions: TruthTurnKaramaShipmentStep[] = []) {
return declareTruthTurnKarama(f.base, source, copy, actions);
}
export function askTruthTurnKaramaShipment(game: Game, asker: string, target: string, mixed: TruthTurnKaramaShipmentExpression,
actions: TruthTurnKaramaShipmentStep[] = []): Game {
return actTruthTurnKaramaShipment(game, asker, { type: 'truthAsk', question: {
kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed),
} }, actions);
}
export function answerTruthTurnKaramaShipment(game: Game, target: string, answer: 'yes' | 'no' | 'unknown',
actions: TruthTurnKaramaShipmentStep[] = []): Game {
return actTruthTurnKaramaShipment(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthTurnKaramaShipment(f: TruthTurnKaramaShipmentFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed,
source = f.game, copy: 0 | 1 = 0, actions: TruthTurnKaramaShipmentStep[] = []) {
const declared = declareTruthTurnKaramaShipment(f, source, copy, actions);
const asked = askTruthTurnKaramaShipment(declared.game, f.asker, f.target, mixed, actions);
return { game: answerTruthTurnKaramaShipment(asked, f.target, answer, actions), declared, asked, actions };
}
export function closeTruthTurnKaramaShipment(game: Game, f: TruthTurnKaramaShipmentFixture,
actions: TruthTurnKaramaShipmentStep[] = []): Game {
return advanceTruthTurnKaramaShipment(game, state => truthTurnKaramaShipmentClean(state) &&
!windowCardOpportunity(state, 'karamaShipment', f.source), actions, true);
}
+102 -1
View File
@@ -32,8 +32,10 @@ const forceRevival = (counter: 'total' | 'normal' | 'elite' = 'total', minimum =
const leaderRevival = (leader: string): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
const finalAlliance = (partner = 'r'): Expression => ({ kind: 'action', action: { kind: 'alliance', partner } });
const printedCard = (effect: 'hajr' | 'ghola' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
const shipmentPriceSource = (recipient = 'p', cunning?: string) => JSON.stringify(['karamaShipment', 2, recipient,
...(cunning ? ['cunning', cunning] : ['original'])]);
const windowClaim = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'controlled-native-blow'
: effect === 'karamaAuction' ? 'ordinary-auction:2:2' : 'storm:2'): Expression =>
: effect === 'karamaAuction' ? 'ordinary-auction:2:2' : effect === 'karamaShipment' ? shipmentPriceSource() : 'storm:2'): Expression =>
({ kind: 'action', action: { kind: 'windowCardPlay', effect, source } });
const planClaim = (claim: MixedBattleActionClaim = { kind: 'dial', compare: 'eq', value: 3 }): Expression =>
({ kind: 'action', action: { kind: 'battlePlan', battle: 'controlled-native-battle', territory: 'arrakeen', claim } });
@@ -113,6 +115,15 @@ function auctionKarama(invoice = false): GameView {
if (!invoice) { game.auction!.active = 'a'; game.active = 'a'; }
return game;
}
function shipmentKarama(recipient = 'p', cunning?: string): GameView {
const game = projected(5, { type: 'card', card: 'controlled-printed-karama', mode: 'shipment', target: recipient });
game.active = recipient; game.movementRemaining = ['p', 'a', 'r']; game.karamaShipping = null;
game.karamaShipmentWindows = game.movementRemaining.map(player => ({ effect: 'karamaShipment' as const,
turn: 2, recipient: player, source: shipmentPriceSource(player, player === recipient ? cunning : undefined) }));
game.karamaShipmentCards = ['controlled-printed-karama'];
game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim('karamaShipment', shipmentPriceSource(recipient, cunning)), payment()] };
return game;
}
function nexus(next: Action = { type: 'alliance', target: 'r' }): GameView {
const game = projected(1, next);
game.nexus = true; game.spiceWindow = null; game.active = 'a';
@@ -919,6 +930,96 @@ void test('current ordinary-lot printed-role questions retain quoted owned Box r
}
});
void test('shipment-price picker names the current public beneficiary without reading card or native funding quotes', () => {
const game = shipmentKarama('a'), e = editor(physical(), false, game);
for (const player of game.players) for (const key of ['hand', 'spice', 'forces', 'leaders', 'traitors', 'reserves'])
Object.defineProperty(player, key, { get() { throw Error(`No private shipment-price authoring ${key} read`); }, configurable: true });
Object.defineProperty(game, 'karamaShipmentCards', { get() { throw Error('The public picker must not inspect the giver’s native card quote'); } });
select(e.tree(), '1', 'action', 'windowCardPlay'); assert.deepEqual(e.value, windowClaim('karamaShipment', shipmentPriceSource('a')));
assert.equal(e.error(), null); assert.match(e.html(), /Asker’s current native shipment price/);
select(e.tree(), '1', 'window-source', ''); assert.ok(e.error());
select(e.tree(), '1', 'window-source', shipmentPriceSource('p')); assert.ok(e.error(), 'An inactive entitlement is not a new public picker option.');
select(e.tree(), '1', 'window-source', shipmentPriceSource('a')); assert.equal(e.error(), null);
game.active = 'p'; assert.ok(e.error()); select(e.tree(), '1', 'window-source', shipmentPriceSource('p')); assert.equal(e.error(), null);
const cunning = shipmentPriceSource('p', 'controlled-cunning-window');
game.karamaShipmentWindows = game.karamaShipmentWindows.map(window => window.recipient === 'p' ? { ...window, source: cunning } : window);
assert.ok(e.error()); select(e.tree(), '1', 'window-source', cunning); assert.equal(e.error(), null);
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'windowCardPlay', effect: 'karamaShipment', source: cunning, target: 'p' } }, game, 'p'));
const fullGame = shipmentKarama(), full = editor({ kind: 'or', terms: [windowClaim('karamaShipment'), fact()] }, false, fullGame);
for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'windowCardPlay'); }
assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/);
const deep = editor({ kind: 'and', terms: [fact(), windowClaim('karamaShipment')] }, false, fullGame);
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.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
});
void test('printed shipment-price guides forward exact own or foreign-giver grants and explain the distinct actual shipment', () => {
for (const recipient of ['p', 'a']) for (const cunning of [undefined, 'controlled-cunning-window']) {
const game = shipmentKarama(recipient, cunning), sent: Action[] = [];
for (const key of ['windowCards', 'compiled', 'preparation'])
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} shipment-price read`); }, configurable: true });
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
assert.equal(sent[0].type, 'card'); assert.equal(sent[0].mode, 'shipment'); assert.equal(sent[0].target, recipient);
assert.equal(sent[0].turnActionPromiseEvent, event); assert.equal(sent[0].turnActionPromisePhase, 5);
assert.match(renderToStaticMarkup(button), /grant .* this native shipment price/);
game.turnActionGuide = null;
const html = markup(game); assert.match(html, /price grant counts separately from an actual force shipment/);
assert.match(html, /does not require the recipient to ship forces/); assert.match(html, /original shipment-price opportunity ends/);
assert.doesNotMatch(html, /windowCards|compiled|controlled-printed-karama/);
}
});
void test('saved shipment-price guides expire with the active source, target, quote, physical card and grant state', () => {
for (const recipient of ['p', 'a']) for (const change of [
(game: GameView) => { game.active = recipient === 'p' ? 'a' : 'p'; },
(game: GameView) => { game.karamaShipmentWindows = game.karamaShipmentWindows.map(window => window.recipient === recipient
? { ...window, source: shipmentPriceSource(recipient, 'later-cunning-window') } : window); },
(game: GameView) => { game.turnActionGuide!.action.target = recipient === 'p' ? 'a' : 'p'; },
(game: GameView) => { game.karamaShipmentCards = []; },
(game: GameView) => { const card = game.players.find(player => player.id === 'p')!.hand?.[0]; assert.ok(card); card.kind = 'worthless'; },
(game: GameView) => { game.karamaShipping = { player: recipient, owner: 'p' }; },
(game: GameView) => { game.turnActionPromises[0].mixed = windowClaim('karamaShipment', shipmentPriceSource('r')); },
]) {
const game = shipmentKarama(recipient), saved = guide({ game, busy: false, act() { assert.fail('A stale shipment-price grant cannot send'); } });
assert.equal(saved.props.disabled, false); change(game); press(saved);
}
const game = shipmentKarama('a'), props: Props = { game, busy: false, act() { assert.fail('A replaced grant callback cannot send'); } };
const saved = guide(props); props.act = () => assert.fail('A replaced callback cannot receive an old grant'); press(saved);
});
void test('current printed shipment roles reject forged grants and inactive targets without blocking older native preparation guides', () => {
for (const extra of [{ target: 'r' }, { target: undefined }, { amount: 3 }, { leader: 'foreign-leader' }, { mode: 'purchase' }]) {
const game = shipmentKarama(); Object.assign(game.turnActionGuide!.action, extra);
const button = guide({ game, busy: false, act() { assert.fail('An unsupported shipment-price packet cannot send'); } });
assert.equal(button.props.disabled, true); press(button);
}
const converted = shipmentKarama(), card = converted.players.find(player => player.id === 'p')!.hand?.[0]; assert.ok(card);
card.kind = 'worthless'; delete card.effect;
assert.equal(guide({ game: converted, busy: false, act() { assert.fail('Worthless conversion is not printed shipment-role use'); } }).props.disabled, true);
const unquoted = shipmentKarama(); unquoted.karamaShipmentCards = [];
assert.equal(guide({ game: unquoted, busy: false, act() { assert.fail('An absent native shipment card quote cannot be bypassed'); } }).props.disabled, true);
const inactive = shipmentKarama('a'); inactive.active = 'p';
assert.equal(guide({ game: inactive, busy: false, act() { assert.fail('An inactive beneficiary cannot receive this grant control'); } }).props.disabled, true);
for (const mixed of [payment(), windowClaim('karamaShipment', shipmentPriceSource('a'))]) {
const legacy = shipmentKarama(); legacy.turnActionPromises[0].mixed = mixed; legacy.karamaShipmentCards = [];
const sent: Action[] = [], button = guide({ game: legacy, busy: false, act(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], legacy.turnActionGuide!.action);
}
});
void test('a current shipment-price question retains a quoted Box route for an owned or foreign giver', () => {
for (const recipient of ['p', 'a']) {
const game = shipmentKarama(recipient), me = game.players.find(player => player.id === 'p')!;
const box = { id: 'controlled-box', name: 'Nullentropy Box', kind: 'special' as const, effect: 'nullentropyBox' };
me.hand = [box]; game.nullentropy = { card: box, blocked: null, search: null };
game.turnActionGuide!.action = { type: 'card', card: box.id, turnActionPromiseEvent: event, turnActionPromisePhase: 5 };
const sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
game.nullentropy.blocked = 'The native Box is no longer available.'; press(button); assert.equal(sent.length, 1);
}
});
void test('turn-action printed-role editor uses public names without holdings or private stages and retains the shared logical budget', () => {
const game = projected();
for (const player of game.players) for (const key of ['hand', 'spice', 'traitors', 'faceDancers', 'tanks', 'leaders', 'hajrAvailable'])
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { windowCardOpportunity } from '../game/window-card-play';
import { createTruthBidFixture, declareTruthBid, actTruthBid as act, advanceTruthBid as advance,
truthBidClean as clean, truthBidPlayer as player, truthBidInventory as inventory } from './fixture-truth-bid';
import { truthTraitorDecisionTraitors as traitors } from './fixture-truth-traitor-decision';
import { askTruthTurnKaramaShipment as ask, truthTurnKaramaShipmentGroup as group,
truthTurnKaramaShipmentPlay as play, truthTurnKaramaShipmentPayment as payment } from './fixture-truth-turn-karama-shipment';
/** Original unopened H2/A1/E1/G1 deal, genuine remaining auction/Revival turns,
* and actual pre-answer bribe. No played purse, response or promise is installed. */
function source(remaining: 2 | 4) {
const f = createTruthBidFixture({ advanced: true, extraGuild: true, targetKarama: true, window: 'active' });
const actions = structuredClone(f.sourceActions);
let game = advance(f.game, state => state.phase === 5 && state.active === f.target && clean(state), actions);
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 10 - remaining }, actions);
assert.equal(player(game, f.target).spice, remaining);
assert.equal(traitors(game).length, 20); assert.deepEqual(inventory(game), f.originalCards);
const window = windowCardOpportunity(game, 'karamaShipment'); assert.ok(window);
const declared = declareTruthBid(f, game); actions.push(...declared.actions);
game = ask(declared.game, f.asker, f.target, group('or', play(window.source), payment(f.asker)), actions);
assert.ok(viewGame(game, f.target).truthTurnActionAnswers?.includes('yes'));
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }, actions);
return { f, game, actions, window };
}
void test('an own shipment cannot spend its remaining payment alternative before a foreign Guild stop decision', () => {
const { f, game, actions } = source(2), before = structuredClone(game);
assert.throws(() => act(game, f.target, { type: 'ship', territory: 'polar_sink', sector: 0,
amount: 1, elite: 0, allyPayment: 0 }, actions), /Truthtrance|completion/);
assert.deepEqual(game, before);
let paid = act(game, f.target, { type: 'bribe', target: f.asker, amount: 2 }, actions);
assert.equal(paid.turnActionPromises![0].fulfilled, true); assert.equal(player(paid, f.target).spice, 0);
paid = act(paid, f.target, { type: 'endMovement' }, actions);
assert.equal(paid.discard.filter(card => card.id === f.karama).length, 0);
assert.deepEqual(inventory(paid), f.originalCards); assert.equal(traitors(paid).length, 20);
});
void test('a funded payment alternative survives actual unpriced delivery and Guild income before being paid', () => {
const { f, actions, game: initial } = source(4);
let game = initial;
game = act(game, f.target, { type: 'ship', territory: 'polar_sink', sector: 0,
amount: 1, elite: 0, allyPayment: 0 }, actions);
assert.equal(game.decision?.kind, 'guildShipment');
assert.equal(game.decision?.player, 'guild'); assert.equal(player(game, f.target).spice, 4);
game = advance(game, clean, actions);
assert.equal(player(game, f.target).spice, 2); assert.equal(player(game, f.target).forces['polar_sink:0'], 1);
assert.equal(game.turnActionPromises![0].released, false);
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 2 }, actions);
assert.equal(player(game, f.target).spice, 0); assert.equal(game.turnActionPromises![0].fulfilled, true);
assert.ok(player(game, f.target).hand.some(card => card.id === f.karama));
assert.deepEqual(inventory(game), f.originalCards); assert.equal(traitors(game).length, 20);
});
@@ -0,0 +1,291 @@
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 { truthFactAnswer } from '../game/truthtrance';
import { windowCardOpportunity } from '../game/window-card-play';
import {
actTruthTurnKaramaShipment as act, advanceTruthTurnKaramaShipment as advance,
answerTruthTurnKaramaShipment as answer, askTruthTurnKaramaShipment as ask,
bindTruthTurnKaramaShipment as bind, closeTruthTurnKaramaShipment as close,
createTruthTurnKaramaShipmentFixture as fixture, declareTruthTurnKaramaShipment as declare,
finishTruthTurnKaramaShipment as finish, nextTruthTurnKaramaShipmentNative as nativeStep,
settleTruthTurnKaramaShipment as settle, truthTurnKaramaShipmentClean as clean,
truthTurnKaramaShipmentFact as fact, truthTurnKaramaShipmentGroup as group,
truthTurnKaramaShipmentInventory as inventory, truthTurnKaramaShipmentPayment as payment,
truthTurnKaramaShipmentPlay as play, truthTurnKaramaShipmentPlayer as player,
truthTurnKaramaShipmentPromise as promise, truthTurnKaramaShipmentShip as ship,
truthTurnKaramaShipmentTraitors as traitors,
type TruthTurnKaramaShipmentFixture, type TruthTurnKaramaShipmentStep,
} from './fixture-truth-turn-karama-shipment';
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: TruthTurnKaramaShipmentFixture, copies: 1 | 2 = 1) {
for (const truth of f.truths.slice(0, copies)) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(traitors(game), f.originalTraitors);
}
function receipt(game: Game, index = 0) {
const stage = game.turnActionPromises![index].windowCards?.karamaShipment; assert.ok(stage); return stage;
}
const armies = (game: Game) => game.players.map(candidate => ({ id: candidate.id, forces: candidate.forces,
reserves: candidate.reserves, tanks: candidate.tanks, elites: candidate.elites }));
void test('shipment-price sources keep original dealt Truths and Karama through actual all-pass and Movement prefixes', () => {
for (const advanced of [false, true]) for (const beneficiary of ['own', 'foreign'] as const) {
const f = fixture({ advanced, beneficiary }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.equal(f.setup.deck.length, 33); assert.equal(f.originalCards.length, 33); assert.equal(f.originalTraitors.length, 15);
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths);
assert.deepEqual(player(f.game, f.target).hand.map(card => card.id), [f.karama]);
assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.beneficiary); assert.equal(f.game.advanced, advanced);
assert.equal(player(f.game, f.target).spice, 10); assert.equal(player(f.game, f.beneficiary).shipped, false);
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceCounts.selected, f.sourceActions.length);
assert.equal(f.sourceCounts.inherited, f.base.sourceActionCount);
assert.equal(f.sourceCounts.nativeContinuation, f.sourceActions.length - f.base.sourceActionCount);
assert.equal(f.sourceCounts.actualAuctionClose, f.auctionClose.steps.length);
assert.ok(f.sourceActions.some(step => step.actor === f.target && step.action.type === 'passBid'));
assert.deepEqual(windowCardOpportunity(f.game, 'karamaShipment'), f.opportunity);
assert.equal(f.source, JSON.stringify(['karamaShipment', 1, f.beneficiary, 'original']));
const own = viewGame(f.game, f.target); assert.deepEqual(own.karamaShipmentCards, [f.karama]);
assert.ok(own.karamaShipmentWindows.some(window => window.source === f.source && window.recipient === f.beneficiary));
assert.deepEqual(viewGame(f.game, f.asker).karamaShipmentCards, []);
assert.equal(f.game.turnActionPromises?.length ?? 0, 0);
assert.deepEqual(inventory(f.game), f.originalCards); assert.deepEqual(traitors(f.game), f.originalTraitors);
}
});
void test('actual self or foreign printed price grant fulfills independently of spice, movement and delivery', () => {
for (const beneficiary of ['own', 'foreign'] as const) {
const f = fixture({ beneficiary }); let game = bind(f, 'yes', play(f.source)).game;
const beforeArmies = structuredClone(armies(game)), purses = game.players.map(candidate => candidate.spice);
assert.deepEqual(receipt(game), { source: f.source, played: false, closed: false });
game = settle(act(game, f.target, f.grant));
assert.equal(game.karamaShipping?.owner, f.target); assert.equal(game.karamaShipping?.player, f.beneficiary);
assert.equal(game.karamaShipping?.card, f.karama); assert.equal(receipt(game).played, true);
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(armies(game), beforeArmies);
assert.deepEqual(game.players.map(candidate => candidate.spice), purses);
assert.equal(player(game, f.beneficiary).shipped, false); assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
game = act(game, f.beneficiary, f.endMovement);
assert.equal(game.karamaShipping, null); assert.deepEqual(armies(game), beforeArmies);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
}
});
void test('real own and foreign shipments pay the native half-price bank tariff after the printed cost', () => {
for (const beneficiary of ['own', 'foreign'] as const) {
const f = fixture({ beneficiary }); let game = bind(f, 'yes', play(f.source)).game;
game = settle(act(game, f.target, f.grant)); const purse = player(game, f.beneficiary).spice;
const reserves = player(game, f.beneficiary).reserves;
game = settle(act(game, f.beneficiary, f.shipment));
assert.equal(player(game, f.beneficiary).spice, purse - 1);
assert.equal(player(game, f.beneficiary).reserves, reserves - 1);
assert.equal(player(game, f.beneficiary).forces['polar_sink:0'], 1);
assert.equal(player(game, f.beneficiary).shipped, true); assert.equal(game.karamaShipping, null);
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
physical(game, f);
}
});
void test('printed price AND actual shipment AND payment share the exact funded purse while facts freeze before payment', () => {
const f = fixture(), mixed = group('and', fact(f.spiceFact), play(f.source), ship(), payment(f.recipient, 9));
let game = bind(f, 'yes', mixed).game;
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 9 });
assert.equal(player(game, f.target).spice, 1); assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
game = settle(act(game, f.target, f.grant)); assert.ok(!promise(game, f.target).fulfilled);
game = settle(act(game, f.target, f.shipment));
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.recipient).bribes, 9);
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(receipt(game).played, true);
assert.equal(player(game, f.target).forces['polar_sink:0'], 1); physical(game, f);
});
void test('printed shipment funding reserves the half-price fee from the actual beneficiary rather than a foreign card giver', () => {
const own = fixture(), unaffordable = group('and', play(own.source), ship(), payment(own.recipient, 10));
const asked = ask(declare(own).game, own.asker, own.target, unaffordable);
assert.deepEqual(viewGame(asked, own.target).truthTurnActionAnswers, ['no']);
rejected(asked, own.target, { type: 'truthAnswer', answer: 'yes' });
const foreign = fixture({ beneficiary: 'foreign' });
const mixed = group('and', play(foreign.source), payment(foreign.recipient, 10));
let game = bind(foreign, 'yes', mixed).game;
const beneficiaryPurse = player(game, foreign.beneficiary).spice;
game = act(game, foreign.target, { type: 'bribe', target: foreign.recipient, amount: 10 });
assert.equal(player(game, foreign.target).spice, 0);
assert.equal(player(game, foreign.beneficiary).spice, beneficiaryPurse, 'The actual bribe receipt remains deferred');
game = settle(act(game, foreign.target, foreign.grant));
assert.equal(promise(game, foreign.target).fulfilled, true); assert.ok(!promise(game, foreign.target).released);
game = settle(act(game, foreign.beneficiary, foreign.shipment));
assert.equal(player(game, foreign.target).spice, 0);
assert.equal(player(game, foreign.beneficiary).spice, beneficiaryPurse - 1);
assert.equal(player(game, foreign.beneficiary).forces['polar_sink:0'], 1);
assert.equal(game.discard.filter(card => card.id === foreign.karama).length, 1);
assert.equal(promise(game, foreign.target).fulfilled, true); assert.ok(!promise(game, foreign.target).released);
physical(game, foreign);
});
void test('whole price OR payment Yes preserves the manual grant route and payment route for either current beneficiary', () => {
for (const beneficiary of ['own', 'foreign'] as const) {
const f = fixture({ beneficiary }), mixed = group('or', play(f.source), payment(f.recipient));
const bound = bind(f, 'yes', mixed).game;
const granted = settle(act(bound, f.target, f.grant));
assert.equal(promise(granted, f.target).fulfilled, true); assert.equal(player(granted, f.recipient).bribes, 0);
let paid = act(bound, f.target, f.payment); paid = act(paid, f.beneficiary, f.endMovement);
assert.equal(promise(paid, f.target).fulfilled, true); assert.equal(receipt(paid).played, false);
assert.ok(player(paid, f.target).hand.some(card => card.id === f.karama)); physical(granted, f); physical(paid, f);
}
});
void test('whole price AND payment No preserves either earlier action and rejects the real completing grant or payment', () => {
const f = fixture();
for (const first of ['payment', 'grant'] as const) {
let game = bind(f, 'no').game;
if (first === 'payment') {
game = act(game, f.target, f.payment); rejected(game, f.target, f.grant);
} else {
game = settle(act(game, f.target, f.grant)); rejected(game, f.target, f.payment);
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
}
assert.ok(!promise(game, f.target).released); game = finish(game);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
}
});
void test('whole price OR payment No keeps the actual cumulative payment cap after the beneficiary closes the source', () => {
for (const beneficiary of ['own', 'foreign'] as const) {
const f = fixture({ beneficiary }), mixed = group('or', play(f.source), payment(f.recipient));
let game = bind(f, 'no', mixed).game; rejected(game, f.target, f.grant); rejected(game, f.target, f.payment);
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); game = close(game, f);
assert.deepEqual(receipt(game), { source: f.source, played: false, closed: true });
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
assert.ok(!promise(game, f.target).released); game = finish(game);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
}
});
void test('own shipment or Movement end cannot evade a required printed price while the original path remains feasible', () => {
const f = fixture(); let game = bind(f, 'yes', play(f.source)).game;
rejected(game, f.target, f.shipment); rejected(game, f.target, f.endMovement);
assert.equal(player(game, f.target).spice, 10); assert.equal(player(game, f.target).shipped, false);
assert.ok(player(game, f.target).hand.some(card => card.id === f.karama));
game = settle(act(game, f.target, f.grant)); game = act(game, f.target, f.endMovement);
assert.equal(player(game, f.target).forces['polar_sink:0'] ?? 0, 0);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
});
void test('whole No price AND shipment checks the prospective physical shipment before closing its price source', () => {
const f = fixture(), mixed = group('and', play(f.source), ship());
let granted = bind(f, 'no', mixed).game; granted = settle(act(granted, f.target, f.grant));
rejected(granted, f.target, f.shipment); assert.equal(player(granted, f.target).spice, 10);
assert.equal(player(granted, f.target).forces['polar_sink:0'] ?? 0, 0);
granted = act(granted, f.target, f.endMovement); granted = finish(granted);
assert.equal(promise(granted, f.target).fulfilled, true); assert.equal(receipt(granted).played, true); physical(granted, f);
let shipped = bind(f, 'no', mixed).game; shipped = settle(act(shipped, f.target, f.shipment));
assert.equal(player(shipped, f.target).spice, 8); assert.equal(receipt(shipped).played, false);
assert.ok(player(shipped, f.target).hand.some(card => card.id === f.karama));
shipped = finish(shipped); assert.equal(promise(shipped, f.target).fulfilled, true); physical(shipped, f);
});
void test('foreign shipment or end stays legal and releases only that source before a second genuine own-beneficiary question', () => {
const f = fixture({ beneficiary: 'foreign' });
for (const action of [f.shipment, f.endMovement]) {
const actions: TruthTurnKaramaShipmentStep[] = [];
let game = bind(f, 'yes', play(f.source), f.game, 0, actions).game;
game = settle(act(game, f.beneficiary, action, actions), actions);
assert.equal(promise(game, f.target).released, true); assert.equal(receipt(game).played, false);
assert.equal(receipt(game).closed, true); assert.ok(player(game, f.target).hand.some(card => card.id === f.karama));
const earlier = structuredClone(receipt(game));
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), actions, true);
const own = windowCardOpportunity(game, 'karamaShipment'); assert.ok(own?.effect === 'karamaShipment');
assert.equal(own.recipient, f.target); assert.notEqual(own.source, f.source);
game = bind(f, 'yes', play(own.source), game, 1, actions).game;
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.target, f.target); assert.equal(guide.event, game.turnActionPromises![1].event);
game = settle(act(game, f.target, guide.action, actions), actions);
assert.equal(game.turnActionPromises![1].fulfilled, true); assert.equal(receipt(game, 1).played, true);
assert.deepEqual(receipt(game), earlier); assert.equal(game.turnActionPromises![0].released, true);
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1); physical(game, f, 2);
}
});
void test('a foreign closed price OR remains payable late even after the same actual Karama prices the own shipment instead', () => {
const f = fixture({ beneficiary: 'foreign' }), mixed = group('or', play(f.source), payment(f.recipient));
let game = bind(f, 'yes', mixed).game; game = act(game, f.beneficiary, f.endMovement);
assert.deepEqual(receipt(game), { source: f.source, played: false, closed: true });
assert.ok(!promise(game, f.target).released); const evidence = structuredClone(receipt(game));
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), [], true);
game = settle(act(game, f.target, f.ownGrant)); game = settle(act(game, f.target, f.shipment));
assert.equal(player(game, f.target).spice, 9); assert.deepEqual(receipt(game), evidence);
assert.ok(!promise(game, f.target).fulfilled); game = advance(game, state => state.phase === 8 && clean(state), [], true);
rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, f.payment);
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(receipt(game), evidence); physical(game, f);
});
void test('a real prior auction cost never counts as future printed Karama shipment spending', () => {
const f = fixture({ priorAuctionSpend: true }); assert.ok(f.auctionCost);
assert.equal(f.sourceCounts.actualPriorAuctionCost, f.auctionCost.steps.length);
assert.ok(f.auctionCost.steps.some(step => step.actor === f.target && step.action.type === 'card' && step.action.mode === 'purchase'));
assert.equal(f.game.discard.filter(card => card.id === f.karama).length, 1);
assert.ok(player(f.game, f.target).hand.some(card => card.id === f.base.lotCard));
const asked = ask(declare(f).game, f.asker, f.target, play(f.source));
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
let game = answer(asked, f.target, 'no'); assert.equal(receipt(game).played, false);
game = close(game, f); assert.equal(receipt(game).played, false); assert.equal(promise(game, f.target).fulfilled, true);
physical(game, f);
});
void test('printed shipment guides carry exact beneficiary source and event while cards remain viewer-owned', () => {
for (const beneficiary of ['own', 'foreign'] as const) {
const f = fixture({ beneficiary }), declared = declare(f);
for (const source of [JSON.stringify(['karamaShipment', f.game.turn + 1, f.beneficiary, 'original']),
JSON.stringify(['karamaShipment', f.game.turn, 'absent-beneficiary', 'original']),
JSON.stringify(['karamaShipment', f.game.turn, f.beneficiary, 'cunning', 'invented-event'])])
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, mixed: play(source) } });
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, mixed: { kind: 'action', action: {
kind: 'windowCardPlay', effect: 'karamaShipment', source: f.source, recipient: f.beneficiary,
} } } });
let game = bind(f).game; game = act(game, f.target, f.payment);
const own = viewGame(game, f.target), guide = own.turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.karama);
assert.equal(guide.action.mode, 'shipment'); assert.equal(guide.action.target, f.beneficiary);
assert.equal(guide.event, promise(game, f.target).event); assert.equal(guide.action.turnActionPromiseEvent, guide.event);
assert.equal(guide.action.turnActionPromisePhase, 5); assert.deepEqual(own.karamaShipmentCards, [f.karama]);
assert.deepEqual(viewGame(game, f.asker).karamaShipmentCards, []);
assert.ok(Object.keys(promise(game, f.target)).every(key => ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'].includes(key)));
rejected(game, f.asker, guide.action); rejected(game, f.target, { ...guide.action, target: f.observer });
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-price-event' });
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 3 });
const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, packet));
rejected(game, f.target, packet); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
}
});
void test('all four minimal profiles execute native printed shipment-price guides for own and foreign beneficiaries', () => {
for (const beneficiary of ['own', 'foreign'] as const) {
const f = fixture({ beneficiary }), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthTurnKaramaShipmentStep[] = [];
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const response = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(response, difficulty);
assert.equal(response.answer, 'yes'); game = act(game, f.target, response, actions);
for (let count = 0; count < 20 && !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 action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.event);
assert.ok(action, `${beneficiary}/${difficulty}`); assert.deepEqual(action, guide.action);
game = act(game, f.target, action, actions);
} else {
const next = nativeStep(game); assert.ok(next, `${beneficiary}/${difficulty}`);
game = act(game, next.actor, next.action, actions);
}
}
assert.equal(promise(game, f.target).fulfilled, true, `${beneficiary}/${difficulty}`);
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'bribe'));
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'card' && step.action.card === f.karama &&
step.action.mode === 'shipment' && step.action.target === f.beneficiary));
game = settle(game); physical(game, f);
}
}
});
+55 -1
View File
@@ -59,7 +59,8 @@ const call = (call = true, battle = 'battle:1'): Expression =>
const card = (effect: 'hajr' | 'ghola' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
const power = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expression =>
({ kind: 'action', action: { kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim } });
const windowSource = (effect: WindowCardEffect) => effect === 'karamaAuction' ? 'ordinary-auction:1:1' :
const windowSource = (effect: WindowCardEffect) => effect === 'karamaShipment'
? JSON.stringify(['karamaShipment', 1, 'recipient', 'original']) : effect === 'karamaAuction' ? 'ordinary-auction:1:1' :
effect === 'harvester' ? 'spice-blow:1' : 'storm:1';
const windowCard = (effect: WindowCardEffect = 'weather', source = windowSource(effect)): Expression =>
({ kind: 'action', action: { kind: 'windowCardPlay', effect, source } });
@@ -1466,3 +1467,56 @@ void test('closing the auction role preserves a later payment alternative and fu
assert.throws(() => turnActionValue(windowed(group('or', windowCard('karamaAuction'),
windowCard('karamaAuction', 'ordinary-auction:1:2')))), TurnActionError);
});
void test('shipment-price receipt records the actual printed grant without requiring use and keeps whole transport correlation', () => {
const source = windowSource('karamaShipment');
const grantOnly = windowed(windowCard('karamaShipment'));
const joint = windowed(group('and', windowCard('karamaShipment'), travel('ship'), pay()), true, 'turn-action:2');
const granted = recordTurnActionWindowCardPlay([grantOnly, joint], 'respondent', 1, 'karamaShipment', source);
assert.equal(granted[0].fulfilled, true, 'The accepted price grant counts even if its beneficiary never ships');
assert.deepEqual(branches(granted[1]), [[signedTravel('ship'), signedPay()]]);
const shipped = recordTurnActionShipment(granted, 'respondent', 1, event());
assert.deepEqual(branches(shipped[1]), [[signedPay()]]);
assert.equal(recordTurnActionPayment(shipped, 'respondent', 1, payment())[1].fulfilled, true);
assert.equal(joint.windowCards!.karamaShipment!.played, false);
validateTurnActionPromises(granted, seats);
});
void test('shipment-price No remains attached to its original recipient while another recipient has a compatible live interval', () => {
const first = windowSource('karamaShipment');
const later = JSON.stringify(['karamaShipment', 1, 'other', 'original']);
const no = windowed(windowCard('karamaShipment', first), false);
const yes = windowed(group('or', windowCard('karamaShipment', later), pay()), true, 'turn-action:2');
const granted = recordTurnActionWindowCardPlay([no, yes], 'respondent', 1, 'karamaShipment', later);
assert.equal(granted[0], no);
assert.equal(granted[1].fulfilled, true);
assert.throws(() => recordTurnActionWindowCardPlay(granted, 'respondent', 1, 'karamaShipment', first), TurnActionError);
assert.equal(no.windowCards!.karamaShipment!.played, false);
const cunning = JSON.stringify(['karamaShipment', 1, 'recipient', 'cunning', 'cunning:1']);
assert.equal(recordTurnActionWindowCardPlay(granted, 'respondent', 1, 'karamaShipment', cunning)[0], no);
assert.equal(closeTurnActionWindowCardPlay(granted, 'respondent', 1, 'karamaShipment', later)[0], no);
const closed = closeTurnActionWindowCardPlay(granted, 'respondent', 1, 'karamaShipment', first);
assert.equal(closed[0].fulfilled, true);
assert.equal(closed[1], granted[1]);
validateTurnActionPromises(closed, seats);
});
void test('closing a shipment-price opportunity retains actual play and later whole payment alternatives through turn expiry', () => {
const source = windowSource('karamaShipment');
const yes = windowed(group('and', windowCard('karamaShipment'), pay()));
const no = windowed(group('and', windowCard('karamaShipment'), pay()), false, 'turn-action:2');
const granted = recordTurnActionWindowCardPlay([yes, no], 'respondent', 1, 'karamaShipment', source);
const closed = closeTurnActionWindowCardPlay(granted, 'respondent', 1, 'karamaShipment', source);
assert.deepEqual(closed[0].windowCards!.karamaShipment, { source, played: true, closed: true });
assert.deepEqual(branches(closed[0]), [[signedPay()]]);
assert.equal(closed[1].fulfilled, false);
assert.throws(() => recordTurnActionPayment(closed, 'respondent', 1, payment()), TurnActionError);
assert.equal(recordTurnActionPayment([closed[0]], 'respondent', 1, payment())[0].fulfilled, true);
const alternative = windowed(group('or', windowCard('karamaShipment'), pay()), true, 'turn-action:3');
const absent = closeTurnActionWindowCardPlay([alternative], 'respondent', 1, 'karamaShipment', source);
assert.deepEqual(branches(absent[0]), [[signedPay()]]);
const expired = finishTurnActionTurn([no], 'respondent', 1)[0];
assert.equal(expired.fulfilled, true);
assert.deepEqual(expired.windowCards!.karamaShipment, { source, played: false, closed: true });
validateTurnActionPromises([expired], seats);
});
+127 -3
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { WINDOW_CARD_EFFECTS, windowCardNativeEffect, windowCardOpportunity, type WindowCardGame } from '../game/window-card-play';
import { WINDOW_CARD_EFFECTS, karamaShipmentOpportunities, parseKaramaShipmentSource, windowCardNativeEffect, windowCardOpportunity,
type WindowCardGame } from '../game/window-card-play';
function storm(): WindowCardGame {
return { status: 'playing', phase: 0, turn: 3, shieldWallDestroyed: false };
@@ -14,10 +15,133 @@ function auction(): WindowCardGame {
function publicAuction(): WindowCardGame {
return { ...storm(), phase: 3, auction: { bid: 4, bidder: 'respondent', remaining: 2 }, bidPromiseLot: { turn: 3, lot: 1 } };
}
const shipmentSource = (recipient: string, turn = 3) => JSON.stringify(['karamaShipment', turn, recipient, 'original']);
function shipments(): WindowCardGame {
return { ...storm(), phase: 5, active: 'first', movementRemaining: ['first', 'later', 'done'],
players: [{ id: 'first', shipped: false }, { id: 'later', shipped: false }, { id: 'done', shipped: true }],
karamaShipping: null };
}
void test('window roles expose a shared exhaustive tuple and exact native printed-card mapping', () => {
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction']);
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama']);
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment']);
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama']);
});
void test('shipment-price windows retain every unused original entitlement while new questions select only the active recipient', () => {
const source = shipments(), before = structuredClone(source);
assert.deepEqual(karamaShipmentOpportunities(source), [
{ effect: 'karamaShipment', turn: 3, source: shipmentSource('first'), recipient: 'first' },
{ effect: 'karamaShipment', turn: 3, source: shipmentSource('later'), recipient: 'later' },
]);
assert.equal(windowCardOpportunity(source, 'karamaShipment')!.source, shipmentSource('first'));
assert.equal(windowCardOpportunity(source, 'karamaShipment', shipmentSource('later'))!.source, shipmentSource('later'));
assert.equal(windowCardOpportunity({ ...source, active: 'later' }, 'karamaShipment', shipmentSource('first'))!.source,
shipmentSource('first'));
assert.deepEqual(source, before);
});
void test('shipment source decoding identifies original and Cunning beneficiaries even after a grant or shipment closes them', () => {
assert.deepEqual(parseKaramaShipmentSource(shipmentSource('first')), { turn: 3, recipient: 'first', kind: 'original' });
const source = JSON.stringify(['karamaShipment', 3, 'first', 'cunning', 'cunning:1']);
assert.deepEqual(parseKaramaShipmentSource(source), { turn: 3, recipient: 'first', kind: 'cunning', event: 'cunning:1' });
for (const invalid of [undefined, null, [], '{}', `${source} `,
JSON.stringify(['karamaShipment', 0, 'first', 'original']),
JSON.stringify(['karamaShipment', 3.5, 'first', 'original']),
JSON.stringify(['karamaShipment', Number.MAX_SAFE_INTEGER + 1, 'first', 'original']),
JSON.stringify(['karamaShipment', 3, ' first', 'original']),
JSON.stringify(['karamaShipment', 3, 'first', 'cunning']),
JSON.stringify(['karamaShipment', 3, 'first', 'cunning', '']),
JSON.stringify(['karamaShipment', 3, 'first', 'original', 'extra'])])
assert.equal(parseKaramaShipmentSource(invalid), null);
assert.equal(windowCardOpportunity({ ...shipments(), karamaShipping: { player: 'first' } }, 'karamaShipment',
shipmentSource('first')), null);
assert.equal(parseKaramaShipmentSource(shipmentSource('first'))!.recipient, 'first');
});
void test('a shipment-price grant closes only its beneficiary and actual shipment or turn completion closes that original source', () => {
const source = shipments();
const granted = { ...source, karamaShipping: { player: 'first' } };
assert.equal(windowCardOpportunity(granted, 'karamaShipment'), null);
assert.deepEqual(karamaShipmentOpportunities(granted).map(window => window.recipient), ['later']);
const shipped = { ...source, players: source.players!.map(player => ({ ...player, shipped: true })) };
assert.deepEqual(karamaShipmentOpportunities(shipped), []);
const finished = { ...source, active: 'later', movementRemaining: ['later', 'done'] };
assert.equal(windowCardOpportunity(finished, 'karamaShipment', shipmentSource('first')), null);
assert.equal(windowCardOpportunity({ ...source, phase: 6 }, 'karamaShipment', shipmentSource('first')), null);
assert.equal(windowCardOpportunity({ ...source, turn: 4 }, 'karamaShipment', shipmentSource('first')), null);
});
void test('temporary native modal priority preserves original shipment entitlements without offering an inactive question', () => {
for (const modal of [{ response: {} }, { decision: {} }, { truthtrance: {} }, { phaseOpening: {} }]) {
const source = { ...shipments(), active: 'later', ...modal };
assert.equal(windowCardOpportunity(source, 'karamaShipment')!.source, shipmentSource('later'));
assert.equal(windowCardOpportunity(source, 'karamaShipment', shipmentSource('first'))!.source, shipmentSource('first'));
}
assert.equal(windowCardOpportunity(storm(), 'weather', 'storm:3')!.source, 'storm:3');
assert.equal(windowCardOpportunity(storm(), 'weather', 'storm:4'), null);
assert.equal(windowCardOpportunity(auction(), 'karamaAuction', 'ordinary-auction:3:2'), null);
});
void test('current Cunning second shipment has its own event source and never reopens an unused original shipment', () => {
const source = shipments(), receipt = { turn: 3, owner: 'first', event: 'cunning:1' };
const second = { ...source, nexusGuildCunningHistory: [{ receipt, stage: 'secondShipment' }] };
const expected = JSON.stringify(['karamaShipment', 3, 'first', 'cunning', 'cunning:1']);
assert.equal(windowCardOpportunity(second, 'karamaShipment')!.source, expected);
const projected = { ...source, players: source.players!.map(({ id }) => ({ id })),
karamaShipmentWindows: karamaShipmentOpportunities(second) };
assert.equal(windowCardOpportunity(projected, 'karamaShipment')!.source, expected);
assert.equal(windowCardOpportunity(second, 'karamaShipment', shipmentSource('first')), null);
assert.equal(windowCardOpportunity({ ...second, active: 'later' }, 'karamaShipment', expected), null);
assert.equal(windowCardOpportunity({ ...second, karamaShipping: { player: 'first' } }, 'karamaShipment', expected), null);
for (const stage of ['pending', 'extraMove', 'completed', 'canceled']) {
const closed = { ...source, nexusGuildCunningHistory: [{ receipt, stage }] };
assert.equal(windowCardOpportunity(closed, 'karamaShipment'), null);
assert.equal(windowCardOpportunity(closed, 'karamaShipment', shipmentSource('first')), null);
assert.ok(windowCardOpportunity(closed, 'karamaShipment', shipmentSource('later')));
}
const old = { ...source, nexusGuildCunningHistory: [{ receipt: { ...receipt, turn: 2 }, stage: 'completed' }] };
assert.equal(windowCardOpportunity(old, 'karamaShipment')!.source, shipmentSource('first'));
});
void test('public shipment projections require no shipped history and return copies of strictly coherent source rows', () => {
const native = shipments(), windows = karamaShipmentOpportunities(native);
const source = { ...native, players: native.players!.map(({ id }) => ({ id })), karamaShipmentWindows: windows };
const before = structuredClone(source), projected = karamaShipmentOpportunities(source);
assert.deepEqual(projected, windows);
assert.notEqual(projected, windows);
assert.notEqual(projected[0], windows[0]);
projected[0].recipient = 'changed-copy';
assert.deepEqual(source, before);
assert.equal(windowCardOpportunity(source, 'karamaShipment', shipmentSource('later'))!.source, shipmentSource('later'));
});
void test('malformed public shipment windows cannot invent a recipient turn entitlement or Cunning source', () => {
const native = shipments(), window = karamaShipmentOpportunities(native)[0];
for (const invalid of [{ ...window, recipient: 'unseated' }, { ...window, turn: 2 }, { ...window, effect: 'karamaAuction' },
{ ...window, source: '' }, { ...window, source: `${window.source} ` }, { ...window, source: shipmentSource('later') },
{ ...window, source: JSON.stringify(['karamaShipment', 3, 'first', 'cunning']) },
{ ...window, source: JSON.stringify(['karamaShipment', 3, 'first', 'cunning', '']) },
{ ...window, card: 'private-card' }])
assert.deepEqual(karamaShipmentOpportunities({ ...native, karamaShipmentWindows: [invalid] } as WindowCardGame), []);
for (const invalid of [[window, window], [window, { ...window,
source: JSON.stringify(['karamaShipment', 3, 'first', 'cunning', 'cunning:1']) }]])
assert.deepEqual(karamaShipmentOpportunities({ ...native, karamaShipmentWindows: invalid }), []);
assert.deepEqual(karamaShipmentOpportunities({ ...native, active: 'later', karamaShipmentWindows: [{ ...window,
source: JSON.stringify(['karamaShipment', 3, 'first', 'cunning', 'cunning:1']) }] }), []);
assert.deepEqual(karamaShipmentOpportunities({ ...native, movementRemaining: ['later'], active: 'later',
karamaShipmentWindows: [window] }), []);
assert.deepEqual(karamaShipmentOpportunities({ ...native, karamaShipping: { player: 'first' },
karamaShipmentWindows: [window] }), []);
});
void test('malformed native shipment metadata cannot create a price opportunity', () => {
const source = shipments();
for (const invalid of [{ players: [{ id: 'first' }] }, { players: [{ id: 'first', shipped: false }, { id: 'first', shipped: false }] },
{ movementRemaining: ['first', 'first'] }, { movementRemaining: ['missing'] }, { movementRemaining: null },
{ active: 'missing' }, { karamaShipping: { player: 'missing' } }, { status: 'setup' }, { turn: 0 },
{ nexusGuildCunningHistory: [{ receipt: { turn: 4, owner: 'first', event: 'cunning:1' }, stage: 'secondShipment' }] },
{ nexusGuildCunningHistory: [{ receipt: { turn: 3, owner: 'missing', event: 'cunning:1' }, stage: 'secondShipment' }] }])
assert.deepEqual(karamaShipmentOpportunities({ ...source, ...invalid }), []);
});
void test('ordinary Karama binds the same public current-lot source from native and projected auction shapes', () => {