Bind completed Semuta exchanges to Truthtrance promises

This commit is contained in:
Quintine committed 2026-10-27 08:27:53 +11:00
1 parent 4c4bebe85e
commit b02839ce2a
24 files changed
+1014 -40

No files matched your search

+3
View File
@@ -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;
+214
View File
@@ -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 };
}
+19 -1
View File
@@ -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');
+4
View File
@@ -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/);
});
+17
View File
@@ -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);
});
+235
View File
@@ -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);
}
});
+116 -6
View File
@@ -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/);
});
+18
View File
@@ -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(
+50 -4
View File
@@ -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);
});