Bind Truthtrance Karama promises to original auction lots
This commit is contained in:
1 parent
e88f871385
commit
fd0b2f0fa5
18 files changed
+1128
-68
No files matched your search
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'])
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user