From 4dfdd500e0a60940b769e3fff54e39f065141182 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:12:37 +1100 Subject: [PATCH] Support Revival answers with Richese Semuta --- components/game-table.tsx | 2 +- docs/CHOAM_KULL_DESIGN.md | 18 ++- docs/CURRENT_STATUS.md | 15 +- docs/RULE_DECISIONS.md | 7 + game/engine.ts | 75 +++++++++ game/faction-reference.ts | 2 +- game/reference.ts | 8 +- game/revival-promises.ts | 8 +- tests/kull-richese-bribe-scope.test.ts | 4 +- tests/kull-richese-revival-commitment.test.ts | 143 ++++++++++++++++++ tests/kull-richese-revival-scope.test.ts | 68 +++++++++ ...ll-richese-revival-table-controls.test.tsx | 91 +++++++++++ .../kull-richese-revival-truth-semuta.test.ts | 112 ++++++++++++++ tests/public-choam-kull-controls.test.tsx | 2 +- 14 files changed, 540 insertions(+), 15 deletions(-) create mode 100644 tests/kull-richese-revival-commitment.test.ts create mode 100644 tests/kull-richese-revival-scope.test.ts create mode 100644 tests/kull-richese-revival-table-controls.test.tsx create mode 100644 tests/kull-richese-revival-truth-semuta.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 5a537729..b2af6bda 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -2254,7 +2254,7 @@ export function GameTable({ : <> Requires Basic CHOAM with the CHOAM and Ix decks plus each faction’s deck, and no other optional modules. Combined turn-action Truthtrance questions are unavailable. - When Richese is seated, alliance and Revival commitments are also unavailable, + When Richese is seated, alliance commitments are also unavailable, and some Kull reactions may be unavailable in complex response windows. }{' '} Unfunded non-CHOAM Karama-dependent bids remain deferred. This does not represent complete CHOAM rules. diff --git a/docs/CHOAM_KULL_DESIGN.md b/docs/CHOAM_KULL_DESIGN.md index ee679458..4f4baf5d 100644 --- a/docs/CHOAM_KULL_DESIGN.md +++ b/docs/CHOAM_KULL_DESIGN.md @@ -1,5 +1,21 @@ # Kull Wahad: source limits and Karama interruption design +## Basic Revival answers with Richese — 29 October 2026 + +**Prototyped / Partial.** Force, named-leader, grouped and mixed Revival +Truthtrance answers can coexist with Semuta in the supported bare Basic +Richese profile, with or without printed Kull. Semuta's response to the spent +Truthtrance preserves the answer and its exact original return binding. With +Kull, successful interception or a distinct counter retains the original +Revival answer through Semuta's claim or pass. The online timing remains +provisional. Tests use genuine native setup and Revival windows with selected +physical cards and, for named-leader cases, staged leader deaths. Alliance +questions with Richese, whole-turn questions, special-cache Kull and broader +module combinations remain separate. +[Revival/Kull evidence](../tests/kull-richese-revival-commitment.test.ts), +[Truth discard evidence](../tests/kull-richese-revival-truth-semuta.test.ts), +[rendered controls](../tests/kull-richese-revival-table-controls.test.tsx). + ## Basic bribe answers with Richese — 29 October 2026 **Prototyped / Partial.** Ordinary, grouped and mixed bribe Truthtrance answers @@ -8,7 +24,7 @@ profile. The physical Truthtrance discard retains its bribe binding through Semuta's claim or pass. A later Kull success or distinct counter likewise preserves the answer through its Semuta reaction; the real payment and No cap remain governed by the original bribe rules. The preview's existing timing is -provisional. Revival and alliance questions with Richese, whole-turn questions, +provisional. Alliance questions with Richese, whole-turn questions, special-cache Kull and broader module combinations remain separate. [Runtime evidence](../tests/kull-richese-bribe-runtime.test.ts), [Truth discard evidence](../tests/kull-richese-bribe-truth-semuta.test.ts), diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 2870cfc8..fb933804 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,18 @@ open mode gates. ## Current checkpoint and work +**Basic Richese and Revival Truthtrance — 29 October. Prototyped / Partial.** +Force, named-leader, grouped and mixed Revival answers now continue through +Semuta in the supported bare Basic Richese profile, with or without printed +Kull. Original answer bindings survive the Truthtrance discard; Kull success +or a distinct counter preserves the original Revival commitment through its +Semuta response. Thirteen related files pass 87 cases; an additional rendered +table/control case passes. Typecheck, lint and the production build pass. +Fixtures use genuine setup and Revival windows with +selected physical cards and staged deaths for named-leader cases. Alliance and +whole-turn questions with Richese, special-cache Kull and broader profiles +remain unfinished. [Scope](CHOAM_KULL_DESIGN.md#basic-revival-answers-with-richese--29-october-2026). + **Basic Kull, Richese and bribe Truthtrance — 29 October. Prototyped / Partial.** Ordinary, grouped and mixed bribe answers now work in the supported bare Basic Kull/Richese profile. The payment binding survives the physical Truthtrance @@ -24,8 +36,7 @@ discard's Semuta claim or pass and later Kull success or a distinct counter, including the respective Semuta response. Twelve related files pass 63 cases, with genuine setup, cache purchase, answer, reaction, payment and minimal bot actions; a separate rendered table/control case passes. Typecheck, lint and the -production build pass. Alliance and Revival -questions with Richese, whole-turn questions, +production build pass. Alliance questions with Richese, whole-turn questions, special-cache Kull and broader profiles remain unfinished. [Scope](CHOAM_KULL_DESIGN.md#basic-bribe-answers-with-richese--29-october-2026). diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index a2a99985..ab4662f1 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,12 @@ # Rule decision index +## Basic Richese Revival answers — 29 October 2026 + +The supported bare Basic profile retains force, leader, grouped and mixed +Revival answers through Semuta, with or without printed Kull. The existing +online ordering remains provisional. +[Scope](CHOAM_KULL_DESIGN.md#basic-revival-answers-with-richese--29-october-2026). + ## Basic Kull and Richese bribe answers — 29 October 2026 The supported bare Basic profile retains ordinary bribe answers through diff --git a/game/engine.ts b/game/engine.ts index 4feb39d7..1f80ada4 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1614,6 +1614,8 @@ export type Game = { purpose?: KullPurpose; /** New Basic Kull attempts may bind an existing structured bribe ledger. */ bribeReceipt?: true; + /** New Kull/Semuta attempts retain the original Revival answer ledger. */ + revivalReceipt?: true; stage: 'offer' | 'counter'; selection?: { source: 'printed' | 'nexus'; card: string; nexusEvent?: string }; intent: { kind: 'ordinary'; use: KaramaUse } | { kind: 'special'; use: SpecialKaramaIntent }; @@ -1927,6 +1929,11 @@ export type Game = { | { kind: 'bribe'; index: number; value: BribePromise } | { kind: 'bribeExpression'; index: number; value: BribeExpressionPromise } | { kind: 'mixedBribe'; index: number; value: NonNullable[number] }; + revivalBinding?: + | { kind: 'revival'; index: number; value: RevivalPromise } + | { kind: 'leaderRevival'; index: number; value: LeaderRevivalPromise } + | { kind: 'revivalExpression'; index: number; value: RevivalExpressionPromise } + | { kind: 'mixedRevival'; index: number; value: NonNullable[number] }; parentSignature: string; } | { @@ -7576,6 +7583,10 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) { ...(next.bribeBinding ? { bribeBinding: next.bribeBinding, bribePromises: g.bribePromises, bribeExpressionPromises: g.bribeExpressionPromises, mixedBribeQuestions: g.mixedBribeQuestions, bribePromiseSequence: g.bribePromiseSequence } : {}), + ...(next.revivalBinding ? { revivalBinding: next.revivalBinding, + revivalPromises: g.revivalPromises, leaderRevivalPromises: g.leaderRevivalPromises, + revivalExpressionPromises: g.revivalExpressionPromises, mixedRevivalQuestions: g.mixedRevivalQuestions, + revivalPromiseSequence: g.revivalPromiseSequence } : {}), controls: nullentropyParentSignature( g, next.resume, @@ -7698,6 +7709,18 @@ function finishTruthtranceAnswer(g: Game) { else if (g.semutaPreview && record.question.kind === 'mixedBribe') continuation.bribeBinding = { kind: 'mixedBribe', index: g.mixedBribeQuestions!.length - 1, value: structuredClone(g.mixedBribeQuestions!.at(-1)!), }; + if (g.semutaPreview && record.question.kind === 'revival') continuation.revivalBinding = { + kind: 'revival', index: g.revivalPromises!.length - 1, value: structuredClone(g.revivalPromises!.at(-1)!), + }; + else if (g.semutaPreview && record.question.kind === 'leaderRevival') continuation.revivalBinding = { + kind: 'leaderRevival', index: g.leaderRevivalPromises!.length - 1, value: structuredClone(g.leaderRevivalPromises!.at(-1)!), + }; + else if (g.semutaPreview && record.question.kind === 'revivalExpression') continuation.revivalBinding = { + kind: 'revivalExpression', index: g.revivalExpressionPromises!.length - 1, value: structuredClone(g.revivalExpressionPromises!.at(-1)!), + }; + else if (g.semutaPreview && record.question.kind === 'mixedRevival') continuation.revivalBinding = { + kind: 'mixedRevival', index: g.mixedRevivalQuestions!.length - 1, value: structuredClone(g.mixedRevivalQuestions!.at(-1)!), + }; continuation.parentSignature = truthtranceDiscardSignature(g, continuation); stageTreacheryDiscard( g, @@ -8720,6 +8743,45 @@ function treacheryDiscardIntegrity(g: Game) { 'The completed mixed bribe answer no longer matches its payment binding.'); } } else requireRule(!c.bribeBinding, 'This Truthtrance answer has no bribe binding.'); + const expectedRevival = g.semutaPreview && + (q.kind === 'revival' || q.kind === 'leaderRevival' || q.kind === 'revivalExpression' || q.kind === 'mixedRevival'); + if (expectedRevival) { + const binding = c.revivalBinding; + const actual = binding?.kind === 'revival' ? g.revivalPromises : + binding?.kind === 'leaderRevival' ? g.leaderRevivalPromises : + binding?.kind === 'revivalExpression' ? g.revivalExpressionPromises : g.mixedRevivalQuestions; + 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 Revival answer.'); + const emptyProgress = {total:0, normal:0, elite:0, leaders:[]}; + if (binding.kind === 'revival' && q.kind === 'revival') + requireRule(binding.value.counter === q.counter && binding.value.minimum === q.minimum && + binding.value.progress === 0 && !binding.value.fulfilled && !binding.value.released && + g.revivalPromiseSequence?.at(-1) === binding.value.event, + 'The completed Truthtrance Revival differs from its original question.'); + else if (binding.kind === 'leaderRevival' && q.kind === 'leaderRevival') + requireRule(binding.value.leader === q.leader && !binding.value.fulfilled && !binding.value.released && + g.revivalPromiseSequence?.at(-1) === binding.value.event, + 'The completed Truthtrance leader Revival differs from its original question.'); + else if (binding.kind === 'revivalExpression' && q.kind === 'revivalExpression') + requireRule(JSON.stringify(binding.value.claim) === JSON.stringify(q.claim) && + JSON.stringify(binding.value.progress) === JSON.stringify(emptyProgress) && + !binding.value.fulfilled && !binding.value.released && g.revivalPromiseSequence?.at(-1) === binding.value.event, + 'The completed Truthtrance Revival expression differs from its original question.'); + else if (binding.kind === 'mixedRevival' && q.kind === 'mixedRevival') { + const residual = g.revivalExpressionPromises?.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 && JSON.stringify(residual.progress) === JSON.stringify(emptyProgress) && + !residual.fulfilled && !residual.released && g.revivalPromiseSequence?.at(-1) === binding.value.event : + !g.revivalExpressionPromises?.some(item => item.event === binding.value.event)), + 'The completed mixed Revival answer no longer matches its return binding.'); + } + } else requireRule(!c.revivalBinding, 'This Truthtrance answer has no Revival binding.'); if (expectedShipment || expectedBattle || expectedMovement || expectedTraitor) { const actual = expectedTraitor ? g.battle?.traitorDecisionPromises : expectedMovement ? g.movementPromises : expectedShipment ? g.shipmentPromises @@ -9350,6 +9412,9 @@ function semutaOfferSupported( if (continuation.kind === 'truthtranceDiscard' && ['bribe', 'bribeExpression', 'mixedBribe'].includes(continuation.record.question.kind) && (!continuation.bribeBinding || !bareBasicSemutaProfile(g) || !bribePromiseModeSupported(g))) return false; + if (continuation.kind === 'truthtranceDiscard' && + ['revival', 'leaderRevival', 'revivalExpression', 'mixedRevival'].includes(continuation.record.question.kind) && + (!continuation.revivalBinding || !bareBasicSemutaProfile(g) || !revivalPromiseModeSupported(g))) return false; const richeseGiftDiscard = continuation.kind === 'karamaRicheseGiftDiscard'; const richeseDiscard = richeseGiftDiscard || continuation.kind === 'karamaRicheseAuctionDiscard'; const auctionAdvantageDiscard = continuation.kind === 'karamaAuctionAdvantageDiscard'; @@ -19158,6 +19223,9 @@ function kullSignature(g: Game, frame: KullFrame): string { ...(frame.bribeReceipt ? {bribeReceipt:frame.bribeReceipt,bribePromises:source.bribePromises, bribeExpressionPromises:source.bribeExpressionPromises,bribePromiseSequence:source.bribePromiseSequence, mixedBribeQuestions:source.mixedBribeQuestions} : {}), + ...(frame.revivalReceipt ? {revivalReceipt:frame.revivalReceipt,revivalPromises:source.revivalPromises, + leaderRevivalPromises:source.leaderRevivalPromises,revivalExpressionPromises:source.revivalExpressionPromises, + mixedRevivalQuestions:source.mixedRevivalQuestions,revivalPromiseSequence:source.revivalPromiseSequence} : {}), }); } /** Only the verified pre-source printed profile may bind its old held cost. */ @@ -19205,6 +19273,10 @@ function kullIntegrity(g: Game): void { requireRule(frame.bribeReceipt === undefined || frame.bribeReceipt === true && !g.advanced && bribePromiseModeSupported(g) && !!(g.bribePromises?.length || g.bribeExpressionPromises?.length || g.mixedBribeQuestions?.length), 'The Kull attempt has lost its original Basic bribe-answer ledger.'); + requireRule(frame.revivalReceipt === undefined || frame.revivalReceipt === true && g.semutaPreview === true && + revivalPromiseModeSupported(g) && !!(g.revivalPromises?.length || g.leaderRevivalPromises?.length || + g.revivalExpressionPromises?.length || g.mixedRevivalQuestions?.length), + 'The Kull attempt has lost its original Basic Revival-answer ledger.'); validateKullAttemptStamp(g, frame, physicalTreacheryCards(g)); requireRule(Number.isSafeInteger(g.kullSequence) && g.kullSequence! > 0 && frame.event === JSON.stringify(['kull', g.turn, g.phase, g.kullSequence, frame.player, frame.owner]), @@ -19289,6 +19361,9 @@ function beginKullAttempt(g: Game, p: Player, card: Card, intent: KullFrame['int ...(g.advanced ? { purpose: kullIntentPurpose(g, intent) } : {}), ...(!g.advanced && (g.bribePromises?.length || g.bribeExpressionPromises?.length || g.mixedBribeQuestions?.length) ? { bribeReceipt: true as const } : {}), + ...(g.semutaPreview && revivalPromiseModeSupported(g) && (g.revivalPromises?.length || + g.leaderRevivalPromises?.length || g.revivalExpressionPromises?.length || g.mixedRevivalQuestions?.length) + ? { revivalReceipt: true as const } : {}), worthless: structuredClone(g.pendingChoamWorthless ?? null), signature: '', }; // Payment dispatch removed this control before entering its native branch. diff --git a/game/faction-reference.ts b/game/faction-reference.ts index 37286471..17eb9f93 100644 --- a/game/faction-reference.ts +++ b/game/faction-reference.ts @@ -14,7 +14,7 @@ export const FACTION_RULES: Record< 'Use Kulon on your movement turn to add one territory of range. Use La La La during Revival to prevent a player’s free force returns for the phase; a reactive decision lets you stop a pending free revival before it resolves. Both cards have Karama responses before discard.', 'Trip to Gamont returns one other player’s force from a chosen sector to its reserves during Mentat. Elite types are preserved. A Karama response precedes the return, and CHOAM gets a final opportunity after the closing market before victory is checked.', 'Baliset prevents a player from moving into a territory occupied by CHOAM for the turn, while permitting shipment. Jubba Cloak protects CHOAM forces in one territory from a moving storm. Both powers have Karama responses before the card is discarded.', - 'New supported Basic lobbies with CHOAM and the CHOAM and Ix decks enable the Kull preview by default; the host may turn it off. Each selected faction needs its deck, and other optional modules remain outside this profile. React to a legal Karama attempt with held Kull Wahad before costs. The original card stays reserved; only a different eligible Karama can counter before the phase ban applies. Success spends Kull, retains the original unplayed card and prevents the activating player’s Karama uses for the phase. Decline or prevention resumes the original once; a prevented Kull stays held but its physical card’s effect is blocked that phase. With Richese, supported Kull and counter discards offer Semuta; ordinary bribe, simple and mixed current-lot bid, and phase-wide bid questions are supported, while Revival and alliance commitments remain unavailable. Combined turn-action questions and unfunded non-CHOAM Karama-dependent overbids remain deferred; see the Kull guide. Complete CHOAM rules remain unfinished.', + 'New supported Basic lobbies with CHOAM and the CHOAM and Ix decks enable the Kull preview by default; the host may turn it off. Each selected faction needs its deck, and other optional modules remain outside this profile. React to a legal Karama attempt with held Kull Wahad before costs. The original card stays reserved; only a different eligible Karama can counter before the phase ban applies. Success spends Kull, retains the original unplayed card and prevents the activating player’s Karama uses for the phase. Decline or prevention resumes the original once; a prevented Kull stays held but its physical card’s effect is blocked that phase. With Richese, supported Kull and counter discards offer Semuta; ordinary bribe and Revival, simple and mixed current-lot bid, and phase-wide bid questions are supported, while alliance commitments remain unavailable. Combined turn-action questions and unfunded non-CHOAM Karama-dependent overbids remain deferred; see the Kull guide. Complete CHOAM rules remain unfinished.', ], advanced: [ 'Once per game, spend a real Karama and discard selected other cards for three spice each. The cash-in can occur during pending phase or power decisions, while committed battle cards remain protected. See the CHOAM special Karama topic for funding and timing details.', diff --git a/game/reference.ts b/game/reference.ts index 55450c9d..5615b25b 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3665,7 +3665,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'The private answer choices and guide use current native quotas and funding, including native free/paid selection. Earlier questions retain current Tanks and actual liabilities, project fresh normal Revival ledgers, and may prepare through actual Charity or owned opening income. Unknown future casualties do not supply forces. Earlier shipment and ground-movement promises retain their funding. Unknown gifts, future draws and foreign choices are not assumed.', 'Your own request, spending or Finish Revival cannot evade an unmet Yes. A completed opposing effect may release an impossible answer in accepted order. A requested return does not fulfill Yes until the actual return completes; its original responses still decide the outcome.', 'The persistent own promise panel displays progress and fulfilled/released status. Before Revival it waits for your actual Charity/Ready, ordinary/native pass, Silent zero or owned ordinary auction-payment choice; at Revival it offers a normal return or Finish Revival. Each guide belongs to its current phase and original event. Basic No remains through phase close because native readiness can reopen; stamped Advanced No ends when your opportunity finishes.', - 'This cumulative this-turn protocol is a provisional online composition of general current-turn Truthtrance authority. Optional-module compositions, special/card revival claims, grouped revival expressions and complete-mode acceptance remain required work.', + 'In supported bare Basic Richese games, force, named-leader, grouped and mixed Revival answers retain their bindings through Semuta, including with printed Kull. This cumulative this-turn protocol is a provisional online composition of general current-turn Truthtrance authority. Optional-module compositions, special/card revival claims and complete-mode acceptance remain required work.', ], related: ['card-truthtrance', 'advanced-revival-order', 'truthtrance-shipment', 'truthtrance-movement', 'implementation-checklist'], checklist: [ @@ -3673,7 +3673,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Player controls', status: 'Partial', detail: 'Structured question, target-only feasible answers and persistent event-bound normal revival/finish guide.', evidence: ['components/revival-claim-fields.tsx', 'components/revival-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-revival-controls.test.tsx'] }, { area: 'AI', status: 'Partial', detail: 'Existing four profiles use feasible answers and the native action guide; strategy and difficulty calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-revival-runtime.test.ts'] }, { area: 'Documentation', status: 'Implemented', detail: 'General authority, provisional timing/count interpretation, native order and remaining boundaries are explicit.', evidence: ['docs/TRUTHTRANCE_REVIVAL_PROMISES.md', 'docs/RULE_DECISIONS.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Focused native original-deal/death, pure and control evidence; broader combinations, browser and final deployed acceptance remain open.', evidence: ['tests/fixture-truth-revival.ts', 'tests/truth-revival-runtime.test.ts', 'tests/truth-revival-controls.test.tsx'] }, + { area: 'Verification', status: 'Partial', detail: 'Focused native original-deal/death, pure and control evidence, including staged Richese Semuta and Kull Revival answers; broader combinations, browser and final deployed acceptance remain open.', evidence: ['tests/fixture-truth-revival.ts', 'tests/truth-revival-runtime.test.ts', 'tests/truth-revival-controls.test.tsx', 'tests/kull-richese-revival-commitment.test.ts', 'tests/kull-richese-revival-truth-semuta.test.ts', 'tests/kull-richese-revival-table-controls.test.tsx'] }, ], }, { @@ -4861,7 +4861,7 @@ export const RULE_TOPICS: RuleTopic[] = [ summary: 'React before a Karama is played; a distinct counter can stop Kull before its phase restriction begins.', steps: [ 'In a new supported Basic lobby with CHOAM and the CHOAM and Ix decks, printed Kull is enabled by default as a development preview. The host may turn it off to keep the broader non-Kull Truthtrance options. Basic supports Richese, Ixians, Tleilaxu, Ecaz, Moritani and classic factions with each seated faction’s deck and no other optional modules. In an Advanced preview lobby, the host may explicitly select printed Kull with CHOAM, classic factions and exactly the CHOAM and Ix decks, without optional modules. Advanced supports Bene Gesserit substitutions and legal special powers. Both previews withhold some structured questions and defer unfunded non-CHOAM Karama-dependent bids. Existing games are not retrofitted, and this warning does not certify complete rules.', - 'In the supported Basic printed Kull preview, simple and mixed current-lot ordinary bid, phase-wide bid and ordinary bribe questions are available, including with Richese and Semuta. Without Richese, supported Revival and alliance questions are also available. A pending Kull response holds their controls while the original transaction resolves. Combined turn-action questions and wider interactions remain incomplete.', + 'In the supported Basic printed Kull preview, simple and mixed current-lot ordinary bid, phase-wide bid, ordinary bribe and Revival questions are available, including with Richese and Semuta. Without Richese, supported alliance questions are also available. A pending Kull response holds their controls while the original transaction resolves. Combined turn-action questions and wider interactions remain incomplete.', 'Another player’s legal Karama cancellation, shipment-rate use, immediate auction purchase, winning-bid payment or supported special activation pauses before its card and other costs. CHOAM sees the attempted player and purpose and may decline or choose a legal held Kull Wahad. Each Use names its printed source explicitly. The separate Nexus Kull guide covers additional Cunning costs in its new profile only. The same public opportunity appears without Kull in hand, so waiting does not disclose hidden ownership. Invalid attempts never open this window; CHOAM does not receive a self-targeted Kull offer.', 'Decline resumes the original attempt once. Declaring Kull opens the ordinary CHOAM-effect response. Kull is still pending: its target may counter with a different eligible physical Karama before any phase ban applies. The interrupted card stays reserved and cannot serve as its own counter. Other eligible responders use their existing response controls.', 'If everyone allows Kull, CHOAM discards the exact Kull card. The original attempted card remains in its owner’s hand; its effect and additional costs do not occur, and an attempted special once-per-game use remains unspent. Basic prevents that player’s Karama activations for this phase. A new Advanced attempt instead prevents only the specific effect: shipment pricing, free Treachery purchase, that faction’s special, or cancellation of the particular advantage. Other effects remain available; printed and substituted Karama share the same restriction. An allied shipment recipient is not automatically the restricted player.', @@ -4876,7 +4876,7 @@ export const RULE_TOPICS: RuleTopic[] = [ example: 'Bene Gesserit attempts to use Baliset as Karama. CHOAM declares Kull. Baliset cannot also counter Kull; a different eligible Karama may. If Kull succeeds, Baliset stays held; in a new Advanced attempt BG cannot repeat that specific Karama effect this phase but may use another legal effect. If a distinct counter prevents Kull, the original Baliset attempt proceeds to its normal conversion response.', related: ['choam-worthless', 'nexus-choam-kull', 'faction-choam', 'card-karama', 'advanced-beneGesserit', 'special-karama', 'bidding', 'choam-karama', 'nexus-cards', 'implementation-checklist'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Selectable Kull binds typed intents, distinct physical counters and Basic/legacy all-purpose or new Advanced semantic-effect restrictions. Different effects remain playable; phase expiry is provisional in Advanced. Basic printed Kull admits native Richese, Ixians, Tleilaxu, Ecaz and Moritani; supported clean Kull discards offer Semuta before their suspended parent resumes. Simple and mixed current-lot bid, phase-wide bid and ordinary bribe commitments can coexist with this preview including Richese; Revival and alliance commitments remain limited to the non-Richese profile. Deferred overbid recovery, whole-turn answers and broader module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/kull-purpose.ts', 'game/bribe-promises.ts', 'game/engine.ts', 'tests/kull-bid-runtime.test.ts', 'tests/kull-phase-bid-runtime.test.ts', 'tests/kull-richese-phase-bid-runtime.test.ts', 'tests/kull-richese-current-bid-runtime.test.ts', 'tests/kull-richese-mixed-bid-runtime.test.ts', 'tests/kull-richese-bribe-runtime.test.ts', 'tests/kull-richese-bribe-truth-semuta.test.ts', 'tests/kull-richese-bribe-table-controls.test.tsx', 'tests/kull-truth-revival-runtime.test.ts', 'tests/kull-alliance-runtime.test.ts', 'tests/kull-richese-semuta-runtime.test.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Selectable Kull binds typed intents, distinct physical counters and Basic/legacy all-purpose or new Advanced semantic-effect restrictions. Different effects remain playable; phase expiry is provisional in Advanced. Basic printed Kull admits native Richese, Ixians, Tleilaxu, Ecaz and Moritani; supported clean Kull discards offer Semuta before their suspended parent resumes. Simple and mixed current-lot bid, phase-wide bid, ordinary bribe and Revival commitments can coexist with this preview including Richese; alliance commitments remain limited to the non-Richese profile. Deferred overbid recovery, whole-turn answers and broader module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/kull-purpose.ts', 'game/bribe-promises.ts', 'game/engine.ts', 'tests/kull-bid-runtime.test.ts', 'tests/kull-phase-bid-runtime.test.ts', 'tests/kull-richese-phase-bid-runtime.test.ts', 'tests/kull-richese-current-bid-runtime.test.ts', 'tests/kull-richese-mixed-bid-runtime.test.ts', 'tests/kull-richese-bribe-runtime.test.ts', 'tests/kull-richese-bribe-truth-semuta.test.ts', 'tests/kull-richese-bribe-table-controls.test.tsx', 'tests/kull-richese-revival-commitment.test.ts', 'tests/kull-richese-revival-truth-semuta.test.ts', 'tests/kull-truth-revival-runtime.test.ts', 'tests/kull-alliance-runtime.test.ts', 'tests/kull-richese-semuta-runtime.test.ts'] }, { area: 'Player controls', status: 'Partial', detail: 'Owner-only legal held costs and decline use the Kull event; counters reuse ordinary response controls. Public viewers receive purpose/actors, not private intent payloads or hidden choices.', evidence: ['components/choam-kull.tsx', 'components/game-table.tsx'] }, { area: 'AI', status: 'Partial', detail: 'Legal preview participation uses projected reactor choices and ordinary distinct counter controls. Full strategy and difficulty calibration remain deferred.', evidence: ['game/bot-choam-kull.ts', 'game/bots.ts'] }, { area: 'Documentation', status: 'Partial', detail: 'Pending versus active restrictions, distinct custody, decline/prevention/success, BG-before-conversion and deferred overbid limits have internal explanations. Publisher-source and full-faction acceptance remain separate.', evidence: ['docs/CHOAM_KULL_DESIGN.md', 'docs/CHOAM_KULL_SOURCE_UPDATE.md', 'docs/NEXUS_CHOAM_RULES.md'] }, diff --git a/game/revival-promises.ts b/game/revival-promises.ts index 8018f280..d1150e47 100644 --- a/game/revival-promises.ts +++ b/game/revival-promises.ts @@ -35,7 +35,7 @@ export function parseRevivalClaim(value: unknown): RevivalClaim { /** The original bare native scope and the supported Basic printed-Kull roster. * Timing, allowance, payment, typed Tanks and actual ordinary-return sources - * remain the engine's rules; Richese Semuta and Nexus Kull stay fenced. */ + * remain the engine's rules; unsupported Semuta and Nexus Kull stay fenced. */ export function revivalPromiseModeSupported(game: NativePromiseScope & { semutaPreview?: unknown; kullPreview?: unknown; nexusKullPreview?: unknown; richeseBetrayalPreview?: unknown; guildBetrayalPreview?: unknown; @@ -43,8 +43,10 @@ export function revivalPromiseModeSupported(game: NativePromiseScope & { nexusHarkonnenBetrayalPreview?: unknown; moritaniAssassinatePreview?: unknown; mobileStronghold?: unknown; }): boolean { - return nativePromiseScopeSupported(game) && !game.semutaPreview && !game.nexusKullPreview && - !game.richeseBetrayalPreview && (!game.kullPreview || bribePromiseModeSupported(game)); + return nativePromiseScopeSupported(game) && !game.nexusKullPreview && + !game.richeseBetrayalPreview && + (!game.semutaPreview || game.semutaPreview === true && bribePromiseModeSupported(game)) && + (!game.kullPreview || bribePromiseModeSupported(game)); } export function revivalClaimText(claim: RevivalClaim): string { diff --git a/tests/kull-richese-bribe-scope.test.ts b/tests/kull-richese-bribe-scope.test.ts index e083d056..2b1f7feb 100644 --- a/tests/kull-richese-bribe-scope.test.ts +++ b/tests/kull-richese-bribe-scope.test.ts @@ -36,9 +36,9 @@ void test('Basic Kull and Semuta expose the bribe family only in the supported n } }); -void test('admitting bribes does not silently enable unrelated Semuta promise families', () => { +void test('Basic Semuta retains separate alliance and bid boundaries', () => { assert.equal(alliancePromiseModeSupported(profile),false); - assert.equal(revivalPromiseModeSupported(profile),false); + assert.equal(revivalPromiseModeSupported(profile),true); assert.equal(bidPromiseModeSupported(profile),false); assert.equal(currentBidPromiseModeSupported(profile),true); }); diff --git a/tests/kull-richese-revival-commitment.test.ts b/tests/kull-richese-revival-commitment.test.ts new file mode 100644 index 00000000..0af34cb0 --- /dev/null +++ b/tests/kull-richese-revival-commitment.test.ts @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { handLimit, viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { act, finishRevival, inventory, player } from './fixture-kull-revival-semuta'; +import { createSemutaRevivalBenefitsForcesFixture } from './fixture-semuta-revival-benefits-forces'; + +/** The native source purchases Semuta, ships four forces and loses them to + * Storm before this real Revival opportunity. As in the existing Kull + * Semuta fixture, only original physical cards are reassigned among zones; + * no phase, wallet, force, promise, response or pending revival is installed. */ +let nativeSource: ReturnType | undefined; +function fixture(answer: 'yes' | 'no' = 'yes') { + const source = nativeSource ??= createSemutaRevivalBenefitsForcesFixture({kind:'choamRevival', advanced:false}); + let game = structuredClone(source.beforeRevival); + assert.ok(game.kullPreview && game.semutaPreview); + const originalCards = inventory(game); + const zones = [game.deck, game.discard, ...(game.richeseCache ? [game.richeseCache] : []), + ...(game.richeseRemoved ? [game.richeseRemoved] : []), ...game.players.map(seat => seat.hand)]; + const move = (id:string, recipient:string) => { + const zone = zones.find(cards=>cards.some(card=>card.id === id)); assert.ok(zone, id); + const [card] = zone.splice(zone.findIndex(row=>row.id === id),1); + const seat = player(game,recipient); assert.ok(seat.hand.length < handLimit(seat)); + seat.hand.push(card); + }; + move(SEMUTA_DRUG_ID, 'richese'); + move('ix-kull-wahad', 'choam'); + const truth = zones.flat().find(card=>card.effect === 'truthtrance'); assert.ok(truth); + move(truth.id, 'atreides'); + const counter = zones.flat().find(card=>card.effect === 'karama' && card.id !== source.cost && + !card.id.startsWith('richese-')); assert.ok(counter); + move(counter.id, 'tleilaxu'); + assert.deepEqual(inventory(game),originalCards); + assert.equal(player(game,'choam').tanks,4); + game = act(game,'atreides',{type:'card',card:truth.id}); + while (game.truthtrance?.stage === 'priority') { + const next = game.players.find(seat=>!game.truthtrance!.passed.includes(seat.id)); assert.ok(next); + game = act(game,next.id,{type:'truthPass'}); + } + game = act(game,'atreides',{type:'truthAsk',question:{kind:'revival',target:'choam', + turn:game.turn,counter:'total',minimum:3}}); + assert.ok(viewGame(game,'choam').truthRevivalAnswers?.includes(answer)); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game,'choam'); view.players.find(seat=>seat.id === 'choam')!.bot = difficulty; + const action = botActions(view)[0]; assert.ok(action,difficulty); + assert.equal(action.type,'truthAnswer',difficulty); + assert.ok(view.truthRevivalAnswers?.includes(action.answer as 'yes'|'no'),difficulty); + assert.doesNotThrow(()=>act(game,'choam',action),difficulty); + } + game = act(game,'choam',{type:'truthAnswer',answer}); + const promise = game.revivalPromises!.find(row=>row.player === 'choam'); assert.ok(promise); + // Pass the physical Truthtrance's Semuta reaction, retaining Semuta for the + // subsequent Kull or counter discard. + if (game.pendingTreacheryDiscard) game = semuta(game,truth.id,false); + assert.ok(game.revivalPromises?.some(row=>row.event === promise.event && !row.released)); + return {game,source,originalCards,event:promise.event,kull:'ix-kull-wahad', + karama:source.cost,counter:counter.id}; +} + +function semuta(game:Game, card:string, claim:boolean):Game { + const pending = game.pendingTreacheryDiscard; assert.ok(pending); + assert.equal(pending.reaction?.stage,'offer'); + assert.equal(pending.batch.entries[0].card.id,card); + const event = pending.batch.event; + if (claim) { + assert.equal(viewGame(game,'richese').semutaReaction?.canCommit,true); + game = act(game,'richese',{type:'semutaCommit',event}); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') + game = act(game,'richese',{type:'semutaSelect',event,card}); + } else { + for (let count=0;game.pendingTreacheryDiscard?.batch.event === event && count!reaction.passed.includes(seat.id)); assert.ok(next); + game = act(game,next.id,{type:'semutaPass',event}); + } + } + assert.notEqual(game.pendingTreacheryDiscard?.batch.event,event); + return game; +} + +function pending(f:ReturnType) { + let game = f.game; + const guide = viewGame(game,'choam').revivalPromiseGuide; assert.ok(guide); + assert.equal(guide.action.type,'revive'); assert.equal(guide.action.amount,3); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game,'choam'); view.players.find(seat=>seat.id === 'choam')!.bot = difficulty; + const action = botActions(view).find(row=>row.revivalPromiseEvent === guide.action.revivalPromiseEvent); + assert.ok(action,difficulty); assert.doesNotThrow(()=>act(game,'choam',action),difficulty); + } + game = act(game,'choam',guide.action); + for (let count=0;count<20 && game.response?.kind !== 'choamRevival';count++) { + if (game.decision?.kind === 'revivalStop' || game.decision?.kind === 'choamFreeRevival') + game = act(game,game.decision.player,{type:'decision',decline:true}); + else throw Error('The original revival did not reach its native benefit response.'); + } + assert.equal(game.response?.kind,'choamRevival'); assert.ok(game.pendingRevival); + game = act(game,'emperor',{type:'card',mode:'cancel',card:f.karama}); + assert.equal(game.pendingKull?.stage,'offer'); + const reaction = viewGame(game,'choam').kullReaction; assert.ok(reaction); + game = act(game,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull}); + assert.equal(game.pendingKull?.stage,'counter'); + return game; +} + +for (const claim of [false,true]) void test(`Revival Yes survives Kull success and Semuta ${claim?'claim':'pass'}`,()=>{ + const f = fixture(); let game = pending(f); + const promise = structuredClone(game.revivalPromises!.find(row=>row.event === f.event)); assert.ok(promise); + for (let count=0;count!game.response!.passed.includes(seat.id)); assert.ok(next); + game = act(game,next.id,{type:'passResponse'}); + } + assert.deepEqual(game.revivalPromises!.find(row=>row.event === f.event),promise); + game = semuta(game,f.kull,claim); + game = finishRevival(game); + assert.equal(game.revivalPromises!.find(row=>row.event === f.event)?.fulfilled,true); + assert.equal(player(game,'choam').tanks,1); + assert.equal(player(game,'richese').hand.some(card=>card.id === f.kull),claim); + assert.deepEqual(inventory(game),f.originalCards); +}); + +for (const claim of [false,true]) void test(`Revival Yes survives Kull counter and Semuta ${claim?'claim':'pass'}`,()=>{ + const f = fixture(); let game = pending(f); + assert.ok(viewGame(game,'tleilaxu').responseControls?.cancelCards.includes(f.counter)); + game = act(game,'tleilaxu',{type:'card',mode:'cancel',card:f.counter}); + assert.ok(!game.revivalPromises!.find(row=>row.event === f.event)?.released); + game = semuta(game,f.counter,claim); + if (game.pendingTreacheryDiscard) game = semuta(game,f.karama,false); + game = finishRevival(game); + assert.equal(game.revivalPromises!.find(row=>row.event === f.event)?.fulfilled,true); + assert.equal(player(game,'choam').tanks,1); + assert.equal(player(game,'richese').hand.some(card=>card.id === f.counter),claim); + assert.deepEqual(inventory(game),f.originalCards); +}); + +void test('Revival No retains its cap in the Kull and Richese profile',()=>{ + const f = fixture('no'); + const before = structuredClone(f.game); + assert.throws(()=>act(f.game,'choam',{type:'revive',amount:3,elite:0})); + assert.deepEqual(f.game,before); + assert.ok(!f.game.revivalPromises!.find(row=>row.event === f.event)?.released); +}); diff --git a/tests/kull-richese-revival-scope.test.ts b/tests/kull-richese-revival-scope.test.ts new file mode 100644 index 00000000..f9c2390f --- /dev/null +++ b/tests/kull-richese-revival-scope.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { alliancePromiseModeSupported } from '../game/alliance-promises'; +import { revivalPromiseModeSupported } from '../game/revival-promises'; +import { leaderRevivalPromiseModeSupported } from '../game/leader-revival-promises'; +import { revivalExpressionPromiseModeSupported } from '../game/revival-expression-promises'; +import { createGame, joinGame, newPlayer, viewGame } from '../game/engine'; +import { act, advance, clean, createKullRicheseSemutaFixture } from './fixture-kull-richese-semuta'; + +const profile = { + advanced: false, expansions: ['choam', 'ix'], kullPreview: true, semutaPreview: true, + players: [{faction:'choam'}, {faction:'richese'}, {faction:'emperor'}, {faction:'harkonnen'}], +}; + +void test('Basic Richese admits each Revival family with and without printed Kull', () => { + for (const quote of [revivalPromiseModeSupported, leaderRevivalPromiseModeSupported, + revivalExpressionPromiseModeSupported]) { + assert.equal(quote(profile), true); + assert.equal(quote({...profile, kullPreview:false}), true); + assert.equal(quote({...profile, expansions:['ix','choam']}), true); + assert.equal(quote({...profile, expansions:['choam','ix','ecaz'], + players:[...profile.players,{faction:'ecaz'},{faction:'moritani'}]}), true); + for (const unsupported of [ + {...profile, advanced:true}, + {...profile, expansions:['choam']}, + {...profile, expansions:['choam','ix','ix']}, + {...profile, players:profile.players.filter(player=>player.faction!=='choam')}, + {...profile, players:profile.players.filter(player=>player.faction!=='richese')}, + {...profile, players:[...profile.players,{faction:'ecaz'}]}, + {...profile, players:[...profile.players,{faction:'richese'}]}, + ]) assert.equal(quote(unsupported),false); + for (const key of ['homeworlds','nexusCards','leaderSkills','techTokens','strongholdCards', + 'discoveryEnabled','nexusKullPreview','richeseBetrayalPreview','guildBetrayalPreview', + 'nexusIxianReplacementPreview','nexusIxianBetrayalPreview','nexusHarkonnenBetrayalPreview', + 'moritaniAssassinatePreview'] as const) + assert.equal(quote({...profile,[key]:true}),false,key); + } + assert.equal(alliancePromiseModeSupported(profile),false); + assert.equal(alliancePromiseModeSupported({...profile,kullPreview:false}),false); +}); + +function assertRevivalTargets(game: ReturnType) { + const view = viewGame(game,'emperor'); + assert.ok(view.truthRevivalTargets.includes('harkonnen')); + assert.ok(view.truthLeaderRevivalTargets.includes('harkonnen')); + assert.ok(view.truthRevivalExpressionTargets.includes('harkonnen')); + assert.ok(view.truthMixedRevivalTargets.includes('harkonnen')); + assert.deepEqual(view.truthAllianceTargets,[]); +} + +void test('a real Basic Kull and Richese start projects all Revival question families', () => { + const {acquired} = createKullRicheseSemutaFixture(); + assert.ok(acquired.kullPreview && acquired.semutaPreview); + assert.equal(acquired.phase,3); + assertRevivalTargets(acquired); +}); + +void test('a real Basic Richese start with Kull switched off projects the same Revival questions', () => { + let game = createGame('SEMUTAREVIVALSCOPE',newPlayer('choam','CHOAM','choam'),false,['choam','ix']); + for (const id of ['richese','emperor','harkonnen'] as const) joinGame(game,newPlayer(id,id,id)); + game = act(game,'choam',{type:'moduleOption',module:'kullPreview',enabled:false}); + for (const seat of game.players) game = act(game,seat.id,{type:'ready'}); + game = act(game,'choam',{type:'start'}); + game = advance(game,state=>state.status==='playing' && clean(state)); + assert.ok(!game.kullPreview); + assert.equal(game.semutaPreview,true); + assertRevivalTargets(game); +}); diff --git a/tests/kull-richese-revival-table-controls.test.tsx b/tests/kull-richese-revival-table-controls.test.tsx new file mode 100644 index 00000000..f6cecbc3 --- /dev/null +++ b/tests/kull-richese-revival-table-controls.test.tsx @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { registerHooks } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { handLimit, viewGame, type Action, type GameView } from '../game/engine'; +import { act, finishRevival, inventory, player } from './fixture-kull-revival-semuta'; +import { createSemutaRevivalBenefitsForcesFixture } from './fixture-semuta-revival-benefits-forces'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next(specifier === 'next/image' ? 'vinext/shims/image' : + specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); + }, + load(url, context, next) { + if (!url.endsWith('.module.css')) return next(url, context); + const css = readFileSync(new URL(url),'utf8'); + const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1],match[1]])); + return {format:'module',source:`export default ${JSON.stringify(classes)}`,shortCircuit:true}; + }, +}); +const {GameTable} = await import('../components/game-table'); +const {Truthtrance} = await import('../components/truthtrance'); +const {RevivalPromiseGuideButton} = await import('../components/revival-promises'); +aliases.deregister(); + +const table = (game:GameView) => renderToStaticMarkup(createElement(GameTable, + {game,busy:false,send:async () => {},onExit() {}})); +const truth = (game:GameView) => renderToStaticMarkup(createElement(Truthtrance, + {game,busy:false,act() {}})); + +/** The native Basic source actually buys Semuta, ships four CHOAM forces and + * loses them to Storm. Only an original physical Truthtrance is reassigned to + * the asker; no phase, force, purse, pending decision or promise is installed. */ +void test('the Kull and Richese table renders a real Revival question, answer and owned return guide',()=>{ + const source = createSemutaRevivalBenefitsForcesFixture({kind:'choamRevival',advanced:false}); + let game = structuredClone(source.beforeRevival); + assert.ok(game.kullPreview && game.semutaPreview); + assert.equal(player(game,'choam').tanks,4); + const originalCards = inventory(game); + const zones = [game.deck,game.discard,...(game.richeseCache ? [game.richeseCache] : []), + ...(game.richeseRemoved ? [game.richeseRemoved] : []),...game.players.map(seat=>seat.hand)]; + const zone = zones.find(cards=>cards.some(card=>card.effect === 'truthtrance')); assert.ok(zone); + const [card] = zone.splice(zone.findIndex(row=>row.effect === 'truthtrance'),1); + const asker = player(game,'atreides'); assert.ok(asker.hand.length < handLimit(asker)); + asker.hand.push(card); + assert.deepEqual(inventory(game),originalCards); + + game = act(game,'atreides',{type:'card',card:card.id}); + while (game.truthtrance?.stage === 'priority') { + const next = game.players.find(seat=>!game.truthtrance!.passed.includes(seat.id)); assert.ok(next); + game = act(game,next.id,{type:'truthPass'}); + } + const asking = viewGame(game,'atreides'); + assert.ok(asking.truthRevivalTargets.includes('choam')); + assert.match(truth(asking), /