From bb6835e6c3417e795b8fdb302233a9f9e0715e3d Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:50:39 +1100 Subject: [PATCH] Preserve original Cunning fuel and suffix through Semuta --- components/game-table.tsx | 6 +- docs/CURRENT_STATUS.md | 25 ++ docs/IMPLEMENTATION_STATUS.md | 49 +++ docs/NEXUS_CHOAM_RULES.md | 11 + docs/NEXUS_CHOAM_RUNTIME.md | 42 +++ docs/SEMUTA_SOURCE_UPDATE_20260907.md | 15 + game/engine.ts | 124 ++++++-- game/reference.ts | 3 + .../fixture-semuta-nexus-cunning-reactive.ts | 259 +++++++++++++++ tests/fixture-semuta-nexus-cunning.ts | 295 ++++++++++++++++++ tests/semuta-nexus-cunning-proactive.test.ts | 210 +++++++++++++ tests/semuta-nexus-cunning-reactive.test.ts | 232 ++++++++++++++ 12 files changed, 1237 insertions(+), 34 deletions(-) create mode 100644 tests/fixture-semuta-nexus-cunning-reactive.ts create mode 100644 tests/fixture-semuta-nexus-cunning.ts create mode 100644 tests/semuta-nexus-cunning-proactive.test.ts create mode 100644 tests/semuta-nexus-cunning-reactive.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 7ee79f19..63057bfc 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1170,8 +1170,10 @@ export function GameTable({ only at the end of Mentat. The explicit Leader Skills entry retains all fourteen original skills and native training. The explicit Nexus entry retains all twelve original Nexus cards and qualifying closing draws, - never a starting Nexus hand. CHOAM Secret Ally trade and native CHOAM - Betrayal discards can react before their original receipts complete; + never a starting Nexus hand. CHOAM Secret Ally trade, native CHOAM + Betrayal and the five ordinary Cunning fuel discards can react before + their original effects or receipts finish. Cunning fuel keeps its + printed identity and private face; Kull retains its separate guard. Homeworlds, Discovery and other overlays stay guarded. Native Moritani support is Basic only: clean shipment/movement Sabotage and Robbery diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 4cba64de..18584b43 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,31 @@ 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 / original CHOAM Cunning fuel — 11 October.** The five ordinary +Cunning effects now reuse original Worthless continuations with a completed +native event binding owner/card/effect/targets and parent flags. Canonical +arbitrary fuel stays private, never a manufactured named Worthless. Native +cancellation retains fuel and the spent Nexus; allowance offers the exact +fresh discard before one original effect/suffix. Old printed receipt signatures +are unchanged when the optional event is absent. +Actual Kulon before cutover discarded fuel/applied its bonus without an offer; +afterward Commit restores one bonus and permits one real distance-two move. +Types/lint pass; affected union120 cases across9 files. Two proactive and24 +reactive runtime scenarios cover Basic/Advanced claim/pass, four legal profiles +and original skill/module closing. Actual Advanced storm uses its original +revealed card, not the Basic fixture's dialers. Incorrect test-only absence, +response, alias-binding and protected-spice assumptions were repaired; no rule +was changed to satisfy them, and paired-view audit scaffolds were removed. +Five styled390px human power→native Allow→full-hand Commit paths pass. +Gamont returns an elite before restoring original Finish/claim2/turn3; La La La +keeps3Tanks/0revived/10spice until a legal paid-two revival reaches1/2/6. +Advanced Jubba keeps3 protected forces, loses3 other CHOAM and4 opposing forces, +destroys both covered spice piles and reaches original market. Original +projectile inspector and updated preview guidance were inspected. +All own probes/service/tab removed; existing games/stores/PDF, deployment, +Kull and full-mode/public gates remain unchanged. See +[five-effect contract](NEXUS_CHOAM_RUNTIME.md#original-five-effect-semuta-composition--11-october-2026). + **Semuta / original native Nexus — 11 October.** Fresh `semuta --nexus-cards` delegates original single/paired CHOAM or both selected E1/E2 setup, optionally explicit Skills and original Tech/Advanced Strongholds. There is no starting diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index b8980fd3..ddc7851a 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,54 @@ # Dune implementation status +## 11 October 2026 — five original Cunning fuel discards through Semuta + +Fresh native Nexus admission made five previously excluded `pending.nexusEvent` +costs reachable. Actual Kulon consumed a canonical projectile, spent the Nexus +and applied bonus1 with no neutral Semuta opportunity. Producer cutover now +reuses original Kulon/Gamont/Baliset/La La La/Jubba continuations with an +optional completed Cunning event. Arbitrary fuel keeps its actual printed +descriptor and private face, unlike the already-public named printed power. + +Five existing signatures include the optional event; undefined omission +preserves old printed shapes. The validator binds the last original completed +Nexus receipt/frame to the exact owner/card/effect/targets and source flags, +retaining original force/parent/state checks. Native cancellation retains fuel +with Nexus spent. Allowance retires the original receipt and pauses after +physical disposal, before the one selected effect and original suffix. +No new initializer, payment, named-card transformation or Kull capability. + +Two disjoint lanes supplied shared genuine setup/closing-draw and proactive +or reactive fixtures. Actual a/b alliance leaves CHOAM unallied to draw its +own card; only afterward are existing identities/conserved later stocks staged. +Parent removed static-markup/wording, forwarding/re-export and paired-view audit +scaffolds. New tests had incidental null/undefined and guaranteed-response +assumptions; the latter now holds an actual printed Karama instead of changing +normalizer behavior. Jubba's incorrect protected-spice expectation was repaired: +printed protection covers forces only. No application storm rule changed. + +The reactive fixture assumed Basic dialers in Advanced and ran to final fallback +without reaching its boundary. A real trace showed Advanced turn3 card distance6 +and zero dialers. The fixture now retains that actual card distance, enumerates +all exposed territories in crossed sectors and uses original readiness; no +distance/clock/phase is fabricated or rerolled. + +Types/lint pass; affected union120/120 across9 unit files. Two proactive and24 +reactive actual runtime cases cover Basic/Advanced claim/pass, four profiles, +full hands and original all14/Tech/Strongholds closing. Singleton Commit stays +automatic. Styled390px actual GameTable completes all five powers through +original Allow and full-hand Commit. Acquired Slip Tip retains projectile +text/inspector. Gamont's Hajr fuel never activates Hajr: one elite returns, then +only original Finish assigns claim2/turn3. La La La preserves3Tanks/0revived/ +10spice before human paid-two revival reaches1Tank/2revived/6spice. +Advanced Jubba's Crysknife claim preserves3 selected forces, loses3 other CHOAM +and4 opposing forces, destroys both crossed spice piles and reaches original +CHOAM market once. All settled frames390px; current warning was reinspected. + +Temporary scripts/client/server, own service and tab removed. Existing games, +stores, user PDF, provider/deployment and public gates untouched; no broad +recovery/privacy/custody campaign. Full game/module acceptance and Kull remain +separate from this bounded original five-effect composition. + ## 11 October 2026 — original native Nexus and Semuta discard receipts `initializeSemutaGameForAudit` delegates existing unused original Nexus setup diff --git a/docs/NEXUS_CHOAM_RULES.md b/docs/NEXUS_CHOAM_RULES.md index 4fe9f6e8..19c8f0a2 100644 --- a/docs/NEXUS_CHOAM_RULES.md +++ b/docs/NEXUS_CHOAM_RULES.md @@ -37,6 +37,17 @@ On an allowed use, the **original physical card** crosses into discard exactly o The effect choice and native use may be public without revealing the rest of CHOAM's hand. Do not add a public poison-income message that identifies a privately discarded category. Preserve existing discard visibility. Successful disposal must pass through the current Semuta/reservation handling, and an interruption must resume the chosen effect and its original parent once. A later retrieval of the discarded card does not undo an already earned effect or permit replaying its receipt. +The explicit native Richese `semuta --nexus-cards` composition now connects +the five ordinary Cunning fuel disposals through those original continuations. +An optional completed Cunning event binds the exact original card and chosen +effect/parent; its arbitrary fuel face remains private until Semuta commitment. +Claim/pass completes the same selected effect once. Ordinary cancellation +retains fuel and spends Nexus without offering that fuel; a separately spent +printed Karama is a distinct discard producer, not a categorical Semuta ban. +The [dated runtime proof](NEXUS_CHOAM_RUNTIME.md#original-five-effect-semuta-composition--11-october-2026) +does not add Kull or weaken its separate source/profile boundary. + + ## Existing boundaries to preserve - **Kull:** the [source update](CHOAM_KULL_SOURCE_UPDATE.md#user-selected-preview-timing--30-september-2026) records approved distinct-card counters and BG-before-conversion custody as user-selected product policy, not publisher clarification. The new `nexus-kull` profile composes those policies with physical any-Treachery fuel and CHOAM Nexus Cunning. It reserves the interrupted original, retains it and any special once-use on success, and resumes it once on decline or prevention. Unpayable winning-overbid recovery is deliberately deferred; the existing private precommitment fence remains independent of hidden ownership. The old `kull` profile still excludes Nexus, and other Nexus previews do not gain this choice. diff --git a/docs/NEXUS_CHOAM_RUNTIME.md b/docs/NEXUS_CHOAM_RUNTIME.md index 1a034e14..2f433bfb 100644 --- a/docs/NEXUS_CHOAM_RUNTIME.md +++ b/docs/NEXUS_CHOAM_RUNTIME.md @@ -2,6 +2,48 @@ The historical checkpoint below integrated native CHOAM Cunning with five Worthless effects in Basic and Advanced. The separately verified 1 October 2026 `nexus-kull` profile adds bounded source-aware Kull, with broader verification **Partial**. The [source contract](NEXUS_CHOAM_RULES.md) lists all six printed effects, not a full-family release. The older [printed Kull profile](CHOAM_KULL_DESIGN.md) still excludes Nexus; other Nexus previews still reject Kull. CHOAM Secret Ally and Betrayal retain separate contracts. No profile silently enables new choices in older games or certifies complete CHOAM, Nexus or expansions. +## Original five-effect Semuta composition — 11 October 2026 + +Fresh native Richese `semuta --nexus-cards` now offers the original CHOAM +Cunning fuel discard for Kulon, Trip to Gamont, Baliset, La La La and Jubba. +Actual qualifying closing draw leaves CHOAM unallied with its own Nexus. +The original quote chooses any eligible canonical held Treachery fuel, not a +manufactured named Worthless card. Native cancellation retains that fuel and +the already-spent Nexus; no fuel is discarded or offered on prevention. + +Allowance retires the original Cunning receipt and discards its exact fuel. +The existing five Worthless continuations carry an optional completed +`nexusEvent`, bound to original owner/card/effect/targets and parent flags. +Old printed signatures remain unchanged when the optional event is absent. +Cunning cost faces remain private through the neutral offer; singleton Commit +acquires the original descriptor and resumes the original selected effect once. +The original ordinary range bonus, typed return and closing Mentat owner, +stopped move/free revival, or threatened-territory storm suffix is not replayed. +Jubba protects CHOAM forces, never storm-covered spice; unprotected own/opposing +forces and both covered spice piles retain their original losses. + +Actual native Kulon exposed the omission before cutover: fuel discarded, +Nexus spent and bonus applied with no Semuta opportunity. After cutover, the +cost pauses before that one native bonus. Two proactive and24 reactive +runtime cases cover Basic/Advanced claim/pass, four legal profiles, full +hands and original Skills/Tech/Strongholds closing. Actual Advanced storm +uses its revealed original card distance, not Basic dialers; the fixture now +uses that real source rather than an unreachable Basic-only boundary. +The affected unit union passes120/120 across9 files. + +Styled390px human controls complete all five original quoted powers through +native Allow and full-hand Commit. Slip Tip, Hajr, Stunner, Maula Pistol and +Crysknife remain their actual printed categories. Gamont returns one elite and +restores its original Finish; only that Finish assigns Stronghold claim2/turn3. +La La La leaves3 +Tanks/0revived/10spice before a legal paid-two revival reaches1Tank/2revived/ +6spice. Advanced Jubba keeps3 protected forces, loses3 other CHOAM and4 +opposing forces, destroys both covered spice piles and reaches original market. +Acquired projectile inspector and current preview guidance were inspected. +Own probes/service/tab are removed; existing games/stores/PDF and deployment +are untouched. Kull, unrelated parents/modules and complete-mode/public gates +remain separate. No comprehensive custody/privacy/recovery campaign is claimed. + ## Physical cost and timing An unallied CHOAM player holding the CHOAM Nexus chooses one actual held Treachery Card and one supported effect. The card need not have the corresponding Worthless name. The engine preserves its original identity, ownership and any reservations; it does not manufacture a named Worthless card or grant a turn-long conversion ability. diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 3767e4c3..3efaeb67 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -123,6 +123,21 @@ player invoked Betrayal. Claim/pass completes the same sampled receipt. CHOAM receives no compensation and the random sample is never repeated. Nexus cards are separate components and never become Treachery targets. +The five original native CHOAM Cunning effects also offer their exact arbitrary +Treachery fuel after allowance: Kulon, Gamont, Baliset, La La La and Jubba. +The original Nexus receipt has already completed, but the selected effect +and its original parent wait behind the fresh discard. An optional native +event binds owner/card/effect/targets without changing old printed receipts. +Fuel keeps its canonical category and private face; Commit does not transform +a projectile, defense or Special into a named Worthless. Claim/pass grants +one range bonus, one typed return/original closing owner, the stopped ordinary +move/free-revival restriction, or one native storm protection and suffix. +Jubba protects forces, not spice; original unprotected own/opposing losses remain. +Cancellation retains that fuel and leaves the played Nexus spent. Kull and +other competing parents remain separate. See +[five-effect evidence](NEXUS_CHOAM_RUNTIME.md#original-five-effect-semuta-composition--11-october-2026). + + diff --git a/game/engine.ts b/game/engine.ts index 632a16bf..2a86cdf5 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1371,6 +1371,7 @@ export type Game = { kind: 'choamKulonDiscard'; player: string; card: string; + nexusEvent?: string; before: number; stateSignature: string; } @@ -1378,6 +1379,7 @@ export type Game = { kind: 'choamLaLaLaDiscard'; player: string; card: string; + nexusEvent?: string; target: string; stoppedRevival?: true; stateSignature: string; @@ -1386,6 +1388,7 @@ export type Game = { kind: 'choamBalisetDiscard'; player: string; card: string; + nexusEvent?: string; target: string; territory: string; stoppedMovement?: true; @@ -1395,6 +1398,7 @@ export type Game = { kind: 'choamGamontDiscard'; player: string; card: string; + nexusEvent?: string; target: string; location: string; elite?: 1; @@ -1406,6 +1410,7 @@ export type Game = { kind: 'choamJubbaDiscard'; player: string; card: string; + nexusEvent?: string; territory: string; stateSignature: string; } @@ -5218,6 +5223,7 @@ function choamKulonDiscardSignature(g: Game, c: ChoamKulonDiscardContinuation) { const owner = getPlayer(g, c.player); return JSON.stringify({ player: c.player, card: c.card, before: c.before, + nexusEvent: c.nexusEvent, turn: g.turn, phase: g.phase, active: g.active, order: g.order, movement: g.choamMovement, remaining: g.movementRemaining, parent: nullentropyParentSignature(g, @@ -5235,6 +5241,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, + nexusEvent: c.nexusEvent, stoppedRevival: c.stoppedRevival, turn: g.turn, phase: g.phase, active: g.active, order: g.order, revivalRules: g.revivalRules, pendingRevival: g.pendingRevival, @@ -5253,6 +5260,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, + nexusEvent: c.nexusEvent, stoppedMovement: c.stoppedMovement, turn: g.turn, phase: g.phase, active: g.active, order: g.order, restrictions: g.choamBaliset, remaining: g.movementRemaining, @@ -5271,6 +5279,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, + nexusEvent: c.nexusEvent, elite: c.elite, noFieldEvent: c.noFieldEvent, mentat: c.mentat, mentatPending: c.mentat ? g.choamMentatPending : undefined, turn: g.turn, phase: g.phase, active: g.active, order: g.order, @@ -5291,6 +5300,7 @@ function choamJubbaDiscardSignature(g: Game, c: ChoamJubbaDiscardContinuation) { const owner = getPlayer(g, c.player); return JSON.stringify({ player: c.player, card: c.card, territory: c.territory, + nexusEvent: c.nexusEvent, turn: g.turn, phase: g.phase, active: g.active, order: g.order, storm: g.storm, stormPending: g.stormPending, resolution: g.stormResolution, @@ -5301,6 +5311,62 @@ function choamJubbaDiscardSignature(g: Game, c: ChoamJubbaDiscardContinuation) { ownerForces: owner.forces, ownerElites: owner.elites, }); } +type ChoamCunningDiscardContinuation = + ChoamKulonDiscardContinuation | ChoamLaLaLaDiscardContinuation | + ChoamBalisetDiscardContinuation | ChoamGamontDiscardContinuation | ChoamJubbaDiscardContinuation; +function choamCunningDiscard( + c: NonNullable['continuation'], +): c is ChoamCunningDiscardContinuation & { nexusEvent: string } { + switch (c.kind) { + case 'choamKulonDiscard': + case 'choamLaLaLaDiscard': + case 'choamBalisetDiscard': + case 'choamGamontDiscard': + case 'choamJubbaDiscard': + return typeof c.nexusEvent === 'string' && c.nexusEvent.length > 0; + default: return false; + } +} +/** Cunning changes the physical fuel, not the original quoted effect or its parent. */ +function choamDiscardCostMatches( + g: Game, c: ChoamCunningDiscardContinuation, + entry: FreshDiscardBatch['entries'][number], effect: Exclude, +) { + if (!entry || entry.card.id !== c.card || entry.discardedBy !== c.player) return false; + if (c.nexusEvent === undefined) + return entry.publicFace && entry.card.kind === 'worthless' && + entry.card.name === CHOAM_NEXUS_EFFECTS[effect]; + if (!choamCunningDiscard(c) || entry.publicFace) return false; + nexusChoamIntegrity(g); + const record = g.nexusChoamHistory?.at(-1); + if (!record || record.stage !== 'complete' || record.receipt.event !== c.nexusEvent || + record.receipt.owner !== c.player || record.receipt.card !== c.card || + record.receipt.effect !== effect || record.receipt.turn !== g.turn || + record.receipt.phase !== g.phase) return false; + let original: Game['pendingChoamWorthless']; + try { original = JSON.parse(record.frame); } catch { return false; } + if (!original || original.owner !== c.player || original.card !== c.card || + original.effect !== effect || original.nexusEvent !== c.nexusEvent) return false; + switch (c.kind) { + case 'choamKulonDiscard': + return !original.storm && !original.revival && !original.movement && !original.mentat; + case 'choamLaLaLaDiscard': + return original.target === c.target && original.revival === (c.stoppedRevival === true) && + !original.storm && !original.movement && !original.mentat; + case 'choamBalisetDiscard': + return original.target === c.target && original.location === c.territory && + original.movement === (c.stoppedMovement === true) && + !original.storm && !original.revival && !original.mentat; + case 'choamGamontDiscard': + return original.target === c.target && original.location === c.location && + original.elite === (c.elite ?? 0) && original.noFieldEvent === c.noFieldEvent && + original.mentat === (c.mentat === true) && + !original.storm && !original.revival && !original.movement; + case 'choamJubbaDiscard': + return original.location === c.territory && original.storm === true && + !original.revival && !original.movement && !original.mentat; + } +} type ThumperDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'thumperDiscard' } @@ -5956,9 +6022,7 @@ function treacheryDiscardIntegrity(g: Game) { !g.pendingChoamWorthless && !g.response && !g.decision && !g.phaseOpening && !g.ornithopter && !g.pendingChoamMove && batch.cause === 'choam:kulon' && batch.entries.length === 1 && - entry.publicFace && entry.discardedBy === c.player && - entry.card.id === c.card && entry.card.kind === 'worthless' && - entry.card.name === 'Kulon' && + choamDiscardCostMatches(g, c, entry, 'kulon') && Number.isSafeInteger(c.before) && c.before >= 0 && c.before === (g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0) && c.stateSignature === choamKulonDiscardSignature(g, c), @@ -5975,9 +6039,7 @@ function treacheryDiscardIntegrity(g: Game) { !g.pendingChoamWorthless && !g.pendingRevival && !g.response && !g.decision && !g.phaseOpening && batch.cause === 'choam:laLaLa' && batch.entries.length === 1 && - entry.publicFace && entry.discardedBy === c.player && - entry.card.id === c.card && entry.card.kind === 'worthless' && - entry.card.name === 'La La La' && + choamDiscardCostMatches(g, c, entry, 'laLaLa') && c.stateSignature === choamLaLaLaDiscardSignature(g, c), 'The used La La La no longer matches its pending revival block.', ); @@ -5992,9 +6054,7 @@ function treacheryDiscardIntegrity(g: Game) { !g.pendingChoamWorthless && !g.pendingChoamMove && !g.response && !g.decision && !g.phaseOpening && !g.ornithopter && batch.cause === 'choam:baliset' && batch.entries.length === 1 && - entry.publicFace && entry.discardedBy === c.player && - entry.card.id === c.card && entry.card.kind === 'worthless' && - entry.card.name === 'Baliset' && + choamDiscardCostMatches(g, c, entry, 'baliset') && c.stateSignature === choamBalisetDiscardSignature(g, c), 'The used Baliset no longer matches its pending territory restriction.', ); @@ -6012,9 +6072,7 @@ function treacheryDiscardIntegrity(g: Game) { !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' && + choamDiscardCostMatches(g, c, entry, 'gamont') && c.stateSignature === choamGamontDiscardSignature(g, c), 'The used Trip to Gamont no longer matches its pending typed force return.', ); @@ -6028,9 +6086,7 @@ function treacheryDiscardIntegrity(g: Game) { !g.pendingChoamWorthless && !g.response && !g.decision && !g.phaseOpening && batch.cause === 'choam:jubba' && batch.entries.length === 1 && - entry.publicFace && entry.discardedBy === c.player && - entry.card.id === c.card && entry.card.kind === 'worthless' && - entry.card.name === 'Jubba Cloak' && + choamDiscardCostMatches(g, c, entry, 'jubba') && c.stateSignature === choamJubbaDiscardSignature(g, c), 'The used Jubba Cloak no longer matches its pending storm protection.', ); @@ -6788,7 +6844,7 @@ function semutaOfferSupported( g.players.some(p => p.faction === 'richese') && entries.length > 0 && (continuation.kind === 'ixAllyCard' || continuation.kind === 'terrorDiscard' || - continuation.kind === 'nexusChoamBetrayal' + continuation.kind === 'nexusChoamBetrayal' || choamCunningDiscard(continuation) ? entries.length === 1 && !entries[0].publicFace : continuation.kind === 'battleCleanup' || continuation.kind === 'winnerMandatoryDiscard' || @@ -24066,15 +24122,16 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) return; } if (pending.effect === 'kulon') { - if (g.semutaPreview && used.name === 'Kulon' && + if (g.semutaPreview && (used.name === 'Kulon' || pending.nexusEvent) && !pending.storm && !pending.revival && !pending.movement && - !pending.mentat && !pending.nexusEvent) { + !pending.mentat) { const continuation: ChoamKulonDiscardContinuation = { kind: 'choamKulonDiscard', player: choam.id, card: used.id, before: g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0, stateSignature: '', }; - const entries = [{ card: used, discardedBy: choam.id, publicFace: true }]; + if (pending.nexusEvent) continuation.nexusEvent = pending.nexusEvent; + const entries = [{ card: used, discardedBy: choam.id, publicFace: pending.nexusEvent === undefined }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = choamKulonDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'choam:kulon', entries, continuation); @@ -24083,14 +24140,15 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) } completeChoamKulon(g, choam); } else if (pending.effect === 'jubba') { - if (g.semutaPreview && used.name === 'Jubba Cloak' && + if (g.semutaPreview && (used.name === 'Jubba Cloak' || pending.nexusEvent) && pending.storm && !pending.revival && !pending.movement && - !pending.mentat && !pending.nexusEvent) { + !pending.mentat) { const continuation: ChoamJubbaDiscardContinuation = { kind: 'choamJubbaDiscard', player: choam.id, card: used.id, territory: pending.location!, stateSignature: '', }; - const entries = [{ card: used, discardedBy: choam.id, publicFace: true }]; + if (pending.nexusEvent) continuation.nexusEvent = pending.nexusEvent; + const entries = [{ card: used, discardedBy: choam.id, publicFace: pending.nexusEvent === undefined }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = choamJubbaDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'choam:jubba', entries, continuation); @@ -24099,8 +24157,8 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) } completeChoamJubba(g, choam, pending.location!); } else if (pending.effect === 'baliset') { - if (g.semutaPreview && used.name === 'Baliset' && - !pending.storm && !pending.revival && !pending.mentat && !pending.nexusEvent && + if (g.semutaPreview && (used.name === 'Baliset' || pending.nexusEvent) && + !pending.storm && !pending.revival && !pending.mentat && (!pending.movement || (g.pendingChoamMove && !g.pendingChoamMove.source && !g.pendingChoamMove.ornithopterEvent && !g.pendingChoamMove.ornithopterRange && !g.pendingChoamMove.discoveryFlight))) { @@ -24111,8 +24169,9 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) card: used.id, target: pending.target!, territory: pending.location!, stateSignature: '', }; + if (pending.nexusEvent) continuation.nexusEvent = pending.nexusEvent; if (movement) continuation.stoppedMovement = true; - const entries = [{ card: used, discardedBy: choam.id, publicFace: true }]; + const entries = [{ card: used, discardedBy: choam.id, publicFace: pending.nexusEvent === undefined }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = choamBalisetDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'choam:baliset', entries, continuation); @@ -24133,18 +24192,18 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) if (ambassador) offerAmbassadorRelocation(g); } } else if (pending.effect === 'gamont') { - if (g.semutaPreview && used.name === 'Trip to Gamont' && - !pending.storm && !pending.revival && !pending.movement && - !pending.nexusEvent) { + if (g.semutaPreview && (used.name === 'Trip to Gamont' || pending.nexusEvent) && + !pending.storm && !pending.revival && !pending.movement) { const continuation: ChoamGamontDiscardContinuation = { kind: 'choamGamontDiscard', player: choam.id, card: used.id, target: pending.target!, location: pending.location!, stateSignature: '', }; + if (pending.nexusEvent) continuation.nexusEvent = pending.nexusEvent; if (pending.elite === 1) continuation.elite = 1; if (pending.noFieldEvent) continuation.noFieldEvent = pending.noFieldEvent; if (pending.mentat) continuation.mentat = true; - const entries = [{ card: used, discardedBy: choam.id, publicFace: true }]; + const entries = [{ card: used, discardedBy: choam.id, publicFace: pending.nexusEvent === undefined }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = choamGamontDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'choam:gamont', entries, continuation); @@ -24154,8 +24213,8 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) completeChoamGamont(g, choam, pending.target!, pending.location!, pending.elite!, pending.noFieldEvent); } else { - if (g.semutaPreview && used.name === 'La La La' && - !pending.storm && !pending.movement && !pending.mentat && !pending.nexusEvent && + if (g.semutaPreview && (used.name === 'La La La' || pending.nexusEvent) && + !pending.storm && !pending.movement && !pending.mentat && (!pending.revival || (g.pendingRevival?.kind === 'forces' && g.pendingRevival.player === pending.target))) { const revival = pending.revival ? g.pendingRevival : null; @@ -24164,8 +24223,9 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) kind: 'choamLaLaLaDiscard', player: choam.id, card: used.id, target: pending.target!, stateSignature: '', }; + if (pending.nexusEvent) continuation.nexusEvent = pending.nexusEvent; if (revival) continuation.stoppedRevival = true; - const entries = [{ card: used, discardedBy: choam.id, publicFace: true }]; + const entries = [{ card: used, discardedBy: choam.id, publicFace: pending.nexusEvent === undefined }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = choamLaLaLaDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'choam:laLaLa', entries, continuation); diff --git a/game/reference.ts b/game/reference.ts index 71c3453a..fff90c31 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1182,6 +1182,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'A printed Karama canceling native Harkonnen capture, CHOAM Auditor or quoted battle income, Ixian casualty substitution, or Basic Moritani allied retention now offers that exact public cost before the original cancellation finishes. Claim or Continue restores the saved response and cancels once. Acquiring the cost card cannot restore a captive, inspect a hand, pay the canceled income, exchange casualties or keep the proposed allied card. Later native card cleanup and markets still finish; Worthless conversion, other cancellations and module combinations remain separate.', 'The explicit fresh Leader Skills entry composes Semuta with original all-fourteen-card native Basic Richese/CHOAM or Advanced E1/E2 setup, retaining original Tech and Advanced Stronghold prerequisites. Actual offers train original living discs; the native Auditor remains ineligible. A completed Hajr discard can resume the same Movement owner before an original private Mentat question. Mandatory loser disposal can resume trained Suk casualty rescue or Rihani’s normal private inspection and optional skilled exchange before winner cleanup. Winning compulsory Artillery stays held through the original skill, then becomes its own fresh Treachery discard. Traitor/Face Dancer movement is never a Semuta target; no rescue, question, inspection or exchange repeats. Basic Ix mixtures, E3, other module/Banker overlays and public starts stay separate.', 'The fresh semuta --nexus-cards entry retains original native E1/E2 Nexus setup, optionally explicit Skills and original Tech/Advanced Strongholds. There is no starting Nexus hand: an actual qualifying closing draw follows a Nexus and settled alliance. CHOAM Secret Ally with CHOAM absent spends a Worthless and its Nexus for two bank spice before the public discard offer; CHOAM Betrayal with CHOAM seated commits one random actual hand-card discard without compensation before its private offer. Claim or Continue retires the original receipt once, without another payment or sample. Separate Nexus cards never become Treachery targets. Homeworlds, Discovery, E3, played-save conversion and public starts stay gated.', + 'The same original native Nexus entry offers the actual CHOAM Cunning fuel discard for Kulon, Gamont, Baliset, La La La and Jubba. Any eligible held Treachery card keeps its printed identity and private face; there is no fabricated named Worthless. Original cancellation retains fuel and the spent Nexus. After allowance, Commit or Continue resumes the selected effect and parent once: one range bonus, one typed force return and original closing owner, stopped ordinary movement/free revival, or one threatened-territory storm protection. Jubba protects CHOAM forces, not spice; unprotected forces and covered spice still suffer the original storm. Kull retains its separate guard.', 'An ordinary Ghola played during battle also offers its consumed card after revival. Claim or Continue preserves the revived leader or forces and the opposing sealed plan, then normal planning resumes. It cannot revive again by resuming the receipt.', '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.', @@ -1246,6 +1247,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/ordinary-card-discard-recovery.test.ts', 'tests/semuta-nexus-trade.test.ts', 'tests/semuta-nexus-betrayal.test.ts', + 'tests/semuta-nexus-cunning-proactive.test.ts', + 'tests/semuta-nexus-cunning-reactive.test.ts', ], }, ], diff --git a/tests/fixture-semuta-nexus-cunning-reactive.ts b/tests/fixture-semuta-nexus-cunning-reactive.ts new file mode 100644 index 00000000..c8fe446d --- /dev/null +++ b/tests/fixture-semuta-nexus-cunning-reactive.ts @@ -0,0 +1,259 @@ +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { gameDistance, splitLocation, TERRITORIES } from '../game/board'; +import { stormExposesTerritory } from '../game/disaster-rules'; +import type { Card } from '../game/cards'; +import { choamPowerAction, choamPowerPlays } from '../game/choam-power-options'; +import { + advanceSemutaNexusCunning, createSemutaNexusCunningStart, + gamePlayer, holdSemutaNexusCunningCard, semutaNexusCunningInventory, + type SemutaNexusCunningFixture, type SemutaNexusCunningStep, +} from './fixture-semuta-nexus-cunning'; + +export type SemutaNexusCunningReactiveEffect = 'baliset' | 'laLaLa' | 'jubba'; +export type SemutaNexusCunningReactiveStep = SemutaNexusCunningStep; +export type SemutaNexusCunningReactiveOptions = { + /** An already created shared start; only its live game and staging are used. */ + start?: SemutaNexusCunningFixture; + advanced?: boolean; + skills?: boolean; + /** Hold one canonical Karama in the other seat for the prevention family. */ + karama?: boolean; +}; +export type SemutaNexusCunningReactiveFixture = { + start: SemutaNexusCunningFixture; + /** The real reactive parent, before any Cunning declaration. */ + game: Game; + /** The conserved state immediately before the real parent action(s). */ + before: Game; + parent: Action; + steps: SemutaNexusCunningReactiveStep[]; + effect: SemutaNexusCunningReactiveEffect; + owner: string; + holder: string; + target: string; + other: string; + cost: Card; + semuta: string; + selection: Record; + /** The ordinary movement origin and destination of the Baliset family. */ + origin: string | null; + destination: string | null; + /** The protected (Jubba) or restricted (Baliset) territory, when any. */ + territory: string | null; + /** A second original crossed territory that keeps its ordinary storm losses. */ + otherTerritory: string | null; + distance: number; + crossed: { sector: number; key: string; territory: string }[]; + dials: { actor: string; amount: number }[]; + staging: string[]; + cards: string[]; +}; + +const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; + + +/** Physical stocks only: no wallets, clocks, phases or leader training. */ +export function semutaNexusCunningReactiveBoard(game: Game) { + return game.players.map(player => ({ + id: player.id, faction: player.faction, spice: player.spice, + forces: player.forces, reserves: player.reserves, tanks: player.tanks, + moved: player.moved, revived: player.revived, + freeForcesRevived: player.freeForcesRevived, + })); +} + +/** Conserved later board counters, taken from the seat's own reserves. */ +function stageForces(game: Game, actor: string, key: string, count: number, staging: string[]): void { + const player = gamePlayer(game, actor); + assert.ok(player.reserves >= count, 'A conserved later placement needs ordinary reserves'); + player.reserves -= count; + player.forces[key] = (player.forces[key] ?? 0) + count; + staging.push(`Conserved later ordinary force ${count} for ${actor} at ${key} from its own reserves; clocks, wallets, allowances and training unchanged.`); +} +/** Conserved later Tanks counters, taken from the seat's own reserves. */ +function stageTanks(game: Game, actor: string, count: number, staging: string[]): void { + const player = gamePlayer(game, actor); + assert.ok(player.reserves >= count, 'A conserved later Tanks placement needs ordinary reserves'); + player.reserves -= count; + player.tanks += count; + staging.push(`Conserved later ${count} physical force(s) for ${actor} in its own Tanks; no revival, wallet, allowance or training change.`); +} +/** Existing board spice only, so a later real storm can be observed. */ +function stageSpice(game: Game, key: string, amount: number, staging: string[]): void { + game.spice[key] = (game.spice[key] ?? 0) + amount; + staging.push(`Conserved ${amount} existing board spice at ${key} for the later real storm; no wallet change.`); +} + +/** An original adjacent non-storm movement pair in ordinary sand territory. */ +function balisetPair(game: Game): { from: string; to: string } { + const keys = TERRITORIES.filter(t => t.type === 'sand' && t.id !== 'imperial_basin') + .flatMap(t => t.sectors.map(s => `${t.id}:${s}`)); + for (const to of keys) { + const destination = splitLocation(to); + if (destination.sector === game.storm) continue; + for (const from of keys) { + const origin = splitLocation(from); + if (origin.territory === destination.territory || origin.sector === game.storm) continue; + if (gameDistance(game, from, to, key => splitLocation(key).sector === game.storm) !== 1) continue; + return { from, to }; + } + } + throw Error('No original adjacent non-storm movement pair remains'); +} + +function crossedExposed(game: Game, distance: number) { + const crossed: { sector: number; key: string; territory: string }[] = []; + for (let step = 0; step < distance; step++) { + const sector = ((game.storm + step) % 18) + 1; + for (const territory of TERRITORIES) + if (territory.sectors.includes(sector) && stormExposesTerritory(territory.id, game.shieldWallDestroyed)) + crossed.push({ sector, key: `${territory.id}:${sector}`, territory: territory.id }); + } + return crossed; +} +function splitDistance(dialers: string[], distance: number) { + const amounts: number[] = []; + let remaining = distance; + for (let index = 0; index < dialers.length; index++) { + const rest = dialers.length - index - 1; + const amount = Math.min(3, Math.max(1, remaining - rest * 3)); + amounts.push(amount); remaining -= amount; + } + assert.equal(remaining, 0); + assert.ok(amounts.every(value => value >= 1 && value <= 3)); + return dialers.map((actor, index) => ({ actor, amount: amounts[index] })); +} +/** Basic uses real dialers; Advanced retains the already revealed original card distance. */ +function stormPlacement(game: Game) { + const dialers = game.stormDialers; + const minimum = game.advanced ? game.stormPending! : dialers.length; + const maximum = game.advanced ? minimum : dialers.length * 3; + if (!game.advanced) assert.equal(dialers.length, 2, 'The original Basic storm keeps its two real dialers'); + for (let distance = minimum; distance <= maximum; distance++) { + const crossed = crossedExposed(game, distance); + const first = crossed[0]; + const second = first && crossed.find(entry => entry.territory !== first.territory); + if (first && second) + return { distance, crossed, dials: game.advanced ? [] : splitDistance(dialers, distance), + protected: first, other: second }; + } + throw Error('No original later storm crosses two exposed territories'); +} + + +export function quoteSemutaNexusCunningReactive( + game: Game, fixture: SemutaNexusCunningReactiveFixture, + selection: Record = {}, +): Action { + const own = viewGame(game, fixture.owner); + const play = choamPowerPlays(own, fixture.effect).find(candidate => + candidate.source === 'nexus' && candidate.card.id === fixture.cost.id); + assert.ok(play && !play.blocked, 'The original private Cunning quote must offer the actual canonical fuel'); + const action = choamPowerAction(own, play, { ...fixture.selection, ...selection }); + assert.ok(action, 'The original private Cunning quote must produce its source action'); + return action; +} +/** One ordinary printed-Karama prevention of the current Cunning declaration. */ +export function cancelSemutaNexusCunningReactive(state: Game, fixture: SemutaNexusCunningReactiveFixture): Game { + const responder = state.players.find(player => player.id !== fixture.owner && + player.hand.some(card => card.effect === 'karama') && + !!viewGame(state, player.id).responseControls?.cancelCards.length); + assert.ok(responder, 'The prevention family needs one ordinary Karama holder'); + const card = responder.hand.find(candidate => candidate.effect === 'karama'); + assert.ok(card); + return applyAction(state, responder.id, { type: 'card', mode: 'cancel', card: card.id }); +} + +/** Fresh real setup, then one original reactive parent: an ordinary declared + * movement into a CHOAM-occupied territory, an ordinary free force revival, or + * a real later dialed storm. Only conserved physical stocks are staged. */ +export function createSemutaNexusCunningReactive( + effect: SemutaNexusCunningReactiveEffect, + options: SemutaNexusCunningReactiveOptions = {}, +): SemutaNexusCunningReactiveFixture { + const start = options.start ?? createSemutaNexusCunningStart({ + advanced: options.advanced ?? false, skills: options.skills ?? false, + }); + const staging = start.staging, steps: SemutaNexusCunningReactiveStep[] = []; + if (options.karama) + holdSemutaNexusCunningCard(start.game, start.other, card => card.effect === 'karama', staging); + const base = { + start, steps, effect, owner: start.owner, holder: start.holder, + target: start.target, other: start.other, cost: start.cost, semuta: start.semuta, + staging, selection: {} as Record, territory: null as string | null, + otherTerritory: null as string | null, origin: null as string | null, + destination: null as string | null, + distance: 0, crossed: [], dials: [] as { actor: string; amount: number }[], + }; + if (effect === 'baliset') { + const before = advanceSemutaNexusCunning(start.game, + game => game.phase === 5 && clean(game) && game.active === start.target, steps); + const pair = balisetPair(before); + stageForces(before, start.target, pair.from, 2, staging); + stageForces(before, start.owner, pair.to, 3, staging); + const parent: Action = { type: 'move', from: pair.from, amount: 1, ...splitLocation(pair.to) }; + const game = applyAction(before, start.target, parent); + const movement = game.decision?.kind === 'choamMovement' ? game.decision : null; + assert.ok(movement, 'A real move into a CHOAM territory opens the reactive parent'); + assert.equal(movement.mover, start.target); + assert.equal(movement.territory, splitLocation(pair.to).territory); + assert.equal(game.pendingChoamMove?.to, splitLocation(pair.to).territory); + return { + ...base, before, game, parent, origin: pair.from, destination: pair.to, + territory: splitLocation(pair.to).territory, + cards: semutaNexusCunningInventory(game), + }; + } + if (effect === 'laLaLa') { + const before = advanceSemutaNexusCunning(start.game, + game => game.phase === 4 && clean(game), steps); + assert.ok(before.revivalRules, 'The real revival phase supplies its own rules'); + stageTanks(before, start.target, 3, staging); + const parent: Action = { type: 'revive', amount: 2 }; + const game = applyAction(before, start.target, parent); + const revival = game.decision?.kind === 'choamFreeRevival' ? game.decision : null; + assert.ok(revival, 'A real free revival opens the reactive parent'); + assert.equal(revival.recipient, start.target); + assert.ok(game.pendingRevival, 'The declared free revival stays uncommitted'); + return { + ...base, before, game, parent, + cards: semutaNexusCunningInventory(game), + }; + } + const before = advanceSemutaNexusCunning(start.game, game => { + if (game.phase !== 0 || !clean(game)) return false; + if (!game.advanced) return game.stormPending === null && + game.stormDialers.every(id => game.stormDials[id] === undefined); + if (typeof game.stormPending !== 'number' || game.stormPending < 1) return false; + const crossed = crossedExposed(game, game.stormPending); + return !!crossed[0] && crossed.some(entry => entry.territory !== crossed[0].territory); + }, steps); + const placement = stormPlacement(before); + stageForces(before, start.owner, placement.protected.key, 3, staging); + stageForces(before, start.owner, placement.other.key, 3, staging); + stageForces(before, start.target, placement.protected.key, 2, staging); + stageForces(before, start.target, placement.other.key, 2, staging); + stageSpice(before, placement.protected.key, 5, staging); + stageSpice(before, placement.other.key, 6, staging); + let dialed = before, parent: Action = before.advanced ? { type: 'ready' } + : { type: 'stormDial', amount: placement.dials[0].amount }; + for (const dial of placement.dials) { + parent = { type: 'stormDial', amount: dial.amount }; + steps.push({ actor: dial.actor, action: parent }); + dialed = applyAction(dialed, dial.actor, parent); + } + assert.equal(dialed.stormPending, placement.distance, 'The original storm preparation fixes its own distance'); + const game = advanceSemutaNexusCunning(dialed, state => state.decision?.kind === 'choamStorm', steps); + if (before.advanced) parent = steps.at(-1)!.action; + const storm = game.decision?.kind === 'choamStorm' ? game.decision : null; + assert.ok(storm, 'The real prepared storm opens the reactive parent'); + assert.equal(storm.player, start.owner); + assert.ok(storm.territories.some(entry => entry.territory === placement.protected.territory)); + return { + ...base, before, game, parent, distance: placement.distance, crossed: placement.crossed, + dials: placement.dials, territory: placement.protected.territory, + otherTerritory: placement.other.territory, selection: { territory: placement.protected.territory }, + cards: semutaNexusCunningInventory(game), + }; +} diff --git a/tests/fixture-semuta-nexus-cunning.ts b/tests/fixture-semuta-nexus-cunning.ts new file mode 100644 index 00000000..d2dafe81 --- /dev/null +++ b/tests/fixture-semuta-nexus-cunning.ts @@ -0,0 +1,295 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, handLimit, initializeSemutaGameForAudit, joinGame, + newPlayer, viewGame, type Action, type Game, type Player, +} from '../game/engine'; +import { gameDistance, splitLocation, TERRITORIES } from '../game/board'; +import type { Card } from '../game/cards'; +import { choamPowerAction, choamPowerPlays } from '../game/choam-power-options'; +import { validateNexusCards } from '../game/nexus-cards'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech'; + +export type SemutaNexusCunningStep = { actor: string; action: Action }; +export type SemutaNexusCunningOptions = { + initial?: Game; + advanced?: boolean; + skills?: boolean; + tech?: boolean; + strongholds?: boolean; + fullHand?: boolean; + costKind?: 'projectile' | 'snooper' | 'special'; +}; +export type SemutaNexusCunningFixture = { + game: Game; + initial: Game; + setup: Game; + afterFirstStorm: Game; + drawBefore: Game; + drawAfter: Game; + actions: SemutaNexusCunningStep[]; + staging: string[]; + owner: string; + holder: string; + target: string; + other: string; + cost: Card; + semuta: string; + cards: string[]; +}; +export type SemutaNexusCunningProactiveFixture = SemutaNexusCunningFixture & { + effect: 'kulon' | 'gamont'; + action: Action; + selection: Record; + from: string; + move?: Action; + elite: 0 | 1; + karama: string; +}; +export function gamePlayer(game: Game, id: string): Player { + const player = game.players.find(p => p.id === id); + assert.ok(player, `Missing original ${id} seat`); + return player; +} +export function semutaNexusCunningInventory(game: Game): string[] { + const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), + ...(game.richeseRemoved ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? []), + ...game.players.flatMap(p => p.hand)].map(c => c.id).sort(); + assert.equal(new Set(cards).size, cards.length, 'Each original Treachery Card keeps one physical slot'); + validateNexusCards(game.nexusCards!.cards!, game.players); + return cards; +} +const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; + +/** Original no-spend phase policy, actual training, and neutral acknowledgements. + * In particular this never spends a secretly held Nexus while seeking a window. */ +export function nextSemutaNexusCunningStep(game: Game): SemutaNexusCunningStep { + const pending = game.pendingTreacheryDiscard, reaction = pending?.reaction; + if (reaction?.stage === 'offer' && pending) { + const actor = game.players.find(p => !reaction.passed.includes(p.id))!.id; + return { actor, action: { type: 'semutaPass', event: pending.batch.event } }; + } + if (clean(game) && game.nexusCards?.phase?.stage === 'drawing') { + const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.phase!.done.includes(id)); + assert.ok(actor); + return { actor, action: { type: 'nexusCardChoice', turn: game.turn, + card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } }; + } + if (game.status === 'setup' && game.setupStage === 'leaderSkills') { + const actor = Object.keys(game.leaderSkills!.offers)[0]; + const offer = game.leaderSkills!.offers[actor], own = viewGame(game, actor).leaderSkills!; + const safe = ['warmaster', 'swordmaster-of-ginaz', 'killer-medic', 'prana-bindu-adept']; + const skill = offer.cards.find(c => safe.includes(c) && !own.unavailableSkills?.[c]) + ?? offer.cards.find(c => !own.unavailableSkills?.[c]); + assert.ok(skill && own.eligibleLeaders[0], 'Actual original training must have a legal offered skill and leader'); + return { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: own.eligibleLeaders[0].id } }; + } + if (!game.phaseOpening && !game.response && + (game.decision?.kind === 'choamMarket' || game.decision?.kind === 'choamMentat')) + return { actor: game.decision.player, action: { type: 'decision', done: true } }; + return nextRicheseSkillsTechNativeStep(game); +} +export function advanceSemutaNexusCunning( + state: Game, until: (game: Game) => boolean, actions: SemutaNexusCunningStep[] = [], +): Game { + let game = state; + for (let n = 0; n < 1800; n++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original native path must reach its requested boundary'); + const next = nextSemutaNexusCunningStep(game); + actions.push(next); game = applyAction(game, next.actor, next.action); + } + throw Error('Original Semuta Cunning actions did not reach the requested boundary'); +} +function orderUnplayedSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]): void { + for (const [position, kind] of kinds.entries()) { + const index = game.spiceDeck.findIndex((c, i) => i >= position && + (kind === 'land' ? 'territory' in c : 'worm' in c)); + assert.ok(index >= position, `An original unplayed ${kind} Spice Card must remain`); + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]); + } + staging.push(`Conserved original unplayed Spice order: ${kinds.join(', ')}.`); +} +export function reserveSemutaNexusCunningBoard(game: Game, staging: string[]): void { + for (const player of game.players) { + // Main force and reserve totals include the separately tracked elite subset. + const total = Object.values(player.forces).reduce((sum, n) => sum + n, 0); + const elites = Object.values(player.elites?.forces ?? {}).reduce((sum, n) => sum + n, 0); + player.reserves += total; + player.forces = {}; + if (player.elites) { player.elites.reserves += elites; player.elites.forces = {}; } + } + staging.push('Later original board counters returned to their own normal/elite reserves; clocks, wallets and losses unchanged.'); +} +export function holdSemutaNexusCunningCard( + game: Game, actor: string, predicate: (card: Card) => boolean, staging: string[] = [], +): Card { + const player = gamePlayer(game, actor), held = player.hand.find(predicate); + if (held) return held; + assert.ok(player.hand.length < handLimit(player)); + for (const zone of [game.deck, game.discard, game.richeseCache!, ...game.players.filter(p => p.id !== actor).map(p => p.hand)]) { + const index = zone.findIndex(predicate); + if (index < 0) continue; + const card = zone.splice(index, 1)[0]; player.hand.push(card); + staging.push(`Conserved canonical ${card.id} into ${actor}'s hand.`); + return card; + } + throw Error('The requested original physical Treachery Card is unavailable'); +} +function swapDrawnChoam(game: Game, owner: string): void { + const cards = game.nexusCards!.cards!, previous = cards.hands[owner]; + assert.ok(previous, 'Native CHOAM must earn its actual closing Nexus draw before any identity staging'); + if (previous === 'choam') return; + const donor = Object.keys(cards.hands).find(id => cards.hands[id] === 'choam'); + if (donor) cards.hands[donor] = previous; + else { + const zone = cards.deck.includes('choam') ? cards.deck : cards.discard; + const index = zone.indexOf('choam'); assert.ok(index >= 0); + zone[index] = previous; + } + cards.hands[owner] = 'choam'; + validateNexusCards(cards, game.players); +} + +/** Fresh paired native setup, first Storm/Mentat and real turn-two worm. Atreides + * and Emperor ally; unallied CHOAM draws and Richese declines. Only after that + * earned draw do original Nexus/Treachery identities move between physical slots. */ +export function createSemutaNexusCunningStart(options: SemutaNexusCunningOptions = {}): SemutaNexusCunningFixture { + let game = options.initial ? structuredClone(options.initial) + : createGame('SEMUTANEXUSCUNNING', newPlayer('r', 'Richese', 'richese'), options.advanced ?? false, ['choam']); + if (!options.initial) { + joinGame(game, newPlayer('e', 'CHOAM', 'choam')); + joinGame(game, newPlayer('a', 'Atreides', 'atreides')); + joinGame(game, newPlayer('b', 'Emperor', 'emperor')); + } + assert.equal(game.status, 'lobby'); assert.deepEqual(game.expansions, ['choam']); + assert.equal(game.players.length, 4); + if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced); + const idFor = (faction: Player['faction']) => { + const player = game.players.find(p => p.faction === faction); assert.ok(player); return player.id; + }; + const owner = idFor('choam'), holder = idFor('richese'), target = idFor('atreides'), other = idFor('emperor'); + if (options.tech !== undefined) game = applyAction(game, game.host, { type: 'techTokens', enabled: options.tech }); + if (options.strongholds !== undefined) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: options.strongholds }); + for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' }); + // Existing fresh lobby module selection: the initializer, not this fixture, deals the packet. + game.nexusCards ??= { cards: null, phase: null }; + assert.equal(game.nexusCards.cards, null); assert.equal(game.nexusCards.phase, null); + const initial = structuredClone(game), actions: SemutaNexusCunningStep[] = [], staging: string[] = []; + game = initializeSemutaGameForAudit(game, options.skills ?? false); + const setup = structuredClone(game); + game = advanceSemutaNexusCunning(game, g => g.status === 'playing', actions); + assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null), 'No starting Nexus hand'); + const cards = semutaNexusCunningInventory(game); + game = advanceSemutaNexusCunning(game, g => g.phase === 1 && clean(g), actions); + const afterFirstStorm = structuredClone(game); + orderUnplayedSpice(game, ['land', 'land'], staging); + game = advanceSemutaNexusCunning(game, g => g.phase === 5 && clean(g), actions); + reserveSemutaNexusCunningBoard(game, staging); + game = advanceSemutaNexusCunning(game, g => g.turn === 2 && g.phase === 1 && clean(g), actions); + assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null)); + orderUnplayedSpice(game, ['worm', 'land', 'land'], staging); + game = advanceSemutaNexusCunning(game, g => g.nexus && !g.spiceWindow && !g.spiceResolution && clean(g), actions); + for (const [actor, targetId] of [[target, other], [other, target]]) { + const action: Action = { type: 'alliance', target: targetId }; + actions.push({ actor, action }); game = applyAction(game, actor, action); + } + game = advanceSemutaNexusCunning(game, g => g.nexusCards?.phase?.stage === 'drawing', actions); + assert.deepEqual([...game.nexusCards!.phase!.eligible].sort(), [owner, holder].sort()); + const drawBefore = structuredClone(game); + for (const actor of game.nexusCards!.phase!.eligible) { + const action: Action = { type: 'nexusCardChoice', turn: game.turn, + card: game.nexusCards!.cards!.hands[actor], choice: actor === owner ? 'draw' : 'keep', ownRedraws: 0 }; + actions.push({ actor, action }); game = applyAction(game, actor, action); + } + assert.equal(game.nexusCards!.phase!.stage, 'complete'); + const drawAfter = structuredClone(game); + swapDrawnChoam(game, owner); + staging.push('Only after real turn-two qualifying draw, exchanged its identity with the existing CHOAM Nexus slot.'); + game = advanceSemutaNexusCunning(game, g => g.phase === 2 && clean(g), actions); + const wallets = game.players.map(p => p.spice); + for (const player of game.players) game.deck.push(...player.hand.splice(0)); + staging.push('At real Charity, returned original held Treachery Cards to deck before conserving later Semuta/fuel positions.'); + const semuta = holdSemutaNexusCunningCard(game, holder, c => c.id === SEMUTA_DRUG_ID, staging).id; + const kind = options.costKind ?? 'projectile'; + const cost = holdSemutaNexusCunningCard(game, owner, + c => c.kind === kind && (kind !== 'special' || c.effect === 'hajr'), staging); + if (options.fullHand) { + const player = gamePlayer(game, holder); + while (player.hand.length < handLimit(player)) + holdSemutaNexusCunningCard(game, holder, c => c.kind !== 'worthless' && !player.hand.some(h => h.id === c.id), staging); + } + assert.deepEqual(semutaNexusCunningInventory(game), cards); + assert.deepEqual(game.players.map(p => p.spice), wallets); + return { game, initial, setup, afterFirstStorm, drawBefore, drawAfter, actions, staging, + owner, holder, target, other, cost: structuredClone(cost), semuta, cards }; +} +export function reactSemutaNexusCunning(state: Game, fixture: SemutaNexusCunningFixture, claim: boolean): Game { + const event = state.pendingTreacheryDiscard!.batch.event; + if (claim) return applyAction(state, fixture.holder, { type: 'semutaCommit', event }); + let game = state; + for (const player of state.players) game = applyAction(game, player.id, { type: 'semutaPass', event }); + return game; +} +export function quoteSemutaNexusCunningAction( + game: Game, fixture: SemutaNexusCunningFixture, effect: 'kulon' | 'gamont', selection: Record = {}, +): Action { + const own = viewGame(game, fixture.owner); + const play = choamPowerPlays(own, effect).find(p => p.source === 'nexus' && p.card.id === fixture.cost.id); + assert.ok(play && !play.blocked, 'Original private Cunning quote must offer the actual canonical fuel'); + const action = choamPowerAction(own, play, selection); assert.ok(action); + return action; +} +export function allowSemutaNexusCunning(state: Game, actions: SemutaNexusCunningStep[] = []): Game { + let game = state; + for (let n = 0; game.response && n < 30; n++) { + const actor = game.players.find(p => { + const controls = viewGame(game, p.id).responseControls; + return controls && !controls.hasPassed && controls.cancelCards.length > 0; + })?.id; + assert.ok(actor, 'Original ordinary CHOAM response needs a legal remaining responder'); + const action: Action = { type: 'passResponse' }; + actions.push({ actor, action }); game = applyAction(game, actor, action); + } + assert.equal(game.response, null); + return game; +} +export function createSemutaNexusCunningProactive( + effect: 'kulon' | 'gamont', options: SemutaNexusCunningOptions & { elite?: boolean } = {}, +): SemutaNexusCunningProactiveFixture { + const f = createSemutaNexusCunningStart(options); + let game = advanceSemutaNexusCunning(f.game, g => g.phase === 5 && clean(g), f.actions); + reserveSemutaNexusCunningBoard(game, f.staging); + let from: string, move: Action | undefined, elite: 0 | 1 = 0; + let selection: Record = {}; + if (effect === 'kulon') { + game = advanceSemutaNexusCunning(game, g => g.phase === 5 && g.active === f.owner && clean(g), f.actions); + const keys = TERRITORIES.filter(t => t.type !== 'stronghold').flatMap(t => t.sectors.map(s => `${t.id}:${s}`)); + const source = keys.find(key => splitLocation(key).sector !== game.storm && keys.some(to => + splitLocation(to).territory !== splitLocation(key).territory && splitLocation(to).sector !== game.storm && + gameDistance(game, key, to, k => splitLocation(k).sector === game.storm) === 2)); + assert.ok(source); from = source; + const to = keys.find(key => splitLocation(key).territory !== splitLocation(from).territory && + splitLocation(key).sector !== game.storm && gameDistance(game, from, key, k => splitLocation(k).sector === game.storm) === 2)!; + const owner = gamePlayer(game, f.owner); assert.ok(owner.reserves >= 2); + owner.reserves -= 2; owner.forces[from] = 2; + move = { type: 'move', forces: { [from]: 1 }, ...splitLocation(to) }; + f.staging.push('At original CHOAM Movement, conserved two normal reserve forces into one desert origin, with a genuine distance-two destination.'); + } else { + game = advanceSemutaNexusCunning(game, g => g.phase === 7 && clean(g), f.actions); + const territory = TERRITORIES.find(t => t.id === 'arrakeen')!; + from = `${territory.id}:${territory.sectors[0]}`; + const target = gamePlayer(game, options.elite ? f.other : f.target); + elite = options.elite ? 1 : 0; + assert.ok(target.reserves > 0); target.reserves--; + if (elite) { assert.ok(target.elites && target.elites.reserves > 0); target.elites.reserves--; target.elites.forces[from] = 1; } + target.forces[from] = 1; + f.staging.push(`At real Collection, conserved one original ${elite ? 'Emperor elite' : 'Atreides normal'} force at Arrakeen before the closing Mentat window.`); + game = advanceSemutaNexusCunning(game, g => g.phase === 8 && g.decision?.kind === 'choamMentat', f.actions); + assert.equal(game.decision!.player, f.owner); + selection = { target: target.id, from, elite }; + } + const karama = holdSemutaNexusCunningCard(game, f.other, c => c.effect === 'karama', f.staging).id; + const action = quoteSemutaNexusCunningAction(game, f, effect, selection); + return { ...f, game, effect, action, selection, from, move, elite, karama }; +} diff --git a/tests/semuta-nexus-cunning-proactive.test.ts b/tests/semuta-nexus-cunning-proactive.test.ts new file mode 100644 index 00000000..94d33cb5 --- /dev/null +++ b/tests/semuta-nexus-cunning-proactive.test.ts @@ -0,0 +1,210 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, handLimit, viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { choamPowerPlays } from '../game/choam-power-options'; +import { + allowSemutaNexusCunning, createSemutaNexusCunningProactive, + gamePlayer, nextSemutaNexusCunningStep, reactSemutaNexusCunning, + semutaNexusCunningInventory, +} from './fixture-semuta-nexus-cunning'; + +for (const advanced of [false, true]) for (const claim of [false, true]) { + void test(`${advanced ? 'Advanced' : 'Basic'} original Cunning Kulon ${claim ? 'claim' : 'pass'} preserves private projectile fuel and grants one native move bonus`, () => { + const f = createSemutaNexusCunningProactive('kulon', { advanced, fullHand: claim }); + assert.equal(f.setup.status, 'setup'); + assert.ok(Object.values(f.setup.nexusCards!.cards!.hands).every(card => card === null)); + assert.equal(f.afterFirstStorm.turn, 1); assert.equal(f.afterFirstStorm.phase, 1); + assert.equal(f.drawBefore.turn, 2); assert.equal(f.drawBefore.phase, 1); + assert.equal(f.drawBefore.nexusCards!.phase!.occurred, true); + assert.equal(gamePlayer(f.drawBefore, f.target).ally, f.other); + assert.equal(gamePlayer(f.drawBefore, f.other).ally, f.target); + assert.equal(gamePlayer(f.drawBefore, f.owner).ally, null); + assert.deepEqual([...f.drawBefore.nexusCards!.phase!.eligible].sort(), [f.owner, f.holder].sort()); + assert.equal(f.drawAfter.nexusCards!.phase!.stage, 'complete'); + assert.ok(f.drawAfter.nexusCards!.cards!.hands[f.owner]); + assert.equal(f.drawAfter.nexusCards!.cards!.hands[f.holder], null); + assert.equal(f.drawAfter.nexusCards!.cards!.deck.length, 11); + assert.equal(f.game.active, f.owner); assert.equal(f.game.phase, 5); + assert.equal(f.cost.kind, 'projectile'); + assert.throws(() => applyAction(f.game, f.owner, f.move!), /more than 1/); + const wallets = f.game.players.map(p => p.spice), handBefore = gamePlayer(f.game, f.holder).hand.length; + const declared = applyAction(f.game, f.owner, f.action); + assert.ok(declared.response, 'The original printed Karama may prevent Cunning'); + assert.deepEqual(gamePlayer(declared, f.owner).hand.find(c => c.id === f.cost.id), f.cost); + assert.equal(declared.nexusCards!.cards!.hands[f.owner], null); + assert.equal(declared.nexusCards!.cards!.discard.filter(c => c === 'choam').length, 1); + assert.equal(declared.nexusChoamHistory!.at(-1)!.stage, 'pending'); + const pending = allowSemutaNexusCunning(declared); + const fresh = pending.pendingTreacheryDiscard!; + assert.equal(fresh.continuation.kind, 'choamKulonDiscard'); + assert.deepEqual(fresh.batch.entries, [{ card: f.cost, discardedBy: f.owner, publicFace: false }]); + assert.equal(fresh.reaction?.stage, 'offer'); + assert.equal(pending.nexusChoamHistory!.length, 1); + assert.equal(pending.nexusChoamHistory![0].stage, 'complete', 'Allowance retires the spent Nexus before Semuta reacts'); + assert.equal(pending.nexusChoamHistory![0].receipt.card, f.cost.id); + assert.equal(pending.nexusChoamHistory![0].receipt.effect, 'kulon'); + assert.equal(pending.discard.filter(c => c.id === f.cost.id).length, 1); + assert.deepEqual(viewGame(pending, f.holder).semutaReaction!.candidates, [], 'No cost inspection before committing Semuta'); + assert.equal(viewGame(pending, f.holder).semutaReaction!.canCommit, true); + assert.throws(() => applyAction(pending, f.owner, f.move!)); + const done = reactSemutaNexusCunning(pending, f, claim); + assert.equal(done.pendingTreacheryDiscard, null, 'The only foreign candidate completes directly at Commit'); + assert.equal(done.resolvedTreacheryDiscardSequence, fresh.sequence); + assert.equal(done.active, f.owner); assert.equal(gamePlayer(done, f.owner).moved, 0); + assert.equal(gamePlayer(done, f.holder).hand.length, handBefore); + assert.equal(gamePlayer(done, f.holder).hand.some(c => c.id === f.semuta), !claim); + assert.equal(done.discard.filter(c => c.id === f.semuta).length, Number(claim)); + assert.equal(done.discard.filter(c => c.id === f.cost.id).length, Number(!claim)); + assert.deepEqual(gamePlayer(done, f.holder).hand.find(c => c.id === f.cost.id), claim ? f.cost : undefined); + assert.deepEqual(done.players.map(p => p.spice), wallets); + assert.equal(done.nexusCards!.cards!.discard.filter(c => c === 'choam').length, 1); + assert.equal(done.nexusChoamHistory!.length, 1); + assert.throws(() => applyAction(done, f.owner, f.action)); + assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event: fresh.batch.event })); + const moved = applyAction(done, f.owner, f.move!); + assert.equal(gamePlayer(moved, f.owner).moved, 1); + assert.equal(gamePlayer(moved, f.owner).forces[f.from], 1); + assert.throws(() => applyAction(moved, f.owner, f.move!), /movement is not available/); + assert.deepEqual(semutaNexusCunningInventory(moved), f.cards); + if (claim) assert.equal(handBefore, handLimit(gamePlayer(f.game, f.holder))); + }); + + void test(`${advanced ? 'Advanced' : 'Basic'} original closing Cunning Gamont ${claim ? 'claim' : 'pass'} returns one ${advanced && claim ? 'elite' : 'normal'} force before its original owner closes Mentat`, () => { + const optional = advanced && claim; + const f = createSemutaNexusCunningProactive('gamont', { + advanced, elite: optional, fullHand: claim, + costKind: optional ? 'special' : 'snooper', + skills: optional, tech: optional, strongholds: optional, + }); + const target = f.selection.target as string, beforeTarget = structuredClone(gamePlayer(f.game, target)); + const wallets = f.game.players.map(p => p.spice), hands = gamePlayer(f.game, f.holder).hand.length; + assert.equal(f.cost.kind, optional ? 'special' : 'snooper'); + if (optional) { + assert.equal(f.cost.effect, 'hajr'); + assert.equal(f.game.leaderSkills!.assignments.length, 4); + assert.ok(Object.values(f.game.techTokens!).every(t => f.game.players.some(p => p.id === t.owner))); + assert.equal(f.game.strongholdCards!.claimedTurn, 1); + } + assert.equal(f.game.phase, 8); + assert.deepEqual(f.game.decision, { kind: 'choamMentat', player: f.owner }); + assert.equal(viewGame(f.game, f.target).mentatVictoryPending, true); + const declared = applyAction(f.game, f.owner, f.action); + assert.equal(declared.nexusCards!.cards!.hands[f.owner], null); + assert.deepEqual(gamePlayer(declared, f.owner).hand.find(c => c.id === f.cost.id), f.cost); + const pending = allowSemutaNexusCunning(declared), fresh = pending.pendingTreacheryDiscard!; + assert.equal(fresh.continuation.kind, 'choamGamontDiscard'); + assert.deepEqual(fresh.batch.entries, [{ card: f.cost, discardedBy: f.owner, publicFace: false }]); + assert.equal(gamePlayer(pending, target).forces[f.from], 1); + assert.equal(gamePlayer(pending, target).reserves, beforeTarget.reserves); + assert.equal(pending.status, 'playing'); assert.deepEqual(pending.winner, []); + assert.equal(pending.nexusChoamHistory![0].stage, 'complete'); + assert.equal(pending.nexusChoamHistory![0].receipt.effect, 'gamont'); + assert.throws(() => applyAction(pending, f.owner, { type: 'decision', done: true })); + if (optional) assert.equal(pending.strongholdCards!.claimedTurn, 1); + const done = reactSemutaNexusCunning(pending, f, claim), returned = gamePlayer(done, target); + assert.equal(done.pendingTreacheryDiscard, null); + assert.equal(done.resolvedTreacheryDiscardSequence, fresh.sequence); + assert.equal(returned.forces[f.from], undefined); + assert.equal(returned.reserves, beforeTarget.reserves + 1); + assert.equal(returned.tanks, beforeTarget.tanks); + assert.equal(returned.battleLosses, beforeTarget.battleLosses); + if (f.elite) { + assert.equal(returned.elites!.forces[f.from], undefined); + assert.equal(returned.elites!.reserves, beforeTarget.elites!.reserves + 1); + assert.equal(returned.elites!.tanks, beforeTarget.elites!.tanks); + } + assert.equal(done.status, 'playing'); assert.equal(done.turn, 2); assert.deepEqual(done.winner, []); + assert.deepEqual(done.decision, { kind: 'choamMentat', player: f.owner }); + assert.equal(done.choamMentatPending, true); + assert.equal(gamePlayer(done, f.holder).hand.length, hands); + assert.deepEqual(gamePlayer(done, f.holder).hand.find(c => c.id === f.cost.id), claim ? f.cost : undefined); + assert.equal(done.discard.filter(c => c.id === f.cost.id).length, Number(!claim)); + assert.equal(done.discard.filter(c => c.id === f.semuta).length, Number(claim)); + assert.deepEqual(done.players.map(p => p.spice), wallets, 'Returning a force grants no spice or replayed Special fuel power'); + assert.equal(done.nexusCards!.cards!.discard.filter(c => c === 'choam').length, 1); + assert.throws(() => applyAction(done, f.owner, f.action)); + assert.throws(() => applyAction(done, f.target, { type: 'decision', done: true })); + if (optional) assert.equal(done.strongholdCards!.claimedTurn, 1); + const closed = applyAction(done, f.owner, { type: 'decision', done: true }); + assert.equal(closed.turn, 3); assert.equal(closed.status, 'playing'); + assert.equal(gamePlayer(closed, target).reserves, returned.reserves, 'Closing cannot repeat the return'); + assert.equal(closed.nexusChoamHistory!.length, 1); + if (optional) { + assert.equal(closed.strongholdCards!.claimedTurn, 2); + assert.equal(closed.strongholdCards!.owners.arrakeen, null, 'The returned sole force cannot claim Arrakeen'); + } + assert.deepEqual(semutaNexusCunningInventory(closed), f.cards); + }); +} + +for (const effect of ['kulon', 'gamont'] as const) void test(`Original printed Karama cancellation retains ${effect} fuel and spends Cunning without offering that fuel`, () => { + const f = createSemutaNexusCunningProactive(effect); + let game = applyAction(f.game, f.owner, f.action); + game = applyAction(game, f.other, { type: 'card', card: f.karama, mode: 'cancel' }); + // A separately discarded printed Karama may have its own ordinary reaction. + for (let n = 0; (game.pendingTreacheryDiscard || game.response) && n < 30; n++) { + assert.ok(!game.pendingTreacheryDiscard?.batch.entries.some(e => e.card.id === f.cost.id)); + const step = nextSemutaNexusCunningStep(game); + game = applyAction(game, step.actor, step.action); + } + assert.deepEqual(gamePlayer(game, f.owner).hand.find(c => c.id === f.cost.id), f.cost); + assert.equal(game.discard.some(c => c.id === f.cost.id), false); + assert.equal(gamePlayer(game, f.holder).hand.some(c => c.id === f.cost.id), false); + assert.equal(gamePlayer(game, f.holder).hand.some(c => c.id === f.semuta), true); + assert.equal(game.nexusCards!.cards!.hands[f.owner], null); + assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'choam').length, 1); + assert.equal(game.nexusChoamHistory!.length, 1); assert.equal(game.nexusChoamHistory![0].stage, 'canceled'); + if (effect === 'kulon') { + assert.throws(() => applyAction(game, f.owner, f.move!), /more than 1/); + } else { + assert.equal(gamePlayer(game, f.target).forces[f.from], 1); + assert.deepEqual(game.decision, { kind: 'choamMentat', player: f.owner }); + } + assert.throws(() => applyAction(game, f.owner, f.action)); + assert.deepEqual(semutaNexusCunningInventory(game), f.cards); +}); + +void test('Rivals cannot activate native Cunning and the Semuta profile cannot spend its Nexus on Kull', () => { + const f = createSemutaNexusCunningProactive('kulon'), own = viewGame(f.game, f.owner); + assert.throws(() => applyAction(f.game, f.holder, f.action)); + const kull = choamPowerPlays(own, 'kull').find(p => p.source === 'nexus')!; + assert.throws(() => applyAction(f.game, f.owner, + { type: 'card', mode: 'choam', card: kull.card.id, nexus: kull.event, effect: 'kull' })); + assert.equal(f.game.nexusCards!.cards!.hands[f.owner], 'choam'); + assert.deepEqual(gamePlayer(f.game, f.owner).hand.find(c => c.id === f.cost.id), f.cost); + assert.deepEqual(semutaNexusCunningInventory(f.game), f.cards); +}); + +void test('Minimal native Cunning and Semuta bot actions use the original cost and finish singleton commitment legally', () => { + const f = createSemutaNexusCunningProactive('kulon'); + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const own = viewGame(f.game, f.owner); own.players.find(p => p.id === f.owner)!.bot = difficulty; + const action = botActions(own)[0]; assert.ok(action); + assert.equal(action.card, f.cost.id); assert.equal(action.nexus, f.action.nexus); assert.equal(action.effect, 'kulon'); + const pending = allowSemutaNexusCunning(applyAction(f.game, f.owner, action)); + const richese = viewGame(pending, f.holder); richese.players.find(p => p.id === f.holder)!.bot = difficulty; + const commit = botActions(richese)[0]; assert.ok(commit); assert.equal(commit.type, 'semutaCommit'); + const done = applyAction(pending, f.holder, commit); + assert.equal(done.pendingTreacheryDiscard, null); + assert.deepEqual(gamePlayer(done, f.holder).hand.find(c => c.id === f.cost.id), f.cost); + const moved = applyAction(done, f.owner, f.move!); + assert.equal(gamePlayer(moved, f.owner).forces[f.from], 1); + assert.equal(gamePlayer(moved, f.owner).moved, 1); + assert.deepEqual(semutaNexusCunningInventory(moved), f.cards); + } + let game: Game = allowSemutaNexusCunning(applyAction(f.game, f.owner, f.action)); + for (const player of game.players) { + const own = viewGame(game, player.id); own.players.find(p => p.id === player.id)!.bot = 'Easy'; + const action = player.id === f.holder + ? { type: 'semutaPass', event: game.pendingTreacheryDiscard!.batch.event } + : botActions(own)[0]; + assert.equal(action?.type, 'semutaPass'); + game = applyAction(game, player.id, action!); + } + assert.equal(game.pendingTreacheryDiscard, null); + const moved = applyAction(game, f.owner, f.move!); + assert.equal(gamePlayer(moved, f.owner).forces[f.from], 1); + assert.equal(gamePlayer(moved, f.owner).moved, 1); + assert.deepEqual(semutaNexusCunningInventory(moved), f.cards); +}); diff --git a/tests/semuta-nexus-cunning-reactive.test.ts b/tests/semuta-nexus-cunning-reactive.test.ts new file mode 100644 index 00000000..a5e8a1b3 --- /dev/null +++ b/tests/semuta-nexus-cunning-reactive.test.ts @@ -0,0 +1,232 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { choamPowerPlays } from '../game/choam-power-options'; +import { + allowSemutaNexusCunning, gamePlayer, reactSemutaNexusCunning, + semutaNexusCunningInventory, +} from './fixture-semuta-nexus-cunning'; +import { + cancelSemutaNexusCunningReactive, createSemutaNexusCunningReactive, + quoteSemutaNexusCunningReactive, semutaNexusCunningReactiveBoard, + type SemutaNexusCunningReactiveEffect, type SemutaNexusCunningReactiveFixture, +} from './fixture-semuta-nexus-cunning-reactive'; + +const holds = (game: Game, id: string, card: string) => + gamePlayer(game, id).hand.some(candidate => candidate.id === card); +const nexusSpent = (game: Game, owner: string) => + game.nexusCards?.cards?.discard.filter(id => id === 'choam').length === 1 && + game.nexusCards?.cards?.hands[owner] === null; +/** Declare the original private Cunning quote and resolve its ordinary response. */ +function declareAndAllow(f: SemutaNexusCunningReactiveFixture) { + const request = quoteSemutaNexusCunningReactive(f.game, f); + const declared = applyAction(f.game, f.owner, request); + return { request, declared, offered: allowSemutaNexusCunning(declared) }; +} +function assertPrivateFuel(f: SemutaNexusCunningReactiveFixture, declared: Game, request: Action) { + assert.equal(f.cost.kind, 'projectile'); + const pending = declared.pendingChoamWorthless; + assert.equal(declared.response?.kind, 'choamWorthless'); + assert.equal(pending?.card, f.cost.id); + assert.equal(pending?.nexusEvent, request.nexus); + assert.equal(pending?.owner, f.owner); + assert.equal(nexusSpent(declared, f.owner), true); + // The other seats see neither the private cost list nor the chosen fuel. + assert.equal(viewGame(declared, f.target).choamWorthless, null); + assert.equal(viewGame(declared, f.other).choamWorthless, null); + assert.equal(JSON.stringify(viewGame(declared, f.target)).includes(f.cost.id), false); + assert.equal(viewGame(declared, f.holder).semutaReaction, null); +} + +void test('reactive Baliset Cunning keeps canonical fuel private until Commit and stops the declared move', () => { + const f = createSemutaNexusCunningReactive('baliset', { karama: true }); + const mover = gamePlayer(f.game, f.target); + const source = Object.keys(mover.forces)[0]; + assert.equal(mover.forces[source], 2); + assert.equal(mover.moved, 0); + const plays = choamPowerPlays(viewGame(f.game, f.owner), 'baliset'); + const nexus = plays.find(play => play.source === 'nexus' && play.card.id === f.cost.id); + assert.ok(nexus && !nexus.blocked); + assert.equal(nexus.card.kind, 'projectile'); + assert.equal(plays.some(play => play.source === 'printed'), false); + const { request, declared, offered } = declareAndAllow(f); + assertPrivateFuel(f, declared, request); + assert.equal(declared.pendingChoamWorthless?.movement, true); + assert.equal(offered.response, null); + assert.equal(offered.decision, null); + assert.equal(offered.pendingChoamMove, null); + assert.equal(offered.nexusChoamLast?.stage, 'complete'); + assert.equal(offered.pendingTreacheryDiscard?.batch.entries.length, 1); + assert.equal(offered.pendingTreacheryDiscard?.batch.entries[0].card.id, f.cost.id); + assert.equal(offered.pendingTreacheryDiscard?.batch.entries[0].publicFace, false); + assert.equal(offered.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + // The private cost face remains unavailable before commitment. + assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true); + assert.deepEqual(viewGame(offered, f.holder).semutaReaction?.candidates, []); + const cards = semutaNexusCunningInventory(offered); + const claimed = reactSemutaNexusCunning(offered, f.start, true); + const passed = reactSemutaNexusCunning(offered, f.start, false); + for (const settled of [claimed, passed]) { + assert.equal(settled.pendingTreacheryDiscard, null); + assert.equal(settled.pendingChoamMove, null); + assert.equal(settled.nexusChoamLast?.stage, 'complete'); + assert.deepEqual(settled.choamBaliset, [{ turn: settled.turn, player: f.target, territory: f.territory }]); + assert.equal(viewGame(settled, f.target).balisetRestrictions.length, 1); + assert.deepEqual(semutaNexusCunningInventory(settled), cards); + // The stopped movement spends no force, movement allowance or spice. + assert.deepEqual(semutaNexusCunningReactiveBoard(settled), semutaNexusCunningReactiveBoard(f.game)); + assert.equal(gamePlayer(settled, f.target).forces[source], 2); + assert.equal(gamePlayer(settled, f.target).moved, 0); + assert.equal(nexusSpent(settled, f.owner), true); + assert.equal(holds(settled, f.owner, f.cost.id), false); + assert.throws(() => applyAction(settled, f.target, f.parent), /Baliset prevents/); + assert.throws(() => reactSemutaNexusCunning(settled, f.start, true)); + } + // The single foreign claim takes the actual discarded fuel; the decline does not. + assert.equal(holds(claimed, f.holder, f.cost.id), true); + assert.equal(claimed.discard.some(card => card.id === f.semuta), true); + assert.equal(holds(claimed, f.holder, f.semuta), false); + assert.equal(passed.discard.some(card => card.id === f.cost.id), true); + assert.equal(holds(passed, f.holder, f.semuta), true); +}); + +void test('reactive La La La Cunning stops the free revival and leaves tanks, allowances and payment untouched', () => { + const f = createSemutaNexusCunningReactive('laLaLa', { karama: true }); + const target = gamePlayer(f.game, f.target); + assert.equal(target.tanks, 3); + assert.equal(target.reserves, 17); + assert.ok(f.game.pendingRevival); + const { request, declared, offered } = declareAndAllow(f); + assertPrivateFuel(f, declared, request); + assert.equal(declared.pendingChoamWorthless?.revival, true); + assert.equal(declared.pendingChoamWorthless?.target, f.target); + assert.equal(offered.nexusChoamLast?.stage, 'complete'); + assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true); + assert.deepEqual(viewGame(offered, f.holder).semutaReaction?.candidates, []); + const cards = semutaNexusCunningInventory(offered); + const claimed = reactSemutaNexusCunning(offered, f.start, true); + const passed = reactSemutaNexusCunning(offered, f.start, false); + for (const settled of [claimed, passed]) { + assert.equal(settled.pendingTreacheryDiscard, null); + assert.equal(settled.pendingRevival, null); + assert.equal(settled.decision, null); + assert.equal(settled.nexusChoamLast?.stage, 'complete'); + assert.deepEqual(semutaNexusCunningReactiveBoard(settled), semutaNexusCunningReactiveBoard(f.game)); + assert.equal(viewGame(settled, f.target).revivalRules?.freeBlocked?.includes(f.target), true); + assert.deepEqual(semutaNexusCunningInventory(settled), cards); + assert.equal(nexusSpent(settled, f.owner), true); + assert.throws(() => reactSemutaNexusCunning(settled, f.start, true)); + // The original parent continues once: the ordinary paid revival still works. + const paid = applyAction(settled, f.target, { type: 'revive', amount: 2 }); + assert.equal(paid.response, null); + assert.equal(gamePlayer(paid, f.target).spice, target.spice - 4); + assert.equal(gamePlayer(paid, f.target).tanks, 1); + assert.equal(gamePlayer(paid, f.target).reserves, 19); + assert.equal(gamePlayer(paid, f.target).freeForcesRevived, 0); + } + assert.equal(holds(claimed, f.holder, f.cost.id), true); + assert.equal(claimed.discard.some(card => card.id === f.semuta), true); + assert.equal(passed.discard.some(card => card.id === f.cost.id), true); +}); + +for (const advanced of [false, true]) void test(`${advanced ? 'Advanced card' : 'Basic dialed'} reactive Jubba Cunning protects forces in one crossed territory without protecting spice`, () => { + const f = createSemutaNexusCunningReactive('jubba', { advanced, karama: true }); + const protectedKey = f.crossed.find(entry => entry.territory === f.territory)!.key; + const otherKey = f.crossed.find(entry => entry.territory === f.otherTerritory)!.key; + const owner = gamePlayer(f.game, f.owner); + const target = gamePlayer(f.game, f.target); + assert.equal(owner.forces[protectedKey], 3); + assert.equal(owner.forces[otherKey], 3); + assert.equal(target.forces[protectedKey], 2); + assert.equal(target.forces[otherKey], 2); + assert.equal(f.game.stormPending, f.distance); + const { request, declared, offered } = declareAndAllow(f); + assertPrivateFuel(f, declared, request); + assert.equal(declared.pendingChoamWorthless?.storm, true); + assert.equal(declared.pendingChoamWorthless?.location, f.territory); + assert.equal(offered.nexusChoamLast?.stage, 'complete'); + assert.ok(offered.stormResolution, 'The real storm stays untraversed behind its cost offer'); + assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true); + assert.deepEqual(viewGame(offered, f.holder).semutaReaction?.candidates, []); + const cards = semutaNexusCunningInventory(offered); + const claimed = reactSemutaNexusCunning(offered, f.start, true); + const passed = reactSemutaNexusCunning(offered, f.start, false); + const arrived = ((f.before.storm + f.distance - 1) % 18) + 1; + for (const settled of [claimed, passed]) { + assert.equal(settled.pendingTreacheryDiscard, null); + assert.equal(settled.nexusChoamLast?.stage, 'complete'); + // The one protected territory is recorded and the real storm pauses for the + // remaining original CHOAM choice rather than resolving early. + assert.equal(settled.stormResolution?.choamProtected?.includes(f.territory!), true); + const remaining = settled.decision?.kind === 'choamStorm' + ? settled.decision.territories.map(entry => entry.territory) : []; + assert.deepEqual(remaining, [f.otherTerritory]); + const continued = settled.decision?.kind === 'choamStorm' + ? applyAction(settled, f.owner, { type: 'decision', decline: true }) + : settled; + assert.equal(continued.stormResolution, null); + assert.equal(continued.storm, arrived); + assert.equal(gamePlayer(continued, f.owner).forces[protectedKey], 3); + assert.equal(gamePlayer(continued, f.owner).forces[otherKey], undefined); + assert.equal(gamePlayer(continued, f.owner).tanks, 3); + assert.equal(gamePlayer(continued, f.target).forces[protectedKey], undefined); + assert.equal(gamePlayer(continued, f.target).forces[otherKey], undefined); + assert.equal(gamePlayer(continued, f.target).tanks, 4); + assert.equal(continued.spice[protectedKey], undefined, 'Jubba protects CHOAM forces, not spice under the storm'); + assert.equal(continued.spice[otherKey], undefined); + assert.deepEqual(semutaNexusCunningInventory(continued), cards); + assert.equal(nexusSpent(continued, f.owner), true); + assert.throws(() => reactSemutaNexusCunning(settled, f.start, true)); + } + assert.equal(holds(claimed, f.holder, f.cost.id), true); + assert.equal(claimed.discard.some(card => card.id === f.semuta), true); + assert.equal(passed.discard.some(card => card.id === f.cost.id), true); +}); + +void test('canceling reactive Cunning retains the canonical fuel and resumes its original parent without offering that fuel', () => { + for (const effect of ['baliset', 'laLaLa', 'jubba'] as SemutaNexusCunningReactiveEffect[]) { + const f = createSemutaNexusCunningReactive(effect, { karama: true }); + const request = quoteSemutaNexusCunningReactive(f.game, f); + const declared = applyAction(f.game, f.owner, request); + assert.equal(declared.pendingChoamWorthless?.nexusEvent, request.nexus); + let canceled = cancelSemutaNexusCunningReactive(declared, f); + for (let n = 0; canceled.pendingTreacheryDiscard && n < 30; n++) { + assert.ok(!canceled.pendingTreacheryDiscard.batch.entries.some(entry => entry.card.id === f.cost.id)); + canceled = reactSemutaNexusCunning(canceled, f.start, false); + } + assert.equal(canceled.response, null); + assert.equal(canceled.pendingChoamWorthless, null); + assert.equal(canceled.nexusChoamLast?.stage, 'canceled'); + assert.equal(canceled.choamWorthlessBlocked?.cards.includes(f.cost.id), true); + assert.equal(holds(canceled, f.owner, f.cost.id), true); + assert.equal(canceled.discard.some(card => card.id === f.cost.id), false); + assert.equal(nexusSpent(canceled, f.owner), true); + assert.equal(choamPowerPlays(viewGame(canceled, f.owner), effect).some(play => play.source === 'nexus'), false); + if (effect === 'baliset') { + const moved = gamePlayer(canceled, f.target); + assert.equal(moved.moved, 1); + assert.equal(moved.forces[f.destination!], 1); + assert.equal(moved.forces[f.origin!], 1); + assert.equal(canceled.pendingChoamMove, null); + } else if (effect === 'laLaLa') { + const revived = gamePlayer(canceled, f.target); + assert.equal(canceled.pendingRevival, null); + assert.equal(revived.tanks, 1); + assert.equal(revived.reserves, 19); + assert.equal(revived.revived, 2); + assert.equal(revived.spice, gamePlayer(f.game, f.target).spice); + } else { + const storm = canceled.decision?.kind === 'choamStorm' ? canceled.decision : null; + assert.ok(storm, 'The prevented Jubba still leaves the original storm choice'); + const traversed = applyAction(canceled, f.owner, { type: 'decision', decline: true }); + assert.equal(traversed.stormResolution, null); + const protectedKey = f.crossed.find(entry => entry.territory === f.territory)!.key; + const otherKey = f.crossed.find(entry => entry.territory === f.otherTerritory)!.key; + assert.equal(gamePlayer(traversed, f.owner).forces[protectedKey], undefined); + assert.equal(gamePlayer(traversed, f.owner).forces[otherKey], undefined); + assert.equal(gamePlayer(traversed, f.owner).tanks, 6); + assert.equal(traversed.spice[protectedKey], undefined); + assert.equal(traversed.spice[otherKey], undefined); + } + } +});