Connect phase-opening Amal promises and native payment order
This commit is contained in:
1 parent
00741a37d0
commit
b81823bbec
20 files changed
+954
-51
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'])
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user