diff --git a/components/game-table.tsx b/components/game-table.tsx index a4fac9b3..84f6e2fc 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -76,6 +76,7 @@ import { BureaucratPayment } from './bureaucrat-payment'; import { Bribes } from './bribes'; import { SukGraduatePanel } from './suk-graduate'; import { NullentropyBox, NullentropySearch } from './nullentropy-box'; +import { SemutaReaction } from './semuta-reaction'; import { OrnithopterMovement } from './ornithopter-movement'; import { DiscoveryOrnithopterMovement } from './discovery-flight-movement'; import { PlanetologistMovement } from './planetologist-movement'; @@ -297,8 +298,9 @@ export function GameTable({ ] : [], ); - const busy = - transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot || g.automaticContinuationPending; + const reactionBusy = + transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot; + const busy = reactionBusy || g.automaticContinuationPending || !!g.semutaReaction; const traitorBattle = g.battle; const traitorBeneficiary = traitorBattle && @@ -1002,6 +1004,8 @@ export function GameTable({ faction(g.players.find((p) => p.id === id)!.faction).name, ) .join(' + ') || 'No winner' + : g.semutaReaction + ? 'Fresh discard response' : g.automaticContinuationPending ? 'Completing the card action' : g.truthtrance @@ -1613,6 +1617,7 @@ export function GameTable({ {g.battle?.revealed && Compare revealed battle plans} + {g.semutaReaction && } diff --git a/components/semuta-reaction.tsx b/components/semuta-reaction.tsx new file mode 100644 index 00000000..d694aa86 --- /dev/null +++ b/components/semuta-reaction.tsx @@ -0,0 +1,94 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { CardInspector, CardRules } from './card-inspector'; +import { Button } from './ui/button'; + +export function SemutaReaction({ + game, + act, + busy, +}: { + game: GameView; + act: (action: Action) => void; + busy: boolean; +}) { + const reaction = game.semutaReaction; + if (!reaction) return null; + + return ( +
+

Fresh Treachery discard

+

+ Response event {reaction.event} +

+ {reaction.stage === 'offer' ? ( + <> +

A Treachery card was just discarded. Continue when you are ready.

+ {reaction.blocked &&

{reaction.blocked}

} + {reaction.passed ? ( +

You continued. Waiting for other seats.

+ ) : ( +
+ + {reaction.canCommit && ( + + )} +
+ )} + + ) : reaction.candidates.length ? ( + <> +

+ You committed Semuta Drug. Choose one eligible freshly discarded + Treachery card to claim. This choice cannot be declined or canceled. +

+
+ {reaction.candidates.map((card) => ( +
+
{card.name}
+

Card ID: {card.id}

+ + + +
+ ))} +
+ + ) : ( +

Waiting for the Semuta Drug claim to finish.

+ )} +
+ ); +} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 0e718485..a07560e2 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -6,6 +6,19 @@ Expansion starts and full rules/publication acceptance remain gated.** ## Current checkpoint and work +The explicit [Semuta development preview](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime) +now pauses a clean public ordinary-card discard with the same neutral +offer for every seat, independently of hidden Semuta possession. A +holder with a free hand slot can commit to the one fresh other-player +card; its physical claim and Semuta disposal resume the original +effect once. All-seat decline, four minimal legal AI profiles, exact +JSON/SQLite continuation and competing-room CAS are connected. Phone +width showed the owned Commit control and the identical rival Continue +event without a private card face. Multi-card and other discard +producers, full-hand ordering, normal Richese starts and deployed +acceptance remain unfinished; the neutral passes and free-slot guard +are product safety choices, not publisher timing rulings. + One [natural Advanced Moritani assassination](MORITANI_ASSASSINATE_LEADERS.md#29-september-2026--natural-revealed-card-game) now proceeds from genuine battle loss to a dead-target Traitor reveal, face-up retirement and private Mentat replacement. The six-seat game diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 09c1f8a4..de8ff902 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,48 @@ # Dune implementation status +## 30 September 2026 — neutral Semuta clean-discard prototype + +The [bounded runtime](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime) +uses an explicit fresh Richese development profile. A real Hajr discard +after genuine setup opens an identical public event for all seats; +only the actual holder sees Commit, no one inspects a fresh face +before commitment, and the sole eligible other-player card transfers +automatically with Semuta's physical disposal. All-seat passes preserve +the card and effect. Free hand space is a conservative, provisional +guard. Other discard producers and full-hand sequencing remain +unfinished; normal Richese/public starts remain closed. + +Focused engine cases preserve exact custody through JSON, hide possession +under an equal-card-count private swap, reject stale/forged actions and +corrupt saved reactions, and complete each original effect once. +An isolated production room module with migrated SQLite restored the +same owner-private offer and committed one of two competing Semuta +requests under room CAS; the fixture's seat rows remained intact. +The actual `start-prototype.ts --profile semuta` CLI started a fresh +disposable ready lobby with its seat preserved. Mobile browser GET +fixtures showed the owned Commit button and rival Continue-only panel +at the same event; no browser POST or real local room was mutated. + +Independent privacy review found no scoped leak. Rules review identified +and prompted fixes for a deleted-reaction auto-retire, duplicate +Semuta custody, a suspended CHOAM sale crossing the clean gate, and a +forged all-passed deadlock. Deleted-reaction, duplicate-card and +all-passed corruption each have failing-before/passing-after regressions; +the CHOAM sale exclusion was source-reviewed without a direct fixture. +A final read-only review found no remaining scoped issue. Focused +Semuta/ordinary-continuation and room-start tests pass; full-game and +deployed acceptance do not follow from this bounded preview. + +An unstaged six-seat Basic Richese preview at RNG seed `20260930` +completed 916 accepted actions, zero rejected candidates, 24 JSON view +restores and six neutral passes at one ordinary-card discard. No +natural Semuta acquisition/commitment occurred in that game; the +focused genuine-setup and production-D1 fixtures cover the committed +claim. `npm run check` passes typecheck, lint and 5,801/5,801 offline +cases; `npm run build` and all 55/55 local development HTTP tests pass. +These results and one complete sampled game do not certify the +remaining card sources, full Richese mode or deployment. + ## 30 September 2026 — owner key rotation and safe re-enable The [account panel](ADMIN_ACCOUNTS.md) now rotates another enabled or diff --git a/docs/RICHESE_COMPONENTS.md b/docs/RICHESE_COMPONENTS.md index d3215311..1dc5dccb 100644 --- a/docs/RICHESE_COMPONENTS.md +++ b/docs/RICHESE_COMPONENTS.md @@ -104,3 +104,14 @@ Five physical effects now have bounded development handlers: Karama, Distrans, N ## Portable Snooper and retention clarification (2026-09-06) Six effects now have bounded development handlers: Karama, Distrans, Nullentropy Box, Ornithopter, Residual Poison and Portable Snooper. Portable supports normal and late poison defense with separate public played-card custody and ordinary winner/Moritani retention. The November 2020 official FAQ corrects the earlier overly strict assumption that a discard footer always denies winner retention; Mirror’s copied mandatory-discard exception remains a separate pending ruling. Four effects and complete combined games remain unfinished. See [PORTABLE_SNOOPER_ENGINE_AUDIT.md](PORTABLE_SNOOPER_ENGINE_AUDIT.md) and [MIRROR_WEAPON_ENGINE_AUDIT.md](MIRROR_WEAPON_ENGINE_AUDIT.md). + +## 30 September 2026 — Semuta clean-discard preview + +The [bounded Semuta runtime](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime) +supersedes the older inactive-card observations above for one +explicitly opted-in Richese development profile. Neutral all-seat +responses, physical one-card recovery, a provisional free-slot guard, +minimal legal AI and saved continuation now work after clean public +ordinary-card discards. The other semantic discard families and +full-hand ordering are not connected; the ten-card inventory, cache +custody and public expansion start gate remain unchanged. diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 3ac3723c..43682bf1 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -58,3 +58,30 @@ Useful acceptance test: hold public state and clock/event progression fixed; rep The existing unresolved **Guild-stopped shipping-Karama refund** also constrains claiming that exact provisionally refundable card. Do not ask it again as a new Semuta question. Fence its competing custody or resolve the existing underlying ruling; this does not prohibit every shipment-phase Semuta event. Current Box guard in `game/engine.ts` near `:1209` already identifies this composition. Do not ask again about normal targets, forced-discard owner bookkeeping, simultaneous selection, zero fee, automatic singleton selection or semantic batch implementation. Do not repeat the three pending Sapho questions. No new authoritative capacity/priority ruling was obtained by this review. + +## 30 September 2026 — bounded clean-discard runtime + +The explicit `semuta` development profile starts a fresh Richese roster +without opening ordinary expansion starts. It now interrupts **one public +ordinary-card discard** from a clean transaction with a neutral +fresh-discard opportunity for every seat, regardless of hidden Semuta +possession. Each seat can Continue; only the canonical holder with a +free hand slot and a card discarded by another player can commit. The +sole eligible fresh card transfers automatically in the same accepted +action, then Semuta goes to the normal discard pile. The saved ordinary +card effect resumes once; passing by every seat preserves the original +discard and effect. No face from an undisclosed event is projected before +commitment. Owner-only multi-candidate selection support exists in the +reaction component and bot adapter, but no multi-discard producer is +activated in this first boundary. + +This neutral all-seat acknowledgement is a conservative **application +policy**, not a newly discovered publisher timing rule. The explicit +free-slot requirement is a provisional safety guard, not the printed +resolution of full-hand take-before-discard. Auction, CHOAM market, +pending gifts, competing responses and other typed discard producers +retain their existing automatic continuation; Semuta cannot claim those +events yet. No source ruling is inferred from this omission. The +separate [reaction privacy audit](SEMUTA_PRIVACY_REVIEW.md) explains why +auto-closing only when nobody secretly holds the card would leak +possession. The main game and public expansion gates remain closed. diff --git a/game/bot-semuta.ts b/game/bot-semuta.ts new file mode 100644 index 00000000..15916ff0 --- /dev/null +++ b/game/bot-semuta.ts @@ -0,0 +1,28 @@ +import type { Action, GameView } from './engine'; +import { SEMUTA_DRUG_ID } from './semuta-drug'; + +/** The offer is public, but only this seat's hand can justify committing. The + * fresh discard faces are never inspected until the private select stage. */ +export function botSemutaActions(g: GameView): Action[] { + const reaction = g.semutaReaction; + if (g.status !== 'playing' || !reaction?.event) return []; + + if (reaction.stage === 'select') { + // Only the committed claimant receives candidates. A mandatory selection + // must still complete when no strategic preference is available. + const choice = reaction.candidates.find((card) => card.id); + return choice + ? [{ type: 'semutaSelect', event: reaction.event, card: choice.id }] + : []; + } + + if (reaction.stage !== 'offer' || reaction.passed) return []; + const own = g.players.find((player) => player.id === g.me); + const hasSemuta = own?.hand?.some( + (card) => card.id === SEMUTA_DRUG_ID && + card.kind === 'special' && card.effect === 'semutaDrug', + ); + if (reaction.canCommit && !reaction.blocked && hasSemuta) + return [{ type: 'semutaCommit', event: reaction.event }]; + return [{ type: 'semutaPass', event: reaction.event }]; +} diff --git a/game/bots.ts b/game/bots.ts index 28f735e3..0a5a2551 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -101,6 +101,7 @@ import { sandmasterWormCollection } from './sandmaster-worm'; import { richeseCardDefinition } from './richese-cards'; import { presenceAt } from './force-presence'; import { botArrivalBlock, botWormRideArrivalBlock } from './bot-arrival'; +import { botSemutaActions } from './bot-semuta'; import { validateCohortSelection, type OrnithopterMode } from './ornithopter'; import { fighterCount, isAdvisor } from './advisors'; import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat'; @@ -3822,6 +3823,7 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null { /** Obligations apply across policy branches, including choosing to move first. */ export function botActions(g: GameView): Action[] { + if (g.semutaReaction) return botSemutaActions(g); const fremenRevival = g.nexusFremenRevival; if (fremenRevival && !fremenRevival.blocked && fremenRevival.eliteOptions.length) { const own = g.players.find(player => player.id === g.me); diff --git a/game/card-presentation.ts b/game/card-presentation.ts index 41cce263..86e355bb 100644 --- a/game/card-presentation.ts +++ b/game/card-presentation.ts @@ -39,6 +39,8 @@ export function richeseCardActionBlock(card: VisibleCard): string | null { return 'Use the Juice of Sapho panel to choose an available order change or become battle aggressor before plans.'; if (definition?.card.effect === 'distrans') return 'Choose a recipient and card in the Distrans transfer panel.'; + if (definition?.card.effect === 'semutaDrug') + return 'Use the neutral fresh-discard response in the Semuta development preview. Only clean public ordinary-card discards are connected; other discard events remain unfinished.'; return definition && definition.card.effect !== 'karama' ? 'This Richese card’s effect is not implemented yet. You can inspect its rules.' : null; @@ -89,6 +91,8 @@ export function cardPresentation(card: VisibleCard): CardPresentation { availability: richese.card.effect === 'juiceOfSapho' ? 'Richese effect integration and verification are incomplete. Supported controls cover Once Around first or last while your bid remains open, bounded movement and battle-choice ordering, and aggressor during the shared pre-plan opportunity. Earlier bids stay committed. Later aggressor intervention and other phase or auction modes remain unfinished; expansion starts remain disabled.' + : richese.card.effect === 'semutaDrug' + ? 'The explicit Semuta development preview offers neutral responses after clean public ordinary-card discards, with owner-only commitment and a conservative free-slot requirement. Other discard producers, full-hand sequencing and normal expansion starts remain unfinished.' : richese.card.effect === 'mirrorWeapon' ? 'Mirror Weapon works in bounded CHOAM/Richese-deck classic-faction battles; copied Tooth and Stone choices resolve separately, and a winning physical Mirror may be kept even after copying Tooth or Artillery. This winner-retention treatment is a user-selected interpretation, not an official clarification. Combined modes and full Richese starts remain gated.' : richese.card.effect === 'stoneBurner' diff --git a/game/engine.ts b/game/engine.ts index db2b42db..a9c56d9d 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -377,7 +377,7 @@ import { resolveNullentropyBox, } from './nullentropy-box'; import { transferDistrans } from './distrans'; -import type { FreshDiscardBatch } from './semuta-drug'; +import { SEMUTA_DRUG_ID, committedSemutaCandidates, resolveSemutaDrug, type FreshDiscardBatch, type SemutaContext } from './semuta-drug'; import { prepareRicheseGift, transferRicheseGift, @@ -1105,6 +1105,9 @@ export type Game = { pendingTreacheryDiscard?: { sequence: number; batch: FreshDiscardBatch; + reaction?: + | { stage: 'offer'; passed: string[] } + | { stage: 'select'; player: string; candidates: string[] }; continuation: | { kind: 'ambassador'; entry: NonNullable } | { kind: 'discoveryStash'; event: string; owner: string; card: string } @@ -1682,6 +1685,8 @@ export type Game = { mentatQuestionPreview?: true; /** Private development opt-in while uniform hidden-eligibility UX is pending. */ moritaniAssassinatePreview?: true; + /** Explicit development-only neutral Semuta reaction profile. */ + semutaPreview?: true; moritaniAssassinate?: MoritaniAssassinateState; moritaniAssassinateCallEvents?: string[]; moritaniAssassinateResume?: { @@ -4838,6 +4843,11 @@ function suspendedControlsIntegrity( auditorIntegrity(context); } function treacheryDiscardIntegrity(g: Game) { + requireRule(g.semutaPreview === undefined || g.semutaPreview === true && + g.expansions.includes('choam') && !!byFaction(g, 'richese') && + !g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.discoveries && + !g.discoveryEnabled && !g.techTokens && !g.strongholdCards, + 'This Semuta preview no longer matches its original Richese profile.'); const pending = g.pendingTreacheryDiscard; const sequence = g.treacheryDiscardSequence ?? 0; const resolved = g.resolvedTreacheryDiscardSequence ?? 0; @@ -4910,6 +4920,32 @@ function treacheryDiscardIntegrity(g: Game) { ); ids.add(card.id); } + const reaction = pending.reaction; + requireRule(reaction !== undefined || !semutaOfferSupported(g, continuation, batch.entries), + 'The saved fresh discard lost its neutral Semuta reaction.'); + if (reaction !== undefined) { + requireRule(semutaOfferSupported(g, continuation, batch.entries), + 'This saved Semuta opportunity has no supported fresh discard.'); + const physical = physicalTreacheryCards(g).filter(card => card.id === SEMUTA_DRUG_ID); + requireRule(physical.length === 1 && + richeseCardDefinition(physical[0])?.card.effect === 'semutaDrug', + 'The saved Semuta reaction lost unique physical card custody.'); + if (reaction.stage === 'offer') { + requireRule(Object.keys(reaction).sort().join(',') === 'passed,stage' && + Array.isArray(reaction.passed) && + new Set(reaction.passed).size === reaction.passed.length && + reaction.passed.length < g.players.length && + reaction.passed.every(id => g.players.some(p => p.id === id)), + 'The neutral Semuta passes lost their original participants.'); + } else if (reaction.stage === 'select') { + const owner = g.players.find(p => p.id === reaction.player); + requireRule(Object.keys(reaction).sort().join(',') === 'candidates,player,stage' && + owner && Array.isArray(reaction.candidates) && reaction.candidates.length >= 1 && + JSON.stringify(reaction.candidates) === + JSON.stringify(semutaCandidates(g, owner).map(card => card.id)), + 'The committed Semuta claim lost its private fresh candidates.'); + } else throw new RuleError('Unknown saved Semuta reaction stage.'); + } if (continuation?.kind === 'nullentropyDiscard') { const c = continuation; const owner = g.players.find((p) => p.id === c.player); @@ -5606,6 +5642,71 @@ function treacheryDiscardIntegrity(g: Game) { 'The committed Kaitain discard no longer matches its payment and closing opportunity.'); } else throw new RuleError('Unknown saved discard continuation.'); } +/** First playable Semuta boundary: one public ordinary-card discard with no + * competing suspended transaction. Other typed frames retain their existing + * automatic continuation until their reaction composition is integrated. */ +function semutaOfferSupported( + g: Game, + continuation: NonNullable['continuation'], + entries: FreshDiscardBatch['entries'], +) { + return g.semutaPreview === true && g.status === 'playing' && + g.players.some(p => p.faction === 'richese') && + continuation.kind === 'ordinaryCardDiscard' && + entries.length === 1 && entries[0].publicFace && + !continuation.resume.response && !continuation.resume.decision && + !continuation.resume.pendingKarama && !continuation.resume.phaseOpening && + !g.karamaShipping && !g.auction && !g.richeseAuction && !g.battle && + !g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy && + !g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingShipment && + !g.pendingHomeworldShipment && !g.pendingRicheseGift && + !g.pendingRichesePurchaseIncome && !g.choamMarket && + !g.pendingChoamMarketGhola; +} +function semutaContext(g: Game, owner: Player): SemutaContext { + const pending = g.pendingTreacheryDiscard!; + return { + batch: pending.batch, event: pending.batch.event, turn: g.turn, phase: g.phase, + owner: owner.id, ownerHand: owner.hand, discard: g.discard, + semutaId: SEMUTA_DRUG_ID, handLimit: handLimit(owner), + incomingReservedSlots: 0, capacityPolicy: 'freeSlot', reservedTargetIds: [], + }; +} +function semutaCandidates(g: Game, owner: Player) { + const context = semutaContext(g, owner); + try { + return committedSemutaCandidates(context, + { event: context.event, player: owner.id, semutaId: SEMUTA_DRUG_ID }); + } catch { + throw new RuleError('This fresh Semuta claim is not available.'); + } +} + +function projectedSemutaReaction(g: Game, me: Player) { + const pending = g.pendingTreacheryDiscard; + const reaction = pending?.reaction; + if (!pending || !reaction) return null; + if (reaction.stage === 'select') + return { + event: pending.batch.event, stage: 'select' as const, passed: true, + canCommit: false, blocked: null, + candidates: reaction.player === me.id ? semutaCandidates(g, me) : [] as Card[], + }; + const passed = reaction.passed.includes(me.id); + const held = me.hand.some(card => card.id === SEMUTA_DRUG_ID && + richeseCardDefinition(card)?.card.effect === 'semutaDrug'); + const blocked = !held || passed ? null + : me.hand.length >= handLimit(me) + ? 'A free hand slot is required before taking a card with Semuta Drug.' + : pending.batch.entries.every(entry => entry.discardedBy === me.id) + ? 'No other player discarded a card in this fresh event.' + : null; + return { + event: pending.batch.event, stage: 'offer' as const, passed, + canCommit: held && !passed && !blocked, blocked, candidates: [] as Card[], + }; +} + function stageTreacheryDiscard( g: Game, cause: string, @@ -5625,20 +5726,38 @@ function stageTreacheryDiscard( cause, entries: structuredClone(entries), }, + ...(semutaOfferSupported(g, continuation, entries) + ? { reaction: { stage: 'offer' as const, passed: [] as string[] } } : {}), continuation: structuredClone(continuation), }; treacheryDiscardIntegrity(g); } -/** No reaction policy is selected here. Semuta activation remains unavailable. - * The existing exchanges resume automatically from their exact committed stage. */ -function finishTreacheryDiscard(g: Game) { +/** Retire a completed fresh discard before its typed continuation resumes. */ +function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string }, finalPass?: string) { treacheryDiscardIntegrity(g); const pending = g.pendingTreacheryDiscard; if (!pending) return; const next = pending.continuation; - // Retire before any suffix can draw or create the next semantic discard. + requireRule(!pending.reaction || + (claim ? pending.reaction.stage === 'select' && + pending.reaction.player === claim.player && + pending.reaction.candidates.includes(claim.card) : + pending.reaction.stage === 'offer' && finalPass !== undefined && + pending.reaction.passed.length === g.players.length - 1 && + g.players.some(p => p.id === finalPass) && + !pending.reaction.passed.includes(finalPass)), + 'Finish the fresh discard reaction before its continuation.'); + const owner = claim ? getPlayer(g, claim.player) : null; + const result = claim && owner ? resolveSemutaDrug(semutaContext(g, owner), claim.card) : null; + // Retire before the Semuta transfer or any suffix can draw the fresh card. g.resolvedTreacheryDiscardSequence = pending.sequence; g.pendingTreacheryDiscard = null; + if (result && owner) { + owner.hand = result.ownerHand; + g.discard = result.discard; + log(g, `${owner.name} played Semuta Drug and recovered one freshly discarded Treachery Card. The acquired card stays private.`, + { faction: owner.faction, name: 'Semuta Drug' }); + } if (next.kind === 'winnerMandatoryDiscard') { finishWinner(g, getPlayer(g, next.player), next.territory, next.optional); return; @@ -5722,6 +5841,40 @@ function finishTreacheryDiscard(g: Game) { finishBattle(g); } } + +function applySemutaReaction(g: Game, id: string, action: Action) { + const pending = g.pendingTreacheryDiscard; + const reaction = pending?.reaction; + requireRule(pending && reaction && action.event === pending.batch.event, + 'This fresh discard reaction is no longer available.'); + const player = getPlayer(g, id); + if (reaction.stage === 'offer') { + requireRule(!reaction.passed.includes(id), 'You already passed this discard.'); + if (action.type === 'semutaPass') { + requireRule(Object.keys(action).sort().join(',') === 'event,type', + 'Pass only the current fresh discard.'); + if (reaction.passed.length + 1 === g.players.length) + finishTreacheryDiscard(g, undefined, id); + else reaction.passed.push(id); + return; + } + requireRule(action.type === 'semutaCommit' && + Object.keys(action).sort().join(',') === 'event,type', + 'Choose whether to commit Semuta to this discard.'); + const candidates = semutaCandidates(g, player).map(card => card.id); + pending.reaction = { stage: 'select', player: id, candidates }; + if (candidates.length === 1) finishTreacheryDiscard(g, { player: id, card: candidates[0] }); + else log(g, `${player.name} committed Semuta Drug to this fresh discard and must choose one card privately.`, + { faction: player.faction, name: 'Semuta Drug' }); + return; + } + requireRule(action.type === 'semutaSelect' && + Object.keys(action).sort().join(',') === 'card,event,type' && + id === reaction.player && typeof action.card === 'string' && + reaction.candidates.includes(action.card), + 'Choose one committed Semuta card from this fresh discard.'); + finishTreacheryDiscard(g, { player: id, card: action.card }); +} function place(p: Player, t: string, s: number, n: number, elite = 0) { const k = location(t, s); p.forces[k] = (p.forces[k] ?? 0) + n; @@ -7013,6 +7166,15 @@ export function initializeMoritaniAssassinateGameForAudit(state: Game): Game { g.moritaniAssassinateCallEvents = []; return g; } +/** Development-only Semuta reaction in a fresh Richese roster; public starts stay gated. */ +export function initializeSemutaGameForAudit(state: Game): Game { + requireRule(!state.semutaPreview && state.expansions.length === 1 && + state.expansions[0] === 'choam' && state.players.some(p => p.faction === 'richese'), + 'Semuta needs a fresh Richese faction game without other modules.'); + const g = initializeFactionExpansionsGameForAudit(state); + g.semutaPreview = true; + return g; +} /** Prototype-only Discovery setup. Public starts stay gated while remaining effects are connected. */ export function initializeDiscoveryGameForAudit(state: Game): Game { requireRule(state.discoveryEnabled === true && !state.discoveries && !state.discoveryStash && !state.greatMaker, @@ -22966,6 +23128,13 @@ export function applyAction(state: Game, id: string, action: Action): Game { } if (state.pendingTreacheryDiscard) { getPlayer(state, id); + if (state.pendingTreacheryDiscard.reaction && + ['semutaPass', 'semutaCommit', 'semutaSelect'].includes(action?.type)) { + const g = structuredClone(state); + applySemutaReaction(g, id, action); + treacheryDiscardIntegrity(g); + return g.pendingTreacheryDiscard?.reaction ? g : normalizeAutomaticGame(g); + } requireRule( action?.type === 'advanceBots', 'Finish the committed discard continuation before another game action.', @@ -23064,6 +23233,10 @@ export function applyAction(state: Game, id: string, action: Action): Game { } } finishActionContinuations(g); + if (g.pendingTreacheryDiscard?.reaction) { + treacheryDiscardIntegrity(g); + return g; + } harkonnenExchangeIntegrity(g); finishLeaderSkillCustody(g, state); leaderSkillsIntegrity(g); @@ -23099,7 +23272,9 @@ function finishActionContinuations(g: Game) { if (g.pendingNullentropy) return; // Losing cleanup may finish casualties and produce the winner's mandatory // discard. Retire that next physical batch before exposing optional choices. - for (let i = 0; g.pendingTreacheryDiscard && i < 16; i++) finishTreacheryDiscard(g); + for (let i = 0; g.pendingTreacheryDiscard && !g.pendingTreacheryDiscard.reaction && i < 16; i++) + finishTreacheryDiscard(g); + if (g.pendingTreacheryDiscard?.reaction) return; requireRule(!g.pendingTreacheryDiscard, 'The automatic discard chain did not finish.'); if (pendingNexusTraitors(g)) return; observeOccupation(g); @@ -23233,6 +23408,10 @@ export function normalizeAutomaticGame(state: Game): Game { normalizeCardNames(g); normalizeBattle(g); finishActionContinuations(g); + if (g.pendingTreacheryDiscard?.reaction) { + treacheryDiscardIntegrity(g); + return g; + } reconcileBattlePromises(g); reconcileShipmentPromises(g); settleAutomaticContinuations(g); @@ -27144,7 +27323,8 @@ export function viewGame(state: Game, id: string) { : null, schema: g.schema, botsPending: g.botsPending ?? false, - automaticContinuationPending: !!g.pendingTreacheryDiscard || homeworldRevealPending(g) || homeworldShipmentAutomatic(g) || grummanCollectionAutomatic(g), + semutaReaction: projectedSemutaReaction(g, me), + automaticContinuationPending: (!!g.pendingTreacheryDiscard && !g.pendingTreacheryDiscard.reaction) || homeworldRevealPending(g) || homeworldShipmentAutomatic(g) || grummanCollectionAutomatic(g), botNextActionAt: g.botNextActionAt ?? null, code: g.code, version: g.version, diff --git a/game/reference.ts b/game/reference.ts index f9885c3f..7ad7e34e 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -414,7 +414,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'A player whose hand becomes full is automatically passed, or submits zero in an unfinished Silent lot. A full-hand owner’s unbid cache card is removed automatically. If every hand is full, preparing the normal pool does not draw and lose unseen cards.', 'AI supports cache choices and all three offered bidding methods using its own projected information. It currently skips the optional Black Market prelude. A seller in an already offered Black Market lot passes or submits zero.', 'Some unresolved rules are explicitly blocked: changing the normal card count after cache-auction cancellation, exhausted-cache arithmetic, positive Black Market self-bids, and advanced Ixian Technology substitution on special lots. These are implementation guards while the rules are resolved, not claims of official prohibitions.', - 'Juice of Sapho now supports bounded first/last Once Around, movement and remaining battle-choice ordering; see its timing guide for current limits. Richese’s Karama uses the existing generic handler, Distrans supports a separate private hand transfer, Nullentropy Box supports a paid private discard search, Ornithopter supports its two movement modes, and Residual Poison supports a random opposing leader death before leader commitment in development fixtures. Portable Snooper supports ordinary and late poison defense. Stone Burner supports a guarded weapon commitment and revealed leader-effect choice. Mirror Weapon copies opposing revealed attacks, with independent copy-first choices for Tooth and Stone in bounded CHOAM/Richese-deck classic-faction battles. A winning physical Mirror may be kept even after copying activated Tooth or Artillery under the user-selected interpretation, not a publisher clarification. Semuta Drug and other Sapho timing modes remain unfinished; this does not certify every Karama interaction. Richese expansion starts remain disabled. Each component guide identifies its currently available action. Independent continuation and persisted-room checks pass. Desktop checks confirmed all ten enlarged guides, direct topic links, Once Around payment and restored private Silent bidding. Mobile and full Richese-game verification remain unfinished.', + 'Juice of Sapho now supports bounded first/last Once Around, movement and remaining battle-choice ordering; see its timing guide for current limits. Richese’s Karama uses the existing generic handler, Distrans supports a separate private hand transfer, Nullentropy Box supports a paid private discard search, Ornithopter supports its two movement modes, and Residual Poison supports a random opposing leader death before leader commitment in development fixtures. Portable Snooper supports ordinary and late poison defense. Stone Burner supports a guarded weapon commitment and revealed leader-effect choice. Mirror Weapon copies opposing revealed attacks, with independent copy-first choices for Tooth and Stone in bounded CHOAM/Richese-deck classic-faction battles. A winning physical Mirror may be kept even after copying activated Tooth or Artillery under the user-selected interpretation, not a publisher clarification. Semuta Drug has a bounded clean public ordinary-discard preview; other discard producers and Sapho timing modes remain unfinished. This does not certify every Karama interaction. Richese expansion starts remain disabled. Each component guide identifies its currently available action. Independent continuation and persisted-room checks pass. Desktop checks confirmed all ten enlarged guides, direct topic links, Once Around payment and restored private Silent bidding. Mobile and full Richese-game verification remain unfinished.', ], related: [ 'choam-modules', @@ -625,7 +625,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'The server checks alliance, exact card ownership, capacity and commitments again before transfer. An obsolete pending gift aborts without a partial transfer and restores its interrupted flow. Unrelated players see the gift power, not its private card identity. A pending card cannot also be spent, exchanged or randomly extracted.', 'After cancellation, trying the same card again in the same phase is currently blocked pending a ruling on repeated use. This is an explicit implementation guard, not a printed once-per-phase limit on all gifts. Other legally available cards are assessed separately.', 'All four AI profiles conservatively give only the canonical Richese Karama during Bidding to free a full hand when the ally’s public card count is lower and there is room. They do not inspect ally hand contents or infer an unknown need for a card. Pending responses, decisions and Truthtrance take priority.', - 'All ten canonical Richese cards may be transferred when legal, and Karama has its existing card-effect handler. Distrans supports its separate card-transfer effect, Nullentropy Box supports paid private discard search, Ornithopter supports its two movement modes, and Residual Poison supports a random opposing leader death before leader commitment. Portable Snooper supports ordinary and late poison defense. Stone Burner supports a guarded weapon commitment and revealed leader-effect choice. Mirror Weapon copies an opposing revealed weapon in bounded CHOAM/Richese-deck classic-faction battles, with copy-first Tooth/Stone choices; physical-Mirror winner retention even after copied activated Tooth/Artillery is a user-selected interpretation. Juice of Sapho supports bounded Once Around and movement ordering. Semuta Drug and other Sapho timing modes remain unfinished. Desktop gift transfer and fresh-tab pending recovery have been checked in a synthetic table. Mobile, broader interactions and full Richese games remain pending; Richese starts remain disabled.', + 'All ten canonical Richese cards may be transferred when legal, and Karama has its existing card-effect handler. Distrans supports its separate card-transfer effect, Nullentropy Box supports paid private discard search, Ornithopter supports its two movement modes, and Residual Poison supports a random opposing leader death before leader commitment. Portable Snooper supports ordinary and late poison defense. Stone Burner supports a guarded weapon commitment and revealed leader-effect choice. Mirror Weapon copies an opposing revealed weapon in bounded CHOAM/Richese-deck classic-faction battles, with copy-first Tooth/Stone choices; physical-Mirror winner retention even after copied activated Tooth/Artillery is a user-selected interpretation. Juice of Sapho supports bounded Once Around and movement ordering. Semuta Drug has a bounded clean ordinary-discard preview; other discard producers and Sapho timing modes remain unfinished. Desktop gift transfer and fresh-tab pending recovery have been checked in a synthetic table. Mobile, broader interactions and full Richese games remain pending; Richese starts remain disabled.', ], related: [ 'richese-cards', @@ -690,7 +690,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'A transfer must preserve binding battle answers, compulsory discards and both sides of a pending Harkonnen hand exchange. The recipient must retain room for its mandatory incoming return. Optional proposals that become unavailable can still finish through their existing decline or no-effect path.', 'A different legal transfer can fill or free space for a pending Richese gift. The ordinary Distrans transfer resolves first, and the pending gift rechecks capacity before moving its own card. A canceled alliance gift does not automatically prevent using this separate card effect.', 'All four AI profiles use their own legal projected cards. They may give an allied Bene Gesserit or CHOAM a Worthless card, or share a duplicate ordinary weapon, defense or Karama to free a full hand. They do not inspect the recipient’s private cards and preserve existing decision priority.', - 'Semuta discard reactions, mobile acceptance, the unresolved bid and self-transfer cases, and full combined Richese games remain unfinished. Completing this transfer does not enable all remaining Richese cards or expansion starts.', + 'Semuta responses to a fresh Distrans discard, mobile acceptance, the unresolved bid and self-transfer cases, and full combined Richese games remain unfinished. The separate clean public ordinary-card Semuta preview does not enable this transfer composition, all remaining Richese cards or expansion starts.', ], related: [ 'card-richese-distrans', @@ -704,7 +704,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'Synchronous two-card custody, private transfer, normal Distrans disposal, both-card reservations and prospective transaction/promise checks are integrated. Open-lot timing and self-transfer remain explicit guards; future Semuta reactions are unfinished.', + 'Synchronous two-card custody, private transfer, normal Distrans disposal, both-card reservations and prospective transaction/promise checks are integrated. Open-lot timing and self-transfer remain explicit guards; Semuta reaction to Distrans disposal is not yet integrated.', }, { area: 'Player controls', @@ -941,7 +941,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'An active Truthtrance question and exact card or transaction commitments must resolve as required. A full hand is currently guarded: the printed sequence adds the recovered card before discarding the Box, and the temporary capacity question is unresolved. The current implementation requires a pre-existing free hand slot. This is not a verified prohibition on every net-zero exchange.', 'A pending Guild refund claim on a discarded shipping Karama also blocks starting the search while that provisional refund policy is unresolved. The guard protects the competing claim to that card; it is not a general ban on Box during Shipment. Empty or only-Box searches are rejected before payment under the current unsupported-empty-search policy; there is no fallback deck draw.', 'All four AI profiles start only when their own projected availability and spice reserve allow it. They do not inspect unpaid discard contents. Once paid, they choose from their entitled cards, prefer existing functional effects over unfinished Richese faces and complete a legal selection even if only unfinished effects remain. A paid search takes priority over optional actions.', - 'This bounded search, its controls and recovery are integrated in development fixtures. Full-hand activation, the provisional Guild refund interaction and future Semuta discard reactions remain unresolved or unfinished. Mirror Weapon has a separate bounded CHOAM/Richese-deck classic-faction battle path; Semuta Drug remains inactive, and full Richese starts remain disabled. Complete browser, mobile and full-game acceptance is not claimed here.', + 'This bounded search, its controls and recovery are integrated in development fixtures. Full-hand activation, the provisional Guild refund interaction and Semuta response to the used Box remain unresolved or unfinished. Mirror Weapon has a separate bounded CHOAM/Richese-deck classic-faction battle path; Semuta Drug has a separate clean public ordinary-card preview, and full Richese starts remain disabled. Complete browser, mobile and full-game acceptance is not claimed here.', ], related: [ 'card-richese-nullentropy-box', @@ -1154,6 +1154,8 @@ export const RULE_TOPICS: RuleTopic[] = [ ? 'Choose Mirror Weapon in the battle weapon slot in a supported CHOAM/Richese-deck table. Its attack copies the opposing revealed weapon; copied Tooth or Stone choices resolve first and independently. Under the user-selected interpretation, a winning physical Mirror may be retained even after a copied Tooth or Artillery attack. Full Richese starts and combined modules remain guarded.' : definition.card.effect === 'portableSnooper' ? 'Choose Portable Snooper as your ordinary poison defense, or use the late-defense panel after reveal before your own traitor decision. It uses ordinary winner retention and cannot stop Poison Tooth.' + : definition.card.effect === 'semutaDrug' + ? 'In an explicitly opted-in Richese development game, every seat sees a neutral response after a clean public ordinary-card discard. Continue or commit your held Semuta with a free hand slot; the sole eligible card transfers automatically. Other discard producers, full-hand sequencing and normal Richese starts remain unfinished.' : 'Reference component only: this Richese card’s game actions are not enabled.', ], ...(definition.card.effect === 'semutaDrug' @@ -1163,32 +1165,36 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation' as const, status: 'Partial' as const, detail: - 'Fresh-card recovery has isolated transfer rules. Ixian and CHOAM exchanges, battle cleanup, Sabotage, Robbery, the used Nullentropy Box, retired Ornithopter, answered Truthtrance and completed ordinary card effects preserve unfinished steps for recovery. Random victims, completed draws and the paid Box shuffle are not repeated. Semuta activation and the remaining discard timings are unfinished.', + 'An explicitly opted-in Richese profile now interrupts clean public ordinary-card discards with a neutral all-seat offer. Committing a held Semuta atomically claims the sole eligible other-player card, discards Semuta and resumes the saved ordinary-card suffix once. Other typed discard producers and full-hand exchange remain unfinished.', }, { area: 'Player controls' as const, status: 'Partial' as const, detail: - 'The complete card can be inspected. Playing Semuta, declining its opportunity and choosing a fresh discard are not enabled.', + 'All seats can Continue at the neutral fresh-discard boundary; only the actual holder sees Commit. A committed multi-card selection control is available when that later producer family is integrated. Normal Richese starts remain gated.', }, { area: 'AI' as const, status: 'Partial' as const, detail: - 'AI resumes saved exchanges, battle cleanup, forced Terror discards, completed Box searches, retired Ornithopter movement, answered Truthtrance queues and completed ordinary card effects automatically. No difficulty can yet play Semuta or choose its claimed card.', + 'All four AI profiles use their own projected hand to commit or pass the exact current event, and can choose only privately projected committed candidates. This is minimal legal participation, not tuned strategy.', }, { area: 'Documentation' as const, status: 'Partial' as const, detail: - 'The guide explains one fresh other-player discard and selection from simultaneous discards. Reaction timing and full-hand use remain unresolved.', + 'The guide distinguishes the bounded public ordinary-card prototype, privacy-neutral acknowledgements and conservative free-slot policy from unresolved full-hand sequencing and remaining discard sources.', }, { area: 'Verification' as const, status: 'Partial' as const, detail: - 'Isolated transfer, private exchange, battle, Box, Ornithopter, Truthtrance and ordinary-card recovery, and concurrent settlement checks pass. These do not verify an actual Semuta opportunity or a complete Richese game.', + 'Genuine setup, physical card custody, neutral hidden-hand parity, saved JSON, authenticated SQLite CAS and all four legal AI profiles cover the bounded ordinary-card reaction. Other discard producers, full games and live deployment remain unverified.', evidence: [ + 'tests/semuta-engine.test.ts', + 'tests/semuta-recovery.test.ts', + 'tests/bot-semuta.test.ts', + 'tests/semuta-reaction-controls.test.tsx', 'tests/semuta-drug.test.ts', 'tests/treachery-discard-continuations.test.ts', 'tests/multiplayer-discard-continuations.test.ts', diff --git a/tests/bot-semuta.test.ts b/tests/bot-semuta.test.ts new file mode 100644 index 00000000..1ef2917e --- /dev/null +++ b/tests/bot-semuta.test.ts @@ -0,0 +1,104 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { baseDeck } from '../game/cards'; +import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { botSemutaActions } from '../game/bot-semuta'; +import { richeseCards } from '../game/richese-cards'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; + +const semuta = richeseCards().find((card) => card.id === SEMUTA_DRUG_ID)!; +const event = 'discard:3:6:19'; + +function offerView(): GameView { + const g = createGame('BOTSEMUTA', newPlayer('owner', 'Owner', 'atreides')); + joinGame(g, newPlayer('rival', 'Rival', 'fremen')); + const v = viewGame(g, 'owner'); + v.status = 'playing'; + v.players.find((p) => p.id === 'owner')!.hand!.push(semuta); + v.semutaReaction = { + event, + stage: 'offer', + passed: false, + canCommit: true, + blocked: null, + candidates: [], + }; + return v; +} + +function denyRivalPrivateFields(v: GameView) { + const rival = v.players.find((p) => p.id === 'rival')!; + for (const key of ['hand', 'traitors', 'faceDancers', 'spice']) + Object.defineProperty(rival, key, { + configurable: true, + get() { throw new Error(`Read rival private ${key}`); }, + }); +} + +void test('all four difficulties commit using only their own card and the exact public event without mutating the projected view', () => { + for (const difficulty of DIFFICULTIES) { + const v = offerView(); + v.players.find((p) => p.id === v.me)!.bot = difficulty; + const before = structuredClone(v); + denyRivalPrivateFields(v); + assert.deepEqual(botSemutaActions(v), [{ type: 'semutaCommit', event }]); + const rival = v.players.find((p) => p.id === 'rival')!; + for (const key of ['hand', 'traitors', 'faceDancers', 'spice']) + Reflect.deleteProperty(rival, key); + assert.deepEqual(v, before); + } +}); + +void test('offer never inspects an uncommitted candidate or rival custody; unable or blocked seats pass once', () => { + const v = offerView(); + const reaction = v.semutaReaction!; + Object.defineProperty(reaction, 'candidates', { + get() { throw new Error('Read unrevealed fresh cards'); }, + }); + denyRivalPrivateFields(v); + assert.deepEqual(botSemutaActions(v), [{ type: 'semutaCommit', event }]); + v.players.find((p) => p.id === v.me)!.hand!.length = 0; + assert.deepEqual(botSemutaActions(v), [{ type: 'semutaPass', event }]); + v.players.find((p) => p.id === v.me)!.hand!.push(semuta); + reaction.blocked = 'No free hand slot'; + assert.deepEqual(botSemutaActions(v), [{ type: 'semutaPass', event }]); + reaction.blocked = null; + reaction.canCommit = false; + assert.deepEqual(botSemutaActions(v), [{ type: 'semutaPass', event }]); + reaction.passed = true; + assert.deepEqual(botSemutaActions(v), []); + v.semutaReaction = null; + assert.deepEqual(botSemutaActions(v), []); +}); + +void test('all four difficulties select an exact projected candidate after commitment, never a guessed discard', () => { + const [first, second] = baseDeck(); + for (const difficulty of DIFFICULTIES) { + const v = offerView(); + v.players.find((p) => p.id === v.me)!.bot = difficulty; + v.semutaReaction = { + event, + stage: 'select', + passed: false, + canCommit: false, + blocked: null, + candidates: [first, second], + }; + const before = structuredClone(v); + denyRivalPrivateFields(v); + assert.deepEqual(botSemutaActions(v), [ + { type: 'semutaSelect', event, card: first.id }, + ]); + const rival = v.players.find((p) => p.id === 'rival')!; + for (const key of ['hand', 'traitors', 'faceDancers', 'spice']) + Reflect.deleteProperty(rival, key); + assert.deepEqual(v, before); + v.semutaReaction.candidates = [second]; + assert.deepEqual(botSemutaActions(v), [ + { type: 'semutaSelect', event, card: second.id }, + ]); + v.semutaReaction.candidates = []; + assert.deepEqual(botSemutaActions(v), []); // Nonclaimants receive no faces. + } +}); diff --git a/tests/prototype-room.test.ts b/tests/prototype-room.test.ts index 04c8cdfc..bec3c7c8 100644 --- a/tests/prototype-room.test.ts +++ b/tests/prototype-room.test.ts @@ -235,3 +235,24 @@ void test('Moritani assassination preview starts only the fresh opted-in Advance assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(),rows); assert.deepEqual(db.prepare('SELECT * FROM seats').all(),seats);assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'),other); }); + +void test('Semuta development profile begins only a fresh Richese lobby without changing saved seats', (t) => { + const { db } = fixture(t); + const game = createGame('PROTOTYP', newPlayer('r', 'Richese', 'richese'), false, ['choam']); + joinGame(game, newPlayer('a', 'Atreides', 'atreides')); + for (const p of game.players) p.ready = true; + db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code); + const seats = db.prepare('SELECT * FROM seats').all(); + const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'); + const result = startPrototypeRoom(db, game.code, 7, 'semuta'); + const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game; + assert.equal(result.status, 'setup'); + assert.equal(saved.semutaPreview, true); + assert.ok(saved.richeseCache?.some(card => card.id === 'richese-semuta-drug')); + const rows = db.prepare('SELECT * FROM rooms ORDER BY code').all(); + assert.throws(() => startPrototypeRoom(db, game.code, 7, 'semuta')); + assert.throws(() => startPrototypeRoom(db, game.code, 8, 'semuta')); + assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rows); + assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats); + assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other); +}); diff --git a/tests/semuta-engine.test.ts b/tests/semuta-engine.test.ts new file mode 100644 index 00000000..2b401abb --- /dev/null +++ b/tests/semuta-engine.test.ts @@ -0,0 +1,168 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; + +const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); +const player = (g: Game, id: string) => g.players.find(p => p.id === id)!; +function take(g: Game, id: string) { + const zones = [g.deck, g.discard, g.richeseCache!, ...g.players.map(p => p.hand)]; + for (const zone of zones) { + const index = zone.findIndex(card => card.id === id || card.effect === id); + if (index >= 0) return zone.splice(index, 1)[0]; + } + throw new Error(`Missing physical ${id}`); +} +function inventory(g: Game) { + const ids = [...g.deck, ...g.discard, ...g.richeseCache!, ...g.players.flatMap(p => p.hand)].map(card => card.id).sort(); + assert.equal(new Set(ids).size, ids.length); + return ids; +} +function fixture() { + let g = createGame('SEMUTAQ2', newPlayer('r', 'Richese', 'richese'), false, ['choam']); + joinGame(g, newPlayer('a', 'Atreides', 'atreides')); + joinGame(g, newPlayer('e', 'Emperor', 'emperor')); + for (const p of g.players) { p.bot = 'Medium'; p.ready = true; } + g = initializeSemutaGameForAudit(g); + for (let step = 0; g.status === 'setup' && step < 80; step++) { + let next: Game | undefined; + for (const p of g.players) { + const action = botActions(viewGame(g, p.id))[0]; + if (action) { next = applyAction(g, p.id, action); break; } + } + assert.ok(next, 'genuine setup must have a legal AI choice'); + g = next; + } + assert.equal(g.status, 'playing'); + const semuta = take(g, SEMUTA_DRUG_ID), hajr = take(g, 'hajr'); + g.deck.push(...g.players.flatMap(p => p.hand)); + for (const p of g.players) p.hand = []; + player(g, 'r').hand.push(semuta); + player(g, 'a').hand.push(hajr); + Object.assign(g, { turn: 2, phase: 5, active: 'a', storm: 18, ready: [], decision: null, + response: null, phaseOpening: null, stormPending: null, movementRemaining: ['a', 'r', 'e'] }); + return { g, semuta, hajr }; +} + +void test('a clean public discard offers every seat the same event, then a committed sole Semuta claim resumes once across JSON', () => { + const { g, semuta, hajr } = fixture(); + const original = inventory(g); + const pending = applyAction(g, 'a', { type: 'card', card: hajr.id }); + const event = pending.pendingTreacheryDiscard?.batch.event; + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.ok(event); + const from = viewGame(pending, 'a').semutaReaction!, to = viewGame(pending, 'r').semutaReaction!; + assert.equal(from.event, to.event); + assert.equal(from.canCommit, false); + assert.equal(to.canCommit, true); + assert.deepEqual(from.candidates, []); + assert.deepEqual(to.candidates, []); + assert.equal(viewGame(pending, 'e').semutaReaction?.event, event); + assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending)); + assert.throws(() => applyAction(pending, 'a', { type: 'move', from: 'imperial_basin:10', amount: 1, + territory: 'arrakeen', sector: 10 })); + const before = JSON.stringify(pending); + assert.throws(() => applyAction(pending, 'a', { type: 'semutaCommit', event })); + assert.throws(() => applyAction(pending, 'r', { type: 'semutaSelect', event, card: hajr.id })); + assert.equal(JSON.stringify(pending), before); + const passed = applyAction(reload(pending), 'a', { type: 'semutaPass', event }); + assert.equal(viewGame(passed, 'a').semutaReaction?.passed, true); + assert.equal(viewGame(passed, 'r').semutaReaction?.passed, false); + assert.deepEqual(normalizeAutomaticGame(reload(passed)), reload(passed)); + const botChoice = botActions(viewGame(passed, 'r'))[0]; + assert.ok(botChoice); + const claimed = applyAction(reload(passed), 'r', botChoice); + assert.equal(claimed.pendingTreacheryDiscard, null); + assert.equal(claimed.resolvedTreacheryDiscardSequence, claimed.treacheryDiscardSequence); + assert.equal(player(claimed, 'r').hand.filter(card => card.id === hajr.id).length, 1); + assert.equal(player(claimed, 'r').hand.some(card => card.id === semuta.id), false); + assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1); + assert.equal(claimed.discard.some(card => card.id === hajr.id), false); + assert.equal(claimed.hajr.filter(id => id === 'a').length, 1); + assert.deepEqual(inventory(claimed), original); + for (const p of claimed.players) + assert.deepEqual(viewGame(reload(claimed), p.id), viewGame(claimed, p.id)); + assert.deepEqual(normalizeAutomaticGame(reload(claimed)), reload(claimed)); + assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event })); +}); + +void test('neutral Semuta passes do not reveal hidden possession and resume a declined effect exactly once', () => { + const { g, semuta, hajr } = fixture(); + const without = reload(g); + player(without, 'r').hand = []; + without.richeseCache!.push(semuta); + player(without, 'r').hand.push(take(without, 'richese-distrans')); + const held = applyAction(g, 'a', { type: 'card', card: hajr.id }); + const absent = applyAction(without, 'a', { type: 'card', card: hajr.id }); + const event = held.pendingTreacheryDiscard!.batch.event; + assert.equal(absent.pendingTreacheryDiscard!.batch.event, event); + for (const id of ['a', 'e']) + assert.deepEqual(viewGame(held, id), viewGame(absent, id)); + assert.equal(viewGame(absent, 'r').semutaReaction?.canCommit, false); + assert.deepEqual(normalizeAutomaticGame(reload(absent)), reload(absent)); + let declined = reload(held); + for (const id of ['e', 'r', 'a']) { + declined = applyAction(declined, id, { type: 'semutaPass', event }); + assert.deepEqual(inventory(declined), inventory(held)); + } + assert.equal(declined.pendingTreacheryDiscard, null); + assert.equal(declined.hajr.filter(id => id === 'a').length, 1); + assert.equal(declined.discard.filter(card => card.id === hajr.id).length, 1); + assert.equal(player(declined, 'r').hand.some(card => card.id === semuta.id), true); + assert.deepEqual(normalizeAutomaticGame(reload(declined)), reload(declined)); +}); + +void test('the provisional free-slot guard is private and never spends a full-hand Semuta', () => { + const { g, semuta, hajr } = fixture(); + for (let n = 0; n < 3; n++) player(g, 'r').hand.push(g.deck.shift()!); + const pending = applyAction(g, 'a', { type: 'card', card: hajr.id }); + const event = pending.pendingTreacheryDiscard!.batch.event; + const richese = viewGame(pending, 'r').semutaReaction!; + assert.equal(richese.canCommit, false); + assert.match(richese.blocked ?? '', /free hand slot/); + assert.equal(viewGame(pending, 'a').semutaReaction?.blocked, null); + const before = JSON.stringify(pending); + assert.throws(() => applyAction(pending, 'r', { type: 'semutaCommit', event })); + assert.equal(JSON.stringify(pending), before); + assert.equal(player(pending, 'r').hand.some(card => card.id === semuta.id), true); +}); + +void test('a saved neutral reaction cannot be deleted to auto-retire the fresh discard', () => { + const { g, hajr } = fixture(); + const pending = applyAction(g, 'a', { type: 'card', card: hajr.id }); + const corrupt = reload(pending); + delete corrupt.pendingTreacheryDiscard!.reaction; + const before = JSON.stringify(corrupt); + assert.throws(() => viewGame(corrupt, 'r')); + assert.throws(() => normalizeAutomaticGame(corrupt)); + assert.throws(() => applyAction(corrupt, 'a', { type: 'advanceBots' })); + assert.equal(JSON.stringify(corrupt), before); +}); + +void test('duplicate physical Semuta custody cannot survive projection or commit', () => { + const { g, semuta, hajr } = fixture(); + const pending = applyAction(g, 'a', { type: 'card', card: hajr.id }); + const corrupt = reload(pending); + corrupt.richeseCache!.push(structuredClone(semuta)); + const before = JSON.stringify(corrupt); + assert.throws(() => viewGame(corrupt, 'r')); + assert.throws(() => applyAction(corrupt, 'r', { + type: 'semutaCommit', event: corrupt.pendingTreacheryDiscard!.batch.event, + })); + assert.equal(JSON.stringify(corrupt), before); +}); + +void test('an impossible saved all-passed offer rejects instead of waiting forever', () => { + const { g, hajr } = fixture(); + const pending = applyAction(g, 'a', { type: 'card', card: hajr.id }); + const corrupt = reload(pending); + const reaction = corrupt.pendingTreacheryDiscard!.reaction; + assert.equal(reaction?.stage, 'offer'); + if (reaction?.stage !== 'offer') return; + reaction.passed = corrupt.players.map(player => player.id); + const before = JSON.stringify(corrupt); + assert.throws(() => viewGame(corrupt, 'r')); + assert.throws(() => normalizeAutomaticGame(corrupt)); + assert.equal(JSON.stringify(corrupt), before); +}); diff --git a/tests/semuta-reaction-controls.test.tsx b/tests/semuta-reaction-controls.test.tsx new file mode 100644 index 00000000..014e92f6 --- /dev/null +++ b/tests/semuta-reaction-controls.test.tsx @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { SemutaReaction } from '../components/semuta-reaction'; +import type { GameView } from '../game/engine'; + +const first = { id: 'fresh-projectile-1', name: 'Projectile', kind: 'projectile' as const }; +const second = { id: 'fresh-projectile-2', name: 'Projectile', kind: 'projectile' as const }; +const event = 'discard-turn-7'; + +type Reaction = NonNullable; + +function view(reaction: Reaction | null): GameView { + return { semutaReaction: reaction } as GameView; +} + +function offer(overrides: Partial = {}): Reaction { + return { + event, + stage: 'offer', + passed: false, + canCommit: false, + blocked: null, + candidates: [], + ...overrides, + }; +} + +function markup(reaction: Reaction | null, busy = false) { + return renderToStaticMarkup( + createElement(SemutaReaction, { game: view(reaction), act() {}, busy }), + ); +} + +void test('all seats see the same fresh-discard event without a card face before commitment', () => { + const holder = markup(offer({ canCommit: true })); + const rival = markup(offer()); + assert.match(holder, /Fresh Treachery discard/); + assert.match(rival, /Fresh Treachery discard/); + assert.match(holder, /discard-turn-7/); + assert.match(rival, /discard-turn-7/); + assert.match(holder, /Commit Semuta Drug/); + assert.doesNotMatch(rival, /Commit Semuta Drug/); + assert.match(holder, />Continue<\/button>/); + assert.match(rival, />Continue<\/button>/); + for (const html of [holder, rival]) { + assert.doesNotMatch(html, /Projectile|fresh-projectile|Inspect card|Claim Projectile/); + } + assert.equal(markup(null), ''); +}); + +void test('only the claimant sees exact physical candidates after commitment', () => { + const selecting = offer({ stage: 'select', candidates: [first, second] }); + const holder = markup(selecting); + const rival = markup(offer({ stage: 'select' })); + for (const id of [first.id, second.id]) { + assert.match(holder, new RegExp(id)); + assert.doesNotMatch(rival, new RegExp(id)); + } + assert.equal((holder.match(/Claim Projectile \(/g) ?? []).length, 2); + assert.match(holder, /Inspect card/); + assert.match(holder, /cannot be declined or canceled/); + assert.doesNotMatch(holder, /Continue|Cancel|Decline/); + assert.match(rival, /Waiting for the Semuta Drug claim to finish/); + assert.doesNotMatch(rival, /Projectile|Inspect card|Commit Semuta Drug|>Continue { + const passed = offer({ passed: true, canCommit: true }); + assert.match(markup(passed), /You continued/); + assert.doesNotMatch(markup(passed), /Commit Semuta Drug|>Continue]*>Commit Semuta Drug/); + assert.match(markup(offer(), true), /disabled=""[^>]*>Continue/); + assert.match(markup(offer({ stage: 'select', candidates: [first, second] }), true), + /disabled=""[^>]*>Claim Projectile/); +}); diff --git a/tests/semuta-recovery.test.ts b/tests/semuta-recovery.test.ts new file mode 100644 index 00000000..f5f0ae98 --- /dev/null +++ b/tests/semuta-recovery.test.ts @@ -0,0 +1,97 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { startPrototypeRoom } from '../tools/prototype-room'; +import { botActions } from '../game/bots'; +import { viewGame, type Game, type Action } from '../game/engine'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { unitStore } from './fixture-nexus-room-store'; + +const clock = { now: () => 10000, sleep: async () => {} }; +const seat = (g: Game, id: string) => g.players.find(p => p.id === id)!; +function take(g: Game, identity: string) { + for (const zone of [g.deck, g.discard, g.richeseCache!, ...g.players.map(p => p.hand)]) { + const index = zone.findIndex(card => card.id === identity || card.effect === identity); + if (index >= 0) return zone.splice(index, 1)[0]; + } + throw new Error('The genuine setup lost a physical card.'); +} + +void test('authenticated fresh discard survives restart and duplicate Semuta commits transfer one physical card', async () => { + const f = unitStore(); + try { + const created = await f.rooms.createRoom('Semuta QA', 'richese', false, ['choam']); + const code = created.view.code; + const second = await f.rooms.joinRoom(code, 'Atreides', 'atreides'); + const third = await f.rooms.joinRoom(code, 'Emperor', 'emperor'); + const auths = await Promise.all([created.token!, second.token!, third.token!] + .map(token => f.restart().authenticate(code, token))); + const act = async (index: number, action: Action) => { + const before = await f.restart().readRoom(code); + await f.restart().act(code, auths[index], before.version, action, clock); + return f.restart().readRoom(code); + }; + for (let i = 0; i < auths.length; i++) await act(i, { type: 'ready' }); + let game = await f.restart().readRoom(code); + startPrototypeRoom(f.sqlite, code, game.version, 'semuta'); + game = await f.restart().readRoom(code); + for (let step = 0; game.status === 'setup' && step < 80; step++) { + let moved = false; + for (let i = 0; i < auths.length; i++) { + const view = viewGame(game, auths[i].playerId); + view.players.find(p => p.id === view.me)!.bot = 'Medium'; + const choice = botActions(view)[0]; + if (choice) { game = await act(i, choice); moved = true; break; } + } + assert.ok(moved, 'genuine setup must retain a legal choice'); + } + assert.equal(game.status, 'playing'); + const ids = auths.map(auth => auth.playerId), originalSeats = f.sqlite.prepare('SELECT * FROM seats').all(); + const semuta = take(game, SEMUTA_DRUG_ID), hajr = take(game, 'hajr'); + game.deck.push(...game.players.flatMap(p => p.hand)); + for (const p of game.players) p.hand = []; + seat(game, ids[0]).hand.push(semuta); + seat(game, ids[1]).hand.push(hajr); + Object.assign(game, { turn: 2, phase: 5, active: ids[1], storm: 18, ready: [], + decision: null, response: null, phaseOpening: null, stormPending: null, + movementRemaining: [ids[1], ids[0], ids[2]] }); + game.version++; + assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?') + .run(JSON.stringify(game), game.version, code, game.version - 1).changes, 1); + game = await act(1, { type: 'card', card: hajr.id }); + const event = game.pendingTreacheryDiscard?.batch.event; + assert.ok(event); + assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + const foreign = await f.restart().readSeatView(code, auths[2]); + const owner = await f.restart().readSeatView(code, auths[0]); + assert.equal(foreign.semutaReaction?.event, owner.semutaReaction?.event); + assert.deepEqual(foreign.semutaReaction?.candidates, []); + assert.deepEqual(owner.semutaReaction?.candidates, []); + assert.equal(owner.semutaReaction?.canCommit, true); + await f.restart().continueRoomAutomatic(code, clock); + assert.equal((await f.restart().readRoom(code)).version, game.version); + game = await act(1, { type: 'semutaPass', event }); + const version = game.version; + let arrivals = 0; let release!: () => void; + const barrier = new Promise(resolve => { release = resolve; }); + f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await barrier; }; + const results = await Promise.allSettled([ + f.rooms.act(code, auths[0], version, { type: 'semutaCommit', event }, clock), + f.restart().act(code, auths[0], version, { type: 'semutaCommit', event }, clock), + ]); + delete f.hooks.beforeWrite; + assert.equal(arrivals, 2); + assert.equal(results.filter(result => result.status === 'fulfilled').length, 1); + assert.equal(results.filter(result => result.status === 'rejected').length, 1); + const done = await f.restart().readRoom(code); + assert.equal(done.version, version + 1); + assert.equal(done.pendingTreacheryDiscard, null); + assert.equal(seat(done, ids[0]).hand.filter(card => card.id === hajr.id).length, 1); + assert.equal(done.discard.filter(card => card.id === semuta.id).length, 1); + assert.equal(done.discard.some(card => card.id === hajr.id), false); + assert.equal(done.hajr.filter(id => id === ids[1]).length, 1); + const cards = [...done.deck, ...done.discard, ...done.richeseCache!, ...done.players.flatMap(p => p.hand)]; + assert.equal(new Set(cards.map(card => card.id)).size, cards.length); + assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats); + for (const auth of auths) assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(done, auth.playerId)); + } finally { f.sqlite.close(); } +}); diff --git a/tools/prototype-room.ts b/tools/prototype-room.ts index 963055b8..8d244593 100644 --- a/tools/prototype-room.ts +++ b/tools/prototype-room.ts @@ -7,6 +7,7 @@ import { initializeNexusGameForAudit, initializeMoritaniAssassinateGameForAudit, initializeEcazTreacheryGameForAudit, + initializeSemutaGameForAudit, viewGame, type Game, } from '../game/engine'; @@ -19,6 +20,7 @@ export const PROTOTYPE_PROFILES = [ 'nexus', 'moritani-assassinate', 'ecaz-treachery', + 'semuta', ] as const; export type PrototypeProfile = (typeof PROTOTYPE_PROFILES)[number]; export function isPrototypeProfile(value: string): value is PrototypeProfile { @@ -65,6 +67,8 @@ export function startPrototypeRoom( ? initializeEcazTreacheryGameForAudit(initial) : profile === 'moritani-assassinate' ? initializeMoritaniAssassinateGameForAudit(initial) + : profile === 'semuta' + ? initializeSemutaGameForAudit(initial) : profile === 'factions' ? initializeFactionExpansionsGameForAudit(initial) : profile === 'nexus' diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 29ae6915..628237ae 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -20,7 +20,7 @@ async function main() { }); if (values.help) { console.log( - 'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|factions|nexus|moritani-assassinate --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Factions supports selected expansion factions without optional modules; the separate Ecaz Treachery variant stays disabled. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.', + 'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|factions|nexus|moritani-assassinate|ecaz-treachery|semuta --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into the limited clean ordinary-discard reaction without opening public starts. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.', ); return; }