Connect phase-opening Amal promises and native payment order

This commit is contained in:
Quintine committed 2026-10-27 05:08:40 +11:00
1 parent 00741a37d0
commit b81823bbec
20 files changed
+954 -51

No files matched your search

+1
View File
@@ -172,6 +172,7 @@ void test('zero/one spice become zero and charity remains available after the op
const before = fixture();
before.phase = 2;
before.phaseOpening = { passed: [], initialize: true };
before.amalWindowOpen = true;
before.players[0].spice = 0;
before.players[1].spice = 1;
let g = ready(play(before));
+163
View File
@@ -0,0 +1,163 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { treacheryDeck } from '../game/cards';
import type { MixedActionExpression } from '../game/mixed-action-question';
import { traitorDeck } from '../game/traitors';
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
import { windowCardOpportunity } from '../game/window-card-play';
import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
import { actTruthTurnThumper, nextTruthTurnThumperNative, truthTurnThumperInventory,
truthTurnThumperPlayer, truthTurnThumperSpice, truthTurnThumperTraitors,
type TruthTurnThumperLeaf, type TruthTurnThumperStep } from './fixture-truth-turn-thumper';
export type TruthTurnAmalLeaf = TruthTurnThumperLeaf | { kind: 'windowCardPlay'; effect: 'amal'; source: string };
export type TruthTurnAmalExpression = MixedActionExpression<TruthTurnAmalLeaf>;
export type TruthTurnAmalStep = TruthTurnThumperStep;
export const actTruthTurnAmal = actTruthTurnThumper;
export const truthTurnAmalPlayer = truthTurnThumperPlayer;
export const truthTurnAmalInventory = truthTurnThumperInventory;
export const truthTurnAmalTraitors = truthTurnThumperTraitors;
export const truthTurnAmalSpice = truthTurnThumperSpice;
/** Quiet original opening remains available; only temporary interactions stop. */
export const truthTurnAmalQuiet = (game: Game) => !game.response && !game.decision && !game.truthtrance &&
!game.pendingTreacheryDiscard && !game.pendingRevival && !game.pendingShipment;
export const truthTurnAmalClean = (game: Game) => truthTurnAmalQuiet(game) && !game.phaseOpening;
export const truthTurnAmalFact = (fact: TruthFact): TruthTurnAmalExpression => ({ kind: 'fact', fact });
export const truthTurnAmalPlay = (source: string): TruthTurnAmalExpression =>
({ kind: 'action', action: { kind: 'windowCardPlay', effect: 'amal', source } });
export const truthTurnAmalPayment = (recipient: string, minimum = 5): TruthTurnAmalExpression =>
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
export const truthTurnAmalGroup = (kind: 'and' | 'or', ...terms: TruthTurnAmalExpression[]): TruthTurnAmalExpression => ({ kind, terms });
export const nextTruthTurnAmalNative = nextTruthTurnThumperNative;
export function nextTruthTurnAmal(game: Game): TruthTurnAmalStep | 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 nextTruthTurnAmalNative(game);
}
export function advanceTruthTurnAmal(game: Game, until: (state: Game) => boolean,
actions: TruthTurnAmalStep[] = [], nativeOnly = false): Game {
for (let count = 0; count < 1900; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Amal source ended before its native boundary');
const step = nativeOnly ? nextTruthTurnAmalNative(game) : nextTruthTurnAmal(game);
assert.ok(step, 'Supply the actual Truthtrance question or private Battle Plan');
game = actTruthTurnAmal(game, step.actor, step.action, actions);
}
throw Error('The genuine Amal chronology did not reach its finite boundary');
}
export function settleTruthTurnAmal(game: Game, actions: TruthTurnAmalStep[] = []): Game {
return advanceTruthTurnAmal(game, truthTurnAmalQuiet, actions, true);
}
/** Original public selected-Ix H/A/E. Only the actual unopened deal and safe
* unopened Spice prefix are selected. Every requested opening is opened by
* real setup, dials, passes, readiness and original movement endings. */
export function createTruthTurnAmalFixture(options: { phase?: 0 | 1 | 8; advanced?: boolean; incoming?: 0 | 4 } = {}) {
const phase = options.phase ?? 0, advanced = options.advanced ?? false, incoming = options.incoming ?? 0;
assert.ok(!incoming || phase === 8, 'The real incoming bribe is earned in the preceding phase7');
const asker = 'harkonnen', observer = 'atreides', target = 'emperor', recipient = asker;
let game = createGame('TRUTHTURNAMAL', newPlayer(asker, 'Harkonnen', 'harkonnen'), advanced, ['ix']);
joinGame(game, newPlayer(observer, 'Atreides', 'atreides')); joinGame(game, newPlayer(target, 'Emperor', 'emperor'));
const initial = structuredClone(game), actions: TruthTurnAmalStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthTurnAmal(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthTurnAmal(game, until, actions, true); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game); assert.equal(game.status, 'setup');
assert.ok(game.players.every(player => player.hand.length === 0));
const originalCards = truthTurnAmalInventory(game), originalTraitors = truthTurnAmalTraitors(game), originalSpice = truthTurnAmalSpice(game);
assert.deepEqual(originalCards, treacheryDeck(['ix']).map(card => card.id).sort());
assert.deepEqual(originalTraitors, traitorDeck(game.players, true).sort());
const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2);
const amal = game.deck.find(card => card.effect === 'amal'), filler = game.deck.find(card => card.name === 'Baliset'); assert.ok(amal && filler);
const deal = [...truths.map(card => card.id), filler.id, amal.id];
const dealOrder = deal.map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; return { position, from, card: id };
});
const beforeStartingDeal = structuredClone(game); reach(state => state.status === 'playing');
const afterSetup = structuredClone(game); assert.equal(game.phase, 0); assert.ok(game.phaseOpening);
let spiceOrder: ReturnType<typeof orderIxSpiceCards> = [];
let collection: { before: Game; steps: TruthTurnAmalStep[]; after: Game } | null = null;
let incomingReceipt: { before: Game; steps: TruthTurnAmalStep[]; after: Game } | null = null;
if (phase === 1) reach(state => state.phase === 1 && !!state.phaseOpening && truthTurnAmalQuiet(state));
if (phase === 8) {
reach(beforeIxSpiceDraw);
spiceOrder = orderIxSpiceCards(game, [{ territory: 'hagga_basin' }, ...(advanced ? [{ territory: 'broken_land' }] : [])]);
reach(state => state.phase === 7 && !!state.phaseOpening && truthTurnAmalQuiet(state));
const beforeCollection = structuredClone(game), collectAt = actions.length;
reach(state => state.phase === 7 && truthTurnAmalClean(state));
collection = { before: beforeCollection, steps: structuredClone(actions.slice(collectAt)), after: structuredClone(game) };
if (incoming) {
const before = structuredClone(game), from = actions.length;
act(asker, { type: 'bribe', target, amount: incoming });
incomingReceipt = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
assert.equal(truthTurnAmalPlayer(game, target).bribes, incoming);
}
reach(state => state.phase === 8 && !!state.phaseOpening && truthTurnAmalQuiet(state));
}
assert.equal(game.turn, 1); assert.equal(game.phase, phase); assert.ok(game.phaseOpening);
assert.deepEqual(game.phaseOpening.passed, []); assert.equal(truthTurnAmalPlayer(game, target).spice, 10);
assert.equal(truthTurnAmalPlayer(game, target).bribes, incoming);
assert.deepEqual(truthTurnAmalPlayer(game, asker).hand.map(card => card.id), truths.map(card => card.id));
assert.deepEqual(truthTurnAmalPlayer(game, target).hand.map(card => card.id), [amal.id]);
assert.deepEqual(truthTurnAmalInventory(game), originalCards); assert.deepEqual(truthTurnAmalTraitors(game), originalTraitors);
assert.deepEqual(truthTurnAmalSpice(game), originalSpice);
const opportunity = windowCardOpportunity(game, 'amal'); assert.ok(opportunity);
const source = opportunity.source; assert.equal(source, `phase-opening:1:${phase}`);
const mixed = truthTurnAmalGroup('and', truthTurnAmalPlay(source), truthTurnAmalPayment(recipient));
const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthTurnAmalPlayer(game, target).spice };
return { game, initial, lobby, setup, beforeStartingDeal, afterSetup, asker, target, recipient, observer,
phase, advanced, incoming, collection, incomingReceipt, truths: truths.map(card => card.id), truth: truths[0].id,
card: amal.id, amal: amal.id, effect: 'amal' as const, source, opportunity, mixed, question, spiceFact,
nativeAction: { type: 'card', card: amal.id } satisfies Action,
payment: { type: 'bribe', target: recipient, amount: 5 } satisfies Action,
dealOrder, spiceOrder, originalCards, originalTraitors, originalSpice,
actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { selected: actions.length, originalSetup: actions.findLastIndex(step => step.action.type === 'traitor') + 1,
actualCollection: collection?.steps.length ?? 0, actualIncomingPayment: incomingReceipt?.steps.length ?? 0 },
sourceDisclosure: 'Actual public Harkonnen/Atreides/Emperor selected-Ix setup: H receives both original Truthtrances, A Baliset and E original Amal through the unopened native deal. ' +
`Only original unopened deal/Spice positions and scalar lotteries are selected. Real setup and original native controls open turn1 phase${phase}; other original openings are passed without Amal. ` +
(phase === 8 ? advanced
? 'Original Harkonnen Carthag and Atreides Arrakeen each actually earn their Advanced two-spice city income during Collection; Emperor has no city income. '
: 'Actual Basic Collection gives no automatic city income, and no original army occupies the selected Spice blow. '
: '') +
(incoming ? 'H actually pays E four spice during quiet phase7; that known receipt stays queued through the original Mentat opening until actual initialization. ' : '') +
'Early-opening funding examples use owned current resources; absolute post-halving limits use the original Mentat opening after Charity and Bidding. ' +
'Owner cash is the original ten before the requested opening. No hand, wallet, ready/pass list, phase, turn, body, income, halving or promise is installed. All counts derive from actual action arrays.',
};
}
export type TruthTurnAmalFixture = ReturnType<typeof createTruthTurnAmalFixture>;
export function declareTruthTurnAmal(f: TruthTurnAmalFixture, source = f.game, copy: 0 | 1 = 0, actions: TruthTurnAmalStep[] = []) {
let game = actTruthTurnAmal(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 = actTruthTurnAmal(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask'); return { game, actions };
}
export function askTruthTurnAmal(game: Game, asker: string, target: string, mixed: TruthTurnAmalExpression, actions: TruthTurnAmalStep[] = []): Game {
return actTruthTurnAmal(game, asker, { type: 'truthAsk', question: { kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed) } }, actions);
}
export function answerTruthTurnAmal(game: Game, target: string, answer: 'yes' | 'no' | 'unknown', actions: TruthTurnAmalStep[] = []): Game {
return actTruthTurnAmal(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthTurnAmal(f: TruthTurnAmalFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed,
source = f.game, copy: 0 | 1 = 0, actions: TruthTurnAmalStep[] = []) {
const declared = declareTruthTurnAmal(f, source, copy, actions), asked = askTruthTurnAmal(declared.game, f.asker, f.target, mixed, actions);
return { game: answerTruthTurnAmal(asked, f.target, answer, actions), declared, asked, actions };
}
export function truthTurnAmalPromise(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 closeTruthTurnAmal(game: Game, f: Pick<TruthTurnAmalFixture, 'source'>, actions: TruthTurnAmalStep[] = []): Game {
return advanceTruthTurnAmal(game, state => truthTurnAmalQuiet(state) && !windowCardOpportunity(state, 'amal', f.source), actions, true);
}
export function finishTruthTurnAmal(game: Game, actions: TruthTurnAmalStep[] = []): Game {
const turn = game.turn;
return advanceTruthTurnAmal(game, state => state.turn === turn + 1 && state.phase === 0 && !!state.phaseOpening && truthTurnAmalQuiet(state), actions);
}
+110 -2
View File
@@ -36,7 +36,7 @@ const shipmentPriceSource = (recipient = 'p', cunning?: string) => JSON.stringif
...(cunning ? ['cunning', cunning] : ['original'])]);
const windowClaim = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'controlled-native-blow'
: effect === 'karamaAuction' ? 'ordinary-auction:2:2' : effect === 'karamaShipment' ? shipmentPriceSource()
: effect === 'thumper' ? 'first-spice-reveal:2' : 'storm:2'): Expression =>
: effect === 'thumper' ? 'first-spice-reveal:2' : effect === 'amal' ? 'phase-opening:2:4' : '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 } });
@@ -86,7 +86,7 @@ function projected(phase = 5, next: Action = { type: 'move', forces: { 'arrakeen
game.turnActionGuide = { event, action: { ...next, turnActionPromiseEvent: event, turnActionPromisePhase: phase } };
game.truthTurnActionAnswers = null;
if (next.type === 'card' && typeof next.card === 'string') {
const cardId = next.card, effect = (['hajr', 'ghola', 'weather', 'atomics', 'harvester', 'thumper'] as const)
const cardId = next.card, effect = (['hajr', 'ghola', 'weather', 'atomics', 'harvester', 'thumper', 'amal'] as const)
.find(effect => cardId.includes(effect)) ?? 'karama';
const face = [...baseDeck(), ...ixSpecialCards()].find(card => card.effect === effect); assert.ok(face);
const me = game.players.find(player => player.id === 'p')!;
@@ -131,6 +131,12 @@ function firstReveal(next: Action = { type: 'card', card: 'controlled-thumper' }
game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim('thumper'), payment()] };
return game;
}
function openingAmal(phase = 4, next: Action = { type: 'card', card: 'controlled-amal' }): GameView {
const game = projected(phase, next); game.amalWindowOpen = true; game.phaseOpening = { passed: [] };
game.amalCards = ['controlled-amal']; game.amalReady = true; game.active = 'a'; game.ready = ['p'];
game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim('amal', `phase-opening:2:${phase}`), payment()] };
return game;
}
function nexus(next: Action = { type: 'alliance', target: 'r' }): GameView {
const game = projected(1, next);
game.nexus = true; game.spiceWindow = null; game.active = 'a';
@@ -1135,6 +1141,108 @@ void test('Thumper first-reveal questions retain the quoted phase-one Box route
game.thumperWindowOpen = false; press(button); assert.equal(sent.length, 1);
});
void test('Amal editor uses the public original phase opening and preserves repairable sixteen-leaf four-level expressions', () => {
const game = openingAmal(), e = editor(physical(), false, game);
for (const player of game.players) for (const key of ['hand', 'spice', 'forces', 'leaders', 'traitors'])
Object.defineProperty(player, key, { get() { throw Error(`No private Amal authoring ${key} read`); }, configurable: true });
for (const key of ['amalCards', 'amalReady']) Object.defineProperty(game, key, { get() { throw Error(`No native Amal authoring ${key} quote read`); } });
select(e.tree(), '1', 'action', 'windowCardPlay'); assert.deepEqual(e.value, windowClaim('amal')); assert.equal(e.error(), null);
assert.match(e.html(), /Original Revival opening/); assert.match(e.html(), /Amal halves available cash before ordinary payments become legal/);
game.phaseOpening!.passed.push('p'); assert.equal(e.error(), null, 'The public source is separate from individual native eligibility.');
game.phaseOpening = null; assert.equal(e.error(), null, 'The explicit original source survives temporary controls.');
select(e.tree(), '1', 'window-source', ''); assert.ok(e.error()); select(e.tree(), '1', 'window-source', 'phase-opening:2:4'); assert.equal(e.error(), null);
game.phase = 0; assert.ok(e.error()); select(e.tree(), '1', 'window-source', 'phase-opening:2:0'); assert.equal(e.error(), null);
game.amalWindowOpen = false; assert.ok(e.error()); assert.match(e.html(), /no current original open native window/);
const fullGame = openingAmal(), full = editor({ kind: 'or', terms: [windowClaim('amal'), 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('amal')] }, 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('Amal play and Ready guides use exact native packets and opening passes rather than ordinary readiness', () => {
for (const phase of [0, 4]) for (const ready of [false, true]) {
const game = openingAmal(phase, ready ? { type: 'ready' } : { type: 'card', card: 'controlled-amal' }), sent: Action[] = [];
for (const key of ['windowCards', 'compiled', 'preparation'])
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private Amal ${key} read`); }, configurable: true });
if (ready) { game.amalCards = []; game.players.find(player => player.id === 'p')!.hand = []; }
assert.ok(game.ready.includes('p')); assert.ok(!game.phaseOpening!.passed.includes('p'));
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].turnActionPromiseEvent, event); assert.equal(sent[0].turnActionPromisePhase, phase);
if (ready) assert.match(renderToStaticMarkup(button), /Pass this original phase opening/);
else assert.equal(sent[0].card, 'controlled-amal');
game.turnActionGuide = null;
const html = markup(game); assert.match(html, /condition closes with this opening/);
assert.match(html, /Amal halves available cash before ordinary payments become legal/); assert.doesNotMatch(html, /windowCards|controlled-amal/);
}
});
void test('saved Amal callbacks retain opening identity, passed list, original source, owner quotes and physical custody', () => {
for (const ready of [false, true]) for (const change of [
(game: GameView) => { game.phaseOpening = { passed: [] }; },
(game: GameView) => { game.phaseOpening!.passed.push('a'); },
(game: GameView) => { game.phaseOpening!.passed.push('p'); },
(game: GameView) => { game.phaseOpening = null; game.ready = []; },
(game: GameView) => { game.amalWindowOpen = false; },
(game: GameView) => { game.turnActionPromises[0].mixed = windowClaim('amal', 'phase-opening:2:3'); },
(game: GameView) => { game.response = { kind: 'voice', owner: 'a', passed: [] }; },
]) {
const game = openingAmal(4, ready ? { type: 'ready' } : { type: 'card', card: 'controlled-amal' });
const saved = guide({ game, busy: false, act() { assert.fail('An obsolete original-opening action cannot send'); } });
assert.equal(saved.props.disabled, false); change(game); press(saved);
}
const quoted = openingAmal(), saved = guide({ game: quoted, busy: false, act() { assert.fail('A removed physical Amal quote cannot send'); } });
quoted.amalCards = []; press(saved);
const cardGame = openingAmal(), cardButton = guide({ game: cardGame, busy: false, act() { assert.fail('A changed physical Amal cannot send'); } });
const card = cardGame.players.find(player => player.id === 'p')!.hand?.[0]; assert.ok(card); card.effect = 'ghola'; press(cardButton);
const readyGame = openingAmal(4, { type: 'ready' }), readyButton = guide({ game: readyGame, busy: false, act() { assert.fail('A removed native Amal Ready quote cannot send'); } });
readyGame.amalReady = false; press(readyButton);
});
void test('Amal opening exception rejects extra fields, unquoted actions, Box and generic card fallback', () => {
for (const ready of [false, true]) for (const extra of [{ amount: 0 }, { mode: 'shipment' }, { target: 'p' }, { event: 'literal-event' }, { phase: 4 }]) {
const game = openingAmal(4, ready ? { type: 'ready' } : { type: 'card', card: 'controlled-amal' });
Object.assign(game.turnActionGuide!.action, extra);
const button = guide({ game, busy: false, act() { assert.fail('Only the original native Amal packet and existing envelope may send'); } });
assert.equal(button.props.disabled, true); press(button);
}
const unquoted = openingAmal(); unquoted.amalCards = [];
assert.equal(guide({ game: unquoted, busy: false, act() { assert.fail('The actual Amal card must be natively quoted'); } }).props.disabled, true);
const unavailableReady = openingAmal(4, { type: 'ready' }); unavailableReady.amalReady = false; unavailableReady.ready = [];
assert.equal(guide({ game: unavailableReady, busy: false, act() { assert.fail('Ordinary Ready cannot bypass an unavailable opening Ready'); } }).props.disabled, true);
const outside = openingAmal(); outside.phaseOpening = null;
assert.equal(guide({ game: outside, busy: false, act() { assert.fail('An explicit source alone does not create a native Amal action'); } }).props.disabled, true);
const other = openingAmal(5, { type: 'card', card: 'controlled-owned-karama' }); other.ready = []; other.active = 'p';
assert.equal(guide({ game: other, busy: false, act() { assert.fail('Other native cards keep the phase-opening veto'); } }).props.disabled, true);
const boxed = openingAmal(0), me = boxed.players.find(player => player.id === 'p')!;
const box = { id: 'controlled-box', name: 'Nullentropy Box', kind: 'special' as const, effect: 'nullentropyBox' };
me.hand = [box]; boxed.nullentropy = { card: box, blocked: null, search: null };
boxed.turnActionGuide!.action = { type: 'card', card: box.id, turnActionPromiseEvent: event, turnActionPromisePhase: 0 };
assert.equal(guide({ game: boxed, busy: false, act() { assert.fail('A quoted Box cannot bypass the native opening priority'); } }).props.disabled, true);
});
void test('native turn-action answers work in an unchanged quoted opening and reject changed passed or modal sources', () => {
for (const mixed of [windowClaim('amal'), claim()]) {
const game = answering(4); game.phaseOpening = { passed: ['a'] }; game.amalWindowOpen = true;
game.truthtrance!.question = { kind: 'turnActions', target: 'p', turn: 2, mixed };
const sent: Action[] = [], tree = TurnActionAnswers({ game, busy: false, act(action) { sent.push(action); } });
const button = find(tree, 'data-turn-action-answer', 'yes'); assert.equal(button.props.disabled, false); click(button);
assert.deepEqual(sent, [{ type: 'truthAnswer', answer: 'yes' }]);
game.phaseOpening.passed.push('r'); click(button); assert.equal(sent.length, 1);
}
for (const change of [
(game: GameView) => { game.phaseOpening = { passed: [] }; },
(game: GameView) => { game.phaseOpening = null; },
(game: GameView) => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
]) {
const game = answering(4); game.phaseOpening = { passed: [] };
const button = find(TurnActionAnswers({ game, busy: false, act() { assert.fail('A changed opening answer source cannot send'); } }), 'data-turn-action-answer', 'yes');
assert.equal(button.props.disabled, false); change(game); click(button);
}
});
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'])
+289
View File
@@ -0,0 +1,289 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { spiceDeck, treacheryDeck } from '../game/cards';
import { viewGame, type Action, type Game } from '../game/engine';
import { CHEAP_HERO_TRAITOR, traitorDeck } from '../game/traitors';
import { truthFactAnswer } from '../game/truthtrance';
import { windowCardOpportunity } from '../game/window-card-play';
import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
import {
actTruthTurnAmal as act, advanceTruthTurnAmal as advance, answerTruthTurnAmal as answer,
askTruthTurnAmal as ask, bindTruthTurnAmal as bind, closeTruthTurnAmal as close,
createTruthTurnAmalFixture as fixture, declareTruthTurnAmal as declare, finishTruthTurnAmal as finish,
nextTruthTurnAmalNative as nativeStep, settleTruthTurnAmal as settle,
truthTurnAmalFact as fact, truthTurnAmalGroup as group, truthTurnAmalInventory as inventory,
truthTurnAmalPayment as payment, truthTurnAmalPlay as play, truthTurnAmalPlayer as player,
truthTurnAmalPromise as promise, truthTurnAmalQuiet as quiet, truthTurnAmalSpice as spice,
truthTurnAmalTraitors as traitors, type TruthTurnAmalFixture, type TruthTurnAmalStep,
} from './fixture-truth-turn-amal';
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: TruthTurnAmalFixture, 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);
assert.deepEqual(spice(game), f.originalSpice);
}
function receipt(game: Game, index = 0) {
const stage = game.turnActionPromises![index].windowCards?.amal; assert.ok(stage); return stage;
}
void test('native Amal sources retain original selected-Ix inventories and real first Storm, Spice and Mentat openings', () => {
for (const advanced of [false, true]) for (const phase of [0, 1, 8] as const) {
const f = fixture({ phase, advanced }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
assert.deepEqual(f.originalCards, treacheryDeck(['ix']).map(card => card.id).sort());
assert.deepEqual(f.originalTraitors, traitorDeck(f.setup.players, true).sort()); assert.ok(f.originalTraitors.includes(CHEAP_HERO_TRAITOR));
assert.equal(f.originalCards.length, 47); assert.equal(f.originalSpice.length, spiceDeck(true).length);
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.amal]);
assert.equal(f.game.phase, phase); assert.equal(f.game.turn, 1); assert.ok(f.game.phaseOpening);
assert.deepEqual(f.game.phaseOpening.passed, []);
const collectionIncome = f.game.players.map(candidate => f.collection
? player(f.collection.after, candidate.id).spice - player(f.collection.before, candidate.id).spice : 0);
assert.deepEqual(f.game.players.map(candidate => candidate.spice), f.afterSetup.players.map((candidate, index) => candidate.spice + collectionIncome[index]));
if (phase === 8) {
assert.ok(f.collection); assert.equal(f.collection.before.phase, 7); assert.ok(f.collection.before.phaseOpening);
assert.equal(f.collection.after.phase, 7); assert.equal(f.collection.after.phaseOpening, null);
assert.deepEqual(collectionIncome, advanced ? [2, 2, 0] : [0, 0, 0]);
assert.equal(f.sourceCounts.actualCollection, f.collection.steps.length);
assert.ok(f.collection.steps.some(step => step.action.type === 'ready'));
assert.equal(player(f.collection.before, f.asker).forces['carthag:11'], 10);
assert.equal(player(f.collection.before, f.observer).forces['arrakeen:10'], 10);
}
assert.equal(f.source, `phase-opening:1:${phase}`); assert.deepEqual(windowCardOpportunity(f.game, 'amal'), f.opportunity);
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceCounts.selected, f.sourceActions.length);
assert.ok(f.sourceCounts.originalSetup <= f.sourceActionCount);
if (phase > 0) assert.ok(f.sourceActions.some(step => step.action.type === 'ready'));
assert.deepEqual(viewGame(f.game, f.target).amalCards, [f.amal]); assert.equal(viewGame(f.game, f.target).amalReady, true);
assert.deepEqual(viewGame(f.game, f.asker).amalCards, []); assert.equal(f.game.turnActionPromises?.length ?? 0, 0);
assert.deepEqual(inventory(f.game), f.originalCards); assert.deepEqual(traitors(f.game), f.originalTraitors);
assert.deepEqual(spice(f.game), f.originalSpice);
}
});
void test('native Amal AND payment quotes the real halving before payment and freezes the original spice fact', () => {
for (const advanced of [false, true]) for (const phase of [0, 1] as const) {
const f = fixture({ phase, advanced });
const mixed = group('and', fact(f.spiceFact), play(f.source), payment(f.recipient));
let game = bind(f, 'yes', mixed).game; rejected(game, f.target, f.payment);
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.amal);
game = settle(act(game, f.target, guide.action));
assert.deepEqual(game.players.map(candidate => candidate.spice), [5, 5, 5]); assert.ok(game.phaseOpening);
assert.deepEqual(game.phaseOpening.passed, []); assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
assert.equal(receipt(game).played, true); assert.ok(!promise(game, f.target).fulfilled); rejected(game, f.target, f.payment);
game = close(game, f); game = act(game, f.target, f.payment);
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.recipient).bribes, 5);
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(promise(game, f.target).mixed, mixed);
assert.equal(game.discard.filter(card => card.id === f.amal).length, 1); physical(game, f);
}
// Absolute five-versus-six funding uses the closing Mentat opening. Early
// examples above prove an owned-current-purse path, not all future income.
const closing = fixture({ phase: 8 }), impossible = group('and', play(closing.source), payment(closing.recipient, 6));
const asked = ask(declare(closing).game, closing.asker, closing.target, impossible);
assert.deepEqual(viewGame(asked, closing.target).truthTurnActionAnswers, ['no']);
rejected(asked, closing.target, { type: 'truthAnswer', answer: 'yes' });
let funded = bind(closing).game; funded = settle(act(funded, closing.target, closing.nativeAction));
funded = close(funded, closing); funded = act(funded, closing.target, closing.payment);
assert.equal(player(funded, closing.target).spice, 0); assert.equal(promise(funded, closing.target).fulfilled, true); physical(funded, closing);
});
void test('whole Amal OR payment Yes preserves printed halving or an ordinary payment after passing without halving', () => {
const f = fixture(), mixed = group('or', play(f.source), payment(f.recipient));
const bound = bind(f, 'yes', mixed).game;
const played = settle(act(bound, f.target, f.nativeAction));
assert.equal(promise(played, f.target).fulfilled, true); assert.equal(player(played, f.recipient).bribes, 0);
assert.equal(player(played, f.target).spice, 5);
let paid = act(bound, f.target, { type: 'ready' }); paid = close(paid, f);
assert.equal(player(paid, f.target).spice, 10); assert.equal(receipt(paid).played, false);
paid = act(paid, f.target, f.payment); assert.equal(player(paid, f.target).spice, 5);
assert.equal(promise(paid, f.target).fulfilled, true); assert.ok(player(paid, f.target).hand.some(card => card.id === f.amal));
physical(played, f); physical(paid, f);
});
void test('whole Amal AND payment No limits the real payment after cost but permits payment after the unplayed opening closes', () => {
const f = fixture(); let played = bind(f, 'no').game;
played = settle(act(played, f.target, f.nativeAction)); played = close(played, f);
rejected(played, f.target, f.payment);
played = act(played, f.target, { type: 'bribe', target: f.recipient, amount: 4 });
rejected(played, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
assert.equal(receipt(played).played, true); assert.ok(!promise(played, f.target).released);
played = finish(played); assert.equal(promise(played, f.target).fulfilled, true); physical(played, f);
let paid = close(bind(f, 'no').game, f); assert.equal(receipt(paid).played, false);
paid = act(paid, f.target, f.payment); assert.equal(player(paid, f.target).spice, 5);
paid = finish(paid); assert.equal(promise(paid, f.target).fulfilled, true); physical(paid, f);
});
void test('whole Amal OR payment No preserves its cumulative payment cap through the original opening and current turn', () => {
const f = fixture(), mixed = group('or', play(f.source), payment(f.recipient));
let game = bind(f, 'no', mixed).game; rejected(game, f.target, f.nativeAction); rejected(game, f.target, f.payment);
game = close(game, f); assert.deepEqual(receipt(game), { source: f.source, played: false, closed: true });
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 4 });
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('native own opening Ready cannot evade a required Amal while foreign passes remain legal and the actual play resets them', () => {
const f = fixture({ phase: 1 }); let game = bind(f, 'yes', play(f.source)).game;
const view = viewGame(game, f.target); assert.equal(view.amalReady, true);
assert.equal(view.turnActionGuide?.action.type, 'card'); assert.equal(view.turnActionGuide?.action.card, f.amal);
rejected(game, f.target, { type: 'ready' });
game = act(game, f.asker, { type: 'ready' }); game = act(game, f.observer, { type: 'ready' });
assert.ok(game.phaseOpening?.passed.includes(f.asker) && game.phaseOpening.passed.includes(f.observer));
rejected(game, f.target, { type: 'ready' }); game = settle(act(game, f.target, f.nativeAction));
assert.deepEqual(game.phaseOpening?.passed, []); assert.equal(promise(game, f.target).fulfilled, true);
game = close(game, f); assert.equal(game.phaseOpening, null); physical(game, f);
});
void test('definite Yes and No including constants reopen a real earlier own opening pass', () => {
const f = fixture(), passed = act(f.game, f.target, { type: 'ready' }); assert.ok(passed.phaseOpening?.passed.includes(f.target));
for (const [selected, mixed] of [
['yes', play(f.source)], ['no', play(f.source)],
['yes', group('or', fact(f.spiceFact), play(f.source))],
['no', group('and', fact({ kind: 'spice', compare: 'gte', value: 100 }), play(f.source))],
] as const) {
const game = bind(f, selected, mixed, passed).game;
assert.ok(game.phaseOpening); assert.equal(game.phaseOpening.passed.includes(f.target), false);
assert.equal(windowCardOpportunity(game, 'amal')?.source, f.source); physical(game, f);
}
});
void test('temporary Truthtrance priority and question stages preserve the original actual Amal opening without halving', () => {
const f = fixture({ phase: 1 }), declared = declare(f);
assert.ok(declared.game.phaseOpening); assert.equal(windowCardOpportunity(declared.game, 'amal')?.source, f.source);
const asked = ask(declared.game, f.asker, f.target, f.mixed);
assert.ok(asked.phaseOpening); assert.equal(windowCardOpportunity(asked, 'amal')?.source, f.source);
assert.deepEqual(asked.players.map(candidate => candidate.spice), [10, 10, 10]);
assert.equal(player(asked, f.target).hand.some(card => card.id === f.amal), true);
const game = answer(asked, f.target, 'yes'); assert.deepEqual(game.players.map(candidate => candidate.spice), [10, 10, 10]);
assert.equal(receipt(game).played, false); assert.deepEqual(game.phaseOpening?.passed, []); physical(game, f);
});
void test('a foreign actual Mentat Amal may release another respondent full-purse payment without vetoing the cost or last pass', () => {
const f = fixture({ phase: 8 }), amount = player(f.game, f.observer).spice, mixed = payment(f.recipient, amount);
const asked = ask(declare(f).game, f.asker, f.observer, mixed);
let game = answer(asked, f.observer, 'yes'); const history = structuredClone(game.truthHistory!.at(-1));
const purses = game.players.map(candidate => candidate.spice);
game = settle(act(game, f.target, f.nativeAction));
assert.deepEqual(game.players.map(candidate => candidate.spice), purses.map(value => Math.floor(value / 2)));
assert.equal(promise(game, f.observer).released, true); assert.ok(!promise(game, f.observer).fulfilled);
assert.deepEqual(game.truthHistory!.at(-1), history); assert.deepEqual(game.turnActionPromises![0].payments, []);
game = act(game, f.asker, { type: 'ready' }); game = act(game, f.target, { type: 'ready' });
game = act(game, f.observer, { type: 'ready' }); assert.equal(game.phaseOpening, null);
assert.equal(promise(game, f.observer).released, true);
assert.equal(game.discard.filter(card => card.id === f.amal).length, 1); physical(game, f);
});
void test('actual queued phase7 bribes arrive only after Mentat opening halving and fund nine rather than ten', () => {
const f = fixture({ phase: 8, incoming: 4 }); assert.ok(f.incomingReceipt);
assert.equal(f.sourceCounts.actualIncomingPayment, f.incomingReceipt.steps.length);
assert.ok(f.incomingReceipt.steps.some(step => step.actor === f.asker && step.action.type === 'bribe' && step.action.amount === 4));
assert.equal(player(f.game, f.target).spice, 10); assert.equal(player(f.game, f.target).bribes, 4);
const impossible = group('and', play(f.source), payment(f.recipient, 10));
const asked = ask(declare(f).game, f.asker, f.target, impossible);
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
const mixed = group('and', fact(f.spiceFact), play(f.source), payment(f.recipient, 9));
let game = bind(f, 'yes', mixed).game; game = settle(act(game, f.target, f.nativeAction));
assert.equal(player(game, f.target).spice, 5); assert.equal(player(game, f.target).bribes, 4);
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 9 });
game = close(game, f); assert.equal(player(game, f.target).spice, 9); assert.equal(player(game, f.target).bribes, 0);
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'bribe'); assert.equal(guide.action.amount, 9); game = act(game, f.target, guide.action);
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.recipient).bribes, 9);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
});
void test('an original first-opening No cannot count or forbid the actual Amal promised by a second Truth in the next phase', () => {
const f = fixture(), actions: TruthTurnAmalStep[] = [];
let game = close(bind(f, 'no', play(f.source), f.game, 0, actions).game, f, actions);
const earlier = structuredClone(receipt(game)); assert.equal(promise(game, f.target).fulfilled, true);
assert.ok(player(game, f.target).hand.some(card => card.id === f.amal));
game = advance(game, state => state.phase === 1 && !!state.phaseOpening && quiet(state), actions, true);
const next = windowCardOpportunity(game, 'amal'); assert.ok(next); assert.notEqual(next.source, f.source);
game = bind(f, 'yes', play(next.source), game, 1, actions).game;
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.event, game.turnActionPromises![1].event);
game = settle(act(game, f.target, guide.action, actions), actions);
assert.equal(game.turnActionPromises![1].fulfilled, true); assert.equal(receipt(game, 1).played, true);
assert.deepEqual(receipt(game), earlier); assert.equal(receipt(game).played, false);
assert.deepEqual(game.players.map(candidate => candidate.spice), [5, 5, 5]);
assert.equal(game.discard.filter(card => card.id === f.amal).length, 1); assert.ok(actions.length > f.sourceActionCount); physical(game, f, 2);
});
void test('exact Amal guides retain the original opening, physical owner and event while stale or late sources reject', () => {
const f = fixture({ phase: 1 }), declared = declare(f);
for (const source of ['phase-opening:1:0', 'phase-opening:2:1', `${f.source}:extra`])
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, mixed: play(source) } });
rejected(declared.game, f.target, { type: 'truthAsk', question: f.question });
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, mixed: { kind: 'action', action: {
kind: 'windowCardPlay', effect: 'amal', source: f.source, amount: 5,
} } } });
let game = bind(f, 'yes', play(f.source)).game;
const own = viewGame(game, f.target), guide = own.turnActionGuide; assert.ok(guide);
assert.deepEqual(Object.keys(guide.action).sort(), ['type', 'card', 'turnActionPromiseEvent', 'turnActionPromisePhase'].sort());
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.amal);
assert.equal(guide.event, promise(game, f.target).event); assert.equal(guide.action.turnActionPromiseEvent, guide.event);
assert.equal(guide.action.turnActionPromisePhase, 1); assert.deepEqual(own.amalCards, [f.amal]); assert.equal(own.amalReady, true);
assert.deepEqual(viewGame(game, f.asker).amalCards, []);
assert.ok(Object.keys(promise(game, f.target)).every(key => ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'].includes(key)));
rejected(game, f.target, { type: 'ready' }); rejected(game, f.asker, guide.action);
rejected(game, f.target, { ...guide.action, mode: 'special' });
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-amal-event' });
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 0 });
const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, packet));
rejected(game, f.target, packet); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
const closed = close(f.game, f); rejected(closed, f.target, f.nativeAction);
const after = declare(f, closed); rejected(after.game, f.asker, { type: 'truthAsk', question: f.question });
});
void test('an actual bidding opening quote leaves the unopened auction pool untouched until real global passes initialize it', () => {
const f = fixture({ phase: 1 }); let game = close(f.game, f); assert.equal(beforeIxSpiceDraw(game), true);
orderIxSpiceCards(game, [{ territory: 'hagga_basin' }]);
game = advance(game, state => state.phase === 3 && !!state.phaseOpening && quiet(state), [], true);
assert.equal(game.auction, null); const deck = game.deck.map(card => card.id), current = windowCardOpportunity(game, 'amal'); assert.ok(current);
const mixed = group('and', play(current.source), payment(f.recipient));
game = bind(f, 'yes', mixed, game).game;
assert.equal(game.auction, null); assert.deepEqual(game.deck.map(card => card.id), deck);
assert.equal(player(game, f.target).spice, 10);
game = settle(act(game, f.target, f.nativeAction)); assert.equal(game.auction, null); assert.deepEqual(game.deck.map(card => card.id), deck);
game = close(game, { source: current.source }); assert.ok(game.auction); assert.ok(game.deck.length < deck.length);
game = act(game, f.target, f.payment); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
});
void test('all four minimal profiles execute real Amal before payment and pass a No through native opening Ready', () => {
for (const phase of [0, 1] as const) {
const f = fixture({ phase }), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthTurnAmalStep[] = [];
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 < 22 && !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, `${phase}/${difficulty}`); assert.deepEqual(action, guide.action);
game = act(game, f.target, action, actions);
} else {
const next = nativeStep(game); assert.ok(next, `${phase}/${difficulty}`); game = act(game, next.actor, next.action, actions);
}
}
assert.equal(promise(game, f.target).fulfilled, true, `${phase}/${difficulty}`);
const costAt = actions.findIndex(step => step.action.type === 'card' && step.action.card === f.amal);
const payAt = actions.findIndex(step => step.actor === f.target && step.action.type === 'bribe');
assert.ok(costAt >= 0 && payAt > costAt); assert.equal(player(game, f.target).spice, 0); physical(game, f);
const no = bind(f, 'no', play(f.source)).game, view = viewGame(no, f.target);
view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
assert.equal(view.amalReady, true); assert.deepEqual(view.amalCards, [f.amal]); rejected(no, f.target, f.nativeAction);
const ready = botActions(view)[0]; assert.equal(ready.type, 'ready'); assert.deepEqual(ready, view.turnActionGuide?.action);
const passed = act(no, f.target, ready), passedView = viewGame(passed, f.target);
assert.equal(passedView.amalReady, false); assert.deepEqual(passedView.amalCards, []);
const completed = close(passed, f);
assert.equal(promise(completed, f.target).fulfilled, true); assert.equal(receipt(completed).played, false); physical(completed, f);
}
}
});
+3 -2
View File
@@ -20,12 +20,13 @@ import {
} from '../game/truthtrance';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
void test('whole card questions name Thumper and the actual printed Karama role and shipment beneficiary', () => {
const question = (effect: 'thumper' | 'karamaAuction' | 'karamaShipment', source: string) =>
void test('whole card questions name Amal, Thumper and the actual printed Karama role and shipment beneficiary', () => {
const question = (effect: 'amal' | 'thumper' | 'karamaAuction' | 'karamaShipment', source: string) =>
truthQuestionText({ kind: 'turnActions', target: 'emperor', turn: 2, mixed: { kind: 'action', action: {
kind: 'windowCardPlay', effect, source,
} } }, id => id, id => id, id => id === 'emperor' ? 'Emperor' : id);
assert.match(question('thumper', 'first-spice-reveal:2'), /Thumper.*original first Spice reveal.*ignored or suppressed/);
assert.match(question('amal', 'phase-opening:2:8'), /Amal.*original phase opening.*before ordinary payments/);
assert.match(question('karamaAuction', 'ordinary-auction:2:1'), /Karama.*original ordinary auction lot.*winning invoice/);
assert.match(question('karamaShipment', '["karamaShipment",2,"emperor","original"]'), /Karama.*Emperor’s original unused shipment price/);
});
+47 -1
View File
@@ -61,7 +61,8 @@ const power = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expressi
({ kind: 'action', action: { kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim } });
const windowSource = (effect: WindowCardEffect) => effect === 'karamaShipment'
? JSON.stringify(['karamaShipment', 1, 'recipient', 'original']) : effect === 'karamaAuction' ? 'ordinary-auction:1:1' :
effect === 'thumper' ? 'first-spice-reveal:1' : effect === 'harvester' ? 'spice-blow:1' : 'storm:1';
effect === 'amal' ? 'phase-opening:1:2' : effect === 'thumper' ? 'first-spice-reveal: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 } });
@@ -1572,3 +1573,48 @@ void test('Thumper whole AND No and OR Yes retain their later payment correlatio
assert.equal(recordTurnActionWindowCardPlay(paid, 'respondent', 1, 'thumper', 'first-spice-reveal:1')[0].fulfilled, true);
validateTurnActionPromises([expiry], seats);
});
void test('Amal No caps actual printed cost in only the original answer interval and preserves whole No payment correlation', () => {
const source = windowSource('amal'), no = windowed(windowCard('amal'), false);
for (const [player, turn, opening] of [['other', 1, source], ['respondent', 2, source],
['respondent', 1, 'phase-opening:1:3']] as const) {
assert.equal(recordTurnActionWindowCardPlay([no], player, turn, 'amal', opening)[0], no);
assert.equal(closeTurnActionWindowCardPlay([no], player, turn, 'amal', opening)[0], no);
}
assert.throws(() => recordTurnActionWindowCardPlay([no], 'respondent', 1, 'amal', source), TurnActionError);
assert.equal(no.windowCards!.amal!.played, false);
const closed = closeTurnActionWindowCardPlay([no], 'respondent', 1, 'amal', source)[0];
assert.equal(closed.fulfilled, true);
assert.equal(recordTurnActionWindowCardPlay([closed], 'respondent', 1, 'amal', source)[0], closed);
const wholeNo = windowed(group('and', windowCard('amal'), pay()), false, 'turn-action:2');
const played = recordTurnActionWindowCardPlay([wholeNo], 'respondent', 1, 'amal', source);
assert.equal(played[0].fulfilled, false);
const later = closeTurnActionWindowCardPlay(played, 'respondent', 1, 'amal', source);
assert.deepEqual(branches(later[0]), [[signedPay('recipient', 3, 0, false)]]);
assert.throws(() => recordTurnActionPayment(later, 'respondent', 1, payment()), TurnActionError);
assert.deepEqual(later[0].payments, []);
assert.equal(finishTurnActionTurn(later, 'respondent', 1)[0].fulfilled, true);
validateTurnActionPromises([closed], seats);
});
void test('Amal whole AND and OR keep printed receipt and later payment alternatives without storing a halving choice', () => {
const source = windowSource('amal');
const and = windowed(group('and', windowCard('amal'), pay()));
const or = windowed(group('or', windowCard('amal'), pay()), true, 'turn-action:2');
for (const extra of [{ amount: 3 }, { target: 'recipient' }, { half: 2 }, { card: 'physical-amal' }])
assert.throws(() => parseTurnActionLeaf({ kind: 'windowCardPlay', effect: 'amal', source, ...extra }), TurnActionError);
const played = recordTurnActionWindowCardPlay([and, or], 'respondent', 1, 'amal', source);
assert.deepEqual(branches(played[0]), [[signedPay()]]);
assert.equal(played[1].fulfilled, true);
const closed = closeTurnActionWindowCardPlay(played, 'respondent', 1, 'amal', source);
assert.equal(closed[1], played[1]);
assert.equal(recordTurnActionPayment(closed, 'respondent', 1, payment())[0].fulfilled, true);
const missed = closeTurnActionWindowCardPlay([or], 'respondent', 1, 'amal', source);
assert.deepEqual(branches(missed[0]), [[signedPay()]]);
assert.equal(recordTurnActionPayment(missed, 'respondent', 1, payment())[0].fulfilled, true);
const no = windowed(windowCard('amal'), false, 'turn-action:3');
const expired = finishTurnActionTurn([no], 'respondent', 1)[0];
assert.deepEqual(expired.windowCards!.amal, { source, played: false, closed: true });
assert.equal(expired.fulfilled, true);
validateTurnActionPromises([expired], seats);
});
+41 -2
View File
@@ -23,8 +23,47 @@ function shipments(): WindowCardGame {
}
void test('window roles expose a shared exhaustive tuple and exact native printed-card mapping', () => {
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'thumper']);
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'thumper']);
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'thumper', 'amal']);
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'thumper', 'amal']);
});
void test('Amal names each original phase opening with only its public turn and phase identity', () => {
for (let phase = 0; phase <= 8; phase++) {
const source = { ...storm(), phase, amalWindowOpen: true };
assert.deepEqual(windowCardOpportunity(source, 'amal'), { effect: 'amal', source: `phase-opening:3:${phase}`, turn: 3 });
}
for (const phase of [-1, 0.5, 9, NaN, Infinity])
assert.equal(windowCardOpportunity({ ...storm(), phase, amalWindowOpen: true }, 'amal'), null);
});
void test('explicit Amal entitlement survives temporary Truth and discard suspension but actual close never reopens it', () => {
const source = { ...storm(), phase: 4, amalWindowOpen: true, phaseOpening: { passed: ['first'] } };
for (const suspended of [{ truthtrance: {} }, { response: {} }, { decision: {} }, { phaseOpening: null },
{ phaseOpening: null, pendingTreacheryDiscard: {} }]) {
const waiting = { ...source, ...suspended };
assert.equal(windowCardOpportunity(waiting, 'amal')!.source, 'phase-opening:3:4');
assert.equal(windowCardOpportunity({ ...waiting, amalWindowOpen: false }, 'amal'), null);
}
assert.equal(windowCardOpportunity({ ...source, amalWindowOpen: false, phaseOpening: { passed: [] } }, 'amal'), null);
});
void test('legacy Amal openings accept the public passed list without requiring native initialization metadata', () => {
for (const phaseOpening of [{ passed: [] }, { passed: ['first', 'later'] }, { passed: [], initialize: true }])
assert.equal(windowCardOpportunity({ ...storm(), phase: 2, phaseOpening }, 'amal')!.source, 'phase-opening:3:2');
for (const phaseOpening of [undefined, null, [], {}, { passed: null }, { passed: 'first' },
{ passed: ['first', 'first'] }, { passed: [''] }, { passed: [' first'] }, { passed: [1] }])
assert.equal(windowCardOpportunity({ ...storm(), phaseOpening }, 'amal'), null);
});
void test('Amal rejects invalid flags and stale opening identities without changing the original state', () => {
const source = { ...storm(), phase: 7, amalWindowOpen: true }, before = structuredClone(source);
assert.equal(windowCardOpportunity(source, 'amal', 'phase-opening:3:7')!.source, 'phase-opening:3:7');
for (const expected of ['phase-opening:3:6', 'phase-opening:4:7', 'phase-opening:03:7', 'phase-opening:3:7 '])
assert.equal(windowCardOpportunity(source, 'amal', expected), null);
for (const invalid of [{ amalWindowOpen: 'true' }, { amalWindowOpen: null }, { amalWindowOpen: 1 },
{ status: 'setup' }, { turn: 0 }, { turn: 1.5 }, { turn: Number.MAX_SAFE_INTEGER + 1 }])
assert.equal(windowCardOpportunity({ ...source, ...invalid } as WindowCardGame, 'amal'), null);
assert.deepEqual(source, before);
});
void test('Thumper binds the public first-reveal entitlement without any hidden deck or physical-card source', () => {