From c8889e94e2d8106bca6ed61004cd3e99869ba0b1 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:18:00 +1100 Subject: [PATCH] Enable Basic Semuta and Truthtrance bribe questions --- docs/CURRENT_STATUS.md | 8 + docs/IMPLEMENTATION_STATUS.md | 9 ++ docs/RULE_DECISIONS.md | 10 ++ docs/TRUTHTRANCE_BRIBE_PROMISES.md | 20 +++ game/bribe-promises.ts | 8 +- game/engine.ts | 57 +++++++ game/reference.ts | 1 + tests/bribe-promises.test.ts | 8 + tests/truth-bribe-semuta-runtime.test.ts | 186 +++++++++++++++++++++++ 9 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 tests/truth-bribe-semuta-runtime.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index a3418b48..54234ec2 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,6 +20,14 @@ open mode gates. Keep identified Basic-function gaps ahead of Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**Basic Richese Semuta and bribe questions — 27 October. Prototyped / Partial.** +Structured ordinary, grouped and mixed Truthtrance payment questions are +available in the bare Basic Richese Semuta profile. Their exact answer and +payment ledger survive the physical fresh-card claim/pass and continuation. +Native runtime cases acquire Semuta through its real auction, then exercise +real questions and payments; Kull plus Semuta, optional modules and final +acceptance remain separate. [Scope](TRUTHTRANCE_BRIBE_PROMISES.md#basic-richese-and-semuta--27-october-2026). + **Emperor special revival and Sardaukar — 27 October. Prototyped / Partial.** The Advanced Emperor's special Karama can return up to three actual Sardaukar without consuming the ordinary one-per-turn elite revival allowance. The diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index cf0f9c4b..81d3f71f 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,14 @@ # Dune implementation status +## 27 October 2026 — Basic Semuta and Truthtrance bribes + +The bare Basic Richese Semuta profile admits structured scalar, grouped and +mixed bribe questions. A signed continuation retains the actual answer and +payment ledger during the fresh Truthtrance discard's Semuta claim or pass. +The existing native payment controls and minimal bot guides remain in use. +Kull plus Semuta and broader modules are not included. +[Scope](TRUTHTRANCE_BRIBE_PROMISES.md#basic-richese-and-semuta--27-october-2026). + ## 27 October 2026 — Emperor special revival and Sardaukar The Advanced Emperor's special Karama may return up to three physical diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index c7495535..c09e3dcc 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,15 @@ # Rule decision index +## Basic Semuta and Truthtrance bribes — 27 October 2026 + +In the supported bare Basic Richese profile, provisionally resolve the fresh +Truthtrance discard through Semuta before restoring the original controls. +Scalar, grouped and mixed bribe answers keep their exact binding and accepted +payment order; the exchange does not itself pay or settle a bribe. The nested +online timing is an implementation interpretation, not a separate publisher +ruling. Kull and optional modules remain outside this slice. +[Scope and source](TRUTHTRANCE_BRIBE_PROMISES.md#basic-richese-and-semuta--27-october-2026). + ## Emperor special revival and Sardaukar limit — 27 October 2026 The authorized supplied rulebook, physical p. 31 (printed p. 29), exempts diff --git a/docs/TRUTHTRANCE_BRIBE_PROMISES.md b/docs/TRUTHTRANCE_BRIBE_PROMISES.md index a5b08130..804cd3aa 100644 --- a/docs/TRUTHTRANCE_BRIBE_PROMISES.md +++ b/docs/TRUTHTRANCE_BRIBE_PROMISES.md @@ -1,5 +1,25 @@ # Truthtrance ordinary bribe-payment promises +## Basic Richese and Semuta — 27 October 2026 + +**Prototyped / Partial.** Bare Basic Richese tables with the default Semuta +preview admit scalar, grouped and mixed ordinary bribe questions. After a +definite answer, the physical Truthtrance's fresh discard can be claimed or +passed through the existing Semuta opportunity before its original controls +and any remaining question resume. The answer, exact binding and accepted +payment order remain attached to that continuation; later native payments, +not the exchange, fulfill the commitment. Existing player controls and minimal +legal bots use the same projected question and payment guides. + +The [Truthtrance question authority](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) +and [Semuta source](SEMUTA_SOURCE_UPDATE_20260907.md) support their respective +effects. This online nested order is a provisional composition, not a separate +publisher ruling. Kull plus Semuta, optional modules and broader combinations +remain outside this slice. The native +[runtime tests](../tests/truth-bribe-semuta-runtime.test.ts) acquire Semuta +through its real cache auction and exercise the original question and payment; +they do not certify complete modes, browser or deployed behavior. + **27 October Basic Kull follow-up:** the printed Kull preview with native CHOAM, classic factions and the CHOAM/Ix decks now admits ordinary, grouped and mixed structured bribe questions. Pending Kull responses suppress bribe controls diff --git a/game/bribe-promises.ts b/game/bribe-promises.ts index 7a7de8ec..4ed9a738 100644 --- a/game/bribe-promises.ts +++ b/game/bribe-promises.ts @@ -47,8 +47,14 @@ export function bribePromiseModeSupported(game: NativePromiseScope & { nexusHarkonnenBetrayalPreview?: unknown; moritaniAssassinatePreview?: unknown; mobileStronghold?: unknown; }): boolean { - if (!nativePromiseScopeSupported(game) || game.semutaPreview || game.nexusKullPreview || + if (!nativePromiseScopeSupported(game) || game.nexusKullPreview || game.richeseBetrayalPreview) return false; + if (game.semutaPreview) return game.semutaPreview === true && !game.advanced && + !game.kullPreview && game.expansions.includes('choam') && + game.players.some(player => player.faction === 'richese') && + !game.guildBetrayalPreview && !game.nexusIxianReplacementPreview && + !game.nexusIxianBetrayalPreview && !game.nexusHarkonnenBetrayalPreview && + !game.moritaniAssassinatePreview; if (!game.kullPreview) return true; return game.kullPreview === true && game.advanced === false && (game.expansions.length === 2 || game.expansions.length === 3 && game.expansions.includes('ecaz')) && diff --git a/game/engine.ts b/game/engine.ts index a758b4a4..ccaed68d 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1853,6 +1853,11 @@ export type Game = { | { kind: 'battle'; index: number; value: BattlePromise } | { kind: 'traitor'; index: number; value: TraitorDecisionPromise } | null; + /** New Basic Semuta bribe answers retain their exact native binding. */ + bribeBinding?: + | { kind: 'bribe'; index: number; value: BribePromise } + | { kind: 'bribeExpression'; index: number; value: BribeExpressionPromise } + | { kind: 'mixedBribe'; index: number; value: NonNullable[number] }; parentSignature: string; } | { @@ -7141,6 +7146,9 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) { record: next.record, remaining: next.remaining, promise: next.promise, + ...(next.bribeBinding ? { bribeBinding: next.bribeBinding, + bribePromises: g.bribePromises, bribeExpressionPromises: g.bribeExpressionPromises, + mixedBribeQuestions: g.mixedBribeQuestions, bribePromiseSequence: g.bribePromiseSequence } : {}), controls: nullentropyParentSignature( g, next.resume, @@ -7251,6 +7259,17 @@ function finishTruthtranceAnswer(g: Game) { promise, parentSignature: '', }; + // Older saved Truth discard receipts keep their original signature. These + // bindings are only created for the newly admitted Basic Semuta questions. + if (g.semutaPreview && record.question.kind === 'bribe') continuation.bribeBinding = { + kind: 'bribe', index: g.bribePromises!.length - 1, value: structuredClone(g.bribePromises!.at(-1)!), + }; + else if (g.semutaPreview && record.question.kind === 'bribeExpression') continuation.bribeBinding = { + kind: 'bribeExpression', index: g.bribeExpressionPromises!.length - 1, value: structuredClone(g.bribeExpressionPromises!.at(-1)!), + }; + else if (g.semutaPreview && record.question.kind === 'mixedBribe') continuation.bribeBinding = { + kind: 'mixedBribe', index: g.mixedBribeQuestions!.length - 1, value: structuredClone(g.mixedBribeQuestions!.at(-1)!), + }; continuation.parentSignature = truthtranceDiscardSignature(g, continuation); stageTreacheryDiscard( g, @@ -8220,6 +8239,41 @@ function treacheryDiscardIntegrity(g: Game) { !g.battle.plans[q.target]; const expectedMovement = q.kind === 'movement'; const expectedTraitor = q.kind === 'traitorCall'; + const expectedBribe = g.semutaPreview && + (q.kind === 'bribe' || q.kind === 'bribeExpression' || q.kind === 'mixedBribe'); + if (expectedBribe) { + const binding = c.bribeBinding; + const actual = binding?.kind === 'bribe' ? g.bribePromises : + binding?.kind === 'bribeExpression' ? g.bribeExpressionPromises : g.mixedBribeQuestions; + requireRule(binding && binding.kind === q.kind && Array.isArray(actual) && + Number.isSafeInteger(binding.index) && binding.index >= 0 && + binding.index === actual.length - 1 && + JSON.stringify(binding.value) === JSON.stringify(actual[binding.index]) && + binding.value.player === q.target && binding.value.asker === record.asker && + binding.value.answer === (record.answer === 'yes') && binding.value.turn === g.turn, + 'The completed Truthtrance lost its original bribe answer.'); + if (binding.kind === 'bribe' && q.kind === 'bribe') + requireRule(binding.value.recipient === q.recipient && binding.value.minimum === q.minimum && + binding.value.progress === 0 && !binding.value.fulfilled && !binding.value.released && + g.bribePromiseSequence?.at(-1) === binding.value.event, + 'The completed Truthtrance bribe differs from its original payment.'); + else if (binding.kind === 'bribeExpression' && q.kind === 'bribeExpression') + requireRule(JSON.stringify(binding.value.claim) === JSON.stringify(q.claim) && + binding.value.progress.length === 0 && !binding.value.fulfilled && !binding.value.released && + g.bribePromiseSequence?.at(-1) === binding.value.event, + 'The completed Truthtrance bribe expression differs from its original question.'); + else if (binding.kind === 'mixedBribe' && q.kind === 'mixedBribe') { + const residual = g.bribeExpressionPromises?.at(-1); + requireRule(JSON.stringify(binding.value.mixed) === JSON.stringify(q.mixed) && + (binding.value.binding ? !!residual && residual.event === binding.value.event && + residual.player === q.target && residual.asker === record.asker && + residual.answer === binding.value.answer && residual.turn === g.turn && + residual.progress.length === 0 && !residual.fulfilled && !residual.released && + g.bribePromiseSequence?.at(-1) === binding.value.event : + !g.bribeExpressionPromises?.some(item => item.event === binding.value.event)), + 'The completed mixed bribe answer no longer matches its payment binding.'); + } + } else requireRule(!c.bribeBinding, 'This Truthtrance answer has no bribe binding.'); if (expectedShipment || expectedBattle || expectedMovement || expectedTraitor) { const actual = expectedTraitor ? g.battle?.traitorDecisionPromises : expectedMovement ? g.movementPromises : expectedShipment ? g.shipmentPromises @@ -8835,6 +8889,9 @@ function semutaOfferSupported( const richeseTruthDiscard = supportedRicheseTruthLot(g) && (continuation.kind === 'truthtranceDiscard' || continuation.kind === 'saphoTruthOrderDiscard') && !!continuation.richeseLotEvent && continuation.richeseLotEvent === g.richeseAuction?.event; + if (continuation.kind === 'truthtranceDiscard' && + ['bribe', 'bribeExpression', 'mixedBribe'].includes(continuation.record.question.kind) && + (!continuation.bribeBinding || !bareBasicSemutaProfile(g) || !!g.kullPreview)) return false; const richeseGiftDiscard = continuation.kind === 'karamaRicheseGiftDiscard'; const richeseDiscard = richeseGiftDiscard || continuation.kind === 'karamaRicheseAuctionDiscard'; const auctionAdvantageDiscard = continuation.kind === 'karamaAuctionAdvantageDiscard'; diff --git a/game/reference.ts b/game/reference.ts index 71dcc036..da0ea8d3 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3470,6 +3470,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'The payer spends spice when the actual native bribe is accepted. The recipient keeps that payment deferred until the next Mentat opening; a late Mentat payment waits for next turn. The question creates no immediate recipient cash, discount or refund. Current auction and sealed-battle liabilities stay reserved.', 'Ordinary payment witnesses use current spendable spice, CHOAM’s own automatic opening receipt once, and one remaining owned Charity claim after prepayments. The automatic receipt and ordinary claim have separate markers; guides spend only actual cash. Later installments share the final purse with battle, bid, revival, shipment and movement commitments. Actual counters or opposing claims can change funding. Other future income, pledge withdrawal, gifts and wider card preparation remain bounded. A later question must preserve earlier answers too.', 'Your persistent panel shows named cumulative progress and fulfilled/released status. Its legal ordinary-payment guide works out of turn in a clear native window. Current payment controls also apply the recipient cap and retain funding for other Yes answers. Stale or modified guides reject.', + 'In supported bare Basic Richese games, an answered bribe question retains its payment commitment while the used Truthtrance passes through the Semuta claim-or-pass opportunity. The same continuation applies to combined and mixed bribe questions. Kull and optional module combinations remain outside this preview.', 'Your own voluntary spending, alliance offer to the recipient or finishing Mentat cannot evade an unpaid Yes. A new Yes reopens an already-ready Charity or Mentat respondent; Collection readiness stays. A known automatic Inflation Double closure requires payment before Collection Ready ends that legal window. Completed opposing consequences may release impossible answers in accepted order. No finishes at the original turn boundary.', 'Quiet Mentat remains available after opening collection; Inflation Double still prohibits actual payments. No can be fixed under Double, while Yes requires a currently legal funded payment. This cumulative funded-current-turn protocol is a provisional composition of general Truthtrance authority; optional modules, future preparations, broader deals and complete-mode acceptance remain due.', ], diff --git a/tests/bribe-promises.test.ts b/tests/bribe-promises.test.ts index 6910f0c1..b570dd30 100644 --- a/tests/bribe-promises.test.ts +++ b/tests/bribe-promises.test.ts @@ -43,6 +43,14 @@ void test('ordinary bribe scope preserves bare classic and selected native modes { ...classic, expansions: ['ix', 'choam', 'ecaz'] }, { ...classic, advanced: true, expansions: ['ecaz'], players: [{ faction: 'moritani' }, { faction: 'harkonnen' }] }, ]) assert.equal(bribePromiseModeSupported(game), true); + const basicRichese = { advanced: false, expansions: ['choam'], semutaPreview: true, + players: [{ faction: 'richese' }, { faction: 'atreides' }, { faction: 'emperor' }] }; + assert.equal(bribePromiseModeSupported(basicRichese), true); + for (const game of [ + { ...basicRichese, advanced: true }, { ...basicRichese, kullPreview: true }, + { ...basicRichese, homeworlds: {} }, { ...basicRichese, nexusCards: {} }, + { ...basicRichese, players: classic.players }, + ]) assert.equal(bribePromiseModeSupported(game), false); for (const option of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards', 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery', 'semutaPreview', 'kullPreview', 'nexusKullPreview', 'richeseBetrayalPreview'] as const) diff --git a/tests/truth-bribe-semuta-runtime.test.ts b/tests/truth-bribe-semuta-runtime.test.ts new file mode 100644 index 00000000..63553ae5 --- /dev/null +++ b/tests/truth-bribe-semuta-runtime.test.ts @@ -0,0 +1,186 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame, type Game, type Action } from '../game/engine'; +import type { TruthQuestion, TruthAnswer } from '../game/truthtrance'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { botActions } from '../game/bots'; +import { createTruthSemutaPrebidFixture, declareTruthTransferOrderPlay, + actTruthTransferOrderPlay as act, truthTransferOrderPlayInventory as inventory, + truthTransferOrderPlayPlayer as player } from './fixture-truth-transfer-order-play'; +import { passFreshDiscard } from './fixture-sapho-truth-order'; + +/** Native Basic Richese/Guild/Atreides/Emperor game: original Truths are dealt in + * setup, Guild actually spends Distrans, completes the turn, then purchases + * the original Semuta from Richese's actual cache for one spice. The fixture + * selects unopened original deal positions and native lotteries; no card, + * funds, phase, paid transaction or Semuta holder is installed. */ +function source() { + const f = createTruthSemutaPrebidFixture(); + assert.equal(f.game.advanced,false); assert.equal(f.game.semutaPreview,true); + assert.ok(player(f.game,f.target).hand.some(card => card.id === f.semuta)); + assert.equal(f.game.truthtrance ?? null,null); assert.equal(f.game.pendingTreacheryDiscard ?? null,null); + assert.ok(player(f.game,f.target).spice >= 2); + assert.equal(player(f.game,f.target).ally,null); + assert.deepEqual(inventory(f.game),f.originalCards); + return f; +} +type Family = 'bribe' | 'bribeExpression' | 'mixedBribe'; +function question(f: ReturnType, family: Family): TruthQuestion { + const target=f.target, turn=f.game.turn, recipient=f.asker; + const other = f.game.players.find(row => row.id !== target && row.id !== recipient)!.id; + if (family === 'bribe') return {kind:'bribe',target,turn,recipient,minimum:2}; + if (family === 'bribeExpression') return {kind:'bribeExpression',target,turn,claim:{kind:'and',terms:[ + {kind:'payment',recipient,minimum:1},{kind:'payment',recipient:other,minimum:1}, + ]}}; + return {kind:'mixedBribe',target,turn,mixed:{kind:'and',terms:[ + {kind:'fact',fact:{kind:'spice',compare:'gte',value:0}}, + {kind:'action',action:{recipient,minimum:2}}, + ]}}; +} +function ask(f: ReturnType, family: Family) { + const declared = declareTruthTransferOrderPlay(f).game; + return act(declared,f.asker,{type:'truthAsk',question:question(f,family)}); +} +function answers(game: Game, target: string, family: Family): TruthAnswer[] | null { + const view = viewGame(game,target); + return family === 'bribe' ? view.truthBribeAnswers : + family === 'bribeExpression' ? view.truthBribeExpressionAnswers : view.truthMixedBribeAnswers; +} +function reject(game: Game, actor: string, action: Action) { + const before=structuredClone(game); assert.throws(() => act(game,actor,action)); assert.deepEqual(game,before); +} +function receipt(game: Game, family: Family, target: string) { + const row = family === 'bribe' ? game.bribePromises?.findLast(entry => entry.player === target) : + family === 'bribeExpression' ? game.bribeExpressionPromises?.findLast(entry => entry.player === target) : + game.mixedBribeQuestions?.findLast(entry => entry.player === target); + assert.ok(row); return structuredClone(row); +} + +for (const family of ['bribe','bribeExpression','mixedBribe'] as const) + for (const answer of ['yes','no'] as const) for (const claim of [false,true]) { + void test(`Basic native Semuta ${claim ? 'claim' : 'pass'} preserves ${family} ${answer} and its real payment`, () => { + const f=source(), before=structuredClone(f.game), asked=ask(f,family); + assert.ok(answers(asked,f.target,family)?.includes(answer)); + assert.equal(viewGame(asked,f.asker).truthBribeAnswers,null); + let game=act(asked,f.target,{type:'truthAnswer',answer}); + const pending=game.pendingTreacheryDiscard; assert.ok(pending?.reaction); + assert.equal(pending.continuation.kind,'truthtranceDiscard'); + assert.equal(pending.batch.cause,'truthtrance'); + assert.deepEqual(pending.batch.entries.map(entry => [entry.card.id,entry.discardedBy,entry.publicFace]), + [[f.truths[0],f.asker,true]]); + assert.equal(game.truthHistory?.at(-1)?.answer,answer); + const savedReceipt=receipt(game,family,f.target), savedHistory=structuredClone(game.truthHistory); + assert.ok(viewGame(game,f.target).semutaReaction?.canCommit); + if (claim) game=act(game,f.target,{type:'semutaCommit',event:pending.batch.event}); + else game=passFreshDiscard(game); + assert.equal(game.pendingTreacheryDiscard,null); assert.equal(game.truthtrance,null); + assert.deepEqual(receipt(game,family,f.target),savedReceipt); + assert.deepEqual(game.truthHistory,savedHistory); + assert.equal(player(game,f.target).hand.some(card => card.id === f.truths[0]),claim); + assert.equal(player(game,f.target).hand.some(card => card.id === f.semuta),!claim); + assert.equal(game.discard.some(card => card.id === f.truths[0]),!claim); + assert.equal(game.discard.some(card => card.id === f.semuta),claim); + assert.equal(game.auction?.index,before.auction?.index); assert.equal(game.auction?.bid,before.auction?.bid); + assert.deepEqual(inventory(game),f.originalCards); + const payerBefore=player(game,f.target).spice, recipientBefore=player(game,f.asker).bribes; + const other=game.players.find(row => row.id !== f.target && row.id !== f.asker)!.id; + if (answer === 'yes' && family === 'bribeExpression') { + game=act(game,f.target,{type:'bribe',target:f.asker,amount:1}); + assert.ok(!game.bribeExpressionPromises?.at(-1)?.fulfilled); + game=act(game,f.target,{type:'bribe',target:other,amount:1}); + assert.equal(game.bribeExpressionPromises?.at(-1)?.fulfilled,true); + } else if (answer === 'yes') { + game=act(game,f.target,{type:'bribe',target:f.asker,amount:2}); + assert.equal((family === 'bribe' ? game.bribePromises?.at(-1) : game.bribeExpressionPromises?.at(-1))?.fulfilled,true); + } else if (family === 'bribeExpression') { + game=act(game,f.target,{type:'bribe',target:f.asker,amount:1}); + reject(game,f.target,{type:'bribe',target:other,amount:1}); + } else { + reject(game,f.target,{type:'bribe',target:f.asker,amount:2}); + game=act(game,f.target,{type:'bribe',target:f.asker,amount:1}); + } + assert.equal(player(game,f.target).spice,payerBefore - (answer === 'yes' ? 2 : 1)); + assert.equal(player(game,f.asker).bribes,recipientBefore + (answer === 'yes' && family !== 'bribeExpression' ? 2 : 1)); + assert.deepEqual(inventory(game),f.originalCards); + }); + } + +void test('Basic native Semuta bribe question has a legal bot answer and continuation for every profile', () => { + const f=source(), asked=ask(f,'bribe'); + for (const difficulty of DIFFICULTIES) { + const view=viewGame(asked,f.target); + view.players.find(row => row.id === f.target)!.bot=difficulty; + const action=botActions(view)[0]; assert.ok(action,difficulty); assert.equal(action.type,'truthAnswer'); + assert.ok(['yes','no'].includes(String(action.answer))); + let game=act(structuredClone(asked),f.target,action); + assert.ok(game.pendingTreacheryDiscard?.reaction); game=passFreshDiscard(game); + const result=receipt(game,'bribe',f.target); + assert.equal(result.answer,action.answer === 'yes'); + if (action.answer === 'yes') { + const own=viewGame(game,f.target); own.players.find(row => row.id === f.target)!.bot=difficulty; + const guide=own.bribePromiseGuide; assert.ok(guide); + const next=botActions(own)[0]; assert.deepEqual(next,guide.action); + game=act(game,f.target,next); assert.equal(game.bribePromises?.at(-1)?.fulfilled,true); + } + assert.deepEqual(inventory(game),f.originalCards); + } +}); + +for (const claim of [false,true]) void test( + `Basic Semuta ${claim ? 'claim' : 'pass'} resumes a JSON-round-tripped constant mixed bribe answer`, () => { + const f=source(), declared=declareTruthTransferOrderPlay(f).game; + // The current known fact is true, so the OR is true independently of the + // future payment. No residual bribe-expression obligation is created. + const mixed = {kind:'or' as const,terms:[ + {kind:'fact' as const,fact:{kind:'spice' as const,compare:'gte' as const,value:0}}, + {kind:'action' as const,action:{recipient:f.asker,minimum:2}}, + ]}; + const asked=act(declared,f.asker,{type:'truthAsk',question:{kind:'mixedBribe', + target:f.target,turn:f.game.turn,mixed}}); + assert.deepEqual(viewGame(asked,f.target).truthMixedBribeAnswers,['yes']); + const pending=act(asked,f.target,{type:'truthAnswer',answer:'yes'}); + const stored=receipt(pending,'mixedBribe',f.target); + assert.ok('binding' in stored); assert.equal(stored.binding,false); + assert.equal(pending.bribeExpressionPromises?.length ?? 0,0); + assert.ok(pending.pendingTreacheryDiscard?.reaction); + const history=structuredClone(pending.truthHistory), event=pending.pendingTreacheryDiscard!.batch.event; + let game=JSON.parse(JSON.stringify(pending)) as Game; + if (claim) game=act(game,f.target,{type:'semutaCommit',event}); + else game=passFreshDiscard(game); + assert.equal(game.pendingTreacheryDiscard,null); assert.equal(game.truthtrance,null); + assert.deepEqual(receipt(game,'mixedBribe',f.target),stored); + assert.deepEqual(game.truthHistory,history); + assert.equal(player(game,f.target).hand.some(card => card.id === f.truths[0]),claim); + assert.equal(game.discard.some(card => card.id === f.truths[0]),!claim); + assert.deepEqual(inventory(game),f.originalCards); + // A constant answer imposes no residual limit on a later legal payment. + const before=player(game,f.target).spice; + game=act(game,f.target,{type:'bribe',target:f.asker,amount:1}); + assert.equal(player(game,f.target).spice,before - 1); + assert.deepEqual(inventory(game),f.originalCards); + }, +); + +void test('A tampered mixed bribe binding cannot resume its original Semuta discard continuation', () => { + const f=source(), declared=declareTruthTransferOrderPlay(f).game; + const asked=act(declared,f.asker,{type:'truthAsk',question:{kind:'mixedBribe', + target:f.target,turn:f.game.turn,mixed:{kind:'or',terms:[ + {kind:'fact',fact:{kind:'spice',compare:'gte',value:0}}, + {kind:'action',action:{recipient:f.asker,minimum:2}}, + ]}}}); + const pending=act(asked,f.target,{type:'truthAnswer',answer:'yes'}); + const event=pending.pendingTreacheryDiscard!.batch.event; + const tampered=JSON.parse(JSON.stringify(pending)) as Game; + const continuation=tampered.pendingTreacheryDiscard!.continuation; + assert.equal(continuation.kind,'truthtranceDiscard'); + if (continuation.kind !== 'truthtranceDiscard') throw Error('Expected original Truth discard'); + assert.ok(continuation.bribeBinding); assert.equal(continuation.bribeBinding.kind,'mixedBribe'); + continuation.bribeBinding.value.answer=false; + reject(tampered,f.target,{type:'semutaCommit',event}); + // Mutating the live ledger rather than the receipt must also reject. + const altered=JSON.parse(JSON.stringify(pending)) as Game; + assert.ok(altered.mixedBribeQuestions?.length); + altered.mixedBribeQuestions!.at(-1)!.answer=false; + reject(altered,f.target,{type:'semutaCommit',event}); + assert.deepEqual(inventory(pending),f.originalCards); +});