From 7004977e75ee83ba857cccd8645bd68543b0d3cb Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:08:42 +1100 Subject: [PATCH] Compose Homeworld storm and native Worthless counter costs with Semuta --- components/game-table.tsx | 7 +- docs/CURRENT_STATUS.md | 22 ++ docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md | 16 +- docs/IMPLEMENTATION_STATUS.md | 54 ++++ docs/SEMUTA_SOURCE_UPDATE_20260907.md | 31 ++ game/engine.ts | 4 +- game/reference.ts | 4 +- .../fixture-semuta-homeworld-choam-karama.ts | 83 ++++++ tests/fixture-semuta-homeworld-choam-storm.ts | 97 ++++++ tests/semuta-homeworld-choam-karama.test.ts | 189 ++++++++++++ tests/semuta-homeworld-choam-storm.test.ts | 280 ++++++++++++++++++ tools/start-prototype.ts | 2 +- 12 files changed, 776 insertions(+), 13 deletions(-) create mode 100644 tests/fixture-semuta-homeworld-choam-karama.ts create mode 100644 tests/fixture-semuta-homeworld-choam-storm.ts create mode 100644 tests/semuta-homeworld-choam-karama.test.ts create mode 100644 tests/semuta-homeworld-choam-storm.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 9caa8d6d..57f78cef 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1169,9 +1169,10 @@ export function GameTable({ Paid high-Kaitain closing discards, occupied Tupile lost-slot cleanup, paid Nullentropy Box search, clean Distrans transfer, definite Truthtrance answers and used ordinary special cards offer Semuta. - Native printed Kulon, Baliset, La La La and Trip to Gamont costs - also pause before their original effect. Printed Karama canceling - Guild, Emperor auction or Richese purchase income remains supported. + Native printed Kulon, Baliset, La La La, Trip to Gamont and Jubba + costs also pause before their original effect. Printed Karama + canceling these native powers or Guild, Emperor auction or Richese + purchase income remains supported. Private discarded faces stay hidden until commitment; a simultaneous group requires one selection. Claim or Continue resumes the same original source once without undoing payment or force commitment. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 19374548..0da31e9e 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,28 @@ 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 Homeworld storm and native counter costs — 13 October.** +The same profile now offers Jubba before protection and printed Karama +canceling a native CHOAM Worthless power before denial. Kull/Nexus and other +producer gates remain unchanged. Ten corrected before programs had no cost +offer;24 parent claim/pass programs now complete the original suffixes. +Jubba protects the actual paid counter through recorded Weather storm1→7; +canceling it retains the fuel and loses exactly that threatened counter after +the original protection decision declines. Native counter claims recover +only Karama: Baliset's original move commits once, Kulon has no bonus, +La La La keeps free allowance and Gamont returns no force before its close. + +Six actual390px flows exercise four power cancellations and protected/canceled +storm, with movement/storm exchanges at4/4. Evidence: +`/tmp/dune-homeworld-native-counter-3215.json` and +`/tmp/dune-homeworld-native-storm-3216.json`. The original Weather was played +by Atreides; the earlier C-caster probe was superseded after correcting the +fixture's target alias, not counted as the specified before proof. The frozen +ten-file unit union passes192/192 with types/lint clean. This is local fixture +proof, not deployed, saved-room or full-mode acceptance. +The application build passes with the recorded deprecation, large-chunk and +static route-classification warnings unchanged; no warning or gate is suppressed. + **Semuta / native Homeworld Worthless powers — 13 October.** The existing Homeworld envelope now offers the printed Kulon, reactive Baliset, La La La and Trip to Gamont discard costs before their original diff --git a/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md b/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md index a765a57f..b6d9bf5e 100644 --- a/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md +++ b/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md @@ -14,13 +14,15 @@ card is the same public fresh batch, claim recovers it and pass leaves it discarded, and the card's own committed effect, payment, gift, answer history, wallets, custody and occupation history are unchanged. -Native printed Kulon, Baliset, La La La and Trip to Gamont disposal also offers -Semuta in this envelope before that power's original effect. Claim/pass adds -Kulon's movement bonus once, finishes the free-revival denial without -inventing forces or a debit, stops the same uncommitted Baliset move, or -returns one original board counter to its native reserves before the same -CHOAM closing-Mentat controls resume. Jubba and other producers remain outside -this batch; no Nexus or wider optional-module combination is admitted. +Native printed Kulon, Baliset, La La La, Trip to Gamont and Jubba disposal +also offers Semuta before that power's original effect. Claim/pass adds one +movement bonus, finishes denial without a force/debit, stops the same +uncommitted Baliset move, returns one original counter before CHOAM's same +closing controls, or applies protection before the same recorded storm moves. +The printed Karama canceling a native power is offered separately before +denial: its unused Worthless fuel remains held and is not a candidate. +Canceled Baliset resumes its original move once; no canceled native effect +is applied. Kull, Nexus and other producer/module combinations remain excluded. Actual selected original cleanup cards are privately discarded first. The typed receipt retains only that cleanup's event, original held-ID commitment diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 85d4fd48..751f3e2d 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,59 @@ # Dune implementation status +## 13 October 2026 — Homeworld Jubba and native Worthless Karama costs + +The Homeworld producer gate now admits only the additional existing +`choamJubbaDiscard` and `karamaChoamWorthlessDiscard` kinds. The unchanged +native cancellation predicate still excludes Kull, battle, Ornithopter and +ecological/arrival-overlap parents. No action, signature, force/payment rule +or optional-module profile changes. + +Jubba uses a real paid reserve shipment, original first-turn suffix, then +turn2 Weather Control by the original Atreides seat. Storm1 plus actual6 +threatens the single CHOAM Red Chasm7 counter; the ordinary Weather discard +receipt is passed by original seats. Native readiness opens the real CHOAM +Storm decision before casualties. The initial fixture mistakenly used the +Kulon fixture's target=c as Weather caster; that C-caster output was real but +did not satisfy the specified third-party fixture. It was corrected to the +actual Atreides seat and the exact closed-gate before proof rerun. + +Ten corrected before programs (Jubba in both modes plus four native printed +power cancellations in both modes) had no cost offer. Twenty-four parent +post-cutover programs cover claim/pass for those sources plus canceled Jubba. +Protection keeps the paid counter alive through the same recorded storm; +canceling retains Jubba, reoffers its original decision, then declining sends +only that counter to Tanks before the same Spice opening. Native printed +counter recovery offers only Karama, never the retained Worthless fuel. +Baliset's original move resumes once; Kulon bonus/LL denial/Gamont return +remain canceled, with original phase/closing controls preserved. + +Four actual390px counter flows cancel with Emperor's held Karama, commit +Semuta and recover that physical Karama. Kulon/Baliset exchange at4/4; +CHOAM retains its printed fuel. Baliset reaches real Battle selection with +Atreides Arrakeen9/Imperial Basin1/moved1, not a second move. Gamont leaves +board10/reserves10/Tanks0 before the original close reaches turn2. La La La +leaves allowance/counters unchanged and reaches original Atreides foresight. +Kulon grants no range and the unused group stays at its original location +before normal Collection. Two additional390px Storm flows use actual protect, +native Karama allow/cancel, Semuta and original CHOAM controls: protected +counter1/Tanks0 versus unprotected counter0/Tanks1, both reserves19 and the +same storm7/turn2/phase1 suffix. Artifacts outside Git: +`/tmp/dune-homeworld-native-counter-3215.json`, +`/tmp/dune-homeworld-native-storm-3216.json`. + +Parent removed wording/log-count and copied-receipt assertions from the +worker tests; real storm position, physical survival/loss and rejected +duplicate controls prove the transitions. The final ten-file union passes +192/192, including70 new cases, with types/lint clean. Native first-turn +Collection can change a fixture wallet, so same-instance baselines preserve +actual original income rather than assigning a constant. Initial Vite +dependency request aborts and cosmetic notice dimming are recorded. +All owned QA services/tabs/root and `/tmp` probe scripts were removed; proof +JSON/screenshots remain outside Git. No deployed, historical-save, natural +full-game or complete-mode certification follows. +The application build passes; module.register deprecation, client chunks +over500kB and Vinext static route classification remain visible warnings. + ## 13 October 2026 — native Homeworld CHOAM Worthless discard continuation The Homeworld Semuta producer gate excluded the native printed Kulon, diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 7c5c7abc..a6b307b0 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -855,6 +855,37 @@ consumer cases pass on that genuine setup. Later held cards/Tanks use conserved transfers, not replacement decks or minted forces. No new ruling, module entry, public gate or complete-mode certification follows. +**13 October 2026 — Homeworld Jubba and native Worthless cancellation.** +The same envelope now admits `choamJubbaDiscard` and +`karamaChoamWorthlessDiscard`. The existing native cancellation predicate +still excludes Kull, battle/Ornithopter and ecological/arrival-overlap parents; +no Nexus or new producer profile is opened. + +Original paid CHOAM shipment supplies one normal Red Chasm7 counter. The +first-turn native suffix reaches turn2 Storm; actual Weather Control by +Atreides moves the recorded storm from1 by6, with the ordinary Weather +Semuta receipt passed by all original seats. Jubba then disposes its exact +card before protection. Claim/pass preserves the original counter, moves +storm1→7 once and resumes the same CHOAM market/Spice opening. Real Karama +cancellation instead retains Jubba and offers only its spent Karama; the +original protection decision reopens, and declining loses the one threatened +counter to Tanks while the same storm/Spice suffix completes. + +Native printed Karama cancellation of Kulon, Baliset, La La La and Gamont +likewise offers only the public spent Karama before the original denial. +Their retained Worthless fuel is never claimable. Canceled Baliset commits +its original declared Atreides move once; canceled Kulon gives no bonus, +canceled La La La keeps free allowance and canceled Gamont returns no force +before restoring its original CHOAM close. Parent runtime covers16 such +claim/pass cases plus8 Jubba/protection-counter cases in both modes. + +Four actual390px cancellation flows and two actual390px storm flows preserve +those original controls, including full4/4 exchanges for movement/Jubba. +`/tmp/dune-homeworld-native-counter-3215.json` and +`/tmp/dune-homeworld-native-storm-3216.json` retain accepted actions and actual +state outside Git. Initial optimizer request aborts are recorded; this is +local fixture proof, not deployed, saved-room or complete-mode acceptance. + **13 October 2026 — native CHOAM Homeworld Worthless costs.** The existing Homeworld envelope now admits the printed Kulon, Baliset, La La La and Trip to Gamont discard continuations. Their original native rules are unchanged; diff --git a/game/engine.ts b/game/engine.ts index 72874ef0..77d6b638 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -6955,7 +6955,9 @@ function semutaOfferSupported( continuation.kind !== 'choamLaLaLaDiscard' && continuation.kind !== 'choamGamontDiscard' && continuation.kind !== 'choamKulonDiscard' && - continuation.kind !== 'choamBalisetDiscard') return false; + continuation.kind !== 'choamBalisetDiscard' && + continuation.kind !== 'choamJubbaDiscard' && + continuation.kind !== 'karamaChoamWorthlessDiscard') return false; if (continuation.kind === 'battleResolved' && !g.lastBattleContext?.mandatoryDiscard) return false; if ((continuation.kind === 'battleCleanup' || diff --git a/game/reference.ts b/game/reference.ts index 835e6dcc..c0fcaf3e 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1186,7 +1186,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'A distinct printed Karama canceling those five ordinary native CHOAM printed or Cunning powers also offers its public cost before the original denial completes. CHOAM retains the declared fuel; a Cunning Nexus remains spent. Claim or Continue restores the same saved response and one original movement, free-revival, storm or closing-Mentat suffix. Acquiring Karama cannot return the canceled force, grant range, stop the original request, protect the storm or restore Nexus. The retained CHOAM fuel is not a discard candidate; Kull and unsupported arrival/flight parents remain separate.', 'A printed Karama canceling the Emperor’s shared gift or extra allied revival also offers that exact public cost before the denial completes. The canceled share moves no spice, forces or revival; the Emperor’s ally and extra-revival ledger are unchanged. Claim or Continue restores the saved response and finishes the cancellation once. Only the actual Semuta holder sees the private claimant choice; other seats see the neutral offer.', 'A printed Karama canceling original paid Guild shipment income, non-Ambassador Emperor auction income or Richese special-purchase income also offers its exact public cost before the native denial. The price and physical transaction have already committed: claim or Continue cannot refund spice, re-ship forces, undo cache acquisition or replay a purchase. Original Movement, next-lot inspection or the Richese purchase’s suspended controls resume once. A winner may spend the very Karama it just bought; claiming that identity does not reverse its payment. Homeworld and Ambassador payment compositions remain separate.', - 'The separate fresh semuta --homeworlds entry retains original Richese/CHOAM and classic Homeworld occupation setup with exact CHOAM and no other modules. Paid Nullentropy Box search, clean Distrans transfer, definite Truthtrance answers, used ordinary specials, paid high-Kaitain disposal, occupied Tupile cleanup and printed Karama canceling Guild/Emperor auction/Richese-purchase income offer Semuta. Native printed Kulon, Baliset, La La La and Trip to Gamont costs also pause before their original effect: claim/pass adds the movement bonus once, denies free revival without moving or charging forces, stops the original uncommitted Baliset move, or returns one original board counter to native reserves before the same CHOAM closing choice. A recovered Worthless card does not grant another faction CHOAM powers. Kaitain private groups require commitment before one selection; each Tupile holder has a separate cost. Canceling native income keeps payment and acquisition/forces but not native credit; its printed occupied portion still goes through the original receiver once. Basic unresolved epochs, Jubba, other producers, module mixtures and public starts remain guarded.', + 'The separate fresh semuta --homeworlds entry retains original Richese/CHOAM and classic Homeworld occupation setup with exact CHOAM and no other modules. Paid Box search, clean Distrans transfer, definite Truthtrance answers, used ordinary specials, paid Kaitain disposal, Tupile cleanup and printed Karama canceling native Guild/Emperor/Richese-purchase income offer Semuta. Native printed Kulon, Baliset, La La La, Trip to Gamont and Jubba costs also pause before their original effect: claim/pass adds one movement bonus, denies free allowance without charging/moving forces, stops the uncommitted Baliset move, returns one original board counter before the same CHOAM close, or applies protection before the same recorded storm moves. Printed Karama canceling these powers pauses separately before denial; the retained Worthless fuel is not a candidate. A canceled reactive Baliset resumes its original declared move once; canceled Kulon/La La La/Gamont/Jubba do not apply their effects. A recovered card grants no other faction CHOAM power. Kaitain private groups require commitment before selection; each Tupile holder has a separate cost. Native income denial retains payment and the original occupied-share allocation. Basic unresolved epochs, Kull, other producers and public starts remain guarded.', '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.', @@ -1264,6 +1264,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/semuta-homeworld-ordinary.test.ts', 'tests/semuta-homeworld-choam-movement.test.ts', 'tests/semuta-homeworld-choam-returns.test.ts', + 'tests/semuta-homeworld-choam-storm.test.ts', + 'tests/semuta-homeworld-choam-karama.test.ts', ], }, ], diff --git a/tests/fixture-semuta-homeworld-choam-karama.ts b/tests/fixture-semuta-homeworld-choam-karama.ts new file mode 100644 index 00000000..128334a9 --- /dev/null +++ b/tests/fixture-semuta-homeworld-choam-karama.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { semutaEmperorPlayer } from './fixture-semuta-emperor-share'; +import { applySemutaHomeworldChoamStep, holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam'; +import { + createSemutaHomeworldChoamMovementFixture, + type SemutaHomeworldChoamMovementFixture, +} from './fixture-semuta-homeworld-choam-movement'; +import { + createSemutaHomeworldChoamReturnsFixture, + type SemutaHomeworldChoamReturnsFixture, +} from './fixture-semuta-homeworld-choam-returns'; +import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech'; + +export type SemutaHomeworldChoamKaramaPower = 'kulon' | 'baliset' | 'laLaLa' | 'gamont'; +export type SemutaHomeworldChoamKaramaFixture = { + setup: Game; + beforeDeclaration: Game; + /** Exact original declaration response, before Emperor spends its Karama. */ + game: Game; + owner: string; + holder: string; + target: string; + canceller: string; + card: string; + karama: string; + power: SemutaHomeworldChoamKaramaPower; + action: Action; + cancelAction: Action; + actions: RicheseSkillsTechStep[]; + movement?: SemutaHomeworldChoamMovementFixture; + returns?: SemutaHomeworldChoamReturnsFixture; +}; + +/** Genuine native prefix and printed declaration. Only conserved original + * physical cards are positioned; the movement fixture already holds Karama. */ +export function createSemutaHomeworldChoamKaramaFixture( + power: SemutaHomeworldChoamKaramaPower, advanced = false, fullHolder = false, +): SemutaHomeworldChoamKaramaFixture { + const movement = power === 'kulon' || power === 'baliset' + ? createSemutaHomeworldChoamMovementFixture(power, advanced, fullHolder) : undefined; + const returns = power === 'laLaLa' || power === 'gamont' + ? createSemutaHomeworldChoamReturnsFixture(power, advanced) : undefined; + const native = movement ?? returns; + assert.ok(native); + const beforeDeclaration = structuredClone(native.game); + const canceller = movement?.canceller + ?? beforeDeclaration.players.find(player => player.faction === 'emperor')!.id; + assert.equal(semutaEmperorPlayer(beforeDeclaration, canceller).faction, 'emperor'); + const held = semutaEmperorPlayer(beforeDeclaration, canceller).hand.find(card => card.effect === 'karama'); + const karama = held?.id ?? holdSemutaHomeworldChoamCard(beforeDeclaration, canceller, 'Karama'); + const actions = structuredClone(native.actions); + const game = applySemutaHomeworldChoamStep(beforeDeclaration, native.owner, native.action, actions); + assert.equal(game.response?.kind, 'choamWorthless'); + assert.equal(game.response!.owner, native.owner); + assert.ok(viewGame(game, canceller).responseControls?.cancelCards.includes(karama)); + return { setup: native.setup, beforeDeclaration, game, + owner: native.owner, holder: native.holder, target: native.target, canceller, + card: native.card, karama, power, action: native.action, + cancelAction: { type: 'card', mode: 'cancel', card: karama }, actions, movement, returns }; +} + +export function cancelSemutaHomeworldChoamKarama( + fixture: SemutaHomeworldChoamKaramaFixture, state = fixture.game, actions?: RicheseSkillsTechStep[], +): Game { + return applySemutaHomeworldChoamStep(state, fixture.canceller, fixture.cancelAction, actions); +} + +/** Singleton printed Karama only; pass every still-entitled neutral participant. */ +export function reactSemutaHomeworldChoamKarama( + state: Game, holder: string, claim: boolean, actions?: RicheseSkillsTechStep[], +): Game { + const pending = state.pendingTreacheryDiscard; + assert.ok(pending); + const reaction = pending.reaction; + if (reaction?.stage !== 'offer') throw Error('Expected the fresh native Karama discard offer'); + const event = pending.batch.event; + if (claim) return applySemutaHomeworldChoamStep(state, holder, { type: 'semutaCommit', event }, actions); + let game = state; + for (const player of state.players.filter(player => !reaction.passed.includes(player.id))) + game = applySemutaHomeworldChoamStep(game, player.id, { type: 'semutaPass', event }, actions); + return game; +} diff --git a/tests/fixture-semuta-homeworld-choam-storm.ts b/tests/fixture-semuta-homeworld-choam-storm.ts new file mode 100644 index 00000000..1f09dc13 --- /dev/null +++ b/tests/fixture-semuta-homeworld-choam-storm.ts @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import { handLimit, viewGame, type Action, type Game } from '../game/engine'; +import { choamPowerAction, choamPowerPlays } from '../game/choam-power-options'; +import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep, + holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam'; +import { createSemutaHomeworldChoamMovementFixture, + allowSemutaHomeworldChoamMovement } from './fixture-semuta-homeworld-choam-movement'; +import { moveSemutaEmperorCard, semutaEmperorPlayer } from './fixture-semuta-emperor-share'; +import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech'; + +export type SemutaHomeworldChoamStormFixture = { + setup: Game; beforeShipment: Game; afterShipment: Game; beforeWeather: Game; + afterWeather: Game; game: Game; owner: string; holder: string; target: string; + canceller: string; weatherOwner: string; weatherCard: string; retainedKulon: string; + card: string; source: 'printed'; territory: string; origin: string; + action: Action; weatherAction: Action; weatherAmount: number; shipment: Action; + participants: string[]; actions: RicheseSkillsTechStep[]; staging: string[]; +}; +const clean = (game: Game) => !game.phaseOpening && !game.response && + !game.decision && !game.pendingTreacheryDiscard; + +/** Paid original reserves shipment, original first-turn suffix, then actual + * Weather Control and native Storm readiness. No force or clock is assigned. */ +export function createSemutaHomeworldChoamStormFixture( + advanced = false, fullHolder = false, +): SemutaHomeworldChoamStormFixture { + const prefix = createSemutaHomeworldChoamMovementFixture('kulon', advanced, false); + let game = structuredClone(prefix.afterShipment); + const actions = [...prefix.actions], staging = [...prefix.staging]; + const owner = prefix.owner, holder = prefix.holder; + const weatherPlayer = game.players.find(player => player.faction === 'atreides'); + assert.ok(weatherPlayer, 'The original setup supplies the other Weather seat'); + const weatherOwner = weatherPlayer.id; + assert.notEqual(weatherOwner, owner); + const card = holdSemutaHomeworldChoamCard(game, owner, 'Jubba Cloak'); + const weatherCard = holdSemutaHomeworldChoamCard(game, weatherOwner, 'Weather Control'); + staging.push('Conserved original Jubba Cloak to CHOAM and Weather Control to Atreides; Kulon remains unplayed.'); + for (const player of game.players) assert.ok(player.hand.length <= handLimit(player)); + game = advanceSemutaHomeworldChoam(game, + state => state.turn === 2 && state.phase === 0 && clean(state), actions); + assert.equal(semutaEmperorPlayer(game, owner).forces[prefix.origin], 1); + assert.ok(semutaEmperorPlayer(game, owner).hand.some(held => held.id === prefix.card)); + const beforeWeather = structuredClone(game); + const amount = (7 - game.storm + 18) % 18; + assert.ok(amount >= 0 && amount <= 10, 'Original Weather dial can reach the shipped counter'); + const weatherAction: Action = { type: 'card', card: weatherCard, amount }; + game = applySemutaHomeworldChoamStep(game, weatherOwner, weatherAction, actions); + game = advanceSemutaHomeworldChoam(game, state => clean(state), actions); + const afterWeather = structuredClone(game); + assert.equal(game.stormPending, amount); + assert.equal(game.stormMovementSource?.kind, 'weather'); + game = advanceSemutaHomeworldChoam(game, + state => state.decision?.kind === 'choamStorm' && state.decision.player === owner, actions); + assert.equal(game.turn, 2); + assert.equal(game.phase, 0); + assert.equal(game.storm, beforeWeather.storm); + assert.equal(semutaEmperorPlayer(game, owner).forces[prefix.origin], 1); + if (fullHolder) { + const player = semutaEmperorPlayer(game, holder); + while (player.hand.length < handLimit(player)) { + assert.ok(game.deck[0]); + moveSemutaEmperorCard(game, game.deck[0].id, holder); + } + staging.push('Conserved original undealt cards fill Richese to its native hand limit.'); + } + const view = viewGame(game, owner); + const printed = choamPowerPlays(view, 'jubba').find(play => play.source === 'printed' && play.card.id === card); + assert.ok(printed); + const territory = 'red_chasm'; + const action = choamPowerAction(view, printed, { territory }); + assert.ok(action); + return { setup: prefix.setup, beforeShipment: prefix.beforeShipment, + afterShipment: prefix.afterShipment, beforeWeather, afterWeather, game, + owner, holder, target: owner, canceller: prefix.canceller, weatherOwner, weatherCard, + retainedKulon: prefix.card, card, source: 'printed', territory, origin: prefix.origin, + action, weatherAction, weatherAmount: amount, shipment: prefix.shipment, + participants: game.players.map(player => player.id), actions, staging }; +} + +/** Declare the canonical printed power and pass only real Karama controls. */ +export function disposeSemutaHomeworldChoamStorm( + fixture: SemutaHomeworldChoamStormFixture, state = fixture.game, + actions?: RicheseSkillsTechStep[], +): Game { + return allowSemutaHomeworldChoamMovement( + applySemutaHomeworldChoamStep(state, fixture.owner, fixture.action, actions), actions); +} + +/** Finish the original Storm's CHOAM market and opening controls, not a new turn. */ +export function finishSemutaHomeworldChoamStorm( + state: Game, actions?: RicheseSkillsTechStep[], +): Game { + const game = advanceSemutaHomeworldChoam(state, + current => current.phase === 1 && clean(current), actions); + assert.equal(game.turn, 2); + return game; +} diff --git a/tests/semuta-homeworld-choam-karama.test.ts b/tests/semuta-homeworld-choam-karama.test.ts new file mode 100644 index 00000000..a3bcbb8d --- /dev/null +++ b/tests/semuta-homeworld-choam-karama.test.ts @@ -0,0 +1,189 @@ +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 { DIFFICULTIES } from '../game/bot-profiles'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share'; +import { advanceSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam'; +import { + semutaHomeworldChoamMovementResources as resources, + semutaHomeworldChoamMovementStock as stock, + withoutSemutaHomeworldChoamMovementHolder, +} from './fixture-semuta-homeworld-choam-movement'; +import { semutaHomeworldChoamReturnsForces } from './fixture-semuta-homeworld-choam-returns'; +import { + cancelSemutaHomeworldChoamKarama, createSemutaHomeworldChoamKaramaFixture, + reactSemutaHomeworldChoamKarama, type SemutaHomeworldChoamKaramaFixture, +} from './fixture-semuta-homeworld-choam-karama'; + +function assertCancellation(f: SemutaHomeworldChoamKaramaFixture, done: Game, claim: boolean) { + assert.ok(!done.pendingTreacheryDiscard); + assert.ok(!done.response); + assert.equal(done.turn, f.beforeDeclaration.turn); + assert.equal(done.phase, f.beforeDeclaration.phase); + assert.deepEqual(stock(done), stock(f.beforeDeclaration)); + assert.deepEqual(semutaHomeworldChoamReturnsForces(done), semutaHomeworldChoamReturnsForces(f.beforeDeclaration)); + assert.equal(player(done, f.owner).hand.filter(card => card.id === f.card).length, 1, + 'Canceled printed fuel stays with its original CHOAM owner'); + assert.equal(player(done, f.holder).hand.some(card => card.id === f.card), false); + assert.equal(done.discard.some(card => card.id === f.card), false); + assert.equal(player(done, f.canceller).hand.some(card => card.id === f.karama), false); + assert.equal(player(done, f.holder).hand.some(card => card.id === f.karama), claim); + assert.equal(player(done, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claim); + assert.equal(done.discard.filter(card => card.id === f.karama).length, Number(!claim)); + assert.equal(done.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim)); + for (const seat of done.players) { + assert.equal(seat.hand.length, player(f.beforeDeclaration, seat.id).hand.length - Number(seat.id === f.canceller), + 'Only Emperor loses a card; a claim is an exact one-for-one Semuta exchange'); + assert.equal(seat.spice, player(f.beforeDeclaration, seat.id).spice); + assert.equal(seat.revived, player(f.beforeDeclaration, seat.id).revived); + assert.equal(seat.freeForcesRevived, player(f.beforeDeclaration, seat.id).freeForcesRevived); + } + assert.throws(() => applyAction(done, f.canceller, f.cancelAction), 'Paid original Karama cannot cancel again'); + assert.throws(() => applyAction(done, f.owner, f.action), 'Retained printed fuel cannot replay this phase'); + if (claim) assert.throws(() => applyAction(done, f.holder, f.action), + 'Only Karama is recovered; Richese never acquires the canceled CHOAM power'); + + if (f.power === 'baliset') { + const movement = f.movement!; + const declined = applyAction(f.beforeDeclaration, f.owner, { type: 'decision', decline: true }); + assert.deepEqual(resources(done), resources(declined), + 'Canceling Baliset commits exactly the original unmodified reactive move'); + assert.equal(player(done, f.target).moved, 1); + assert.equal(player(done, f.target).forces[movement.origin], player(movement.beforeMove, f.target).forces[movement.origin] - 1); + assert.equal(player(done, f.target).forces[movement.destination], 1); + assert.ok(!done.pendingChoamMove); + assert.deepEqual(viewGame(done, f.target).balisetRestrictions, []); + assert.throws(() => applyAction(done, f.target, movement.move), 'The original move cannot resume twice'); + assert.equal(player(done, f.owner).reserves, player(movement.afterShipment, f.owner).reserves); + } else { + assert.deepEqual(resources(done), resources(f.beforeDeclaration), + 'Canceling a power never applies its original resources or counters'); + if (f.power === 'kulon') { + assert.equal(viewGame(done, f.owner).choamMovementBonus, 0); + assert.throws(() => applyAction(done, f.owner, f.movement!.move), 'Kulon cancellation gives no range-two bonus'); + assert.equal(player(done, f.owner).reserves, player(f.movement!.afterShipment, f.owner).reserves); + } else if (f.power === 'laLaLa') { + const before = viewGame(f.beforeDeclaration, f.target).revival; + const after = viewGame(done, f.target).revival; + assert.ok(before.freeRemaining > 0); + assert.equal(after.freeRemaining, before.freeRemaining); + assert.equal(after.freeBlocked, false); + assert.equal(after.limit, before.limit); + for (const seat of done.players) + assert.equal(viewGame(done, seat.id).revival.freeRemaining, viewGame(f.beforeDeclaration, seat.id).revival.freeRemaining); + } else { + assert.deepEqual(viewGame(done, f.target).homeworlds!.worlds!.map(world => world.forces), + viewGame(f.beforeDeclaration, f.target).homeworlds!.worlds!.map(world => world.forces)); + assert.equal(done.decision?.kind, 'choamMentat'); + assert.equal(done.decision!.player, f.owner); + assert.equal(done.choamMentatPending, true); + assert.equal(viewGame(done, f.target).mentatVictoryPending, true); + assert.throws(() => applyAction(done, f.target, { type: 'decision', done: true })); + } + } +} + +function consumeCancellationSuffix(f: SemutaHomeworldChoamKaramaFixture, state: Game) { + const after = f.power === 'gamont' + ? applyAction(state, f.owner, { type: 'decision', done: true }) + : advanceSemutaHomeworldChoam(state, game => game.phase > state.phase + && !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard); + assert.equal(after.status, 'playing'); + assert.equal(after.turn, state.turn + Number(f.power === 'gamont')); + assert.deepEqual(stock(after), stock(state)); + assert.deepEqual(semutaHomeworldChoamReturnsForces(after), semutaHomeworldChoamReturnsForces(state)); + for (const seat of after.players) { + const before = player(state, seat.id); + assert.deepEqual(seat.forces, before.forces, 'Native suffix cannot run a canceled move or return a second time'); + assert.deepEqual(seat.elites, before.elites); + assert.equal(seat.reserves, before.reserves); + assert.equal(seat.tanks, before.tanks); + } + if (f.power === 'gamont') { + assert.equal(after.phase, 0); + assert.equal(after.choamMentatPending, false); + assert.throws(() => applyAction(after, f.owner, { type: 'decision', done: true }), + 'The original CHOAM close is consumed exactly once'); + } else assert.ok(after.phase >= (f.power === 'laLaLa' ? 5 : 6)); +} + +for (const advanced of [false, true]) for (const power of ['kulon', 'baliset', 'laLaLa', 'gamont'] as const) { + const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld printed ${power} cancellation`; + for (const claim of [false, true]) void test(`${label}: Semuta ${claim ? 'claims' : 'passes'} only original Emperor Karama before one native denial suffix`, () => { + const f = createSemutaHomeworldChoamKaramaFixture(power, advanced); + if (f.movement) { + assert.equal(player(f.movement.afterShipment, f.owner).spice, player(f.movement.beforeShipment, f.owner).spice - 2); + assert.equal(player(f.movement.afterShipment, f.owner).reserves, player(f.movement.beforeShipment, f.owner).reserves - 1); + } + const pending = cancelSemutaHomeworldChoamKarama(f); + const cost = pending.pendingTreacheryDiscard; + assert.ok(cost, 'Actual Karama spending pauses before printed denial and original continuation'); + assert.equal(pending.discard.filter(card => card.id === f.karama).length, 1); + assert.equal(player(pending, f.owner).hand.some(card => card.id === f.card), true); + assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true); + assert.deepEqual(resources(pending), resources(f.beforeDeclaration), 'Original move/return stays uncommitted during recovery'); + assert.deepEqual(stock(pending), stock(f.beforeDeclaration)); + assert.throws(() => applyAction(pending, f.canceller, f.cancelAction)); + assert.throws(() => applyAction(pending, f.owner, f.action)); + assert.throws(() => applyAction(pending, f.target, { type: 'ready' })); + assert.throws(() => applyAction(pending, f.owner, { type: 'decision', done: true })); + assert.throws(() => applyAction(pending, f.target, { type: 'semutaCommit', event: cost.batch.event })); + if (f.movement) assert.throws(() => applyAction(pending, f.target, f.movement!.move)); + const done = reactSemutaHomeworldChoamKarama(structuredClone(pending), f.holder, claim); + assertCancellation(f, done, claim); + assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event: cost.batch.event })); + assert.throws(() => applyAction(done, f.target, { type: 'semutaPass', event: cost.batch.event })); + consumeCancellationSuffix(f, done); + }); + + void test(`${label}: actual entitlement is private and cancellation cost is a singleton exchange`, () => { + const f = createSemutaHomeworldChoamKaramaFixture(power, advanced, power === 'kulon' || power === 'baliset'); + const pending = cancelSemutaHomeworldChoamKarama(f); + const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder); + const neutral = cancelSemutaHomeworldChoamKarama(f, absent); + assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true); + assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false); + for (const seat of pending.players.filter(seat => seat.id !== f.holder)) { + assert.deepEqual(viewGame(pending, seat.id), viewGame(neutral, seat.id)); + assert.equal(viewGame(pending, seat.id).semutaReaction?.canCommit, false); + assert.throws(() => applyAction(pending, seat.id, + { type: 'semutaCommit', event: pending.pendingTreacheryDiscard!.batch.event })); + } + const done = reactSemutaHomeworldChoamKarama(pending, f.holder, true); + assertCancellation(f, done, true); + if (f.movement) assert.equal(player(done, f.holder).hand.length, handLimit(player(done, f.holder)), + 'Printed Karama exchange remains legal at the native hand limit'); + const noHolder = reactSemutaHomeworldChoamKarama(neutral, f.holder, false); + assert.equal(noHolder.discard.filter(card => card.id === f.karama).length, 1); + assert.deepEqual(resources(noHolder), resources(done)); + consumeCancellationSuffix(f, done); + }); + + for (const difficulty of DIFFICULTIES) void test(`${difficulty} legally claims or passes ${label} then consumes original controls`, () => { + const f = createSemutaHomeworldChoamKaramaFixture(power, advanced); + let game = cancelSemutaHomeworldChoamKarama(f); + const own = viewGame(game, f.holder); + own.players.find(seat => seat.id === f.holder)!.bot = difficulty; + const action = botActions(own)[0]; + assert.ok(action); + assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass'); + game = applyAction(game, f.holder, action); + if (game.pendingTreacheryDiscard) game = reactSemutaHomeworldChoamKarama(game, f.holder, false); + assertCancellation(f, game, action.type === 'semutaCommit'); + if (power === 'gamont') { + const closing = viewGame(game, f.owner); + closing.players.find(seat => seat.id === f.owner)!.bot = difficulty; + const close = botActions(closing)[0]; + assert.ok(close); + const done = applyAction(game, f.owner, close); + assert.equal(done.phase, 0); + assert.equal(done.turn, game.turn + 1); + assert.equal(done.choamMentatPending, false); + assert.deepEqual(player(done, f.target).forces, player(game, f.target).forces); + assert.deepEqual(done.homeworlds?.custody, game.homeworlds?.custody); + assert.deepEqual(stock(done), stock(game)); + } else consumeCancellationSuffix(f, game); + }); +} diff --git a/tests/semuta-homeworld-choam-storm.test.ts b/tests/semuta-homeworld-choam-storm.test.ts new file mode 100644 index 00000000..cb2af616 --- /dev/null +++ b/tests/semuta-homeworld-choam-storm.test.ts @@ -0,0 +1,280 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, handLimit, normalizeAutomaticGame, viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam'; +import { splitLocation } from '../game/board'; +import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share'; +import { semutaHomeworldChoamMovementResources as resources, + semutaHomeworldChoamMovementStock as stock, + withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement'; +import { createSemutaHomeworldChoamStormFixture, disposeSemutaHomeworldChoamStorm, + finishSemutaHomeworldChoamStorm, type SemutaHomeworldChoamStormFixture } from './fixture-semuta-homeworld-choam-storm'; + +/** Sectors that the fixture's actual Weather dial really traverses this turn. */ +function stormCrossedSectors(fixture: SemutaHomeworldChoamStormFixture): number[] { + return Array.from({ length: fixture.weatherAmount }, + (_, step) => (fixture.beforeWeather.storm + step) % 18 + 1); +} + +function assertConservedCounters(game: Game, original: Game) { + for (const seat of game.players) { + const before = player(original, seat.id); + assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum, amount) => sum + amount, 0), + before.reserves + before.tanks + Object.values(before.forces).reduce((sum, amount) => sum + amount, 0)); + assert.ok(Object.values(seat.forces).every(amount => amount > 0)); + if (seat.elites && before.elites) { + assert.equal(seat.elites.reserves + seat.elites.tanks + Object.values(seat.elites.forces).reduce((sum, amount) => sum + amount, 0), + before.elites.reserves + before.elites.tanks + Object.values(before.elites.forces).reduce((sum, amount) => sum + amount, 0)); + for (const [key, amount] of Object.entries(seat.elites.forces)) + assert.ok(amount >= 0 && amount <= (seat.forces[key] ?? 0)); + } + } + assert.deepEqual(game.homeworlds?.custody, original.homeworlds?.custody); +} + +for (const advanced of [false, true]) { + const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld Jubba`; + for (const claim of [false, true]) void test(`${label}: Semuta ${claim ? 'claim' : 'pass'} protects the genuinely shipped original counter and resumes one Weather storm`, () => { + const f = createSemutaHomeworldChoamStormFixture(advanced); + const inventory = stock(f.game), paid = resources(f.game); + assert.equal(f.afterShipment.turn, 1); + assert.equal(f.afterShipment.phase, 5); + assert.equal(player(f.afterShipment, f.owner).spice, player(f.beforeShipment, f.owner).spice - 2); + assert.equal(player(f.afterShipment, f.owner).reserves, player(f.beforeShipment, f.owner).reserves - 1); + assert.equal(player(f.afterShipment, f.owner).forces[f.origin], 1); + assert.equal(player(f.game, f.owner).reserves, player(f.afterShipment, f.owner).reserves); + assert.equal(f.beforeWeather.turn, 2); + assert.equal(f.beforeWeather.phase, 0); + assert.equal(f.beforeWeather.storm, 1); + assert.notEqual(f.weatherOwner, f.owner); + assert.equal(player(f.beforeWeather, f.weatherOwner).hand.filter(card => card.id === f.weatherCard).length, 1); + assert.equal(player(f.afterWeather, f.weatherOwner).hand.some(card => card.id === f.weatherCard), false); + assert.equal(f.afterWeather.stormPending, f.weatherAmount); + assert.equal(f.game.stormMovementSource?.kind, 'weather'); + assert.equal(f.game.stormMovementSource?.distance, f.weatherAmount); + assert.equal(f.game.discard.filter(card => card.id === f.weatherCard).length, 1); + assert.equal(player(f.game, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), true, + 'The ordinary Weather receipt was passed natively, not claimed'); + assert.equal(f.game.decision?.kind, 'choamStorm'); + if (f.game.decision?.kind !== 'choamStorm') throw Error('Missing original Storm protection decision'); + assert.equal(f.game.decision.player, f.owner); + assert.throws(() => applyAction(f.game, f.weatherOwner, f.action), + 'Another original seat cannot protect itself using CHOAM’s printed card'); + assert.throws(() => applyAction(f.game, f.owner, { ...f.action, territory: 'arrakeen' }), + 'The original foreign Atreides territory is not a CHOAM protection target'); + assert.throws(() => applyAction(f.game, f.owner, { ...f.action, target: f.weatherOwner }), + 'The real CHOAM decision cannot redirect protection to a foreign faction'); + assert.deepEqual(stock(f.game), stock(f.setup)); + assertConservedCounters(f.game, f.setup); + + const declared = applyAction(f.game, f.owner, f.action); + assert.equal(declared.response?.kind, 'choamWorthless'); + assert.equal(declared.pendingTreacheryDiscard, null, 'Jubba cannot be disposed before original Karama passes'); + assert.equal(player(declared, f.owner).hand.some(card => card.id === f.card), true); + const offered = disposeSemutaHomeworldChoamStorm(f); + assert.equal(offered.pendingTreacheryDiscard?.continuation.kind, 'choamJubbaDiscard'); + assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true); + assert.equal(offered.storm, f.game.storm); + assert.equal(offered.stormResolution?.distance, f.weatherAmount); + assert.deepEqual(offered.stormResolution?.choamProtected ?? [], []); + assert.deepEqual(resources(offered), paid); + assert.deepEqual(stock(offered), inventory); + assert.equal(offered.discard.filter(card => card.id === f.card).length, 1); + assert.equal(player(offered, f.owner).hand.some(card => card.id === f.card), false); + assert.deepEqual(normalizeAutomaticGame(structuredClone(offered)), offered); + const event = offered.pendingTreacheryDiscard!.batch.event; + assert.throws(() => applyAction(offered, f.owner, { type: 'decision', decline: true })); + assert.throws(() => applyAction(offered, f.owner, f.action)); + assert.throws(() => applyAction(offered, f.holder, { type: 'semutaCommit', event: `${event}:stale` })); + + const resumed = reactSemutaHomeworldChoam(structuredClone(offered), f.holder, claim); + assert.equal(resumed.pendingTreacheryDiscard, null); + assert.equal(resumed.stormResolution, null); + assert.equal(resumed.storm, 7); + assert.equal(resumed.decision?.kind, 'choamMarket'); + assert.equal(resumed.turn, 2); + assert.equal(player(resumed, f.owner).forces[f.origin], 1); + assert.equal(player(resumed, f.owner).tanks, 0); + assert.deepEqual(resources(resumed).players, paid.players, + 'Protection moves no counter, pays no new cost and does not repeat the shipment'); + const protectedSpice = Object.fromEntries(Object.entries(f.game.spice) + .filter(([key]) => !stormCrossedSectors(f).includes(splitLocation(key).sector))); + assert.deepEqual(resumed.spice, protectedSpice, + 'The original storm destroys spice only in the sectors it crosses'); + assert.equal(player(resumed, f.holder).hand.filter(card => card.id === f.card).length, claim ? 1 : 0); + assert.equal(resumed.discard.filter(card => card.id === f.card).length, claim ? 0 : 1); + assert.equal(resumed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, claim ? 1 : 0); + assert.equal(player(resumed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claim); + assert.equal(resumed.discard.filter(card => card.id === f.weatherCard).length, 1); + assert.equal(player(resumed, f.owner).hand.filter(card => card.id === f.retainedKulon).length, 1); + assert.deepEqual(stock(resumed), inventory); + assertConservedCounters(resumed, f.setup); + assert.throws(() => applyAction(resumed, f.holder, { type: 'semutaCommit', event })); + assert.throws(() => applyAction(resumed, f.holder, { type: 'semutaPass', event })); + assert.throws(() => applyAction(resumed, f.owner, f.action)); + assert.throws(() => applyAction(resumed, f.holder, f.action)); + const completed = finishSemutaHomeworldChoamStorm(resumed); + assert.equal(completed.turn, 2); + assert.equal(completed.phase, 1); + assert.equal(completed.storm, 7); + assert.equal(player(completed, f.owner).forces[f.origin], 1); + assert.equal(player(completed, f.owner).reserves, player(f.game, f.owner).reserves); + assert.equal(player(completed, f.owner).spice, player(f.game, f.owner).spice); + assert.deepEqual(stock(completed), inventory); + assertConservedCounters(completed, f.setup); + }); + + void test(`${label}: full-hand singleton exchange keeps entitlement private and rejects replayed receipt controls`, () => { + const f = createSemutaHomeworldChoamStormFixture(advanced, true); + assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder))); + const offered = disposeSemutaHomeworldChoamStorm(f); + const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder); + const neutral = disposeSemutaHomeworldChoamStorm(f, absent); + assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true); + assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false); + const event = offered.pendingTreacheryDiscard!.batch.event; + for (const seat of offered.players.filter(seat => seat.id !== f.holder)) { + assert.deepEqual(viewGame(offered, seat.id), viewGame(neutral, seat.id)); + assert.equal(viewGame(offered, seat.id).semutaReaction?.canCommit, false); + assert.throws(() => applyAction(offered, seat.id, { type: 'semutaCommit', event })); + } + assert.throws(() => applyAction(offered, 'outside-seat', { type: 'semutaPass', event })); + const passed = applyAction(offered, f.owner, { type: 'semutaPass', event }); + assert.throws(() => applyAction(passed, f.owner, { type: 'semutaPass', event })); + assert.equal(passed.storm, 1); + assert.equal(player(passed, f.owner).forces[f.origin], 1); + const claimed = reactSemutaHomeworldChoam(offered, f.holder, true); + assert.equal(player(claimed, f.holder).hand.length, handLimit(player(claimed, f.holder))); + assert.equal(player(claimed, f.holder).hand.filter(card => card.id === f.card).length, 1); + assert.equal(player(claimed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), false); + assert.deepEqual(stock(claimed), stock(f.game)); + assertConservedCounters(claimed, f.setup); + assert.equal(player(finishSemutaHomeworldChoamStorm(claimed), f.owner).forces[f.origin], 1); + }); + + void test(`${label}: all four minimal bot profiles legally claim or pass and finish the original Weather storm`, () => { + const f = createSemutaHomeworldChoamStormFixture(advanced); + const offered = disposeSemutaHomeworldChoamStorm(f); + for (const difficulty of DIFFICULTIES) { + let game: Game = structuredClone(offered); + const own = viewGame(game, f.holder); + own.players.find(seat => seat.id === f.holder)!.bot = difficulty; + const action = botActions(own)[0]; + assert.ok(action, `${difficulty} has an entitled receipt action`); + assert.ok(['semutaCommit', 'semutaPass'].includes(action.type)); + game = applyAction(game, f.holder, action); + while (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') { + const pending = game.pendingTreacheryDiscard, reaction = pending.reaction; + if (reaction?.stage !== 'offer') throw Error('Missing neutral discard offer'); + const actor = game.players.find(seat => !reaction.passed.includes(seat.id))!; + game = applyAction(game, actor.id, { type: 'semutaPass', event: pending.batch.event }); + } + assert.equal(game.pendingTreacheryDiscard, null); + assert.equal(game.storm, 7); + assert.equal(player(game, f.owner).forces[f.origin], 1); + assert.equal(player(game, f.holder).hand.some(card => card.id === f.card), action.type === 'semutaCommit'); + assert.deepEqual(resources(game).players, resources(f.game).players); + const botSpice = Object.fromEntries(Object.entries(f.game.spice) + .filter(([key]) => !stormCrossedSectors(f).includes(splitLocation(key).sector))); + assert.deepEqual(game.spice, botSpice); + assert.deepEqual(stock(game), stock(f.game)); + assertConservedCounters(game, f.setup); + const completed = finishSemutaHomeworldChoamStorm(game); + assert.equal(completed.phase, 1); + assert.equal(player(completed, f.owner).forces[f.origin], 1); + } + }); + + void test(`${label}: native decline leaves Jubba undisposed and the single unprotected original counter dies once`, () => { + const f = createSemutaHomeworldChoamStormFixture(advanced); + const declined = applyAction(f.game, f.owner, { type: 'decision', decline: true }); + assert.equal(declined.pendingTreacheryDiscard, null); + assert.equal(declined.storm, 7); + assert.equal(declined.stormResolution, null); + assert.equal(declined.decision?.kind, 'choamMarket'); + assert.equal(player(declined, f.owner).forces[f.origin] ?? 0, 0); + assert.equal(player(declined, f.owner).tanks, 1); + assert.equal(player(declined, f.owner).reserves, player(f.game, f.owner).reserves); + assert.equal(player(declined, f.owner).spice, player(f.game, f.owner).spice); + assert.equal(player(declined, f.owner).hand.filter(card => card.id === f.card).length, 1); + assert.equal(declined.discard.filter(card => card.id === f.card).length, 0); + assert.equal(player(declined, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), true); + assert.equal(player(declined, f.owner).hand.filter(card => card.id === f.retainedKulon).length, 1); + assert.deepEqual(stock(declined), stock(f.game)); + assertConservedCounters(declined, f.setup); + assert.throws(() => applyAction(declined, f.owner, { type: 'decision', decline: true })); + assert.throws(() => applyAction(declined, f.owner, f.action)); + assert.equal(finishSemutaHomeworldChoamStorm(declined).phase, 1); + }); + + for (const claim of [false, true]) void test(`${label}: real Karama cancellation Semuta ${claim ? 'claim' : 'pass'} decides the physical Karama while Jubba stays and the counter dies unprotected`, () => { + const f = createSemutaHomeworldChoamStormFixture(advanced); + const inventory = stock(f.game), paid = resources(f.game); + const karama = player(f.game, f.canceller).hand.find(card => card.name === 'Karama'); + assert.ok(karama); + const declared = applyAction(f.game, f.owner, f.action); + assert.equal(declared.response?.kind, 'choamWorthless'); + const cancelled = applyAction(declared, f.canceller, { type: 'card', mode: 'cancel', card: karama.id }); + assert.equal(cancelled.response, null); + assert.equal(cancelled.pendingTreacheryDiscard?.continuation.kind, 'karamaChoamWorthlessDiscard'); + assert.equal(cancelled.decision, null); + assert.equal(cancelled.storm, f.game.storm); + assert.equal(player(cancelled, f.owner).forces[f.origin], 1); + assert.equal(player(cancelled, f.owner).tanks, 0); + assert.equal(player(cancelled, f.owner).hand.filter(card => card.id === f.card).length, 1, + 'A cancelled declaration never disposes the retained Jubba'); + assert.equal(viewGame(cancelled, f.holder).semutaReaction?.canCommit, true); + const event = cancelled.pendingTreacheryDiscard!.batch.event; + for (const seat of cancelled.players.filter(seat => seat.id !== f.holder)) + assert.throws(() => applyAction(cancelled, seat.id, { type: 'semutaCommit', event })); + assert.throws(() => applyAction(cancelled, f.canceller, f.action), + 'Jubba cannot be declared again during the Karama receipt'); + assert.throws(() => applyAction(cancelled, f.canceller, { type: 'decision', decline: true })); + assert.equal(cancelled.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + + const resumed = reactSemutaHomeworldChoam(structuredClone(cancelled), f.holder, claim); + assert.equal(resumed.pendingTreacheryDiscard, null); + assert.equal(resumed.decision?.kind, 'choamStorm', 'The safe original protection decision is reoffered'); + assert.equal(player(resumed, f.owner).forces[f.origin], 1); + assert.equal(player(resumed, f.owner).hand.filter(card => card.id === f.card).length, 1); + assert.equal(resumed.discard.filter(card => card.id === f.card).length, 0); + assert.equal(player(resumed, f.holder).hand.filter(card => card.id === karama.id).length, claim ? 1 : 0); + assert.equal(resumed.discard.filter(card => card.id === karama.id).length, claim ? 0 : 1); + assert.equal(player(resumed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claim); + assert.equal(resumed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, claim ? 1 : 0); + assert.deepEqual(stock(resumed), inventory); + assert.throws(() => applyAction(resumed, f.holder, { type: 'semutaCommit', event })); + + const resolved = applyAction(resumed, f.owner, { type: 'decision', decline: true }); + assert.equal(resolved.storm, 7); + assert.equal(resolved.stormResolution, null); + assert.equal(resolved.decision?.kind, 'choamMarket'); + assert.equal(player(resolved, f.owner).forces[f.origin] ?? 0, 0, 'Only the threatened original counter is lost'); + assert.equal(player(resolved, f.owner).tanks, 1); + assert.equal(player(resolved, f.owner).reserves, player(f.game, f.owner).reserves); + assert.equal(player(resolved, f.owner).spice, player(f.game, f.owner).spice); + assert.equal(player(resolved, f.owner).hand.filter(card => card.id === f.card).length, 1); + assert.equal(player(resolved, f.owner).hand.filter(card => card.id === f.retainedKulon).length, 1); + assert.equal(player(resolved, f.holder).hand.filter(card => card.id === karama.id).length, claim ? 1 : 0); + assert.deepEqual(resources(resolved).players, paid.players.map(seat => seat.id === f.owner + ? { ...seat, forces: {}, tanks: 1 } + : seat), 'Only the genuinely threatened original counter becomes a Tank'); + const crossedSpice = Object.fromEntries(Object.entries(f.game.spice) + .filter(([key]) => !stormCrossedSectors(f).includes(splitLocation(key).sector))); + assert.deepEqual(resolved.spice, crossedSpice); + assert.deepEqual(stock(resolved), inventory); + assertConservedCounters(resolved, f.setup); + assert.throws(() => applyAction(resolved, f.owner, { type: 'decision', decline: true })); + assert.throws(() => applyAction(resolved, f.canceller, { type: 'card', mode: 'cancel', card: karama.id })); + const completed = finishSemutaHomeworldChoamStorm(resolved); + assert.equal(completed.phase, 1); + assert.equal(completed.turn, 2); + assert.equal(player(completed, f.owner).forces[f.origin] ?? 0, 0); + assert.equal(player(completed, f.owner).tanks, 1); + assert.deepEqual(stock(completed), inventory); + assertConservedCounters(completed, f.setup); + }); +} diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index d8d43c79..822a8719 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -30,7 +30,7 @@ async function main() { '\nSemuta with CHOAM and optional Ix preserves original unused Tech Tokens in fresh 3–6-seat Basic/Advanced Richese lobbies under existing native prerequisites. Advanced additionally retains original unused Stronghold Cards at2–6; both modules require3–6. Native setup, first-Storm Tech and final-Mentat Stronghold assignment remain original. Mandatory/winner discards react before the original winner takes one loser-owned token, before Face Dance. Closing Gamont reacts before the original CHOAM finish and changed-board claim. Optional original skills require --leader-skills as described below; other modules, the separate CHOAM/Ecaz Moritani entry, retrofit and public starts remain excluded.', '\n--profile semuta --leader-skills explicitly composes original all14 skills with fresh native Basic Richese/CHOAM and classics using exact CHOAM, or Advanced native E1/E2 with Richese and selected required CHOAM/optional Ix decks, at2–6 seats. Optional original Tech requires3+; Strongholds requires Advanced. Original private offers/training, Auditor exclusion, normal Mentat question, typed Suk rescue and Rihani inspection/exchange remain. Traitor/Face Dancer movement is not a Treachery discard. Basic Ix mixtures without their original Nexus envelope, E3/Moritani, Homeworld/Discovery/Banker overlays, public starts and played-game conversion are not opened.', '\n--profile semuta --nexus-cards retains original all12 Nexus in fresh native Richese CHOAM/optional Ix lobbies, optionally --leader-skills, original Tech3+ and Advanced Strongholds2+. Single/paired CHOAM or both selected E1/E2 native families retain original prerequisites. No starting Nexus hand is added: a qualifying actual end-Spice draw follows a Nexus and settled alliance. CHOAM Secret Ally spends a Worthless for two bank spice only with CHOAM absent; CHOAM Betrayal spends its Nexus to discard one server-sampled native CHOAM hand card without compensation. Semuta claim/pass retires the original receipt once, after the committed fresh discard. No Homeworld, Discovery, E3, retrofit or public gate opens.', - '\n--profile semuta --homeworlds uses original fresh Basic/Advanced Richese with CHOAM and classic factions, exact CHOAM deck, at2–6 seats. Native occupation setup and source history remain original. Paid Nullentropy Box search, clean Distrans transfer, definite Truthtrance answers, used ordinary special cards, paid high-Kaitain disposal, occupied-Tupile cleanup and printed Karama canceling native Guild/Emperor auction/Richese-purchase income offer Semuta before their original suffix. Native printed Kulon, Baliset, La La La and Trip to Gamont costs also pause before their original effect; recovery cannot repeat a move or force return. Canceling native income does not cancel the printed occupied portion: its original receiver still allocates it once before Movement, the next lot or saved purchase controls. Jubba, other Homeworld producers, Nexus, Skills, Tech, Strongholds, Discovery, Ix/E3 mixing, retrofit and public starts remain guarded.', + '\n--profile semuta --homeworlds uses original fresh Basic/Advanced Richese with CHOAM and classic factions, exact CHOAM deck, at2–6 seats. Native occupation setup and source history remain original. Paid Box search, clean Distrans transfer, definite Truthtrance answers, used ordinary specials, paid Kaitain disposal, occupied-Tupile cleanup and printed Karama canceling native Guild/Emperor/Richese-purchase income offer Semuta before their original suffix. Native printed Kulon, Baliset, La La La, Trip to Gamont and Jubba costs also pause before their original effect. A printed Karama canceling these native powers is separately offered before denial; the retained Worthless fuel is not claimable. Recovery cannot repeat movement, return a force twice, redo storm movement or refund payment. Occupied income shares still use their original receiver once. Kull, other Homeworld producers, Nexus, Skills, Tech, Strongholds, Discovery, Ix/E3 mixing, retrofit and public starts remain guarded.', '\nEcaz-occupy (--profile ecaz-occupy) opts only a fresh ready 2–6-seat lobby with native Ecaz and classic/Ixian/Tleilaxu/CHOAM/Richese opponents (optional native Moritani). Advanced uses distinct selected ecaz/ix/choam decks; Basic uses the exact ecaz deck with classic/optional Moritani opponents. Ordinary33/47/35 cards retain real original offers/HMS/Auditor/Face Dancers and Richese cache. Explicit --ecaz-treachery independently adds the three physical variant cards, making36/50/38 (Advanced only). Basic combined battles require an even Ecaz force count; the preserved publisher odd-force casualty-rounding conflict rejects before any lead choice. Native Richese ordinary and marker-only pools compose with selected lead plans; original own mixed No-Field dialing, whole-plan disclosure and optional overlays remain guarded.' + '\nLeader-skills retains existing classic and Basic native entries and adds native Basic Richese/CHOAM. Advanced Ixians, Tleilaxu, CHOAM and/or Richese plus classic rosters use distinct required family decks and all fourteen skills, with original cache, Face Dancers, Auditor exclusion and typed aftermath. Foreign revival grants no new skill. Separate non-Harkonnen Advanced Moritani uses the ecaz deck and original skill-first assassination preview. Native Ecaz supports its five ordinary discs, original Ambassador/Duke custody and ecaz deck with classic opponents in Basic/Advanced; Advanced Harkonnen retains its existing Duke capture exclusion. Normal Mentat questions use a uniform private card-show step in supported Leader Skills games. Banker-income additionally supports native Richese and Ecaz, never ordinary seller/allowed Emperor income or a bank grant as a payer cost. Basic auction recipients remain unchanged. Public activation, exhausted-cache counts, native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, shared-Duke assignment, combined Occupy skills and other pending module/rulings remain guarded.', '\nStronghold-factions additionally admits standalone Advanced Ecaz OR Moritani with classic opponents and the exact ecaz deck, six Stronghold Cards with optional unused Tech Tokens at 3–6 seats. Original setup and actual end-Mentat claims remain. Ecaz holds shared-control cards and only the actual selected holder-plan benefits. Native Moritani retains original assassination with printed bounty and one private Mentat replacement; Harkonnen is excluded there. An E3 pair, E1/E2/E3 mixtures and other modules remain separate.' +