From d718e380b7fc3b4d211697afdeaa97db591846f5 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:36:48 +1100 Subject: [PATCH] Offer Semuta before native paid-income Karama cancellations --- components/game-table.tsx | 9 +- docs/CURRENT_STATUS.md | 17 +++ docs/IMPLEMENTATION_STATUS.md | 47 ++++++ docs/SEMUTA_SOURCE_UPDATE_20260907.md | 18 +++ game/engine.ts | 74 ++++++++-- game/reference.ts | 3 + tests/fixture-semuta-emperor-income.ts | 192 +++++++++++++++++++++++++ tests/fixture-semuta-guild-income.ts | 187 ++++++++++++++++++++++++ tests/semuta-emperor-income.test.ts | 137 ++++++++++++++++++ tests/semuta-guild-income.test.ts | 130 +++++++++++++++++ 10 files changed, 799 insertions(+), 15 deletions(-) create mode 100644 tests/fixture-semuta-emperor-income.ts create mode 100644 tests/fixture-semuta-guild-income.ts create mode 100644 tests/semuta-emperor-income.test.ts create mode 100644 tests/semuta-guild-income.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index b8fed353..20d64bd6 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1174,9 +1174,12 @@ export function GameTable({ Betrayal and the five ordinary Cunning fuel discards can react before their original effects or receipts finish. Cunning fuel keeps its printed identity and private face; Kull retains its separate guard. - A printed Karama canceling those native powers or the Emperor’s shared - gift/revival also reacts before the original cancellation: the denied - effect never applies. Taking Karama cannot restore it. + A printed Karama canceling those native powers, the Emperor’s shared + gift/revival, Guild shipment income, paid Emperor auction income or + Richese special-purchase income also reacts before the original + cancellation. The denied effect never applies; already-paid shipments + and purchases are not undone. Taking Karama cannot restore the + canceled power or refund a payment. Homeworlds, Discovery and other overlays stay guarded. Native Moritani support is Basic only: clean shipment/movement Sabotage and Robbery diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index fb600158..ab018b8f 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,23 @@ 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 income-cancellation costs — 11 October.** +Printed Karama canceling original `guildIncome`, non-Ambassador +`emperorIncome` or `richesePurchaseIncome` now offers its public discard before +the original denial finishes. The existing typed cost envelope restores the +same saved response once; paid shipment/purchase mechanics are not replayed. +Five native before scenarios had no cost offer. Ten post-cutover claim/pass +programs now pass, including Basic/Advanced Guild shipment, paid auction, +the just-purchased Karama as its own cancellation cost, and Advanced Richese +special purchase. Guild denial keeps the six-spice debit and three arrived +forces; auction keeps the one-spice payment and next-lot inspection; Richese +keeps its three-spice payment, cache acquisition, spent activation and original +declaration owner. Sixteen new consumer cases pass, including genuine full +hands and minimal legal bots. Styled390px human Cancel→Commit controls prove +all three sources; native Movement and Richese cache-choice controls remain +usable afterward. No Homeworld/Ambassador composition or public gate opens. +The frozen seven-file response-cost union passes70/70 with types/lint clean. + **Semuta / Emperor-share native continuation proof — 11 October.** `karamaEmperorShareDiscard` covers printed Karama canceling `emperorGift` and `emperorRevival` before the original denial. The initial fixture assigned diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index ff20bb4c..ef6f7d54 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,52 @@ # Dune implementation status +## 11 October 2026 — native income-cancellation costs through Semuta + +Five genuine native before scenarios discarded the printed Karama and +completed the income denial without a Semuta offer: Basic/Advanced Guild +shipment, ordinary Emperor auction, just-purchased auction Karama, and +Advanced Richese special purchase. + +New `karamaIncomeDiscard` reuses the original response-cost union, signature, +admission and retirement. Admission retains completed native paid sources; +only their corresponding auction or Richese purchase parent crosses its +existing fence. Original auction and Richese cancellation quotes remain the +authority. No fresh source identity, payment calculation or refund is added. +Signature facts bind original auction/sale, source controls, current Movement +and committed force/shipment state; older cost kinds omit the new fields. + +Ten actual source-action replay/claim/pass programs retain the payment and +physical transaction. A three-force ordinary Guild shipment costs six spice: +Atreides stays at four spice with three arrivals and its shipment used, Guild +stays at five. A one-spice auction keeps Atreides at nine and Emperor at ten, +advances exactly one lot and restores original Atreides inspection. Spending +the exact newly bought Karama is legal; its later claim does not refund the +bid. Advanced Richese pays three, stays at two spice, keeps its cache card and +spent separate activation, and recovers the original declaration owner. + +Sixteen new consumer cases cover those continuations, original full-hand +exchange, foreign cost ownership, neutral passes and minimal legal bots. +During integration the Guild fixture's counter baseline was moved after +native setup: Advanced Emperor's original five elites are created there. +No engine stock change was needed. Pure fixture-wiring, default and message +pins were removed rather than added as contracts. + +Styled390px actual GameTable Cancel→Commit flows acquire the printed Karama +at hand4/4 for all three sources. Human Atreides can finish original Movement; +human Richese can answer its restored declaration and open native cache +selection, retaining Ornithopter. Screenshots inspect the acquired Karama, +original next-lot ownership and restored cache controls. Updated live guidance +was reinspected. Vite HMR reloaded the QA client and aborted four dependency +fetches; reselecting the existing memory scene recovered the original controls. +This is bounded local proof, not error-free/full-phone or deployed acceptance. + +No module admission, new ruling, public start or played-save conversion. +Temporary scripts, isolated server and tab were removed; existing games and +unrelated services remain untouched. +Types/lint and the frozen seven-file response-cost union pass70/70. The two +fixture closure-narrowing errors found by typecheck were repaired with the +existing local reaction binding; no runtime source or rule changed. + ## 11 October 2026 — native Emperor and Moritani continuation evidence corrected The initial Emperor-share fixture directly assigned phase, clock, alliance, diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 8111e179..abc2cb48 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -854,3 +854,21 @@ the native Emperor has ten spice and the ally retains three Tanks. Six 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. + +**11 October 2026 — native paid-income cancellation costs.** Printed Karama +canceling original Guild shipment income, non-Ambassador Emperor auction +income or Advanced Richese special-purchase income offers that public +singleton before the original denial. Claim/pass restores its existing +response once. Canceling income does not cancel or refund the paid shipment +or acquisition; the next native Movement, auction or saved Richese control +still owns the suffix. A purchased Karama may itself pay for canceling its +auction income; its new Semuta custody does not reverse the bid. + +Five genuine before programs lacked the offer; ten source-action replay and +claim/pass programs now retain original prices, forces and cards. Sixteen +consumer cases cover full hands, foreign ownership, neutral passes and legal +bots. Styled390px actual controls complete all three costs; original +Movement and restored Richese declaration/cache choice are usable. Guild +rate cancellation, Homeworld/Ambassador payments and unsupported overlapping +parents remain distinct coverage; no printed prohibition or module gate is +invented to describe those limits. diff --git a/game/engine.ts b/game/engine.ts index 8ef34dd5..c9947150 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1344,7 +1344,7 @@ export type Game = { stateSignature: string; } | { - kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard'; + kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard'; player: string; card: string; use: Extract; @@ -5172,14 +5172,32 @@ function semutaEmperorShareKaramaResponse(g: Game, response: ResponseWindow): bo response.amount! > 0 && (response.elite === undefined || response.elite === 0 || response.elite === 1) && !g.battle && !g.pendingShipment && !g.pendingGuildTransport; } +/** Original paid income sources; cancellation never reverses the completed transaction. */ +function semutaIncomeKaramaResponse(g: Game, response: ResponseWindow): boolean { + if (g.battle || g.pendingShipment || g.pendingGuildTransport || + response.source === 'ambassador') return false; + const owner = g.players.find(p => p.id === response.owner); + if (response.kind === 'guildIncome') + return g.phase === 5 && owner?.faction === 'guild' && + Number.isSafeInteger(response.amount) && response.amount! > 0; + if (owner?.faction !== 'emperor') return false; + if (response.kind === 'emperorIncome') + return g.phase === 3 && !!g.currentAuctionSale && + auctionEmperorIncomeAmount(g.currentAuctionSale) > 0; + const purchase = g.pendingRichesePurchaseIncome; + return response.kind === 'richesePurchaseIncome' && g.advanced && + response.amount === 3 && !!purchase && purchase.owner === owner.id && + purchase.turn === g.turn && purchase.phase === g.phase; +} type KaramaResponseDiscardContinuation = Extract< NonNullable['continuation'], - { kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' } + { kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' } >; function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) { const battle = c.kind === 'karamaBattleDiscard'; const worthless = c.kind === 'karamaChoamWorthlessDiscard'; const emperorShare = c.kind === 'karamaEmperorShareDiscard'; + const income = c.kind === 'karamaIncomeDiscard'; return JSON.stringify({ player: c.player, card: c.card, use: c.use, parent: nullentropyParentSignature(g, c.resume, @@ -5206,11 +5224,17 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu emperorShare: emperorShare ? { response: g.response, emperorExtra: g.emperorExtra, } : undefined, + income: income ? { + auction: g.auction, sale: g.currentAuctionSale, + richeseBidding: g.richeseBidding, richeseAuction: g.richeseAuction, + active: g.active, ready: g.ready, movementRemaining: g.movementRemaining, + } : undefined, order: g.order, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, hand: p.hand.map(card => card.id), spice: p.spice, - physical: battle || worthless ? { forces: p.forces, reserves: p.reserves, tanks: p.tanks, + shipped: income ? p.shipped : undefined, + physical: battle || worthless || income ? { forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites, leaders: battle ? p.leaders : undefined } : undefined, })), }); @@ -5975,7 +5999,8 @@ function treacheryDiscardIntegrity(g: Game) { continuation?.kind === 'karamaBgCharityDiscard' || continuation?.kind === 'karamaBattleDiscard' || continuation?.kind === 'karamaChoamWorthlessDiscard' || - continuation?.kind === 'karamaEmperorShareDiscard') { + continuation?.kind === 'karamaEmperorShareDiscard' || + continuation?.kind === 'karamaIncomeDiscard') { const c = continuation, entry = batch.entries[0]; requireRule( !g.response && @@ -5995,7 +6020,9 @@ function treacheryDiscardIntegrity(g: Game) { (c.kind === 'karamaChoamWorthlessDiscard' && semutaChoamKaramaResponse(g, c.use.response)) || (c.kind === 'karamaEmperorShareDiscard' && - semutaEmperorShareKaramaResponse(g, c.use.response))) && + semutaEmperorShareKaramaResponse(g, c.use.response)) || + (c.kind === 'karamaIncomeDiscard' && + semutaIncomeKaramaResponse(g, c.use.response))) && c.use.response.owner !== c.player && c.resume.response?.kind === c.use.response.kind && JSON.stringify(c.resume.response) === JSON.stringify(c.use.response) && @@ -6015,6 +6042,12 @@ function treacheryDiscardIntegrity(g: Game) { ixSubstitutionCancellationQuote(g, c.use.response); else validateAftermathCancellation(g, c.use.response); } + if (c.kind === 'karamaIncomeDiscard') { + if (c.use.response.kind === 'emperorIncome') + validateAuctionContinuationCancellation(g, c.use.response); + else if (c.use.response.kind === 'richesePurchaseIncome') + currentRicheseCancellationQuote(g, c.use.response); + } if (c.kind === 'karamaChoamWorthlessDiscard') { try { quoteChoamWorthlessCancellation(g, c.use.response); } catch (error) { @@ -6821,6 +6854,7 @@ function semutaOfferSupported( continuation.kind !== 'karamaBattleDiscard' && continuation.kind !== 'karamaChoamWorthlessDiscard' && continuation.kind !== 'karamaEmperorShareDiscard' && + continuation.kind !== 'karamaIncomeDiscard' && continuation.kind !== 'karamaPurchaseDiscard' && continuation.kind !== 'karamaPaymentDiscard' && continuation.kind !== 'thumperDiscard' && @@ -6902,14 +6936,17 @@ function semutaOfferSupported( continuation.kind === 'karamaBgCharityDiscard' || continuation.kind === 'karamaBattleDiscard' || continuation.kind === 'karamaChoamWorthlessDiscard' || - continuation.kind === 'karamaEmperorShareDiscard' + continuation.kind === 'karamaEmperorShareDiscard' || + continuation.kind === 'karamaIncomeDiscard' ? (continuation.kind === 'karamaBattleDiscard' ? semutaBattleKaramaResponse(g, continuation.use.response) : continuation.kind === 'karamaChoamWorthlessDiscard' ? semutaChoamKaramaResponse(g, continuation.use.response) : continuation.kind === 'karamaEmperorShareDiscard' ? semutaEmperorShareKaramaResponse(g, continuation.use.response) - : ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) && + : continuation.kind === 'karamaIncomeDiscard' + ? semutaIncomeKaramaResponse(g, continuation.use.response) + : ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) && !continuation.resume.decision && !continuation.resume.pendingKarama && !continuation.resume.phaseOpening && !g.pendingKarama && (!g.response || JSON.stringify(g.response) === JSON.stringify(continuation.resume.response)) @@ -6933,6 +6970,7 @@ function semutaOfferSupported( continuation.kind === 'karamaBattleDiscard' || continuation.kind === 'karamaChoamWorthlessDiscard' || continuation.kind === 'karamaEmperorShareDiscard' || + continuation.kind === 'karamaIncomeDiscard' || (!continuation.resume.response && !continuation.resume.decision && !continuation.resume.pendingKarama && (continuation.kind === 'amalDiscard' @@ -6948,9 +6986,15 @@ function semutaOfferSupported( : continuation.kind === 'karamaPurchaseDiscard' || continuation.kind === 'karamaPaymentDiscard' ? !!g.auction && !g.currentAuctionSale && !g.richeseAuction - : !g.auction) && + : continuation.kind === 'karamaIncomeDiscard' && + continuation.use.response.kind === 'emperorIncome' + ? !!g.currentAuctionSale && (g.currentAuctionSale.origin === 'normal' + ? !!g.auction && !g.richeseAuction : !!g.richeseAuction && !g.auction) + : !g.auction) && (continuation.kind === 'saphoAuctionDiscard' || - continuation.kind === 'ixAllyCard' || !g.richeseAuction) && + continuation.kind === 'ixAllyCard' || + (continuation.kind === 'karamaIncomeDiscard' && + continuation.use.response.kind === 'emperorIncome') || !g.richeseAuction) && (continuation.kind === 'saphoAggressorDiscard' || continuation.kind === 'residualPoisonDiscard' || continuation.kind === 'truthtranceDiscard' || @@ -6975,7 +7019,8 @@ function semutaOfferSupported( !g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy && !g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingShipment && !g.pendingHomeworldShipment && !g.pendingRicheseGift && - !g.pendingRichesePurchaseIncome && + ((continuation.kind === 'karamaIncomeDiscard' && + continuation.use.response.kind === 'richesePurchaseIncome') || !g.pendingRichesePurchaseIncome) && (continuation.kind === 'choamSaleDiscard' ? !!g.choamMarket && !g.choamMarket.sale && !g.choamMarket.trade && !g.biddingEnd && !g.currentAuctionSale @@ -7199,6 +7244,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string next.kind === 'karamaBattleDiscard' || next.kind === 'karamaChoamWorthlessDiscard' || next.kind === 'karamaEmperorShareDiscard' || + next.kind === 'karamaIncomeDiscard' || next.kind === 'karamaPurchaseDiscard' || next.kind === 'karamaPaymentDiscard') { g.response = next.resume.response; @@ -13413,7 +13459,8 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered use.response.kind === 'choamInflation' || use.response.kind === 'bgCharity' || semutaBattleKaramaResponse(g, use.response) || semutaChoamKaramaResponse(g, use.response) || - semutaEmperorShareKaramaResponse(g, use.response))) { + semutaEmperorShareKaramaResponse(g, use.response) || + semutaIncomeKaramaResponse(g, use.response))) { const continuation: KaramaResponseDiscardContinuation = { kind: use.response.kind === 'choamCharity' ? 'karamaCharityDiscard' @@ -13422,7 +13469,10 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered : use.response.kind === 'bgCharity' ? 'karamaBgCharityDiscard' : use.response.kind === 'choamWorthless' ? 'karamaChoamWorthlessDiscard' : use.response.kind === 'emperorGift' || use.response.kind === 'emperorRevival' - ? 'karamaEmperorShareDiscard' : 'karamaBattleDiscard', + ? 'karamaEmperorShareDiscard' + : use.response.kind === 'guildIncome' || use.response.kind === 'emperorIncome' || + use.response.kind === 'richesePurchaseIncome' + ? 'karamaIncomeDiscard' : 'karamaBattleDiscard', player: p.id, card: used.id, use, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, diff --git a/game/reference.ts b/game/reference.ts index f22b2e02..3d3c8aa2 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1185,6 +1185,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'The same original native Nexus entry offers the actual CHOAM Cunning fuel discard for Kulon, Gamont, Baliset, La La La and Jubba. Any eligible held Treachery card keeps its printed identity and private face; there is no fabricated named Worthless. Original cancellation retains fuel and the spent Nexus. After allowance, Commit or Continue resumes the selected effect and parent once: one range bonus, one typed force return and original closing owner, stopped ordinary movement/free revival, or one threatened-territory storm protection. Jubba protects CHOAM forces, not spice; unprotected forces and covered spice still suffer the original storm. Kull retains its separate guard.', '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.', '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.', @@ -1253,6 +1254,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/semuta-nexus-cunning-reactive.test.ts', 'tests/semuta-choam-karama.test.ts', 'tests/semuta-emperor-share.test.ts', + 'tests/semuta-guild-income.test.ts', + 'tests/semuta-emperor-income.test.ts', ], }, ], diff --git a/tests/fixture-semuta-emperor-income.ts b/tests/fixture-semuta-emperor-income.ts new file mode 100644 index 00000000..462fba3f --- /dev/null +++ b/tests/fixture-semuta-emperor-income.ts @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, + type Action, type Game, +} from '../game/engine'; +import type { Card } from '../game/cards'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech'; +import { + moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer, +} from './fixture-semuta-emperor-share'; + +export type SemutaEmperorIncomeKind = 'emperorIncome' | 'richesePurchaseIncome'; +export type SemutaEmperorIncomeFixture = { + game: Game; + beforePayment: Game; + sourceActions: { actor: string; action: Action }[]; + owner: string; + holder: string; + buyer: string; + canceler: string; + karama: string; + karamaCard: Card; + acquired: string; + special?: string; + amount: number; + kind: SemutaEmperorIncomeKind; + cancel: Action; + originalCards: string[]; + staging: string[]; +}; + +/** Original ownership zones only; the paid auction prefix is a reference, not another card. */ +export function semutaEmperorIncomeCards(game: Game): string[] { + return [ + ...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), + ...game.players.flatMap(player => player.hand), + ...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? []), + ].map(card => card.id).sort(); +} + +export function nextSemutaEmperorIncomeStep(game: Game): { actor: string; action: Action } { + const pending = game.pendingTreacheryDiscard; + const reaction = pending?.reaction; + if (reaction?.stage === 'offer' && pending) { + const actor = game.players.find(player => !reaction.passed.includes(player.id))!.id; + return { actor, action: { type: 'semutaPass', event: pending.batch.event } }; + } + return nextRicheseSkillsTechNativeStep(game); +} + +function reach(state: Game, until: (game: Game) => boolean): Game { + let game = state; + for (let n = 0; !until(game) && n < 1400; n++) { + assert.notEqual(game.status, 'finished', 'Native actions must reach the original income source'); + const next = nextSemutaEmperorIncomeStep(game); + game = applyAction(game, next.actor, next.action); + } + assert.ok(until(game), 'Native actions must reach the requested source boundary'); + return game; +} + +/** Move an original physical card to the next native draw, without replacing the deck. */ +function nextOriginalDraw(game: Game, id: string): void { + for (const zone of [game.deck, game.discard, game.richeseCache ?? [], + game.richeseRemoved ?? [], ...game.players.map(player => player.hand)]) { + const index = zone.findIndex(card => card.id === id); + if (index < 0) continue; + const card = zone.splice(index, 1)[0]; + game.deck.unshift(card); + return; + } + throw new Error(`Missing original draw ${id}`); +} + +function fillHolder(game: Game): void { + const holder = semutaEmperorPlayer(game, 'r'); + while (holder.hand.length < 4) { + const card = game.deck.find(candidate => candidate.effect !== 'karama'); + assert.ok(card); + moveSemutaEmperorCard(game, card.id, holder.id); + } + assert.equal(holder.hand.length, 4); +} + +/** Fresh original setup and first-turn phase actions. Only unplayed Spice order and + * conserved later physical cards are staged; no clocks, wallets, allies or stocks are minted. + * Purchase is made inside the genuine Richese declaration, so its original owner must return. + * The auction optionally buys the very printed Karama that the winner then spends on income. */ +export function createSemutaEmperorIncomeFixture( + kind: SemutaEmperorIncomeKind, + options: { acquiredKarama?: boolean } = {}, +): SemutaEmperorIncomeFixture { + const advanced = kind === 'richesePurchaseIncome'; + let game = createGame('SEMUTAINCOME', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']); + joinGame(game, newPlayer('e', 'Emperor', 'emperor')); + joinGame(game, newPlayer('a', 'Atreides', 'atreides')); + for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); + game = initializeSemutaGameForAudit(game); + const originalCards = semutaEmperorIncomeCards(game); + const staging: string[] = []; + game = reach(game, state => state.status === 'playing'); + // Control only the two still-unplayed original first-turn blows. + 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('Two unplayed original non-worm Spice Cards precede native first-turn Charity/Bidding.'); + game = reach(game, state => state.phase === 2 && !state.phaseOpening && !state.response && !state.decision); + moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r'); + let special: string | undefined; + if (advanced) { + special = moveSemutaEmperorCard(game, + semutaEmperorCardId(game, card => card.effect === 'karama'), 'r'); + } + const karama = semutaEmperorCardId(game, + card => card.effect === 'karama' && card.id !== special); + if (options.acquiredKarama) nextOriginalDraw(game, karama); + else moveSemutaEmperorCard(game, karama, 'a'); + staging.push('Original Semuta moves from cache to Richese; original printed Karama custody is conserved.'); + if (!advanced) { + const inspectionCounter = semutaEmperorCardId(game, + card => card.effect === 'karama' && card.id !== karama); + moveSemutaEmperorCard(game, inspectionCounter, 'e'); + staging.push('The other original printed Karama is held by Emperor, qualifying the next Atreides inspection response.'); + } + const sourceActions: { actor: string; action: Action }[] = []; + let beforePayment: Game; + let acquired: string; + if (advanced) { + fillHolder(game); + staging.push('Original later deck cards fill Richese to its four-card limit, including its separate activation.'); + game = reach(game, state => state.decision?.kind === 'richeseDeclaration' && !state.response && !state.phaseOpening); + assert.equal(game.decision!.player, 'r'); + beforePayment = structuredClone(game); + acquired = game.richeseCache![0].id; + const purchase: Action = { type: 'card', mode: 'special', card: special!, acquire: acquired }; + sourceActions.push({ actor: 'r', action: purchase }); + game = applyAction(game, 'r', purchase); + } else { + if (!options.acquiredKarama) { + const first = game.deck.find(card => card.effect !== 'karama'); + assert.ok(first); + acquired = first.id; + nextOriginalDraw(game, acquired); + } else acquired = karama; + staging.push(`Original ${acquired} is moved to the next native ordinary-pool draw, not a replacement deck.`); + game = reach(game, state => !!state.auction && state.phase === 3 && + !state.phaseOpening && !state.response && !state.decision); + assert.equal(game.auction!.cards[game.auction!.index].id, acquired); + assert.ok(game.auction!.cards.length >= 2, 'The native ordinary pool must have a successor lot'); + fillHolder(game); + staging.push('Conserved later deck cards bring Semuta holder to hand4 after the original pool is drawn.'); + for (let n = 0; game.auction!.active !== 'a' && n < 6; n++) + game = applyAction(game, game.auction!.active, { type: 'passBid' }); + assert.equal(game.auction!.active, 'a'); + beforePayment = structuredClone(game); + sourceActions.push({ actor: 'a', action: { type: 'bid', amount: 1 } }); + game = applyAction(game, 'a', sourceActions[0].action); + for (let n = 0; !game.response && n < 8; n++) { + const next = nextSemutaEmperorIncomeStep(game); + sourceActions.push(next); + game = applyAction(game, next.actor, next.action); + } + } + assert.equal(game.response?.kind, kind); + assert.equal(game.response!.owner, 'e'); + assert.notEqual(game.response!.source, 'ambassador'); + assert.equal(game.turn, 1); + assert.equal(game.phase, 3); + assert.deepEqual(semutaEmperorIncomeCards(game), originalCards); + const karamaCard = semutaEmperorPlayer(game, 'a').hand.find(card => card.id === karama)!; + assert.ok(karamaCard); + if (special) assert.notEqual(special, karama); + return { + game, beforePayment, sourceActions, owner: 'e', holder: 'r', buyer: advanced ? 'r' : 'a', canceler: 'a', + karama, karamaCard, acquired, special, amount: advanced ? 3 : 1, kind, + cancel: { type: 'card', mode: 'cancel', card: karama }, originalCards, staging, + }; +} + +export function reactSemutaEmperorIncome( + state: Game, fixture: SemutaEmperorIncomeFixture, claim: boolean, +): Game { + const event = state.pendingTreacheryDiscard!.batch.event; + if (claim) return applyAction(state, fixture.holder, { type: 'semutaCommit', event }); + let game = state; + for (const player of state.players) + game = applyAction(game, player.id, { type: 'semutaPass', event }); + return game; +} diff --git a/tests/fixture-semuta-guild-income.ts b/tests/fixture-semuta-guild-income.ts new file mode 100644 index 00000000..73f0a88f --- /dev/null +++ b/tests/fixture-semuta-guild-income.ts @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, handLimit, initializeSemutaGameForAudit, joinGame, + newPlayer, viewGame, type Action, type Game, +} from '../game/engine'; +import { TERRITORIES } from '../game/board'; +import { botEntryAllowed } from '../game/bot-mobility'; +import { reserveShipmentCost } from '../game/shipment-price'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import type { Card } from '../game/cards'; +import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech'; +import { + moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer, +} from './fixture-semuta-emperor-share'; + +export type SemutaGuildIncomeOptions = { advanced?: boolean; fullHand?: boolean }; +export type SemutaGuildIncomeFixture = { + game: Game; + income: Game; + initial: Game; + setup: Game; + afterSetup: Game; + afterFirstStorm: Game; + beforeStaging: Game; + actions: RicheseSkillsTechStep[]; + producerActions: RicheseSkillsTechStep[]; + staging: string[]; + owner: string; + payer: string; + holder: string; + canceller: string; + karama: Card; + semuta: string; + territory: string; + key: string; + amount: number; + cost: number; + ship: Action; + cancel: Action; +}; +export function semutaGuildIncomeInventory(game: Game): string[] { + const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), + ...(game.richeseRemoved ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? []), + ...game.players.flatMap(player => player.hand)].map(card => card.id).sort(); + assert.equal(new Set(ids).size, ids.length, 'One physical slot per original Treachery identity'); + return ids; +} +export function semutaGuildIncomeCounters(game: Game) { + return game.players.map(player => ({ + id: player.id, + total: player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), + elite: player.elites ? player.elites.reserves + player.elites.tanks + + Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0) : 0, + })); +} +/** Exclude only hands from the already-paid shipment snapshot. Semuta changes + * custody, never wallets, force pools, shipment flags or Movement ownership. */ +export function semutaGuildIncomePosition(game: Game) { + return { + status: game.status, turn: game.turn, phase: game.phase, active: game.active, + order: game.order, ready: game.ready, movementRemaining: game.movementRemaining, + pendingShipment: game.pendingShipment, guildRateBlocked: game.guildRateBlocked, + players: game.players.map(player => ({ + id: player.id, faction: player.faction, ally: player.ally, spice: player.spice, + forces: player.forces, reserves: player.reserves, tanks: player.tanks, + elites: player.elites, leaders: player.leaders, + shipped: player.shipped, moved: player.moved, revived: player.revived, + })), + }; +} +const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard; +/** Existing no-spend native setup/phase policy, with neutral passes for any + * fresh discard encountered on the way. No clock, wallet or turn is staged. */ +export function nextSemutaGuildIncomeNativeStep(game: Game): RicheseSkillsTechStep { + const pending = game.pendingTreacheryDiscard; + const reaction = pending?.reaction; + if (reaction?.stage === 'offer' && pending) { + const actor = game.players.find(player => !reaction.passed.includes(player.id)); + assert.ok(actor); + return { actor: actor.id, action: { type: 'semutaPass', event: pending.batch.event } }; + } + return nextRicheseSkillsTechNativeStep(game); +} +/** Fresh Richese/Guild/classic setup, original first Storm and actual first-turn + * Movement. Only still-unplayed Spice order and conserved later hands change. + * Original board, leaders, wallets, reserve stocks and native order stay intact. */ +export function createSemutaGuildIncomeFixture(options: SemutaGuildIncomeOptions = {}): SemutaGuildIncomeFixture { + let game = createGame('SEMUTAGUILDINCOME', newPlayer('r', 'Richese', 'richese'), options.advanced ?? false, ['choam']); + joinGame(game, newPlayer('g', 'Guild', 'guild')); + joinGame(game, newPlayer('a', 'Atreides', 'atreides')); + joinGame(game, newPlayer('e', 'Emperor', 'emperor')); + for (const seat of game.players) game = applyAction(game, seat.id, { type: 'ready' }); + const initial = structuredClone(game), actions: RicheseSkillsTechStep[] = []; + game = initializeSemutaGameForAudit(game); + const setup = structuredClone(game), inventory = semutaGuildIncomeInventory(game); + const reach = (until: () => boolean) => { + for (let n = 0; !until() && n < 1200; n++) { + assert.notEqual(game.status, 'finished', 'Original native actions must reach Movement'); + const next = nextSemutaGuildIncomeNativeStep(game); + actions.push(next); + game = applyAction(game, next.actor, next.action); + } + assert.ok(until(), 'The genuine native phase path must reach its requested boundary'); + }; + reach(() => game.status === 'playing'); + const afterSetup = structuredClone(game); + const counters = semutaGuildIncomeCounters(game); + // Prevent an incidental first-turn worm by reordering original unplayed cards. + for (const position of [0, 1]) { + 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]); + } + reach(() => game.phase === 1 && clean(game)); + const afterFirstStorm = structuredClone(game); + reach(() => game.phase === 5 && game.active === 'a' && clean(game)); + assert.equal(game.turn, 1); + const beforeStaging = structuredClone(game), position = semutaGuildIncomePosition(game); + for (const player of game.players) game.deck.push(...player.hand.splice(0)); + const staging = [ + 'Reordered only the first two original unplayed Spice Cards to non-worm blows.', + 'At actual first-turn Atreides Movement, returned original held Treachery Cards to the same deck; no board, reserve, leader or wallet staging.', + ]; + const karamaId = moveSemutaEmperorCard(game, semutaEmperorCardId(game, card => card.effect === 'karama'), 'e'); + const semuta = moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r'); + const karama = semutaEmperorPlayer(game, 'e').hand.find(card => card.id === karamaId)!; + staging.push(`Conserved original ${karamaId} to Emperor and canonical ${semuta} to Richese; neither is a replacement card.`); + if (options.fullHand) { + const holder = semutaEmperorPlayer(game, 'r'); + while (holder.hand.length < handLimit(holder)) { + const id = semutaEmperorCardId(game, card => card.id !== semuta && card.id !== karamaId && + card.effect !== 'karama' && !holder.hand.some(held => held.id === card.id)); + moveSemutaEmperorCard(game, id, holder.id); + staging.push(`Conserved original ${id} into Richese's full-hand exchange position.`); + } + } + assert.deepEqual(semutaGuildIncomePosition(game), position); + assert.deepEqual(semutaGuildIncomeInventory(game), inventory); + assert.deepEqual(semutaGuildIncomeCounters(game), counters); + const own = viewGame(game, 'a'), payer = own.players.find(player => player.id === 'a')!; + // Empty ordinary sand isolates the paid shipment from battle/arrival parents. + // The consumer's original entry and tariff helpers choose the actual action. + const destination = TERRITORIES.find(t => t.type === 'sand' && !t.sectors.includes(game.storm) && + !game.players.some(player => Object.keys(player.forces).some(key => key.startsWith(`${t.id}:`))) && + botEntryAllowed(own, payer, t.id, t.sectors[0], 'ship')); + assert.ok(destination, 'An original legal empty sand destination must remain'); + const amount = 3, sector = destination.sectors[0]; + const cost = reserveShipmentCost({ faction: payer.faction, halfRate: false }, destination.type, amount); + assert.ok(payer.reserves >= amount && (payer.spice ?? 0) >= cost); + const ship: Action = { type: 'ship', territory: destination.id, sector, amount, elite: 0, allyPayment: 0 }; + const producerActions: RicheseSkillsTechStep[] = [{ actor: 'a', action: ship }]; + if (game.advanced) producerActions.push({ actor: 'g', action: { type: 'decision', allow: true } }); + const fixture: Omit = { + game, initial, setup, afterSetup, afterFirstStorm, beforeStaging, actions, producerActions, staging, + owner: 'g', payer: 'a', holder: 'r', canceller: 'e', karama, semuta, + territory: destination.id, key: `${destination.id}:${sector}`, amount, cost, ship, + cancel: { type: 'card', mode: 'cancel', card: karamaId }, + }; + return { ...fixture, income: shipSemutaGuildIncome(fixture) }; +} +/** Issue the real reserve shipment; Advanced's original Guild special-stop + * decision must explicitly allow it before any payment or force arrival. */ +export function shipSemutaGuildIncome( + fixture: Pick, +): Game { + let game = applyAction(fixture.game, fixture.payer, fixture.ship); + if (fixture.game.advanced) { + assert.equal(game.decision?.kind, 'guildShipment'); + assert.equal(game.decision?.player, fixture.owner); + assert.deepEqual(semutaGuildIncomePosition(game), semutaGuildIncomePosition({ + ...fixture.game, pendingShipment: game.pendingShipment, + })); + game = applyAction(game, fixture.owner, { type: 'decision', allow: true }); + } + assert.equal(game.response?.kind, 'guildIncome', 'This is paid Guild income, not a rate or shipment-stop cancellation'); + assert.equal(game.response.owner, fixture.owner); + assert.equal(game.response.amount, fixture.cost); + assert.equal(semutaEmperorPlayer(game, fixture.payer).shipped, true); + return game; +} +export function reactSemutaGuildIncome(state: Game, fixture: SemutaGuildIncomeFixture, claim: boolean): Game { + const event = state.pendingTreacheryDiscard!.batch.event; + if (claim) return applyAction(state, fixture.holder, { type: 'semutaCommit', event }); + let game = state; + for (const seat of state.players) game = applyAction(game, seat.id, { type: 'semutaPass', event }); + return game; +} diff --git a/tests/semuta-emperor-income.test.ts b/tests/semuta-emperor-income.test.ts new file mode 100644 index 00000000..4db21ba7 --- /dev/null +++ b/tests/semuta-emperor-income.test.ts @@ -0,0 +1,137 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame } from '../game/engine'; +import { botActions } from '../game/bots'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { semutaEmperorPlayer } from './fixture-semuta-emperor-share'; +import { + createSemutaEmperorIncomeFixture, nextSemutaEmperorIncomeStep, + reactSemutaEmperorIncome, semutaEmperorIncomeCards, + type SemutaEmperorIncomeKind, +} from './fixture-semuta-emperor-income'; + +for (const kind of ['emperorIncome', 'richesePurchaseIncome'] as SemutaEmperorIncomeKind[]) { + for (const claim of [false, true]) void test( + `Semuta ${claim ? 'claims' : 'passes'} the foreign Karama canceling native ${kind} without repeating its paid source`, () => { + const f = createSemutaEmperorIncomeFixture(kind); + const originalBuyer = semutaEmperorPlayer(f.beforePayment, f.buyer); + const originalOwner = semutaEmperorPlayer(f.beforePayment, f.owner); + assert.equal(semutaEmperorPlayer(f.game, f.buyer).spice, originalBuyer.spice - f.amount); + assert.equal(semutaEmperorPlayer(f.game, f.owner).spice, originalOwner.spice); + assert.equal(semutaEmperorPlayer(f.game, f.buyer).hand.filter(card => card.id === f.acquired).length, 1); + assert.equal(semutaEmperorPlayer(f.game, f.holder).hand.length, 4); + const wallets = f.game.players.map(player => ({ id: player.id, spice: player.spice })); + const pending = applyAction(f.game, f.canceler, f.cancel); + const cost = pending.pendingTreacheryDiscard!; + assert.ok(cost, 'The actual paid income cancellation must first offer its printed Karama cost'); + assert.equal(cost.batch.cause, `karama:${kind}`); + assert.deepEqual(cost.batch.entries, [{ card: f.karamaCard, discardedBy: f.canceler, publicFace: true }]); + assert.equal(viewGame(pending, f.holder).semutaReaction!.canCommit, true, + 'A genuine full hand can exchange Semuta for the foreign cancellation cost'); + assert.deepEqual(viewGame(pending, f.holder).semutaReaction!.candidates, []); + assert.equal(semutaEmperorPlayer(pending, f.buyer).hand.filter(card => card.id === f.acquired).length, 1); + assert.deepEqual(pending.players.map(player => ({ id: player.id, spice: player.spice })), wallets); + if (kind === 'emperorIncome') { + assert.equal(pending.auction!.index, f.game.auction!.index, 'The original next lot must wait for Semuta'); + assert.equal(pending.currentAuctionSale!.winner, f.buyer); + } else { + assert.equal(pending.richeseCache!.some(card => card.id === f.acquired), false); + assert.equal(pending.discard.filter(card => card.id === f.special).length, 1); + assert.equal(cost.batch.entries.some(entry => entry.card.id === f.special), false, + 'Richese never claims its own separately spent activation'); + assert.equal(semutaEmperorPlayer(pending, f.buyer).specialKaramaUsed, true); + } + const done = reactSemutaEmperorIncome(pending, f, claim); + assert.equal(done.resolvedTreacheryDiscardSequence, cost.sequence); + assert.deepEqual(done.players.map(player => ({ id: player.id, spice: player.spice })), wallets, + 'No refund, repeated debit or canceled Emperor credit occurs'); + assert.equal(semutaEmperorPlayer(done, f.buyer).hand.filter(card => card.id === f.acquired).length, 1); + assert.equal(semutaEmperorPlayer(done, f.holder).hand.length, 4); + assert.equal(semutaEmperorPlayer(done, f.holder).hand.some(card => card.id === f.karama), claim); + assert.equal(semutaEmperorPlayer(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)); + assert.deepEqual(semutaEmperorIncomeCards(done), f.originalCards); + assert.throws(() => applyAction(done, f.canceler, f.cancel)); + assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event: cost.batch.event })); + if (kind === 'emperorIncome') { + assert.equal(done.auction!.index, f.game.auction!.index + 1); + assert.deepEqual(done.auction!.cards.map(card => card.id), f.game.auction!.cards.map(card => card.id)); + assert.equal(done.response!.kind, 'atreidesAuction'); + assert.equal(done.response!.owner, 'a', 'Original Atreides inspection owns the next paid-auction suffix'); + let resumed = done; + while (resumed.response?.kind === 'atreidesAuction') { + const player = resumed.players.find(seat => !resumed.response!.passed.includes(seat.id))!; + resumed = applyAction(resumed, player.id, { type: 'passResponse' }); + } + assert.equal(resumed.auction!.index, done.auction!.index, 'Inspection must not advance a second lot'); + const bidder = resumed.auction!.active; + resumed = applyAction(resumed, bidder, { type: 'bid', amount: 1 }); + assert.equal(resumed.auction!.bidder, bidder, 'The original next lot is usable by its actual active bidder'); + assert.equal(resumed.auction!.index, done.auction!.index); + assert.deepEqual(resumed.players.map(player => ({ id: player.id, spice: player.spice })), wallets); + } else { + assert.deepEqual(done.decision, f.beforePayment.decision, + 'The native Richese declaration, including its original owner, is restored'); + assert.equal(done.richeseCache!.length, f.beforePayment.richeseCache!.length - 1); + assert.equal(done.discard.filter(card => card.id === f.special).length, 1); + const next = nextSemutaEmperorIncomeStep(done); + assert.equal(next.actor, f.buyer); + assert.throws(() => applyAction(done, f.owner, next.action), 'Emperor cannot answer the original Richese decision'); + const resumed = applyAction(done, next.actor, next.action); + assert.equal(resumed.decision!.kind, 'richeseCache'); + assert.equal(resumed.decision!.player, f.buyer, + 'Native declaration opens its actual cache choice, after automatic uncanceled acknowledgements'); + assert.deepEqual(resumed.players.map(player => ({ id: player.id, spice: player.spice })), wallets); + assert.equal(semutaEmperorPlayer(resumed, f.buyer).hand.filter(card => card.id === f.acquired).length, 1); + assert.throws(() => applyAction(resumed, next.actor, next.action), 'Declaration is not replayable'); + } + }, + ); +} + +for (const claim of [false, true]) void test( + `a real paid-auction winner spends its newly purchased Karama and Semuta ${claim ? 'claims' : 'passes'} that same physical card`, () => { + const f = createSemutaEmperorIncomeFixture('emperorIncome', { acquiredKarama: true }); + assert.equal(f.acquired, f.karama); + assert.equal(semutaEmperorPlayer(f.game, f.buyer).spice, + semutaEmperorPlayer(f.beforePayment, f.buyer).spice - 1); + const pending = applyAction(f.game, f.canceler, f.cancel); + assert.ok(pending.pendingTreacheryDiscard); + assert.equal(pending.pendingTreacheryDiscard.batch.entries[0].discardedBy, f.buyer); + assert.equal(semutaEmperorPlayer(pending, f.buyer).hand.some(card => card.id === f.acquired), false); + const done = reactSemutaEmperorIncome(pending, f, claim); + assert.equal(semutaEmperorPlayer(done, f.buyer).spice, semutaEmperorPlayer(f.game, f.buyer).spice, + 'Buying then spending the card does not refund its paid auction'); + assert.equal(semutaEmperorPlayer(done, f.owner).spice, semutaEmperorPlayer(f.beforePayment, f.owner).spice); + assert.equal(semutaEmperorPlayer(done, f.holder).hand.some(card => card.id === f.acquired), claim); + assert.equal(done.discard.filter(card => card.id === f.acquired).length, Number(!claim)); + assert.equal(done.auction!.index, f.game.auction!.index + 1); + assert.equal(done.response!.kind, 'atreidesAuction'); + assert.deepEqual(semutaEmperorIncomeCards(done), f.originalCards); + }, +); + +for (const kind of ['emperorIncome', 'richesePurchaseIncome'] as SemutaEmperorIncomeKind[]) void test( + `a legal Easy bot completes the full-hand native ${kind} Semuta cost and returns its original controls`, () => { + const f = createSemutaEmperorIncomeFixture(kind); + const pending = applyAction(f.game, f.canceler, f.cancel); + const view = viewGame(pending, f.holder); + view.players.find(player => player.id === f.holder)!.bot = 'Easy'; + const action = botActions(view)[0]; + assert.ok(action); + assert.equal(action.type, 'semutaCommit'); + const done = applyAction(pending, f.holder, action); + assert.equal(semutaEmperorPlayer(done, f.holder).hand.length, 4); + assert.equal(semutaEmperorPlayer(done, f.holder).hand.some(card => card.id === f.karama), true); + assert.equal(semutaEmperorPlayer(done, f.buyer).spice, semutaEmperorPlayer(f.game, f.buyer).spice); + assert.equal(semutaEmperorPlayer(done, f.owner).spice, semutaEmperorPlayer(f.beforePayment, f.owner).spice); + if (kind === 'emperorIncome') { + assert.equal(done.auction!.index, f.game.auction!.index + 1); + assert.equal(done.response!.owner, 'a'); + } else { + assert.deepEqual(done.decision, f.beforePayment.decision); + assert.equal(semutaEmperorPlayer(done, f.buyer).specialKaramaUsed, true); + } + }, +); diff --git a/tests/semuta-guild-income.test.ts b/tests/semuta-guild-income.test.ts new file mode 100644 index 00000000..93549a7b --- /dev/null +++ b/tests/semuta-guild-income.test.ts @@ -0,0 +1,130 @@ +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 { + createSemutaGuildIncomeFixture, reactSemutaGuildIncome, + semutaGuildIncomeInventory, + semutaGuildIncomePosition, type SemutaGuildIncomeFixture, +} from './fixture-semuta-guild-income'; +import { semutaEmperorPlayer as semutaGuildIncomePlayer } from './fixture-semuta-emperor-share'; + +function openPaid(f: SemutaGuildIncomeFixture): Game { + const paid = f.income; + const payerBefore = semutaGuildIncomePlayer(f.game, f.payer), payerAfter = semutaGuildIncomePlayer(paid, f.payer); + assert.equal(f.cost, 6, 'Three ordinary sand arrivals pay full price, not the Guild half rate'); + assert.equal(payerAfter.spice, payerBefore.spice - f.cost); + assert.equal(payerAfter.reserves, payerBefore.reserves - f.amount); + assert.equal(payerAfter.forces[f.key], (payerBefore.forces[f.key] ?? 0) + f.amount); + assert.equal(payerAfter.shipped, true); + assert.equal(payerAfter.moved, payerBefore.moved); + assert.equal(paid.active, f.payer); + assert.deepEqual(paid.movementRemaining, f.game.movementRemaining); + assert.equal(semutaGuildIncomePlayer(paid, f.owner).spice, semutaGuildIncomePlayer(f.game, f.owner).spice); + return paid; +} +function openCost(f: SemutaGuildIncomeFixture, paid: Game): Game { + assert.ok(viewGame(paid, f.canceller).responseControls!.cancelCards.includes(f.karama.id)); + assert.deepEqual(viewGame(paid, f.owner).responseControls!.cancelCards, [], 'Guild is income owner, not foreign canceller'); + assert.throws(() => applyAction(paid, f.payer, f.cancel), + 'The shipper cannot spend the foreign Emperor-controlled cost'); + const pending = applyAction(paid, f.canceller, f.cancel); + const discard = pending.pendingTreacheryDiscard; + assert.ok(discard, 'Printed Karama needs its immediate public Semuta opportunity'); + assert.equal(discard.batch.cause, 'karama:guildIncome'); + assert.deepEqual(discard.batch.entries, [{ card: f.karama, discardedBy: f.canceller, publicFace: true }]); + assert.equal(discard.reaction?.stage, 'offer'); + assert.deepEqual(semutaGuildIncomePosition(pending), semutaGuildIncomePosition(paid)); + assert.equal(semutaGuildIncomePlayer(pending, f.canceller).hand.some(card => card.id === f.karama.id), false); + for (const seat of pending.players) { + const offer = viewGame(pending, seat.id).semutaReaction!; + assert.equal(offer.canCommit, seat.id === f.holder); + } + assert.throws(() => applyAction(pending, f.payer, { type: 'endMovement' })); + assert.throws(() => applyAction(pending, f.payer, { type: 'semutaCommit', event: discard.batch.event })); + return pending; +} +function assertCompletion(f: SemutaGuildIncomeFixture, paid: Game, pending: Game, done: Game, claim: boolean): void { + const event = pending.pendingTreacheryDiscard!.batch.event; + const holder = semutaGuildIncomePlayer(done, f.holder); + assert.equal(holder.hand.some(card => card.id === f.karama.id), claim); + assert.equal(holder.hand.some(card => card.id === f.semuta), !claim); + assert.equal(done.discard.filter(card => card.id === f.karama.id).length, Number(!claim)); + assert.equal(done.discard.filter(card => card.id === f.semuta).length, Number(claim)); + assert.equal(done.resolvedTreacheryDiscardSequence, pending.pendingTreacheryDiscard!.sequence); + assert.deepEqual(semutaGuildIncomePosition(done), semutaGuildIncomePosition(paid), + 'Canceling Guild income never refunds, reprices, re-ships, moves or advances the native owner'); + assert.deepEqual(semutaGuildIncomeInventory(done), semutaGuildIncomeInventory(f.setup)); + assert.throws(() => applyAction(done, f.payer, f.ship)); + assert.throws(() => applyAction(done, f.canceller, f.cancel)); + assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event })); + // Compare the next native Movement scheduling with the original allowed + // income branch. Only Guild's collected spice differs; no owner/suffix is invented. + const allowed = applyAction(paid, f.canceller, { type: 'passResponse' }); + assert.equal(semutaGuildIncomePlayer(allowed, f.owner).spice, + semutaGuildIncomePlayer(f.game, f.owner).spice + f.cost); + const ordinarySuffix = applyAction(allowed, f.payer, { type: 'endMovement' }); + const canceledSuffix = applyAction(done, f.payer, { type: 'endMovement' }); + assert.equal(canceledSuffix.turn, ordinarySuffix.turn); + assert.equal(canceledSuffix.phase, ordinarySuffix.phase); + assert.equal(canceledSuffix.active, ordinarySuffix.active); + assert.deepEqual(canceledSuffix.movementRemaining, ordinarySuffix.movementRemaining); + assert.deepEqual(canceledSuffix.decision, ordinarySuffix.decision); + assert.deepEqual(canceledSuffix.response, ordinarySuffix.response); + assert.ok(!canceledSuffix.movementRemaining?.includes(f.payer)); + const payer = semutaGuildIncomePlayer(canceledSuffix, f.payer); + assert.equal(payer.spice, semutaGuildIncomePlayer(f.game, f.payer).spice - f.cost); + assert.equal(payer.reserves, semutaGuildIncomePlayer(f.game, f.payer).reserves - f.amount); + assert.equal(payer.forces[f.key], f.amount); + assert.equal(payer.shipped, true); + assert.equal(semutaGuildIncomePlayer(canceledSuffix, f.owner).spice, semutaGuildIncomePlayer(f.game, f.owner).spice); + assert.deepEqual(semutaGuildIncomeInventory(canceledSuffix), semutaGuildIncomeInventory(f.setup)); + for (const seat of canceledSuffix.players) viewGame(canceledSuffix, seat.id); +} + +for (const advanced of [false, true]) { + for (const claim of [false, true]) void test( + `${advanced ? 'Advanced' : 'Basic'} native paid reserve shipment Semuta ${claim ? 'claims' : 'passes'} income-canceling Karama without refund or repeat arrival`, () => { + const f = createSemutaGuildIncomeFixture({ advanced }), paid = openPaid(f), pending = openCost(f, paid); + const done = reactSemutaGuildIncome(pending, f, claim); + assertCompletion(f, paid, pending, done, claim); + }, + ); + void test(`${advanced ? 'Advanced' : 'Basic'} full-hand Richese exchanges Semuta for the exact foreign Guild-income cost`, () => { + const f = createSemutaGuildIncomeFixture({ advanced, fullHand: true }); + const held = semutaGuildIncomePlayer(f.game, f.holder); + assert.equal(held.hand.length, handLimit(held)); + const paid = openPaid(f), pending = openCost(f, paid); + assert.equal(viewGame(pending, f.holder).semutaReaction!.canCommit, true); + const done = reactSemutaGuildIncome(pending, f, true); + assert.equal(semutaGuildIncomePlayer(done, f.holder).hand.length, handLimit(held)); + assert.deepEqual(semutaGuildIncomePlayer(done, f.holder).hand.map(card => card.id).sort(), + [...held.hand.filter(card => card.id !== f.semuta).map(card => card.id), f.karama.id].sort()); + assertCompletion(f, paid, pending, done, true); + }); +} + +void test('native Guild-income denial waits through neutral passes and the original paid Movement stays owned', () => { + const f = createSemutaGuildIncomeFixture(), paid = openPaid(f), pending = openCost(f, paid); + const event = pending.pendingTreacheryDiscard!.batch.event; + let game = pending; + for (const actor of [f.owner, f.payer, f.canceller]) { + game = applyAction(game, actor, { type: 'semutaPass', event }); + assert.equal(game.pendingTreacheryDiscard!.reaction!.stage, 'offer'); + assert.deepEqual(semutaGuildIncomePosition(game), semutaGuildIncomePosition(paid)); + assert.equal(game.discard.filter(card => card.id === f.karama.id).length, 1); + } + game = applyAction(game, f.holder, { type: 'semutaPass', event }); + assertCompletion(f, paid, pending, game, false); +}); + +void test('legal Advanced Semuta bot finishes the full-hand Guild-income cancellation and resumes native Movement', () => { + const f = createSemutaGuildIncomeFixture({ advanced: true, fullHand: true }); + const paid = openPaid(f), pending = openCost(f, paid); + const own = viewGame(pending, f.holder); + own.players.find(player => player.id === f.holder)!.bot = 'Easy'; + const action = botActions(own)[0]; + assert.ok(action); + const done = applyAction(pending, f.holder, action); + assertCompletion(f, paid, pending, done, true); +});