diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index ba1e7067..087c9a94 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -13,8 +13,8 @@ Ornithopter, used Distrans, final Truthtrance, four bounded Sapho, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a clean normal-auction lot, a completed CHOAM sale or clean -Kulon/La La La/Baliset Worthless, battle-card and -Ix-deck Thumper/Amal discards +Kulon/La La La/Baliset/ordinary Trip to Gamont +Worthless, battle-card and Ix-deck Thumper/Amal discards open the same neutral offer regardless of hidden Semuta custody. A clean **private** normal-auction Ixian ally replacement discard also pauses before its draw; no unknown face is shown before a @@ -28,6 +28,9 @@ selected target loses remaining free revival; paid revival remains available. Proactive Baliset spends its public card before the selected player is barred from movement into CHOAM's occupied territory; shipment stays legal. +Clean ordinary Trip to Gamont spends its public card before +one opponent force returns to reserves; elite and concealed +No-Field returns retain their earlier automatic path. Residual Poison commits one random death before the offer; only an uninspected battle with nobody ready qualifies. Once Around commits bidder order and frees a slot; other Sapho scopes commit chooser or @@ -43,6 +46,18 @@ full-hand ordering, normal Richese starts and deployed acceptance remain unfinished. Neutral passes and free-slot guard are product choices. +Clean ordinary Trip to Gamont passes focused engine and +authenticated SQLite checks, a direct physical claim/force +return smoke and independent review without findings. +`npm run check` passed types, lint and 5,851 offline tests; +typecheck, lint and 18 guide/Semuta tests passed after the +in-app text update, and the production build passed. The +built phone-width rules guide displayed both new CHOAM +discard scopes. Isolated built-worker HTTP passed 50/55: +four Wrangler-dev proxy 503s and one timeout. It is not a +green local HTTP suite or a live deployment; proxy-free CI +for this exact checkpoint is pending. + Clean proactive Baliset passes focused engine and authenticated SQLite checks, a direct engine claim/restriction smoke and independent review without findings. `npm run check` passed @@ -50,8 +65,9 @@ types, lint and 5,849 offline tests; the production build passed. Isolated built-worker HTTP passed 49/55: four POSTs returned the Wrangler-dev proxy's 503 rather than app statuses, and two requests timed out. This is **not** a green local HTTP -suite or production deployment; proxy-free CI remains pending -for this exact checkpoint. +suite or production deployment. Proxy-free workflow +`36673190056` passed the full isolated HTTP suite for +`756af19` and published its image; this is not NAS deployment. Clean proactive La La La passes engine, authenticated SQLite, direct engine smoke and phone-width guide checks. `npm run check` diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index b1983756..caf25d39 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,42 @@ # Dune implementation status +## 30 September 2026 — Semuta after proactive ordinary Trip cost + +In the explicit Richese preview, an accepted ordinary Trip to +Gamont Worthless response now discards its named public card +before the selected opponent force leaves its board sector. +Every seat sees the same neutral Semuta offer regardless of +the private holder. A claim can take that physical card, and +either claim or all-seat decline then returns exactly one +ordinary force to reserves. The saved receipt binds target +and location, original target force/elite/reserve custody, +owner hand and suspended parent state. No second Karama +response or duplicate force return occurs. Elite and No-Field +returns, closing Mentat decisions, Nexus conversion and +competing parents retain the existing direct path. + +Focused engine checks cover hidden-holder parity, pre-return +timing, JSON normalization, corrupted target/location and +force custody rejection, single claim or decline, legal AI, +physical cards and subsequent Mentat continuation. +Authenticated in-memory SQLite verifies seat views, restart, +stale retry, card custody and one actual force return. The +full Semuta and expansion gates remain open. + +`npm run check` passed types, lint and 5,851 offline +tests. After the in-app guide update, typecheck, lint and 18 +focused reference/Semuta cases passed; the production build +passed. Independent rule review found no scoped defect. A +direct engine smoke observed the accepted public discard, +pre-return custody, exact physical claim and single ordinary +force return. The built phone-width rules guide visibly named +Baliset and ordinary Trip before their effects. Isolated +Wrangler-dev HTTP passed 50/55: four requests returned its +proxy-layer 503 and one Advanced-preview request timed out. +This is not a green local HTTP suite. Proxy-free container +verification and live deployment remain pending for this +checkpoint; no complete expansion claim follows. + ## 30 September 2026 — Semuta after proactive CHOAM Baliset cost In the explicit Richese preview, an accepted proactive Baliset @@ -28,8 +65,9 @@ including 23 Baliset and 13 Semuta recovery cases. The production build passed; independent review had no scoped finding. Isolated built-worker HTTP passed 49/55: four Wrangler-dev proxy 503s and two 15-second timeouts. This does -not certify the HTTP suite; proxy-free container CI for the -checkpoint is pending. No NAS deployment is claimed. +not certify local HTTP. Proxy-free workflow `36673190056` +passed the full HTTP suite for `756af19` and published its +image. No NAS deployment is claimed. ## 30 September 2026 — Semuta after proactive CHOAM La La La cost diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 200e7526..028218f2 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -104,6 +104,15 @@ decline installs the one saved restriction; shipment remains possible. Declared moves, Nexus conversion and overlapping arrival parents retain their automatic paths. +Clean proactive ordinary Trip to Gamont stages +`choamGamontDiscard` after the accepted public card cost but +before returning one selected opponent force to reserves. The +receipt binds the selected location, target force/reserve +custody and CHOAM hand; claim or decline completes the one +return without replaying the response. Elite, concealed +No-Field, closing Mentat decision, Nexus and competing parents +retain their automatic paths. + Completed Ornithopter movement now offers the same retired public card before its already committed arrival suffix only in a roster without Bene Gesserit intrusion, Ecaz Ambassador or Moritani Terror response diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 8bc3c236..bae244de 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -68,8 +68,9 @@ ordinary-card, paid Box, retired Ornithopter, used Distrans, final Truthtrance, four bounded Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a clean normal-auction lot, a completed -CHOAM sale or clean Kulon/proactive La La La/Baliset Worthless -discard, battle and Ix-deck Thumper/Amal discards**, plus +CHOAM sale or clean Kulon/proactive La La La/Baliset/ordinary +Trip to Gamont Worthless discard, battle and Ix-deck +Thumper/Amal discards**, plus a clean **private** normal-auction Ixian ally replacement discard before its draw. Other than the one explicitly suspended charity/Inflation response, the @@ -117,6 +118,14 @@ restriction without replaying the response. Shipment remains legal. Reactive declared movement, Nexus conversion and competing arrivals retain their original automatic paths. +A clean proactive ordinary Trip to Gamont response pauses after +its named public card cost but before moving one selected force +from a player's board sector to reserves. The saved receipt binds +the target, location and pre-return force custody; a claim or +decline returns that one force and reconciles occupation once. +Elite, concealed No-Field, closing Mentat, Nexus and competing +parents retain their prior automatic paths. + An early-ended Ornithopter advances the saved queue; a completed flight resumes one committed arrival only without BG, Ecaz or Moritani entry reactions. Distrans's gift remains private with its diff --git a/game/engine.ts b/game/engine.ts index 559ef2b4..22c9d81c 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1236,6 +1236,14 @@ export type Game = { territory: string; stateSignature: string; } + | { + kind: 'choamGamontDiscard'; + player: string; + card: string; + target: string; + location: string; + stateSignature: string; + } | { kind: 'thumperDiscard'; player: string; @@ -5005,6 +5013,24 @@ function choamBalisetDiscardSignature(g: Game, c: ChoamBalisetDiscardContinuatio ownerElites: owner.elites?.forces, ownerSpice: owner.spice, }); } +type ChoamGamontDiscardContinuation = Extract< + NonNullable['continuation'], + { kind: 'choamGamontDiscard' } +>; +function choamGamontDiscardSignature(g: Game, c: ChoamGamontDiscardContinuation) { + const owner = getPlayer(g, c.player), target = getPlayer(g, c.target); + return JSON.stringify({ + player: c.player, card: c.card, target: c.target, location: c.location, + turn: g.turn, phase: g.phase, active: g.active, order: g.order, + ready: g.ready, storm: g.storm, + parent: nullentropyParentSignature(g, + { response: null, decision: null, pendingKarama: null, phaseOpening: null }, + `choam:gamont:${c.card}`), + ownerHand: owner.hand.map(card => card.id), + targetForces: target.forces, targetReserves: target.reserves, + targetElites: target.elites, targetNoField: target.noField, + }); +} type ThumperDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'thumperDiscard' } @@ -5679,6 +5705,22 @@ function treacheryDiscardIntegrity(g: Game) { c.stateSignature === choamBalisetDiscardSignature(g, c), 'The used Baliset no longer matches its pending territory restriction.', ); + } else if (continuation?.kind === 'choamGamontDiscard') { + const c = continuation, entry = batch.entries[0]; + const owner = g.players.find(p => p.id === c.player); + requireRule( + g.phase === 8 && owner?.faction === 'choam' && + c.target !== c.player && + gamontAvailable(g, c.target, c.location, 0) && + !g.pendingChoamWorthless && !g.response && !g.decision && + !g.phaseOpening && !g.choamMarket && + batch.cause === 'choam:gamont' && batch.entries.length === 1 && + entry.publicFace && entry.discardedBy === c.player && + entry.card.id === c.card && entry.card.kind === 'worthless' && + entry.card.name === 'Trip to Gamont' && + c.stateSignature === choamGamontDiscardSignature(g, c), + 'The used Trip to Gamont no longer matches its pending ordinary force return.', + ); } else if (continuation?.kind === 'thumperDiscard') { const c = continuation, entry = batch.entries[0]; requireRule( @@ -6348,6 +6390,7 @@ function semutaOfferSupported( continuation.kind !== 'choamKulonDiscard' && continuation.kind !== 'choamLaLaLaDiscard' && continuation.kind !== 'choamBalisetDiscard' && + continuation.kind !== 'choamGamontDiscard' && !(continuation.kind === 'battleCleanup' && continuation.source === 'winner') && continuation.kind !== 'winnerMandatoryDiscard' && continuation.kind !== 'battleResolved' && @@ -6418,6 +6461,7 @@ function semutaOfferSupported( continuation.kind === 'choamKulonDiscard' || continuation.kind === 'choamLaLaLaDiscard' || continuation.kind === 'choamBalisetDiscard' || + continuation.kind === 'choamGamontDiscard' || (continuation.kind === 'karamaCharityDiscard' || continuation.kind === 'karamaInflationDiscard' || continuation.kind === 'karamaBgCharityDiscard' || @@ -6444,6 +6488,9 @@ function semutaOfferSupported( (!g.pendingRevival && !g.pendingChoamMove && !g.ornithopter)) && (continuation.kind !== 'choamBalisetDiscard' || (!g.pendingChoamMove && !g.ornithopter && !g.pendingArrivalOverlap)) && + (continuation.kind !== 'choamGamontDiscard' || + (!g.pendingChoamMove && !g.pendingArrivalOverlap && + !g.pendingRevival && !g.ornithopter)) && !g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy && !g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingShipment && !g.pendingHomeworldShipment && !g.pendingRicheseGift && @@ -6600,6 +6647,9 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string completeChoamLaLaLa(g, next.target); } else if (next.kind === 'choamBalisetDiscard') { completeChoamBaliset(g, next.target, next.territory); + } else if (next.kind === 'choamGamontDiscard') { + completeChoamGamont(g, getPlayer(g, next.player), next.target, + next.location, 0); } else if (next.kind === 'nullentropyDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; @@ -20434,6 +20484,24 @@ function completeChoamBaliset(g: Game, target: string, location: string) { (g.choamBaliset ??= []).push({ turn: g.turn, player: target, territory: location }); log(g, `${getPlayer(g, target).name} cannot move into CHOAM's ${territory(location).name} this phase; shipment remains possible.`); } +function completeChoamGamont( + g: Game, owner: Player, targetId: string, from: string, + elite: number, noFieldEvent?: string, +) { + const target = getPlayer(g, targetId); + if (noFieldEvent) revealPlayerNoField(g, target, 'gamont'); + const returned = (target.forces[from] ?? 0) > 0; + if (returned) { + removeGroup(target, [[from, 1]], { [from]: elite }); + target.reserves++; + if (target.elites) target.elites.reserves += elite; + } + observeOccupation(g); + settleAdvisors(g); + log(g, returned + ? `${owner.name} sent one of ${target.name}’s forces to reserves with Trip to Gamont.` + : `${owner.name} used Trip to Gamont to reveal ${target.name}’s No-Field. No force was present to return; the card is still used.`); +} function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) { karamaConversionIntegrity(g); currentFactionPayment(g); @@ -20837,24 +20905,24 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) if (ambassador) offerAmbassadorRelocation(g); } } else if (pending.effect === 'gamont') { - const target = getPlayer(g, pending.target!); - if (pending.noFieldEvent) revealPlayerNoField(g, target, 'gamont'); - const returned = (target.forces[pending.location!] ?? 0) > 0; - if (returned) { - removeGroup(target, [[pending.location!, 1]], { - [pending.location!]: pending.elite!, - }); - target.reserves++; - if (target.elites) target.elites.reserves += pending.elite!; + if (g.semutaPreview && used.name === 'Trip to Gamont' && + pending.elite === 0 && !pending.noFieldEvent && + !pending.storm && !pending.revival && !pending.movement && + !pending.mentat && !pending.nexusEvent) { + const continuation: ChoamGamontDiscardContinuation = { + kind: 'choamGamontDiscard', player: choam.id, + card: used.id, target: pending.target!, + location: pending.location!, stateSignature: '', + }; + const entries = [{ card: used, discardedBy: choam.id, publicFace: true }]; + if (semutaOfferSupported(g, continuation, entries)) { + continuation.stateSignature = choamGamontDiscardSignature(g, continuation); + stageTreacheryDiscard(g, 'choam:gamont', entries, continuation); + return; + } } - observeOccupation(g); - settleAdvisors(g); - log( - g, - returned - ? `${choam.name} sent one of ${target.name}’s forces to reserves with Trip to Gamont.` - : `${choam.name} used Trip to Gamont to reveal ${target.name}’s No-Field. No force was present to return; the card is still used.`, - ); + completeChoamGamont(g, choam, pending.target!, pending.location!, + pending.elite!, pending.noFieldEvent); } else { if (g.semutaPreview && used.name === 'La La La' && !pending.storm && !pending.revival && !pending.movement && diff --git a/game/reference.ts b/game/reference.ts index eec82a1e..7845bdb9 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1156,7 +1156,7 @@ export const RULE_TOPICS: RuleTopic[] = [ : 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 the explicit Richese development preview, every seat sees a neutral response to supported clean discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other public sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase or winning-bid payment before lot settlement, a completed CHOAM card sale after its bank income, clean Kulon Worthless disposal before its movement bonus, proactive La La La disposal before blocking free revival, and three battle batches. A private normal-auction Ixian ally replacement also pauses before drawing, without showing its face until a holder commits. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, reactive La La La, inspected/partly ready battles, nested responses, pending rewards, full-hand sequencing and public starts remain unfinished.' + ? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other public sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase or winning-bid payment before lot settlement, a completed CHOAM card sale after its bank income, clean Kulon Worthless disposal before its movement bonus, proactive La La La disposal before blocking free revival, proactive Baliset before restricting movement, proactive ordinary Trip to Gamont before returning a force, and three battle batches. A private normal-auction Ixian ally replacement also pauses before drawing, without showing its face until a holder commits. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, reactive Worthless uses, elite or concealed Trip returns, inspected/partly ready battles, nested responses, pending rewards, full-hand sequencing and public starts remain unfinished.' : 'Reference component only: this Richese card’s game actions are not enabled.', ], ...(definition.card.effect === 'semutaDrug' @@ -1166,7 +1166,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation' as const, status: 'Partial' as const, detail: - 'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, a completed CHOAM sale, clean Kulon and proactive La La La Worthless use, Ix-deck Thumper/Amal and three battle discard stages. A private normal-auction Ixian ally replacement pauses before its draw with a neutral, face-hidden offer. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds one movement range and La La La blocks one target’s free revival only after their offers. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. Other Karama uses, reactive Worthless uses, private discards, nested responses, pending rewards and full-hand exchange remain unfinished.', + 'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, a completed CHOAM sale, clean Kulon and proactive La La La/Baliset/ordinary Trip to Gamont Worthless use, Ix-deck Thumper/Amal and three battle discard stages. A private normal-auction Ixian ally replacement pauses before its draw with a neutral, face-hidden offer. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds one movement range, La La La blocks one target’s free revival, Baliset restricts the chosen player’s movement and ordinary Trip returns one selected force only after their offers. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. Other Karama uses, reactive Worthless uses, private discards, nested responses, pending rewards and full-hand exchange remain unfinished.', }, { area: 'Player controls' as const, @@ -1184,13 +1184,13 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Documentation' as const, status: 'Partial' as const, detail: - 'The guide distinguishes clean public ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La Worthless, Ix-deck Thumper/Amal and three battle batches from a private normal-auction Ixian ally replacement, privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.', + 'The guide distinguishes clean public ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless, Ix-deck Thumper/Amal and three battle batches from a private normal-auction Ixian ally replacement, privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.', }, { area: 'Verification' as const, status: 'Partial' as const, detail: - 'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La Worthless, Ix-deck Thumper/Amal, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, Karama uses, reactive Worthless uses, pending rewards, full games and live deployment remain unverified.', + 'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless, Ix-deck Thumper/Amal, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, Karama uses, reactive Worthless uses, pending rewards, full games and live deployment remain unverified.', evidence: [ 'tests/semuta-engine.test.ts', 'tests/semuta-recovery.test.ts', diff --git a/tests/choam-gamont.test.ts b/tests/choam-gamont.test.ts index 4640db5e..94307168 100644 --- a/tests/choam-gamont.test.ts +++ b/tests/choam-gamont.test.ts @@ -5,6 +5,7 @@ import { createGame, newPlayer, viewGame, + normalizeAutomaticGame, RuleError, type Game, type Action, @@ -13,6 +14,8 @@ import { baseDeck } from '../game/cards'; import { TERRITORIES, MOBILE_LOCATION } from '../game/board'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; +import { richeseCards } from '../game/richese-cards'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; const key = (id: string) => `${id}:${TERRITORIES.find((t) => t.id === id)!.sectors[0]}`; const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!; @@ -149,6 +152,75 @@ void test('Trip may be played during ordinary Mentat and clears readiness before g = done(closing(g)); assert.equal(g.turn, 2); }); +void test('clean proactive ordinary Trip discard offers Semuta before one force returns', () => { + const initial = fixture(); + initial.players[1] = newPlayer('e', 'Richese', 'richese'); + player(initial, 'e').forces = Object.fromEntries( + ['arrakeen', 'carthag', 'sietch_tabr'].map(t => [key(t), 1])); + player(initial, 'e').reserves = 17; + initial.richeseCache = richeseCards(); + initial.semutaPreview = true; + const index = initial.richeseCache.findIndex(card => card.id === SEMUTA_DRUG_ID); + assert.ok(index >= 0); + const semuta = initial.richeseCache.splice(index, 1)[0]; + player(initial, 'e').hand.push(semuta); + contest(initial, 'b'); + const card = player(initial, 'c').hand.find(card => card.name === 'Trip to Gamont')!.id; + const physical = (state: Game) => [ + ...state.deck, ...state.discard, ...state.richeseCache!, + ...state.players.flatMap(p => p.hand), + ].map(card => card.id).sort(); + const original = physical(initial); + const absent = structuredClone(initial); + player(absent, 'e').hand = [absent.richeseCache!.splice(0, 1)[0]]; + absent.richeseCache!.push(semuta); + const pending = allow(trip(mentat(initial))); + const neutral = allow(trip(mentat(absent))); + const event = pending.pendingTreacheryDiscard!.batch.event; + assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'choamGamontDiscard'); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(neutral.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(player(pending, 'e').forces[key('arrakeen')], 1); + assert.equal(player(pending, 'e').reserves, 17); + assert.equal(pending.discard.filter(used => used.id === card).length, 1); + for (const id of ['c', 'b', 'g']) + assert.deepEqual(viewGame(pending, id), viewGame(neutral, id)); + assert.equal(viewGame(pending, 'e').semutaReaction?.canCommit, true); + assert.equal(botActions(viewGame(pending, 'e'))[0]?.type, 'semutaCommit'); + assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(pending))), pending); + for (const change of [ + (state: Game) => { + const c = state.pendingTreacheryDiscard!.continuation; + if (c.kind === 'choamGamontDiscard') c.target = 'b'; + }, + (state: Game) => { delete player(state, 'e').forces[key('arrakeen')]; }, + (state: Game) => { state.pendingTreacheryDiscard!.batch.entries[0].card.name = 'Forgery'; }, + ]) { + const corrupt: Game = JSON.parse(JSON.stringify(pending)); + change(corrupt); + const before = JSON.stringify(corrupt); + assert.throws(() => send(corrupt, 'e', { type: 'semutaCommit', event })); + assert.equal(JSON.stringify(corrupt), before); + } + let declined: Game = JSON.parse(JSON.stringify(pending)); + for (const id of ['c', 'b', 'g', 'e']) + declined = send(declined, id, { type: 'semutaPass', event }); + const claimed = send(JSON.parse(JSON.stringify(pending)), 'e', + { type: 'semutaCommit', event }); + for (const state of [declined, claimed]) { + assert.equal(state.pendingTreacheryDiscard, null); + assert.equal(player(state, 'e').forces[key('arrakeen')], undefined); + assert.equal(player(state, 'e').reserves, 18); + assert.deepEqual(state.ready, []); + assert.deepEqual(physical(state), original); + assert.equal(done(closing(state)).turn, 2); + assert.throws(() => send(state, 'e', { type: 'semutaCommit', event })); + } + assert.equal(declined.discard.filter(used => used.id === card).length, 1); + assert.equal(player(claimed, 'e').hand.filter(used => used.id === card).length, 1); + assert.equal(claimed.discard.filter(used => used.id === semuta.id).length, 1); +}); + void test('a Karama cancellation keeps both the selected force and Trip card and does not skip the victory opportunity', () => { const initial = fixture(); const karama = hold(initial, 'e', 'Karama'); diff --git a/tests/semuta-recovery.test.ts b/tests/semuta-recovery.test.ts index c5e7955c..bd937a10 100644 --- a/tests/semuta-recovery.test.ts +++ b/tests/semuta-recovery.test.ts @@ -1045,6 +1045,81 @@ void test('authenticated proactive Baliset Semuta claim restores only the select } finally { f.sqlite.close(); } }); +void test('authenticated ordinary Trip Semuta claim returns one force after saved offer', async () => { + const f = unitStore(); + try { + const made = await f.rooms.createRoom('Semuta Trip', 'choam', false, ['choam']); + const code = made.view.code; + const richese = await f.rooms.joinRoom(code, 'Richese', 'richese'); + const bg = await f.rooms.joinRoom(code, 'BG', 'beneGesserit'); + const auths = await Promise.all([made.token!, richese.token!, bg.token!] + .map(token => f.restart().authenticate(code, token))); + const existing = await f.restart().readRoom(code); + const g = createGame(code, newPlayer(auths[0].playerId, 'CHOAM', 'choam'), false, ['choam']); + g.players.push(newPlayer(auths[1].playerId, 'Richese', 'richese'), + newPlayer(auths[2].playerId, 'BG', 'beneGesserit')); + Object.assign(g, { status: 'playing', phase: 8, turn: 2, storm: 18, + active: auths[0].playerId, order: auths.map(auth => auth.playerId), + deck: baseDeck(), richeseCache: richeseCards(), semutaPreview: true }); + for (const p of g.players) { + p.spice = 10; + p.hand = []; + p.traitors = p.leaders.length ? [p.leaders[0].id] : []; + p.traitorChoices = []; + } + const semuta = take(g, SEMUTA_DRUG_ID), karama = take(g, 'karama'); + const index = g.deck.findIndex(card => card.name === 'Trip to Gamont'); + assert.ok(index >= 0); + const used = g.deck.splice(index, 1)[0]; + g.players[0].hand.push(used); + g.players[1].hand.push(semuta); + g.players[1].forces = { 'arrakeen:10': 1 }; + g.players[1].reserves = 19; + g.players[2].hand.push(karama); + g.version = existing.version + 1; + assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?') + .run(JSON.stringify(g), g.version, code, existing.version).changes, 1); + const seats = f.sqlite.prepare('SELECT * FROM seats').all(); + const act = async (index: number, action: Action) => { + const current = await f.restart().readRoom(code); + await f.restart().act(code, auths[index], current.version, action, clock); + return f.restart().readRoom(code); + }; + let current = await act(0, { + type: 'card', mode: 'choam', card: used.id, target: auths[1].playerId, + from: 'arrakeen:10', elite: 0, + }); + assert.equal(current.response?.kind, 'choamWorthless'); + for (let i = 0; current.response?.kind === 'choamWorthless' && i < 3; i++) { + const index = auths.findIndex(auth => + !viewGame(current, auth.playerId).responseControls?.hasPassed && + !!viewGame(current, auth.playerId).responseControls?.cancelCards.length); + assert.ok(index >= 0); + current = await act(index, { type: 'passResponse' }); + } + const event = current.pendingTreacheryDiscard!.batch.event; + assert.equal(current.pendingTreacheryDiscard?.continuation.kind, 'choamGamontDiscard'); + assert.equal(current.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(current.players[1].forces['arrakeen:10'], 1); + assert.equal(current.players[1].reserves, 19); + assert.equal(current.discard.filter(card => card.id === used.id).length, 1); + await f.restart().continueRoomAutomatic(code, clock); + assert.equal((await f.restart().readRoom(code)).version, current.version); + assert.equal((await f.restart().readSeatView(code, auths[1])).semutaReaction?.canCommit, true); + const claimed = await act(1, { type: 'semutaCommit', event }); + assert.equal(claimed.pendingTreacheryDiscard, null); + assert.equal(claimed.players[1].forces['arrakeen:10'], undefined); + assert.equal(claimed.players[1].reserves, 20); + assert.equal(claimed.players[1].hand.filter(card => card.id === used.id).length, 1); + assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1); + await assert.rejects(f.restart().act(code, auths[1], current.version, + { type: 'semutaCommit', event }, clock)); + assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats); + for (const auth of auths) + assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimed, auth.playerId)); + } finally { f.sqlite.close(); } +}); + void test('authenticated CHOAM sale retains paid spice and market after a saved Semuta claim', async () => { const f = unitStore(); try {