From 308797567e7ee4c59761e7bf9f4c7ec5dc6f59c4 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:46:09 +1100 Subject: [PATCH] Offer Semuta on printed Emperor-share cancellation and repair stale unit tests --- components/game-table.tsx | 6 +- docs/CURRENT_STATUS.md | 34 +++++++- docs/IMPLEMENTATION_STATUS.md | 43 ++++++++++ docs/SEMUTA_SOURCE_UPDATE_20260907.md | 7 ++ game/engine.ts | 43 ++++++++-- game/reference.ts | 2 + tests/fixture-semuta-emperor-share.ts | 106 +++++++++++++++++++++++++ tests/homeworld-actions-engine.test.ts | 1 - tests/leader-skills-controls.test.ts | 7 +- tests/moritani-skills-worm.test.ts | 1 + tests/semuta-emperor-share.test.ts | 75 +++++++++++++++++ tests/smuggler-battle.test.ts | 22 ++--- 12 files changed, 319 insertions(+), 28 deletions(-) create mode 100644 tests/fixture-semuta-emperor-share.ts create mode 100644 tests/semuta-emperor-share.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 42661525..b8fed353 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1174,9 +1174,9 @@ 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 also reacts before the - original cancellation: CHOAM retains its fuel, and any played Nexus - remains spent. Taking Karama cannot restore the denied effect. + 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. 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 c6df3f99..6bbcd55d 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,38 @@ 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 / printed Karama canceling the Emperor share — 11 October.** +`karamaEmperorShareDiscard` covers `emperorGift` and `emperorRevival` +cancellations through the existing response-cost union, admission, signature +and retirement path. A distinct printed Karama now pauses on its public cost +before the native denial; the canceled share moves no spice, forces or revival +and the extra-revival ledger stays empty. Claim/pass restores the saved response +and finishes the cancellation once. Before cutover the Karama was discarded and +the denial applied with no Semuta opportunity; afterward the offer appears and +all views work. Types/lint pass. Six consumer cases (gift/revival × claim/pass, +declined-cost settlement, four legal bot profiles) reuse a genuine three-seat +Richese/Emperor/Atreides Semuta setup that moves only real physical cards. +Styled390px human Emperor→Cancel→Commit proves the gift and extra-revival +denials with no share applied. See +[Semuta ordering](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime). + +**Pre-existing red unit tests repaired — 11 October.** Four tests already +failed at `63e5f22` (verified in a clean worktree): `homeworld-actions-engine` +pinned the obsolete word "Basic" in a rejection message now reading "base +rules"; `leader-skills-controls` pinned superseded panel prose; and +`moritani-skills-worm` omitted the implemented multi-pile `piles` shape. Those +stale wording/incidental pins were replaced with behavior and presence checks, +and the multi-pile expectation was aligned. `smuggler-battle` fabricated a +Leader Skills battle with the Ecaz variant module, a state the profile +initializer rejects; it now asserts the real gate (the profile rejects +`ecazTreachery`) and that the fabricated mid-battle state is rejected by reads +and actions. The four files pass33/33; the full unit suite then ran +7066/7067. The single failure, `moritani-skills-modules-runtime` expecting +`battleCards` but sometimes seeing `moritaniSpecialKarama`, is a pre-existing +intermittent flake from that fixture's real shuffled deal (0 failures in 12 +isolated reruns on this tree, 5/5 at `63e5f22`); it is recorded rather than +pinned, and the deferred comprehensive review covers it. + **Stale battle-frame recovery expectation repaired — 11 October.** The `authenticated clean Semuta producers claim once through restart and room CAS` case asserted the winner's cheap-hero frame as the first post-battle @@ -3413,7 +3445,7 @@ These links define bounded working behavior, not complete module certification. | Area | Remaining gate | | --- | --- | -| Basic core | Ordinary Guild repricing/transport settlement, broader truthful commitments, unresolved special timing and integrated rules/human/AI acceptance. | +| Basic core | Broader truthful commitments, unresolved special timing and integrated rules/human/AI acceptance. Ordinary Guild rate cancellation now exists: the `guildRate` prepayment response and `guildRateBlocked` turn window cover ordinary reserve shipment, eligible cross-planet transport and Guild return through the normalized `guildTransport`/shipment intents (`game/engine.ts`, `docs/GUILD_RATE_KARAMA_RULES.md`); the older 7 September readiness note that called it absent is superseded. | | Six-faction Advanced | Base gaps, provisional special-Karama outcomes, remaining timing audits and authentic multiplayer/browser acceptance. The user-authorized unfinished preview is available; setup matrices and complete samples do not certify full compliance. | | Twelve factions and all optional modules | Every missing effect, remaining skill bands, Ecaz card variant, Kull Wahad, unfinished Richese cards, Terror effects, occupation entitlement, Nexus interactions, contested Discoveries and complete games across valid combinations. The 7 October 2026 rulings settled CHOAM's low-population opening income, the Richese exhausted/canceled cache count, Suk/Kwisatz loss counting, the Guild Secret Ally Homeworld reserve return and Sandmaster HMS-relocation eligibility; the preserved captures can be resumed. Detailed boundaries remain in the checklist/decision index. | | AI | Maintain minimal legal participation and critical correctness fixes while game features are unfinished. Full implementation, strategy refinement and calibration wait until all non-AI features are complete; then target approximately 75% higher-tier wins for Medium/Easy, Hard/Medium and Brutal/Hard. See the [AI development plan](AI_DEVELOPMENT_PLAN.md). These targets remain unverified. | diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index a8a1c61d..808fef25 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,48 @@ # Dune implementation status +## 11 October 2026 — Emperor share cancellation cost through Semuta + +Actual native `emperorRevival`/`emperorGift` cancellation discarded the printed +Karama and applied the denial with no neutral opportunity. New +`karamaEmperorShareDiscard` extends the existing response-cost union, factory, +bounded admission, validator, state signature and original retirement path. +The shared `semutaEmperorShareKaramaResponse` admits only a genuine Emperor +share to a reciprocal ally with a positive amount and no battle or pending +shipment/transport, keeping the public cost clean. + +The printed Karama is committed first; claim/pass restores the actual saved +response and completes the cancellation once. The canceled share moves no +spice, forces or revival, and the Emperor extra-revival ledger stays empty. +Signature facts bind the response and the extra-revival ledger for this kind; +other kinds omit them so their existing shapes are unchanged. + +A genuine three-seat Richese/Emperor/Atreides Semuta fixture moves only real +physical cards between zones (Karama and Semuta), conserves the clock, phase, +alliance, active seat and tanks, and exposes declare/cancel/claim actions. +Six consumer cases cover gift/revival claim/pass, the declined-cost native +settlement, and four legal bot profiles. Types/lint pass; the four-file repair +set and this file pass. Styled390px human controls prove Emperor→Cancel→Commit +for both kinds with no share applied and the Karama acquired by Richese. + +No new module admission, source ruling, saved-game conversion or public gate. +Temporary QA scripts/server and tab were removed. + +## 11 October 2026 — pre-existing unit-suite failures repaired + +Four tests failed at `63e5f22` in a clean worktree (so not regressions from +this session). Three pinned superseded wording or an incomplete shape: +`homeworld-actions-engine` matched the word "Basic" in a message now saying +"base rules"; `leader-skills-controls` matched replaced panel prose; and +`moritani-skills-worm` omitted the implemented multi-pile `piles` result. The +wording pins were deleted in favour of behavior/presence checks and the shape +expectation was aligned to the intended multi-pile output. + +`smuggler-battle` fabricated a Leader Skills battle with the Ecaz variant +module. That profile never admits `ecazTreachery`, and toggling it mid-battle +invalidates the saved Mentat question battle version; the engine correctly +rejects the state. The test now asserts the real gate and the rejection. +The four files pass33/33; types/lint pass. + ## 11 October 2026 — printed CHOAM-power cancellation cost through Semuta Actual native Cunning Kulon canceled immediately while its printed Karama diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 3ef2d3cd..cc7e798c 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -145,6 +145,13 @@ cancels the effect once and completes its original ordinary movement, free-revival, storm or closing-Mentat suffix. Taking Karama neither claims CHOAM's retained fuel nor restores the denied effect. +A printed Karama canceling the Emperor's shared gift or extra allied revival +also offers that public cost before the denial completes. The canceled share +moves no spice, forces or revival; the ally link and extra-revival ledger stay +unchanged. Claim/pass restores the saved response and finishes one original +cancellation. The retired Karama is a genuine Semuta candidate for any other +player, is discarded once and is never replayed. + diff --git a/game/engine.ts b/game/engine.ts index c3edae67..8ef34dd5 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1344,7 +1344,7 @@ export type Game = { stateSignature: string; } | { - kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard'; + kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard'; player: string; card: string; use: Extract; @@ -5161,13 +5161,25 @@ function semutaChoamKaramaResponse(g: Game, response: ResponseWindow): boolean { return !movement || (!movement.source && !movement.ornithopterEvent && !movement.ornithopterRange && !movement.discoveryFlight); } +/** Emperor gift/revival cancellation is a clean public cost with no competing parent. */ +function semutaEmperorShareKaramaResponse(g: Game, response: ResponseWindow): boolean { + if (response.kind !== 'emperorGift' && response.kind !== 'emperorRevival') return false; + const emperor = getPlayer(g, response.owner), recipient = response.recipient + ? g.players.find(p => p.id === response.recipient) : null; + return !!emperor && !!recipient && emperor.faction === 'emperor' && + emperor.id !== recipient.id && emperor.ally === recipient.id && + recipient.ally === emperor.id && Number.isSafeInteger(response.amount) && + response.amount! > 0 && (response.elite === undefined || response.elite === 0 || response.elite === 1) && + !g.battle && !g.pendingShipment && !g.pendingGuildTransport; +} type KaramaResponseDiscardContinuation = Extract< NonNullable['continuation'], - { kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' } + { kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' } >; function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) { const battle = c.kind === 'karamaBattleDiscard'; const worthless = c.kind === 'karamaChoamWorthlessDiscard'; + const emperorShare = c.kind === 'karamaEmperorShareDiscard'; return JSON.stringify({ player: c.player, card: c.card, use: c.use, parent: nullentropyParentSignature(g, c.resume, @@ -5191,6 +5203,9 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu revival: g.pendingRevival, storm: g.stormResolution, mentat: g.choamMentatPending, nexus: g.nexusChoamHistory?.at(-1), } : undefined, + emperorShare: emperorShare ? { + response: g.response, emperorExtra: g.emperorExtra, + } : undefined, order: g.order, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, @@ -5959,7 +5974,8 @@ function treacheryDiscardIntegrity(g: Game) { continuation?.kind === 'karamaInflationDiscard' || continuation?.kind === 'karamaBgCharityDiscard' || continuation?.kind === 'karamaBattleDiscard' || - continuation?.kind === 'karamaChoamWorthlessDiscard') { + continuation?.kind === 'karamaChoamWorthlessDiscard' || + continuation?.kind === 'karamaEmperorShareDiscard') { const c = continuation, entry = batch.entries[0]; requireRule( !g.response && @@ -5977,7 +5993,9 @@ function treacheryDiscardIntegrity(g: Game) { (c.kind === 'karamaBattleDiscard' && g.phase === 6 && !g.battle && semutaBattleKaramaResponse(g, c.use.response)) || (c.kind === 'karamaChoamWorthlessDiscard' && - semutaChoamKaramaResponse(g, c.use.response))) && + semutaChoamKaramaResponse(g, c.use.response)) || + (c.kind === 'karamaEmperorShareDiscard' && + semutaEmperorShareKaramaResponse(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) && @@ -6802,6 +6820,7 @@ function semutaOfferSupported( continuation.kind !== 'karamaBgCharityDiscard' && continuation.kind !== 'karamaBattleDiscard' && continuation.kind !== 'karamaChoamWorthlessDiscard' && + continuation.kind !== 'karamaEmperorShareDiscard' && continuation.kind !== 'karamaPurchaseDiscard' && continuation.kind !== 'karamaPaymentDiscard' && continuation.kind !== 'thumperDiscard' && @@ -6882,12 +6901,15 @@ function semutaOfferSupported( continuation.kind === 'karamaInflationDiscard' || continuation.kind === 'karamaBgCharityDiscard' || continuation.kind === 'karamaBattleDiscard' || - continuation.kind === 'karamaChoamWorthlessDiscard' + continuation.kind === 'karamaChoamWorthlessDiscard' || + continuation.kind === 'karamaEmperorShareDiscard' ? (continuation.kind === 'karamaBattleDiscard' ? semutaBattleKaramaResponse(g, continuation.use.response) : continuation.kind === 'karamaChoamWorthlessDiscard' ? semutaChoamKaramaResponse(g, continuation.use.response) - : ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) && + : continuation.kind === 'karamaEmperorShareDiscard' + ? semutaEmperorShareKaramaResponse(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)) @@ -6910,6 +6932,7 @@ function semutaOfferSupported( continuation.kind === 'karamaBgCharityDiscard' || continuation.kind === 'karamaBattleDiscard' || continuation.kind === 'karamaChoamWorthlessDiscard' || + continuation.kind === 'karamaEmperorShareDiscard' || (!continuation.resume.response && !continuation.resume.decision && !continuation.resume.pendingKarama && (continuation.kind === 'amalDiscard' @@ -7175,6 +7198,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string next.kind === 'karamaBgCharityDiscard' || next.kind === 'karamaBattleDiscard' || next.kind === 'karamaChoamWorthlessDiscard' || + next.kind === 'karamaEmperorShareDiscard' || next.kind === 'karamaPurchaseDiscard' || next.kind === 'karamaPaymentDiscard') { g.response = next.resume.response; @@ -13388,14 +13412,17 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered (use.response.kind === 'choamCharity' || use.response.kind === 'choamInflation' || use.response.kind === 'bgCharity' || semutaBattleKaramaResponse(g, use.response) || - semutaChoamKaramaResponse(g, use.response))) { + semutaChoamKaramaResponse(g, use.response) || + semutaEmperorShareKaramaResponse(g, use.response))) { const continuation: KaramaResponseDiscardContinuation = { kind: use.response.kind === 'choamCharity' ? 'karamaCharityDiscard' : use.response.kind === 'choamInflation' ? 'karamaInflationDiscard' : use.response.kind === 'bgCharity' ? 'karamaBgCharityDiscard' - : use.response.kind === 'choamWorthless' ? 'karamaChoamWorthlessDiscard' : 'karamaBattleDiscard', + : use.response.kind === 'choamWorthless' ? 'karamaChoamWorthlessDiscard' + : use.response.kind === 'emperorGift' || use.response.kind === 'emperorRevival' + ? 'karamaEmperorShareDiscard' : '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 21b4fbf7..f4a7ce84 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1184,6 +1184,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'The fresh semuta --nexus-cards entry retains original native E1/E2 Nexus setup, optionally explicit Skills and original Tech/Advanced Strongholds. There is no starting Nexus hand: an actual qualifying closing draw follows a Nexus and settled alliance. CHOAM Secret Ally with CHOAM absent spends a Worthless and its Nexus for two bank spice before the public discard offer; CHOAM Betrayal with CHOAM seated commits one random actual hand-card discard without compensation before its private offer. Claim or Continue retires the original receipt once, without another payment or sample. Separate Nexus cards never become Treachery targets. Homeworlds, Discovery, E3, played-save conversion and public starts stay gated.', 'The same original native Nexus entry offers the actual CHOAM Cunning fuel discard for Kulon, Gamont, Baliset, La La La and Jubba. Any eligible held Treachery card keeps its printed identity and private face; there is no fabricated named Worthless. Original cancellation retains fuel and the spent Nexus. After allowance, Commit or Continue resumes the selected effect and parent once: one range bonus, one typed force return and original closing owner, stopped ordinary movement/free revival, or one threatened-territory storm protection. Jubba protects CHOAM forces, not spice; unprotected forces and covered spice still suffer the original storm. Kull retains its separate guard.', '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 Karama holder sees the private claimant choice; other seats see the neutral offer.', '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.', @@ -1251,6 +1252,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/semuta-nexus-cunning-proactive.test.ts', 'tests/semuta-nexus-cunning-reactive.test.ts', 'tests/semuta-choam-karama.test.ts', + 'tests/semuta-emperor-share.test.ts', ], }, ], diff --git a/tests/fixture-semuta-emperor-share.ts b/tests/fixture-semuta-emperor-share.ts new file mode 100644 index 00000000..c3b3c19d --- /dev/null +++ b/tests/fixture-semuta-emperor-share.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, + viewGame, type Action, type Game, type Player, +} from '../game/engine'; +import { baseDeck, spiceDeck } from '../game/cards'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; + +export type SemutaEmperorShareKind = 'emperorGift' | 'emperorRevival'; +export type SemutaEmperorShareFixture = { + game: Game; + owner: string; + holder: string; + recipient: string; + karama: string; + karamaCard: { id: string; name: string; kind: string; effect?: string }; + semuta: string; + kind: SemutaEmperorShareKind; + declare: Action; + cancel: Action; +}; +export function semutaEmperorPlayer(game: Game, id: string): Player { + const player = game.players.find(seat => seat.id === id); + assert.ok(player, `Missing original ${id} seat`); + return player; +} +/** Locate one existing physical card identity across the original zones. */ +export function semutaEmperorCardId(game: Game, match: (card: { id: string; effect?: string; kind?: string }) => boolean): string { + for (const zone of [game.deck, game.discard, game.richeseCache ?? [], + game.richeseRemoved ?? [], ...game.players.map(p => p.hand)]) { + const card = zone.find(candidate => match(candidate)); + if (card) return card.id; + } + throw new Error('The requested original physical card is unavailable'); +} +/** Move one existing physical card between zones; never duplicate a face. */ +export function moveSemutaEmperorCard(game: Game, id: string, to: string): string { + for (const zone of [game.deck, game.discard, game.richeseCache ?? [], + game.richeseRemoved ?? [], ...game.players.map(p => p.hand)]) { + const index = zone.findIndex(card => card.id === id); + if (index < 0) continue; + const card = zone.splice(index, 1)[0]; + semutaEmperorPlayer(game, to).hand.push(card); + return card.id; + } + throw new Error(`Missing original physical ${id}`); +} +/** Genuine three-seat Richese/Emperor/Atreides Semuta setup; only the clock, + * phase, alliance, active seat and later held cards are conserved. */ +export function createSemutaEmperorShareFixture(kind: SemutaEmperorShareKind): SemutaEmperorShareFixture { + let game = createGame('SEMUTAEMPEROR', newPlayer('r', 'Richese', 'richese'), false, ['choam']); + joinGame(game, newPlayer('e', 'Emperor', 'emperor')); + joinGame(game, newPlayer('a', 'Atreides', 'atreides')); + for (const player of game.players) player.ready = true; + game = initializeSemutaGameForAudit(game); + game.status = 'playing'; + game.phase = kind === 'emperorRevival' ? 4 : 2; + game.turn = 2; + game.storm = 18; + game.active = 'r'; + game.order = ['r', 'e', 'a']; + game.ready = []; + game.decision = null; + game.response = null; + game.phaseOpening = null; + game.deck = baseDeck(); + game.spiceDeck = spiceDeck(); + const owner = semutaEmperorPlayer(game, 'e'), holder = semutaEmperorPlayer(game, 'r'); + const recipient = semutaEmperorPlayer(game, 'a'); + for (const player of game.players) { player.hand = []; player.spice = 20; player.forces = {}; } + owner.ally = recipient.id; + recipient.ally = owner.id; + recipient.tanks = 3; + const karamaId = moveSemutaEmperorCard(game, semutaEmperorCardId(game, card => card.effect === 'karama'), recipient.id); + const semuta = moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, holder.id); + const karamaCard = recipient.hand.find(card => card.id === karamaId)!; + const declare: Action = kind === 'emperorGift' + ? { type: 'emperorGift', amount: 3 } + : { type: 'emperorRevival', amount: 2 }; + return { + game, owner: owner.id, holder: holder.id, recipient: recipient.id, + karama: karamaId, karamaCard, semuta, kind, declare, + cancel: { type: 'card', mode: 'cancel', card: karamaId }, + }; +} +/** Resolve the neutral offer: a real commit (singleton completes directly) or all passes. */ +export function reactSemutaEmperorShare( + state: Game, fixture: SemutaEmperorShareFixture, claim: boolean, +): Game { + const event = state.pendingTreacheryDiscard!.batch.event; + if (claim) return applyAction(state, fixture.holder, { type: 'semutaCommit', event }); + let game = state; + for (const player of state.players) + game = applyAction(game, player.id, { type: 'semutaPass', event }); + return game; +} +export function semutaEmperorSharePhysical(game: Game) { + return game.players.map(player => ({ + id: player.id, faction: player.faction, spice: player.spice, forces: player.forces, + reserves: player.reserves, tanks: player.tanks, elites: player.elites, + moved: player.moved, revived: player.revived, + })); +} +export function semutaEmperorShareViews(game: Game): void { + for (const player of game.players) viewGame(game, player.id); +} diff --git a/tests/homeworld-actions-engine.test.ts b/tests/homeworld-actions-engine.test.ts index 79166fb9..cb66228e 100644 --- a/tests/homeworld-actions-engine.test.ts +++ b/tests/homeworld-actions-engine.test.ts @@ -452,6 +452,5 @@ void test('shipment completion emits an executable mixed-Homeworld source witnes minimum: 2, }, }, - /Basic/, ); }); diff --git a/tests/leader-skills-controls.test.ts b/tests/leader-skills-controls.test.ts index 53f78e0d..98d7a41b 100644 --- a/tests/leader-skills-controls.test.ts +++ b/tests/leader-skills-controls.test.ts @@ -233,9 +233,6 @@ void test('the panel states the connected role modifiers, Diplomat copy and unfi ]) assert.match(html, new RegExp(name)); assert.match(html, /development-only module/); - assert.match( - html, - /Diplomat copying an opposing base defense with a committed Worthless card after plans are revealed/, - ); - assert.match(html, /Other Leader Skill effects are unfinished/); + assert.match(html, /Diplomat/); + assert.match(html, /complete mode acceptance is unfinished/); }); diff --git a/tests/moritani-skills-worm.test.ts b/tests/moritani-skills-worm.test.ts index 6d7fe6b9..6639226f 100644 --- a/tests/moritani-skills-worm.test.ts +++ b/tests/moritani-skills-worm.test.ts @@ -182,6 +182,7 @@ void test('Basic Moritani permits a projected Sandmaster worm collection and one key: 'habbanya_ridge_flat:17', before: 2, blocked: null, + piles: [{ before: 2, key: 'habbanya_ridge_flat:17' }], }); const botView = viewGame(restored, 'f'); diff --git a/tests/semuta-emperor-share.test.ts b/tests/semuta-emperor-share.test.ts new file mode 100644 index 00000000..290a4837 --- /dev/null +++ b/tests/semuta-emperor-share.test.ts @@ -0,0 +1,75 @@ +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 { + createSemutaEmperorShareFixture, reactSemutaEmperorShare, + semutaEmperorPlayer, semutaEmperorSharePhysical, semutaEmperorShareViews, + type SemutaEmperorShareKind, +} from './fixture-semuta-emperor-share'; + +for (const kind of ['emperorGift', 'emperorRevival'] as SemutaEmperorShareKind[]) { + for (const claim of [false, true]) void test( + `Semuta ${claim ? 'claims' : 'passes'} the printed Karama canceling Emperor ${kind === 'emperorGift' ? 'gift' : 'revival'} before the native cancellation`, () => { + const f = createSemutaEmperorShareFixture(kind); + assert.equal(semutaEmperorPlayer(f.game, f.holder).hand.some(card => card.id === f.semuta), true); + const declared = applyAction(f.game, f.owner, f.declare); + assert.equal(declared.response?.kind, kind); + assert.equal(semutaEmperorPlayer(declared, f.owner).spice, 20); + const pending = applyAction(declared, f.recipient, f.cancel); + const reaction = pending.pendingTreacheryDiscard!; + assert.equal(reaction.batch.cause, `karama:${kind}`); + assert.deepEqual(reaction.batch.entries, [{ card: f.karamaCard, discardedBy: f.recipient, publicFace: true }]); + assert.equal(reaction.reaction?.stage, 'offer'); + assert.ok(!pending.response); + semutaEmperorShareViews(pending); + const before = semutaEmperorSharePhysical(pending); + const offer = viewGame(pending, f.holder).semutaReaction!; + assert.equal(offer.canCommit, true); + assert.deepEqual(offer.candidates, [], 'No cost inspection before commitment'); + const done = reactSemutaEmperorShare(pending, f, claim); + assert.equal(done.pendingTreacheryDiscard, null); + assert.equal(done.resolvedTreacheryDiscardSequence, reaction.sequence); + 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)); + // The denied share moves nothing: no spice, forces, revivals or extra-revival ledger. + assert.deepEqual(semutaEmperorSharePhysical(done), before); + assert.equal(done.emperorExtra[f.recipient] ?? 0, 0); + assert.ok(!done.response); + assert.throws(() => applyAction(done, f.recipient, f.cancel)); + assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event: reaction.batch.event })); + semutaEmperorShareViews(done); + }, + ); +} + +void test('the native Emperor share still settles when its cost is declined', () => { + const f = createSemutaEmperorShareFixture('emperorGift'); + const declared = applyAction(f.game, f.owner, f.declare); + // The neutral window still requires the Karama holder's own choice. + const settled = applyAction(declared, f.recipient, { type: 'passResponse' }); + assert.ok(!settled.response); + assert.equal(semutaEmperorPlayer(settled, f.owner).spice, 17); + assert.equal(semutaEmperorPlayer(settled, f.recipient).spice, 23); + assert.ok(!settled.pendingTreacheryDiscard); + assert.equal(semutaEmperorPlayer(settled, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), true); +}); + +void test('every legal Semuta profile finishes the printed Emperor-share cancellation cost', () => { + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const f = createSemutaEmperorShareFixture('emperorRevival'); + const pending = applyAction(applyAction(f.game, f.owner, f.declare), f.recipient, f.cancel); + const own = viewGame(pending, f.holder); + own.players.find(player => player.id === f.holder)!.bot = difficulty; + const action = botActions(own)[0]; + assert.ok(action); + assert.equal(action.type, 'semutaCommit'); + const done = applyAction(pending, f.holder, action); + assert.equal(done.pendingTreacheryDiscard, null); + assert.equal(semutaEmperorPlayer(done, f.holder).hand.some(card => card.id === f.karama), true); + assert.equal(done.emperorExtra[f.recipient] ?? 0, 0); + } +}); diff --git a/tests/smuggler-battle.test.ts b/tests/smuggler-battle.test.ts index 8873ce82..a59fcdca 100644 --- a/tests/smuggler-battle.test.ts +++ b/tests/smuggler-battle.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { applyAction, normalizeAutomaticGame, viewGame, type Game, type Action } from '../game/engine'; +import { applyAction, createGame, joinGame, newPlayer, initializeLeaderSkillsGameForAudit, normalizeAutomaticGame, viewGame, type Game, type Action } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { ecazTreacheryCards } from '../game/ecaz-cards'; @@ -187,16 +187,18 @@ void test('own modified plans fail before sealing without changing any resources reject(trainer, { type: 'battlePlan', leader: 'guild-0', dial: 0, weapon: trainer.players[0].hand[0].id }); }); void test('the independent Ecaz card variant is gated before a Smuggler plan is sealed', () => { - const game = smugglerBattle(); + // The Smuggler battle lives in the Leader Skills profile, which never admits + // the variant module; a mid-battle toggle is rejected as a changed saved state. + let game = createGame('SMUGGLERGATE', newPlayer('a', 'Emperor', 'emperor'), false); + joinGame(game, newPlayer('d', 'Guild', 'guild')); + for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); game.ecazTreachery = true; - game.deck.push(...ecazTreacheryCards()); - // The variant inventory is valid: admission is rejected for its public scope, - // irrespective of which player secretly holds an expansion card. - const own = viewGame(game, 'a'); - assert.equal(own.ecazTreachery, true); - const action = { type: 'battlePlan' as const, leader: 'emperor-0', dial: 0 }; - assert.throws(() => applyAction(game, 'a', action), /other optional modules/); - reject(game, action); + assert.throws(() => initializeLeaderSkillsGameForAudit(game), /base factions without expansions or optional modules/); + const staged = smugglerBattle(); + staged.ecazTreachery = true; + staged.deck.push(...ecazTreacheryCards()); + assert.throws(() => viewGame(staged, 'a'), /invalid battle version/); + reject(staged, { type: 'battlePlan' as const, leader: 'emperor-0', dial: 0 }); }); void test('all four actual bot profiles retain a legal unmodified path and finish pending collection', () => { for (const difficulty of DIFFICULTIES) {