diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 0960f541..e0cb916e 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,20 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Semuta / typed and concealed Gamont — 11 October.** A clean proactive +discard now preserves the selected elite counter or native No-Field event, +instead of hard-coding an ordinary return. The original native helper performs +one exact return or reveal/capped recall; zero invents no force and revival +usage is unchanged. All three regressions fail before and pass after. +Types/lint and 163 affected unit cases across nine files pass. +Thirty runtime scenarios cover Advanced elite and Basic/Advanced zero/three +markers with claim/pass and all four legal AI profiles. Real GameTable at 390px +claims all three cases: capped three leaves board1/reserves1/Tanks18, zero leaves +board0/reserves2, and elite returns to elite reserves5. The actual force inspector +shows twenty counters; no overflow. Other parents remain separate. +Temporary QA scaffolds are removed; no saved store or deployed game was targeted. +See [the source/runtime boundary](SEMUTA_SOURCE_UPDATE_20260907.md). + **Semuta / reactive Baliset and La La La — 11 October.** Accepted native declarations now offer their used card after stopping the original uncommitted move or free-force request. Claim/pass installs the same restriction once; diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 73f8777a..c22c4719 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,38 @@ # Dune implementation status +## 11 October 2026 — typed and concealed Gamont with Semuta + +The existing receipt already signs target elite and No-Field custody, but the +admission excluded those targets and the suffix used a literal ordinary-force +argument. The receipt now stores an optional selected elite or native marker +event, validates it and passes it to the unchanged native Gamont helper. +Ordinary selections retain the omitted defaults and original signature shape. +Reactive stopped-marker signatures also use direct optional fields, with +assignments instead of temporary spread objects. + +All three permanent regressions fail before and pass after. The elite case +uses genuine Advanced Emperor setup and a staged mixed stack: exactly the elite +returns, aggregate reserves19 include elite reserves5, and normal/elite revival +usage stays unchanged. The No-Field cases use actual original deployment actions +before the staged Mentat position. Markers remain concealed during the offer; +zero returns no force, while three caps materialization at the two current +reserves and recalls one. Both preserve Tanks18, cards and prior shipment +payment, and reject retired reactions. + +Thirty isolated runtime scenarios cover Advanced elite, Basic/Advanced +zero/three markers, claim/pass and all four legal AI profiles. Real production +GameTable at 390px claims each target case. Its force inspector verifies twenty +physical counters after the capped three reveal: one board, one reserve, +eighteen Tanks. Zero leaves board0/reserves2; elite leaves one normal board +counter and five elite reserves. There is no horizontal overflow. +Types/lint and 163 affected unit cases across nine files pass. + +Temporary scripts, isolated server and browser tab are removed/stopped; no +saved store or deployed game was targeted. Closing Mentat, Nexus and competing +parents remain separate. Full Semuta/CHOAM and deployed acceptance remain open. +Contracts: [Semuta source/runtime](SEMUTA_SOURCE_UPDATE_20260907.md), +[discard suffix](SEMUTA_DISCARD_CONTINUATIONS.md). + ## 11 October 2026 — reactive Worthless denials with Semuta The existing `choamBalisetDiscard` and `choamLaLaLaDiscard` receipts now admit diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 5dee9d5a..4b24d63f 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -144,14 +144,13 @@ ordinary declared move is already stopped without spending its allowance. Nexus, movement-card/Ambassador 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. +Clean proactive Trip to Gamont stages `choamGamontDiscard` after the accepted +public card cost but before its selected ordinary/elite return or native +No-Field reveal and capped recall. The receipt binds location, exact counter +type/marker event, target force/reserve/elite/No-Field custody and CHOAM hand. +Claim or decline completes the original selection once without replaying the +response. A zero marker reveals without inventing a force; revival usage stays +unchanged. Closing Mentat, Nexus and competing parents retain automatic paths. Accepted printed Jubba Cloak stages `choamJubbaDiscard` after its public Worthless card cost but before protecting CHOAM's diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 947b6b96..b2131351 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -149,13 +149,12 @@ legal. A clean ordinary declared move is already stopped without spending a movement. Movement-card/Ambassador parents, 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. +A clean proactive Trip to Gamont response pauses after its public card cost, +before returning the selected ordinary/elite physical force or performing the +native concealed No-Field reveal and capped recall. The receipt binds target, +location, exact counter type or marker event and pre-return custody. Claim or +decline applies the original selection once; a zero marker reveals without +inventing a force. Closing Mentat, Nexus and competing parents remain automatic. An accepted printed Jubba Cloak response pauses after its public Worthless card cost but before the chosen CHOAM territory @@ -248,6 +247,38 @@ 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. +**11 October 2026 — elite and concealed Gamont targets.** The existing +receipt already signed target elite and No-Field custody, but stored neither +selection and resumed with a literal ordinary-force argument. It now carries +the selected elite flag or native marker event, validates the original +selection and resumes `completeChoamGamont` with those exact values. The native +return/reveal rules are unchanged; ordinary receipts keep their omitted defaults. + +All three permanent regressions fail before and pass after. An Advanced mixed +Emperor stack loses only its selected elite: one normal remains, aggregate +reserves19 include all five elites, and revival usage is unchanged. Native +zero/three markers are deployed through actual shipment before the staged +Mentat case. They remain concealed during the offer; claim/pass performs one +native reveal and reserve-capped recall. With reserves2/Tanks18, zero returns +nothing and keeps reserves2; three materializes two, recalls one and leaves +board1/reserves1. Card custody and retired-event rejection remain checked. + +Thirty isolated runtime scenarios cover Advanced elite and Basic/Advanced +zero/three targets, claim/pass and all four legal AI profiles. Real GameTable at +390px commits Semuta in all three target cases. The public force inspector shows +20 counters after the capped three reveal: board1/reserves1/Tanks18. Zero has no +board force and reserves2; elite leaves one normal on board and elite reserves5. +No surface overflows horizontally. The QA cases stage actual completed native +actions in memory; no saved store or deployed game was targeted. Temporary +scripts/server/browser are removed or stopped. +Types/lint and 163 affected unit cases across nine files pass. + +Optional signature fields now serialize directly, and reactive stopped markers +are assigned without temporary spread objects. Absent optional fields are +omitted by JSON, preserving the prior ordinary/proactive signature shape. +Closing Mentat, Nexus and competing parents remain separate. This is bounded +counter-selection coverage, not complete Semuta/CHOAM or deployed acceptance. + **11 October 2026 — reactive Baliset and La La La.** Accepted native declarations now offer their used public card in the clean reactive paths too. After the cost, the uncommitted ordinary move or force-revival request is diff --git a/game/card-presentation.ts b/game/card-presentation.ts index 42ff390d..b397f1fb 100644 --- a/game/card-presentation.ts +++ b/game/card-presentation.ts @@ -40,7 +40,7 @@ export function richeseCardActionBlock(card: VisibleCard): string | null { 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. Clean ordinary-card, paid Box, retired Ornithopter (before native Intrusion), Distrans, Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling CHOAM Charity, Inflation or Bene Gesserit Charity, battle-card and Ix-deck Thumper/Amal discards are connected, as are private normal-auction and Black Market Ixian allied replacements. Battle discards may pause before native Advanced Harkonnen capture; capture resumes after cleanup. A Basic Richese/Moritani CHOAM/Ecaz profile also reacts to private Sabotage and Robbery overflow after clean shipment/movement arrivals. Clean reactive Baliset and La La La also pause after cost without completing the stopped move or free revival. The approved full-hand exchange spends Semuta without increasing the completed hand. Other pending rewards, producers and nested responses remain unfinished.'; + return 'Use the neutral fresh-discard response in the Semuta development preview. Clean ordinary-card, paid Box, retired Ornithopter (before native Intrusion), Distrans, Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling CHOAM Charity, Inflation or Bene Gesserit Charity, battle-card and Ix-deck Thumper/Amal discards are connected, as are private normal-auction and Black Market Ixian allied replacements. Battle discards may pause before native Advanced Harkonnen capture; capture resumes after cleanup. A Basic Richese/Moritani CHOAM/Ecaz profile also reacts to private Sabotage and Robbery overflow after clean shipment/movement arrivals. Clean reactive Baliset and La La La pause after cost without completing the stopped move or free revival. Proactive Trip to Gamont preserves the exact ordinary/elite return or native No-Field reveal and capped recall. The approved full-hand exchange spends Semuta without increasing the completed hand. Other pending rewards, producers and nested responses remain unfinished.'; return definition && definition.card.effect !== 'karama' ? 'This Richese card’s effect is not implemented yet. You can inspect its rules.' : null; @@ -92,7 +92,7 @@ export function cardPresentation(card: VisibleCard): CardPresentation { 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, paid Box disposal, retired Ornithopter movement (including a following Bene Gesserit Intrusion), Distrans, ordinary and queued definite Truthtrance answers, four Sapho scopes, unready pre-leader Residual Poison, printed Karama cancelling CHOAM Charity/Inflation/Bene Gesserit Charity or settling a normal-auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset, ordinary Trip to Gamont Worthless use, accepted Jubba Cloak before storm protection, Ix-deck Thumper/Amal, three battle discard stages (including before native Advanced Harkonnen capture) and private normal-auction or Black Market Ixian ally replacements. The Basic Richese/Moritani CHOAM/Ecaz entry adds private Sabotage and Robbery overflow after clean shipment/movement arrivals, preserving the original victim/draw and arrival suffix. Commitment is owner-only; the approved full-hand exchange spends Semuta without increasing your completed hand. Other pending rewards, producers, nested responses and normal expansion starts remain unfinished.' + ? 'The explicit Semuta development preview offers neutral responses after clean public ordinary-card discards, paid Box disposal, retired Ornithopter movement (including a following Bene Gesserit Intrusion), Distrans, ordinary and queued definite Truthtrance answers, four Sapho scopes, unready pre-leader Residual Poison, printed Karama cancelling CHOAM Charity/Inflation/Bene Gesserit Charity or settling a normal-auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset, proactive Trip to Gamont with an ordinary/elite force or concealed No-Field, accepted Jubba Cloak before storm protection, Ix-deck Thumper/Amal, three battle discard stages (including before native Advanced Harkonnen capture) and private normal-auction or Black Market Ixian ally replacements. The Basic Richese/Moritani CHOAM/Ecaz entry adds private Sabotage and Robbery overflow after clean shipment/movement arrivals, preserving the original victim/draw and arrival suffix. Commitment is owner-only; the approved full-hand exchange spends Semuta without increasing your completed hand. Other pending rewards, producers, nested responses 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 92e85e67..d001d099 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1397,6 +1397,8 @@ export type Game = { card: string; target: string; location: string; + elite?: 1; + noFieldEvent?: string; stateSignature: string; } | { @@ -5220,7 +5222,7 @@ function choamLaLaLaDiscardSignature(g: Game, c: ChoamLaLaLaDiscardContinuation) const owner = getPlayer(g, c.player), target = getPlayer(g, c.target); return JSON.stringify({ player: c.player, card: c.card, target: c.target, - ...(c.stoppedRevival ? { stoppedRevival: true } : {}), + stoppedRevival: c.stoppedRevival, turn: g.turn, phase: g.phase, active: g.active, order: g.order, revivalRules: g.revivalRules, pendingRevival: g.pendingRevival, parent: nullentropyParentSignature(g, @@ -5238,7 +5240,7 @@ function choamBalisetDiscardSignature(g: Game, c: ChoamBalisetDiscardContinuatio const owner = getPlayer(g, c.player); return JSON.stringify({ player: c.player, card: c.card, target: c.target, territory: c.territory, - ...(c.stoppedMovement ? { stoppedMovement: true } : {}), + stoppedMovement: c.stoppedMovement, turn: g.turn, phase: g.phase, active: g.active, order: g.order, restrictions: g.choamBaliset, remaining: g.movementRemaining, parent: nullentropyParentSignature(g, @@ -5256,6 +5258,7 @@ 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, + elite: c.elite, noFieldEvent: c.noFieldEvent, turn: g.turn, phase: g.phase, active: g.active, order: g.order, ready: g.ready, storm: g.storm, parent: nullentropyParentSignature(g, @@ -5966,7 +5969,10 @@ function treacheryDiscardIntegrity(g: Game) { requireRule( g.phase === 8 && owner?.faction === 'choam' && c.target !== c.player && - gamontAvailable(g, c.target, c.location, 0) && + (c.elite === undefined || c.elite === 1) && + (c.noFieldEvent === undefined || + (typeof c.noFieldEvent === 'string' && c.noFieldEvent.length > 0)) && + gamontAvailable(g, c.target, c.location, c.elite ?? 0, c.noFieldEvent) && !g.pendingChoamWorthless && !g.response && !g.decision && !g.phaseOpening && !g.choamMarket && batch.cause === 'choam:gamont' && batch.entries.length === 1 && @@ -5974,7 +5980,7 @@ function treacheryDiscardIntegrity(g: Game) { 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.', + 'The used Trip to Gamont no longer matches its pending typed force return.', ); } else if (continuation?.kind === 'choamJubbaDiscard') { const c = continuation, entry = batch.entries[0]; @@ -6959,7 +6965,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string log(g, 'The declared move was prevented without moving forces or spending a movement.'); } else if (next.kind === 'choamGamontDiscard') { completeChoamGamont(g, getPlayer(g, next.player), next.target, - next.location, 0); + next.location, next.elite ?? 0, next.noFieldEvent); } else if (next.kind === 'choamJubbaDiscard') { completeChoamJubba(g, getPlayer(g, next.player), next.territory); offerChoamStorm(g); @@ -24028,8 +24034,8 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) kind: 'choamBalisetDiscard', player: choam.id, card: used.id, target: pending.target!, territory: pending.location!, stateSignature: '', - ...(movement ? { stoppedMovement: true as const } : {}), }; + if (movement) continuation.stoppedMovement = true; const entries = [{ card: used, discardedBy: choam.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = choamBalisetDiscardSignature(g, continuation); @@ -24052,7 +24058,6 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) } } else if (pending.effect === 'gamont') { 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 = { @@ -24060,6 +24065,8 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) card: used.id, target: pending.target!, location: pending.location!, stateSignature: '', }; + if (pending.elite === 1) continuation.elite = 1; + if (pending.noFieldEvent) continuation.noFieldEvent = pending.noFieldEvent; const entries = [{ card: used, discardedBy: choam.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = choamGamontDiscardSignature(g, continuation); @@ -24079,8 +24086,8 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) const continuation: ChoamLaLaLaDiscardContinuation = { kind: 'choamLaLaLaDiscard', player: choam.id, card: used.id, target: pending.target!, stateSignature: '', - ...(revival ? { stoppedRevival: true as const } : {}), }; + if (revival) continuation.stoppedRevival = true; const entries = [{ card: used, discardedBy: choam.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = choamLaLaLaDiscardSignature(g, continuation); diff --git a/game/reference.ts b/game/reference.ts index b19444be..bd2ff07f 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1169,7 +1169,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, definite ordinary Truthtrance answers including queued questions, supported shipment promises and battle answers before or after plan submission/reveal, 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 or reactive La La La disposal before blocking free revival, proactive or reactive Baliset before restricting movement, proactive ordinary Trip to Gamont before returning a force, accepted Jubba Cloak before storm protection, and three battle batches. A private normal-auction or Black Market Ixian ally replacement also pauses before drawing, without showing its face until a holder commits; original payment and seller income settle once. Cache purchases cannot use native Ixian allied replacement. Ordinary definite Truthtrance answers pause even when another question stays reserved or the answer bound a supported shipment or unsubmitted battle-plan promise. Submitted/revealed plans bind no new promise, but their answers still offer Semuta without changing the plan. Under the user-selected exchange ruling, Commit may replace Semuta even at your faction hand limit: acquisition and Semuta disposal settle atomically, without increasing your completed hand. Reserved incoming cards still need room. For multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, other reactive Worthless uses, elite or concealed Trip returns, inspected/partly ready Residual Poison battles, nested responses, other pending rewards 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, definite ordinary Truthtrance answers including queued questions, supported shipment promises and battle answers before or after plan submission/reveal, 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 or reactive La La La disposal before blocking free revival, proactive or reactive Baliset before restricting movement, proactive Trip to Gamont before its ordinary/elite return or native No-Field reveal and capped recall, accepted Jubba Cloak before storm protection, and three battle batches. A private normal-auction or Black Market Ixian ally replacement also pauses before drawing, without showing its face until a holder commits; original payment and seller income settle once. Cache purchases cannot use native Ixian allied replacement. Ordinary definite Truthtrance answers pause even when another question stays reserved or the answer bound a supported shipment or unsubmitted battle-plan promise. Submitted/revealed plans bind no new promise, but their answers still offer Semuta without changing the plan. Under the user-selected exchange ruling, Commit may replace Semuta even at your faction hand limit: acquisition and Semuta disposal settle atomically, without increasing your completed hand. Reserved incoming cards still need room. For multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, other reactive Worthless uses, inspected/partly ready Residual Poison battles, nested responses, other pending rewards and public starts remain unfinished.' : 'Reference component only: this Richese card’s game actions are not enabled.', ...(definition.card.effect === 'semutaDrug' ? [ 'Mandatory and winner battle discards also pause before native Advanced Harkonnen capture. Claim or Continue completes the original casualties and card cleanup before the capture offer; it does not sample a captive early or repeat capture. Other pending rewards remain outside this bounded path.', @@ -1177,6 +1177,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'A completed paid Nullentropy Box search during battle offers only the used Box. Claim or Continue preserves the two-spice payment, private recovered card, already-shuffled pile and sealed opposing plan. The recovered card may be used in normal legal planning; the search is not repeated.', 'A clean Distrans transfer during battle also offers only the used Distrans. The private card has already reached its recipient; claim or Continue cannot repeat the gift or change a sealed plan. A recipient holding Semuta may claim the used Distrans and keep the already-transferred card.', 'Reactive Baliset and La La La also offer their used card after the accepted cost. The original uncommitted move or free-revival request is already stopped: claim or Continue installs the original restriction without moving forces, consuming movement, reviving or charging the denied request. Another legal move or paid revival remains available. Movement-card, Ambassador, Nexus and other competing parents remain separate.', + 'A clean proactive Trip to Gamont also pauses for a selected elite or concealed No-Field. Claim or Continue returns the exact selected physical counter, or performs the native No-Field reveal and returns at most one actual force after reserve-capped materialization. A zero token reveals without inventing a force; taking the used card does not undo that effect or spend revival usage.', 'The same local semuta entry also admits Basic native Richese and Moritani with classic opponents, exactly CHOAM/Ecaz decks and no optional modules. A clean shipment/movement Sabotage or Robbery overflow offers its private fresh discard without showing the face before commitment. Claim or Continue preserves the original victim/draw, removed token, shipment payment and forces; Sabotage may then offer its original optional gift. Advanced Moritani, native Ecaz, Ix/E3 mixtures and worm/Ambassador/overlap arrivals remain outside this bounded entry. The table visibly labels the development preview.', ] : []), ], diff --git a/tests/semuta-engine.test.ts b/tests/semuta-engine.test.ts index c3303472..e5607600 100644 --- a/tests/semuta-engine.test.ts +++ b/tests/semuta-engine.test.ts @@ -25,10 +25,10 @@ function inventory(g: Game) { assert.equal(new Set(ids).size, ids.length); return ids; } -function fixture(ix = false, ixianAlly = false, advanced = false, opponent: 'emperor' | 'beneGesserit' | 'harkonnen' | 'moritani' | 'choam' = 'emperor') { +function fixture(ix = false, ixianAlly = false, advanced = false, opponent: 'emperor' | 'beneGesserit' | 'harkonnen' | 'moritani' | 'choam' = 'emperor', actor: 'atreides' | 'emperor' = 'atreides') { let g = createGame('SEMUTAQ2', newPlayer('r', 'Richese', 'richese'), advanced, opponent === 'moritani' ? ['choam', 'ecaz'] : ix ? ['choam', 'ix'] : ['choam']); - joinGame(g, newPlayer('a', 'Atreides', 'atreides')); + joinGame(g, newPlayer('a', actor === 'emperor' ? 'Emperor' : 'Atreides', actor)); joinGame(g, newPlayer('e', ixianAlly ? 'Ixians' : opponent === 'beneGesserit' ? 'Bene Gesserit' : opponent === 'harkonnen' ? 'Harkonnen' : opponent === 'moritani' ? 'Moritani' : opponent === 'choam' ? 'CHOAM' : 'Emperor', ixianAlly ? 'ixians' : opponent)); for (const p of g.players) { p.bot = 'Medium'; p.ready = true; } @@ -53,6 +53,90 @@ function fixture(ix = false, ixianAlly = false, advanced = false, opponent: 'emp return { g, semuta, hajr }; } +void test('Semuta preserves one elite Gamont return without spending revival usage', () => { + const { g, semuta } = fixture(false, false, true, 'choam', 'emperor'); + const card = take(g, g.deck.find(c => c.name === 'Trip to Gamont')!.id); + player(g, 'e').hand.push(card); + for (const p of g.players) Object.assign(p, { forces: {}, reserves: 20, tanks: 0 }); + const target = player(g, 'a'); + Object.assign(target, { forces: { 'arrakeen:10': 2 }, reserves: 18 }); + Object.assign(target.elites!, { forces: { 'arrakeen:10': 1 }, reserves: 4, tanks: 0 }); + Object.assign(g, { phase: 8, active: null, movementRemaining: [] }); + const original = inventory(g); + const usage = { normal: target.revived, elite: target.elites!.revived }; + let pending = applyAction(g, 'e', { type: 'card', mode: 'choam', card: card.id, + target: 'a', from: 'arrakeen:10', elite: 1 }); + while (pending.response) { + const actor = pending.players.find(p => !pending.response!.passed.includes(p.id))!; + pending = applyAction(pending, actor.id, { type: 'passResponse' }); + } + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(player(pending, 'a').forces['arrakeen:10'], 2); + const event = pending.pendingTreacheryDiscard!.batch.event; + let declined = reload(pending); + for (const id of ['a', 'e', 'r']) declined = applyAction(declined, id, { type: 'semutaPass', event }); + const claimed = applyAction(reload(pending), 'r', { type: 'semutaCommit', event }); + for (const resumed of [declined, claimed]) { + const returned = player(resumed, 'a'); + assert.deepEqual(returned.forces, { 'arrakeen:10': 1 }); + assert.deepEqual(returned.elites!.forces, {}); + assert.equal(returned.reserves, 19); + assert.equal(returned.elites!.reserves, 5); + assert.equal(returned.tanks, 0); + assert.deepEqual({ normal: returned.revived, elite: returned.elites!.revived }, usage); + assert.deepEqual(inventory(resumed), original); + assert.throws(() => applyAction(resumed, 'r', { type: 'semutaCommit', event })); + } + assert.equal(player(claimed, 'r').hand.some(c => c.id === card.id), true); + assert.equal(claimed.discard.some(c => c.id === semuta.id), true); +}); + +for (const value of [0, 3] as const) void test( + `Semuta preserves the native ${value} No-Field Gamont reveal and reserve-capped return`, () => { + const { g, semuta } = fixture(false, false, false, 'choam'); + const card = take(g, g.deck.find(c => c.name === 'Trip to Gamont')!.id); + player(g, 'e').hand.push(card); + for (const p of g.players) Object.assign(p, { forces: {}, reserves: 20, tanks: 0, shipped: false, moved: 0 }); + let state = applyAction(g, 'a', { type: 'endMovement' }); + assert.equal(state.active, 'r'); + const owner = player(state, 'r'); + const token = owner.noField!.tokens.find(t => t.value === value)!; + state = applyAction(state, 'r', { type: 'ship', noField: token.id, + event: owner.noFieldEvent, territory: 'imperial_basin', sector: 10 }); + assert.ok(player(state, 'r').noField!.deployed); + Object.assign(player(state, 'r'), { reserves: 2, tanks: 18 }); + Object.assign(state, { phase: 8, active: null, movementRemaining: [] }); + const original = inventory(state); + const wallet = player(state, 'r').spice; + let pending = applyAction(state, 'e', { type: 'card', mode: 'choam', card: card.id, + target: 'r', from: 'imperial_basin:10', elite: 0 }); + while (pending.response) { + const actor = pending.players.find(p => !pending.response!.passed.includes(p.id))!; + pending = applyAction(pending, actor.id, { type: 'passResponse' }); + } + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.ok(player(pending, 'r').noField!.deployed); + assert.deepEqual(player(pending, 'r').forces, {}); + assert.equal(player(pending, 'r').reserves, 2); + const event = pending.pendingTreacheryDiscard!.batch.event; + let declined = reload(pending); + for (const id of ['r', 'a', 'e']) declined = applyAction(declined, id, { type: 'semutaPass', event }); + const claimed = applyAction(reload(pending), 'r', { type: 'semutaCommit', event }); + for (const resumed of [declined, claimed]) { + const returned = player(resumed, 'r'); + const revealed = Math.min(value, 2), recalled = revealed > 0 ? 1 : 0; + assert.equal(returned.noField!.deployed, null); + assert.equal(returned.forces['imperial_basin:10'] ?? 0, revealed - recalled); + assert.equal(returned.reserves, 2 - revealed + recalled); + assert.equal(returned.tanks, 18); + assert.equal(returned.spice, wallet); + assert.deepEqual(inventory(resumed), original); + assert.throws(() => applyAction(resumed, 'r', { type: 'semutaCommit', event })); + } + assert.equal(player(claimed, 'r').hand.some(c => c.id === card.id), true); + assert.equal(claimed.discard.some(c => c.id === semuta.id), true); + }); + for (const effect of ['Baliset', 'La La La'] as const) void test( `Semuta preserves reactive ${effect} denial before native resumption`, () => { const { g, semuta } = fixture(false, false, false, 'choam');