Bind completed Semuta exchanges to Truthtrance promises
This commit is contained in:
1 parent
4c4bebe85e
commit
b02839ce2a
24 files changed
+1014
-40
No files matched your search
@@ -66,6 +66,9 @@ void test('offer never inspects an uncommitted candidate or rival custody; unabl
|
||||
reaction.blocked = null;
|
||||
reaction.canCommit = false;
|
||||
assert.deepEqual(botSemutaActions(v), [{ type: 'semutaPass', event }]);
|
||||
reaction.passBlocked = 'Retain the required whole-answer exchange.';
|
||||
assert.deepEqual(botSemutaActions(v), []);
|
||||
delete reaction.passBlocked;
|
||||
reaction.passed = true;
|
||||
assert.deepEqual(botSemutaActions(v), []);
|
||||
v.semutaReaction = null;
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||||
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
|
||||
import { semutaBattleKaramaFixture } from './fixture-semuta-battle-karama';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import {
|
||||
actTruthTransferOrderPlay, createTruthSemutaPrebidFixture, nextTruthTransferOrderPlayNative,
|
||||
truthTransferOrderPlayInventory, truthTransferOrderPlayPlayer, truthTransferOrderPlaySpice,
|
||||
truthTransferOrderPlayTraitors,
|
||||
} from './fixture-truth-transfer-order-play';
|
||||
|
||||
export type TruthSemutaPlayLeaf = { kind: 'cardPlay'; effect: 'semutaDrug' } |
|
||||
{ kind: 'payment'; recipient: string; minimum: number };
|
||||
export type TruthSemutaPlayExpression = MixedActionExpression<TruthSemutaPlayLeaf>;
|
||||
export type TruthSemutaPlayStep = { actor: string; action: Action };
|
||||
export const actTruthSemutaPlay = actTruthTransferOrderPlay;
|
||||
export const truthSemutaPlayPlayer = truthTransferOrderPlayPlayer;
|
||||
export const truthSemutaPlayInventory = truthTransferOrderPlayInventory;
|
||||
export const truthSemutaPlaySpice = truthTransferOrderPlaySpice;
|
||||
export const truthSemutaPlayTraitors = truthTransferOrderPlayTraitors;
|
||||
export const truthSemutaPlayClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingNullentropy && !game.pendingShipment && !game.currentAuctionSale;
|
||||
export const truthSemutaPlayUse = (): TruthSemutaPlayExpression => ({ kind: 'action', action: { kind: 'cardPlay', effect: 'semutaDrug' } });
|
||||
export const truthSemutaPlayFact = (fact: TruthFact): TruthSemutaPlayExpression => ({ kind: 'fact', fact });
|
||||
export const truthSemutaPlayPayment = (recipient: string, minimum = 1): TruthSemutaPlayExpression =>
|
||||
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
|
||||
export const truthSemutaPlayGroup = (kind: 'and' | 'or', ...terms: TruthSemutaPlayExpression[]): TruthSemutaPlayExpression => ({ kind, terms });
|
||||
|
||||
export type TruthSemutaPlaySource = {
|
||||
game: Game; source: Game; sourceKind: 'nativeSingleton' | 'controlledMulti'; nativeAcquisition: boolean;
|
||||
target: string; asker: string; recipient: string; truths: string[]; truthHolders: string[]; truth: string;
|
||||
semuta: string; card: string; effect: 'semutaDrug'; spiceFact: TruthFact; mixed: TruthSemutaPlayExpression; question: TruthQuestion;
|
||||
setup: Game; originalCards: string[]; originalSpice: string[]; originalTraitors: string[];
|
||||
actions: TruthSemutaPlayStep[]; sourceActions: TruthSemutaPlayStep[]; sourceActionCount: number;
|
||||
sourceCounts: Record<string, number | null>; sourceDisclosure: string;
|
||||
};
|
||||
|
||||
/** Native neutral controls never volunteer the promised Semuta action. The
|
||||
* caller explicitly uses its guide, passes a permitted offer or selects. */
|
||||
export function nextTruthSemutaPlayNative(game: Game): TruthSemutaPlayStep | null {
|
||||
if (game.truthtrance || game.pendingNullentropy || game.pendingTreacheryDiscard?.reaction?.stage === 'select') return null;
|
||||
return game.players.some(player => player.id === 'g') ? nextTruthTransferOrderPlayNative(game) : nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
export function advanceTruthSemutaPlay(game: Game, until: (state: Game) => boolean, actions: TruthSemutaPlayStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextTruthSemutaPlayNative(game); assert.ok(step, 'Supply the actual Truth answer or committed private Semuta choice');
|
||||
game = actTruthSemutaPlay(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The finite native Semuta continuation did not reach its boundary');
|
||||
}
|
||||
export function passTruthSemutaOffer(game: Game, actions: TruthSemutaPlayStep[] = []): Game {
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.reaction?.stage === 'offer');
|
||||
const event = pending.batch.event;
|
||||
return advanceTruthSemutaPlay(game, state => state.pendingTreacheryDiscard?.batch.event !== event, actions);
|
||||
}
|
||||
export function settleTruthSemutaPlay(game: Game, actions: TruthSemutaPlayStep[] = []): Game {
|
||||
return advanceTruthSemutaPlay(game, truthSemutaPlayClean, actions);
|
||||
}
|
||||
export function finishTruthSemutaPlay(game: Game, actions: TruthSemutaPlayStep[] = []): Game {
|
||||
const turn = game.turn;
|
||||
return advanceTruthSemutaPlay(game, state => state.turn === turn + 1, actions);
|
||||
}
|
||||
|
||||
/** Actual turn1 Distrans use and turn2 cache Semuta1 acquisition. A later
|
||||
* physical Truth answer supplies its original foreign singleton discard. */
|
||||
export function createTruthSemutaPlayFixture(options: { firstBid?: boolean } = {}) {
|
||||
const base = createTruthSemutaPrebidFixture(options), game = structuredClone(base.game), actions = structuredClone(base.actions);
|
||||
const effect = 'semutaDrug' as const, mixed = truthSemutaPlayGroup('and', truthSemutaPlayUse(),
|
||||
truthSemutaPlayPayment(base.recipient, viewGame(game, base.target).bribeOptions.available));
|
||||
const question: TruthQuestion = { kind: 'turnActions', target: base.target, turn: game.turn, mixed };
|
||||
const source = { ...base, game, source: structuredClone(game), sourceKind: 'nativeSingleton' as const, nativeAcquisition: true,
|
||||
effect, card: base.semuta, mixed, question, base,
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
sourceDisclosure: base.sourceDisclosure + ' This adapter asks only about a future completed printed Semuta use after the actual answer. ' +
|
||||
'Committing a multi-card search alone is not completion; no selected-card predicate or hypothetical future discard is added. ' +
|
||||
'The current declared foreign Truth supplies the genuine fresh singleton. All source counts derive from actual arrays.',
|
||||
};
|
||||
return source satisfies TruthSemutaPlaySource;
|
||||
}
|
||||
|
||||
/** Explicit unit supplement: inherit the existing bare Advanced conserved
|
||||
* battle setup, then conserve only original hands before the real question.
|
||||
* Acquisition is NOT native evidence. No fresh batch/event is installed. */
|
||||
export function createTruthSemutaMultiFixture() {
|
||||
const inherited = semutaBattleKaramaFixture('capture'), game = structuredClone(inherited.staged);
|
||||
const originalCards = truthSemutaPlayInventory(game), originalSpice = truthSemutaPlaySpice(game), originalTraitors = truthSemutaPlayTraitors(game);
|
||||
const target = inherited.holder, asker = inherited.winner, recipient = asker, opponent = inherited.loser;
|
||||
for (const actor of [asker, opponent]) game.deck.push(...truthSemutaPlayPlayer(game, actor).hand.splice(0));
|
||||
const move = (id: string, holder: string) => {
|
||||
for (const zone of [game.deck, game.discard, ...(game.richeseCache ? [game.richeseCache] : []), ...game.players.map(player => player.hand)]) {
|
||||
const at = zone.findIndex(card => card.id === id);
|
||||
if (at >= 0) { truthSemutaPlayPlayer(game, holder).hand.push(zone.splice(at, 1)[0]); return id; }
|
||||
}
|
||||
throw Error('Conserve the original physical card for this explicitly controlled supplement');
|
||||
};
|
||||
const truths = game.deck.filter(card => card.effect === 'truthtrance').map(card => card.id); assert.equal(truths.length, 2);
|
||||
move(truths[0], asker); move(truths[1], opponent);
|
||||
const weapons = game.deck.filter(card => card.kind === 'projectile').slice(0, 2).map(card => card.id);
|
||||
const defenses = game.deck.filter(card => card.kind === 'shield').slice(0, 2).map(card => card.id);
|
||||
assert.equal(weapons.length, 2); assert.equal(defenses.length, 2);
|
||||
const winnerCards = [move(weapons[0], asker), move(defenses[0], asker)], loserCards = [move(weapons[1], opponent), move(defenses[1], opponent)];
|
||||
assert.ok(!game.battle && !game.pendingTreacheryDiscard && truthSemutaPlayClean(game));
|
||||
assert.ok(truthSemutaPlayPlayer(game, target).hand.some(card => card.id === inherited.semuta));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && !game.strongholdCards);
|
||||
assert.deepEqual(truthSemutaPlayInventory(game), originalCards);
|
||||
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthSemutaPlayPlayer(game, target).spice };
|
||||
const mixed = truthSemutaPlayGroup('or', truthSemutaPlayUse(), truthSemutaPlayPayment(recipient));
|
||||
const question: TruthQuestion = { kind: 'turnActions', target, turn: game.turn, mixed };
|
||||
const actions: TruthSemutaPlayStep[] = [];
|
||||
const source = { game, source: structuredClone(game), sourceKind: 'controlledMulti' as const, nativeAcquisition: false,
|
||||
target, asker, recipient, truth: truths[0], truths, truthHolders: [asker, opponent], semuta: inherited.semuta,
|
||||
card: inherited.semuta, effect: 'semutaDrug' as const, spiceFact, mixed, question,
|
||||
setup: structuredClone(inherited.staged), originalCards, originalSpice, originalTraitors,
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
sourceCounts: { selected: actions.length, inheritedRecordedActions: null },
|
||||
producer: { actor: game.active!, opponent, territory: 'arrakeen', winner: asker, loser: opponent, winnerCards, loserCards },
|
||||
sourceDisclosure: 'CONTROLLED CONSERVED UNIT SUPPLEMENT, not native acquisition/history evidence. ' +
|
||||
'Inherit semutaBattleKaramaFixture(capture).staged, whose hands, wallets, armies, phase and turn were explicitly positioned by that older unit fixture. ' +
|
||||
'Conserve its original hands into separate projectile/Shield pairs and two original foreign Truths. ' +
|
||||
'The actual Truth declaration/answer, permitted first-offer passes, chooseBattle, plans, Traitor declines and original outcome acknowledgements later generate a genuine two-card loser-disposal event. ' +
|
||||
'No live battle, fresh discard batch, event, selection, receipt or promise is installed. The inherited fixture records no action array; its action count is explicitly unavailable. Only new actual controls are counted.',
|
||||
};
|
||||
return source satisfies TruthSemutaPlaySource;
|
||||
}
|
||||
export type TruthSemutaMultiFixture = ReturnType<typeof createTruthSemutaMultiFixture>;
|
||||
|
||||
export function declareTruthSemutaPlay(f: TruthSemutaPlaySource, source = f.game, copy: 0 | 1 = 0, actions: TruthSemutaPlayStep[] = []) {
|
||||
const asker = f.truthHolders[copy]; let game = actTruthSemutaPlay(source, 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 = actTruthSemutaPlay(game, actor.id, { type: 'truthPass' }, actions);
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask'); return { game, asker, actions };
|
||||
}
|
||||
export function askTruthSemutaPlay(game: Game, asker: string, question: TruthQuestion, actions: TruthSemutaPlayStep[] = []): Game {
|
||||
return actTruthSemutaPlay(game, asker, { type: 'truthAsk', question: structuredClone(question) }, actions);
|
||||
}
|
||||
export function answerTruthSemutaPlay(game: Game, target: string, answer: 'yes' | 'no', actions: TruthSemutaPlayStep[] = []): Game {
|
||||
return actTruthSemutaPlay(game, target, { type: 'truthAnswer', answer }, actions);
|
||||
}
|
||||
export function bindTruthSemutaPlay(f: TruthSemutaPlaySource, answer: 'yes' | 'no' = 'yes', question: TruthQuestion = f.question,
|
||||
source = f.game, copy: 0 | 1 = 0, actions: TruthSemutaPlayStep[] = []) {
|
||||
const declared = declareTruthSemutaPlay(f, source, copy, actions), asked = askTruthSemutaPlay(declared.game, declared.asker, question, actions);
|
||||
return { game: answerTruthSemutaPlay(asked, f.target, answer, actions), declared, asked, actions };
|
||||
}
|
||||
export function truthSemutaPlayPromise(game: Game, target = game.turnActionPromises?.[0]?.player ?? game.players[0].id) {
|
||||
const row = viewGame(game, target).turnActionPromises.findLast(promise => promise.player === target); assert.ok(row); return row;
|
||||
}
|
||||
export function openTruthSemutaMultiBatch(f: TruthSemutaMultiFixture, source: Game, actions: TruthSemutaPlayStep[] = []): Game {
|
||||
let game = source;
|
||||
if (game.pendingTreacheryDiscard) game = passTruthSemutaOffer(game, actions);
|
||||
assert.ok(truthSemutaPlayClean(game));
|
||||
game = actTruthSemutaPlay(game, f.producer.actor, { type: 'chooseBattle', territory: f.producer.territory,
|
||||
target: f.producer.actor === f.producer.winner ? f.producer.loser : f.producer.winner }, actions);
|
||||
game = advanceTruthSemutaPlay(game, state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && truthSemutaPlayClean(state), actions);
|
||||
const strongest = truthSemutaPlayPlayer(game, f.producer.winner).leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => b.strength - a.strength)[0];
|
||||
const weakest = truthSemutaPlayPlayer(game, f.producer.loser).leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(strongest && weakest && strongest.strength + 1 > weakest.strength);
|
||||
game = actTruthSemutaPlay(game, f.producer.winner, { type: 'battlePlan', dial: 1, support: 1, leader: strongest.id,
|
||||
weapon: f.producer.winnerCards[0], defense: f.producer.winnerCards[1] }, actions);
|
||||
game = actTruthSemutaPlay(game, f.producer.loser, { type: 'battlePlan', dial: 0, support: 0, leader: weakest.id,
|
||||
weapon: f.producer.loserCards[0], defense: f.producer.loserCards[1] }, actions);
|
||||
game = advanceTruthSemutaPlay(game, state => !!state.pendingTreacheryDiscard && state.pendingTreacheryDiscard.batch.entries.length >= 2, actions);
|
||||
assert.deepEqual(game.pendingTreacheryDiscard!.batch.entries.map(entry => entry.card.id).sort(), [...f.producer.loserCards].sort());
|
||||
assert.equal(game.pendingTreacheryDiscard!.reaction?.stage, 'offer'); return game;
|
||||
}
|
||||
export function commitTruthSemutaPlay(game: Game, target: string, actions: TruthSemutaPlayStep[] = []): Game {
|
||||
const offer = game.pendingTreacheryDiscard; assert.ok(offer && offer.reaction?.stage === 'offer');
|
||||
return actTruthSemutaPlay(game, target, { type: 'semutaCommit', event: offer.batch.event }, actions);
|
||||
}
|
||||
export function selectTruthSemutaPlay(game: Game, target: string, selected: string, actions: TruthSemutaPlayStep[] = []): Game {
|
||||
const offer = game.pendingTreacheryDiscard; assert.ok(offer && offer.reaction?.stage === 'select');
|
||||
return actTruthSemutaPlay(game, target, { type: 'semutaSelect', event: offer.batch.event, card: selected }, actions);
|
||||
}
|
||||
|
||||
/** One actual recovery producer, rather than installed recovered custody.
|
||||
* Basic Guild retains its original3 after free Semuta use, then spends the
|
||||
* next original Box cache1 and printed search2 before choosing Semuta. */
|
||||
export function recoverTruthSemutaWithNativeBox(f: TruthSemutaPlaySource, source: Game, actions: TruthSemutaPlayStep[] = []) {
|
||||
assert.equal(f.sourceKind, 'nativeSingleton'); assert.equal(source.advanced, false);
|
||||
assert.equal(source.discard.filter(card => card.id === f.semuta).length, 1);
|
||||
let game = source; const turn = game.turn;
|
||||
game = advanceTruthSemutaPlay(game, state => state.turn === turn + 1 && !state.phaseOpening && !state.response &&
|
||||
state.decision?.kind === 'richeseCache', actions);
|
||||
const beforePurchase = structuredClone(game), box = game.richeseCache?.find(card => card.effect === 'nullentropyBox'); assert.ok(box);
|
||||
const from = actions.length;
|
||||
game = actTruthSemutaPlay(game, game.decision!.player, { type: 'decision', event: game.richeseBidding!.event,
|
||||
card: box.id, method: 'onceAround', direction: 'counterclockwise' }, actions);
|
||||
for (let count = 0; count < 200 && !(truthSemutaPlayPlayer(game, f.target).hand.some(card => card.id === box.id) &&
|
||||
truthSemutaPlayClean(game) && !game.richeseAuction && !!game.auction && game.auction.bid === 0 && game.auction.passed.length === 0); count++) {
|
||||
const lot = game.richeseAuction;
|
||||
const step = truthSemutaPlayClean(game) && lot?.cardId === box.id && !lot.outcome && lot.active === f.target && lot.bid === 0
|
||||
? { actor: f.target, action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } satisfies Action }
|
||||
: nextTruthSemutaPlayNative(game);
|
||||
assert.ok(step); game = actTruthSemutaPlay(game, step.actor, step.action, actions);
|
||||
}
|
||||
assert.ok(truthSemutaPlayPlayer(game, f.target).hand.some(card => card.id === box.id));
|
||||
assert.equal(truthSemutaPlayPlayer(beforePurchase, f.target).spice - truthSemutaPlayPlayer(game, f.target).spice, 1);
|
||||
const purchase = { before: beforePurchase, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
|
||||
const beforeRecovery = structuredClone(game);
|
||||
game = actTruthSemutaPlay(game, f.target, { type: 'card', card: box.id }, actions); const paid = structuredClone(game);
|
||||
if (game.pendingNullentropy) {
|
||||
const search = viewGame(game, f.target).nullentropy?.search; assert.ok(search && search.cards.some(card => card.id === f.semuta));
|
||||
game = actTruthSemutaPlay(game, f.target, { type: 'decision', event: search.event, card: f.semuta }, actions);
|
||||
}
|
||||
game = settleTruthSemutaPlay(game, actions);
|
||||
assert.ok(truthSemutaPlayPlayer(game, f.target).hand.some(card => card.id === f.semuta));
|
||||
assert.equal(truthSemutaPlayPlayer(beforeRecovery, f.target).spice - truthSemutaPlayPlayer(game, f.target).spice, 2);
|
||||
assert.deepEqual(truthSemutaPlayInventory(game), f.originalCards);
|
||||
return { game, box: box.id, purchase, beforeRecovery, paid, afterRecovery: structuredClone(game), actions };
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '../game/printed-card-play';
|
||||
|
||||
void test('Box accepted activation counts extend the shared roles without changing earlier Hajr or Ghola baselines', () => {
|
||||
assert.deepEqual(PRINTED_CARD_EFFECTS, ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho']);
|
||||
assert.deepEqual(PRINTED_CARD_EFFECTS, ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho', 'semutaDrug']);
|
||||
assert.equal(printedCardPlayCount(undefined, 'richese', 3, 'nullentropyBox'), 0);
|
||||
const ghola = recordPrintedCardPlay(undefined, 'richese', 3, 'ghola');
|
||||
const first = recordPrintedCardPlay(ghola, 'richese', 3, 'nullentropyBox');
|
||||
@@ -58,6 +58,24 @@ void test('Distrans and Sapho count separate accepted printed uses and replay wi
|
||||
{ turn: 3, plays: [{ player: 'richese', effect: 'distrans', count: 1 }] });
|
||||
});
|
||||
|
||||
void test('Semuta counts actual physical resolutions separately from other roles without accepting commitment or selected-card metadata', () => {
|
||||
const original = recordPrintedCardPlay(undefined, 'richese', 4, 'distrans');
|
||||
assert.equal(printedCardPlayCount(original, 'richese', 4, 'semutaDrug'), 0);
|
||||
const used = recordPrintedCardPlay(original, 'richese', 4, 'semutaDrug');
|
||||
const replay = recordPrintedCardPlay(used, 'richese', 4, 'semutaDrug');
|
||||
assert.equal(printedCardPlayCount(replay, 'richese', 4, 'semutaDrug'), 2);
|
||||
assert.equal(printedCardPlayCount(replay, 'other', 4, 'semutaDrug'), 0);
|
||||
assert.equal(printedCardPlayCount(replay, 'richese', 5, 'semutaDrug'), 0);
|
||||
assert.deepEqual(original, { turn: 4, plays: [{ player: 'richese', effect: 'distrans', count: 1 }] });
|
||||
for (const extra of [{ committed: true }, { stage: 'select' }, { selected: 'private-fresh-card' }, { event: 'discard:4:2:1' }]) {
|
||||
const invalid = { turn: 4, plays: [{ player: 'richese', effect: 'semutaDrug', count: 1, ...extra }] } as PrintedCardPlayCounts;
|
||||
assert.throws(() => printedCardPlayCount(invalid, 'richese', 4, 'semutaDrug'), PrintedCardPlayError);
|
||||
}
|
||||
const full: PrintedCardPlayCounts = { turn: 4, plays: [{ player: 'richese', effect: 'semutaDrug', count: Number.MAX_SAFE_INTEGER }] };
|
||||
assert.throws(() => recordPrintedCardPlay(full, 'richese', 4, 'semutaDrug'), PrintedCardPlayError);
|
||||
assert.equal(printedCardPlayCount(full, 'richese', 4, 'semutaDrug'), Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
|
||||
void test('missing legacy counts and other turn/player/effect baselines read as zero', () => {
|
||||
assert.equal(printedCardPlayCount(undefined, 'atreides', 1, 'hajr'), 0);
|
||||
const counts = recordPrintedCardPlay(undefined, 'atreides', 1, 'hajr');
|
||||
|
||||
@@ -76,4 +76,8 @@ void test('passed, blocked, busy and waiting seats hide or disable unavailable c
|
||||
assert.match(markup(offer(), true), /disabled=""[^>]*>Continue/);
|
||||
assert.match(markup(offer({ stage: 'select', candidates: [first, second] }), true),
|
||||
/disabled=""[^>]*>Claim Projectile/);
|
||||
const required = markup(offer({ canCommit: true, passBlocked: 'Keep this required fresh exchange.' }));
|
||||
assert.match(required, /Keep this required fresh exchange/);
|
||||
assert.match(required, /disabled=""[^>]*>Continue/);
|
||||
assert.doesNotMatch(required, /disabled=""[^>]*>Commit Semuta Drug/);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { createDistransPrebidFixture } from './fixture-distrans-prebid';
|
||||
import {
|
||||
askTruthTurnCombat as ask,
|
||||
createTruthTurnCombatFixture as fixture,
|
||||
@@ -80,3 +81,19 @@ void test('detached opaque weapon forecast retains an independent positive dial-
|
||||
assert.equal(Object.hasOwn(asked, '_turnActionUnknownCards'), false);
|
||||
physical(asked, f); physical(opaque, f); physical(f.game, f);
|
||||
});
|
||||
|
||||
void test('detached opaque acquisition supplies generic Distrans transfer, but cannot activate Distrans itself', () => {
|
||||
const f = createDistransPrebidFixture(), original = structuredClone(f.game);
|
||||
const recipient = f.action.target as string, given = f.action.give as string;
|
||||
assert.ok(viewGame(f.game, f.holder).distrans?.choices.some(choice =>
|
||||
choice.recipient === recipient && choice.cards.some(card => card.id === given)));
|
||||
const opaque = opaqueQuote(f.game, given), before = structuredClone(opaque);
|
||||
assert.ok(viewGame(opaque, f.holder).distrans?.choices.some(choice =>
|
||||
choice.recipient === recipient && choice.cards.some(card => card.id === given)),
|
||||
'A generic other-card transfer does not require an unseen specific effect');
|
||||
assert.deepEqual(opaque, before);
|
||||
const unknownRole = opaqueQuote(f.game, f.card);
|
||||
assert.match(viewGame(unknownRole, f.holder).distrans?.blocked ?? '', /unseen future acquired card/i);
|
||||
assert.deepEqual(f.game, original);
|
||||
assert.equal(Object.hasOwn(f.game, '_turnActionUnknownCards'), false);
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { printedCardPlayCount } from '../game/printed-card-play';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { truthFactAnswer, type TruthQuestion } from '../game/truthtrance';
|
||||
import {
|
||||
actTruthSemutaPlay as act, advanceTruthSemutaPlay as advance, answerTruthSemutaPlay as answer,
|
||||
askTruthSemutaPlay as ask, bindTruthSemutaPlay as bind, commitTruthSemutaPlay as commit,
|
||||
createTruthSemutaPlayFixture as fixture, createTruthSemutaMultiFixture as multiFixture,
|
||||
declareTruthSemutaPlay as declare, finishTruthSemutaPlay as finish, nextTruthSemutaPlayNative as nativeStep,
|
||||
openTruthSemutaMultiBatch as multiBatch, passTruthSemutaOffer as passOffer,
|
||||
recoverTruthSemutaWithNativeBox as recover, selectTruthSemutaPlay as select, settleTruthSemutaPlay as settle,
|
||||
truthSemutaPlayClean as clean, truthSemutaPlayFact as fact, truthSemutaPlayGroup as group,
|
||||
truthSemutaPlayInventory as inventory, truthSemutaPlayPayment as payment, truthSemutaPlayPlayer as player,
|
||||
truthSemutaPlayPromise as promise, truthSemutaPlaySpice as spice, truthSemutaPlayTraitors as traitors,
|
||||
truthSemutaPlayUse as use, type TruthSemutaPlayExpression, type TruthSemutaPlaySource, type TruthSemutaPlayStep,
|
||||
} from './fixture-truth-semuta-play';
|
||||
|
||||
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: TruthSemutaPlaySource) {
|
||||
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(spice(game), f.originalSpice);
|
||||
assert.deepEqual(traitors(game), f.originalTraitors);
|
||||
}
|
||||
const raw = (f: TruthSemutaPlaySource, mixed: TruthSemutaPlayExpression, turn = f.game.turn): TruthQuestion =>
|
||||
({ kind: 'turnActions', target: f.target, turn, mixed });
|
||||
const stage = (game: Game, index = 0) => game.turnActionPromises![index].cardPlays?.semutaDrug;
|
||||
const count = (game: Game, f: TruthSemutaPlaySource) => printedCardPlayCount(game.printedCardPlays, f.target, game.turn, 'semutaDrug');
|
||||
const pay = (game: Game, f: TruthSemutaPlaySource, amount: number, actions: TruthSemutaPlayStep[] = []) =>
|
||||
act(game, f.target, { type: 'bribe', target: f.recipient, amount }, actions);
|
||||
|
||||
void test('Semuta sources distinguish genuine cache acquisition from the explicitly conserved multi-card producer supplement', () => {
|
||||
for (const firstBid of [false, true]) {
|
||||
const f = fixture({ firstBid }); assert.equal(f.nativeAcquisition, true); assert.equal(f.sourceKind, 'nativeSingleton');
|
||||
assert.equal(f.setup.status, 'setup'); assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
|
||||
assert.deepEqual(f.originalCards, [...treacheryDeck(['choam']), ...richeseCards()].map(card => card.id).sort());
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.sourceCounts.selected, f.actions.length);
|
||||
assert.equal(f.game.auction!.bid, firstBid ? 1 : 0); assert.equal(f.game.semutaPreview, true);
|
||||
assert.ok(f.acquisition.steps.some(step => step.actor === f.target && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.ok(player(f.game, f.target).hand.some(card => card.id === f.semuta)); assert.equal(player(f.game, f.target).spice, 3);
|
||||
for (const [index, truth] of f.truths.entries()) assert.ok(player(f.game, f.truthHolders[index]).hand.some(card => card.id === truth));
|
||||
assert.equal(count(f.game, f), 0); assert.equal(f.game.turnActionPromises?.length ?? 0, 0); physical(f.game, f);
|
||||
}
|
||||
const supplement = multiFixture(); assert.equal(supplement.nativeAcquisition, false); assert.equal(supplement.sourceKind, 'controlledMulti');
|
||||
assert.match(supplement.sourceDisclosure, /CONTROLLED CONSERVED UNIT SUPPLEMENT/);
|
||||
assert.equal(supplement.sourceCounts.inheritedRecordedActions, null); assert.equal(supplement.sourceActionCount, supplement.actions.length);
|
||||
assert.ok(!supplement.game.battle && !supplement.game.pendingTreacheryDiscard);
|
||||
assert.ok(!supplement.game.homeworlds && !supplement.game.nexusCards && !supplement.game.leaderSkills);
|
||||
physical(supplement.game, supplement);
|
||||
});
|
||||
|
||||
void test('genuine Semuta AND frozen fact and current-budget payment uses the actual foreign Truth cost for free before paying', () => {
|
||||
for (const firstBid of [false, true]) {
|
||||
const f = fixture({ firstBid }), cash = player(f.game, f.target).spice,
|
||||
budget = viewGame(f.game, f.target).bribeOptions.available;
|
||||
assert.equal(budget, cash - Number(firstBid));
|
||||
if (firstBid) {
|
||||
const declared = declare(f), unfunded = ask(declared.game, declared.asker,
|
||||
raw(f, group('and', fact(f.spiceFact), use(), payment(f.recipient, cash))));
|
||||
assert.deepEqual(viewGame(unfunded, f.target).truthTurnActionAnswers, ['no']);
|
||||
rejected(unfunded, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
}
|
||||
const mixed = group('and', fact(f.spiceFact), use(), payment(f.recipient, budget));
|
||||
let game = bind(f, 'yes', raw(f, mixed)).game;
|
||||
const event = game.pendingTreacheryDiscard!.batch.event, lot = structuredClone(game.auction);
|
||||
assert.equal(game.pendingTreacheryDiscard!.batch.entries[0].card.id, f.truth);
|
||||
assert.equal(stage(game)?.played, false); assert.equal(count(game, f), 0); rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: budget });
|
||||
game = commit(game, f.target); assert.ok(!game.pendingTreacheryDiscard);
|
||||
assert.equal(player(game, f.target).spice, cash); assert.equal(count(game, f), 1); assert.equal(stage(game)?.played, true);
|
||||
assert.ok(player(game, f.target).hand.some(card => card.id === f.truth)); assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
|
||||
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'yes'); assert.deepEqual(game.auction, lot);
|
||||
rejected(game, f.target, { type: 'semutaCommit', event }); game = pay(game, f, budget);
|
||||
assert.equal(player(game, f.target).spice, cash - budget); assert.equal(player(game, f.recipient).bribes, budget);
|
||||
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); assert.equal(promise(game).fulfilled, true);
|
||||
assert.deepEqual(promise(game).mixed, mixed); assert.equal(count(game, f), 1); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('required Yes cannot voluntarily Pass its only known Semuta trigger while foreign neutral passes retain their native rights', () => {
|
||||
const f = fixture(); let game = bind(f, 'yes', raw(f, use())).game;
|
||||
const event = game.pendingTreacheryDiscard!.batch.event;
|
||||
rejected(game, f.target, { type: 'semutaPass', event });
|
||||
assert.ok(viewGame(game, f.target).semutaReaction?.passBlocked);
|
||||
for (const actor of game.players.filter(candidate => candidate.id !== f.target).map(candidate => candidate.id))
|
||||
game = act(game, actor, { type: 'semutaPass', event });
|
||||
assert.equal(game.pendingTreacheryDiscard?.batch.event, event); assert.equal(stage(game)?.played, false);
|
||||
rejected(game, f.target, { type: 'semutaPass', event });
|
||||
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'semutaCommit');
|
||||
game = act(game, f.target, guide.action); assert.equal(promise(game).fulfilled, true); assert.equal(count(game, f), 1);
|
||||
assert.equal(player(game, f.target).spice, 3); physical(game, f);
|
||||
});
|
||||
|
||||
void test('whole OR permits passing Semuta and retaining the physical card for a later actual Mentat payment route', () => {
|
||||
const f = fixture(), budget = player(f.game, f.target).spice;
|
||||
let game = bind(f, 'yes', raw(f, group('or', use(), payment(f.recipient, budget)))).game;
|
||||
const event = game.pendingTreacheryDiscard!.batch.event;
|
||||
game = act(game, f.target, { type: 'semutaPass', event }); game = passOffer(game);
|
||||
assert.equal(stage(game)?.played, false); assert.equal(count(game, f), 0);
|
||||
assert.ok(player(game, f.target).hand.some(card => card.id === f.semuta));
|
||||
game = advance(game, state => state.phase === 8 && clean(state));
|
||||
assert.ok(!promise(game).fulfilled && !promise(game).released); game = pay(game, f, budget);
|
||||
assert.equal(promise(game).fulfilled, true); assert.equal(stage(game)?.played, false); assert.equal(count(game, f), 0);
|
||||
assert.ok(player(game, f.target).hand.some(card => card.id === f.semuta)); physical(game, f);
|
||||
});
|
||||
|
||||
void test('whole OR No blocks Semuta commitment before inspection and retains its cumulative payment cap through the turn', () => {
|
||||
const f = fixture(); let game = bind(f, 'no', raw(f, group('or', use(), payment(f.recipient, 2)))).game;
|
||||
const reaction = viewGame(game, f.target).semutaReaction; assert.ok(reaction);
|
||||
assert.equal(reaction.canCommit, false); assert.deepEqual(reaction.candidates, []);
|
||||
rejected(game, f.target, { type: 'semutaCommit', event: reaction.event }); assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
game = passOffer(game); game = pay(game, f, 1);
|
||||
game = advance(game, state => state.phase === 8 && clean(state));
|
||||
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); assert.ok(!promise(game).released);
|
||||
game = finish(game); assert.equal(promise(game).fulfilled, true); assert.equal(stage(game)?.played, false); assert.equal(stage(game)?.closed, true);
|
||||
assert.ok(player(game, f.target).hand.some(card => card.id === f.semuta)); physical(game, f);
|
||||
});
|
||||
|
||||
void test('whole AND No permits the real Semuta exchange but forbids the later payment that would complete both terms', () => {
|
||||
const f = fixture(); let game = bind(f, 'no', raw(f, group('and', use(), payment(f.recipient, 2)))).game;
|
||||
game = commit(game, f.target); assert.equal(count(game, f), 1); assert.equal(stage(game)?.played, true);
|
||||
game = pay(game, f, 1); rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
|
||||
assert.ok(!promise(game).released); game = finish(game); assert.equal(promise(game).fulfilled, true);
|
||||
assert.equal(stage(game)?.played, true); assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1); physical(game, f);
|
||||
});
|
||||
|
||||
void test('current offer guide packets retain exact native event and phase through JSON and reject stale owner or preselection', () => {
|
||||
const f = fixture({ firstBid: true }); let game = bind(f, 'yes', raw(f, use())).game;
|
||||
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'semutaCommit'); assert.equal(guide.event, promise(game).event);
|
||||
assert.equal(guide.action.event, game.pendingTreacheryDiscard!.batch.event);
|
||||
assert.equal(guide.action.turnActionPromiseEvent, promise(game).event); assert.equal(guide.action.turnActionPromisePhase, 3);
|
||||
rejected(game, f.asker, guide.action); rejected(game, f.target, { ...guide.action, event: 'stale-fresh-semuta-source' });
|
||||
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-whole-answer' });
|
||||
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 4 }); rejected(game, f.target, { ...guide.action, card: f.truth });
|
||||
const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, packet);
|
||||
assert.equal(promise(game).fulfilled, true); assert.equal(count(game, f), 1); rejected(game, f.target, packet); physical(game, f);
|
||||
});
|
||||
|
||||
void test('controlled native two-card batches count neither commitment nor inspection, and either completed choice or its exact selection guide fulfills', () => {
|
||||
const f = multiFixture();
|
||||
for (const route of ['first', 'second', 'guide'] as const) {
|
||||
const actions: TruthSemutaPlayStep[] = []; let game = bind(f, 'yes', f.question, f.game, 0, actions).game;
|
||||
game = multiBatch(f, game, actions); const event = game.pendingTreacheryDiscard!.batch.event;
|
||||
assert.deepEqual(viewGame(game, f.target).semutaReaction?.candidates, []); assert.equal(count(game, f), 0);
|
||||
game = commit(game, f.target, actions); assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'select');
|
||||
assert.equal(count(game, f), 0); assert.equal(stage(game)?.played, false); assert.ok(!promise(game).fulfilled);
|
||||
assert.ok(player(game, f.target).hand.some(card => card.id === f.semuta)); assert.equal(game.discard.filter(card => card.id === f.semuta).length, 0);
|
||||
const candidates = viewGame(game, f.target).semutaReaction!.candidates; assert.equal(candidates.length, 2);
|
||||
assert.deepEqual(candidates.map(card => card.id).sort(), [...f.producer.loserCards].sort());
|
||||
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'semutaSelect');
|
||||
assert.equal(guide.action.event, event); assert.equal(guide.action.turnActionPromiseEvent, promise(game).event);
|
||||
assert.equal(guide.action.turnActionPromisePhase, 6); assert.ok(candidates.some(card => card.id === guide.action.card));
|
||||
rejected(game, f.target, { type: 'semutaPass', event }); rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
|
||||
rejected(game, f.asker, guide.action); rejected(game, f.target, { ...guide.action, event: 'stale-multi-source' });
|
||||
rejected(game, f.target, { type: 'semutaSelect', event, card: f.truth });
|
||||
const chosen = route === 'guide' ? String(guide.action.card) : candidates[route === 'first' ? 0 : 1].id;
|
||||
game = route === 'guide' ? act(game, f.target, JSON.parse(JSON.stringify(guide.action)) as Action, actions)
|
||||
: select(game, f.target, chosen, actions);
|
||||
assert.equal(count(game, f), 1); assert.equal(stage(game)?.played, true); assert.equal(promise(game).fulfilled, true);
|
||||
assert.ok(player(game, f.target).hand.some(card => card.id === chosen)); assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
|
||||
const receipt = structuredClone(stage(game)); game = settle(game, actions); assert.deepEqual(stage(game), receipt);
|
||||
assert.equal(count(game, f), 1); assert.equal(f.nativeAcquisition, false); assert.ok(actions.some(step => step.action.type === 'chooseBattle'));
|
||||
assert.ok(actions.filter(step => step.action.type === 'battlePlan').length === 2); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a controlled native multi-card offer still rejects whole No commitment before exposing either fresh choice', () => {
|
||||
const f = multiFixture(); let game = bind(f, 'no', raw(f, group('or', use(), payment(f.recipient, 2)))).game;
|
||||
game = multiBatch(f, game); const event = game.pendingTreacheryDiscard!.batch.event;
|
||||
const view = viewGame(game, f.target); assert.equal(view.semutaReaction?.canCommit, false); assert.deepEqual(view.semutaReaction?.candidates, []);
|
||||
rejected(game, f.target, { type: 'semutaCommit', event }); assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.equal(count(game, f), 0); assert.equal(stage(game)?.played, false);
|
||||
game = settle(passOffer(game)); assert.ok(player(game, f.target).hand.some(card => card.id === f.semuta)); physical(game, f);
|
||||
});
|
||||
|
||||
void test('an earlier completed physical Semuta use cannot satisfy a second original Truth future-play interval', () => {
|
||||
const f = fixture(); let game = bind(f, 'yes', raw(f, use())).game; game = commit(game, f.target);
|
||||
assert.equal(promise(game).fulfilled, true); assert.equal(count(game, f), 1);
|
||||
const prior = structuredClone(viewGame(game, f.target).turnActionPromises[0]), declared = declare(f, game, 1);
|
||||
const asked = ask(declared.game, declared.asker, raw(f, use())); assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
|
||||
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' }); game = settle(answer(asked, f.target, 'no'));
|
||||
assert.equal(stage(game, 1)?.played, false); assert.equal(count(game, f), 1);
|
||||
assert.deepEqual(viewGame(game, f.target).turnActionPromises[0], prior); physical(game, f);
|
||||
});
|
||||
|
||||
void test('real paid Box recovery of the original Semuta is not another play, while a later original Truth cost permits genuine replay', () => {
|
||||
const f = fixture(); let game = bind(f, 'yes', raw(f, use())).game; game = commit(game, f.target);
|
||||
assert.equal(player(game, f.target).spice, 3); assert.equal(count(game, f), 1);
|
||||
const actions: TruthSemutaPlayStep[] = [], recovered = recover(f, game, actions); game = recovered.game;
|
||||
assert.equal(game.turn, f.game.turn + 1); assert.equal(player(game, f.target).spice, 0); assert.equal(count(game, f), 0);
|
||||
assert.ok(recovered.purchase.steps.some(step => step.actor === f.target && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.ok(recovered.paid.pendingNullentropy); assert.ok(actions.some(step => step.action.type === 'decision' && step.action.card === f.semuta));
|
||||
assert.ok(player(game, f.target).hand.some(card => card.id === f.semuta));
|
||||
const question = raw(f, use(), game.turn); game = bind(f, 'yes', question, game, 1).game;
|
||||
assert.equal(stage(game, 1)?.played, false); assert.equal(count(game, f), 0);
|
||||
game = commit(game, f.target); assert.equal(stage(game, 1)?.played, true); assert.equal(promise(game).fulfilled, true);
|
||||
assert.equal(count(game, f), 1); assert.equal(player(game, f.target).spice, 0);
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1); physical(game, f);
|
||||
});
|
||||
|
||||
void test('all four minimal profiles execute native Semuta offer and committed selection guides through physical completion', () => {
|
||||
const f = fixture(), declared = declare(f), asked = ask(declared.game, declared.asker, f.question);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = structuredClone(asked); const actions: TruthSemutaPlayStep[] = [];
|
||||
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); assert.equal(response.answer, 'yes');
|
||||
game = act(game, f.target, response, actions);
|
||||
for (let step = 0; step < 80 && (!promise(game).fulfilled || !clean(game)); step++) {
|
||||
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
if (view.turnActionGuide) {
|
||||
const guide = view.turnActionGuide, action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.action.turnActionPromiseEvent);
|
||||
assert.ok(action); assert.deepEqual(action, guide.action); game = act(game, f.target, action, actions);
|
||||
} else {
|
||||
const next = nativeStep(game); assert.ok(next); game = act(game, next.actor, next.action, actions);
|
||||
}
|
||||
}
|
||||
assert.equal(promise(game).fulfilled, true); assert.ok(clean(game)); assert.equal(count(game, f), 1);
|
||||
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.recipient).bribes, 3); physical(game, f);
|
||||
}
|
||||
const supplement = multiFixture();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = bind(supplement).game; game = multiBatch(supplement, game); game = commit(game, supplement.target);
|
||||
assert.equal(count(game, supplement), 0);
|
||||
const view = viewGame(game, supplement.target); view.players.find(candidate => candidate.id === supplement.target)!.bot = difficulty;
|
||||
const guide = view.turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'semutaSelect');
|
||||
const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.action.turnActionPromiseEvent);
|
||||
assert.ok(action); assert.deepEqual(action, guide.action); game = settle(act(game, supplement.target, action));
|
||||
assert.equal(promise(game).fulfilled, true); assert.equal(count(game, supplement), 1); physical(game, supplement);
|
||||
}
|
||||
});
|
||||
@@ -32,7 +32,7 @@ const forceRevival = (counter: 'total' | 'normal' | 'elite' = 'total', minimum =
|
||||
({ kind: 'action', action: { kind: 'forces', counter, minimum } });
|
||||
const leaderRevival = (leader: string): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
|
||||
const finalAlliance = (partner = 'r'): Expression => ({ kind: 'action', action: { kind: 'alliance', partner } });
|
||||
const printedCard = (effect: 'hajr' | 'ghola' | 'nullentropyBox' | 'distrans' | 'juiceOfSapho' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
|
||||
const printedCard = (effect: 'hajr' | 'ghola' | 'nullentropyBox' | 'distrans' | 'juiceOfSapho' | 'semutaDrug' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
|
||||
const shipmentPriceSource = (recipient = 'p', cunning?: string) => JSON.stringify(['karamaShipment', 2, recipient,
|
||||
...(cunning ? ['cunning', cunning] : ['original'])]);
|
||||
const windowClaim = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'controlled-native-blow'
|
||||
@@ -169,6 +169,18 @@ function saphoPlay(phase = 5, option: NonNullable<GameView['saphoOptions']>[numb
|
||||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard('juiceOfSapho'), payment()] };
|
||||
return game;
|
||||
}
|
||||
function semutaPlay(stage: 'offer' | 'select' = 'offer', next?: Action): GameView {
|
||||
const card = richeseCards().find(card => card.effect === 'semutaDrug'), candidate = baseDeck().find(card => card.name === 'Shield');
|
||||
assert.ok(card); assert.ok(candidate);
|
||||
const reactionEvent = 'controlled-fresh-discard';
|
||||
const game = projected(3, next ?? (stage === 'offer' ? { type: 'semutaCommit', event: reactionEvent }
|
||||
: { type: 'semutaSelect', event: reactionEvent, card: candidate.id }));
|
||||
game.players.find(player => player.id === 'p')!.hand = [card]; game.active = 'a';
|
||||
game.semutaReaction = { event: reactionEvent, stage, passed: stage === 'select', canCommit: stage === 'offer', blocked: null,
|
||||
candidates: stage === 'select' ? [candidate] : [] };
|
||||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard('semutaDrug'), 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';
|
||||
@@ -1283,15 +1295,16 @@ void test('turn-action printed-role editor uses public names without holdings or
|
||||
Object.defineProperty(game, 'nullentropy', { get() { throw Error('No private Box authoring inspection'); }, configurable: true });
|
||||
Object.defineProperty(game, 'distrans', { get() { throw Error('No private Distrans authoring inspection'); }, configurable: true });
|
||||
Object.defineProperty(game, 'saphoOptions', { get() { throw Error('No private Sapho authoring inspection'); }, configurable: true });
|
||||
Object.defineProperty(game, 'semutaReaction', { get() { throw Error('No private Semuta authoring inspection'); }, configurable: true });
|
||||
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private card-play stage'); } });
|
||||
const e = editor(physical(), false, game); select(e.tree(), '1', 'action', 'cardPlay');
|
||||
assert.deepEqual(e.value, printedCard()); assert.equal(e.error(), null);
|
||||
const choices = elements(find(e.tree(), 'id', 'truth-turn-action-1-effect')).filter(element => element.type === 'option');
|
||||
assert.deepEqual(choices.map(choice => choice.props.value), ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho']);
|
||||
assert.deepEqual(choices.map(choice => choice.props.value), ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho', 'semutaDrug']);
|
||||
select(e.tree(), '1', 'effect', 'ghola'); assert.deepEqual(e.value, printedCard('ghola')); assert.equal(e.error(), null);
|
||||
select(e.tree(), '1', 'effect', 'karama'); assert.deepEqual(e.value, printedCard('ghola'));
|
||||
select(e.tree(), '1', 'effect', 'nullentropyBox'); assert.deepEqual(e.value, printedCard('nullentropyBox')); assert.equal(e.error(), null);
|
||||
for (const effect of ['distrans', 'juiceOfSapho'] as const) {
|
||||
for (const effect of ['distrans', 'juiceOfSapho', 'semutaDrug'] as const) {
|
||||
select(e.tree(), '1', 'effect', effect); assert.deepEqual(e.value, printedCard(effect)); assert.equal(e.error(), null);
|
||||
for (const extra of [{ target: 'r' }, { give: 'private-card' }, { scope: 'movement' }, { event: 'private-source' }, { mode: 'first' }])
|
||||
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect, ...extra } }, game, 'p'));
|
||||
@@ -1309,17 +1322,19 @@ void test('turn-action printed-role editor uses public names without holdings or
|
||||
assert.match(html, /The selected recovered card stays private and is not promised/);
|
||||
assert.match(html, /Play Distrans in its printed role after this answer this turn/);
|
||||
assert.match(html, /Play Juice of Sapho in its printed role after this answer this turn/);
|
||||
assert.match(html, /Complete a printed Semuta Drug transfer and disposal after this answer this turn/);
|
||||
assert.match(html, /Committing to inspect multiple candidates, an earlier play or recovery alone does not count/);
|
||||
assert.match(html, /not a particular recipient, transferred card or order outcome/);
|
||||
assert.match(html, /Hajr counts when played even if its extra movement is unused/);
|
||||
assert.match(html, /an actual later replay after Box recovery does/); assert.doesNotMatch(html, /private-card-id|controlled-ghola|controlled-hajr/);
|
||||
const full = editor({ kind: 'or', terms: [printedCard(), printedCard('nullentropyBox')] }, false, game);
|
||||
for (let count = 3; count <= 16; count++) {
|
||||
click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'cardPlay');
|
||||
select(full.tree(), `1.${count}`, 'effect', count % 2 ? 'distrans' : 'juiceOfSapho');
|
||||
select(full.tree(), `1.${count}`, 'effect', count % 3 === 0 ? 'semutaDrug' : count % 2 ? 'distrans' : 'juiceOfSapho');
|
||||
}
|
||||
assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/);
|
||||
assert.equal(control(full.tree(), 'add-action-1').props.disabled, true);
|
||||
const deep = editor({ kind: 'and', terms: [fact(), printedCard('nullentropyBox')] }, false, game);
|
||||
const deep = editor({ kind: 'and', terms: [fact(), printedCard('semutaDrug')] }, false, game);
|
||||
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/);
|
||||
const frozen = editor(printedCard(), true, game); select(frozen.tree(), '1', 'effect', 'ghola');
|
||||
@@ -1565,9 +1580,102 @@ void test('printed Sapho callbacks and both role guides reject stale quotes or m
|
||||
}
|
||||
});
|
||||
|
||||
void test('Semuta offer guides forward exact native commit or pass without reading candidates or holdings', () => {
|
||||
for (const type of ['semutaCommit', 'semutaPass']) {
|
||||
const game = semutaPlay('offer', { type, event: 'controlled-fresh-discard' }), sent: Action[] = [];
|
||||
if (type === 'semutaPass') {
|
||||
game.semutaReaction!.canCommit = false; game.semutaReaction!.blocked = 'The physical card is unavailable.';
|
||||
game.turnActionPromises[0].answer = false;
|
||||
}
|
||||
Object.defineProperty(game.semutaReaction!, 'candidates', { get() { throw Error('Offer guides cannot inspect private candidates'); } });
|
||||
for (const player of game.players)
|
||||
Object.defineProperty(player, 'hand', { get() { throw Error('The native Semuta quote already owns custody'); }, configurable: true });
|
||||
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private Semuta receipt inspection'); } });
|
||||
game.decision = { kind: 'auctionPayment', player: 'a' };
|
||||
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.deepEqual(sent[0], { type, event: 'controlled-fresh-discard', turnActionPromiseEvent: event, turnActionPromisePhase: 3 });
|
||||
assert.match(markup(game), type === 'semutaCommit' ? /Commit Semuta Drug to this fresh discard/ : /Continue without committing Semuta Drug/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Semuta selection guides use only the actual current private candidate and cannot pass or commit again', () => {
|
||||
const game = semutaPlay('select'), sent: Action[] = [];
|
||||
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.match(markup(game), /Complete Semuta Drug by claiming Shield/);
|
||||
assert.match(markup(game), /Committing to inspect multiple candidates, an earlier play or recovery alone does not count/);
|
||||
for (const type of ['semutaPass', 'semutaCommit']) {
|
||||
const choosing = semutaPlay('select', { type, event: 'controlled-fresh-discard' });
|
||||
const disabled = guide({ game: choosing, busy: false, act() { assert.fail('A committed private choice cannot be canceled'); } });
|
||||
assert.equal(disabled.props.disabled, true); press(disabled);
|
||||
}
|
||||
for (const change of [
|
||||
(state: GameView) => { state.semutaReaction!.candidates = []; },
|
||||
(state: GameView) => { state.turnActionGuide!.action.card = 'foreign-or-unquoted-card'; },
|
||||
]) {
|
||||
const choosing = semutaPlay('select'); change(choosing);
|
||||
const disabled = guide({ game: choosing, busy: false, act() { assert.fail('Only the actual selector receives a legal current candidate'); } });
|
||||
assert.equal(disabled.props.disabled, true); press(disabled);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Semuta whole guides reject stale native stages, events, ownership, choices and parent modals', () => {
|
||||
const changes: ((game: GameView) => void)[] = [
|
||||
game => { game.semutaReaction = null; },
|
||||
game => { game.semutaReaction = { ...game.semutaReaction! }; },
|
||||
game => { game.semutaReaction!.event = 'next-fresh-discard'; },
|
||||
game => { game.semutaReaction!.stage = 'select'; },
|
||||
game => { game.semutaReaction!.passed = true; },
|
||||
game => { game.semutaReaction!.canCommit = false; },
|
||||
game => { game.semutaReaction!.blocked = 'The physical card became reserved.'; },
|
||||
game => { game.semutaReaction!.passBlocked = 'The original whole answer now requires this exchange.'; },
|
||||
game => { game.turnActionPromises[0].mixed = payment(); },
|
||||
game => { game.me = 'a'; },
|
||||
game => { game.phase = 4; },
|
||||
game => { game.turnActionGuide!.action.event = 'old-offer-event'; },
|
||||
game => { game.decision = { kind: 'auctionPayment', player: 'a' }; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = semutaPlay(), sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||||
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
for (const change of [
|
||||
(game: GameView) => { game.semutaReaction!.candidates = []; },
|
||||
(game: GameView) => { game.semutaReaction!.candidates[0].kind = 'worthless'; },
|
||||
]) {
|
||||
const choosing = semutaPlay('select'), sent: Action[] = [], saved = guide({ game: choosing, busy: false, act(action) { sent.push(action); } });
|
||||
assert.equal(saved.props.disabled, false); change(choosing); press(saved); assert.deepEqual(sent, []);
|
||||
}
|
||||
const props = { game: semutaPlay(), busy: false, act() { assert.fail('Busy Semuta callbacks cannot send'); } }, busy = guide(props);
|
||||
assert.equal(busy.props.disabled, false); props.busy = true; press(busy);
|
||||
for (const field of ['paused', 'closed'] as const) {
|
||||
const game = semutaPlay(); game.roomControl = { paused: false, joinLocked: false, revision: 1, updatedAt: null, [field]: true };
|
||||
assert.equal(guide({ game, busy: false, act() { assert.fail('Room controls retain priority'); } }).props.disabled, true);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Semuta guides reject extra fields, offer-stage selection and fake card-role actions without fallback', () => {
|
||||
for (const type of ['semutaCommit', 'semutaPass', 'semutaSelect']) for (const extra of [{ amount: 1 }, { target: 'a' }, { mode: 'claim' }, { give: 'private-card' }, { card: 'unopened-candidate' }]) {
|
||||
const game = semutaPlay(type === 'semutaSelect' ? 'select' : 'offer', { type, event: 'controlled-fresh-discard',
|
||||
...(type === 'semutaSelect' ? { card: baseDeck().find(card => card.name === 'Shield')!.id } : {}), ...extra });
|
||||
const button = guide({ game, busy: false, act() { assert.fail('Only the exact native Semuta packet is permitted'); } });
|
||||
assert.equal(button.props.disabled, true); press(button);
|
||||
}
|
||||
const offer = semutaPlay('offer', { type: 'semutaSelect', event: 'controlled-fresh-discard', card: 'unopened-candidate' });
|
||||
Object.defineProperty(offer.semutaReaction!, 'candidates', { get() { throw Error('Offer-stage selection cannot inspect candidates'); } });
|
||||
assert.equal(guide({ game: offer, busy: false, act() { assert.fail('A private choice has not opened'); } }).props.disabled, true);
|
||||
const physical = richeseCards().find(card => card.effect === 'semutaDrug'); assert.ok(physical);
|
||||
const forged = projected(2, { type: 'card', card: physical.id }); forged.players.find(player => player.id === 'p')!.hand = [physical];
|
||||
forged.turnActionPromises[0].mixed = printedCard('semutaDrug');
|
||||
assert.equal(guide({ game: forged, busy: false, act() { assert.fail('Semuta is played through a real fresh-discard reaction'); } }).props.disabled, true);
|
||||
const unrelated = projected(); unrelated.semutaReaction = semutaPlay().semutaReaction;
|
||||
assert.equal(guide({ game: unrelated, busy: false, act() { assert.fail('A pending fresh discard owns its native controls'); } }).props.disabled, true);
|
||||
});
|
||||
|
||||
void test('printed-card history retains only raw aggregate conditions and their distinct native deadlines', () => {
|
||||
const game = projected(8); game.turnActionGuide = null;
|
||||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard(), { kind: 'and', terms: [fact(), printedCard('ghola'), printedCard('nullentropyBox'), printedCard('distrans'), printedCard('juiceOfSapho')] }] };
|
||||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard(), { kind: 'and', terms: [fact(), printedCard('ghola'), printedCard('nullentropyBox'), printedCard('distrans'), printedCard('juiceOfSapho'), printedCard('semutaDrug')] }] };
|
||||
game.turnActionPromises[0].answer = false;
|
||||
for (const key of ['cardPlays', 'compiled', 'combat', 'preparation'])
|
||||
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||||
@@ -1579,6 +1687,8 @@ void test('printed-card history retains only raw aggregate conditions and their
|
||||
assert.match(html, /you activate Nullentropy Box/); assert.match(html, /Box conditions close at the actual turn end/);
|
||||
assert.match(html, /Distrans/); assert.match(html, /Juice of Sapho/);
|
||||
assert.match(html, /The deadline is the actual turn end, including for a whole No answer/);
|
||||
assert.match(html, /Semuta Drug counts only when its native transfer and disposal actually complete after the answer/);
|
||||
assert.match(html, /A committed private choice cannot be canceled/);
|
||||
assert.match(html, /remains open until the actual turn ends/); assert.doesNotMatch(html, /cardPlays|compiled|controlled-hajr|controlled-ghola/);
|
||||
});
|
||||
|
||||
|
||||
@@ -76,6 +76,24 @@ void test('Distrans and Sapho questions name only actual printed use this turn w
|
||||
}
|
||||
});
|
||||
|
||||
void test('Semuta future-use questions require completed physical exchange without naming a fresh target or inspection', () => {
|
||||
const game = fixture();
|
||||
assert.ok(!game.players.find(player => player.id === 'e')!.hand.some(card => card.effect === 'semutaDrug'));
|
||||
const action = { kind: 'cardPlay' as const, effect: 'semutaDrug' as const };
|
||||
const question: TruthQuestion = { kind: 'turnActions', target: 'e', turn: 2,
|
||||
mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } },
|
||||
{ kind: 'action', action }] } };
|
||||
assert.doesNotThrow(() => validateTruthQuestionReceipt(game, 'a', question));
|
||||
const text = truthQuestionText(question, id => id, id => id);
|
||||
assert.match(text, /complete a printed Semuta Drug exchange and discard Semuta Drug after this answer this turn/);
|
||||
assert.doesNotMatch(text, /inspect|commit|private-fresh-card|Ghola|Sapho/);
|
||||
for (const extra of [{ selected: 'private-fresh-card' }, { committed: true }, { stage: 'select' }, { event: 'discard:2:4:1' }]) {
|
||||
const invalid = { ...question, mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } },
|
||||
{ kind: 'action', action: { ...action, ...extra } }] } } as TruthQuestion;
|
||||
assert.throws(() => validateTruthQuestionReceipt(game, 'a', invalid));
|
||||
}
|
||||
});
|
||||
|
||||
function fixture() {
|
||||
const g = createGame('TRUTH22', newPlayer('a', 'Atreides', 'atreides'));
|
||||
g.players.push(
|
||||
|
||||
@@ -937,7 +937,7 @@ void test('printed card-play leaves contain only the printed effect and every ra
|
||||
assert.throws(() => turnActionValue(promise(group('or', fact(), card(effect)))), TurnActionError,
|
||||
'Compiler folding never removes the original raw interval requirement');
|
||||
}
|
||||
for (const effect of ['', 'Hajr', 'karama', 'thumper', 'semutaDrug', null])
|
||||
for (const effect of ['', 'Hajr', 'karama', 'thumper', 'Semuta Drug', null])
|
||||
assert.throws(() => parseTurnActionLeaf({ kind: 'cardPlay', effect }), TurnActionError);
|
||||
validateTurnActionPromises([cards(group('and', card(), card('ghola')))], seats);
|
||||
});
|
||||
@@ -1693,8 +1693,8 @@ void test('optional Box stage preserves legacy bytes and strict raw folded metad
|
||||
|
||||
void test('each optional printed stage subset preserves legacy and Box bytes while rejecting unknown choice metadata', () => {
|
||||
const base = cards(card('ghola'), false);
|
||||
const optional = ['nullentropyBox', 'distrans', 'juiceOfSapho'] as const;
|
||||
for (let mask = 0; mask < 8; mask++) {
|
||||
const optional = ['nullentropyBox', 'distrans', 'juiceOfSapho', 'semutaDrug'] as const;
|
||||
for (let mask = 0; mask < (1 << optional.length); mask++) {
|
||||
const cardPlays = { ...base.cardPlays! };
|
||||
optional.forEach((effect, index) => { if (mask & (1 << index)) cardPlays[effect] = null; });
|
||||
const row = { ...base, cardPlays }, before = JSON.stringify(row);
|
||||
@@ -1712,7 +1712,7 @@ void test('each optional printed stage subset preserves legacy and Box bytes whi
|
||||
assert.throws(() => turnActionValue(invalid), TurnActionError);
|
||||
}
|
||||
}
|
||||
const unsupported = { ...base, cardPlays: { ...base.cardPlays!, semutaDrug: null } } as TurnActionPromise;
|
||||
const unsupported = { ...base, cardPlays: { ...base.cardPlays!, unknownPrintedRole: null } } as TurnActionPromise;
|
||||
assert.throws(() => turnActionValue(unsupported), TurnActionError);
|
||||
});
|
||||
|
||||
@@ -1761,3 +1761,49 @@ void test('Distrans and Sapho No retain turn deadlines through Movement and paym
|
||||
validateTurnActionPromises([expired], seats);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Semuta whole branches track only physical resolution in the future answer interval without storing inspected fresh cards', () => {
|
||||
const joint = cards(group('and', card('semutaDrug'), pay()));
|
||||
const alternative = cards(group('or', card('semutaDrug'), card('nullentropyBox')), true, 'turn-action:2');
|
||||
assert.deepEqual(branches(joint), [[{ kind: 'cardPlay', effect: 'semutaDrug', answer: true }, signedPay()]]);
|
||||
for (const extra of [{ committed: true }, { stage: 'select' }, { selected: 'private-fresh-card' }, { event: 'discard:1:2:1' }]) {
|
||||
assert.throws(() => parseTurnActionLeaf({ kind: 'cardPlay', effect: 'semutaDrug', ...extra }), TurnActionError);
|
||||
const invalid = { ...joint, cardPlays: { ...joint.cardPlays!, semutaDrug: { played: false, closed: false, ...extra } } };
|
||||
assert.throws(() => turnActionValue(invalid), TurnActionError);
|
||||
}
|
||||
assert.equal(recordTurnActionCardPlay([joint], 'other', 1, 'semutaDrug')[0], joint);
|
||||
assert.equal(recordTurnActionCardPlay([joint], 'respondent', 2, 'semutaDrug')[0], joint);
|
||||
assert.equal(recordTurnActionCardPlay([joint], 'respondent', 1, 'nullentropyBox')[0], joint);
|
||||
assert.equal(joint.cardPlays!.semutaDrug!.played, false);
|
||||
const used = recordTurnActionCardPlay([joint, alternative], 'respondent', 1, 'semutaDrug');
|
||||
assert.deepEqual(branches(used[0]), [[signedPay()]]);
|
||||
assert.equal(used[1].fulfilled, true);
|
||||
const fresh = cards(card('semutaDrug'), true, 'turn-action:3');
|
||||
assert.equal(fresh.cardPlays!.semutaDrug!.played, false);
|
||||
const replay = recordTurnActionCardPlay([used[0], fresh], 'respondent', 1, 'semutaDrug');
|
||||
assert.equal(replay[0], used[0]);
|
||||
assert.equal(replay[1].fulfilled, true);
|
||||
assert.equal(recordTurnActionPayment([used[0]], 'respondent', 1, payment())[0].fulfilled, true);
|
||||
validateTurnActionPromises(used, seats);
|
||||
});
|
||||
|
||||
void test('Semuta No remains binding through other deadlines and physical use or late correlated payment fails atomically', () => {
|
||||
const yes = cards(group('and', card('semutaDrug'), pay()));
|
||||
const no = cards(card('semutaDrug'), false, 'turn-action:2');
|
||||
const before = structuredClone([yes, no]);
|
||||
assert.throws(() => recordTurnActionCardPlay([yes, no], 'respondent', 1, 'semutaDrug'), TurnActionError);
|
||||
assert.deepEqual([yes, no], before);
|
||||
const waiting = closeTurnActionPayments(closeTurnActionTransport([no], 'respondent', 1), 'respondent', 1);
|
||||
assert.deepEqual(waiting[0].cardPlays!.semutaDrug, { played: false, closed: false });
|
||||
assert.equal(waiting[0].fulfilled, false);
|
||||
const expired = finishTurnActionTurn(waiting, 'respondent', 1)[0];
|
||||
assert.equal(expired.fulfilled, true);
|
||||
assert.deepEqual(expired.cardPlays!.semutaDrug, { played: false, closed: true });
|
||||
assert.equal(recordTurnActionCardPlay([expired], 'respondent', 1, 'semutaDrug')[0], expired);
|
||||
assert.equal(finishTurnActionTurn([yes], 'respondent', 1)[0].released, true);
|
||||
const wholeNo = cards(group('and', card('semutaDrug'), pay()), false, 'turn-action:3');
|
||||
const used = recordTurnActionCardPlay([wholeNo], 'respondent', 1, 'semutaDrug');
|
||||
assert.throws(() => recordTurnActionPayment(used, 'respondent', 1, payment()), TurnActionError);
|
||||
assert.deepEqual(used[0].payments, []);
|
||||
validateTurnActionPromises([expired], seats);
|
||||
});
|
||||
Reference in new issue
Block a user