diff --git a/components/game-table.tsx b/components/game-table.tsx index cd351ee4..cda890df 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1171,6 +1171,9 @@ export function GameTable({ Truthtrance answers and used ordinary special cards offer Semuta. Completed legal CHOAM market sales also offer their used card; recovery preserves the already credited two or three spice. + Final Ornithopter disposal keeps the completed original groups; + finite Once Around and clean unstarted movement Sapho costs keep + the changed queue before its original next actor continues. 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 3d97aab8..41ef5171 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,26 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Semuta / native Homeworld flight and finite order — 13 October.** +Final Ornithopter and clean Sapho Once Around/Movement order costs now +offer their used card. Six flight/eight order before programs had no offer; +28 parent after claim/pass programs preserve completed groups, paid prefixes, +finite opportunities and original turn ownership. Seventy new cases cover +both modes, empty-cohort early end, queue/funding and all four legal profiles. +Three actual390px flight paths exchange at4/4 without another move; four +actual390px Sapho paths exchange at4/4, keep Emperor10→9/Richese5→6 native +invoice or finish original unstarted turns without global order rewriting. +Artifacts: `/tmp/dune-homeworld-native-flight-3218.json`, +`/tmp/dune-homeworld-native-sapho-3219.json`. Hajr/prior movement, active +Homeworld transfers, advisors and unresolved cyclic/partial/Guild/late Sapho +timing remain guarded. Local staged-card proof is not release/save/deployed +or complete-faction acceptance. +Types/lint and126/126 across six focused card test files pass. Seventy new +cases keep original flight/finite-order constraints rather than opening +unresolved timing. No broader assurance suite was substituted. +The application build passes with deprecation, large-chunk and static +route-classification warnings recorded; no warning or mode gate is suppressed. + **Semuta / native Homeworld market disposal — 13 October.** Completed legal CHOAM market sales now offer their used card after the original physical discard and two/three-spice credit. High Tupile's diff --git a/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md b/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md index 43be0168..0cd76db6 100644 --- a/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md +++ b/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md @@ -31,6 +31,13 @@ Worthless-sale prohibition, including Worthless duplicates, is unchanged; actual native shipment can lower population before a legal sale. Normal duplicates need no low-population exception. No new bank-income ruling follows. +Final Ornithopter and clean finite Sapho auction/Movement order disposal also +offer their used card in this profile. Completed original groups and paid +sources stay committed; reordered unacted queues keep earlier bids/funding +and physical storm order. Recovery only releases the same next native actor. +Hajr/prior flight, active Homeworld transfers, advisors, cyclic auctions, +partly completed turns, first-versus-Guild and late battle timing remain guarded. + 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 ca160be9..3718f436 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,56 @@ # Dune implementation status +## 13 October 2026 — Homeworld carried-flight and finite Sapho disposal + +The producer gate additionally admits existing `ornithopterDiscard`, +`saphoAuctionDiscard` and `saphoMovementDiscard` kinds. The native range, +cohort, original groups, queue, funding and event validators remain unchanged. +No signature, price, physical-order or optional-profile cutover. + +Original Homeworld CHOAM/Richese/classic setup and paid native shipment +provide one/two CHOAM counters. Real first/final moves use the printed range- +three or two-group card, and an empty original cohort can end early. +Six before programs disposed once without Semuta. Twelve parent after +claim/pass programs retain moved1/2/1, counter custody and the original +arrival/turn suffix to Collection. The existing prior-move/Hajr, advanced +advisor and active Homeworld-transfer guards remain. + +Real Richese Declaration/cache/method controls provide a Once Around lot with +an earlier one-spice bid. Clean no-Guild unstarted turns provide Movement +order. Original physical Sapho is conservatively positioned for a non-Richese +unacted holder. Eight before programs changed only their finite queue and +disposed without an offer. Sixteen parent after programs preserve earlier +funding/acquisition/income or one original paid shipment/ground move through +the same reordered turns. Cyclic/silent, partly completed, first-versus-Guild +and late battle timing remain excluded; no pending source question is settled. + +Three actual390px card controls complete range3, a second original group +and empty-cohort early end. All recover the physical card at4/4 without +repeating movement or shipment: paid C8/6/8, reserves19/18/19, moved1/2/1 +and the same original next Atreides turn. Four actual390px Sapho controls +change first/last, commit Semuta at4/4 and use original actor controls. +Both finite lots keep Emperor's original10→9 invoice/card and Richese5→6 +credit. First Movement uses one real native shipment and ground move; +last Movement consumes original r/c/a/e turns once to Collection. +Physical order remains r/e/c/a. Artifacts outside Git: +`/tmp/dune-homeworld-native-flight-3218.json`, +`/tmp/dune-homeworld-native-sapho-3219.json`. + +Action notices occasionally cover controls; local Continue acknowledges +them. One transient DOM failure occurred after a successfully ended Richese +turn; fresh actual state showed c/a/e remaining, so the accepted action +was not repeated. Initial optimizer aborts are retained. Only owned QA +services/tabs/root files were removed. No deployed, saved-room, natural +full-game or completed-mode certification. New cases cover70 semantic +paths and all four minimal legal profiles; no strategy tuning. +The frozen six-file card union passes126/126 with types/lint. Parent kept +resource, cohort, funded acquisition and actual actor-sequence assertions; +fixture-only ship/move trace counts were removed. JSON comparison ignores +incidental omitted undefined controls, not physical/queue/private behavior. +The application build passes with the existing module.register deprecation, +client chunks over500kB and Vinext static route-classification limitations. +No warning threshold is changed. + ## 13 October 2026 — native Homeworld CHOAM completed-sale discard The existing Homeworld Semuta gate now admits `choamSaleDiscard`. Native diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index fcd4fc9f..bffa3a42 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -855,6 +855,33 @@ consumer cases pass on that genuine setup. Later held cards/Tanks use conserved transfers, not replacement decks or minted forces. No new ruling, module entry, public gate or complete-mode certification follows. +**13 October 2026 — original Homeworld carried-flight and finite-order costs.** +The existing profile now admits final `ornithopterDiscard`, clean +`saphoAuctionDiscard` and `saphoMovementDiscard`. Native range, cohort, +funding, queue and event rules are unchanged. Active Homeworld transfers, +Hajr/prior moves, advanced advisor flight and unresolved cyclic/partial/Guild/ +late-Sapho timing remain guarded. + +Six real flight before programs and eight real finite-order before programs +disposed their cards without offers. Twelve parent flight claim/pass programs +now preserve one/two completed original counters and original turn advancement. +Sixteen parent Sapho claim/pass programs preserve the earlier one-spice cache +bid/purchase/income or one original paid shipment and ground move through the +reordered queue. No global storm-order rewrite or repeated opportunity. + +Three actual390px flight controls use range-three, final second group and +empty-cohort early end; recovery at4/4 leaves moved1/2/1, paid reserves19/18/19 +and the same next actor. Four actual390px Sapho first/last controls preserve +the original finite lot invoice (Emperor10→9, Richese5→6) or unchanged physical +order while remaining combined turns reorder. A first Movement turn makes one +real paid shipment10→8/reserves20→19 and one ground move; last ordering ends +original r/c/a/e turns to Collection. Scope artifacts outside Git: +`/tmp/dune-homeworld-native-flight-3218.json`, +`/tmp/dune-homeworld-native-sapho-3219.json`. +Cosmetic notices required local acknowledgement; an observed transient DOM +failure was followed by fresh actual state, not a repeated accepted action. +Initial optimizer aborts remain recorded. No deployed/save/full-mode claim. + **13 October 2026 — completed native Homeworld CHOAM market disposal.** `choamSaleDiscard` is now admitted in the existing Homeworld envelope. The original sale still commits its physical discard and two/three-spice credit diff --git a/game/engine.ts b/game/engine.ts index 5632d1c6..bf36addd 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -6958,7 +6958,10 @@ function semutaOfferSupported( continuation.kind !== 'choamBalisetDiscard' && continuation.kind !== 'choamJubbaDiscard' && continuation.kind !== 'karamaChoamWorthlessDiscard' && - continuation.kind !== 'choamSaleDiscard') return false; + continuation.kind !== 'choamSaleDiscard' && + continuation.kind !== 'ornithopterDiscard' && + continuation.kind !== 'saphoAuctionDiscard' && + continuation.kind !== 'saphoMovementDiscard') 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 4681e4ff..2dad18ba 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1188,6 +1188,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'A printed Karama canceling original paid Guild shipment income, non-Ambassador Emperor auction income or Richese special-purchase income also offers its exact public cost before the native denial. The price and physical transaction have already committed: claim or Continue cannot refund spice, re-ship forces, undo cache acquisition or replay a purchase. Original Movement, next-lot inspection or the Richese purchase’s suspended controls resume once. A winner may spend the very Karama it just bought; claiming that identity does not reverse its payment. Homeworld and Ambassador payment compositions remain separate.', 'The separate fresh semuta --homeworlds entry retains original Richese/CHOAM and classic Homeworld occupation setup with exact CHOAM and no other modules. Paid Box search, clean Distrans transfer, definite Truthtrance answers, used ordinary specials, paid Kaitain disposal, Tupile cleanup and printed Karama canceling native Guild/Emperor/Richese-purchase income offer Semuta. Native printed Kulon, Baliset, La La La, Trip to Gamont and Jubba costs also pause before their original effect: claim/pass adds one movement bonus, denies free allowance without charging/moving forces, stops the uncommitted Baliset move, returns one original board counter before the same CHOAM close, or applies protection before the same recorded storm moves. Printed Karama canceling these powers pauses separately before denial; the retained Worthless fuel is not a candidate. A canceled reactive Baliset resumes its original declared move once; canceled Kulon/La La La/Gamont/Jubba do not apply their effects. A recovered card grants no other faction CHOAM power. Kaitain private groups require commitment before selection; each Tupile holder has a separate cost. Native income denial retains payment and the original occupied-share allocation. Basic unresolved epochs, Kull, other producers and public starts remain guarded.', '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.', '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.', @@ -1268,6 +1269,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/semuta-homeworld-choam-storm.test.ts', 'tests/semuta-homeworld-choam-karama.test.ts', 'tests/semuta-homeworld-choam-sale.test.ts', + 'tests/semuta-homeworld-ornithopter.test.ts', + 'tests/semuta-homeworld-sapho.test.ts', ], }, ], diff --git a/tests/fixture-semuta-homeworld-ornithopter.ts b/tests/fixture-semuta-homeworld-ornithopter.ts new file mode 100644 index 00000000..b8fbe98c --- /dev/null +++ b/tests/fixture-semuta-homeworld-ornithopter.ts @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { handLimit, viewGame, type Action, type Game } from '../game/engine'; +import { TERRITORIES, gameDistance, location, splitLocation } from '../game/board'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import type { NativeReserveSelections } from '../game/homeworld-native-reserves'; +import type { OrnithopterMode } from '../game/ornithopter'; +import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech'; +import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep, + createSemutaHomeworldChoamSetup, holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam'; +import { moveSemutaEmperorCard, semutaEmperorPlayer as player } from './fixture-semuta-emperor-share'; + +export type SemutaHomeworldOrnithopterFixture = { + setup: Game; beforeShipment: Game; afterShipment: Game; beforeFirstMove: Game; game: Game; + owner: string; holder: string; card: string; mode: OrnithopterMode; earlyFinish: boolean; + origin: string; destination: string; secondDestination: string; tooFarDestination: string; + shipment: Action; firstMove: Action; action: Action; moves: number; amount: number; + sources: NativeReserveSelections; originalForces: Record; + actions: RicheseSkillsTechStep[]; staging: string[]; +}; +const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard; +export function semutaHomeworldOrnithopterStock(game: Game): string[] { + return [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [], + ...game.players.map(p => p.hand), ...(game.ornithopter ? [[game.ornithopter.card]] : [])] + .flat().map(card => card.id).sort(); +} +export function semutaHomeworldOrnithopterResources(game: Game) { + return { custody: structuredClone(game.homeworlds?.custody), spice: structuredClone(game.spice), + players: game.players.map(p => ({ id: p.id, spice: p.spice, reserves: p.reserves, + tanks: p.tanks, forces: structuredClone(p.forces), elites: structuredClone(p.elites), + shipped: p.shipped, moved: p.moved, ally: p.ally })) }; +} +function destination(game: Game, origin: string, range: number, excluded: string[] = []) { + const key = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id, s))) + .find(key => !excluded.includes(key) && splitLocation(key).sector !== game.storm && + game.players.every(p => !(p.forces[key] > 0)) && + gameDistance(game, origin, key, k => splitLocation(k).sector === game.storm) === range); + assert.ok(key, `Original board needs an empty sand destination at range ${range}`); + return key; +} +/** Fresh original setup and paid native CHOAM source counters. Only original + * unplayed Spice Card order and conserved physical hand positions are staged. */ +export function createSemutaHomeworldOrnithopterFixture( + mode: OrnithopterMode, advanced = false, earlyFinish = false, fullHolder = false, +): SemutaHomeworldOrnithopterFixture { + assert.ok(!earlyFinish || mode === 'twoGroups'); + const native = createSemutaHomeworldChoamSetup(advanced); + let game = native.game; + const setup = structuredClone(game), actions = native.actions; + const staging = ['Conserved original physical Semuta in Richese hand.']; + const blows = game.spiceDeck.filter(c => 'territory' in c).slice(0, 2); + assert.equal(blows.length, 2); + for (const [position, card] of blows.entries()) + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]); + staging.push('Reorder two original unplayed non-worm Spice Cards; no replacement deck.'); + game = advanceSemutaHomeworldChoam(game, + g => g.turn === 1 && g.phase === 5 && g.active === native.owner && clean(g), actions); + const card = holdSemutaHomeworldChoamCard(game, native.owner, 'Ornithopter'); + assert.equal(card, 'richese-ornithopter'); + assert.ok(player(game, native.owner).hand.length <= handLimit(player(game, native.owner))); + staging.push('Conserved original Richese cache Ornithopter to CHOAM; acquisition history unchanged.'); + if (fullHolder) { + const holder = player(game, native.holder); + while (holder.hand.length < handLimit(holder)) { + assert.ok(game.deck[0]); + moveSemutaEmperorCard(game, game.deck[0].id, holder.id); + } + staging.push('Conserved original undealt cards fill Richese to its native hand limit.'); + } + const origin = 'red_chasm:7', amount = mode === 'twoGroups' && !earlyFinish ? 2 : 1; + const sources = nativeShipmentSources(viewGame(game, native.owner), amount, 0); + assert.ok(sources, 'Native Homeworld reserves must supply the actual counters'); + const shipment: Action = { type: 'ship', ...splitLocation(origin), amount, elite: 0, + homeworldSources: sources, allyPayment: 0 }; + const beforeShipment = structuredClone(game); + game = applySemutaHomeworldChoamStep(game, native.owner, shipment, actions); + game = advanceSemutaHomeworldChoam(game, clean, actions); + const afterShipment = structuredClone(game), beforeFirstMove = structuredClone(game); + const originalForces = structuredClone(player(game, native.owner).forces); + const target = destination(game, origin, mode === 'range3' ? 3 : 1); + const secondDestination = destination(game, origin, 1, [target]); + const tooFarDestination = destination(game, origin, 4); + const option = viewGame(game, native.owner).ornithopter; + assert.equal(option?.card.id, card); + assert.ok(option?.modes.some(m => m.mode === mode && !m.blocked)); + const firstMove: Action = { type: 'move', from: origin, amount: 1, elite: 0, + ...splitLocation(target), movementCard: card, ornithopter: mode }; + let action = firstMove; + if (mode === 'twoGroups') { + game = applySemutaHomeworldChoamStep(game, native.owner, firstMove, actions); + assert.equal(game.ornithopter?.completed, 1); + action = earlyFinish ? { type: 'endMovement' } : { type: 'move', from: origin, amount: 1, + elite: 0, ...splitLocation(secondDestination), ornithopterEvent: game.ornithopter!.event }; + } + return { setup, beforeShipment, afterShipment, beforeFirstMove, game, + owner: native.owner, holder: native.holder, card, mode, earlyFinish, origin, + destination: target, secondDestination, tooFarDestination, shipment, firstMove, action, + amount, moves: mode === 'twoGroups' && !earlyFinish ? 2 : 1, sources: structuredClone(sources), + originalForces, actions, staging }; +} +export function disposeSemutaHomeworldOrnithopter(f: SemutaHomeworldOrnithopterFixture, + state = f.game, actions?: RicheseSkillsTechStep[]): Game { + return applySemutaHomeworldChoamStep(state, f.owner, f.action, actions); +} +/** Real original movement queue continuation; never another CHOAM ground move. */ +export function finishSemutaHomeworldOrnithopter(state: Game, actions?: RicheseSkillsTechStep[]): Game { + return advanceSemutaHomeworldChoam(state, g => g.phase > 5 && clean(g), actions); +} diff --git a/tests/fixture-semuta-homeworld-sapho.ts b/tests/fixture-semuta-homeworld-sapho.ts new file mode 100644 index 00000000..87169105 --- /dev/null +++ b/tests/fixture-semuta-homeworld-sapho.ts @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine'; +import { TERRITORIES, gameDistance, location, splitLocation } from '../game/board'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep, + createSemutaHomeworldChoamSetup, holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam'; +import { moveSemutaEmperorCard, semutaEmperorPlayer as player } from './fixture-semuta-emperor-share'; +import { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding'; +import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech'; + +export type SemutaHomeworldSaphoScope = 'onceAround' | 'movement'; +export type SemutaHomeworldSaphoMode = 'first' | 'last'; +export type SemutaHomeworldSaphoFixture = { + setup: Game; game: Game; scope: SemutaHomeworldSaphoScope; mode: SemutaHomeworldSaphoMode; + holder: string; owner: string; card: string; action: Action; event: string; + nextActor: string; suffix: string[]; prefix: string[]; originalQueue: string[]; + lotCard: string | null; actions: RicheseSkillsTechStep[]; staging: string[]; +}; +const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard; + +/** Original completed R/E/C/A Homeworld setup, native finite lot or unstarted + * Movement queue. Only original unplayed Spice order and physical cards move. */ +export function createSemutaHomeworldSaphoFixture( + scope: SemutaHomeworldSaphoScope, mode: SemutaHomeworldSaphoMode, + advanced = false, fullHolder = false, +): SemutaHomeworldSaphoFixture { + const native = createSemutaHomeworldChoamSetup(advanced); + let game = native.game; + const setup = structuredClone(game), actions = native.actions; + const staging = ['Conserved original physical Semuta in Richese hand.']; + const blows = game.spiceDeck.filter(card => 'territory' in card).slice(0, 2); + assert.equal(blows.length, 2); + for (const [position, card] of blows.entries()) + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]); + staging.push('Reorder two original unplayed non-worm Spice Cards; no replacement stock.'); + let lotCard: string | null = null; + if (scope === 'onceAround') { + game = advanceSemutaHomeworldChoam(game, g => g.decision?.kind === 'richeseDeclaration', actions); + game = applySemutaHomeworldChoamStep(game, native.holder, + { type: 'decision', event: game.richeseBidding!.event, position: 'first' }, actions); + game = advanceSemutaHomeworldChoam(game, g => g.decision?.kind === 'richeseCache', actions); + assert.equal(game.decision?.kind, 'richeseCache'); + const selected = game.richeseCache!.find(card => card.id !== 'richese-juice-of-sapho')!; + lotCard = selected.id; + game = applySemutaHomeworldChoamStep(game, native.holder, + { type: 'decision', event: game.richeseBidding!.event, card: selected.id, + method: 'onceAround', direction: 'counterclockwise' }, actions); + game = advanceSemutaHomeworldChoam(game, g => !!g.richeseAuction && clean(g), actions); + assert.equal(game.richeseAuction!.source, 'cache'); + assert.equal(game.richeseAuction!.method, 'onceAround'); + const actor = game.richeseAuction!.active!; + assert.ok(player(game, actor).spice >= 1); + game = applySemutaHomeworldChoamStep(game, actor, + { type: 'richeseBid', event: game.richeseAuction!.event, amount: 1, allyPayment: 0 }, actions); + } else { + game = advanceSemutaHomeworldChoam(game, g => g.phase === 5 && clean(g), actions); + assert.ok(game.players.every(p => !p.shipped && p.moved === 0)); + assert.deepEqual(game.movementRemaining, game.order); + } + assert.equal(game.turn, 1); + const originalQueue = scope === 'onceAround' ? [...game.richeseAuction!.order] : [...game.movementRemaining!]; + const prefix = scope === 'onceAround' ? [...game.richeseAuction!.acted] : []; + const remaining = originalQueue.filter(id => !prefix.includes(id)); + const owner = remaining.find(id => id !== native.holder && + (mode === 'first' ? id !== remaining[0] : id !== remaining.at(-1))); + assert.ok(owner, 'A real non-Richese unacted actor changes its selected position'); + const card = holdSemutaHomeworldChoamCard(game, owner, 'Juice of Sapho'); + staging.push(`Conserved original printed Juice of Sapho to ${owner}; not an auction acquisition.`); + assert.ok(player(game, owner).hand.length <= handLimit(player(game, owner))); + if (fullHolder) { + const holder = player(game, native.holder); + while (holder.hand.length < handLimit(holder)) { + assert.ok(game.deck[0]); + moveSemutaEmperorCard(game, game.deck[0].id, holder.id); + } + staging.push('Conserved original undealt cards fill Richese to its native hand limit.'); + } + const option = viewGame(game, owner).saphoOptions.find(option => option.scope === scope && option.mode === mode); + assert.ok(option); + const action: Action = { type: 'card', card, scope, mode, event: option.event }; + const suffix = mode === 'first' ? [owner, ...remaining.filter(id => id !== owner)] + : [...remaining.filter(id => id !== owner), owner]; + assert.deepEqual(biddingPhysicalIds(game), biddingPhysicalIds(setup)); + return { setup, game, scope, mode, holder: native.holder, owner, card, action, + event: option.event!, nextActor: suffix[0], suffix, prefix, originalQueue, lotCard, actions, staging }; +} + +export function disposeSemutaHomeworldSapho(fixture: SemutaHomeworldSaphoFixture, state = fixture.game): Game { + return applyAction(state, fixture.owner, fixture.action); +} + +/** Finish only this same finite lot: each remaining bidder passes once, leaving + * the real earlier one-spice invoice to native quote/payment/acquisition. */ +export function finishSemutaHomeworldSaphoAuction(state: Game, fixture: SemutaHomeworldSaphoFixture, + actions: RicheseSkillsTechStep[] = []): Game { + let game = state; + while (game.richeseAuction?.event === fixture.event && !game.richeseAuction.outcome) { + assert.ok(clean(game)); + const actor = game.richeseAuction.active!; + game = applySemutaHomeworldChoamStep(game, actor, + { type: 'richeseBid', event: fixture.event, amount: null, allyPayment: 0 }, actions); + } + return game; +} + +/** One actual paid original reserve shipment and one ground move, then each + * original remaining combined turn ends once through public native controls. */ +export function finishSemutaHomeworldSaphoMovement(state: Game, fixture: SemutaHomeworldSaphoFixture, + actions: RicheseSkillsTechStep[] = []): { game: Game; beforeShipment: Game; afterShipment: Game; + origin: string; destination: string; shipment: Action; move: Action } { + let game = state; + assert.ok(clean(game)); + assert.equal(game.active, fixture.nextActor); + const origin = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id, s))) + .find(key => splitLocation(key).sector !== game.storm && game.players.every(p => !(p.forces[key] > 0))); + assert.ok(origin); + const destination = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id, s))) + .find(key => splitLocation(key).sector !== game.storm && game.players.every(p => !(p.forces[key] > 0)) && + gameDistance(game, origin, key, k => splitLocation(k).sector === game.storm) === 1); + assert.ok(destination); + const sources = nativeShipmentSources(viewGame(game, fixture.nextActor), 1, 0); + assert.ok(sources); + const shipment: Action = { type: 'ship', ...splitLocation(origin), amount: 1, elite: 0, + homeworldSources: sources, allyPayment: 0 }; + const beforeShipment = structuredClone(game); + game = applySemutaHomeworldChoamStep(game, fixture.nextActor, shipment, actions); + game = advanceSemutaHomeworldChoam(game, clean, actions); + const afterShipment = structuredClone(game); + const move: Action = { type: 'move', from: origin, ...splitLocation(destination), amount: 1, elite: 0 }; + game = applySemutaHomeworldChoamStep(game, fixture.nextActor, move, actions); + game = advanceSemutaHomeworldChoam(game, clean, actions); + game = advanceSemutaHomeworldChoam(game, g => g.phase > 5 && clean(g), actions); + return { game, beforeShipment, afterShipment, origin, destination, shipment, move }; +} diff --git a/tests/semuta-homeworld-ornithopter.test.ts b/tests/semuta-homeworld-ornithopter.test.ts new file mode 100644 index 00000000..bcf3a793 --- /dev/null +++ b/tests/semuta-homeworld-ornithopter.test.ts @@ -0,0 +1,187 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, handLimit, normalizeAutomaticGame, viewGame, type Game } from '../game/engine'; +import { gameDistance, splitLocation } from '../game/board'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import type { OrnithopterMode } from '../game/ornithopter'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { advanceSemutaHomeworldChoam, holdSemutaHomeworldChoamCard, + reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam'; +import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share'; +import { withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement'; +import { createSemutaHomeworldOrnithopterFixture, disposeSemutaHomeworldOrnithopter, + finishSemutaHomeworldOrnithopter, semutaHomeworldOrnithopterResources as resources, + semutaHomeworldOrnithopterStock as stock } from './fixture-semuta-homeworld-ornithopter'; + +function assertCounters(game: Game, original: Game) { + for (const p of game.players) { + const before = player(original, p.id); + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((n, x) => n + x, 0), + before.reserves + before.tanks + Object.values(before.forces).reduce((n, x) => n + x, 0)); + if (p.elites && before.elites) { + assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((n, x) => n + x, 0), + before.elites.reserves + before.elites.tanks + Object.values(before.elites.forces).reduce((n, x) => n + x, 0)); + for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[key] ?? 0)); + } + } + assert.deepEqual(game.homeworlds?.custody, original.homeworlds?.custody); +} +const paths: { mode: OrnithopterMode; early: boolean }[] = [ + { mode: 'range3', early: false }, { mode: 'twoGroups', early: false }, { mode: 'twoGroups', early: true }, +]; +for (const advanced of [false, true]) for (const { mode, early } of paths) { + const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld Ornithopter ${mode}${early ? ' early finish' : ''}`; + for (const claim of [false, true]) void test(`${label}: ${claim ? 'claim' : 'pass'} retires once before the original arrival or turn continuation`, () => { + const f = createSemutaHomeworldOrnithopterFixture(mode, advanced, early); + const inventory = stock(f.beforeFirstMove), owner = player(f.afterShipment, f.owner); + assert.equal(f.afterShipment.turn, 1); + assert.equal(f.afterShipment.phase, 5); + assert.equal(owner.spice, player(f.beforeShipment, f.owner).spice - f.amount * 2); + assert.equal(owner.reserves, player(f.beforeShipment, f.owner).reserves - f.amount); + assert.equal(owner.forces[f.origin], f.amount); + assertCounters(f.afterShipment, f.setup); + const distance = gameDistance(f.beforeFirstMove, f.origin, f.destination, + key => splitLocation(key).sector === f.beforeFirstMove.storm); + assert.equal(distance, mode === 'range3' ? 3 : 1); + if (mode === 'range3') assert.throws(() => applyAction(f.beforeFirstMove, f.owner, + { ...f.firstMove, movementCard: undefined, ornithopter: undefined })); + if (mode === 'twoGroups') { + assert.equal(player(f.game, f.owner).forces[f.destination], 1); + assert.equal(player(f.game, f.owner).moved, 1); + assert.equal(player(f.game, f.owner).hand.some(c => c.id === f.card), false); + assert.equal(f.game.discard.some(c => c.id === f.card), false); + assert.equal(viewGame(f.game, f.owner).ornithopter?.active?.remaining, 1); + assert.deepEqual(viewGame(f.game, f.owner).ornithopter?.active?.cohort?.forces, + early ? {} : { [f.origin]: 1 }); + assert.throws(() => applyAction(f.game, f.owner, { type: 'move', from: f.destination, + amount: 1, elite: 0, ...splitLocation(f.origin), ornithopterEvent: f.game.ornithopter!.event })); + assert.throws(() => applyAction(f.game, f.owner, { ...f.action, type: 'move', + from: f.origin, amount: 1, ...splitLocation(f.secondDestination), ornithopterEvent: 'stale' })); + } + const uninterrupted = reactSemutaHomeworldChoam(disposeSemutaHomeworldOrnithopter(f, + withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder)), f.holder, false); + const offered = disposeSemutaHomeworldOrnithopter(f); + assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true); + assert.equal(offered.discard.filter(c => c.id === f.card).length, 1); + assert.equal(player(offered, f.owner).hand.some(c => c.id === f.card), false); + assert.equal(player(offered, f.owner).moved, f.moves); + assert.equal(player(offered, f.owner).forces[f.destination], 1); + if (mode === 'twoGroups' && !early) assert.equal(player(offered, f.owner).forces[f.secondDestination], 1); + assert.equal(player(offered, f.owner).forces[f.origin] ?? 0, 0); + assert.equal(offered.active, f.owner, 'Retirement suspends the same original turn, not its successor'); + assert.deepEqual(offered.movementRemaining, f.game.movementRemaining); + assert.deepEqual(stock(offered), inventory); + assertCounters(offered, f.setup); + assert.deepEqual(normalizeAutomaticGame(structuredClone(offered)), offered); + const reloaded = normalizeAutomaticGame(JSON.parse(JSON.stringify(offered))); + assert.deepEqual(resources(reloaded), resources(offered)); + assert.deepEqual(stock(reloaded), inventory); + assert.equal(reloaded.active, offered.active); + assert.deepEqual(reloaded.movementRemaining, offered.movementRemaining); + for (const p of offered.players) assert.deepEqual(viewGame(reloaded, p.id), viewGame(offered, p.id)); + const frozen = resources(offered), event = offered.pendingTreacheryDiscard!.batch.event; + assert.throws(() => applyAction(offered, f.owner, { type: 'endMovement' })); + assert.throws(() => applyAction(offered, f.owner, f.action)); + const resumed = reactSemutaHomeworldChoam(JSON.parse(JSON.stringify(offered)), f.holder, claim); + assert.equal(resumed.pendingTreacheryDiscard, null); + assert.deepEqual(resources(resumed), frozen, 'Resume cannot replay a paid shipment or ground move'); + assert.deepEqual(resources(resumed), resources(uninterrupted)); + assert.equal(resumed.active, uninterrupted.active); + assert.deepEqual(resumed.movementRemaining, uninterrupted.movementRemaining); + assert.equal(player(resumed, f.holder).hand.filter(c => c.id === f.card).length, claim ? 1 : 0); + assert.equal(resumed.discard.filter(c => c.id === f.card).length, claim ? 0 : 1); + assert.equal(resumed.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, claim ? 1 : 0); + assert.deepEqual(stock(resumed), inventory); + assert.throws(() => applyAction(resumed, f.holder, { type: 'semutaCommit', event })); + assert.throws(() => applyAction(resumed, f.owner, f.firstMove)); + assert.throws(() => applyAction(resumed, f.owner, { type: 'move', from: f.destination, + amount: 1, ...splitLocation(f.origin) })); + const replay = structuredClone(resumed); + replay.pendingTreacheryDiscard = structuredClone(offered.pendingTreacheryDiscard); + assert.throws(() => normalizeAutomaticGame(replay)); + assert.deepEqual(normalizeAutomaticGame(structuredClone(resumed)), resumed); + const completed = finishSemutaHomeworldOrnithopter(resumed); + assert.equal(completed.turn, 1); + assert.equal(completed.phase, 7); + assert.equal(player(completed, f.owner).moved, f.moves); + assert.equal(player(completed, f.owner).reserves, owner.reserves); + assert.deepEqual(player(completed, f.owner).forces, player(offered, f.owner).forces); + assert.deepEqual(stock(completed), inventory); + assertCounters(completed, f.setup); + }); + + void test(`${label}: full-hand singleton entitlement is private and original forces cannot be borrowed by another player`, () => { + const f = createSemutaHomeworldOrnithopterFixture(mode, advanced, early, true); + assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder))); + assert.throws(() => applyAction(f.game, f.holder, f.action)); + const offered = disposeSemutaHomeworldOrnithopter(f); + const neutral = disposeSemutaHomeworldOrnithopter(f, withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder)); + assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true); + assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false); + const event = offered.pendingTreacheryDiscard!.batch.event; + for (const p of offered.players.filter(p => p.id !== f.holder)) { + assert.deepEqual(viewGame(offered, p.id), viewGame(neutral, p.id)); + assert.equal(viewGame(offered, p.id).semutaReaction?.canCommit, false); + assert.throws(() => applyAction(offered, p.id, { type: 'semutaCommit', event })); + } + assert.throws(() => applyAction(offered, f.holder, { type: 'semutaCommit', event: 'stale' })); + const claimed = reactSemutaHomeworldChoam(offered, f.holder, true); + assert.equal(player(claimed, f.holder).hand.length, handLimit(player(claimed, f.holder))); + assert.equal(player(claimed, f.holder).hand.some(c => c.id === f.card), true); + assert.equal(player(claimed, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), false); + const done = finishSemutaHomeworldOrnithopter(claimed); + assert.equal(player(done, f.owner).moved, f.moves); + assert.deepEqual(stock(done), stock(f.beforeFirstMove)); + }); + + void test(`${label}: all four bot profiles legally claim or pass without a second shipment or repeated group`, () => { + const f = createSemutaHomeworldOrnithopterFixture(mode, advanced, early); + const offered = disposeSemutaHomeworldOrnithopter(f), frozen = resources(offered); + for (const difficulty of DIFFICULTIES) { + let game: Game = structuredClone(offered); + const own = viewGame(game, f.holder); + own.players.find(p => p.id === f.holder)!.bot = difficulty; + const action = botActions(own)[0]; + assert.ok(action); + assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass'); + game = applyAction(game, f.holder, action); + while (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') { + const pending = game.pendingTreacheryDiscard, reaction = pending.reaction; + if (reaction?.stage !== 'offer') throw Error('Missing original retirement offer'); + const actor = game.players.find(p => !reaction.passed.includes(p.id))!.id; + game = applyAction(game, actor, { type: 'semutaPass', event: pending.batch.event }); + } + assert.equal(game.pendingTreacheryDiscard, null); + assert.equal(player(game, f.holder).hand.some(c => c.id === f.card), action.type === 'semutaCommit'); + assert.deepEqual(resources(game), frozen); + const done = finishSemutaHomeworldOrnithopter(game); + assert.equal(player(done, f.owner).moved, f.moves); + assert.deepEqual(stock(done), stock(f.beforeFirstMove)); + assertCounters(done, f.setup); + } + }); +} + +for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} Homeworld original prior-move, Hajr and fixed maximum-three guards remain real`, () => { + const f = createSemutaHomeworldOrnithopterFixture('range3', advanced); + const before = structuredClone(f.beforeFirstMove); + assert.throws(() => applyAction(before, f.owner, { ...f.firstMove, ...splitLocation(f.tooFarDestination) })); + assert.deepEqual(before, f.beforeFirstMove); + const moved = applyAction(before, f.owner, { ...f.firstMove, movementCard: undefined, + ornithopter: undefined, ...splitLocation(f.secondDestination) }); + assert.equal(player(moved, f.owner).moved, 1); + assert.equal(player(moved, f.owner).hand.some(c => c.id === f.card), true); + assert.throws(() => applyAction(moved, f.owner, { ...f.firstMove, from: f.secondDestination, + ...splitLocation(f.origin) })); + const hajrGame = structuredClone(before); + const hajr = holdSemutaHomeworldChoamCard(hajrGame, f.owner, 'Hajr'); + const played = applyAction(hajrGame, f.owner, { type: 'card', card: hajr }); + const allowed = advanceSemutaHomeworldChoam(played, + g => !g.pendingTreacheryDiscard && !g.response && !g.decision && !g.phaseOpening); + assert.equal(player(allowed, f.owner).moved, 0); + assert.ok(allowed.hajr.includes(f.owner)); + assert.throws(() => applyAction(allowed, f.owner, f.firstMove)); + assert.equal(player(allowed, f.owner).hand.some(c => c.id === f.card), true); + assertCounters(allowed, f.setup); +}); diff --git a/tests/semuta-homeworld-sapho.test.ts b/tests/semuta-homeworld-sapho.test.ts new file mode 100644 index 00000000..59bf8933 --- /dev/null +++ b/tests/semuta-homeworld-sapho.test.ts @@ -0,0 +1,208 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, handLimit, normalizeAutomaticGame, viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { splitLocation } from '../game/board'; +import { advanceSemutaHomeworldChoam, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam'; +import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share'; +import { semutaHomeworldChoamMovementResources as resources, + withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement'; +import { biddingPhysicalIds as stock } from './fixture-homeworld-occupied-bidding'; +import { + createSemutaHomeworldSaphoFixture, disposeSemutaHomeworldSapho, + finishSemutaHomeworldSaphoAuction, finishSemutaHomeworldSaphoMovement, + type SemutaHomeworldSaphoFixture, type SemutaHomeworldSaphoMode, type SemutaHomeworldSaphoScope, +} from './fixture-semuta-homeworld-sapho'; +import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech'; + +const scopes: SemutaHomeworldSaphoScope[] = ['onceAround', 'movement']; +const modes: SemutaHomeworldSaphoMode[] = ['first', 'last']; + +/** Assert the original consumer opportunities/payment, not receipt wiring. */ +function finishOriginal(state: Game, f: SemutaHomeworldSaphoFixture) { + const actions: RicheseSkillsTechStep[] = []; + if (f.scope === 'onceAround') { + const lot = f.game.richeseAuction!, buyer = lot.bidder!; + const funding = viewGame(f.game, buyer).richeseAuction!; + assert.equal(lot.bid, 1); + assert.deepEqual(state.richeseAuction!.acted, f.prefix); + assert.equal(state.richeseAuction!.bidder, buyer); + assert.equal(state.richeseAuction!.bid, lot.bid); + assert.equal(viewGame(state, buyer).richeseAuction!.ownAvailable, funding.ownAvailable); + assert.equal(viewGame(state, buyer).richeseAuction!.ownAllyPayment, funding.ownAllyPayment); + assert.throws(() => applyAction(state, buyer, + { type: 'richeseBid', event: f.event, amount: 2, allyPayment: 0 }), + 'The earlier bidder never receives a second opportunity'); + const done = finishSemutaHomeworldSaphoAuction(state, f, actions); + const expected = f.suffix.filter(id => player(state, id).hand.length < handLimit(player(state, id))); + assert.deepEqual(actions.map(step => step.actor), expected); + assert.equal(new Set([...f.prefix, ...actions.map(step => step.actor)]).size, + f.prefix.length + actions.length); + assert.equal(player(done, buyer).hand.filter(card => card.id === f.lotCard).length, 1); + assert.equal(player(done, buyer).spice, player(f.game, buyer).spice - 1); + assert.equal(player(done, f.holder).spice, player(f.game, f.holder).spice + 1, + 'Original unoccupied Richese receives exactly the native cache-lot invoice'); + for (const seat of done.players.filter(seat => ![buyer, f.holder].includes(seat.id))) + assert.equal(seat.spice, player(f.game, seat.id).spice); + assert.deepEqual(done.homeworlds?.custody, f.setup.homeworlds?.custody); + assert.deepEqual(stock(done), stock(f.game)); + assert.throws(() => applyAction(done, buyer, + { type: 'richeseBid', event: f.event, amount: 2, allyPayment: 0 })); + return done; + } + const result = finishSemutaHomeworldSaphoMovement(state, f, actions), done = result.game; + assert.deepEqual(actions.filter(step => step.action.type === 'endMovement').map(step => step.actor), f.suffix, + 'Every original combined turn finishes exactly once in the reordered public queue'); + assert.equal(player(result.afterShipment, f.nextActor).spice, + player(result.beforeShipment, f.nextActor).spice - 2); + assert.equal(player(result.afterShipment, f.nextActor).reserves, + player(result.beforeShipment, f.nextActor).reserves - 1); + assert.equal(player(result.afterShipment, f.nextActor).forces[result.origin], 1); + assert.equal(player(done, f.nextActor).forces[result.origin] ?? 0, 0); + assert.equal(player(done, f.nextActor).forces[result.destination], 1); + assert.equal(player(done, f.nextActor).moved, 1); + assert.equal(done.turn, 1); + assert.equal(done.phase, 7); + assert.deepEqual(done.homeworlds?.custody, f.setup.homeworlds?.custody); + assert.deepEqual(stock(done), stock(f.game)); + assert.throws(() => applyAction(done, f.nextActor, result.shipment)); + assert.throws(() => applyAction(done, f.nextActor, result.move)); + return done; +} + +for (const advanced of [false, true]) for (const scope of scopes) for (const mode of modes) { + const label = `${advanced ? 'Advanced' : 'Basic'} original Homeworld Sapho ${scope} ${mode}`; + for (const claim of [false, true]) void test(`${label}: Semuta ${claim ? 'claim' : 'pass'} retains reordered unacted opportunities and original funded continuation`, () => { + const f = createSemutaHomeworldSaphoFixture(scope, mode, advanced); + const original = stock(f.game), paid = resources(f.game); + const offered = disposeSemutaHomeworldSapho(f); + assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true); + assert.equal(player(offered, f.owner).hand.some(card => card.id === f.card), false); + assert.equal(offered.discard.filter(card => card.id === f.card).length, 1); + assert.deepEqual(stock(offered), original); + assert.deepEqual(resources(offered), paid, 'Sapho and its disposal cannot spend the paid prefix or start a turn'); + assert.deepEqual(offered.order, f.game.order, 'Physical storm order is never rewritten'); + assert.deepEqual(normalizeAutomaticGame(structuredClone(offered)), offered); + if (scope === 'onceAround') { + assert.deepEqual(offered.richeseAuction!.order.filter(id => !offered.richeseAuction!.acted.includes(id)), f.suffix); + assert.deepEqual(offered.richeseAuction!.acted, f.prefix); + assert.deepEqual(offered.richeseAuction!.tieOrder, f.game.richeseAuction!.tieOrder); + assert.equal(viewGame(offered, 'a').richeseAuction!.card!.id, f.lotCard, + 'Atreides retains the real public cache card knowledge'); + assert.throws(() => applyAction(offered, f.nextActor, + { type: 'richeseBid', event: f.event, amount: null, allyPayment: 0 })); + } else { + assert.deepEqual(offered.movementRemaining, f.suffix); + assert.equal(offered.active, f.game.active, 'The original unstarted turn stays frozen until disposal resolves'); + assert.ok(offered.players.every(seat => !seat.shipped && seat.moved === 0)); + assert.throws(() => applyAction(offered, f.game.active!, { type: 'endMovement' })); + } + const event = offered.pendingTreacheryDiscard!.batch.event; + const resumed = reactSemutaHomeworldChoam(structuredClone(offered), f.holder, claim); + assert.equal(resumed.pendingTreacheryDiscard, null); + assert.equal(resumed.active, f.nextActor); + assert.deepEqual(resources(resumed), paid); + assert.deepEqual(stock(resumed), original); + assert.equal(player(resumed, f.holder).hand.filter(card => card.id === f.card).length, claim ? 1 : 0); + assert.equal(resumed.discard.filter(card => card.id === f.card).length, claim ? 0 : 1); + assert.equal(resumed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, claim ? 1 : 0); + assert.throws(() => applyAction(resumed, f.holder, { type: 'semutaCommit', event })); + assert.throws(() => applyAction(resumed, f.owner, f.action), 'The same physical Sapho cannot be spent twice'); + const done = finishOriginal(resumed, f); + assert.deepEqual(done.order, f.game.order); + }); + + void test(`${label}: original full-hand singleton exchange and private entitlement`, () => { + const f = createSemutaHomeworldSaphoFixture(scope, mode, advanced, true); + const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder); + assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder))); + const offered = disposeSemutaHomeworldSapho(f); + const neutral = disposeSemutaHomeworldSapho(f, absent); + assert.equal(viewGame(offered, 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 offered.players.filter(seat => seat.id !== f.holder)) { + assert.deepEqual(viewGame(offered, seat.id), viewGame(neutral, seat.id)); + assert.equal(viewGame(offered, seat.id).semutaReaction?.canCommit, false); + assert.throws(() => applyAction(offered, seat.id, + { type: 'semutaCommit', event: offered.pendingTreacheryDiscard!.batch.event })); + } + const claimed = reactSemutaHomeworldChoam(offered, f.holder, true); + assert.equal(player(claimed, f.holder).hand.length, handLimit(player(claimed, f.holder))); + assert.equal(player(claimed, f.holder).hand.some(card => card.id === f.card), true); + assert.equal(player(claimed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), false); + finishOriginal(claimed, f); + }); + + void test(`${label}: every minimal bot profile legally claims or passes before the native continuation`, () => { + const f = createSemutaHomeworldSaphoFixture(scope, mode, advanced); + const offered = disposeSemutaHomeworldSapho(f); + for (const difficulty of DIFFICULTIES) { + let game = structuredClone(offered); + const own = viewGame(game, f.holder); + own.players.find(seat => seat.id === f.holder)!.bot = difficulty; + const proposal = botActions(own)[0]; + assert.ok(proposal); + assert.ok(['semutaCommit', 'semutaPass'].includes(proposal.type)); + game = applyAction(game, f.holder, proposal); + while (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') { + const pending = game.pendingTreacheryDiscard, reaction = pending.reaction; + if (reaction?.stage !== 'offer') throw Error('Missing Semuta offer'); + const actor = game.players.find(seat => !reaction.passed.includes(seat.id))!.id; + game = applyAction(game, actor, { type: 'semutaPass', event: pending.batch.event }); + } + assert.equal(game.pendingTreacheryDiscard, null); + assert.equal(player(game, f.holder).hand.some(card => card.id === f.card), proposal.type === 'semutaCommit'); + const nextView = viewGame(game, f.nextActor); + nextView.players.find(seat => seat.id === f.nextActor)!.bot = difficulty; + const nextAction = botActions(nextView)[0]; + assert.ok(nextAction, 'The resumed original actor has a real legal bot continuation'); + const botContinued = applyAction(structuredClone(game), f.nextActor, nextAction); + assert.deepEqual(stock(botContinued), stock(game)); + finishOriginal(game, f); + } + }); + + void test(`${label}: stale source events and altered committed queue reject without card/resource loss`, () => { + const f = createSemutaHomeworldSaphoFixture(scope, mode, advanced); + const before = structuredClone(f.game); + assert.throws(() => applyAction(f.game, f.owner, { ...f.action, event: 'expired' })); + assert.deepEqual(f.game, before); + const offered = disposeSemutaHomeworldSapho(f), tampered = structuredClone(offered); + if (scope === 'onceAround') tampered.richeseAuction!.order.reverse(); + else tampered.movementRemaining!.reverse(); + const saved = structuredClone(tampered); + assert.throws(() => applyAction(tampered, f.holder, + { type: 'semutaCommit', event: tampered.pendingTreacheryDiscard!.batch.event })); + assert.deepEqual(tampered, saved); + const passed = applyAction(offered, f.holder, + { type: 'semutaPass', event: offered.pendingTreacheryDiscard!.batch.event }); + assert.throws(() => applyAction(passed, f.holder, + { type: 'semutaCommit', event: offered.pendingTreacheryDiscard!.batch.event })); + }); +} + +for (const advanced of [false, true]) for (const mode of modes) void test( + `${advanced ? 'Advanced' : 'Basic'} Homeworld Sapho ${mode}: actual paid current shipment closes the unstarted Movement window`, () => { + const f = createSemutaHomeworldSaphoFixture('movement', mode, advanced); + const origin = 'red_chasm:7'; + const actor = f.game.active!; + const sources = nativeShipmentSources(viewGame(f.game, actor), 1, 0); + assert.ok(sources); + let started = applyAction(f.game, actor, { type: 'ship', ...splitLocation(origin), amount: 1, elite: 0, + homeworldSources: sources, allyPayment: 0 }); + started = advanceSemutaHomeworldChoam(started, + game => !game.response && !game.decision && !game.pendingTreacheryDiscard && !game.phaseOpening); + assert.equal(player(started, actor).shipped, true); + const paid = resources(started), original = stock(started); + assert.ok(!viewGame(started, f.owner).saphoOptions.some(option => option.scope === 'movement')); + assert.throws(() => applyAction(started, f.owner, f.action)); + assert.deepEqual(resources(started), paid); + assert.deepEqual(stock(started), original); + assert.equal(player(started, f.owner).hand.some(card => card.id === f.card), true); + }, +); diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 171eb93b..8fb9a254 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -30,7 +30,7 @@ async function main() { '\nSemuta with CHOAM and optional Ix preserves original unused Tech Tokens in fresh 3–6-seat Basic/Advanced Richese lobbies under existing native prerequisites. Advanced additionally retains original unused Stronghold Cards at2–6; both modules require3–6. Native setup, first-Storm Tech and final-Mentat Stronghold assignment remain original. Mandatory/winner discards react before the original winner takes one loser-owned token, before Face Dance. Closing Gamont reacts before the original CHOAM finish and changed-board claim. Optional original skills require --leader-skills as described below; other modules, the separate CHOAM/Ecaz Moritani entry, retrofit and public starts remain excluded.', '\n--profile semuta --leader-skills explicitly composes original all14 skills with fresh native Basic Richese/CHOAM and classics using exact CHOAM, or Advanced native E1/E2 with Richese and selected required CHOAM/optional Ix decks, at2–6 seats. Optional original Tech requires3+; Strongholds requires Advanced. Original private offers/training, Auditor exclusion, normal Mentat question, typed Suk rescue and Rihani inspection/exchange remain. Traitor/Face Dancer movement is not a Treachery discard. Basic Ix mixtures without their original Nexus envelope, E3/Moritani, Homeworld/Discovery/Banker overlays, public starts and played-game conversion are not opened.', '\n--profile semuta --nexus-cards retains original all12 Nexus in fresh native Richese CHOAM/optional Ix lobbies, optionally --leader-skills, original Tech3+ and Advanced Strongholds2+. Single/paired CHOAM or both selected E1/E2 native families retain original prerequisites. No starting Nexus hand is added: a qualifying actual end-Spice draw follows a Nexus and settled alliance. CHOAM Secret Ally spends a Worthless for two bank spice only with CHOAM absent; CHOAM Betrayal spends its Nexus to discard one server-sampled native CHOAM hand card without compensation. Semuta claim/pass retires the original receipt once, after the committed fresh discard. No Homeworld, Discovery, E3, retrofit or public gate opens.', - '\n--profile semuta --homeworlds uses original fresh Basic/Advanced Richese with CHOAM and classic factions, exact CHOAM deck, at2–6 seats. Native occupation setup and source history remain original. Paid Box search, clean Distrans transfer, definite Truthtrance answers, used ordinary specials, paid Kaitain disposal, Tupile cleanup and completed legal CHOAM market sales offer Semuta before their original suffix. Market recovery preserves the credited two/three spice and retained duplicate witness; high Tupile still prohibits Worthless sales. Native printed Kulon/Baliset/La La La/Gamont/Jubba costs pause before their effect; printed Karama canceling these powers or native Guild/Emperor/Richese income is separately offered before denial. Retained fuel is never claimable. No repeated movement/force return/storm/payout or refund is introduced. Original occupied shares still allocate once. Kull, other producers, Nexus, Skills, Tech, Strongholds, Discovery, Ix/E3 mixtures, 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. 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.', '\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.' +