From 8c5b6c8b553c649de6093daa533bd911f41ad116 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:50:50 +1100 Subject: [PATCH] Compose native Homeworld economic Karama costs with Semuta --- components/game-table.tsx | 3 + docs/CURRENT_STATUS.md | 19 ++ docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md | 7 + docs/IMPLEMENTATION_STATUS.md | 62 +++++ docs/SEMUTA_SOURCE_UPDATE_20260907.md | 16 ++ game/engine.ts | 6 +- game/reference.ts | 3 + ...ixture-semuta-homeworld-economic-karama.ts | 147 +++++++++++ ...fixture-semuta-homeworld-karama-auction.ts | 132 ++++++++++ .../semuta-homeworld-economic-karama.test.ts | 241 ++++++++++++++++++ tests/semuta-homeworld-karama-auction.test.ts | 239 +++++++++++++++++ tools/start-prototype.ts | 2 +- 12 files changed, 875 insertions(+), 2 deletions(-) create mode 100644 tests/fixture-semuta-homeworld-economic-karama.ts create mode 100644 tests/fixture-semuta-homeworld-karama-auction.ts create mode 100644 tests/semuta-homeworld-economic-karama.test.ts create mode 100644 tests/semuta-homeworld-karama-auction.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index a9b4004d..1e657b90 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1184,6 +1184,9 @@ export function GameTable({ costs also pause before their original effect. Printed Karama canceling these native powers or Guild, Emperor auction or Richese purchase income remains supported. + Printed Karama canceling CHOAM opening income or Inflation and + normal-lot purchase/payment costs keep the original denial, + spent placement attempt or one zero-spice acquisition. 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 791696aa..a32cb219 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,25 @@ 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 / native economic Karama producers — 13 October.** +Printed Karama canceling CHOAM opening income/Inflation, direct normal-lot +purchase and free winning-bid payment now offer their costs. Eight genuine +before programs had no offer;18 parent claim/pass/side programs preserve +withheld8, spent placement, ordinary next-turn C18 or one free original lot. +Four actual390px flows exchange at4/4: C stays2 after income denial, +Inflation attempt1 has no token, and purchase/payment acquires exactly one +reserved lot without charging A10 (real payment bid11). Normal pool3 and +cache continuation remain original. Artifact: +`/tmp/dune-homeworld-native-economic-3222.json`. +One actual Medium Gamont follow-on settles normally after the retired denial; +A10→9/reserves11, original A12 Collection and next-turn C18 remain valid. +No BG substitution, Kull, Richese-lot, signature/profile or public gate change. +Local staged proof is not save/deployed/natural-full-game acceptance. +Frozen types/lint and176/176 across seven related files pass (68 new cases). +The application build passes with unchanged deprecation, large-chunk and +static route-classification warnings. Five initial fixture assumptions were +repaired without a production rule or bot change. + **Semuta / clean Homeworld preparation and chooser costs — 13 October.** Clean Residual Poison, early Sapho aggressor and remaining Battle chooser disposal now offer their used cards without new timing or signatures. Eight diff --git a/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md b/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md index e012cdb7..f0862fa1 100644 --- a/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md +++ b/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md @@ -51,6 +51,13 @@ interruption. Early Sapho keeps original participants and ordinary tie priority, including an already-ready opponent; between-battle first/last keeps completed prefixes and only remaining chooser opportunities. None rewrites storm order. +Printed Karama canceling original CHOAM opening income or Inflation placement +likewise offers before denial. Recovery cannot restore the withheld native +income or reset the spent placement attempt. Normal-lot direct purchase and +free winning-bid payment offer their costs before one reserved card arrives +at zero price; original ordinary/cache pools and later controls stay intact. +No new low-Tupile income classification or Richese-lot scope is introduced. + Actual selected original cleanup cards are privately discarded first. The typed receipt retains only that cleanup's event, original held-ID commitment and suspended opening; its lease row remains pending during the neutral diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index bdf95689..3578aff9 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,67 @@ # Dune implementation status +## 13 October 2026 — native economic Karama costs + +Only the existing `karamaCharityDiscard`, `karamaInflationDiscard`, +`karamaPurchaseDiscard` and `karamaPaymentDiscard` are newly admitted by +the Homeworld Semuta gate. Original response/lot/capacity/physical-card +validators remain; no signing, price, profile or publication cutover. + +Four genuine Basic/Advanced opening-income/Inflation before programs spend +the original Emperor's printed Karama once with no offer. First phase2 +CHOAM2 stays2 when its native2×4=8 income is canceled. Original Mentat +Inflation cancellation leaves C10, attemptTurn1, no token/used state. +Advanced A12 is the actual earlier stronghold Collection, not assigned wealth. +Four genuine normal-lot purchase/payment before programs use original +Richese Declaration-last, actual Atreides inspection, bid/pass and payment +controls. A real winning11-spice bid exceeds A's10 wallet but uses the held +printed Karama for free payment. The original lot arrives once, all wallets +stay r5/e10/c10/a10, normalCount3 persists and native Bidding reaches phase5. + +Eighteen parent after programs cover both modes, each cost's claim/pass with +full Richese hands and both Inflation sides. CHOAM denial stays2 into phase3; +canceled Inflation still permits ordinary next-turn8 income (C18), never +sixteen/zero. Purchase/payment holds the original lot through Semuta and +delivers it once at zero price, without income/share/refund or bonus draw. +Both original normal pool and compulsory cache finish, cacheRemaining8, +while all original wallets remain unchanged. + +Four actual390px flows use native Cancel with Karama, Take current auction +card or Spend Karama controls and exchange at4/4. Basic opening income +remains2; Advanced Inflation keeps attempt1/no token and the original C +Ready control. Basic purchase acquires original Weather after the cost; +Advanced payment shows bid11, disabled spice payment at A10 and acquires +original Poison Tooth after the cost. Normal index0→1 and pool3 remain. +The UI proofs stop at native response/next-lot controls; runtime completes +the original later queues. Artifact outside Git: +`/tmp/dune-homeworld-native-economic-3222.json`. +The owned browser reported no errors; only its service/tab/root files +were stopped/removed. No saved room, deployment or full mode is certified. + +The initial frozen union passed types/lint but failed five fixture assumptions. +Four auction cases assumed a following response where the native next lot was +already ready; that response-only step was removed, not re-pinned. One actual +Advanced Medium next actor legally declared Gamont while the economic helper +required every response absent. A real conserved-card reproduction observed +the original CHOAM Worthless response. The helper now settles that different +native response while still rejecting the original unretired Karama cost/ +denied response, then follows the same closing controls. A deterministic +consumer regression proves A10→9/reserves11, A12 unchanged, native C18 and no +Inflation token. The separate actual post-fix program observes the same result. +No production exception was suppressed or bot strategy changed. + +The original Collection quote is taken before the last native Movement close: +clean phase7 has already credited Collection, and an empty/full-hand phase6 +may be auto-skipped. No clock/wealth rewind supplies that quote. Initial +uninitialized-attempt and absent-sale input pins were removed. Existing direct +purchase capacity versus winning-payment spent-card exchange, cache/normal- +method Black Market exclusions and printed-only/no-Kull limits remain. +Final frozen checks pass types/lint and176/176 across seven related unit +files, including68 new consumer cases. The application build passes with +unchanged module.register deprecation, client chunks over500kB and Vinext +static route-classification limitations. No broad assurance/deployed +check is substituted for the actual native cost/control proof. + ## 13 October 2026 — clean Homeworld preparation and remaining chooser costs Only existing `residualPoisonDiscard`, `saphoAggressorDiscard` and diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 53c852bb..c2a4f27a 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -855,6 +855,22 @@ 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 — original Homeworld economic Karama costs.** +Printed CHOAM opening-income/Inflation cancellation and normal-lot purchase/ +winning-payment costs now offer before their original terminal effect. +Claim/pass never restores withheld income or a canceled token, resets the +placement attempt, re-bids, charges spice or draws/delivers another lot. +Eight actual before programs had no offer;18 parent after programs preserve +native C2 denial, ordinary next-turn C18 and original free A10 acquisitions +(real payment bid11). Normal pool3 and compulsory cache continue unchanged. +Four actual390px controls exchange at4/4 and return to native response/next +lot; full later-queue evidence is runtime, not an unplayed UI certification. +Artifact: `/tmp/dune-homeworld-native-economic-3222.json`. +One legal Medium Gamont follow-on exposed a fixture-only response assumption; +actual settlement returns A1 to reserves and keeps original denial/Collection. +No production price, signature, profile, BG substitute, Kull or Richese-lot +ruling follows. Existing capacity/timing boundaries and public gates remain. + **13 October 2026 — clean native Homeworld preparation and remaining chooser.** Original clean Residual Poison disposal, early Sapho aggressor and between- battle first/last costs now offer their physical cards. The printed/native diff --git a/game/engine.ts b/game/engine.ts index 4582d610..3f156b13 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -6967,7 +6967,11 @@ function semutaOfferSupported( continuation.kind !== 'battleCleanup' && continuation.kind !== 'residualPoisonDiscard' && continuation.kind !== 'saphoAggressorDiscard' && - continuation.kind !== 'saphoBattleOrderDiscard') return false; + continuation.kind !== 'saphoBattleOrderDiscard' && + continuation.kind !== 'karamaCharityDiscard' && + continuation.kind !== 'karamaInflationDiscard' && + continuation.kind !== 'karamaPurchaseDiscard' && + continuation.kind !== 'karamaPaymentDiscard') 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 46e68414..62720575 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1191,6 +1191,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Final Ornithopter disposal also offers its used card in the Homeworld profile after the one-group range-three or original two-group movement completes, including an early end with no useful original group. Claim/pass cannot move those counters again or replay the paid native shipment; prior-move/Hajr, advisor and Homeworld-transfer boundaries remain. Clean Sapho Once Around and unstarted Movement first/last costs likewise offer after reordering the finite unacted queue. Earlier bids/funding and physical storm order stay committed, and the same next bidder or combined turn resumes once. Cyclic auctions, partly completed turns, first-versus-Guild and late battle timing are not opened.', 'Original Homeworld battle disposal offers accepted losing cards, later mandatory winning cards and optional winner-selected cards as separate fresh events. Commit and exact selection recover at most one other player’s newly discarded card; unselected cards stay in the pile. The original paid deployments, accepted result, leader deaths and casualty obligations remain committed, with the same native winner cleanup and rewards afterward. A recovered card cannot replay combat or consume another original opportunity.', 'Clean Homeworld Residual Poison disposal offers its used card after exactly one native random eligible leader enters the Tanks, with no spice bounty or force loss; claim/pass resumes the original shared preparation and does not redraw the victim. Early Sapho aggressor disposal likewise keeps the original participants/chooser and changed ordinary tie priority, while clean between-battle first/last disposal keeps earlier resolved battles and only reorders remaining chooser opportunities. Physical storm order is unchanged. Readied or inspected Poison costs, late Sapho timing and Advanced Harkonnen captive disclosure remain outside these clean reactions.', + 'Printed Karama canceling original CHOAM opening income or native Inflation placement also offers its used card in the Homeworld profile. Claim/pass completes the same denial once: unpaid income stays withheld, and the original placement attempt remains spent with no token. Clean normal-lot printed Karama purchase and free winning-bid payment likewise offer their costs before exactly one original reserved card arrives without spice payment. Earlier bids, the normal pool, Richese cache, original wallets and later native controls stay committed. Richese lots, BG Worthless conversion, Kull and other cancellations remain separate.', '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.', @@ -1277,6 +1278,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/semuta-homeworld-battle-winner.test.ts', 'tests/semuta-homeworld-residual-poison.test.ts', 'tests/semuta-homeworld-battle-sapho.test.ts', + 'tests/semuta-homeworld-economic-karama.test.ts', + 'tests/semuta-homeworld-karama-auction.test.ts', ], }, ], diff --git a/tests/fixture-semuta-homeworld-economic-karama.ts b/tests/fixture-semuta-homeworld-economic-karama.ts new file mode 100644 index 00000000..e143ab65 --- /dev/null +++ b/tests/fixture-semuta-homeworld-economic-karama.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import { handLimit, viewGame, type Action, type Game } from '../game/engine'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { quoteSpiceCollection, type CollectionQuote } from '../game/board-resolution-quote'; +import { moveSemutaEmperorCard, semutaEmperorPlayer } from './fixture-semuta-emperor-share'; +import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep, + createSemutaHomeworldChoamSetup, holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam'; +import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech'; + +export type SemutaHomeworldEconomicKaramaScope = 'charity' | 'inflation'; +export type SemutaHomeworldEconomicKaramaFixture = { + scope: SemutaHomeworldEconomicKaramaScope; + setup: Game; beforeDeclaration: Game; originalResponse: Game; game: Game; + owner: string; canceller: string; holder: string; target: string; + card: string; action: Action; declaration: Action | null; event: string | null; + actions: RicheseSkillsTechStep[]; staging: string[]; + afterCancel?: Game; targetSuffix?: Game; beforeNextIncome?: Game; + beforeCollection?: Game; collectionQuote?: CollectionQuote; +}; +const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard; + +/** Spending-free native progression, preserving every original population and + * wallet. End-of-Bidding readiness is not the paid Kaitain disposal control. */ +function advance(state: Game, until: (g: Game) => boolean, actions?: RicheseSkillsTechStep[]): Game { + let game = state; + for (let n = 0; !until(game) && n < 1600; n++) { + assert.notEqual(game.status, 'finished'); + const end = game.biddingEnd; + const actor = end && clean(game) ? end.owners.find(id => !end.ready.includes(id)) : undefined; + if (actor) game = applySemutaHomeworldChoamStep(game, actor, + { type: 'biddingEnd', event: end!.event, mode: 'ready' }, actions); + else if (game.phase === 5 && clean(game)) + game = applySemutaHomeworldChoamStep(game, game.active!, { type: 'endMovement' }, actions); + else game = advanceSemutaHomeworldChoam(game, + g => until(g) || (!!g.biddingEnd && clean(g)) || (g.phase === 5 && clean(g)), actions); + } + assert.ok(until(game), 'Original native controls must reach the economic boundary'); + return game; +} + +/** Original completed R/C/E/A Homeworld setup, then first native phase-two + * income or first Mentat placement. Only conserved cards/unplayed Spice order + * are staged; no phase, response, attempt stamp, resource or receipt is set. */ +export function createSemutaHomeworldEconomicKaramaFixture( + scope: SemutaHomeworldEconomicKaramaScope, advanced = false, fullHolder = false, +): SemutaHomeworldEconomicKaramaFixture { + const native = createSemutaHomeworldChoamSetup(advanced); + let game = native.game; + const setup = structuredClone(game), actions = native.actions; + const staging = ['Conserved original physical Semuta to Richese after completed native setup.']; + const blows = game.spiceDeck.filter(card => 'territory' in card).slice(0, 4); + assert.equal(blows.length, 4); + for (const [position, card] of blows.entries()) + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]); + staging.push('Order four original unplayed non-worm Spice Cards for the first and following native turns.'); + const card = holdSemutaHomeworldChoamCard(game, native.canceller, 'Karama'); + staging.push('Conserved printed Karama to original Emperor; no Worthless substitution.'); + if (fullHolder) { + const holder = semutaEmperorPlayer(game, native.holder); + while (holder.hand.length < handLimit(holder)) { + assert.ok(game.deck[0]); + moveSemutaEmperorCard(game, game.deck[0].id, holder.id); + } + staging.push('Conserved undealt cards fill original Richese hand to four.'); + } + let beforeCollection: Game | undefined, collectionQuote: CollectionQuote | undefined; + if (scope === 'inflation') { + // Empty Battle may auto-skip phase six when no market is entitled. Capture + // the actual last spending-free Movement control, before any Collection. + game = advance(game, g => g.phase === 5 && clean(g) && g.movementRemaining?.length === 1, actions); + beforeCollection = structuredClone(game); + collectionQuote = quoteSpiceCollection(game); + } + game = advance(game, g => scope === 'charity' + ? g.response?.kind === 'choamCharity' + : g.phase === 8 && clean(g), actions); + assert.equal(game.turn, setup.turn); + const beforeDeclaration = structuredClone(game); + const declaration: Action | null = scope === 'inflation' ? { type: 'choamInflation', side: 'double' } : null; + if (declaration) game = applySemutaHomeworldChoamStep(game, native.owner, declaration, actions); + assert.equal(game.response?.kind, scope === 'charity' ? 'choamCharity' : 'choamInflation'); + assert.ok(viewGame(game, native.canceller).responseControls?.cancelCards.includes(card)); + return { scope, setup, beforeDeclaration, originalResponse: structuredClone(game), game, + owner: native.owner, canceller: native.canceller, holder: native.holder, target: native.target, + card, action: { type: 'card', mode: 'cancel', card }, declaration, + event: null, actions, staging, beforeCollection, collectionQuote }; +} + +/** The returned game pauses at the printed cost, before accepted denial. */ +export function disposeSemutaHomeworldEconomicKarama( + f: SemutaHomeworldEconomicKaramaFixture, state = f.game, actions?: RicheseSkillsTechStep[], +): Game { + const game = applySemutaHomeworldChoamStep(state, f.canceller, f.action, actions); + f.event = game.pendingTreacheryDiscard?.batch.event ?? null; + if (!game.pendingTreacheryDiscard) f.afterCancel = structuredClone(game); + return game; +} + +export function reactSemutaHomeworldEconomicKarama(state: Game, holder: string, + claim: boolean, actions?: RicheseSkillsTechStep[]): Game { + const pending = state.pendingTreacheryDiscard, reaction = pending?.reaction; + assert.ok(pending && reaction?.stage === 'offer'); + const event = pending.batch.event; + if (claim) return applySemutaHomeworldChoamStep(state, holder, { type: 'semutaCommit', event }, actions); + let game = state; + for (const seat of state.players.filter(p => !reaction.passed.includes(p.id))) + game = applySemutaHomeworldChoamStep(game, seat.id, { type: 'semutaPass', event }, actions); + return game; +} + +/** Only original queues: charity ends into native Bidding; canceled Inflation + * closes the same CHOAM Mentat and reaches ordinary next-turn opening income. */ +export function finishSemutaHomeworldEconomicKarama(state: Game, + f: SemutaHomeworldEconomicKaramaFixture, actions?: RicheseSkillsTechStep[]): Game { + assert.ok(!state.pendingTreacheryDiscard && + state.response?.kind !== f.originalResponse.response?.kind, + 'The original printed Karama cost and denied response must already be retired'); + // A legal next actor may have declared a different native power, such as + // Gamont. Settle that real response before following the original close. + const settled = advance(state, g => !g.pendingTreacheryDiscard && !g.response, actions); + f.afterCancel = structuredClone(settled); + if (f.scope === 'charity') { + const game = advance(settled, g => g.phase === 3 && clean(g), actions); + f.targetSuffix = structuredClone(game); + return game; + } + let game = advance(settled, g => g.decision?.kind === 'choamMentat', actions); + assert.equal(game.decision!.player, f.owner); + game = applySemutaHomeworldChoamStep(game, f.owner, { type: 'decision', done: true }, actions); + game = advance(game, g => g.turn === state.turn + 1 && g.phase === 1 && clean(g), actions); + f.beforeNextIncome = structuredClone(game); + game = advance(game, g => g.phase === 2 && clean(g), actions); + f.targetSuffix = structuredClone(game); + return game; +} + +/** Conserved equal-size substitution changes only private Semuta ownership. */ +export function withoutSemutaHomeworldEconomicHolder(state: Game, holder: string): Game { + const game = structuredClone(state), hand = semutaEmperorPlayer(game, holder).hand; + const at = hand.findIndex(card => card.id === SEMUTA_DRUG_ID); + const replacement = game.deck.findIndex(card => card.effect !== 'karama'); + assert.ok(at >= 0 && replacement >= 0); + const semuta = hand[at]; + hand[at] = game.deck.splice(replacement, 1)[0]; + game.deck.push(semuta); + return game; +} diff --git a/tests/fixture-semuta-homeworld-karama-auction.ts b/tests/fixture-semuta-homeworld-karama-auction.ts new file mode 100644 index 00000000..e9226b8f --- /dev/null +++ b/tests/fixture-semuta-homeworld-karama-auction.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import { handLimit, type Action, type Game } from '../game/engine'; +import { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding'; +import { semutaEmperorCardId, moveSemutaEmperorCard, semutaEmperorPlayer } from './fixture-semuta-emperor-share'; +import { createSemutaHomeworldChoamSetup, applySemutaHomeworldChoamStep, advanceSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam'; +import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech'; + +export type SemutaHomeworldKaramaAuctionScope = 'purchase' | 'payment'; +export type SemutaHomeworldKaramaAuctionFixture = { + scope: SemutaHomeworldKaramaAuctionScope; + setup: Game; + beforeLot: Game; + game: Game; + gameBeforeCost: Game; + actor: string; + owner: string; + holder: string; + card: string; + action: Action; + event: string; + reservedLot: string; + nativeBid: number; + payers: { player: string; spice: number }[]; + custody: Game['homeworlds']; + actions: RicheseSkillsTechStep[]; + staging: string[]; +}; +const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard; + +/** The original R/C/E/A setup and real first Bidding. Only conserved physical + * cards and two still-unplayed Spice Cards are positioned; no economy or clock + * is assigned. Richese declares last before the native normal pool is drawn. */ +export function createSemutaHomeworldKaramaAuctionFixture( + scope: SemutaHomeworldKaramaAuctionScope, advanced = false, fullHolder = false, +): SemutaHomeworldKaramaAuctionFixture { + const native = createSemutaHomeworldChoamSetup(advanced); + const setup = structuredClone(native.game), actions = native.actions, staging: string[] = []; + let game = native.game; + const actor = native.target, holder = native.holder; + const physical = biddingPhysicalIds(game); + const held = semutaEmperorPlayer(game, actor).hand.find(card => card.effect === 'karama'); + const card = held?.id ?? moveSemutaEmperorCard(game, + semutaEmperorCardId(game, candidate => candidate.effect === 'karama'), actor); + staging.push(`Conserved printed Karama ${card} in original Atreides ${actor} custody before Bidding.`); + assert.ok(semutaEmperorPlayer(game, actor).hand.length < handLimit(semutaEmperorPlayer(game, actor))); + assert.deepEqual(biddingPhysicalIds(game), physical); + for (let position = 0; position < 2; position++) { + const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card); + assert.ok(index >= position); + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]); + } + staging.push('Position two original unplayed non-worm Spice Cards, retaining the original deck.'); + game = advanceSemutaHomeworldChoam(game, state => state.decision?.kind === 'richeseDeclaration', actions); + game = applySemutaHomeworldChoamStep(game, game.decision!.player, + { type: 'decision', event: game.richeseBidding!.event, position: 'last' }, actions); + game = advanceSemutaHomeworldChoam(game, state => !!state.auction && clean(state), actions); + const beforeLot = structuredClone(game); + assert.equal(game.auction!.peekKnown, true, 'The actual native Atreides inspection has completed'); + assert.equal(game.richeseBidding!.position, 'last'); + const reservedLot = game.auction!.cards[game.auction!.index].id; + if (fullHolder) { + const original = biddingPhysicalIds(game), receiver = semutaEmperorPlayer(game, holder); + while (receiver.hand.length < handLimit(receiver)) { + const incoming = game.deck.shift(); + assert.ok(incoming, 'Original undealt stock must supply holder capacity'); + receiver.hand.push(incoming); + staging.push(`After ordinary lot quantity committed, conserve undealt ${incoming.id} into ${holder} hand.`); + } + assert.equal(receiver.hand.length, 4); + assert.deepEqual(biddingPhysicalIds(game), original); + } + if (scope === 'payment') { + for (let n = 0; game.auction!.active !== actor && n < game.players.length; n++) + game = applySemutaHomeworldChoamStep(game, game.auction!.active, { type: 'passBid' }, actions); + assert.equal(game.auction!.active, actor); + const amount = semutaEmperorPlayer(game, actor).spice + 1; + game = applySemutaHomeworldChoamStep(game, actor, { type: 'bid', amount }, actions); + for (let n = 0; game.decision?.kind !== 'auctionPayment' && n < game.players.length + 2; n++) + game = applySemutaHomeworldChoamStep(game, game.auction!.active, { type: 'passBid' }, actions); + assert.equal(game.decision?.kind, 'auctionPayment'); + assert.equal(game.decision!.player, actor); + assert.equal(game.auction!.bidder, actor); + assert.ok(game.auction!.bid > semutaEmperorPlayer(game, actor).spice); + } + assert.equal(game.auction!.cards[game.auction!.index].id, reservedLot); + const action: Action = scope === 'purchase' + ? { type: 'card', mode: 'purchase', card } + : { type: 'decision', karama: true, card }; + return { scope, setup, beforeLot, game, gameBeforeCost: structuredClone(game), actor, owner: actor, + holder, card, action, event: game.richeseBidding!.event, reservedLot, nativeBid: game.auction!.bid, + payers: game.players.map(player => ({ player: player.id, spice: player.spice })), + custody: structuredClone(game.homeworlds), actions, staging }; +} + +export function disposeSemutaHomeworldKaramaAuction( + fixture: SemutaHomeworldKaramaAuctionFixture, state = fixture.game, actions?: RicheseSkillsTechStep[], +): Game { + return applySemutaHomeworldChoamStep(state, fixture.actor, fixture.action, actions); +} + +/** Keep every original remaining normal/cache lot and invoice. Finish with + * native no-spend human choices, including the actual Bidding-end ready gate. */ +export function finishSemutaHomeworldKaramaAuction( + state: Game, fixture: SemutaHomeworldKaramaAuctionFixture, actions?: RicheseSkillsTechStep[], +): Game { + let game = state; + for (let n = 0; !(game.phase === 5 && clean(game)) && n < 1600; n++) { + assert.equal(game.turn, fixture.game.turn); + const end = game.biddingEnd; + const actor = end && clean(game) ? end.owners.find(id => !end.ready.includes(id)) : undefined; + if (actor) game = applySemutaHomeworldChoamStep(game, actor, + { type: 'biddingEnd', event: end!.event, mode: 'ready' }, actions); + else if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') { + const pending = game.pendingTreacheryDiscard, reaction = pending.reaction; + assert.ok(reaction?.stage === 'offer'); + const seat = game.players.find(player => !reaction.passed.includes(player.id))!; + game = applySemutaHomeworldChoamStep(game, seat.id, + { type: 'semutaPass', event: pending.batch.event }, actions); + } else if (game.decision?.kind === 'choamMarket') + game = applySemutaHomeworldChoamStep(game, game.decision.player, { type: 'decision', done: true }, actions); + else if (game.decision?.kind === 'caladanReinforcement') + game = applySemutaHomeworldChoamStep(game, game.decision.player, + { type: 'decision', event: game.decision.event, decline: true }, actions); + else { + const next = nextRicheseSkillsTechNativeStep(game); + game = applySemutaHomeworldChoamStep(game, next.actor, next.action, actions); + } + } + assert.equal(game.phase, 5); + assert.ok(clean(game)); + return game; +} diff --git a/tests/semuta-homeworld-economic-karama.test.ts b/tests/semuta-homeworld-economic-karama.test.ts new file mode 100644 index 00000000..d49de4da --- /dev/null +++ b/tests/semuta-homeworld-economic-karama.test.ts @@ -0,0 +1,241 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, handLimit, viewGame, type Action, 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 { holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam'; +import { semutaHomeworldChoamMovementResources as resources, + semutaHomeworldChoamMovementStock as stock } from './fixture-semuta-homeworld-choam-movement'; +import { + createSemutaHomeworldEconomicKaramaFixture, disposeSemutaHomeworldEconomicKarama, + finishSemutaHomeworldEconomicKarama, reactSemutaHomeworldEconomicKarama, + withoutSemutaHomeworldEconomicHolder, type SemutaHomeworldEconomicKaramaFixture, +} from './fixture-semuta-homeworld-economic-karama'; + +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before, 'A rejected stale/cross-actor action has no effect'); +} + +function assertNativeEconomy(f: SemutaHomeworldEconomicKaramaFixture) { + assert.equal(f.game.turn, 1); + assert.equal(player(f.game, f.owner).faction, 'choam'); + assert.equal(player(f.game, f.canceller).faction, 'emperor'); + assert.equal(player(f.game, f.holder).faction, 'richese'); + assert.equal(player(f.setup, f.owner).spice, 2); + assert.equal(player(f.game, f.canceller).spice, 10); + assert.deepEqual(f.game.homeworlds?.custody, f.setup.homeworlds?.custody); + for (const seat of f.game.players) { + assert.equal(seat.reserves, player(f.setup, seat.id).reserves); + assert.equal(seat.tanks, player(f.setup, seat.id).tanks); + assert.deepEqual(seat.forces, player(f.setup, seat.id).forces); + assert.deepEqual(seat.elites, player(f.setup, seat.id).elites); + } + const ownerView = viewGame(f.game, f.owner); + assert.equal(ownerView.homeworlds!.worlds!.find(world => world.native === f.owner)!.population, 20, + 'Original ordinary Tupile population is preserved without any shipment'); + assert.equal(ownerView.charity.incomeHomeworld, 0, 'Original full Tupile has no low-population bonus'); + assert.equal(ownerView.charity.multiplier, 1); + if (f.scope === 'charity') { + assert.equal(f.game.phase, 2); + assert.equal(player(f.game, f.owner).spice, 2, 'Native eight-spice CHOAM opening income is still unpaid'); + assert.equal(ownerView.charity.incomePending, true); + } else { + assert.equal(f.game.phase, 8); + assert.equal(player(f.game, f.owner).spice, 10, 'First ordinary native income paid exactly eight'); + assert.equal(f.game.inflationAttemptTurn, 1, 'Actual placement, not Karama spending, consumes the attempt'); + assert.ok(f.beforeCollection && f.collectionQuote); + for (const receipt of f.collectionQuote.receipts) + assert.equal(player(f.game, receipt.player).spice, receipt.balance, + 'Native Collection credits including Advanced strongholds precede Mentat'); + assert.equal(player(f.beforeCollection, f.target).spice, 10); + assert.equal(player(f.game, f.target).spice, f.game.advanced ? 12 : 10); + } +} + +function assertDenied(f: SemutaHomeworldEconomicKaramaFixture, done: Game, claimed: boolean) { + assert.ok(!done.pendingTreacheryDiscard && !done.response); + assert.equal(done.phase, f.game.phase); + assert.equal(done.turn, 1); + assert.deepEqual(resources(done), resources(f.game), 'Cost and denial never transfer/refund spice or move original custody'); + assert.deepEqual(stock(done), stock(f.game)); + assert.equal(player(done, f.canceller).hand.some(c => c.id === f.card), false); + assert.equal(player(done, f.holder).hand.some(c => c.id === f.card), claimed); + assert.equal(player(done, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), !claimed); + assert.equal(done.discard.filter(c => c.id === f.card).length, Number(!claimed)); + assert.equal(done.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, Number(claimed)); + for (const seat of done.players) + assert.equal(seat.hand.length, player(f.game, seat.id).hand.length - Number(seat.id === f.canceller)); + if (f.scope === 'charity') { + assert.deepEqual(done.choamCharity, { turn: 1, canceled: true }); + assert.equal(player(done, f.owner).spice, 2); + assert.equal(viewGame(done, f.owner).charity.incomeCanceled, true); + assert.equal(viewGame(done, f.owner).charity.payer, null, 'Canceled income leaves charity bank-paid'); + } else { + assert.equal(done.inflationAttemptTurn, 1); + assert.ok(!done.inflation && !done.inflationUsed); + assert.equal(player(done, f.owner).spice, 10); + reject(done, f.owner, { type: 'choamInflation', side: 'double' }); + reject(done, f.owner, { type: 'choamInflation', side: 'cancel' }); + } + reject(done, f.canceller, f.action); + reject(done, f.canceller, { type: 'passResponse' }); + if (claimed) reject(done, f.holder, f.action); +} + +function assertSuffix(f: SemutaHomeworldEconomicKaramaFixture, done: Game) { + const suffix = finishSemutaHomeworldEconomicKarama(done, f); + assert.equal(suffix.status, 'playing'); + const settled = f.afterCancel!; + assert.deepEqual(suffix.homeworlds?.custody, settled.homeworlds?.custody); + for (const seat of suffix.players) { + assert.deepEqual(seat.forces, player(settled, seat.id).forces); + assert.equal(seat.reserves, player(settled, seat.id).reserves); + assert.equal(seat.tanks, player(settled, seat.id).tanks); + } + if (f.scope === 'charity') { + assert.equal(suffix.phase, 3); + assert.equal(suffix.turn, 1); + assert.deepEqual(resources(suffix), resources(done), 'The completed opening denial cannot repay its eight spice'); + assert.deepEqual(suffix.choamCharity, { turn: 1, canceled: true }); + } else { + assert.equal(suffix.phase, 2); + assert.equal(suffix.turn, 2); + assert.ok(!suffix.inflation && !suffix.inflationUsed); + assert.equal(suffix.choamMentatPending, false); + assert.equal(viewGame(suffix, f.owner).charity.multiplier, 1); + assert.deepEqual(suffix.choamCharity, { turn: 2, canceled: false }); + assert.ok(f.beforeNextIncome); + assert.equal(player(f.beforeNextIncome, f.owner).spice, 10); + assert.equal(player(suffix, f.owner).spice, 18, 'Original next-turn income remains ordinary eight, not sixteen/zero'); + for (const seat of suffix.players.filter(p => p.id !== f.owner)) + assert.equal(seat.spice, player(done, seat.id).spice); + reject(suffix, f.owner, { type: 'decision', done: true }); + } +} + +for (const advanced of [false, true]) for (const scope of ['charity', 'inflation'] as const) { + const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld ${scope} printed Karama`; + for (const claimed of [false, true]) void test(`${label}: ${claimed ? 'claim' : 'pass'} resumes exactly one denial without native economic replay`, () => { + const f = createSemutaHomeworldEconomicKaramaFixture(scope, advanced); + assertNativeEconomy(f); + const pending = disposeSemutaHomeworldEconomicKarama(f); + const cost = pending.pendingTreacheryDiscard; + assert.ok(cost); + assert.equal(cost.continuation.kind, scope === 'charity' ? 'karamaCharityDiscard' : 'karamaInflationDiscard'); + assert.equal(cost.batch.cause, scope === 'charity' ? 'karama:choamCharity' : 'karama:choamInflation'); + assert.deepEqual(cost.batch.entries.map(e => ({ card: e.card.id, actor: e.discardedBy, face: e.publicFace })), + [{ card: f.card, actor: f.canceller, face: true }]); + assert.equal(pending.discard.filter(c => c.id === f.card).length, 1); + assert.equal(player(pending, f.canceller).hand.some(c => c.id === f.card), false); + assert.deepEqual(resources(pending), resources(f.game)); + assert.deepEqual(pending.choamCharity, f.game.choamCharity, 'Printed denial has not happened during its cost offer'); + assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true); + reject(pending, f.canceller, f.action); + reject(pending, f.owner, { type: 'ready' }); + reject(pending, f.canceller, { type: 'passResponse' }); + reject(pending, f.target, { type: 'semutaCommit', event: cost.batch.event }); + reject(pending, f.holder, { type: 'semutaCommit', event: `${cost.batch.event}:stale` }); + const done = reactSemutaHomeworldEconomicKarama(JSON.parse(JSON.stringify(pending)), f.holder, claimed); + assertDenied(f, done, claimed); + reject(done, f.holder, { type: 'semutaCommit', event: cost.batch.event }); + reject(done, f.target, { type: 'semutaPass', event: cost.batch.event }); + assertSuffix(f, done); + }); + + void test(`${label}: full four-card holder exchanges one card; equal-size absent-Semuta substitution is private`, () => { + const f = createSemutaHomeworldEconomicKaramaFixture(scope, advanced, true); + const pending = disposeSemutaHomeworldEconomicKarama(f); + const neutral = disposeSemutaHomeworldEconomicKarama(f, withoutSemutaHomeworldEconomicHolder(f.game, f.holder)); + assert.equal(player(f.game, f.holder).hand.length, 4); + assert.equal(handLimit(player(f.game, f.holder)), 4); + assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true); + assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false); + reject(neutral, f.holder, { type: 'semutaCommit', event: neutral.pendingTreacheryDiscard!.batch.event }); + for (const seat of pending.players.filter(p => p.id !== f.holder)) { + assert.deepEqual(viewGame(pending, seat.id), viewGame(neutral, seat.id)); + assert.equal(viewGame(pending, seat.id).semutaReaction?.canCommit, false); + } + const done = reactSemutaHomeworldEconomicKarama(pending, f.holder, true); + assertDenied(f, done, true); + assert.equal(player(done, f.holder).hand.length, 4); + const passed = reactSemutaHomeworldEconomicKarama(neutral, f.holder, false); + assert.deepEqual(resources(passed), resources(done)); + assert.equal(passed.discard.filter(c => c.id === f.card).length, 1); + assertSuffix(f, done); + }); + + void test(`${label}: non-BG Worthless, absent Ix Kull and wrong-seat cancellation remain excluded`, () => { + const f = createSemutaHomeworldEconomicKaramaFixture(scope, advanced); + const game = structuredClone(f.game); + const worthless = holdSemutaHomeworldChoamCard(game, f.canceller, 'Baliset'); + const kull = 'ix-kull-wahad'; + assert.equal(stock(game).includes(kull), false, 'Exact CHOAM profile has no printed Ix Kull to substitute'); + assert.ok(!game.players.some(p => p.faction === 'beneGesserit')); + assert.ok(!viewGame(game, f.canceller).responseControls?.cancelCards.includes(worthless)); + reject(game, f.canceller, { type: 'card', card: worthless, mode: 'cancel' }); + reject(game, f.owner, { type: 'card', card: kull, mode: 'cancel' }); + reject(game, f.holder, f.action); + reject(game, f.canceller, { type: 'choamInflation', side: 'double' }); + reject(game, f.owner, { type: 'card', card: kull, mode: 'kull', target: f.canceller }); + const pending = disposeSemutaHomeworldEconomicKarama(f, game); + assert.deepEqual(pending.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), [f.card]); + assert.equal(player(pending, f.owner).hand.some(c => c.id === kull), false); + assert.equal(player(pending, f.canceller).hand.some(c => c.id === worthless), true); + }); + + for (const difficulty of DIFFICULTIES) void test(`${difficulty}: ${label} uses actual Semuta and the original CHOAM next-actor controls`, () => { + const f = createSemutaHomeworldEconomicKaramaFixture(scope, advanced); + let game = disposeSemutaHomeworldEconomicKarama(f); + const view = viewGame(game, f.holder); + view.players.find(p => p.id === f.holder)!.bot = difficulty; + const action = botActions(view)[0]; + assert.ok(action && (action.type === 'semutaCommit' || action.type === 'semutaPass')); + game = applyAction(game, f.holder, action); + if (game.pendingTreacheryDiscard) game = reactSemutaHomeworldEconomicKarama(game, f.holder, false); + assertDenied(f, game, action.type === 'semutaCommit'); + const nextView = viewGame(game, f.owner); + nextView.players.find(p => p.id === f.owner)!.bot = difficulty; + const next = botActions(nextView)[0]; + assert.ok(next); + const after = applyAction(game, f.owner, next); + assert.equal(after.turn, 1); + assert.equal(player(after, f.owner).spice, player(game, f.owner).spice); + assertSuffix(f, after); + }); +} + +for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'}: original Cancel-side Inflation denial also consumes its attempt without zeroing next charity`, () => { + const base = createSemutaHomeworldEconomicKaramaFixture('inflation', advanced); + const declaration: Action = { type: 'choamInflation', side: 'cancel' }; + const game = applyAction(base.beforeDeclaration, base.owner, declaration); + const f = { ...base, game, originalResponse: structuredClone(game), declaration }; + assert.equal(game.response?.intent, 'cancel'); + const pending = disposeSemutaHomeworldEconomicKarama(f); + const done = reactSemutaHomeworldEconomicKarama(pending, f.holder, true); + assertDenied(f, done, true); + assertSuffix(f, done); +}); + +void test('Advanced Medium: a legal native Gamont follow-on preserves the canceled Inflation attempt and original close', () => { + const f = createSemutaHomeworldEconomicKaramaFixture('inflation', true); + const gamont = holdSemutaHomeworldChoamCard(f.game, f.owner, 'Trip to Gamont'); + const done = reactSemutaHomeworldEconomicKarama(disposeSemutaHomeworldEconomicKarama(f), f.holder, true); + const own = viewGame(done, f.owner); + own.players.find(p => p.id === f.owner)!.bot = 'Medium'; + const proposal = botActions(own)[0]; + assert.ok(proposal && proposal.card === gamont); + const declared = applyAction(done, f.owner, proposal); + assert.equal(declared.response?.kind, 'choamWorthless'); + assert.equal(declared.inflationAttemptTurn, 1); + assert.equal(player(declared, f.target).forces['arrakeen:10'], 10); + assertSuffix(f, declared); + assert.equal(player(f.targetSuffix!, f.target).forces['arrakeen:10'], 9); + assert.equal(player(f.targetSuffix!, f.target).reserves, 11); + assert.equal(player(f.targetSuffix!, f.owner).spice, 18); + assert.equal(player(f.targetSuffix!, f.target).spice, 12); + assert.equal(f.targetSuffix!.discard.filter(c => c.id === gamont).length, 1); +}); diff --git a/tests/semuta-homeworld-karama-auction.test.ts b/tests/semuta-homeworld-karama-auction.test.ts new file mode 100644 index 00000000..abf8ccb3 --- /dev/null +++ b/tests/semuta-homeworld-karama-auction.test.ts @@ -0,0 +1,239 @@ +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 { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding'; +import { semutaEmperorPlayer as player, moveSemutaEmperorCard } from './fixture-semuta-emperor-share'; +import { advanceSemutaHomeworldChoam, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam'; +import { + createSemutaHomeworldKaramaAuctionFixture, disposeSemutaHomeworldKaramaAuction, + finishSemutaHomeworldKaramaAuction, type SemutaHomeworldKaramaAuctionFixture, +} from './fixture-semuta-homeworld-karama-auction'; + +function assertReserved(f: SemutaHomeworldKaramaAuctionFixture, pending: Game) { + const cost = pending.pendingTreacheryDiscard!; + assert.equal(cost.continuation.kind, f.scope === 'purchase' ? 'karamaPurchaseDiscard' : 'karamaPaymentDiscard'); + assert.equal(cost.reaction?.stage, 'offer'); + assert.deepEqual(cost.batch.entries.map(entry => ({ id: entry.card.id, owner: entry.discardedBy, public: entry.publicFace })), + [{ id: f.card, owner: f.actor, public: true }]); + assert.equal(player(pending, f.actor).hand.some(card => card.id === f.card), false); + assert.equal(pending.discard.filter(card => card.id === f.card).length, 1); + assert.equal(player(pending, f.actor).hand.some(card => card.id === f.reservedLot), false); + assert.deepEqual(pending.auction, f.game.auction, 'The same native lot and winning bid remain reserved, not delivered'); + assert.deepEqual(pending.players.map(p => ({ player: p.id, spice: p.spice })), f.payers); + assert.deepEqual(pending.deck, f.game.deck); + assert.deepEqual(pending.richeseCache, f.game.richeseCache); + assert.deepEqual(pending.richeseBidding, f.game.richeseBidding); + assert.deepEqual(pending.homeworlds, f.custody); + assert.deepEqual(biddingPhysicalIds(pending), biddingPhysicalIds(f.game)); + assert.equal(pending.response, null); + assert.equal(pending.decision, null); +} + +function assertAcquired(f: SemutaHomeworldKaramaAuctionFixture, done: Game, claimed: boolean) { + assert.equal(done.pendingTreacheryDiscard, null); + const buyerBefore = player(f.game, f.actor), buyer = player(done, f.actor); + const lot = f.game.auction!.cards[f.game.auction!.index]; + assert.deepEqual(buyer.hand, [...buyerBefore.hand.filter(card => card.id !== f.card), lot]); + assert.equal(buyer.hand.filter(card => card.id === f.reservedLot).length, 1); + assert.deepEqual(done.players.map(p => ({ player: p.id, spice: p.spice })), f.payers, + 'Zero price creates neither Emperor income, foreign occupied share nor refund'); + assert.deepEqual(done.deck, f.game.deck, 'No bonus or replacement card is drawn'); + assert.deepEqual(done.richeseCache, f.game.richeseCache); + assert.deepEqual(done.richeseRemoved, f.game.richeseRemoved); + assert.deepEqual(done.richeseBidding, f.game.richeseBidding); + assert.deepEqual(done.homeworlds, f.custody); + assert.deepEqual(done.order, f.game.order); + assert.deepEqual(biddingPhysicalIds(done), biddingPhysicalIds(f.game)); + assert.deepEqual(done.auction!.cards, f.game.auction!.cards); + assert.equal(done.auction!.index, f.game.auction!.index + 1); + assert.equal(done.auction!.bid, 0); + assert.equal(done.auction!.bidder, null); + assert.equal(player(done, f.holder).hand.filter(card => card.id === f.card).length, Number(claimed)); + assert.equal(done.discard.filter(card => card.id === f.card).length, Number(!claimed)); + assert.equal(done.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claimed)); + assert.equal(player(done, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claimed); +} + +for (const advanced of [false, true]) for (const scope of ['purchase', 'payment'] as const) { + const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld printed Karama ${scope}`; + void test(`${label}: original cost reserves the lot until an exact singleton claim or pass`, () => { + const f = createSemutaHomeworldKaramaAuctionFixture(scope, advanced); + if (scope === 'payment') { + assert.equal(f.game.auction!.bidder, f.actor); + assert.ok(f.nativeBid > player(f.game, f.actor).spice); + assert.equal(f.game.decision?.kind, 'auctionPayment'); + } + const pending = disposeSemutaHomeworldKaramaAuction(f); + assertReserved(f, pending); + const event = pending.pendingTreacheryDiscard!.batch.event; + assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true); + assert.throws(() => applyAction(pending, f.actor, f.action)); + assert.throws(() => applyAction(pending, f.holder, { type: 'semutaCommit', event: `${event}-stale` })); + assert.throws(() => applyAction(pending, f.actor, { type: 'bid', amount: f.nativeBid + 1 })); + assert.throws(() => applyAction(pending, f.actor, { type: 'decision', karama: false })); + for (const seat of pending.players.filter(seat => seat.id !== f.holder)) { + assert.equal(viewGame(pending, seat.id).semutaReaction?.canCommit, false); + assert.throws(() => applyAction(pending, seat.id, { type: 'semutaCommit', event })); + } + for (const claim of [false, true]) { + const done = reactSemutaHomeworldChoam(JSON.parse(JSON.stringify(pending)) as Game, f.holder, claim); + assertAcquired(f, done, claim); + assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event })); + assert.throws(() => applyAction(done, f.actor, f.action)); + for (const seat of done.players.filter(seat => seat.id !== f.actor)) + assert.throws(() => applyAction(done, seat.id, { type: 'decision', karama: true, card: f.card })); + const finished = finishSemutaHomeworldKaramaAuction(done, f); + assert.equal(finished.phase, 5); + assert.equal(finished.turn, f.game.turn); + assert.equal(finished.richeseBidding!.stage, 'complete'); + assert.equal(finished.richeseCache!.length, f.game.richeseCache!.length - 1, + 'The original compulsory cache auction is completed, never cut from the continuation'); + assert.deepEqual(finished.players.map(p => ({ player: p.id, spice: p.spice })), f.payers); + assert.equal(player(finished, f.actor).hand.filter(card => card.id === f.reservedLot).length, 1); + } + }); + + void test(`${label}: full holder exchange is atomic and nonholder projections cannot reveal entitlement`, () => { + const f = createSemutaHomeworldKaramaAuctionFixture(scope, advanced, true); + assert.equal(player(f.game, f.holder).hand.length, 4); + assert.equal(f.game.richeseBidding!.normalCount, f.beforeLot.richeseBidding!.normalCount); + assert.deepEqual(f.game.auction!.cards, f.beforeLot.auction!.cards); + const absent = structuredClone(f.game), hand = player(absent, f.holder).hand; + const index = hand.findIndex(card => card.id === SEMUTA_DRUG_ID); + const replacement = absent.deck.findIndex(card => card.id !== SEMUTA_DRUG_ID); + [hand[index], absent.deck[replacement]] = [absent.deck[replacement], hand[index]]; + assert.deepEqual(biddingPhysicalIds(absent), biddingPhysicalIds(f.game)); + const pending = disposeSemutaHomeworldKaramaAuction(f), neutral = disposeSemutaHomeworldKaramaAuction(f, absent); + assertReserved(f, pending); + assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false); + for (const seat of pending.players.filter(seat => seat.id !== f.holder)) + assert.deepEqual(JSON.parse(JSON.stringify(viewGame(pending, seat.id))), + JSON.parse(JSON.stringify(viewGame(neutral, seat.id)))); + assert.throws(() => applyAction(neutral, f.holder, + { type: 'semutaCommit', event: neutral.pendingTreacheryDiscard!.batch.event })); + const done = reactSemutaHomeworldChoam(pending, f.holder, true); + assertAcquired(f, done, true); + assert.equal(player(done, f.holder).hand.length, handLimit(player(done, f.holder))); + const declined = reactSemutaHomeworldChoam(neutral, f.holder, false); + assert.equal(player(declined, f.actor).hand.filter(card => card.id === f.reservedLot).length, 1); + assert.deepEqual(declined.players.map(p => ({ player: p.id, spice: p.spice })), f.payers); + }); + + void test(`${label}: changed reserved native settlement rejects without effect replay across JSON`, () => { + const f = createSemutaHomeworldKaramaAuctionFixture(scope, advanced); + const pending = disposeSemutaHomeworldKaramaAuction(f), event = pending.pendingTreacheryDiscard!.batch.event; + const mutations: ((game: Game) => void)[] = [ + game => { game.auction!.cards[game.auction!.index].name = 'Changed reserved face'; }, + game => { game.richeseBidding!.position = 'first'; }, + game => { game.auction!.bidder = f.holder; }, + game => { + const continuation = game.pendingTreacheryDiscard!.continuation; + if (continuation.kind === 'karamaPurchaseDiscard' || continuation.kind === 'karamaPaymentDiscard') + continuation.stateSignature = 'retired'; + }, + ]; + for (const mutate of mutations) { + const altered = JSON.parse(JSON.stringify(pending)) as Game; + mutate(altered); + const original = JSON.stringify(altered); + assert.throws(() => applyAction(altered, f.holder, { type: 'semutaCommit', event })); + assert.equal(JSON.stringify(altered), original, 'Rejected altered continuation neither settles nor spends Semuta'); + } + for (const seat of f.game.players.filter(seat => seat.id !== f.actor)) + assert.throws(() => disposeSemutaHomeworldKaramaAuction({ ...f, actor: seat.id })); + }); + + for (const difficulty of DIFFICULTIES) void test(`${difficulty}: ${label} consumes a minimal legal private reaction then native next-lot control`, () => { + const f = createSemutaHomeworldKaramaAuctionFixture(scope, advanced); + let game = disposeSemutaHomeworldKaramaAuction(f); + const own = viewGame(game, f.holder); + own.players.find(seat => seat.id === f.holder)!.bot = difficulty; + const reaction = botActions(own)[0]; + assert.ok(reaction?.type === 'semutaCommit' || reaction?.type === 'semutaPass'); + game = applyAction(game, f.holder, reaction); + if (game.pendingTreacheryDiscard) { + const pending = game.pendingTreacheryDiscard, offer = pending.reaction; + assert.ok(offer?.stage === 'offer'); + for (const seat of game.players.filter(seat => !offer.passed.includes(seat.id))) + game = applyAction(game, seat.id, { type: 'semutaPass', event: pending.batch.event }); + } + assertAcquired(f, game, reaction.type === 'semutaCommit'); + game = advanceSemutaHomeworldChoam(game, state => !state.response && !state.decision); + const actor = game.auction!.active, native = viewGame(game, actor); + native.players.find(seat => seat.id === actor)!.bot = difficulty; + const action = botActions(native)[0]; + assert.ok(action); + const moved = applyAction(game, actor, action); + assert.equal(player(moved, f.actor).hand.filter(card => card.id === f.reservedLot).length, 1); + const done = finishSemutaHomeworldKaramaAuction(moved, f); + assert.equal(done.phase, 5); + assert.equal(done.richeseBidding!.stage, 'complete'); + assert.equal(player(done, f.actor).hand.filter(card => card.id === f.reservedLot).length, 1); + }); +} + +for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'}: native buyer capacity distinguishes direct purchase from spending a winning-payment Karama`, () => { + for (const scope of ['purchase', 'payment'] as const) { + const f = createSemutaHomeworldKaramaAuctionFixture(scope, advanced), game = structuredClone(f.game); + const buyer = player(game, f.actor); + while (buyer.hand.length < handLimit(buyer)) buyer.hand.push(game.deck.shift()!); + assert.equal(buyer.hand.length, handLimit(buyer)); + assert.deepEqual(biddingPhysicalIds(game), biddingPhysicalIds(f.game)); + if (scope === 'purchase') { + const original = JSON.stringify(game); + assert.throws(() => disposeSemutaHomeworldKaramaAuction(f, game)); + assert.equal(JSON.stringify(game), original); + } else { + const cost = disposeSemutaHomeworldKaramaAuction(f, game); + assert.equal(player(cost, f.actor).hand.length, handLimit(buyer) - 1); + assert.equal(player(cost, f.actor).hand.some(card => card.id === f.reservedLot), false); + const done = reactSemutaHomeworldChoam(cost, f.holder, true); + assert.equal(player(done, f.actor).hand.length, handLimit(buyer)); + assert.equal(player(done, f.actor).hand.filter(card => card.id === f.reservedLot).length, 1); + assert.deepEqual(done.players.map(p => ({ player: p.id, spice: p.spice })), f.payers); + } + } +}); + +for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'}: original printed cache lot still cannot be taken with normal-auction Karama`, () => { + const f = createSemutaHomeworldKaramaAuctionFixture('purchase', advanced); + let game = advanceSemutaHomeworldChoam(f.game, state => state.decision?.kind === 'richeseCache'); + game = applyAction(game, game.decision!.player, { type: 'decision', event: game.richeseBidding!.event, + card: game.richeseCache![0].id, method: 'onceAround', direction: 'counterclockwise' }); + game = advanceSemutaHomeworldChoam(game, state => !state.response && !state.decision); + assert.equal(game.richeseAuction!.source, 'cache'); + assert.equal(game.auction, null); + const original = JSON.stringify(game); + assert.throws(() => disposeSemutaHomeworldKaramaAuction(f, game)); + assert.equal(JSON.stringify(game), original); + assert.equal(player(game, f.actor).hand.filter(card => card.id === f.card).length, 1); +}); + +void test('Advanced: genuine original normal-method Black Market still cannot be taken by normal-auction Karama', () => { + const f = createSemutaHomeworldKaramaAuctionFixture('purchase', true); + let game = structuredClone(f.setup); + if (!player(game, f.actor).hand.some(card => card.id === f.card)) + moveSemutaEmperorCard(game, f.card, f.actor); + // These are the same still-unplayed physical cards, before any first-turn blow. + for (let position = 0; position < 2; position++) { + const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card); + assert.ok(index >= position); + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]); + } + game = advanceSemutaHomeworldChoam(game, state => state.decision?.kind === 'richeseBlackMarket'); + const owner = game.decision!.player, lot = player(game, owner).hand[0]; + assert.ok(lot); + game = applyAction(game, owner, { type: 'decision', event: game.richeseBidding!.event, + card: lot.id, method: 'normal' }); + game = advanceSemutaHomeworldChoam(game, state => !state.response && !state.decision); + assert.equal(game.richeseAuction!.source, 'blackMarket'); + assert.equal(game.richeseAuction!.method, 'normal'); + assert.equal(game.auction, null); + const original = JSON.stringify(game); + assert.throws(() => disposeSemutaHomeworldKaramaAuction(f, game)); + assert.equal(JSON.stringify(game), original); + assert.equal(player(game, f.actor).hand.filter(card => card.id === f.card).length, 1); +}); diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 65586ebc..14bf839a 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/history remain original. Paid Box, Distrans, definite Truthtrance, ordinary specials, Kaitain, Tupile cleanup and legal CHOAM market sales offer used cards before their original suffix. Final Ornithopter disposal keeps completed original groups; finite Once Around and clean unstarted movement Sapho disposal keeps the reordered queue/funding. Accepted losing-card, mandatory winning-card and selected winner disposal remain separate fresh events, preserving paid deployments, casualties and original rewards without replaying combat. Clean Residual Poison keeps one native random death without bounty; early Sapho aggressor and remaining Battle chooser costs keep original participants and finite priority. Native printed CHOAM costs pause before their effect; printed Karama canceling these powers or native Guild/Emperor/Richese income is separately offered before denial. Retained fuel/witness and credited sale spice remain unchanged. No repeated move/return/storm/payout/refund is introduced. Kull, Hajr/prior-flight integration, unresolved cyclic/partial/Guild/late Sapho timing, readied/inspected Poison costs, Advanced Harkonnen captive disclosure, other producers/modules, 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/history remain original. Paid Box, Distrans, definite Truthtrance, ordinary specials, Kaitain, Tupile cleanup and legal CHOAM market sales offer used cards before their original suffix. Final Ornithopter disposal keeps completed original groups; finite Once Around and clean unstarted movement Sapho disposal keeps the reordered queue/funding. Accepted losing-card, mandatory winning-card and selected winner disposal remain separate fresh events, preserving paid deployments, casualties and original rewards without replaying combat. Clean Residual Poison keeps one native random death without bounty; early Sapho aggressor and remaining Battle chooser costs keep original participants and finite priority. Native printed CHOAM costs pause before their effect; printed Karama canceling these powers or native Guild/Emperor/Richese income is separately offered before denial. Printed CHOAM opening-income/Inflation cancellation and normal-lot Karama purchase/free-payment costs also pause before their original denial, spent attempt or zero-spice acquisition. Retained fuel/witness and credited sale spice remain unchanged. No repeated move/return/storm/payout/refund is introduced. Kull, Hajr/prior-flight integration, unresolved cyclic/partial/Guild/late Sapho timing, readied/inspected Poison costs, Advanced Harkonnen captive disclosure, other producers/modules, 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.' +