diff --git a/components/game-table.tsx b/components/game-table.tsx index b5836a1c..a9b4004d 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1177,6 +1177,9 @@ export function GameTable({ Accepted losing-card, mandatory winning-card and selected winner disposal are separate fresh events. Recovery keeps the original casualties and paid native deployments; combat does not repeat. + Clean Residual Poison keeps its one native random leader death + without a bounty. Early Sapho aggressor and remaining Battle + chooser costs keep their original participants and finite priority. Native printed Kulon, Baliset, La La La, Trip to Gamont and Jubba costs also pause before their original effect. Printed Karama canceling these native powers or Guild, Emperor auction or Richese diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index fffaeeb3..791696aa 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,25 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Semuta / clean Homeworld preparation and chooser costs — 13 October.** +Clean Residual Poison, early Sapho aggressor and remaining Battle chooser +disposal now offer their used cards without new timing or signatures. Eight +genuine before programs had no offer;24 parent after programs preserve one +random death/no bounty or the original tie/finite chooser state and finish +native Collection. The ready-Poison guard and already-ready Sapho opponent +retain their distinct original behavior. C is already eligible-last after +the real R/A prefix, so the legal last fixture uses Emperor, not a forced option. +Five actual390px flows exchange at4/4 and continue original shared readiness +or choose the opposing physical attacker from C's control. Artifacts: +`/tmp/dune-homeworld-native-preparation-3221.json`, +`/tmp/dune-homeworld-native-chooser-3221.json`. +UI preparation proof stops before plans; the24 runtime programs reach phase7. +Late timing, inspected/readied Poison interruption and Advanced Harkonnen +disclosure remain guarded. No save/deployed/full-mode certification. +Types/lint and92/92 across seven focused unit files (62 new cases) pass. +The build passes with existing deprecation/chunk/classification warnings. +One unused type import failed initial lint; only lint/tests were retried. + **Semuta / original Homeworld battle costs — 13 October.** Accepted losing-card, later mandatory winning-card and optional winner-selected disposal now offer separate fresh events. Six genuine before programs had no diff --git a/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md b/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md index 07a7352f..e012cdb7 100644 --- a/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md +++ b/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md @@ -44,6 +44,13 @@ deployments and accepted combat remain committed; recovery restores original casualties/cleanup, then normal Collection can credit its real board income. It does not refund support, revive a killed leader or rerun a battle. +Clean original Residual Poison and early/remaining-Battle Sapho costs also +offer their used cards. Poison keeps one actual random death with no bounty; +already-readied/inspected Poison retains automatic disposal without this +interruption. Early Sapho keeps original participants and ordinary tie priority, +including an already-ready opponent; between-battle first/last keeps completed +prefixes and only remaining chooser opportunities. None rewrites storm order. + Actual selected original cleanup cards are privately discarded first. The typed receipt retains only that cleanup's event, original held-ID commitment and suspended opening; its lease row remains pending during the neutral diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 6665a7ad..bdf95689 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,78 @@ # Dune implementation status +## 13 October 2026 — clean Homeworld preparation and remaining chooser costs + +Only existing `residualPoisonDiscard`, `saphoAggressorDiscard` and +`saphoBattleOrderDiscard` are admitted by the Homeworld Semuta gate. +Original death selection, event/readiness, participant, finite-order and +private-card validators remain unchanged. No payment/profile/signature +cutover or interpretation of the still-pending late timing. + +Two actual Basic/Advanced Poison before programs conserve the printed Richese +cache card into CHOAM and use native paid E1/C4 shipments to Red Chasm7 +(cost2/8). One actual random eligible Emperor disc enters Tanks, no bounty +or force delta; the used card disposed without an offer. The initial Advanced +call had an unsettled original interaction and committed no card/death; the +fixture resolves that real owner control, not a suppressed guard. + +Six actual Sapho before programs cover early C aggressor and remaining first/ +last in both modes. The chooser programs additionally ship original R1 into +Arrakeen10 for1 and resolve the real R/A battle: R1Tanks, A10 retained, the +native Caladan reinforcement explicitly declined. With only e/c eligible, +C is already last and cannot legally spend Sapho for that position. Parent +adapted last to genuine Emperor card ownership, without new geometry or rules. +C-first priority is c/r/e/a; E-last is r/c/a/e; physical order stays r/e/c/a. +The original E attacker/C defender pair remains; C chooses next in either mode. + +Ten parent Poison programs cover eight thin/full claim/pass branches and two +actual opponent-ready guards. JSON/resume preserves the one random victim +record, wallets and deployments. Original living-disc plans then kill a +different combat leader, not the Poison victim; C keeps3/reserves16/Tanks1, +E has one lost force/reserves19, and real Collection takes6 from the printed8. +Fourteen parent Sapho programs cover both modes, early/first/last claim/pass +and two already-ready opponent branches. Original equal-value, zero-dial +battles give C's changed aggressor the tie and four survivors/collection8, +while chooser reordering leaves native E the tie and one survivor/collection2. +All24 parent programs reach phase7 turn1 without repeated opportunity. + +Five actual390px flows use native Poison in both modes, Advanced early +aggressor, Basic first and Advanced Emperor-last. Poison exchanges at4/4 +after Caid/Burseg's native death; one victim/deaths1, no bounty or counter +delta, both original readiness controls close the same event. Early Sapho +keeps E attacker/C defender/E chooser, changes ordinary tie priority toC, +exchanges at4/4 and closes original readiness. First/last exchange at4/4 +after the already-completed R/A prefix; C's real Fight Emperor control +opens E attacker/C defender/C chooser. Advanced last then declines the +original Atreides foreknowledge offer and uses both native readiness controls. +These UI proofs stop before plans; runtime evidence covers actual phase7. +Artifacts outside Git: +`/tmp/dune-homeworld-native-preparation-3221.json`, +`/tmp/dune-homeworld-native-chooser-3221.json`. + +The clean producer keeps Poison unoffered after an opponent readiness while +the native death still occurs. Sapho's existing early contract instead +allows the other combatant already ready; that recorded readiness stays. +Inspections, closed preparation, late Sapho and Advanced Harkonnen captive +disclosure remain guarded. No historical/save/full-mode or deployed claim. +Only the owned QA server was restarted for the final gate/fixture code; +first-three-flow evidence was retained separately rather than overwritten. +The owned browser reported no errors. Owned services/tab/root files were +removed/stopped; unrelated services and saved games remain untouched. + +One worker ran its permanent20-case file despite the no-checks contract, +then withdrew that result as integration proof and froze. Another returned +local pass counts while denying permanent checks. Parent does not count +either claim as frozen integration verification. Root removed fixture/input +echoes and non-empty event/queue pins, corrected the native chooser lookup, +and retained explicit two-ready-opponent regressions and actual resource/tie +outcomes. +Parent frozen verification: types and lint pass; seven related unit files +pass92/92, including62 new cases. The initial lint failed one unused type +import; after removal only lint and the not-yet-run tests were retried. +Build passes with the unchanged module.register deprecation, client chunks +over500kB and static route-classification warnings. No broader assurance +or deployed check was substituted for this local changed-path evidence. + ## 13 October 2026 — original Homeworld battle discard producers The Homeworld Semuta gate now admits only the existing `battleResolved`, diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 0558d090..53c852bb 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -855,6 +855,29 @@ consumer cases pass on that genuine setup. Later held cards/Tanks use conserved transfers, not replacement decks or minted forces. No new ruling, module entry, public gate or complete-mode certification follows. +**13 October 2026 — clean native Homeworld preparation and remaining chooser.** +Original clean Residual Poison disposal, early Sapho aggressor and between- +battle first/last costs now offer their physical cards. The printed/native +effects already committed: one random eligible leader death without bounty, +changed ordinary tie priority or only remaining chooser priority. Recovery +does not reroll, change participants, replay a prefix or grant another battle. +Source/timing contracts above remain; no new late/interception ruling. + +Eight actual before programs had no offer. Twenty-four parent after programs +cover both rules modes, claim/pass/full-hand, the two distinct other-ready +boundaries, original living-disc plans/ties and one native Collection. +Poison's clean offer excludes earlier readiness; native death remains legal. +Early Sapho instead preserves an already-ready opponent under its existing +pre-plan contract. After the paid R1/A10 prefix, C is already eligible-last; +the genuine last example uses Emperor, not a fabricated C option. + +Five actual390px used-card/exchange/readiness/chooser flows retain original +E attacker/C defender and physical r/e/c/a order. UI stops before plans; +runtime reaches phase7. Artifacts: +`/tmp/dune-homeworld-native-preparation-3221.json`, +`/tmp/dune-homeworld-native-chooser-3221.json`. +No private saved room, deployment or full-mode acceptance is claimed. + **13 October 2026 — original Homeworld public battle disposal.** `battleResolved`, `winnerMandatoryDiscard` and winner `battleCleanup` now offer their existing fresh events in this profile. The first losing batch, diff --git a/game/engine.ts b/game/engine.ts index 01442985..4582d610 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -6964,7 +6964,10 @@ function semutaOfferSupported( continuation.kind !== 'saphoMovementDiscard' && continuation.kind !== 'battleResolved' && continuation.kind !== 'winnerMandatoryDiscard' && - continuation.kind !== 'battleCleanup') return false; + continuation.kind !== 'battleCleanup' && + continuation.kind !== 'residualPoisonDiscard' && + continuation.kind !== 'saphoAggressorDiscard' && + continuation.kind !== 'saphoBattleOrderDiscard') return false; if (continuation.kind === 'battleResolved' && !g.lastBattleContext?.mandatoryDiscard) return false; if ((continuation.kind === 'battleCleanup' || diff --git a/game/reference.ts b/game/reference.ts index 3f2ade17..46e68414 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1190,6 +1190,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Legal completed CHOAM market sales also offer their used card in the Homeworld profile. Worthless cashout keeps its original two-spice credit; a non-Worthless duplicate keeps its three-spice credit and the original same-name witness stays held. The high-Tupile Worthless-sale prohibition still applies, including duplicate Worthless sales. Claim/pass cannot repeat payout, undo the actual reserve shipment or replace the original market close; no new bank-income interpretation is made.', 'Final Ornithopter disposal also offers its used card in the Homeworld profile after the one-group range-three or original two-group movement completes, including an early end with no useful original group. Claim/pass cannot move those counters again or replay the paid native shipment; prior-move/Hajr, advisor and Homeworld-transfer boundaries remain. Clean Sapho Once Around and unstarted Movement first/last costs likewise offer after reordering the finite unacted queue. Earlier bids/funding and physical storm order stay committed, and the same next bidder or combined turn resumes once. Cyclic auctions, partly completed turns, first-versus-Guild and late battle timing are not opened.', 'Original Homeworld battle disposal offers accepted losing cards, later mandatory winning cards and optional winner-selected cards as separate fresh events. Commit and exact selection recover at most one other player’s newly discarded card; unselected cards stay in the pile. The original paid deployments, accepted result, leader deaths and casualty obligations remain committed, with the same native winner cleanup and rewards afterward. A recovered card cannot replay combat or consume another original opportunity.', + 'Clean Homeworld Residual Poison disposal offers its used card after exactly one native random eligible leader enters the Tanks, with no spice bounty or force loss; claim/pass resumes the original shared preparation and does not redraw the victim. Early Sapho aggressor disposal likewise keeps the original participants/chooser and changed ordinary tie priority, while clean between-battle first/last disposal keeps earlier resolved battles and only reorders remaining chooser opportunities. Physical storm order is unchanged. Readied or inspected Poison costs, late Sapho timing and Advanced Harkonnen captive disclosure remain outside these clean reactions.', '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.', @@ -1274,6 +1275,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/semuta-homeworld-sapho.test.ts', 'tests/semuta-homeworld-battle-loser.test.ts', 'tests/semuta-homeworld-battle-winner.test.ts', + 'tests/semuta-homeworld-residual-poison.test.ts', + 'tests/semuta-homeworld-battle-sapho.test.ts', ], }, ], diff --git a/tests/fixture-semuta-homeworld-battle-sapho.ts b/tests/fixture-semuta-homeworld-battle-sapho.ts new file mode 100644 index 00000000..883d3b76 --- /dev/null +++ b/tests/fixture-semuta-homeworld-battle-sapho.ts @@ -0,0 +1,198 @@ +import assert from 'node:assert/strict'; +import { handLimit, viewGame, type Action, type Game } from '../game/engine'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { semutaEmperorPlayer as player, moveSemutaEmperorCard } from './fixture-semuta-emperor-share'; +import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep, + createSemutaHomeworldChoamSetup, holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam'; +import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech'; + +export type SemutaHomeworldBattleSaphoFixture = { + scope: 'aggressor' | 'order'; mode: 'first' | 'last'; advanced: boolean; + setup: Game; beforeBattle: Game; game: Game; + choam: string; holder: string; emperor: string; atreides: string; owner: string; actor: string; + action: Action; card: string; location: string; actions: RicheseSkillsTechStep[]; staging: string[]; + shipments: { actor: string; action: Action; before: Game; after: Game; price: number }[]; + completedPrefix?: { before: Game; after: Game; event: string; actions: RicheseSkillsTechStep[]; + richeseLeader: string; atreidesLeader: string; reinforcementEvent: string }; +}; +const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard; + +/** Reach the requested native boundary, answering each real gate with its own + * entitled seat's original spending-free control and declining the winner's own + * native Caladan reinforcement instead of letting a generic bot spend. */ +function advance(state: Game, until: (game: Game) => boolean, actions: RicheseSkillsTechStep[]): Game { + let game = state; + const stop = (g: Game) => until(g) || !!g.biddingEnd || (g.phase === 5 && clean(g)); + for (let n = 0; !until(game) && n < 1600; n++) { + assert.notEqual(game.status, 'finished'); + const end = game.biddingEnd; + const seat = end && clean(game) ? end.owners.find(id => !end.ready.includes(id)) : undefined; + if (seat) game = applySemutaHomeworldChoamStep(game, seat, + { type: 'biddingEnd', event: end!.event, mode: 'ready' }, actions); + else if (game.decision?.kind === 'caladanReinforcement') + game = applySemutaHomeworldChoamStep(game, game.decision.player, + { type: 'decision', event: game.decision.event, decline: true }, actions); + else if (game.phase === 5 && clean(game)) + game = applySemutaHomeworldChoamStep(game, game.active!, { type: 'endMovement' }, actions); + else game = advanceSemutaHomeworldChoam(game, + g => stop(g) || g.decision?.kind === 'caladanReinforcement', actions); + } + assert.ok(until(game), 'Native continuation must reach the requested boundary'); + return game; +} + +/** Real shared preparation: podium, readiness, optional powers, pre-leader close. */ +export function prepareSemutaHomeworldBattleSapho(state: Game, actions?: RicheseSkillsTechStep[]): Game { + return advanceSemutaHomeworldChoam(state, + g => !!g.battle?.preLeader?.closed && !g.battle.preparation && clean(g), actions); +} + +/** Genuine native first turn; only conserved physical hands, unplayed Spice order + * and the real Caladan decline are controlled. Every force, wallet and battle + * control is native. Order-last uses the genuine Emperor as the conserved Sapho + * owner, whose position can legally move behind CHOAM. */ +export function createSemutaHomeworldBattleSaphoFixture( + scope: 'aggressor' | 'order', mode: 'first' | 'last' = 'first', advanced = false, fullHolder = false, +): SemutaHomeworldBattleSaphoFixture { + const native = createSemutaHomeworldChoamSetup(advanced); + let game = native.game; + const setup = structuredClone(game), actions = native.actions; + const choam = native.owner, holder = native.holder, emperor = native.canceller, atreides = native.target; + const owner = scope === 'order' && mode === 'last' ? emperor : choam; + const staging = ['Conserved physical Semuta to Richese; use native spending-free end-of-Bidding readiness.']; + const blow = game.spiceDeck.find(c => 'territory' in c && c.territory === 'red_chasm'); + const other = game.spiceDeck.find(c => 'territory' in c && c !== blow); + assert.ok(blow && other); + for (const [index, card] of [blow, other].entries()) + game.spiceDeck.splice(index, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]); + staging.push('Position original Red Chasm eight-spice blow and another non-worm Spice Card.'); + const shipments: SemutaHomeworldBattleSaphoFixture['shipments'] = []; + const routes: readonly (readonly [string, number, string, number, number])[] = scope === 'order' + ? [[holder, 1, 'arrakeen', 10, 1], [emperor, 1, 'red_chasm', 7, 2], [choam, 4, 'red_chasm', 7, 8]] + : [[emperor, 1, 'red_chasm', 7, 2], [choam, 4, 'red_chasm', 7, 8]]; + for (const [actor, amount, territory, sector, price] of routes) { + game = advance(game, g => g.phase === 5 && g.active === actor && clean(g), actions); + const sources = nativeShipmentSources(viewGame(game, actor), amount, 0); + assert.ok(sources); + const action: Action = { type: 'ship', territory, sector, amount, elite: 0, + homeworldSources: sources, allyPayment: 0 }; + const before = structuredClone(game); + game = applySemutaHomeworldChoamStep(game, actor, action, actions); + game = advance(game, clean, actions); + assert.equal(player(game, actor).spice, player(before, actor).spice - price); + assert.equal(player(game, actor).reserves, player(before, actor).reserves - amount); + shipments.push({ actor, action, before, after: structuredClone(game), price }); + game = applySemutaHomeworldChoamStep(game, actor, { type: 'endMovement' }, actions); + } + game = advance(game, g => g.phase === 6 && clean(g), actions); + const beforeBattle = structuredClone(game); + let completedPrefix: SemutaHomeworldBattleSaphoFixture['completedPrefix']; + if (scope === 'order') { + assert.equal(game.active, holder); + const start = actions.length, before = structuredClone(game); + game = applySemutaHomeworldChoamStep(game, holder, + { type: 'chooseBattle', territory: 'arrakeen', target: atreides }, actions); + const event = game.battle!.event!; + game = prepareSemutaHomeworldBattleSapho(game, actions); + const richeseLeader = player(game, holder).leaders.filter(l => !l.dead && !l.usedAt) + .sort((a, b) => a.strength - b.strength)[0]; + const atreidesLeader = player(game, atreides).leaders.filter(l => !l.dead && !l.usedAt) + .sort((a, b) => b.strength - a.strength)[0]; + assert.ok(richeseLeader && atreidesLeader && atreidesLeader.strength > richeseLeader.strength); + for (const [actor, leader] of [[holder, richeseLeader.id], [atreides, atreidesLeader.id]]) + game = applySemutaHomeworldChoamStep(game, actor, + { type: 'battlePlan', dial: 0, support: 0, leader }, actions); + game = advance(game, g => g.phase === 6 && !g.battle && clean(g), actions); + const decline = actions.slice(start).find(step => + step.action.type === 'decision' && 'decline' in step.action && step.action.decline === true && + 'event' in step.action && typeof step.action.event === 'string'); + assert.ok(decline && decline.action.type === 'decision' && 'event' in decline.action && + typeof decline.action.event === 'string', 'The real Caladan decline must be recorded'); + const reinforcementEvent: string = decline.action.event; + assert.equal(player(game, holder).tanks, 1); + assert.equal(player(game, holder).forces['arrakeen:10'] ?? 0, 0); + assert.equal(player(game, atreides).forces['arrakeen:10'], 10); + assert.equal(player(game, atreides).reserves, player(before, atreides).reserves); + completedPrefix = { before, after: structuredClone(game), event, + actions: actions.slice(start), richeseLeader: richeseLeader.id, + atreidesLeader: atreidesLeader.id, reinforcementEvent }; + } + const card = holdSemutaHomeworldChoamCard(game, owner, 'Juice of Sapho'); + staging.push('Conserve canonical Juice of Sapho to its genuine owner before the next clean opportunity.'); + if (fullHolder) { + while (player(game, holder).hand.length < handLimit(player(game, holder))) { + const next = game.deck.find(c => c.id !== SEMUTA_DRUG_ID); + assert.ok(next); + moveSemutaEmperorCard(game, next.id, holder); + } + staging.push('Conserve undealt original cards to fill the native Richese hand limit.'); + } + if (scope === 'aggressor') { + assert.equal(game.active, emperor); + game = applySemutaHomeworldChoamStep(game, emperor, + { type: 'chooseBattle', territory: 'red_chasm', target: choam }, actions); + if (game.decision?.kind === 'fullPlanOffer') + game = applySemutaHomeworldChoamStep(game, game.decision.player, + { type: 'decision', decline: true }, actions); + assert.equal(game.battle!.attacker, emperor); + assert.equal(game.battle!.defender, choam); + assert.equal(game.battle!.chooser, emperor); + assert.equal(game.battle!.preLeader!.closed, false); + assert.equal(game.battle!.preLeader!.ready.length, 0); + assert.ok(!game.battle!.preparation); + } + for (const seat of game.players) assert.ok(seat.hand.length <= handLimit(seat)); + const option = viewGame(game, owner).saphoOptions.find(o => scope === 'aggressor' + ? o.scope === 'battleAggressor' : o.scope === 'battleOrder' && o.mode === mode); + assert.ok(option, 'The original native game exposes the exact legal Sapho option'); + const action: Action = { type: 'card', card, ...option }; + return { scope, mode, advanced, setup, beforeBattle, game, choam, holder, emperor, atreides, + owner, actor: owner, action, card, location: 'red_chasm:7', actions, staging, shipments, completedPrefix }; +} + +export function disposeSemutaHomeworldBattleSapho(f: SemutaHomeworldBattleSaphoFixture, + state = f.game, actions?: RicheseSkillsTechStep[]): Game { + return applySemutaHomeworldChoamStep(state, f.actor, f.action, actions); +} + +/** Real next-chooser selection, shared preparation, zero-dial equal living + * leaders, traitor passes and cleanup, then the one native Collection. */ +export function finishSemutaHomeworldBattleSapho(state: Game, f: SemutaHomeworldBattleSaphoFixture, + actions: RicheseSkillsTechStep[] = []) { + let game = state; + if (game.pendingTreacheryDiscard) + for (const seat of game.players) { + if (!game.pendingTreacheryDiscard) break; + game = applySemutaHomeworldChoamStep(game, seat.id, + { type: 'semutaPass', event: game.pendingTreacheryDiscard.batch.event }, actions); + } + const chooser = game.battle?.chooser ?? game.active; + assert.ok(chooser, 'The original native actor retains the next battle choice'); + let attacker = game.battle?.attacker, defender = game.battle?.defender, target: string | null = null; + if (!game.battle) { + const choice = viewGame(game, chooser).battleChoices.find(c => c.chooser === chooser); + assert.ok(choice, 'The native next chooser retains a real physical battle option'); + target = choice.attacker === chooser ? choice.defender : choice.attacker; + attacker = choice.attacker; + defender = choice.defender; + game = applySemutaHomeworldChoamStep(game, chooser, + { type: 'chooseBattle', territory: choice.territory, target }, actions); + } + const opened = structuredClone(game); + game = prepareSemutaHomeworldBattleSapho(game, actions); + const [first, second] = [attacker!, defender!]; + const strength = player(game, first).leaders.find(l => !l.dead && !l.usedAt && + player(game, second).leaders.some(o => !o.dead && !o.usedAt && o.strength === l.strength))!.strength; + for (const id of [first, second]) { + const leader = player(game, id).leaders.find(l => !l.dead && !l.usedAt && l.strength === strength)!; + game = applySemutaHomeworldChoamStep(game, id, + { type: 'battlePlan', leader: leader.id, dial: 0, support: 0 }, actions); + } + const revealed = structuredClone(game); + game = advance(game, g => !g.battle && clean(g), actions); + const afterBattle = structuredClone(game); + game = advance(game, g => g.phase === 7 && clean(g), actions); + assert.equal(game.turn, state.turn); + return { game, opened, revealed, afterBattle, chooser, attacker, defender, target }; +} diff --git a/tests/fixture-semuta-homeworld-residual-poison.ts b/tests/fixture-semuta-homeworld-residual-poison.ts new file mode 100644 index 00000000..14e8c6f1 --- /dev/null +++ b/tests/fixture-semuta-homeworld-residual-poison.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import { handLimit, viewGame, type Action, type Game } from '../game/engine'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { semutaEmperorPlayer as player, moveSemutaEmperorCard } from './fixture-semuta-emperor-share'; +import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep, + createSemutaHomeworldChoamSetup, holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam'; +import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech'; + +export type SemutaHomeworldResidualPoisonFixture = { + setup: Game; beforeBattle: Game; game: Game; owner: string; holder: string; target: string; + actor: string; chooser: string; card: string; action: Action; event: string; location: string; + projectile: string; shield: string; worthless: string; + actions: RicheseSkillsTechStep[]; staging: string[]; + shipments: { actor: string; action: Action; before: Game; after: Game }[]; +}; +const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard; + +/** Original spending-free end-of-Bidding readiness, not incidental paid Kaitain + * disposal selected by a generic continuation policy. */ +function advance(state: Game, until: (g: Game) => boolean, actions: RicheseSkillsTechStep[]): Game { + let game = state; + for (let n = 0; !until(game) && n < 1600; n++) { + const end = game.biddingEnd; + const actor = end && clean(game) ? end.owners.find(id => !end.ready.includes(id)) : undefined; + if (actor) game = applySemutaHomeworldChoamStep(game, actor, + { type: 'biddingEnd', event: end!.event, mode: 'ready' }, actions); + else game = advanceSemutaHomeworldChoam(game, g => until(g) || !!g.biddingEnd, actions); + } + assert.ok(until(game)); + return game; +} + +/** Native first-turn paid C4/E1 deployments. Only original physical cards and + * two unplayed non-worm Spice Cards are positioned; no leader is manufactured. */ +export function createSemutaHomeworldResidualPoisonFixture( + advanced = false, fullHolder = false, +): SemutaHomeworldResidualPoisonFixture { + const native = createSemutaHomeworldChoamSetup(advanced); + let game = native.game; + const setup = structuredClone(game), actions = native.actions; + const owner = native.owner, holder = native.holder, target = native.canceller; + const location = 'red_chasm:7'; + const staging = ['Conserved original Semuta in Richese hand.', + 'Each entitled seat answers original end-of-Bidding with its spending-free ready mode.']; + const blow = game.spiceDeck.find(c => 'territory' in c && c.territory === 'red_chasm'); + const other = game.spiceDeck.find(c => 'territory' in c && c !== blow); + assert.ok(blow && other); + for (const [position, card] of [blow, other].entries()) + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]); + staging.push('Position original eight-spice Red Chasm blow and another original non-worm Spice Card.'); + const shipments: SemutaHomeworldResidualPoisonFixture['shipments'] = []; + for (const [actor, amount] of [[target, 1], [owner, 4]] as const) { + game = advance(game, g => g.phase === 5 && g.active === actor && clean(g), actions); + assert.equal(game.turn, 1); + const sources = nativeShipmentSources(viewGame(game, actor), amount, 0); + assert.ok(sources); + const action: Action = { type: 'ship', territory: 'red_chasm', sector: 7, + amount, elite: 0, homeworldSources: sources, allyPayment: 0 }; + const before = structuredClone(game); + game = applySemutaHomeworldChoamStep(game, actor, action, actions); + game = advance(game, clean, actions); + assert.equal(player(game, actor).spice, player(before, actor).spice - 2 * amount); + assert.equal(player(game, actor).reserves, player(before, actor).reserves - amount); + shipments.push({ actor, action, before, after: structuredClone(game) }); + game = applySemutaHomeworldChoamStep(game, actor, { type: 'endMovement' }, actions); + } + game = advance(game, g => g.phase === 6 && clean(g), actions); + const cached = game.richeseCache?.find(c => c.effect === 'residualPoison'); + assert.ok(cached, 'Original Richese cache must supply the canonical physical Poison'); + const card = moveSemutaEmperorCard(game, cached.id, owner); + const projectile = holdSemutaHomeworldChoamCard(game, owner, 'Crysknife'); + const shield = holdSemutaHomeworldChoamCard(game, owner, 'Shield'); + const worthless = holdSemutaHomeworldChoamCard(game, target, 'Baliset'); + staging.push('Conserve original Richese-cache Residual Poison, Crysknife and Shield to CHOAM; Baliset to Emperor.'); + if (fullHolder) { + while (player(game, holder).hand.length < handLimit(player(game, holder))) { + const next = game.deck.find(c => c.id !== SEMUTA_DRUG_ID); + assert.ok(next); + moveSemutaEmperorCard(game, next.id, holder); + } + staging.push('Conserved undealt cards fill Richese to its native four-card limit.'); + } + for (const seat of game.players) assert.ok(seat.hand.length <= handLimit(seat)); + const beforeBattle = structuredClone(game), chooser = game.active!; + assert.equal(chooser, target, 'Original Emperor actor chooses the native desert battle'); + game = applySemutaHomeworldChoamStep(game, chooser, + { type: 'chooseBattle', territory: 'red_chasm', target: owner }, actions); + game = advance(game, clean, actions); + const battle = game.battle!; + assert.equal(battle.preLeader?.closed, false); + assert.deepEqual(battle.preLeader.ready, []); + assert.deepEqual(battle.plans, {}); + assert.ok(!battle.preparation && !battle.prescience && !battle.nexusInspection); + const event = battle.event!; + const action: Action = { type: 'card', card, target, event }; + return { setup, beforeBattle, game, owner, holder, target, actor: owner, chooser, + card, action, event, location, projectile, shield, worthless, actions, staging, shipments }; +} + +export function disposeSemutaHomeworldResidualPoison(f: SemutaHomeworldResidualPoisonFixture, + state = f.game, actions?: RicheseSkillsTechStep[]): Game { + return applySemutaHomeworldChoamStep(state, f.actor, f.action, actions); +} + +/** Finish the same battle with currently living native discs and actual supported + * plans, then retain played winner cards and reach original native Collection. */ +export function finishSemutaHomeworldResidualPoison(f: SemutaHomeworldResidualPoisonFixture, + state: Game, actions: RicheseSkillsTechStep[] = []) { + let game = advance(state, g => !!g.battle?.preLeader?.closed && clean(g), actions); + assert.equal(game.battle?.event, f.event); + const beforePlans = structuredClone(game); + const winnerLeader = player(game, f.owner).leaders.find(l => !l.dead && (!l.usedAt || l.usedAt === 'red_chasm')); + const loserLeader = player(game, f.target).leaders.find(l => !l.dead && (!l.usedAt || l.usedAt === 'red_chasm')); + assert.ok(winnerLeader && loserLeader); + const plans: RicheseSkillsTechStep[] = [ + { actor: f.owner, action: { type: 'battlePlan', dial: 1, support: game.advanced ? 1 : 0, + leader: winnerLeader.id, weapon: f.projectile, defense: f.shield } }, + { actor: f.target, action: { type: 'battlePlan', dial: 0, support: 0, + leader: loserLeader.id, weapon: f.worthless } }, + ]; + for (const plan of plans) game = applySemutaHomeworldChoamStep(game, plan.actor, plan.action, actions); + game = advance(game, g => g.decision?.kind === 'battleCards', actions); + const afterBattle = structuredClone(game); + assert.equal(game.decision?.player, f.owner); + game = applySemutaHomeworldChoamStep(game, f.owner, { type: 'decision', discard: [] }, actions); + const beforeCollection = advance(game, g => g.decision?.kind === 'choamMarket', actions); + game = advance(beforeCollection, g => g.phase === 7 && clean(g), actions); + assert.equal(game.turn, state.turn); + return { game, beforePlans, afterBattle, beforeCollection, plans, + winnerLeader: winnerLeader.id, loserLeader: loserLeader.id }; +} diff --git a/tests/semuta-homeworld-battle-sapho.test.ts b/tests/semuta-homeworld-battle-sapho.test.ts new file mode 100644 index 00000000..17ceed41 --- /dev/null +++ b/tests/semuta-homeworld-battle-sapho.test.ts @@ -0,0 +1,271 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, handLimit, viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam'; +import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share'; +import { withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement'; +import { + createSemutaHomeworldBattleSaphoFixture, disposeSemutaHomeworldBattleSapho, + finishSemutaHomeworldBattleSapho, +} from './fixture-semuta-homeworld-battle-sapho'; + +const combos = [ + { scope: 'aggressor', mode: 'first' }, + { scope: 'order', mode: 'first' }, + { scope: 'order', mode: 'last' }, +] as const; + +/** Only original force, wallet, leader and tank custody — never the committed + * Sapho card, its discard or the scheduling receipt, which the action changes. */ +function custody(game: Game) { + return game.players.map(seat => ({ id: seat.id, spice: seat.spice, reserves: seat.reserves, + tanks: seat.tanks, forces: structuredClone(seat.forces), elites: structuredClone(seat.elites), + leaders: structuredClone(seat.leaders) })); +} + +function offerEvent(game: Game): string { + const pending = game.pendingTreacheryDiscard; + assert.ok(pending, 'A fresh public disposal offer must be open'); + assert.equal(pending.reaction?.stage, 'offer'); + return pending.batch.event; +} + +function passOffer(state: Game): Game { + let game = state; + if (!game.pendingTreacheryDiscard) return game; + const event = offerEvent(game); + for (const seat of state.players) { + if (!game.pendingTreacheryDiscard) break; + const reaction = game.pendingTreacheryDiscard.reaction; + assert.ok(reaction?.stage === 'offer'); + if (!reaction.passed.includes(seat.id)) + game = applyAction(game, seat.id, { type: 'semutaPass', event }); + } + return game; +} + +for (const advanced of [false, true]) for (const combo of combos) { + const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld Sapho ${combo.scope} ${combo.mode}`; + + void test(`${label}: actual paid prefix disposes the exact card and commits its real effect`, () => { + const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced); + for (const shipment of f.shipments) { + const amount = shipment.actor === f.choam ? 4 : 1; + const price = shipment.actor === f.holder ? 1 : 2 * amount; + const destination = shipment.actor === f.holder ? 'arrakeen:10' : f.location; + assert.equal(player(shipment.after, shipment.actor).spice, + player(shipment.before, shipment.actor).spice - price); + assert.equal(player(shipment.after, shipment.actor).reserves, + player(shipment.before, shipment.actor).reserves - amount); + assert.equal(player(shipment.after, shipment.actor).forces[destination], amount); + assert.deepEqual(player(shipment.after, shipment.actor).elites, + player(shipment.before, shipment.actor).elites); + } + const pending = disposeSemutaHomeworldBattleSapho(f); + assert.equal(player(pending, f.owner).hand.some(card => card.id === f.card), false); + assert.equal(pending.discard.filter(card => card.id === f.card).length, 1); + assert.deepEqual(pending.order, f.game.order, 'Physical storm order is never rewritten'); + assert.deepEqual(custody(pending), custody(f.game), 'No paid prefix, leader or casualty moves'); + if (f.scope === 'aggressor') { + assert.equal(pending.battle!.saphoAggressor!.uses.at(-1)!.player, f.owner); + assert.deepEqual(pending.battleOrder, f.game.battleOrder, + 'Early aggression preserves original remaining chooser state'); + } else { + assert.ok(!pending.battle, 'A completed prefix does not create the next battle'); + assert.equal(pending.battleOrder!.uses.length, 1); + assert.equal(pending.battleOrder!.uses[0].mode, f.mode); + assert.equal(pending.battleOrder!.uses[0].player, f.owner); + assert.deepEqual([...pending.battleOrder!.priority].sort(), [...pending.order].sort()); + assert.equal(player(pending, f.holder).tanks, 1, 'The original losing prefix force stays destroyed'); + assert.equal(player(pending, f.holder).forces['arrakeen:10'] ?? 0, 0); + assert.equal(player(pending, f.atreides).forces['arrakeen:10'], 10, 'Atreides keeps its untouched ten'); + assert.equal(player(pending, f.atreides).reserves, player(f.completedPrefix!.before, f.atreides).reserves); + assert.equal(pending.lastBattleContext?.event, f.completedPrefix!.event); + assert.equal(pending.lastBattleContext?.winner, f.atreides); + assert.equal(pending.lastBattleContext?.territory, 'arrakeen'); + } + assert.deepEqual(pending.pendingTreacheryDiscard!.batch.entries.map(entry => entry.card.id), [f.card]); + assert.deepEqual(pending.pendingTreacheryDiscard!.batch.entries[0].publicFace, true); + }); + + void test(`${label}: the open offer exchanges exactly the physical card or stays with Semuta`, () => { + const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced); + const pending = disposeSemutaHomeworldBattleSapho(f), event = offerEvent(pending); + assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true); + for (const seat of pending.players.filter(seat => seat.id !== f.holder)) + assert.equal(viewGame(pending, seat.id).semutaReaction?.canCommit, false); + assert.throws(() => applyAction(pending, f.actor, f.action), 'The accepted disposal cannot replay'); + assert.throws(() => applyAction(pending, f.holder, + { type: 'semutaCommit', event: `${event}-retired` }), 'A stale offer event is rejected'); + assert.throws(() => applyAction(pending, f.choam, { type: 'ready' })); + const claimed = reactSemutaHomeworldChoam(structuredClone(pending), f.holder, true); + const passed = reactSemutaHomeworldChoam(structuredClone(pending), f.holder, false); + for (const done of [claimed, passed]) { + assert.equal(done.pendingTreacheryDiscard, null); + assert.deepEqual(custody(done), custody(pending), 'Semuta never repeats the paid prefix or casualties'); + assert.deepEqual(done.lastBattleContext, pending.lastBattleContext); + assert.deepEqual(done.order, pending.order); + } + assert.equal(player(claimed, f.holder).hand.filter(card => card.id === f.card).length, 1); + assert.equal(claimed.discard.filter(card => card.id === f.card).length, 0); + assert.equal(claimed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1); + assert.equal(player(passed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), true); + assert.equal(player(passed, f.holder).hand.some(card => card.id === f.card), false); + assert.equal(passed.discard.filter(card => card.id === f.card).length, 1); + for (const done of [claimed, passed]) + assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event })); + }); + + void test(`${label}: a full native hand exchanges atomically and entitlement stays private`, () => { + const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced, true); + assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder))); + const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder); + const pending = disposeSemutaHomeworldBattleSapho(f); + const neutral = disposeSemutaHomeworldBattleSapho(f, absent); + assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true); + assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false); + assert.throws(() => applyAction(neutral, f.holder, + { type: 'semutaCommit', event: neutral.pendingTreacheryDiscard!.batch.event })); + for (const seat of pending.players.filter(seat => seat.id !== f.holder)) + assert.deepEqual(JSON.parse(JSON.stringify(viewGame(pending, seat.id))), + JSON.parse(JSON.stringify(viewGame(neutral, seat.id))), + 'Nonholders cannot infer the private entitlement from consumer views'); + const claimed = reactSemutaHomeworldChoam(pending, f.holder, true); + assert.equal(player(claimed, f.holder).hand.length, handLimit(player(claimed, f.holder))); + assert.equal(player(claimed, f.holder).hand.some(card => card.id === f.card), true); + assert.equal(player(claimed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), false); + assert.deepEqual(custody(claimed), custody(pending)); + }); + + void test(`${label}: the real next chooser finishes one battle and one native Collection`, () => { + const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced); + const done = passOffer(disposeSemutaHomeworldBattleSapho(f)); + assert.deepEqual(done.order, f.game.order); + const result = finishSemutaHomeworldBattleSapho(done, f); + assert.equal(result.game.turn, 1); + assert.equal(result.game.phase, 7); + assert.ok(!result.game.battle && !result.game.decision && !result.game.response && + !result.game.pendingTreacheryDiscard); + assert.deepEqual(result.game.order, f.game.order, 'The finished phase keeps its physical circle'); + assert.equal(result.game.lastBattleContext?.result, 'normal'); + assert.equal(result.game.lastBattleContext?.territory, 'red_chasm'); + if (f.scope === 'aggressor') { + assert.equal(result.attacker, f.emperor); + assert.equal(result.defender, f.choam); + assert.equal(result.game.lastBattleContext?.winner, f.owner, 'The recorded aggressor takes the tie'); + } else { + assert.equal(result.attacker, f.emperor); + assert.equal(result.defender, f.choam); + assert.equal(result.target, result.attacker, 'The chooser names the actual opposing physical slot'); + assert.equal(result.game.lastBattleContext?.winner, f.emperor); + if (combo.mode === 'first') + assert.equal(result.chooser, f.owner, 'The first position makes the physical defender C the chooser'); + else { + assert.equal(result.chooser, f.choam, 'A last position immunizes nobody from the current chooser'); + assert.equal(done.battleOrder!.priority.at(-1), f.owner, + 'The Emperor really holds the accepted last chooser position'); + } + } + const victor = f.scope === 'aggressor' ? f.owner : f.emperor; + const loser = f.scope === 'aggressor' ? f.emperor : f.choam; + const committed = player(done, loser).forces['red_chasm:7'] ?? 0; + const survivors = player(result.game, victor).forces['red_chasm:7'] ?? 0; + assert.equal(survivors, player(done, victor).forces['red_chasm:7'], + 'The winner keeps exactly its untouched committed force'); + assert.equal(player(result.game, loser).forces['red_chasm:7'] ?? 0, 0); + assert.equal(player(result.game, loser).tanks, player(done, loser).tanks + committed, + 'Battle casualties destroy the loser force once'); + assert.equal(player(result.game, victor).tanks, player(done, victor).tanks); + assert.equal(result.game.spice['red_chasm:7'], 8 - 2 * survivors, + 'The original eight-spice blow is drained exactly once by its surviving force'); + assert.equal(player(result.game, victor).spice, player(done, victor).spice + 2 * survivors, + 'Only the survivor collection credits the winner; no phase or receipt repeats'); + for (const seat of result.game.players.filter(seat => seat.id !== victor)) + assert.equal(seat.forces['red_chasm:7'] ?? 0, 0); + assert.throws(() => applyAction(result.game, f.choam, { type: 'endMovement' })); + }); + + void test(`${label}: only legacy, stale or readiness-closed Sapho controls survive untouched`, () => { + const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced); + const scope = f.scope === 'aggressor' ? 'battleAggressor' : 'battleOrder'; + assert.equal(viewGame(f.game, f.owner).saphoOptions.find(option => option.scope === scope)?.mode, + f.scope === 'aggressor' ? 'aggressor' : f.mode); + const spent = applyAction(f.game, f.actor, f.action); + assert.deepEqual(viewGame(spent, f.owner).saphoOptions, [], 'One card cannot be spent twice'); + assert.throws(() => applyAction(spent, f.actor, f.action)); + assert.throws(() => applyAction(f.game, f.actor, { ...f.action, event: 'expired' })); + const wrong = f.scope === 'aggressor' ? 'last' : f.mode === 'first' ? 'last' : 'first'; + assert.throws(() => applyAction(f.game, f.owner, { ...f.action, mode: wrong }), + 'Only the one legal position at this boundary is accepted'); + if (f.scope === 'aggressor') + assert.deepEqual(viewGame(applyAction(f.game, f.owner, + { type: 'battlePreparationReady', event: f.game.battle!.event }), f.owner).saphoOptions, [], + 'The owner\'s own readiness closes the early pre-plan aggressor window'); + const other = f.game.players.find(seat => ![f.owner, f.holder].includes(seat.id))!.id; + assert.throws(() => applyAction(f.game, other, f.action)); + }); + + void test(`${label}: every minimal bot profile plays the real producer then the native battle`, () => { + for (const difficulty of DIFFICULTIES) { + const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced); + const own = viewGame(structuredClone(f.game), f.owner); + own.players.find(seat => seat.id === f.owner)!.bot = difficulty; + const action = botActions(own).find(proposal => proposal.card === f.card); + assert.ok(action, `${difficulty}: the owner keeps a real Sapho action`); + assert.equal(action.scope, f.scope === 'aggressor' ? 'battleAggressor' : 'battleOrder'); + let game = applyAction(f.game, f.owner, action); + game = passOffer(game); + assert.ok(!game.pendingTreacheryDiscard); + const result = finishSemutaHomeworldBattleSapho(game, f); + assert.equal(result.game.phase, 7, `${difficulty}: the real continuation reaches Collection`); + assert.equal(result.game.turn, 1); + } + }); +} + +const orders = combos.filter(combo => combo.scope === 'order'); +for (const advanced of [false, true]) for (const combo of orders) { + const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld Sapho order ${combo.mode}`; + void test(`${label}: finite remaining queue and the exact accepted position, no extra battle`, () => { + const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced); + const pending = disposeSemutaHomeworldBattleSapho(f); + const order = pending.battleOrder!; + const priority = order.priority; + assert.equal(new Set(priority).size, pending.order.length); + assert.deepEqual([...priority].sort(), [...pending.order].sort()); + if (combo.mode === 'first') assert.equal(priority.indexOf(f.owner), 0); + else assert.equal(priority.indexOf(f.owner), priority.length - 1); + const view = viewGame(pending, f.owner).battleOrder; + assert.ok(view); + assert.deepEqual(view.remaining, [f.choam, f.emperor], + 'Only the two remaining physical combatants retain one chooser opportunity'); + assert.equal(view.uses.length, 1); + assert.ok(!pending.battle); + assert.deepEqual(pending.order, f.game.order); + }); +} + +for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} early aggressor retains the opponent's completed readiness through Semuta`, () => { + const f = createSemutaHomeworldBattleSaphoFixture('aggressor', 'first', advanced, true); + const event = f.game.battle!.event!; + const ready = applyAction(f.game, f.emperor, { type: 'battlePreparationReady', event }); + const pending = disposeSemutaHomeworldBattleSapho(f, ready); + assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'saphoAggressorDiscard'); + const done = reactSemutaHomeworldChoam(pending, f.holder, true); + assert.deepEqual(done.battle!.preLeader!.ready, [f.emperor]); + assert.deepEqual(custody(done), custody(ready)); + assert.equal(done.battle!.attacker, f.emperor); + assert.equal(done.battle!.defender, f.choam); + assert.equal(done.battle!.chooser, f.emperor); + assert.equal(done.battle!.saphoAggressor!.uses.at(-1)!.player, f.choam); + const prepared = applyAction(done, f.choam, { type: 'battlePreparationReady', event }); + assert.equal(prepared.battle!.preLeader!.closed, true); + const result = finishSemutaHomeworldBattleSapho(prepared, f); + assert.equal(result.game.lastBattleContext!.winner, f.choam); + assert.equal(player(result.game, f.choam).forces[f.location], 4); + assert.equal(player(result.game, f.emperor).tanks, 1); + assert.equal(result.game.phase, 7); +}); diff --git a/tests/semuta-homeworld-residual-poison.test.ts b/tests/semuta-homeworld-residual-poison.test.ts new file mode 100644 index 00000000..0233d5f4 --- /dev/null +++ b/tests/semuta-homeworld-residual-poison.test.ts @@ -0,0 +1,219 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { quoteSpiceCollection } from '../game/board-resolution-quote'; +import { residualPoisonCandidates, residualPoisonDeath } from '../game/residual-poison'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share'; +import { reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam'; +import { withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement'; +import { semutaHomeworldWinnerResources as resources, semutaHomeworldWinnerStock as stock } from './fixture-semuta-homeworld-battle-winner'; +import { createSemutaHomeworldResidualPoisonFixture, disposeSemutaHomeworldResidualPoison, + finishSemutaHomeworldResidualPoison, type SemutaHomeworldResidualPoisonFixture } from './fixture-semuta-homeworld-residual-poison'; + +function rejectUnchanged(game: Game, actor: string, action: Action) { + const before = JSON.stringify(game); + assert.throws(() => applyAction(game, actor, action)); + assert.equal(JSON.stringify(game), before, 'Rejected actions preserve the original physical game'); +} + +function assertNativePoison(f: SemutaHomeworldResidualPoisonFixture, game: Game, before = f.game) { + const prior = player(before, f.target).leaders; + const changed = player(game, f.target).leaders.filter(l => + JSON.stringify(l) !== JSON.stringify(prior.find(old => old.id === l.id))); + assert.equal(changed.length, 1, 'Exactly one original Emperor disc changes at the Poison cost boundary'); + const victim = changed[0]; + assert.ok(residualPoisonCandidates(prior, 'red_chasm').some(l => l.id === victim.id)); + assert.deepEqual(victim, residualPoisonDeath(prior.find(l => l.id === victim.id)!)); + const expected = resources(before); + expected.players.find(p => p.id === f.target)!.leaders = prior.map(l => + l.id === victim.id ? residualPoisonDeath(l) : l); + assert.deepEqual(resources(game), expected, + 'Poison awards no spice, loses no counters, and leaves every other leader/custody/resource unchanged'); + assert.equal(player(game, f.owner).forces[f.location], 4); + assert.equal(player(game, f.target).forces[f.location], 1); + assert.equal(player(game, f.owner).hand.some(c => c.id === f.card), false); + assert.equal(game.discard.filter(c => c.id === f.card).length, 1); + assert.equal(game.battle?.event, f.event); + assert.deepEqual(game.battle?.plans, {}); + assert.deepEqual(stock(game), stock(f.setup)); + return victim.id; +} + +function assertExchange(f: SemutaHomeworldResidualPoisonFixture, pending: Game, game: Game, + claim: boolean, holderHasSemuta = true) { + assert.ok(!game.pendingTreacheryDiscard); + assert.deepEqual(resources(game), resources(pending), 'Resume neither redraws nor repeats the committed native death'); + assert.deepEqual(game.battle, pending.battle, 'The original shared preparation event and readiness resume unchanged'); + assert.equal(player(game, f.holder).hand.filter(c => c.id === f.card).length, Number(claim)); + assert.equal(game.discard.filter(c => c.id === f.card).length, Number(!claim)); + if (holderHasSemuta) { + assert.equal(game.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, Number(claim)); + assert.equal(player(game, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), !claim); + } else + assert.equal(player(game, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), false, + 'The absent-holder fork never returns the Semuta Drug to this seat'); + for (const seat of game.players) { + const expected = player(pending, seat.id).hand.map(c => c.id) + .filter(id => seat.id !== f.holder || !claim || id !== SEMUTA_DRUG_ID); + if (seat.id === f.holder && claim) expected.push(f.card); + assert.deepEqual(seat.hand.map(c => c.id).sort(), expected.sort()); + assert.ok(seat.hand.length <= handLimit(seat)); + } + assert.deepEqual(stock(game), stock(f.setup)); +} + +function finishOnce(f: SemutaHomeworldResidualPoisonFixture, state: Game, victim: string) { + const acceptedVictim = structuredClone(player(state, f.target).leaders.find(l => l.id === victim)); + const finished = finishSemutaHomeworldResidualPoison(f, state); + const { beforePlans, afterBattle, beforeCollection, game } = finished; + assert.equal(beforePlans.battle?.event, f.event); + assert.equal(beforePlans.battle?.preLeader?.closed, true); + assert.deepEqual(beforePlans.battle?.preLeader?.ready.slice().sort(), [f.owner, f.target].sort()); + assert.notEqual(finished.loserLeader, victim, 'Future native plans select a currently living Emperor disc, never the random victim'); + assert.equal(player(beforePlans, f.target).leaders.find(l => l.id === finished.loserLeader)?.dead, false); + assert.equal(afterBattle.lastBattleContext?.winner, f.owner); + assert.equal(afterBattle.lastBattleContext?.result, 'normal'); + assert.equal(afterBattle.lastBattleContext?.territory, 'red_chasm'); + assert.ok(!afterBattle.battle); + assert.equal(player(afterBattle, f.owner).forces[f.location], 3); + assert.equal(player(afterBattle, f.owner).reserves, 16); + assert.equal(player(afterBattle, f.owner).tanks, player(beforePlans, f.owner).tanks + 1); + assert.equal(player(afterBattle, f.owner).battleLosses, player(beforePlans, f.owner).battleLosses + 1); + assert.equal(player(afterBattle, f.target).forces[f.location] ?? 0, 0); + assert.equal(player(afterBattle, f.target).reserves, 19); + assert.equal(player(afterBattle, f.target).tanks, player(beforePlans, f.target).tanks + 1); + const combatLeader = player(beforePlans, f.target).leaders.find(l => l.id === finished.loserLeader)!; + assert.equal(player(afterBattle, f.target).leaders.find(l => l.id === combatLeader.id)?.dead, true); + assert.equal(player(afterBattle, f.target).leaders.find(l => l.id === combatLeader.id)?.deaths, combatLeader.deaths + 1); + assert.deepEqual(player(afterBattle, f.target).leaders.find(l => l.id === victim), acceptedVictim); + assert.equal(player(afterBattle, f.owner).spice, + player(beforePlans, f.owner).spice - Number(game.advanced) + combatLeader.strength, + 'Only actual supported dial cost and the distinct killed combat leader produce battle spice'); + assert.equal(player(afterBattle, f.target).spice, player(beforePlans, f.target).spice); + assert.deepEqual(afterBattle.homeworlds?.custody, f.shipments[1].after.homeworlds?.custody); + const collection = quoteSpiceCollection(beforeCollection), expected = resources(beforeCollection); + assert.equal(collection.receipts.find(r => r.player === f.owner)?.desert, 6, + 'Three surviving counters collect six from the original eight-spice Red Chasm blow'); + for (const receipt of collection.receipts) expected.players.find(p => p.id === receipt.player)!.spice = receipt.balance; + assert.deepEqual(resources(game), expected, 'Native Collection credits legitimate board/stronghold income once'); + assert.deepEqual(game.spice, collection.spice); + assert.deepEqual(game.lastBattleContext, afterBattle.lastBattleContext); + assert.deepEqual(player(game, f.target).leaders.find(l => l.id === victim), acceptedVictim); + assert.deepEqual(stock(game), stock(f.setup)); + assert.equal(game.phase, 7); + assert.equal(game.turn, 1); + assert.ok(!game.response && !game.decision && !game.pendingTreacheryDiscard); + rejectUnchanged(game, f.owner, { type: 'decision', done: true }); +} + +for (const advanced of [false, true]) { + const label = `${advanced ? 'Advanced' : 'Basic'} paid Homeworld Residual Poison`; + for (const full of [false, true]) for (const claim of [false, true]) + void test(`${label}: ${full ? 'full four-card' : 'thin'} hand ${claim ? 'claim' : 'all-pass'} preserves native death and future battle`, () => { + const f = createSemutaHomeworldResidualPoisonFixture(advanced, full); + assert.equal(f.chooser, f.target); + assert.equal(viewGame(f.game, f.owner).residualPoison?.blocked, null); + assert.equal(viewGame(f.game, f.owner).residualPoison?.target, f.target); + assert.equal(player(f.game, f.holder).hand.length === 4, full); + for (const shipment of f.shipments) { + const amount = shipment.actor === f.target ? 1 : 4; + assert.equal(player(shipment.after, shipment.actor).spice, player(shipment.before, shipment.actor).spice - 2 * amount); + assert.equal(player(shipment.after, shipment.actor).reserves, player(shipment.before, shipment.actor).reserves - amount); + assert.equal(player(shipment.after, shipment.actor).forces[f.location], amount); + assert.deepEqual(player(shipment.after, shipment.actor).elites, player(shipment.before, shipment.actor).elites); + } + rejectUnchanged(f.game, f.holder, f.action); + rejectUnchanged(f.game, f.owner, { ...f.action, target: f.holder }); + rejectUnchanged(f.game, f.owner, { ...f.action, event: `${f.event}-stale` }); + rejectUnchanged(f.beforeBattle, f.owner, f.action); + const pending = disposeSemutaHomeworldResidualPoison(f); + const victim = assertNativePoison(f, pending); + const event = pending.pendingTreacheryDiscard!.batch.event; + assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'residualPoisonDiscard'); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.deepEqual(pending.pendingTreacheryDiscard?.batch.entries.map(e => e.card.id), [f.card]); + assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true); + rejectUnchanged(pending, f.owner, f.action); + rejectUnchanged(pending, f.target, { type: 'battlePreparationReady', event: f.event }); + rejectUnchanged(pending, f.target, { type: 'semutaCommit', event }); + rejectUnchanged(pending, f.holder, { type: 'semutaCommit', event: `${event}-stale` }); + rejectUnchanged(pending, f.holder, { type: 'semutaSelect', event, card: f.shield }); + const acknowledged = applyAction(pending, f.target, { type: 'semutaPass', event }); + assert.deepEqual(resources(acknowledged), resources(pending)); + rejectUnchanged(acknowledged, f.target, { type: 'semutaPass', event }); + const done = reactSemutaHomeworldChoam(JSON.parse(JSON.stringify(pending)) as Game, f.holder, claim); + assertExchange(f, pending, done, claim); + assert.deepEqual(player(done, f.target).leaders.find(l => l.id === victim), player(pending, f.target).leaders.find(l => l.id === victim)); + rejectUnchanged(done, f.holder, { type: 'semutaCommit', event }); + rejectUnchanged(done, f.holder, { type: 'semutaPass', event }); + rejectUnchanged(done, f.holder, { type: 'semutaSelect', event, card: f.card }); + rejectUnchanged(done, f.target, { type: 'battlePreparationReady', event: `${f.event}-stale` }); + const ready = applyAction(done, f.target, { type: 'battlePreparationReady', event: f.event }); + assert.deepEqual(ready.battle?.preLeader?.ready, [f.target]); + assert.deepEqual(resources(ready), resources(done)); + finishOnce(f, ready, victim); + }); + + void test(`${label}: full-hand entitlement is private and absent-holder pass changes no death or resource`, () => { + const f = createSemutaHomeworldResidualPoisonFixture(advanced, true); + const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder); + const pending = disposeSemutaHomeworldResidualPoison(f); + const neutral = disposeSemutaHomeworldResidualPoison(f, absent); + assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true); + assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false); + for (const seat of pending.players.filter(p => p.id !== f.holder)) { + assert.deepEqual(JSON.parse(JSON.stringify(viewGame(f.game, seat.id))), + JSON.parse(JSON.stringify(viewGame(absent, seat.id))), + 'The conserved private Semuta position is invisible to other seats'); + // Native programs can select different victims. Compare neutral offer + // entitlement separately from its opaque event. + const { event: _heldEvent, ...heldOffer } = viewGame(pending, seat.id).semutaReaction!; + const { event: _absentEvent, ...absentOffer } = viewGame(neutral, seat.id).semutaReaction!; + assert.equal(heldOffer.canCommit, false); + assert.deepEqual(heldOffer, absentOffer); + } + const victim = assertNativePoison(f, neutral, absent); + const done = reactSemutaHomeworldChoam(JSON.parse(JSON.stringify(neutral)) as Game, f.holder, false); + assertExchange(f, neutral, done, false, false); + finishOnce(f, done, victim); + }); + + void test(`${label}: actual opponent readiness permits native Poison but excludes Semuta's clean producer`, () => { + const f = createSemutaHomeworldResidualPoisonFixture(advanced); + const ready = applyAction(f.game, f.target, { type: 'battlePreparationReady', event: f.event }); + assert.deepEqual(ready.battle?.preLeader?.ready, [f.target]); + assert.equal(viewGame(ready, f.owner).residualPoison?.blocked, null); + const done = disposeSemutaHomeworldResidualPoison(f, ready); + const victim = assertNativePoison(f, done, ready); + assert.ok(!done.pendingTreacheryDiscard); + assert.ok(!viewGame(done, f.holder).semutaReaction); + assert.deepEqual(done.battle?.preLeader?.ready, [f.target]); + finishOnce(f, done, victim); + }); + + for (const difficulty of DIFFICULTIES) void test(`${difficulty}: legal minimum ${label} consumer and original future plans`, () => { + const f = createSemutaHomeworldResidualPoisonFixture(advanced); + const pending = disposeSemutaHomeworldResidualPoison(f); + const victim = assertNativePoison(f, pending); + let game = pending; + const own = viewGame(game, f.holder); + own.players.find(p => p.id === f.holder)!.bot = difficulty; + const action = botActions(own)[0]; + assert.ok(action && (action.type === 'semutaCommit' || action.type === 'semutaPass')); + game = applyAction(game, f.holder, action); + for (const seat of pending.players) { + if (!game.pendingTreacheryDiscard) break; + const view = viewGame(game, seat.id); + if (!view.semutaReaction || view.semutaReaction.passed) continue; + view.players.find(p => p.id === seat.id)!.bot = difficulty; + const pass = botActions(view)[0]; + assert.equal(pass?.type, 'semutaPass'); + game = applyAction(game, seat.id, pass); + } + assertExchange(f, pending, game, action.type === 'semutaCommit'); + finishOnce(f, game, victim); + }); +} diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 1e4db699..65586ebc 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -30,7 +30,7 @@ async function main() { '\nSemuta with CHOAM and optional Ix preserves original unused Tech Tokens in fresh 3–6-seat Basic/Advanced Richese lobbies under existing native prerequisites. Advanced additionally retains original unused Stronghold Cards at2–6; both modules require3–6. Native setup, first-Storm Tech and final-Mentat Stronghold assignment remain original. Mandatory/winner discards react before the original winner takes one loser-owned token, before Face Dance. Closing Gamont reacts before the original CHOAM finish and changed-board claim. Optional original skills require --leader-skills as described below; other modules, the separate CHOAM/Ecaz Moritani entry, retrofit and public starts remain excluded.', '\n--profile semuta --leader-skills explicitly composes original all14 skills with fresh native Basic Richese/CHOAM and classics using exact CHOAM, or Advanced native E1/E2 with Richese and selected required CHOAM/optional Ix decks, at2–6 seats. Optional original Tech requires3+; Strongholds requires Advanced. Original private offers/training, Auditor exclusion, normal Mentat question, typed Suk rescue and Rihani inspection/exchange remain. Traitor/Face Dancer movement is not a Treachery discard. Basic Ix mixtures without their original Nexus envelope, E3/Moritani, Homeworld/Discovery/Banker overlays, public starts and played-game conversion are not opened.', '\n--profile semuta --nexus-cards retains original all12 Nexus in fresh native Richese CHOAM/optional Ix lobbies, optionally --leader-skills, original Tech3+ and Advanced Strongholds2+. Single/paired CHOAM or both selected E1/E2 native families retain original prerequisites. No starting Nexus hand is added: a qualifying actual end-Spice draw follows a Nexus and settled alliance. CHOAM Secret Ally spends a Worthless for two bank spice only with CHOAM absent; CHOAM Betrayal spends its Nexus to discard one server-sampled native CHOAM hand card without compensation. Semuta claim/pass retires the original receipt once, after the committed fresh discard. No Homeworld, Discovery, E3, retrofit or public gate opens.', - '\n--profile semuta --homeworlds uses original fresh Basic/Advanced Richese with CHOAM and classic factions, exact CHOAM deck, at2–6 seats. Native occupation setup/history remain original. Paid Box, Distrans, definite Truthtrance, ordinary specials, Kaitain, Tupile cleanup and legal CHOAM market sales offer used cards before their original suffix. Final Ornithopter disposal keeps completed original groups; finite Once Around and clean unstarted movement Sapho disposal keeps the reordered queue/funding. Accepted losing-card, mandatory winning-card and selected winner disposal remain separate fresh events, preserving paid deployments, casualties and original rewards without replaying combat. Native printed CHOAM costs pause before their effect; printed Karama canceling these powers or native Guild/Emperor/Richese income is separately offered before denial. Retained fuel/witness and credited sale spice remain unchanged. No repeated move/return/storm/payout/refund is introduced. Kull, Hajr/prior-flight integration, unresolved cyclic/partial/Guild/Sapho battle timing, other producers/modules, retrofit and public starts remain guarded.', + '\n--profile semuta --homeworlds uses original fresh Basic/Advanced Richese with CHOAM and classic factions, exact CHOAM deck, at2–6 seats. Native occupation setup/history remain original. Paid Box, Distrans, definite Truthtrance, ordinary specials, Kaitain, Tupile cleanup and legal CHOAM market sales offer used cards before their original suffix. Final Ornithopter disposal keeps completed original groups; finite Once Around and clean unstarted movement Sapho disposal keeps the reordered queue/funding. Accepted losing-card, mandatory winning-card and selected winner disposal remain separate fresh events, preserving paid deployments, casualties and original rewards without replaying combat. Clean Residual Poison keeps one native random death without bounty; early Sapho aggressor and remaining Battle chooser costs keep original participants and finite priority. Native printed CHOAM costs pause before their effect; printed Karama canceling these powers or native Guild/Emperor/Richese income is separately offered before denial. Retained fuel/witness and credited sale spice remain unchanged. No repeated move/return/storm/payout/refund is introduced. Kull, Hajr/prior-flight integration, unresolved cyclic/partial/Guild/late Sapho timing, readied/inspected Poison costs, Advanced Harkonnen captive disclosure, other producers/modules, retrofit and public starts remain guarded.', '\nEcaz-occupy (--profile ecaz-occupy) opts only a fresh ready 2–6-seat lobby with native Ecaz and classic/Ixian/Tleilaxu/CHOAM/Richese opponents (optional native Moritani). Advanced uses distinct selected ecaz/ix/choam decks; Basic uses the exact ecaz deck with classic/optional Moritani opponents. Ordinary33/47/35 cards retain real original offers/HMS/Auditor/Face Dancers and Richese cache. Explicit --ecaz-treachery independently adds the three physical variant cards, making36/50/38 (Advanced only). Basic combined battles require an even Ecaz force count; the preserved publisher odd-force casualty-rounding conflict rejects before any lead choice. Native Richese ordinary and marker-only pools compose with selected lead plans; original own mixed No-Field dialing, whole-plan disclosure and optional overlays remain guarded.' + '\nLeader-skills retains existing classic and Basic native entries and adds native Basic Richese/CHOAM. Advanced Ixians, Tleilaxu, CHOAM and/or Richese plus classic rosters use distinct required family decks and all fourteen skills, with original cache, Face Dancers, Auditor exclusion and typed aftermath. Foreign revival grants no new skill. Separate non-Harkonnen Advanced Moritani uses the ecaz deck and original skill-first assassination preview. Native Ecaz supports its five ordinary discs, original Ambassador/Duke custody and ecaz deck with classic opponents in Basic/Advanced; Advanced Harkonnen retains its existing Duke capture exclusion. Normal Mentat questions use a uniform private card-show step in supported Leader Skills games. Banker-income additionally supports native Richese and Ecaz, never ordinary seller/allowed Emperor income or a bank grant as a payer cost. Basic auction recipients remain unchanged. Public activation, exhausted-cache counts, native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, shared-Duke assignment, combined Occupy skills and other pending module/rulings remain guarded.', '\nStronghold-factions additionally admits standalone Advanced Ecaz OR Moritani with classic opponents and the exact ecaz deck, six Stronghold Cards with optional unused Tech Tokens at 3–6 seats. Original setup and actual end-Mentat claims remain. Ecaz holds shared-control cards and only the actual selected holder-plan benefits. Native Moritani retains original assassination with printed bounty and one private Mentat replacement; Harkonnen is excluded there. An E3 pair, E1/E2/E3 mixtures and other modules remain separate.' +