Bind Truthtrance Karama promises to original auction lots

This commit is contained in:
Quintine committed 2026-10-27 03:07:33 +11:00
1 parent e88f871385
commit fd0b2f0fa5
18 files changed
+1128 -68

No files matched your search

+2 -1
View File
@@ -53,6 +53,7 @@ export function finishTruthBid(game: Game, actions: TruthBidStep[] = []): Game {
* auction, question, answer or promise is installed. */
export function createTruthBidFixture(options: {
advanced?: boolean; window?: 'waiting' | 'active'; targetKarama?: boolean;
firstLotEffect?: 'hajr';
} = {}) {
const advanced = options.advanced ?? false, window = options.window ?? 'waiting';
const asker = 'harkonnen', opponent = 'atreides', target = 'emperor';
@@ -71,7 +72,7 @@ export function createTruthBidFixture(options: {
const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2);
const filler = game.deck.find(card => card.name === 'Baliset');
const targetCard = game.deck.find(card => options.targetKarama ? card.effect === 'karama' : card.kind === 'shield');
const lotCards = [game.deck.find(card => card.kind === 'projectile'), game.deck.find(card => card.kind === 'poison'),
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];
+151
View File
@@ -0,0 +1,151 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
import { windowCardOpportunity } from '../game/window-card-play';
import { actTruthBid, createTruthBidFixture, nextTruthBid, truthBidClean, truthBidInventory, truthBidPlayer,
type TruthBidStep } from './fixture-truth-bid';
import { truthTraitorDecisionTraitors } from './fixture-truth-traitor-decision';
import type { TruthTurnWindowCardLeaf } from './fixture-truth-turn-window-card';
export type TruthTurnKaramaLeaf = TruthTurnWindowCardLeaf |
{ kind: 'windowCardPlay'; effect: 'karamaAuction'; source: string };
export type TruthTurnKaramaExpression = MixedActionExpression<TruthTurnKaramaLeaf>;
export type TruthTurnKaramaStep = TruthBidStep;
export const actTruthTurnKarama = actTruthBid;
export const truthTurnKaramaPlayer = truthBidPlayer;
export const truthTurnKaramaInventory = truthBidInventory;
export const truthTurnKaramaTraitors = truthTraitorDecisionTraitors;
export const truthTurnKaramaClean = (game: Game) => truthBidClean(game) && !game.pendingRevival && !game.pendingShipment;
export const truthTurnKaramaFact = (fact: TruthFact): TruthTurnKaramaExpression => ({ kind: 'fact', fact });
export const truthTurnKaramaPlay = (source: string): TruthTurnKaramaExpression =>
({ kind: 'action', action: { kind: 'windowCardPlay', effect: 'karamaAuction', source } });
export const truthTurnKaramaPayment = (recipient: string, minimum = 2): TruthTurnKaramaExpression =>
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
export const truthTurnKaramaBid = (lot = 1, minimum = 3): TruthTurnKaramaExpression =>
({ kind: 'action', action: { kind: 'bid', lot, minimum } });
export const truthTurnKaramaGroup = (kind: 'and' | 'or', ...terms: TruthTurnKaramaExpression[]): TruthTurnKaramaExpression =>
({ kind, terms });
/** Thin genuine original H/A/E base33 adapter. The live-opponent variant
* follows original Basic waiting23 by a real H bid1 and A pass: E can really bid,
* then buy immediately while H still owns the next native bidder turn. */
export function createTruthTurnKaramaFixture(options: {
window?: 'active' | 'waiting'; advanced?: boolean; liveOpponent?: boolean;
} = {}) {
const liveOpponent = options.liveOpponent ?? false;
const base = createTruthBidFixture({ targetKarama: true, advanced: options.advanced,
window: liveOpponent ? 'waiting' : options.window ?? 'active' });
let game = structuredClone(base.game); const actions = structuredClone(base.sourceActions), inherited = actions.length;
if (liveOpponent) {
game = actTruthTurnKarama(game, base.asker, { type: 'bid', amount: 1, allyPayment: 0 }, actions);
game = actTruthTurnKarama(game, base.opponent, { type: 'passBid' }, actions);
assert.equal(game.auction?.active, base.target); assert.equal(game.auction?.bidder, base.asker);
assert.equal(game.auction?.passed.includes(base.asker), false);
}
assert.ok(base.karama && game.auction);
const opportunity = windowCardOpportunity(game, 'karamaAuction'); assert.ok(opportunity);
const source = opportunity.source, lot = game.auction.index + 1, asker = base.asker, target = base.target, recipient = asker;
assert.equal(source, `ordinary-auction:${game.turn}:${lot}`);
const mixed = truthTurnKaramaGroup('and', truthTurnKaramaPlay(source), truthTurnKaramaPayment(recipient));
const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
const originalTraitors = truthTurnKaramaTraitors(game);
assert.equal(originalTraitors.length, 15); assert.deepEqual(truthTurnKaramaInventory(game), base.originalCards);
assert.deepEqual(truthTurnKaramaPlayer(game, asker).hand.map(card => card.id), base.truths);
assert.deepEqual(truthTurnKaramaPlayer(game, target).hand.map(card => card.id), [base.karama]);
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthTurnKaramaPlayer(game, target).spice };
return { game, base, initial: base.initial, setup: base.setup, afterSetup: base.afterSetup,
beforeStartingDeal: base.beforeStartingDeal, asker, target, recipient, observer: base.opponent,
truths: structuredClone(base.truths), truth: base.truth, karama: base.karama, card: base.karama,
effect: 'karamaAuction' as const, source, opportunity, lot, lotCard: base.lotCard, lotCards: base.lotCards,
advanced: base.advanced, window: base.window, liveOpponent, mixed, question, spiceFact,
purchase: { type: 'card', card: base.karama, mode: 'purchase' } satisfies Action,
karamaPayment: { type: 'decision', karama: true, card: base.karama } satisfies Action,
spicePayment: { type: 'decision', karama: false } satisfies Action,
bid: { type: 'bid', amount: 3, allyPayment: 0 } satisfies Action,
payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action,
originalCards: base.originalCards, originalTraitors, originalSpice: base.originalSpice,
actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { inherited, nativeContinuation: actions.length - inherited, selected: actions.length },
sourceDisclosure: `${base.sourceDisclosure} This adapter changes only the raw printed-Karama/current-lot question. ` +
'The original held Karama opens the native Atreides auction-inspection response; all three real response passes remain in the recorded source prefix. ' +
(liveOpponent ? 'Actually bid1 with original Harkonnen and pass with Atreides, leaving the original H bidder live when Emperor acts. ' : '') +
'Both original H Truthtrances and the original Emperor Karama retain physical custody. No hand, purse, army, lot, bid, invoice, cancellation response, cost receipt or promise is installed.',
};
}
export type TruthTurnKaramaFixture = ReturnType<typeof createTruthTurnKaramaFixture>;
export function declareTruthTurnKarama(f: TruthTurnKaramaFixture, source = f.game, copy: 0 | 1 = 0,
actions: TruthTurnKaramaStep[] = []) {
let game = actTruthTurnKarama(source, f.asker, { type: 'card', card: f.truths[copy] }, actions);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = actTruthTurnKarama(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask'); return { game, actions };
}
export function askTruthTurnKarama(game: Game, asker: string, target: string, mixed: TruthTurnKaramaExpression,
actions: TruthTurnKaramaStep[] = []): Game {
return actTruthTurnKarama(game, asker, { type: 'truthAsk', question: {
kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed),
} }, actions);
}
export function answerTruthTurnKarama(game: Game, target: string, answer: 'yes' | 'no' | 'unknown',
actions: TruthTurnKaramaStep[] = []): Game {
return actTruthTurnKarama(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthTurnKarama(f: TruthTurnKaramaFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed,
source = f.game, copy: 0 | 1 = 0, actions: TruthTurnKaramaStep[] = []) {
const declared = declareTruthTurnKarama(f, source, copy, actions);
const asked = askTruthTurnKarama(declared.game, f.asker, f.target, mixed, actions);
return { game: answerTruthTurnKarama(asked, f.target, answer, actions), declared, asked, actions };
}
export function truthTurnKaramaPromise(game: Game, target = game.turnActionPromises![0].player) {
const row = viewGame(game, target).turnActionPromises.find(candidate => candidate.player === target); assert.ok(row); return row;
}
export function nextTruthTurnKaramaNative(game: Game): TruthTurnKaramaStep | null {
return nextTruthBid(game);
}
export function nextTruthTurnKarama(game: Game): TruthTurnKaramaStep | null {
if (game.truthtrance) return null;
for (const player of game.players) {
const guide = viewGame(game, player.id).turnActionGuide;
if (guide) return { actor: player.id, action: guide.action };
}
return nextTruthTurnKaramaNative(game);
}
export function advanceTruthTurnKarama(game: Game, until: (state: Game) => boolean,
actions: TruthTurnKaramaStep[] = [], nativeOnly = false): Game {
for (let count = 0; count < 1400; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Karama-auction source ended before its native boundary');
const step = nativeOnly ? nextTruthTurnKaramaNative(game) : nextTruthTurnKarama(game);
assert.ok(step, 'Supply the actual Truthtrance or original unfunded Karama invoice choice');
game = actTruthTurnKarama(game, step.actor, step.action, actions);
}
throw Error('The original Karama-auction source did not reach its finite boundary');
}
export function settleTruthTurnKarama(game: Game, actions: TruthTurnKaramaStep[] = []): Game {
return advanceTruthTurnKarama(game, truthTurnKaramaClean, actions, true);
}
export function winTruthTurnKaramaBid(game: Game, f: TruthTurnKaramaFixture, amount = 3,
actions: TruthTurnKaramaStep[] = []): Game {
game = advanceTruthTurnKarama(game, state => state.auction?.active === f.target && truthTurnKaramaClean(state), actions, true);
game = actTruthTurnKarama(game, f.target, { type: 'bid', amount, allyPayment: 0 }, actions);
return reachTruthTurnKaramaInvoice(game, f, actions);
}
/** A new bid reopens both other original bidders. Actually pass each remaining
* native turn before stopping at the winning Emperor's printed-card invoice. */
export function reachTruthTurnKaramaInvoice(game: Game, f: TruthTurnKaramaFixture,
actions: TruthTurnKaramaStep[] = []): Game {
return advanceTruthTurnKarama(game, state => !state.response && !state.phaseOpening && !state.pendingTreacheryDiscard &&
state.decision?.kind === 'auctionPayment' && state.decision.player === f.target, actions, true);
}
export function closeTruthTurnKaramaLot(game: Game, f: TruthTurnKaramaFixture, actions: TruthTurnKaramaStep[] = []): Game {
return advanceTruthTurnKarama(game, state => truthTurnKaramaClean(state) &&
windowCardOpportunity(state, 'karamaAuction')?.source !== f.source, actions, true);
}
export function finishTruthTurnKarama(game: Game, actions: TruthTurnKaramaStep[] = []): Game {
const turn = game.turn;
return advanceTruthTurnKarama(game, state => state.turn === turn + 1 && state.phase === 0 && truthTurnKaramaClean(state), actions);
}
+115 -1
View File
@@ -32,7 +32,8 @@ 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 windowClaim = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'controlled-native-blow' : 'storm:2'): Expression =>
const windowClaim = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'controlled-native-blow'
: effect === 'karamaAuction' ? 'ordinary-auction:2:2' : '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 } });
@@ -102,6 +103,16 @@ function auction(next: Action = { type: 'passBid' }): GameView {
if (next.type === 'decision') { game.auction.active = 'a'; game.decision = { kind: 'auctionPayment', player: 'p' }; }
return game;
}
function auctionKarama(invoice = false): GameView {
const card = baseDeck().find(card => card.kind === 'special' && card.effect === 'karama'); assert.ok(card);
const id = 'controlled-printed-karama';
const game = auction(invoice ? { type: 'decision', karama: true, card: id } : { type: 'card', card: id, mode: 'purchase' });
const me = game.players.find(player => player.id === 'p')!; me.hand = [{ ...card, id }]; me.handLimit = 4;
game.karamaAuctionCards = [id];
game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim('karamaAuction'), payment()] };
if (!invoice) { game.auction!.active = 'a'; game.active = 'a'; }
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';
@@ -805,6 +816,109 @@ void test('window Ready guides retain the exact live raw source, known Storm dis
replaced.spiceWindow = { ...replaced.spiceWindow! }; press(saved);
});
void test('current ordinary-lot Karama picker is public, source-bound and repairable within the shared logical budget', () => {
const game = auctionKarama(), e = editor(physical(), false, game);
for (const player of game.players) for (const key of ['hand', 'spice', 'leaders', 'traitors', 'forces'])
Object.defineProperty(player, key, { get() { throw Error(`No private Karama authoring ${key} read`); }, configurable: true });
Object.defineProperty(game, 'karamaAuctionCards', { get() { throw Error('The public question must not inspect the owner’s native card quote'); } });
select(e.tree(), '1', 'action', 'windowCardPlay'); assert.deepEqual(e.value, windowClaim('karamaAuction')); assert.equal(e.error(), null);
assert.match(e.html(), /Spend a printed Karama for this current ordinary lot/); assert.match(e.html(), /Ordinary lot 2/);
select(e.tree(), '1', 'window-source', ''); assert.ok(e.error());
select(e.tree(), '1', 'window-source', 'ordinary-auction:2:2'); assert.equal(e.error(), null);
game.bidPromiseLot = { turn: 2, lot: 3 }; assert.ok(e.error());
select(e.tree(), '1', 'window-source', 'ordinary-auction:2:3'); assert.equal(e.error(), null);
const originalAuction = game.auction;
game.auction = null; assert.ok(e.error()); assert.match(e.html(), /no current original open native window/); game.auction = originalAuction;
for (const extra of [{ card: 'private-karama' }, { mode: 'purchase' }, { amount: 0 }])
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'windowCardPlay', effect: 'karamaAuction', source: 'ordinary-auction:2:3', ...extra } }, game, 'p'));
const fullGame = auctionKarama(), full = editor({ kind: 'or', terms: [windowClaim('karamaAuction'), 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('karamaAuction')] }, 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 current-lot Karama guides forward exact purchase and winning-invoice packets using native owner quotes', () => {
for (const invoice of [false, true]) {
const game = auctionKarama(invoice), sent: Action[] = [];
for (const key of ['windowCards', 'compiled', 'cardPlays'])
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} Karama guide 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].card, 'controlled-printed-karama'); assert.equal(sent[0].turnActionPromisePhase, 3);
assert.equal(sent[0].turnActionPromiseEvent, event);
if (invoice) assert.match(renderToStaticMarkup(button), /printed Karama on this ordinary-lot winning invoice/);
else { assert.equal(sent[0].mode, 'purchase'); assert.equal(game.auction!.active, 'a', 'The native purchase remains possible outside the bidder’s turn.'); }
game.turnActionGuide = null;
const html = markup(game); assert.match(html, /printed Karama/); assert.match(html, /immediate purchase/);
assert.match(html, /Worthless conversion and a successful bid alone do not count/); assert.match(html, /closes with the original ordinary lot/);
assert.doesNotMatch(html, /windowCards|controlled-printed-karama/);
}
});
void test('saved printed-Karama auction callbacks expire with lot, native quote, physical card, owner and callback changes', () => {
for (const invoice of [false, true]) for (const change of [
(game: GameView) => { game.bidPromiseLot = { turn: 2, lot: 3 }; },
(game: GameView) => { game.auction = { ...game.auction! }; },
(game: GameView) => { game.auction!.active = 'r'; },
(game: GameView) => { game.karamaAuctionCards = []; },
(game: GameView) => { const card = game.players.find(player => player.id === 'p')!.hand?.[0]; assert.ok(card); card.kind = 'worthless'; },
(game: GameView) => { game.turnActionPromises[0].mixed = windowClaim('karamaAuction', 'ordinary-auction:2:1'); },
(game: GameView) => { game.me = 'a'; },
]) {
const game = auctionKarama(invoice), button = guide({ game, busy: false, act() { assert.fail('A stale ordinary-lot printed Karama cannot send'); } });
assert.equal(button.props.disabled, false); change(game); press(button);
}
const game = auctionKarama(), props: Props = { game, busy: false, act() { assert.fail('A saved Karama callback cannot send after replacement'); } };
const saved = guide(props); props.act = () => assert.fail('The replacement callback cannot receive an old purchase'); press(saved);
const full = auctionKarama(), fullButton = guide({ game: full, busy: false, act() { assert.fail('A newly full hand cannot purchase the lot'); } });
full.players.find(player => player.id === 'p')!.handLimit = 1; press(fullButton);
assert.equal(guide({ game: full, busy: false, act() { assert.fail('An initially full hand cannot purchase the lot'); } }).props.disabled, true);
});
void test('printed auction-role guides refuse conversion, unsupported packets and generic purchase fallback while retaining unrelated invoices', () => {
for (const invoice of [false, true]) for (const extra of [{ amount: 0 }, { target: 'r' }, { leader: 'foreign-leader' },
...(invoice ? [{ mode: 'purchase' }] : [{ mode: 'shipment' }])]) {
const game = auctionKarama(invoice); Object.assign(game.turnActionGuide!.action, extra);
const button = guide({ game, busy: false, act() { assert.fail('An unsupported auction-role packet cannot send'); } });
assert.equal(button.props.disabled, true); press(button);
}
for (const invoice of [false, true]) {
const game = auctionKarama(invoice), card = game.players.find(player => player.id === 'p')!.hand?.[0]; assert.ok(card);
card.kind = 'worthless'; delete card.effect;
assert.equal(guide({ game, busy: false, act() { assert.fail('Worthless conversion is not printed Karama use'); } }).props.disabled, true);
const missingQuote = auctionKarama(invoice); missingQuote.karamaAuctionCards = [];
assert.equal(guide({ game: missingQuote, busy: false, act() { assert.fail('A native card quote is required'); } }).props.disabled, true);
}
for (const phase of [2, 4, 5, 6]) {
const game = auctionKarama(); game.phase = phase; game.turnActionGuide!.action.turnActionPromisePhase = phase;
assert.equal(guide({ game, busy: false, act() { assert.fail('A purchase cannot become generic preparation'); } }).props.disabled, true);
}
const foreign = auctionKarama(true); foreign.decision = { kind: 'auctionPayment', player: 'a' };
assert.equal(guide({ game: foreign, busy: false, act() { assert.fail('A foreign invoice cannot consume this printed Karama'); } }).props.disabled, true);
const prior = auctionKarama(true); prior.turnActionPromises[0].mixed = payment(); prior.karamaAuctionCards = [];
for (const player of prior.players) Object.defineProperty(player, 'hand', { get() { throw Error('Existing unrelated invoice guides keep their original native authority'); } });
const sent: Action[] = [], oldInvoice = guide({ game: prior, busy: false, act(action) { sent.push(action); } });
assert.equal(oldInvoice.props.disabled, false); press(oldInvoice); assert.equal(sent[0], prior.turnActionGuide!.action);
const later = auctionKarama(true); later.bidPromiseLot = { turn: 2, lot: 3 }; later.karamaAuctionCards = [];
for (const player of later.players) Object.defineProperty(player, 'hand', { get() { throw Error('An older raw window cannot impose printed-role eligibility on an unrelated later invoice'); } });
const laterSent: Action[] = [], laterInvoice = guide({ game: later, busy: false, act(action) { laterSent.push(action); } });
assert.equal(laterInvoice.props.disabled, false); press(laterInvoice); assert.equal(laterSent[0], later.turnActionGuide!.action);
});
void test('current ordinary-lot printed-role questions retain quoted owned Box routes before purchase and inside their own invoice', () => {
for (const invoice of [false, true]) {
const game = auctionKarama(invoice), me = game.players.find(player => player.id === 'p')!;
me.hand = [{ id: 'controlled-box', name: 'Nullentropy Box', kind: 'special', effect: 'nullentropyBox' }];
game.nullentropy = { card: me.hand[0], blocked: null, search: null };
game.turnActionGuide!.action = { type: 'card', card: me.hand[0].id, turnActionPromiseEvent: event, turnActionPromisePhase: 3 };
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.bidPromiseLot = { turn: 2, lot: 3 }; 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'])
+52
View File
@@ -0,0 +1,52 @@
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, truthBidInventory } from './fixture-truth-bid';
import { askTruthTurnKarama, settleTruthTurnKarama, truthTurnKaramaGroup, truthTurnKaramaPlay,
createTruthTurnKaramaFixture, bindTruthTurnKarama } from './fixture-truth-turn-karama';
void test('an unseen auction Hajr cannot fund a future card promise before its actual Karama acquisition', () => {
const f = createTruthBidFixture({ targetKarama: true, firstLotEffect: 'hajr', window: 'active' });
assert.equal(f.game.auction?.cards[0].effect, 'hajr');
assert.equal(viewGame(f.game, f.target).auction?.card, null);
const source = windowCardOpportunity(f.game, 'karamaAuction')!;
const mixed = truthTurnKaramaGroup('and', truthTurnKaramaPlay(source.source),
{ kind: 'action', action: { kind: 'cardPlay', effect: 'hajr' } });
let game = declareTruthBid(f).game;
game = askTruthTurnKarama(game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(game, f.target).truthTurnActionAnswers, ['no']);
game = actTruthBid(game, f.target, { type: 'truthAnswer', answer: 'no' });
game = settleTruthTurnKarama(actTruthBid(game, f.target, { type: 'card', card: f.karama!, mode: 'purchase' }));
assert.ok(game.players.find(player => player.id === f.target)!.hand.some(card => card.effect === 'hajr'));
assert.deepEqual(truthBidInventory(game), f.originalCards);
// The actual acquisition makes the printed card owned knowledge. A second
// original Truth can now ask about the known remaining Movement play.
game = actTruthBid(game, f.asker, { type: 'card', card: f.truths[1] });
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id))!;
game = actTruthBid(game, actor.id, { type: 'truthPass' });
}
game = askTruthTurnKarama(game, f.asker, f.target,
{ kind: 'action', action: { kind: 'cardPlay', effect: 'hajr' } });
// The earlier No still forbids fulfilling the AND by that Hajr. It is the
// original binding, rather than lack of owned card knowledge, that applies.
assert.deepEqual(viewGame(game, f.target).truthTurnActionAnswers, ['no']);
let owned = settleTruthTurnKarama(actTruthBid(f.game, f.target, { type: 'card', card: f.karama!, mode: 'purchase' }));
owned = declareTruthBid(f, owned).game;
owned = askTruthTurnKarama(owned, f.asker, f.target,
{ kind: 'action', action: { kind: 'cardPlay', effect: 'hajr' } });
assert.ok(viewGame(owned, f.target).truthTurnActionAnswers?.includes('yes'),
'Actual ownership permits the native future play when no earlier answer forbids it');
});
void test('the last native pass cannot close an own unfulfilled printed Karama promise', () => {
const f = createTruthTurnKaramaFixture();
const game = bindTruthTurnKarama(f, 'yes', truthTurnKaramaPlay(f.source)).game;
assert.equal(game.auction?.active, f.target);
const before = structuredClone(game);
assert.throws(() => actTruthBid(game, f.target, { type: 'passBid' }), /Karama lot|Truthtrance/);
assert.deepEqual(game, before);
const played = settleTruthTurnKarama(actTruthBid(game, f.target, f.purchase));
assert.equal(played.turnActionPromises![0].fulfilled, true);
});
+270
View File
@@ -0,0 +1,270 @@
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 {
actTruthTurnKarama as act, advanceTruthTurnKarama as advance, answerTruthTurnKarama as answer,
askTruthTurnKarama as ask, bindTruthTurnKarama as bind, closeTruthTurnKaramaLot as close,
createTruthTurnKaramaFixture as fixture, declareTruthTurnKarama as declare, finishTruthTurnKarama as finish,
nextTruthTurnKaramaNative as nativeStep, reachTruthTurnKaramaInvoice as reachInvoice, settleTruthTurnKarama as settle,
truthTurnKaramaBid as bid, truthTurnKaramaClean as clean, truthTurnKaramaFact as fact,
truthTurnKaramaGroup as group, truthTurnKaramaInventory as inventory, truthTurnKaramaPayment as payment,
truthTurnKaramaPlay as play, truthTurnKaramaPlayer as player, truthTurnKaramaPromise as promise,
truthTurnKaramaTraitors as traitors, winTruthTurnKaramaBid as invoice,
type TruthTurnKaramaFixture, type TruthTurnKaramaStep,
} from './fixture-truth-turn-karama';
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: TruthTurnKaramaFixture, 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?.karamaAuction; assert.ok(stage); return stage;
}
void test('native Karama sources preserve actual opening two Truths, original printed card and recorded bidder controls', () => {
for (const advanced of [false, true]) for (const window of ['active', 'waiting'] as const) {
const f = fixture({ advanced, window }); 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(player(f.game, f.target).spice, 10); assert.equal(f.game.advanced, advanced);
assert.equal(f.source, 'ordinary-auction:1:1'); assert.deepEqual(windowCardOpportunity(f.game, 'karamaAuction'), f.opportunity);
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceCounts.inherited, f.base.sourceActions.length);
assert.equal(f.sourceCounts.nativeContinuation, 0); assert.equal(f.sourceCounts.selected, f.base.sourceActionCount);
assert.equal(f.sourceActions.filter(step => step.action.type === 'passBid').length, window === 'active' ? 2 : 0);
// The held Karama creates three genuine Atreides-inspection response passes.
assert.equal(f.sourceActionCount, (advanced ? 26 : 23) + (window === 'active' ? 2 : 0));
assert.equal(f.game.auction?.active, window === 'active' ? f.target : f.asker);
assert.equal(f.game.turnActionPromises?.length ?? 0, 0);
assert.deepEqual(inventory(f.game), f.originalCards); assert.deepEqual(traitors(f.game), f.originalTraitors);
}
const f = fixture({ liveOpponent: true }); assert.equal(f.sourceCounts.inherited, 23);
assert.equal(f.sourceCounts.nativeContinuation, 2); assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.equal(f.sourceActionCount, 25);
assert.equal(f.game.auction?.bidder, f.asker); assert.equal(f.game.auction?.bid, 1);
assert.equal(f.game.auction?.active, f.target); assert.equal(f.game.auction?.passed.includes(f.asker), false);
});
void test('a printed Karama purchase Yes grants the exact original lot for free and records one future physical cost', () => {
for (const window of ['active', 'waiting'] as const) {
const f = fixture({ window }); let game = bind(f, 'yes', play(f.source)).game;
assert.deepEqual(receipt(game), { source: f.source, played: false, closed: false });
const purse = player(game, f.target).spice; game = settle(act(game, f.target, f.purchase));
assert.equal(player(game, f.target).spice, purse); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
assert.equal(receipt(game).played, true); assert.equal(promise(game, f.target).fulfilled, true);
assert.notEqual(windowCardOpportunity(game, 'karamaAuction')?.source, f.source); physical(game, f);
}
});
void test('a real winning invoice can spend the original printed Karama without spice even for a native overbid', () => {
const f = fixture(); let game = bind(f, 'yes', play(f.source)).game;
game = invoice(game, f, 11); assert.equal(game.auction?.bid, 11); assert.equal(game.decision?.kind, 'auctionPayment');
assert.equal(player(game, f.target).spice, 10); assert.equal(receipt(game).played, false);
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'decision'); assert.equal(guide.action.karama, true); assert.equal(guide.action.card, f.karama);
game = settle(act(game, f.target, guide.action));
assert.equal(player(game, f.target).spice, 10); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
assert.equal(receipt(game).played, true); 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('No to this printed auction cost blocks immediate and invoice Karama while preserving the native spice payment', () => {
const f = fixture(); let game = bind(f, 'no', play(f.source)).game;
rejected(game, f.target, f.purchase); game = invoice(game, f);
rejected(game, f.target, f.karamaPayment); game = settle(act(game, f.target, f.spicePayment));
assert.equal(player(game, f.target).spice, 7); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
assert.ok(player(game, f.target).hand.some(card => card.id === f.karama));
assert.deepEqual(receipt(game), { source: f.source, played: false, closed: true });
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
});
void test('whole printed-auction OR payment Yes preserves either actual free purchase or ordinary paid spice route', () => {
const f = fixture(), mixed = group('or', play(f.source), payment(f.recipient));
const bound = bind(f, 'yes', mixed).game;
const bought = settle(act(bound, f.target, f.purchase));
assert.equal(promise(bought, f.target).fulfilled, true); assert.equal(player(bought, f.recipient).bribes, 0);
let paid = act(bound, f.target, f.payment); assert.equal(promise(paid, f.target).fulfilled, true);
paid = invoice(paid, f); paid = settle(act(paid, f.target, f.spicePayment));
assert.equal(player(paid, f.target).spice, 5); assert.ok(player(paid, f.target).hand.some(card => card.id === f.karama));
assert.equal(receipt(paid).played, false); physical(bought, f); physical(paid, f);
});
void test('whole printed-auction AND payment No caps only completion and survives a free purchase until actual turn end', () => {
const f = fixture();
let paid = bind(f, 'no').game; paid = act(paid, f.target, f.payment);
rejected(paid, f.target, f.purchase); paid = invoice(paid, f); rejected(paid, f.target, f.karamaPayment);
paid = settle(act(paid, f.target, f.spicePayment));
assert.deepEqual(receipt(paid), { source: f.source, played: false, closed: true });
assert.ok(!promise(paid, f.target).released); assert.ok(!promise(paid, f.target).fulfilled);
assert.equal(paid.turnActionPromises![0].paymentClosed, false);
paid = finish(paid); assert.equal(promise(paid, f.target).fulfilled, true); physical(paid, f);
let bought = settle(act(bind(f, 'no').game, f.target, f.purchase));
assert.equal(receipt(bought).played, true); rejected(bought, f.target, f.payment);
bought = act(bought, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
rejected(bought, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
assert.ok(!promise(bought, f.target).fulfilled); bought = finish(bought);
assert.equal(promise(bought, f.target).fulfilled, true); physical(bought, f);
});
void test('whole printed-auction OR payment No keeps its cumulative payment cap after the original lot closes', () => {
const f = fixture(), mixed = group('or', play(f.source), payment(f.recipient));
let game = bind(f, 'no', mixed).game; rejected(game, f.target, f.purchase); rejected(game, f.target, f.payment);
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); game = close(game, f);
assert.equal(receipt(game).closed, true); assert.equal(receipt(game).played, false);
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
game = finish(game); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
});
void test('one funded current purse can pay all ten and still fulfill printed Karama with a free original purchase', () => {
const f = fixture(), mixed = group('and', fact(f.spiceFact), play(f.source), payment(f.recipient, 10));
let game = bind(f, 'yes', mixed).game;
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 10 });
assert.equal(player(game, f.target).spice, 0); assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.karama); assert.equal(guide.action.mode, 'purchase');
game = settle(act(game, f.target, guide.action));
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.recipient).bribes, 10);
assert.equal(promise(game, f.target).fulfilled, true); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
physical(game, f);
});
void test('a required future bid precedes printed purchase and still permits either immediate or winning-invoice native cost', () => {
const f = fixture({ liveOpponent: true }), mixed = group('and', bid(f.lot), play(f.source), payment(f.recipient));
for (const route of ['purchase', 'invoice'] as const) {
const actions: TruthTurnKaramaStep[] = [];
let game = bind(f, 'yes', mixed, f.game, 0, actions).game;
rejected(game, f.target, f.purchase);
game = act(game, f.target, f.payment, actions);
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'bid'); assert.equal(guide.action.amount, 3);
game = act(game, f.target, guide.action, actions);
assert.equal(game.turnActionPromises![0].preparation?.bid?.maximum, 3);
assert.equal(game.auction?.active, f.asker); assert.equal(receipt(game).played, false);
if (route === 'invoice') {
game = act(game, f.asker, { type: 'passBid' }, actions); assert.equal(game.auction?.active, f.observer);
game = reachInvoice(game, f, actions); assert.equal(game.auction?.bid, 3);
assert.equal(game.decision?.kind, 'auctionPayment'); assert.equal(game.decision?.player, f.target);
const paying = viewGame(game, f.target).turnActionGuide; assert.ok(paying);
assert.equal(paying.action.type, 'decision'); assert.equal(paying.action.karama, true);
assert.equal(paying.action.card, f.karama); game = act(game, f.target, paying.action, actions);
} else game = act(game, f.target, f.purchase, actions);
game = settle(game, actions); assert.equal(player(game, f.target).spice, 8);
assert.equal(promise(game, f.target).fulfilled, true); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
const bidAt = actions.findIndex(step => step.actor === f.target && step.action.type === 'bid');
const costAt = actions.findIndex(step => step.actor === f.target && (step.action.type === 'card' && step.action.card === f.karama ||
step.action.type === 'decision' && step.action.karama === true));
assert.ok(bidAt >= 0 && costAt > bidAt); physical(game, f);
}
});
void test('two actual Truths reject bid ten plus pay ten when the earlier No forbids the only free Karama invoice', () => {
const f = fixture(); let game = bind(f, 'no', play(f.source)).game;
const earlier = structuredClone(game.turnActionPromises![0]), history = structuredClone(game.truthHistory![0]);
const second = group('and', bid(f.lot, 10), payment(f.recipient, 10));
const declared = declare(f, game, 1), asked = ask(declared.game, f.asker, f.target, second);
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
game = answer(asked, f.target, 'no'); assert.deepEqual(game.turnActionPromises![0], earlier);
assert.deepEqual(game.truthHistory![0], history); assert.equal(player(game, f.target).spice, 10);
assert.ok(player(game, f.target).hand.some(card => card.id === f.karama)); physical(game, f, 2);
});
void test('an original first-lot No permits the actual printed Karama in a later lot without crediting the earlier receipt', () => {
const f = fixture({ window: 'waiting' }); const actions: TruthTurnKaramaStep[] = [];
let game = bind(f, 'no', play(f.source), f.game, 0, actions).game;
const bidderPurse = player(game, f.asker).spice, emperorPurse = player(game, f.target).spice;
game = act(game, f.asker, { type: 'bid', amount: 1, allyPayment: 0 }, actions);
game = act(game, f.observer, { type: 'passBid' }, actions); game = act(game, f.target, { type: 'passBid' }, actions);
// H holds no Karama: its native one-spice purchase is automatic. The named
// boundary helper really completes Emperor income, H bonus and next-lot peek.
game = close(game, f, actions); assert.equal(game.auction?.index, 1);
assert.equal(player(game, f.asker).spice, bidderPurse - 1);
assert.equal(player(game, f.target).spice, emperorPurse + 1);
assert.ok(player(game, f.asker).hand.some(card => card.id === f.lotCard));
assert.equal(promise(game, f.target).fulfilled, true); const evidence = structuredClone(receipt(game));
assert.equal(evidence.played, false); assert.equal(evidence.closed, true);
const next = windowCardOpportunity(game, 'karamaAuction'); assert.ok(next); assert.notEqual(next.source, f.source);
const nextCard = game.auction!.cards[game.auction!.index].id, purse = player(game, f.target).spice;
game = settle(act(game, f.target, f.purchase, actions), actions);
assert.ok(player(game, f.target).hand.some(card => card.id === nextCard)); assert.equal(player(game, f.target).spice, purse);
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1); assert.deepEqual(receipt(game), evidence);
assert.ok(actions.some(step => step.actor === f.asker && step.action.type === 'bid'));
physical(game, f);
});
void test('actual later Karama shipment is a different role and cannot fulfill an expired auction alternative', () => {
const f = fixture(), mixed = group('or', play(f.source), payment(f.recipient));
let game = close(bind(f, 'yes', mixed).game, f);
assert.deepEqual(receipt(game), { source: f.source, played: false, closed: true });
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), [], true);
game = act(game, f.target, { type: 'card', card: f.karama, mode: 'shipment', target: f.target });
assert.ok(game.karamaShipping); assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
assert.equal(receipt(game).played, false); assert.ok(!promise(game, f.target).fulfilled);
const purse = player(game, f.target).spice;
game = settle(act(game, f.target, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 }));
assert.equal(player(game, f.target).spice, purse - 1, 'Native Karama halves the original two-spice desert tariff');
assert.equal(player(game, f.target).forces['polar_sink:0'], 1);
game = act(game, f.target, f.payment); assert.equal(promise(game, f.target).fulfilled, true);
assert.equal(receipt(game).played, false); physical(game, f);
});
void test('Karama guide packets retain original lot and physical card identity and reject stale sources or owners', () => {
const f = fixture(), declared = declare(f);
rejected(declared.game, f.asker, { type: 'truthAsk', question: { kind: 'turnActions', target: f.target,
turn: f.game.turn, mixed: play(`${f.source}:stale`) } });
rejected(declared.game, f.target, { type: 'truthAsk', question: f.question });
let game = bind(f).game; game = act(game, f.target, f.payment);
const row = promise(game, f.target), guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.karama); assert.equal(guide.action.mode, 'purchase');
assert.equal(guide.event, row.event); assert.equal(guide.action.turnActionPromiseEvent, row.event);
assert.equal(guide.action.turnActionPromisePhase, 3);
assert.ok(Object.keys(row).every(key => ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'].includes(key)));
assert.deepEqual(viewGame(game, f.observer).turnActionPromises, []); assert.equal(viewGame(game, f.observer).turnActionGuide, null);
rejected(game, f.observer, guide.action);
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-event' });
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 5 });
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);
const second = declare(f, game, 1);
rejected(second.game, f.asker, { type: 'truthAsk', question: { ...f.question, mixed: play(f.source) } }); physical(game, f);
});
void test('all four minimal profiles execute funded native printed Karama guides including a required first bid', () => {
for (const joint of [false, true]) {
const f = fixture({ liveOpponent: joint }), mixed = joint ? group('and', bid(f.lot), play(f.source), payment(f.recipient)) : f.mixed;
const asked = ask(declare(f).game, f.asker, f.target, mixed);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthTurnKaramaStep[] = [];
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 < 26 && !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, `${joint}/${difficulty}`); assert.deepEqual(action, guide.action);
game = act(game, f.target, action, actions);
} else {
const next = nativeStep(game); assert.ok(next, `${joint}/${difficulty}`);
game = act(game, next.actor, next.action, actions);
}
}
assert.equal(promise(game, f.target).fulfilled, true, `${joint}/${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.type === 'decision' && step.action.karama === true)));
if (joint) assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'bid'));
game = settle(game); physical(game, f);
}
}
});
+75 -5
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { battlePowerOf, type BattlePowerClaim, type BattlePowerDeclaration } from '../game/battle-power-question';
import type { WindowCardEffect } from '../game/window-card-play';
import { WINDOW_CARD_EFFECTS, type WindowCardEffect } from '../game/window-card-play';
import {
compileMixedActionExpression,
type MixedActionExpression,
@@ -59,7 +59,9 @@ 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 windowCard = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'spice-blow:1' : 'storm:1'): Expression =>
const windowSource = (effect: WindowCardEffect) => 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 } });
const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
@@ -1268,8 +1270,8 @@ void test('power predicates deduplicate only exact original clauses and retain t
});
void test('window-card raw leaves name only a printed role and exact original source, including folded clauses', () => {
for (const effect of ['weather', 'atomics', 'harvester'] as const) {
const original = { kind: 'windowCardPlay', effect, source: effect === 'harvester' ? 'spice-blow:1' : 'storm:1' };
for (const effect of WINDOW_CARD_EFFECTS) {
const original = { kind: 'windowCardPlay', effect, source: windowSource(effect) };
assert.deepEqual(parseTurnActionLeaf(original), original);
assert.notEqual(parseTurnActionLeaf(original), original);
for (const invalid of [{ ...original, source: '' }, { ...original, card: 'physical-id' }, { ...original, amount: 12 },
@@ -1278,7 +1280,7 @@ void test('window-card raw leaves name only a printed role and exact original so
assert.throws(() => turnActionValue(promise(windowCard(effect))), TurnActionError);
assert.throws(() => turnActionValue(promise(group('or', fact(), windowCard(effect)))), TurnActionError);
}
for (const effect of ['hajr', 'ghola', 'Weather Control', null])
for (const effect of ['hajr', 'ghola', 'karama', 'Weather Control', null])
assert.throws(() => parseTurnActionLeaf({ kind: 'windowCardPlay', effect, source: 'storm:1' }), TurnActionError);
});
@@ -1396,3 +1398,71 @@ void test('window-card clauses deduplicate only their exact original source and
} } };
assert.throws(() => turnActionValue(foreign as TurnActionPromise), TurnActionError);
});
void test('printed auction Karama receipt is separate from the bid and preserves their joint payment remainder', () => {
const original = { ...windowed(group('and', windowCard('karamaAuction'), bid(5), pay())), preparation: {
bid: { lot: 1, source: 'private-original-lot:1', maximum: 0, closed: false }, revival: null,
} };
const signed = { kind: 'windowCardPlay', effect: 'karamaAuction', source: 'ordinary-auction:1:1', answer: true };
const bidPlaced = recordTurnActionBid([original], 'respondent', 1, { lot: 1, source: 'private-original-lot:1', amount: 5 });
assert.deepEqual(branches(bidPlaced[0]), [[signed, signedPay()]]);
assert.equal(bidPlaced[0].windowCards!.karamaAuction!.played, false);
const rolePlayed = recordTurnActionWindowCardPlay(bidPlaced, 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:1');
assert.deepEqual(branches(rolePlayed[0]), [[signedPay()]]);
const complete = recordTurnActionPayment(rolePlayed, 'respondent', 1, payment());
assert.equal(complete[0].fulfilled, true);
assert.equal(original.windowCards!.karamaAuction!.played, false);
validateTurnActionPromises(complete, seats);
});
void test('auction Karama replay receipts affect only future answers for the exact original player turn and lot', () => {
const original = windowed(group('and', windowCard('karamaAuction'), pay()));
for (const [player, turn, source] of [['other', 1, 'ordinary-auction:1:1'],
['respondent', 2, 'ordinary-auction:1:1'], ['respondent', 1, 'ordinary-auction:1:2']] as const) {
assert.equal(recordTurnActionWindowCardPlay([original], player, turn, 'karamaAuction', source)[0], original);
assert.equal(closeTurnActionWindowCardPlay([original], player, turn, 'karamaAuction', source)[0], original);
}
const played = recordTurnActionWindowCardPlay([original], 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:1')[0];
const fresh = windowed(windowCard('karamaAuction'), true, 'turn-action:2');
const replay = recordTurnActionWindowCardPlay([played, fresh], 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:1');
assert.equal(replay[0], played);
assert.equal(replay[1].fulfilled, true);
assert.equal(fresh.windowCards!.karamaAuction!.played, false);
const closed = closeTurnActionWindowCardPlay([original], 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:1')[0];
assert.equal(recordTurnActionWindowCardPlay([closed], 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:1')[0], closed);
});
void test('whole No retains its auction Karama cap until source close and rejects a later correlated payment atomically', () => {
const no = windowed(windowCard('karamaAuction'), false);
assert.throws(() => recordTurnActionWindowCardPlay([no], 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:1'), TurnActionError);
assert.equal(no.windowCards!.karamaAuction!.played, false);
assert.equal(closeTurnActionWindowCardPlay([no], 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:2')[0], no);
assert.equal(closeTurnActionPayments([no], 'respondent', 1)[0].fulfilled, false);
const closed = closeTurnActionWindowCardPlay([no], 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:1')[0];
assert.equal(closed.fulfilled, true);
assert.equal(recordTurnActionWindowCardPlay([closed], 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:1')[0], closed);
const correlated = windowed(group('and', windowCard('karamaAuction'), pay()), false, 'turn-action:2');
const played = recordTurnActionWindowCardPlay([correlated], 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:1');
assert.equal(played[0].fulfilled, false);
assert.equal(recordTurnActionWindowCardPlay(played, 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:1')[0], played[0]);
assert.throws(() => recordTurnActionPayment(played, 'respondent', 1, payment()), TurnActionError);
assert.deepEqual(played[0].payments, []);
validateTurnActionPromises([closed], seats);
});
void test('closing the auction role preserves a later payment alternative and full expiry includes the new stage', () => {
const alternative = windowed(group('or', windowCard('karamaAuction'), pay()));
const closed = closeTurnActionWindowCardPlay([alternative], 'respondent', 1, 'karamaAuction', 'ordinary-auction:1:1');
assert.deepEqual(branches(closed[0]), [[signedPay()]]);
assert.equal(closed[0].paymentClosed, false);
assert.equal(recordTurnActionPayment(closed, 'respondent', 1, payment())[0].fulfilled, true);
const no = windowed(windowCard('karamaAuction'), false, 'turn-action:2');
const finished = finishTurnActionTurn([no], 'respondent', 1)[0];
assert.deepEqual(finished.windowCards!.karamaAuction, { source: 'ordinary-auction:1:1', played: false, closed: true });
assert.equal(finished.fulfilled, true);
validateTurnActionPromises([finished], seats);
const wrongStage = { ...alternative, windowCards: { karamaAuction: { source: 'ordinary-auction:1:2', played: false, closed: false } } };
assert.throws(() => turnActionValue(wrongStage), TurnActionError);
assert.throws(() => turnActionValue(windowed(group('or', windowCard('karamaAuction'),
windowCard('karamaAuction', 'ordinary-auction:1:2')))), TurnActionError);
});
+63 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { windowCardOpportunity, type WindowCardGame } from '../game/window-card-play';
import { WINDOW_CARD_EFFECTS, windowCardNativeEffect, windowCardOpportunity, type WindowCardGame } from '../game/window-card-play';
function storm(): WindowCardGame {
return { status: 'playing', phase: 0, turn: 3, shieldWallDestroyed: false };
@@ -8,6 +8,68 @@ function storm(): WindowCardGame {
function blow(): WindowCardGame {
return { ...storm(), phase: 1, spiceWindow: { event: 'spice-blow:1', territory: 'hagga_basin', sector: 5, harvested: false } };
}
function auction(): WindowCardGame {
return { ...storm(), phase: 3, auction: { cards: [{ id: 'private-card:1' }, { id: 'private-card:2' }], index: 0 } };
}
function publicAuction(): WindowCardGame {
return { ...storm(), phase: 3, auction: { bid: 4, bidder: 'respondent', remaining: 2 }, bidPromiseLot: { turn: 3, lot: 1 } };
}
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']);
});
void test('ordinary Karama binds the same public current-lot source from native and projected auction shapes', () => {
const expected = { effect: 'karamaAuction', source: 'ordinary-auction:3:1', turn: 3 };
assert.deepEqual(windowCardOpportunity(auction(), 'karamaAuction'), expected);
assert.deepEqual(windowCardOpportunity(publicAuction(), 'karamaAuction'), expected);
assert.deepEqual(windowCardOpportunity({ ...auction(), bidPromiseLot: { turn: 3, lot: 1 } }, 'karamaAuction'), expected);
const otherFace = { ...auction(), auction: { cards: [{ id: 'different-hidden-card' }], index: 0 } };
assert.deepEqual(windowCardOpportunity(otherFace, 'karamaAuction'), expected);
});
void test('ordinary lot responses and a winning unpaid invoice retain Karama source until actual lot close', () => {
for (const source of [auction(), publicAuction()]) {
for (const modal of [{ response: { kind: 'karama' } }, { decision: { kind: 'auctionPayment', player: 'respondent' } },
{ truthtrance: {} }, { phaseOpening: {} }])
assert.equal(windowCardOpportunity({ ...source, ...modal }, 'karamaAuction')!.source, 'ordinary-auction:3:1');
for (const exclusion of [{ richeseAuction: {} }, { currentAuctionSale: {} }, { ixAuction: {} },
{ phase: 4 }, { status: 'setup' }, { auction: null }])
assert.equal(windowCardOpportunity({ ...source, ...exclusion }, 'karamaAuction'), null);
}
});
void test('Karama lot progression changes only the public source and never mutates physical auction metadata', () => {
const source = auction(), before = structuredClone(source);
assert.equal(windowCardOpportunity(source, 'karamaAuction')!.source, 'ordinary-auction:3:1');
assert.equal(windowCardOpportunity({ ...source, auction: { cards: [{}, {}], index: 1 } }, 'karamaAuction')!.source,
'ordinary-auction:3:2');
assert.equal(windowCardOpportunity({ ...publicAuction(), bidPromiseLot: { turn: 3, lot: 2 } }, 'karamaAuction')!.source,
'ordinary-auction:3:2');
assert.equal(windowCardOpportunity({ ...source, turn: 4 }, 'karamaAuction')!.source, 'ordinary-auction:4:1');
assert.deepEqual(source, before);
});
void test('malformed or exhausted native auction arrays and indices cannot fall back to a public lot', () => {
for (const native of [{ cards: [], index: 0 }, { cards: [{}], index: 1 }, { cards: [{}], index: -1 },
{ cards: [{}], index: 0.5 }, { cards: [{}], index: Number.MAX_SAFE_INTEGER + 1 },
{ cards: [null], index: 0 }, { cards: [undefined], index: 0 }, { cards: ['private-card'], index: 0 },
{ cards: {}, index: 0 }, { cards: [{}] }, { index: 0 }, { cards: [{}], index: undefined }]) {
assert.equal(windowCardOpportunity({ ...auction(), auction: native }, 'karamaAuction'), null);
assert.equal(windowCardOpportunity({ ...publicAuction(), auction: native }, 'karamaAuction'), null);
}
});
void test('projected lot metadata is positive safe coherent and cannot invent an absent ordinary auction', () => {
for (const bidPromiseLot of [null, undefined, { turn: 3, lot: 0 }, { turn: 3, lot: -1 }, { turn: 3, lot: 1.5 },
{ turn: 3, lot: Number.MAX_SAFE_INTEGER + 1 }, { turn: 4, lot: 1 }, { turn: 3, lot: '1' },
{ turn: 3, lot: 1, card: 'private-card' }, { lot: 1 }])
assert.equal(windowCardOpportunity({ ...publicAuction(), bidPromiseLot } as WindowCardGame, 'karamaAuction'), null);
assert.equal(windowCardOpportunity({ ...auction(), bidPromiseLot: { turn: 3, lot: 2 } }, 'karamaAuction'), null);
assert.equal(windowCardOpportunity({ ...publicAuction(), auction: [] }, 'karamaAuction'), null);
assert.equal(windowCardOpportunity({ ...storm(), phase: 3, auction: {} }, 'karamaAuction'), null);
});
void test('Weather and Family Atomics share the exact original current Storm source before movement', () => {
assert.deepEqual(windowCardOpportunity(storm(), 'weather'), { effect: 'weather', source: 'storm:3', turn: 3 });