From 26553dd1759249b59a7d92dd2f653eee64f41a11 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:19:04 +1100 Subject: [PATCH] Complete provisional Basic Tupile slot lifecycle Basic marked Homeworld games now use the existing provisional current-sole-controller policy for Tupile slots. Observed eligible intervals retain one original activation across turns, close on departure/foreign contest/native high, and create a fresh source on restoration even when same-turn qualification rows deduplicate. Preserve original valid source histories and the lease format. Advanced retains its authorized last-departure rule. Original normal-limit cleanup remains binding before a returning holder can reuse a slot. Native low intelligence becomes available after Basic loss subject to actual population/contact, without clearing historical answers or lifetime faction usage. Existing owner-only cleanup controls and minimal policies use the native event and exact held cards. Persistent table and cleanup text explain the provisional policy. No new profile, state version or module opening. Validation and exact frozen-source evidence are recorded in the canonical Tupile runtime contract. Two original public Basic programmes use actual setup, three paid normal lots, earned Guild shipment income, a paid invasion and native battle, one continuous slot, optional real fifth purchase, paid last departure and zero/one normal-limit cleanup. All four minimal profiles submit legal held-card choices and finish the turn. Controlled interval/reactivation, allied cleanup and intelligence probes remain separate. No broad save/recovery campaign or strategy tuning. This is a first-version application interpretation under the standing recommended-choice direction; publisher Basic lifecycle clarification, full module/mode acceptance, final assurance and browser/deployed proof remain open. No saved-room/database/reset/restart/deployment operation. --- components/game-table.tsx | 15 +- components/homeworld-tupile-cleanup.tsx | 9 +- docs/CURRENT_STATUS.md | 20 ++ docs/HOMEWORLD_OCCUPATION_RULES.md | 7 + docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md | 83 ++++++++ docs/IMPLEMENTATION_STATUS.md | 20 ++ docs/RULE_DECISIONS.md | 2 +- game/basic-tupile-occupation.ts | 63 ++++++ game/homeworld-occupied-tupile.ts | 42 +++- game/homeworld-stable-occupation.ts | 8 +- game/reference.ts | 9 +- tests/basic-tupile-lifecycle-engine.test.ts | 113 +++++++++++ tests/fixture-basic-tupile-lifecycle.ts | 183 ++++++++++++++++++ tests/homeworld-occupied-tupile.test.ts | 108 ++++++++--- 14 files changed, 630 insertions(+), 52 deletions(-) create mode 100644 game/basic-tupile-occupation.ts create mode 100644 tests/basic-tupile-lifecycle-engine.test.ts create mode 100644 tests/fixture-basic-tupile-lifecycle.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index f8c0e79a..101315d1 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1180,12 +1180,15 @@ export function GameTable({ {g.homeworldTupilePreview && ( Occupied Tupile development preview · the occupier and reciprocal ally - each gain one server-derived hand slot; losing it requires the original - owner to discard eligible held cards to the normal limit. Advanced rulebook - page 22 retains occupation benefits until the occupier’s last own force - leaves, not on contests, native high population or turn changes; a new sole - occupier after departure starts a new epoch. Basic unresolved expiry and - competing-owner cases remain guarded. No extra card or spice is granted. + each gain one extra hand slot. A player who loses it must discard + eligible held cards to the normal limit.{' '} + {g.advanced ? <>Advanced retains occupation benefits until the occupier’s + last own force leaves; contests, native high population and turn changes + do not end it. : <>Basic provisionally gives the slot to the current + sole foreign controller. Departure, another foreign army or native high + population ends it. If you regain control, finish your earlier required + discards before using the extra slot again.}{' '} + Continuous control never adds a second slot. No extra card or spice is granted. Not complete or certified Homeworld rules.{' '} Preview rules and limits diff --git a/components/homeworld-tupile-cleanup.tsx b/components/homeworld-tupile-cleanup.tsx index 1ca7fd55..d4f750f9 100644 --- a/components/homeworld-tupile-cleanup.tsx +++ b/components/homeworld-tupile-cleanup.tsx @@ -50,10 +50,11 @@ function CleanupChoice({ game, offer, act, busy }: Props & { offer: Offer }) { cards are protected. This settles the lost slot; normal discard effects remain separate.

- Development preview · Advanced rulebook page 22 retains the original occupation - benefit until the occupier’s last own force leaves; contests, native high population - and turn changes do not end it. A new sole occupier after departure starts a new - occupation epoch. Basic unresolved expiry and competing-owner cases remain guarded. + Development preview · {game.advanced ? <>Advanced retains the original occupation + benefit until the occupier’s last own force leaves; contests, native high population + and turn changes do not end it. : <>Basic provisionally ends the slot on + departure, another foreign army or native high population. Regaining control + does not cancel this earlier normal-limit discard.}{' '} Not complete or certified Homeworld rules.

Original occupation cleanup event: {offer.event}

diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 7d2f6722..e521fa0f 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,26 @@ open mode gates. ## Current checkpoint and work +**Basic Tupile slot lifecycle — 24 October. Prototyped / Partial.** The +existing provisional Basic current-sole policy now supplies Tupile's slot and +normal-limit cleanup instead of stopping on its conservative lifecycle guard. +Observed continuous intervals distinguish turn changes from actual loss/return; +a new grant cannot erase an earlier required discard. Original intelligence use +and Advanced last-departure retention remain. Controls label the Basic policy. + +A disjoint genuine-fixture lane, bounded source-identity review and owner +integration pass **10 files70/70**, types/lint/build and two original public +Basic gameplay/rendering programmes plus four legal cleanup profiles. Actual +normal1 purchases, earned Guild income2, invasion2/cost1 and native battle +create one visitor/slot. The fifth-card source really buys card5 for1. Sources +use88/91 actions; paid last return1 and zero/one cleanup use1/2 continuations, +retain unused movement and then explicitly finish. Controlled turnover/contest/ +native-high/reacquisition/ally and intelligence probes are separate. Only a +TypeScript loop-narrowing fix followed feedback. No wider recovery audit, +complete-mode or browser/visual/deployed acceptance. No saved-room/database/ +reset/restart/deployment operation. +[Canonical policy and exact proof](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#basic-current-sole-slot-lifecycle--24-october-2026). + **Truthtrance before the respondent's turn — 24 October. Prototyped / Partial.** Same-phase questions now include a supported unused turn still in the original queue. Waiting promises remain visible without an executable early action; diff --git a/docs/HOMEWORLD_OCCUPATION_RULES.md b/docs/HOMEWORLD_OCCUPATION_RULES.md index c91891ed..9d98afec 100644 --- a/docs/HOMEWORLD_OCCUPATION_RULES.md +++ b/docs/HOMEWORLD_OCCUPATION_RULES.md @@ -2,6 +2,13 @@ Runtime follow-up, 10 September 2026: [Tupile intelligence and qualification history](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#occupation-observation-boundaries) implement confirmed observations while retaining the unresolved entitlement and expiry questions below. The source audit remains historical evidence. +The [24 October Tupile follow-up](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#basic-current-sole-slot-lifecycle--24-october-2026) +extends the provisional Basic policy below to occupied Tupile slots and their +normal-limit cleanup, superseding its historical conservative consumer exception. +Advanced retains its distinct authorized retention rule. The publisher question +remains explicit; this is usable provisional behavior under the standing +recommended-choice instruction, not newly located source authority. + ## Basic current-sole benefit policy — 9 October 2026 The content-first pass replaces Basic's blanket lifecycle block with an diff --git a/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md b/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md index 4d62de74..b37fc007 100644 --- a/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md +++ b/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md @@ -2,6 +2,89 @@ Runtime checkpoint, 10 September 2026. Native CHOAM can now request and retain a private Tupile intelligence observation in a game with proven setup history. This implements the bounded disclosure described in the [source contract](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md); occupation entitlement, expiry and occupied income remain unresolved. Advanced and unfinished expansion starts, publication and complete-rules release retain the gates in [README](../README.md). +## Basic current-sole slot lifecycle — 24 October 2026 + +**Prototyped / Partial.** Basic Tupile now follows the same visibly provisional +current-sole-controller policy as the other Basic occupied benefits. This +supersedes the conservative Tupile exception in the 5 October contract and +9 October general policy. No explicit human ruling required that exception; +under the standing recommended-choice direction, keep the printed one-slot and +normal-limit discard mechanics usable while publisher lifecycle clarification +remains unresolved. This is an application interpretation, not a GF9 ruling or +a transfer of the supplied Advanced retention rule into Basic. + +A qualified sole foreign controller and its reciprocal ally each receive one +slot while native reserves remain below high threshold. Departure, foreign +contest or native high population ends the Basic grant. Actual recorded public +snapshots identify each continuous eligible interval. A turn boundary preserves +its activation; loss and later observed restoration create a fresh activation, +even when the original qualification reducer deduplicates a same-turn fact. +Current forces without the necessary observed source cannot invent a new grant. +The existing source/lease format and historical valid qualification sources stay +readable; no saved version or setup flag is added. + +Losing holders owe the original normal-limit cleanup. A later activation cannot +lend space until that holder's earlier cleanup finishes. Original exact held-card +choices, protected cards, event identity, suspended controls and discard effects +remain. Views and repeated sync never draw a card, add spice, stack slots or +settle another player's discard. Alliance changes continue to remove only the +former holders. Basic loss restores new low-intelligence eligibility when +population/contact permit it, without clearing historical private answers or +once-per-faction use. Advanced retains its original last-force-departure rule. +Existing unmarked profiles keep their previous behavior; no other module opens. + +### Targeted proof and limits + +One disjoint original-action fixture lane, bounded source-identity review and +owner integration pass **10 files70/70**, `npm run check:quick`, production build +and two gameplay/rendered-table programmes plus four finite legal policies. +First feedback repaired only TypeScript loop narrowing: the replay tracks its +active interval by index rather than an inferred nullable object. The full +frozen batch then passes; no new source policy or numerical ruling followed. + +The passing source is based on +`ae2d0371ceb0b9119b929ba0eef616df565ace56`, tree +`de29c84b36a735c7a2e9da2614b936ead6fd0a68f18043059f84d9b05ba3c1c7`, +2,601 tracked/nonignored files. The untracked source PDF contributes only its +hash, without being committed or republished. Final checkpoint/reference prose +is reviewed against all frozen source-file hashes before push. + +`tests/fixture-basic-tupile-lifecycle.ts` uses the real public Basic three-seat +Atreides/CHOAM/Guild Homeworld start. Only unopened land Spice positions and +scalar lotteries are selected. Three actual normal1 purchases fill Guild's +original dealt hand to4. Atreides's actual earlier Imperial Basin shipment costs2 +and earns Guild2. Guild ships2 from its own World to Tupile for1. A real unarmed +battle with Guild dial1 and CHOAM dial0 puts native20 and Guild1 in Tanks, leaving +one foreign visitor and its single slot. No dealt cards, funds, forces, phase, +queue, alliance, lease or occupation history is installed. + +The no-fifth source uses88 actions; the fifth-card source uses91 and actually +buys that fifth normal-auction card for1. Both retain the same original activation +across the turn boundary. Actual last-force return costs1 and takes1 continuation +with no overfull prompt, or2 with exactly one normal-limit discard. Both preserve +unused original movement, then the runtime explicitly ends the real Guild turn. +All four minimal profiles submit one legal actually held-card cleanup and finish +the native turn; no strength or strategy calibration is claimed. + +`tests/basic-tupile-lifecycle-engine.test.ts` covers those original control paths. +The revised controlled Tupile tests separately cover turn continuity, departure, +contest, native high population, a different controller, same-turn return with +no new qualification row, no unobserved activation, old cleanup before reacquired +capacity, reciprocal ally removal and preserved Basic/Advanced intelligence use. +Existing Advanced retained-occupation, hand-capacity, cleanup and source tests pass. + +Actual GameTable and cleanup components render the persistent provisional Basic +policy, occupied/fifth-card/paid-return state, exact owned choice and busy controls. +Framework aliases/CSS stubs do not establish browser clicks, visual or deployed +acceptance. Private source hashes, logs, HTML and runtime results are in +`/home/quintine/.local/share/dune/checkpoints/20261024-basic-tupile/`; +`frozen-integrated-2/report.json` is the complete passing batch, with the earlier +feedback retained. No broad recovery/privacy/custody audit was added. + +Publisher Basic lifecycle clarification, wider interactions, full Homeworld/mode +certification, final assurance and browser/deployed acceptance remain open. No +saved-room/database/reset/restart/deployment operation was performed. + ## Semuta / original lost-slot discard — 11 October 2026 The separate fresh `semuta --homeworlds` entry reuses original occupation setup diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 3e0b383c..bf016d35 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,25 @@ # Dune implementation status +## 24 October 2026 — Basic occupied Tupile lifecycle + +Extend the existing provisional Basic current-sole policy to one-slot grants and +original normal-limit cleanup. Observed intervals keep continuous turns separate +from loss and reacquisition; old discards cannot be erased by a returning grant. +Historical valid sources, intelligence usage and Advanced retention remain. +Persistent controls label the application policy rather than claiming a GF9 ruling. + +A disjoint original-action fixture lane, bounded source review and owner +integration pass10-file70/70, types/lint/build and two actual public Basic gameplay/ +rendering programmes plus four legal cleanup profiles. Sources88/91 earn actual +cards/income, paid invasion and native battle, then one continuous slot and a real +optional fifth purchase. Paid return1 plus zero/one cleanup use1/2 continuations, +leave original movement unused and then explicitly end the turn. Controlled +lifecycle/ally/intelligence probes remain separate. Only TypeScript loop narrowing +needed feedback repair. [Canonical source, policy and exact proof](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#basic-current-sole-slot-lifecycle--24-october-2026). +Full module/modes, publisher clarification, final assurance and browser/deployed +acceptance stay open. No saved-room/database/reset/restart/deployment operation. + + ## 24 October 2026 — Truthtrance before the respondent's turn Supported unused shipments still in the original same-phase queue now admit diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index bacf1bb1..46e9c0b2 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -803,7 +803,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Topic | Decision to preserve | Detailed authority and evidence | | --- | --- | --- | -| Stable Homeworld occupation | Fresh original Homeworlds alone and source history. Advanced uses authorized p22 sole qualification retained until last-force departure; Basic uses the visibly provisional current-sole policy recorded in the Basic table above, with Basic Tupile keeping its conservative epoch gate. Actual bank/percentage income, sharing, Caladan/Ix/Richese/Giedi ownership and native defenses are connected. Native CHOAM fresh entries separately mark original Tupile slots, owner-selected normal-limit cleanup and low-query restoration; old profiles are not upgraded. No inferred controller, back-award, invented canceled-auction count, public/save conversion or unrelated overlay. Exceptional Duke remains separate. | [Source cutover](HOMEWORLD_OCCUPATION_RULES.md#occupied-economics-bidding-and-advanced-source-cutover--5-october-2026), [Basic policy](HOMEWORLD_OCCUPATION_RULES.md#basic-current-sole-benefit-policy--9-october-2026), [Tupile original lease and consumers](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#occupied-tupile-slots-and-original-cleanup--5-october-2026) | +| Stable Homeworld occupation | Fresh original Homeworlds alone and source history. Advanced uses authorized p22 sole qualification retained until last-force departure; Basic uses the visibly provisional current-sole policy recorded in the Basic table above, including Tupile through observed continuous activation sources and original normal-limit cleanup. Turn boundaries do not stack slots; reacquisition cannot erase an owed discard. Actual bank/percentage income, sharing, Caladan/Ix/Richese/Giedi ownership and native defenses are connected. Native CHOAM fresh entries separately mark original Tupile slots, owner-selected normal-limit cleanup and low-query restoration; old profiles are not upgraded. No inferred controller, back-award, invented canceled-auction count, public/save conversion or unrelated overlay. Exceptional Duke remains separate. | [Source cutover](HOMEWORLD_OCCUPATION_RULES.md#occupied-economics-bidding-and-advanced-source-cutover--5-october-2026), [Basic policy](HOMEWORLD_OCCUPATION_RULES.md#basic-current-sole-benefit-policy--9-october-2026), [Tupile Basic lifecycle and original consumers](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#basic-current-sole-slot-lifecycle--24-october-2026) | | Printed high Ecaz Homeworld victory | Fresh native Ecaz/Homeworld entry only: current high7+, reciprocal alliance jointly holds one actual uncontested stronghold and proved physically held worlds of two other native factions. Separate source joins ordinary winner union before BG prediction/final fallback; no ordinary point inflation, Tech substitute or Duke permission. Basic holdings now follow the provisional current-sole policy, so a repopulated, contested or departed world simply stops counting instead of blocking the win; old/public profiles unchanged. | [Card contract and actual original win](HOMEWORLD_RULES.md#printed-high-ecaz-alliance-victory--5-october-2026) | | Standalone E3 Nexus / Skills / original components | Fresh Ecaz OR Moritani/classic ecaz33/all14/all12, Basic/Advanced2..6, original Tech3+/Advanced Strongholds2+. Original Duke/assassination/Terror, chosen-plan Advanced Occupy and separate physical armies remain. Ecaz-led Suk rescues only its own fixed casualties; Diplomat uses the selected faction's own undialed commitment. Owner-pool skill composition is an explicit implementation inference, not a combined-skill FAQ. E3 pairs/Harkonnen Advanced/HW/previews/shared-Duke training/enhanced relocation and pending rulings remain guarded. | [Source and verified scope](NEXUS_CARD_RULES.md#standalone-e3-nexus-skills-and-original-components--5-october-2026), [coalition contract](ECAZ_OCCUPY_RULES.md#standalone-nexus--skills-coalition--5-october-2026) | | Harkonnen special-Karama forced return | Return automatically only when the combined hand equals the count owed. Keep actual drawn-card observations owner-private after settlement; legacy forced saves describe held-card history without inventing draw provenance. Existing provisional auction-payment recovery and mode gates remain. | [Contract and limits](HARKONNEN_EXCHANGE.md) | diff --git a/game/basic-tupile-occupation.ts b/game/basic-tupile-occupation.ts new file mode 100644 index 00000000..0438669b --- /dev/null +++ b/game/basic-tupile-occupation.ts @@ -0,0 +1,63 @@ +import { homeworldForceGroups, HomeworldCustodyError } from './homeworld-custody'; +import { HOMEWORLD_CARDS } from './homeworld-cards'; +import { homeworldContext } from './homeworld-game'; +import type { StableHomeworldOccupationContext } from './homeworld-stable-occupation'; + +export type BasicTupileActivation = { + event: string; + signature: string; + occupier: string; +}; + +/** The caller validates the original occupation history before replaying it. + * Qualifications establish eligibility; subsequent observed eligible intervals + * identify slot activations even when same-turn qualification rows deduplicate. + * Continuous control, including a turn boundary, keeps the original activation. + * This is the provisional Basic current-sole policy, not Advanced retention. */ +export function basicTupileActivations(game: StableHomeworldOccupationContext): { + epochs: BasicTupileActivation[]; + current: BasicTupileActivation | null; + blocked: string | null; +} { + const native = game.players.find(player => player.faction === 'choam'); + const history = game.homeworldOccupationHistory; + if (game.advanced || !native || !history || !game.homeworlds?.custody) + throw new HomeworldCustodyError('Basic Tupile activation requires its original Basic occupation history.'); + const high = HOMEWORLD_CARDS.find(card => card.id === 'tupile')!.high.reserves.min; + type Group = [string, number, number]; + const eligible = (groups: readonly Group[]): string | null => { + const nativeGroup = groups.find(([id]) => id === native.id); + if (!nativeGroup || nativeGroup[1] + nativeGroup[2] >= high) return null; + const foreign = groups.filter(([id, normal, elite]) => id !== native.id && normal + elite > 0); + return foreign.length === 1 ? foreign[0][0] : null; + }; + const qualifications = new Map(); + for (const fact of history.qualifications) if (fact.world === 'homeworld:choam') { + const players = qualifications.get(fact.event) ?? []; + players.push(fact.player); + qualifications.set(fact.event, players); + } + const qualified = new Set(), epochs: BasicTupileActivation[] = []; + let activeIndex: number | null = null; + for (const observation of history.sources) { + for (const player of qualifications.get(observation.event) ?? []) qualified.add(player); + const snapshot = JSON.parse(history.snapshots[observation.snapshot]) as [string, Group[]][]; + const groups = snapshot.find(([world]) => world === 'homeworld:choam'); + if (!groups) throw new HomeworldCustodyError('The original Basic Tupile observation lost its native world.'); + const candidate = eligible(groups[1]); + if (activeIndex !== null && epochs[activeIndex].occupier !== candidate) activeIndex = null; + if (activeIndex === null && candidate && qualified.has(candidate) && observation.cause !== 'setup') { + activeIndex = epochs.length; + epochs.push({ event: observation.event, signature: observation.signature, occupier: candidate }); + } + } + const world = homeworldForceGroups(homeworldContext(game), game.homeworlds.custody) + .find(world => world.id === 'homeworld:choam')!; + const current = activeIndex === null ? null : epochs[activeIndex]; + const actual = eligible(Object.entries(world.forces).map(([id, forces]): Group => [id, forces.normal, forces.elite])); + if (actual !== current?.occupier) { + return { epochs, current: null, blocked: actual && qualified.has(actual) + ? 'The current Basic Tupile activation must be observed before its benefit settles.' : null }; + } + return { epochs, current, blocked: null }; +} diff --git a/game/homeworld-occupied-tupile.ts b/game/homeworld-occupied-tupile.ts index d03307d6..48854886 100644 --- a/game/homeworld-occupied-tupile.ts +++ b/game/homeworld-occupied-tupile.ts @@ -2,6 +2,7 @@ import { nativeHandLimit } from './engine'; import { HomeworldCustodyError } from './homeworld-custody'; import { homeworldContext } from './homeworld-game'; import { validateHomeworldOccupationHistory } from './homeworld-occupation-history'; +import { basicTupileActivations } from './basic-tupile-occupation'; import { quoteStableHomeworldOccupation, type StableHomeworldOccupationContext, @@ -97,7 +98,7 @@ const cleanupEvent = (lease: OccupiedTupileLease, player: string) => /** Printed original Tupile occupied face (homeworld-cards.ts): one slot EACH, * never one per army, and loss of CHOAM's low advantage. Stable history owns all - * Basic ruling gates and the authorized Advanced retained-occupation lifecycle. + * provisional Basic current-sole and authorized Advanced retained lifecycles. * The extra preview marker is original fresh-entry-only, not a public option. */ export function quoteOccupiedTupileAuthority(game: OccupiedTupileContext): OccupiedTupileAuthority { const native = game.players.find(player => player.faction === 'choam'); @@ -108,9 +109,19 @@ export function quoteOccupiedTupileAuthority(game: OccupiedTupileContext): Occup if (!game.homeworldTupilePreview || !native) return none; if (!game.homeworldOccupationPreview) return Object.freeze({ ...base, status: 'unknown', lowIntelligence: 'unknown', blocked: 'Tupile slots require the original fresh occupation profile.' }); - const quote = quoteStableHomeworldOccupation(game, 'tupile', { basic: 'epoch' }); - const entitlement = quote.entitlement; - const blocked = quote.blocked ?? (entitlement?.ally === native.id + const quote = quoteStableHomeworldOccupation(game, 'tupile'); + let entitlement = quote.entitlement; + let activationBlock: string | null = null; + if (!game.advanced && game.homeworlds?.custody && game.homeworlds.historyVersion === 1 && game.homeworldOccupationHistory) { + const activation = basicTupileActivations(game); + activationBlock = activation.blocked; + if (entitlement) { + if (activation.current?.occupier === entitlement.occupier) + entitlement = { ...entitlement, qualification: activation.current.event }; + else activationBlock ??= 'The current Basic Tupile activation needs its original observed source.'; + } + } + const blocked = quote.blocked ?? activationBlock ?? (entitlement?.ally === native.id ? 'The native CHOAM cannot ally with its own Homeworld occupier.' : null); if (blocked) return Object.freeze({ ...base, status: 'unknown', lowIntelligence: 'unknown', blocked }); if (!entitlement) return none; @@ -143,6 +154,19 @@ function originalEpochs(game: OccupiedTupileContext): Map(); + if (!game.advanced) { + // Keep all previously valid Basic qualification sources readable. New slot + // activations also bind their actual observed interval, never an old lease. + for (const fact of history.qualifications.filter(fact => fact.world === 'homeworld:choam')) { + const source = history.sources.find(source => source.event === fact.event)!; + if (!epochs.has(source.event)) epochs.set(source.event, sourceCopy({ world: 'homeworld:choam', card: 'tupile', native: native.id, + occupier: fact.player, qualification: source.event, signature: source.signature })); + } + for (const source of basicTupileActivations(game).epochs) + epochs.set(source.event, sourceCopy({ world: 'homeworld:choam', card: 'tupile', native: native.id, + occupier: source.occupier, qualification: source.event, signature: source.signature })); + return epochs; + } let retained: string | null = null; for (const observation of history.sources) { const snapshot = JSON.parse(history.snapshots[observation.snapshot]) as [string, [string, number, number][]][]; @@ -152,13 +176,11 @@ function originalEpochs(game: OccupiedTupileContext): Map id === retained)) retained = null; const candidate = observation.cause !== 'setup' && present.length === 1 && present[0][0] !== native.id ? present[0][0] : null; - if (game.advanced ? !retained && candidate !== null : history.qualifications.some(fact => - fact.world === 'homeworld:choam' && fact.event === observation.event)) { - const occupier = game.advanced ? candidate! : history.qualifications.find(fact => - fact.world === 'homeworld:choam' && fact.event === observation.event)!.player; + if (!retained && candidate !== null) { + const occupier = candidate; epochs.set(observation.event, sourceCopy({ world: 'homeworld:choam', card: 'tupile', native: native.id, occupier, qualification: observation.event, signature: observation.signature })); - if (game.advanced) retained = occupier; + retained = occupier; } } return epochs; @@ -231,7 +253,7 @@ function syncResult(state: OccupiedTupileState, authority: OccupiedTupileAuthori /** Persist next state and backing flags atomically at the original occupation * observation / reciprocal-alliance boundary. Repeated view/sync is idempotent. - * Unknown Basic expiry cannot invent departure, cleanup or a new beneficiary. + * Basic changes use an observed activation rather than reviving a closed grant. * No card/spice draw occurs here. Parent owns control-suffix suspension/restoration. */ export function syncOccupiedTupileLease(game: OccupiedTupileContext, state: OccupiedTupileState): OccupiedTupileSync { validateOccupiedTupileState(state, game); diff --git a/game/homeworld-stable-occupation.ts b/game/homeworld-stable-occupation.ts index 0b5a44d1..b7d62530 100644 --- a/game/homeworld-stable-occupation.ts +++ b/game/homeworld-stable-occupation.ts @@ -29,8 +29,8 @@ type Player = Game['players'][number]; export type StableHomeworldOccupationOptions = { /** Basic-only policy selector. `currentSole` is the provisional default used * by occupied income, Bidding, defenses and percentage effects. `epoch` keeps - * the conservative original-epoch gate for Basic Tupile slot leasing while - * the retention/turnover ruling stays open. Advanced ignores this option. */ + * the historical conservative original-epoch selector. Tupile now separately + * replays observed Basic activations. Advanced ignores this option. */ basic?: 'currentSole' | 'epoch'; }; @@ -39,8 +39,8 @@ export type StableHomeworldOccupationOptions = { * requires sole foreign presence and survives until that occupier leaves. * Basic defaults to a visibly provisional current-sole-controller policy; the * sole-foreign-controller entitlement expires immediately on departure, - * contest or native repopulation. Tupile keeps the conservative Basic epoch - * gate documented in its own module. Neither mode invents an unobserved event. */ + * contest or native repopulation. Tupile adds observed activation identity in + * its own module. Neither mode invents an unobserved event. */ export function quoteStableHomeworldOccupation( game: StableHomeworldOccupationContext, cardId: HomeworldId, diff --git a/game/reference.ts b/game/reference.ts index deacf564..afebc3d7 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4199,7 +4199,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - "All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Southern and Tleilax preserve the exact eligible revival group through one optional placement, with held income and explicit unresolved destination rules. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure; Basic lifecycle, exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.", + "All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Southern and Tleilax preserve the exact eligible revival group through one optional placement, with held income and explicit unresolved destination rules. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.", evidence: [ 'game/faction-module-profile.ts', 'game/reinforcements-homeworld.ts', @@ -4237,6 +4237,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'game/homeworld-occupied-bidding.ts', 'game/homeworld-occupied-defenses.ts', 'game/homeworld-occupied-tupile.ts', + 'game/basic-tupile-occupation.ts', 'game/ecaz-homeworld-victory.ts', 'game/victory-quote.ts', 'components/victory-progress.tsx', @@ -4256,7 +4257,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Player controls', status: 'Partial', detail: - "Card inspection is available in the reference. Development tables display native reserves, explicit Imperial shipment sources, world-to-world shipment and Guild transport from Arrakis with costs and pledged funding, Guild interception, optional Junction half/full offers and typed sponsored transport, transfers between Kaitain and Salusa, charity payment sources, current revival allowances, population-based movement restrictions and one/two-advisor choices, native battle bonuses, Homeworld casualties, replacement controls, Tupile sale explanations, shared end-of-Bidding actions, Ecaz’s private poison-income record, current low-population payment splits, automatic Giedi collection history, count-dependent Ambassador destinations and the optional revived-group placement. Fresh occupation adds bounded actual percentage allocation, real bonus receiver choice, source-authorized Ix pool/cache controls and separate native Richese terms. Normal starts remain unavailable. Four current original-action390px journeys show the unused-price warning, full3, poor2 then replacement half2, ordinary purchased2 alternative and full6 normal Guild income; movement remains available.", + "Card inspection is available in the reference. Development tables display native reserves, explicit Imperial shipment sources, world-to-world shipment and Guild transport from Arrakis with costs and pledged funding, Guild interception, optional Junction half/full offers and typed sponsored transport, transfers between Kaitain and Salusa, charity payment sources, current revival allowances, population-based movement restrictions and one/two-advisor choices, native battle bonuses, Homeworld casualties, replacement controls, Tupile sale explanations, shared end-of-Bidding actions, Ecaz’s private poison-income record, current low-population payment splits, automatic Giedi collection history, count-dependent Ambassador destinations and the optional revived-group placement. Fresh occupation adds bounded actual percentage allocation, real bonus receiver choice, source-authorized Ix pool/cache controls and separate native Richese terms. Supported starts remain previews. Basic Tupile controls explicitly show provisional current-sole loss and the earlier normal-limit discard. Four current original-action390px journeys show the unused-price warning, full3, poor2 then replacement half2, ordinary purchased2 alternative and full6 normal Guild income; movement remains available.", evidence: [ 'components/homeworld-cards.tsx', 'components/homeworld-table.tsx', @@ -4317,7 +4318,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Verification', status: 'Partial', detail: - "Mixed E1/E2 and paired E3 Nexus adds31 original physical programmes plus2 Loyalty-stock regressions. Final489/489 affected checks across42 files pass with types/lint. All64 original families finish258/288 six-seat games with338931 accepted/339426 attempted actions and9013 JSON across three frozen trees and preserved-prefix continuations. All30 remaining source-guard traces are read. Actual390px original Ixian offer/training, typed native and foreign-world payment/free Suboid plan and paired Ecaz foreign entry/living shared Duke preserve20 physical counters. Human Ixian loses, so no winning Suk rescue is claimed. Earlier qualified evidence remains canonical; full rules, assurance, calibration and deployed acceptance remain open. Current Junction follow-up passes131/131 affected cases and14 runtime/policy paths (11 original/3 controlled), with four actual original-action390px journeys. No saved/deployed acceptance is claimed.", + "Basic Tupile current-sole follow-up passes70/70 cases in10 files, types/lint/build, two original public gameplay/rendering programmes and four legal cleanup profiles. Its provisional policy and broader acceptance limits stay explicit. Earlier mixed E1/E2 and paired E3 Nexus added31 original physical programmes plus2 Loyalty-stock regressions. Final489/489 affected checks across42 files pass with types/lint. All64 original families finish258/288 six-seat games with338931 accepted/339426 attempted actions and9013 JSON across three frozen trees and preserved-prefix continuations. All30 remaining source-guard traces are read. Actual390px original Ixian offer/training, typed native and foreign-world payment/free Suboid plan and paired Ecaz foreign entry/living shared Duke preserve20 physical counters. Human Ixian loses, so no winning Suk rescue is claimed. Earlier qualified evidence remains canonical; full rules, assurance, calibration and deployed acceptance remain open. Current Junction follow-up passes131/131 affected cases and14 runtime/policy paths (11 original/3 controlled), with four actual original-action390px journeys. No saved/deployed acceptance is claimed.", evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/homeworld-classic-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-runtime.test.ts', 'tests/homeworld-skills-discovery-runtime.test.ts', @@ -4403,6 +4404,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/homeworld-occupied-private-lots.test.ts', 'tests/homeworld-occupied-tupile.test.ts', 'tests/homeworld-occupied-tupile-runtime.test.ts', + 'tests/basic-tupile-lifecycle-engine.test.ts', + 'tests/fixture-basic-tupile-lifecycle.ts', 'tests/ecaz-homeworld-victory.test.ts', 'tests/ecaz-homeworld-victory-runtime.test.ts', 'tests/grumman-collection.test.ts', diff --git a/tests/basic-tupile-lifecycle-engine.test.ts b/tests/basic-tupile-lifecycle-engine.test.ts new file mode 100644 index 00000000..e9d2d4d5 --- /dev/null +++ b/tests/basic-tupile-lifecycle-engine.test.ts @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { handLimit, viewGame } from '../game/engine'; +import { botActions } from '../game/bots'; +import { + BASIC_TUPILE_WORLD, actBasicTupile, completeBasicTupileLifecycle, createBasicTupileLifecycleFixture, + departBasicTupile, basicTupilePlayer as player, type BasicTupileLifecycleFixture, +} from './fixture-basic-tupile-lifecycle'; + +function source(f: BasicTupileLifecycleFixture) { + assert.equal(f.game.advanced, false); + assert.deepEqual(f.game.expansions, ['choam']); + assert.ok(f.actions.some(step => step.actor === f.game.host && step.action.type === 'homeworlds' && step.action.enabled === true)); + assert.ok(f.actions.some(step => step.actor === f.game.host && step.action.type === 'start')); + assert.equal(f.game.homeworldOccupationPreview, true); + assert.equal(f.game.homeworldTupilePreview, true); + assert.equal(player(f.afterSetup).hand.length, 1); + assert.equal(f.purchases.filter(purchase => purchase.before.turn === 1).length, 3); + assert.equal(player(f.filledFour).hand.length, 4); + assert.equal(player(f.beforeFirstAuction).spice - player(f.filledFour).spice, 3); + assert.equal(player(f.beforeIncome, f.incomePayer).spice - player(f.afterIncome, f.incomePayer).spice, 2); + assert.equal(player(f.afterIncome).spice - player(f.beforeIncome).spice, 2); + assert.equal(f.invasionQuote.cost, 1); + assert.equal(player(f.beforeInvasion).spice - player(f.afterInvasion).spice, 1); + assert.equal(player(f.beforeInvasion).reserves - player(f.afterInvasion).reserves, 2); + assert.deepEqual(f.afterInvasion.homeworlds!.custody!.visitors[BASIC_TUPILE_WORLD].guild, { normal: 2, elite: 0 }); + assert.equal(player(f.occupied, f.native).reserves, 0); + assert.equal(player(f.occupied, f.native).tanks, 20); + assert.equal(player(f.occupied).tanks, 1); + assert.deepEqual(f.occupied.homeworlds!.custody!.visitors[BASIC_TUPILE_WORLD].guild, { normal: 1, elite: 0 }); + assert.equal(player(f.occupied).tupileHandSlot, true); + assert.equal(handLimit(player(f.occupied)), 5); + assert.equal(f.occupied.homeworldTupileState!.leases.length, 1); + assert.equal(f.game.homeworldTupileState!.leases.length, 1); + assert.equal(f.game.homeworldTupileState!.current, 0); + assert.equal(f.occupied.homeworldTupileState!.leases[0].source.qualification, + f.game.homeworldTupileState!.leases[0].source.qualification); + assert.equal(viewGame(f.game, f.occupier).homeworldTupileBlocked, null); + assert.equal(f.sourceActionCount, f.actions.length); +} +function departed(f: BasicTupileLifecycleFixture, result: ReturnType) { + assert.equal(result.departure.quote.cost, 1); + assert.equal(player(result.game).spice, player(f.game).spice - 1); + assert.equal(player(result.game).reserves, player(f.game).reserves + 1); + assert.equal(result.game.homeworlds!.custody!.visitors[BASIC_TUPILE_WORLD], undefined); + assert.equal(player(result.game).tanks, player(f.game).tanks); + assert.equal(player(result.game).tupileHandSlot, undefined); + assert.equal(handLimit(player(result.game)), 4); + assert.equal(result.game.homeworldTupileState!.current, null); + assert.equal(result.game.homeworldTupileState!.cleanups.length, 1); + assert.equal(result.game.homeworldTupileState!.cleanups[0].status, 'completed'); + assert.deepEqual(result.game.homeworldTupileState!.leases[0].lost, [f.occupier]); + assert.equal(result.game.active, f.occupier); + assert.equal(result.game.phase, 5); + assert.equal(player(result.game).shipped, true); + assert.equal(player(result.game).moved, 0); + assert.ok(result.game.movementRemaining!.includes(f.occupier)); + assert.equal(viewGame(result.game, f.occupier).homeworldTupileBlocked, null); + assert.equal(result.actions.filter(step => step.action.type === 'homeworldShip').length, 1); + assert.throws(() => actBasicTupile(result.game, f.occupier, result.departure.step.action)); +} + +void test('genuine Basic Tupile battle grants one continuous slot and actual paid last departure loses it without a source block', () => { + const f = createBasicTupileLifecycleFixture({ fifthCard: false }); source(f); + const result = completeBasicTupileLifecycle(f); departed(f, result); + assert.equal(result.cleanups.length, 0, 'The original four-card hand needs no extra discard prompt'); + assert.equal(player(result.game).hand.length, 4); + assert.deepEqual(player(result.game).hand, player(f.game).hand); +}); + +void test('genuine fifth paid auction card uses the Basic Tupile slot and one actual departure cleanup discards exactly its excess', () => { + const f = createBasicTupileLifecycleFixture(); source(f); + assert.ok(f.beforeFifth && f.acquiredFifth); + assert.equal(player(f.beforeFifth).hand.length, 4); + assert.equal(handLimit(player(f.beforeFifth)), 5); + assert.equal(player(f.acquiredFifth).hand.length, 5); + assert.equal(player(f.beforeFifth).spice - player(f.acquiredFifth).spice, 1); + assert.equal(f.purchases.filter(purchase => purchase.before.turn === 2).length, 1); + const fifth = f.purchases.find(purchase => purchase.before.turn === 2)!; + assert.ok(player(f.game).hand.some(card => card.id === fifth.card)); + const result = completeBasicTupileLifecycle(f); departed(f, result); + assert.equal(result.cleanups.length, 1); + const cleanup = result.cleanups[0], offer = viewGame(cleanup.before, f.occupier).homeworldTupileCleanup!; + assert.equal(offer.excess, 1); assert.equal(offer.limit, 4); assert.equal(offer.blocked, null); + const cards = cleanup.step.action.cards as string[]; + assert.equal(cards.length, 1); + assert.ok(player(f.game).hand.some(card => card.id === cards[0])); + assert.equal(result.game.discard.filter(card => card.id === cards[0]).length, + f.game.discard.filter(card => card.id === cards[0]).length + 1); + assert.equal(player(result.game).hand.length, 4); + assert.throws(() => actBasicTupile(result.game, f.occupier, cleanup.step.action)); +}); + +void test('all four minimal profiles resolve the genuine Basic excess using an actually held card and retain original unused movement', () => { + const f = createBasicTupileLifecycleFixture(), departure = departBasicTupile(f); + assert.equal(departure.game.decision?.kind, 'homeworldTupileCleanup'); + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const view = viewGame(departure.game, f.occupier); + view.players.find(player => player.id === f.occupier)!.bot = difficulty; + const offer = view.homeworldTupileCleanup!; + const action = botActions(view).find(action => action.type === 'decision' && action.event === offer.event); assert.ok(action); + assert.equal((action.cards as string[]).length, 1); + assert.ok(offer.eligibleCards.includes((action.cards as string[])[0])); + const game = actBasicTupile(structuredClone(departure.game), f.occupier, action); + assert.equal(player(game).hand.length, 4); + assert.equal(player(game).moved, 0); + assert.equal(player(game).shipped, true); + assert.equal(game.active, f.occupier); + assert.equal(viewGame(game, f.occupier).homeworldTupileBlocked, null); + assert.equal(game.homeworldTupileState!.cleanups[0].status, 'completed'); + assert.doesNotThrow(() => actBasicTupile(game, f.occupier, { type: 'endMovement' })); + } +}); diff --git a/tests/fixture-basic-tupile-lifecycle.ts b/tests/fixture-basic-tupile-lifecycle.ts new file mode 100644 index 00000000..f3f8ec8a --- /dev/null +++ b/tests/fixture-basic-tupile-lifecycle.ts @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, handLimit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; + +export const BASIC_TUPILE_WORLD = 'homeworld:choam'; +export type BasicTupileStep = { actor: string; action: Action }; +export const basicTupilePlayer = (game: Game, actor = 'guild') => { + const player = game.players.find(player => player.id === actor); assert.ok(player); return player; +}; +export const basicTupileClean = (game: Game) => !game.response && !game.phaseOpening && + !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard; +export function actBasicTupile(game: Game, actor: string, action: Action, actions: BasicTupileStep[] = []): Game { + const next = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action)); + actions.push({ actor, action: structuredClone(action) }); return next; +} +/** Native controls only; a cleanup and unsealed human plan remain owned + * choices. Never spend native revival to rebuild the conquered world. */ +export function nextBasicTupileStep(game: Game): BasicTupileStep | null { + if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) { + const decision = game.decision; + if (decision?.kind === 'homeworldTupileCleanup') return null; + if (decision?.kind === 'homeworldShipmentGuild') return { actor: decision.player, + action: { type: 'decision', event: decision.event, allow: true } }; + if (decision?.kind === 'homeworldDefense') return { actor: decision.player, + action: { type: 'decision', event: decision.event, use: false } }; + if (decision?.kind === 'homeworldRevivalDeployment' || decision?.kind === 'caladanReinforcement' || + decision?.kind === 'grummanCollection') return { actor: decision.player, + action: { type: 'decision', event: decision.event, decline: true } }; + if (game.biddingEnd) { + const actor = game.biddingEnd.owners.find(actor => !game.biddingEnd!.ready.includes(actor)); + if (actor) return { actor, action: { type: 'biddingEnd', event: game.biddingEnd.event, mode: 'ready' } }; + } + } + return nextStrongholdFactionsNativeStep(game); +} +export function advanceBasicTupile(game: Game, until: (state: Game) => boolean, actions: BasicTupileStep[] = []): Game { + for (let count = 0; count < 2200; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original Basic Tupile programme ended before its next control'); + const step = nextBasicTupileStep(game); assert.ok(step, 'Choose the actual owned Tupile cleanup or human Battle Plan explicitly'); + game = actBasicTupile(game, step.actor, step.action, actions); + } + throw Error('The original Basic Tupile programme did not reach its finite boundary'); +} +function orderOriginalLand(game: Game) { + const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(index >= 0); + game.spiceDeck.unshift(game.spiceDeck.splice(index, 1)[0]); +} +type BasicTupilePurchase = { before: Game; step: BasicTupileStep; declared: Game; card: string }; +/** One-spice bids in original ordinary lots, never direct hand assignment. */ +function buyUntil(game: Game, count: number, actions: BasicTupileStep[]) { + const purchases: BasicTupilePurchase[] = []; + for (let step = 0; step < 750; step++) { + if (basicTupilePlayer(game).hand.length === count) return { game, purchases }; + let next: BasicTupileStep | null = null; + if (basicTupileClean(game) && game.auction?.active === 'guild' && game.auction.bid === 0) { + const before = structuredClone(game), card = game.auction.cards[game.auction.index].id; + next = { actor: 'guild', action: { type: 'bid', amount: 1, allyPayment: 0 } }; + game = actBasicTupile(game, next.actor, next.action, actions); + purchases.push({ before, step: next, declared: structuredClone(game), card }); continue; + } + next = nextBasicTupileStep(game); assert.ok(next, 'The actual ordinary auction needs its original next control'); + game = actBasicTupile(game, next.actor, next.action, actions); + } + throw Error('The original funded ordinary purchases did not reach their genuine hand size'); +} + +/** Public Basic Homeworld start; no development initializer or played-save + * reflag. Three paid normal1 lots fill the original Guild hand to four. Real + * Atreides shipment income funds the actual invasion and later paid return. + * A native no-weapon battle creates sole Tupile control and its original slot. + * Only unopened land Spice positions/scalar lotteries are controlled. */ +export function createBasicTupileLifecycleFixture(options: { fifthCard?: boolean } = {}) { + const fifthCard = options.fifthCard ?? true; + let game = createGame('BASICTUPILELIFECYCLE', newPlayer('atreides', 'Atreides', 'atreides'), false, ['choam']); + for (const faction of ['choam', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction)); + const actions: BasicTupileStep[] = []; + const act = (actor: string, action: Action) => { game = actBasicTupile(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceBasicTupile(game, until, actions); }; + act(game.host, { type: 'homeworlds', enabled: true }); + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); + act(game.host, { type: 'start' }); + const setup = structuredClone(game); + reach(state => state.status === 'playing'); + assert.equal(game.homeworldOccupationPreview, true); + assert.equal(game.homeworldTupilePreview, true); + const afterSetup = structuredClone(game); + orderOriginalLand(game); + reach(state => state.phase === 3 && !!state.auction && basicTupileClean(state)); + const beforeFirstAuction = structuredClone(game); + const first = buyUntil(game, 4, actions); game = first.game; + const purchases = [...first.purchases], filledFour = structuredClone(game); + reach(state => state.phase === 5 && state.active === 'atreides' && basicTupileClean(state)); + const beforeIncome = structuredClone(game), nativeSources = nativeShipmentSources(viewGame(game, 'atreides'), 1, 0); assert.ok(nativeSources); + const incomeShipment: BasicTupileStep = { actor: 'atreides', action: { type: 'ship', + territory: 'imperial_basin', sector: 9, amount: 1, elite: 0, allyPayment: 0, homeworldSources: nativeSources } }; + act(incomeShipment.actor, incomeShipment.action); reach(basicTupileClean); + const afterIncome = structuredClone(game); + act('atreides', { type: 'endMovement' }); + reach(state => state.phase === 5 && state.active === 'guild' && basicTupileClean(state)); + const beforeInvasion = structuredClone(game); + const invasionQuote = homeworldShipmentChoice(viewGame(game, 'guild'), BASIC_TUPILE_WORLD, + { 'homeworld:guild': { normal: 2, elite: 0 } }); + assert.ok(invasionQuote.action, invasionQuote.blocked ?? 'The actual native Guild invasion must be available'); + const invasion: BasicTupileStep = { actor: 'guild', action: invasionQuote.action }; + act(invasion.actor, invasion.action); reach(game => basicTupileClean(game) && !game.pendingHomeworldShipment); + const afterInvasion = structuredClone(game); + act('guild', { type: 'endMovement' }); + reach(state => state.phase === 6 && !!state.active && basicTupileClean(state)); + const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices + .find(choice => choice.territory === BASIC_TUPILE_WORLD); assert.ok(choice); + const chooseBattle: BasicTupileStep = { actor: choice.chooser, action: { type: 'chooseBattle', + territory: BASIC_TUPILE_WORLD, target: choice.chooser === 'guild' ? 'choam' : 'guild' } }; + act(chooseBattle.actor, chooseBattle.action); + reach(state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && basicTupileClean(state)); + const battle = structuredClone(game); + const winner = [...basicTupilePlayer(game, 'guild').leaders].filter(leader => !leader.dead && !leader.usedAt) + .sort((a, b) => b.strength - a.strength)[0]; + const native = [...basicTupilePlayer(game, 'choam').leaders].filter(leader => !leader.dead && !leader.usedAt) + .sort((a, b) => a.strength - b.strength)[0]; + assert.ok(winner && native && winner.strength + 1 > native.strength); + const plans: BasicTupileStep[] = [ + { actor: 'guild', action: { type: 'battlePlan', leader: winner.id, dial: 1, support: 0 } }, + { actor: 'choam', action: { type: 'battlePlan', leader: native.id, dial: 0, support: 0 } }, + ]; + for (const step of plans) act(step.actor, step.action); + reach(state => !state.battle && basicTupileClean(state)); + const occupied = structuredClone(game); + assert.equal(game.homeworlds!.custody!.visitors[BASIC_TUPILE_WORLD].guild.normal, 1); + assert.equal(basicTupilePlayer(game, 'choam').reserves, 0); + assert.equal(basicTupilePlayer(game).tupileHandSlot, true); + assert.equal(handLimit(basicTupilePlayer(game)), 5); + reach(state => state.turn === 2 && state.phase === 1 && basicTupileClean(state)); + orderOriginalLand(game); + let beforeFifth: Game | null = null, acquiredFifth: Game | null = null; + if (fifthCard) { + reach(state => state.turn === 2 && state.phase === 3 && !!state.auction && basicTupileClean(state)); + beforeFifth = structuredClone(game); + const fifth = buyUntil(game, 5, actions); game = fifth.game; purchases.push(...fifth.purchases); + acquiredFifth = structuredClone(game); + } + reach(state => state.turn === 2 && state.phase === 5 && state.active === 'guild' && basicTupileClean(state)); + assert.equal(basicTupilePlayer(game).hand.length, fifthCard ? 5 : 4); + assert.equal(game.homeworldTupileState!.leases.length, 1, 'Continuous original control across a turn grants no additional slot'); + return { game, lobby, setup, afterSetup, beforeFirstAuction, filledFour, purchases, beforeIncome, incomeShipment, afterIncome, + beforeInvasion, invasionQuote, invasion, afterInvasion, beforeBattle, chooseBattle, battle, plans, occupied, + beforeFifth, acquiredFifth, fifthCard, occupier: 'guild', native: 'choam', incomePayer: 'atreides', + sourceActionCount: actions.length, actions, + staging: ['Public Basic Atreides/CHOAM/Guild + original Homeworld selection/start, genuine paid auctions and shipments, and an actual low-dial native Homeworld battle provide every card, force and occupation observation. Only unopened land Spice positions and scalar lotteries are selected. No hand, resource, force, phase, alliance, lease or history is installed; this is not the conserved Semuta/Tupile fixture.'] }; +} +export type BasicTupileLifecycleFixture = ReturnType; +export function departBasicTupile(fixture: BasicTupileLifecycleFixture) { + const before = structuredClone(fixture.game), actions: BasicTupileStep[] = []; + const quote = homeworldShipmentChoice(viewGame(before, fixture.occupier), 'homeworld:guild', + { [BASIC_TUPILE_WORLD]: { normal: 1, elite: 0 } }); + assert.ok(quote.action, quote.blocked ?? 'The actual last Guild visitor must be able to return'); + const step: BasicTupileStep = { actor: fixture.occupier, action: quote.action }; + let game = actBasicTupile(before, step.actor, step.action, actions); + game = advanceBasicTupile(game, game => game.decision?.kind === 'homeworldTupileCleanup' || + basicTupileClean(game) && !game.pendingHomeworldShipment, actions); + return { game, before, quote, step, actions }; +} +/** Complete only the original hand cleanup, retaining the occupier's current + * movement turn and unused ordinary movement. No second shipment is issued. */ +export function completeBasicTupileLifecycle(fixture: BasicTupileLifecycleFixture) { + const departure = departBasicTupile(fixture), actions = [...departure.actions]; + let game = departure.game; + const cleanups: { before: Game; step: BasicTupileStep; after: Game }[] = []; + for (let count = 0; game.decision?.kind === 'homeworldTupileCleanup' && count < 3; count++) { + const before = structuredClone(game), actor = game.decision.player; + const offer = viewGame(game, actor).homeworldTupileCleanup; assert.ok(offer && !offer.blocked); + const step: BasicTupileStep = { actor, action: { type: 'decision', event: offer.event, + cards: offer.eligibleCards.slice(0, offer.excess) } }; + game = actBasicTupile(game, step.actor, step.action, actions); + cleanups.push({ before, step, after: structuredClone(game) }); + } + game = advanceBasicTupile(game, game => basicTupileClean(game) && !game.pendingHomeworldShipment, actions); + return { game, departure, cleanups, actions }; +} diff --git a/tests/homeworld-occupied-tupile.test.ts b/tests/homeworld-occupied-tupile.test.ts index 467de86a..71d0e037 100644 --- a/tests/homeworld-occupied-tupile.test.ts +++ b/tests/homeworld-occupied-tupile.test.ts @@ -177,13 +177,13 @@ void test('Advanced turnover and same-player return are new original epochs; pen validateOccupiedTupileState(final.state, game); }); -void test('Basic unobserved turn, contest, native repopulation, competing source and departure remain unknown/read-only, never guessed cleanup', () => { +void test('Basic current-sole policy keeps continuous turns and closes or transfers slots at observed control changes', () => { for (const mode of ['turn', 'contest', 'nativeHigh', 'departure', 'competitor'] as const) { const { game, cards } = occupiedTupileFixture(false); const first = syncOccupiedTupileLease(game, createOccupiedTupileState()); if (mode === 'turn') { game.turn++; - // Controlled incomplete boundary: no current-turn qualification is fabricated. + // A new turn alone changes neither the original activation nor its slot. } else if (mode === 'nativeHigh') { restoreTupileNative(game, 11); recordTupilePosition(game, 'pending-native-repopulation'); @@ -196,14 +196,18 @@ void test('Basic unobserved turn, contest, native repopulation, competing source recordTupilePosition(game, `pending-basic-${mode}`); } const before = structuredClone({ game, state: first.state }), quote = quoteOccupiedTupileAuthority(game); - assert.equal(quote.status, 'unknown'); - assert.equal(quote.lowIntelligence, 'unknown'); - assert.ok(quote.blocked); - assert.equal(quote.source, null); - assert.deepEqual(quote.holders, []); + assert.equal(quote.status, mode === 'turn' || mode === 'competitor' ? 'occupied' : 'unoccupied'); + assert.equal(quote.lowIntelligence, mode === 'turn' || mode === 'competitor' ? 'suppressed' : 'available'); + assert.equal(quote.blocked, null); const result = syncOccupiedTupileLease(game, first.state); - assert.equal(result.state.current, first.state.current); - assert.deepEqual(result.pending, []); + if (mode === 'turn') { + assert.deepEqual(quote.source, first.authority.source); + assert.equal(result.state, first.state); + assert.deepEqual(result.pending, []); + } else { + assert.deepEqual(result.pending.map(row => row.player), ['occupier', 'ally']); + assert.deepEqual(result.slotHolders, mode === 'competitor' ? ['fremen'] : []); + } assert.deepEqual({ game, state: first.state }, before); assert.deepEqual(biddingPhysicalIds(game), cards); } @@ -223,6 +227,63 @@ void test('Basic observed continuous turn qualification keeps one existing slot assert.deepEqual(biddingPhysicalIds(game), cards); }); +void test('Basic same-turn departure and return needs a new observed activation and settles the old excess before regaining slots', () => { + const { game, cards } = occupiedTupileFixture(false); + const first = syncOccupiedTupileLease(game, createOccupiedTupileState()); + stageBiddingHandSize(game, 'occupier', 5); + stageBiddingHandSize(game, 'ally', 9); + const qualifications = game.homeworldOccupationHistory!.qualifications.length; + removeTupileForces(game, 'occupier'); + recordTupilePosition(game, 'basic-observed-last-departure'); + const closed = syncOccupiedTupileLease(game, first.state); + assert.equal(closed.state.current, null); + addTupileForces(game, 'occupier'); + const before = structuredClone(game); + assert.equal(quoteOccupiedTupileAuthority(game).status, 'unknown'); + assert.match(quoteOccupiedTupileAuthority(game).blocked!, /observed/); + assert.deepEqual(game, before); + recordTupilePosition(game, 'basic-observed-same-turn-return'); + assert.equal(game.homeworldOccupationHistory!.qualifications.length, qualifications, + 'The original qualification reducer deliberately deduplicates this same-turn fact'); + const renewed = syncOccupiedTupileLease(game, closed.state); + assert.equal(renewed.authority.status, 'occupied'); + assert.notEqual(renewed.lease!.source.qualification, first.lease!.source.qualification); + assert.deepEqual(renewed.slotHolders, []); + assert.deepEqual(renewed.pending.map(row => row.player), ['occupier', 'ally']); + const settled = settleAll(game, renewed.state); + const available = syncOccupiedTupileLease(game, settled); + assert.deepEqual(available.slotHolders, ['occupier', 'ally']); + assert.equal(tupilePlayer(game, 'occupier').hand.length, 4); + assert.equal(tupilePlayer(game, 'ally').hand.length, 8); + assert.equal(available.state.leases.length, 2); + assert.equal(syncOccupiedTupileLease(game, available.state).state, available.state); + assert.throws(() => completeOccupiedTupileCleanup(game, settled, renewed.pending[0].event, 'occupier'), HomeworldCustodyError); + assert.deepEqual(biddingPhysicalIds(game), cards); +}); + +for (const cause of ['contest', 'nativeHigh'] as const) void test( + `Basic ${cause} ends the slot and clearing it starts a new observed interval without stacking`, () => { + const { game, cards } = occupiedTupileFixture(false); + const first = syncOccupiedTupileLease(game, createOccupiedTupileState()); + if (cause === 'contest') addTupileForces(game, 'fremen'); + else restoreTupileNative(game, 11); + recordTupilePosition(game, `basic-${cause}-closes-control`); + const closed = syncOccupiedTupileLease(game, first.state); + assert.equal(closed.authority.status, 'unoccupied'); + assert.equal(closed.authority.lowIntelligence, 'available'); + assert.equal(closed.state.current, null); + if (cause === 'contest') removeTupileForces(game, 'fremen'); + else clearDefenseNative(game, TUPILE_WORLD); + recordTupilePosition(game, `basic-${cause}-cleared`); + const renewed = syncOccupiedTupileLease(game, settleAll(game, closed.state)); + assert.equal(renewed.authority.status, 'occupied'); + assert.notEqual(renewed.lease!.source.qualification, first.lease!.source.qualification); + assert.deepEqual(renewed.slotHolders, ['occupier', 'ally']); + assert.equal(renewed.authority.capacities[0].occupied, 5); + validateOccupiedTupileState(renewed.state, game); + assert.deepEqual(biddingPhysicalIds(game), cards); + }); + void test('unobserved Advanced writes cannot fabricate a new source or resurrect a closed original epoch', () => { const { game } = occupiedTupileFixture(true, false); const first = syncOccupiedTupileLease(game, createOccupiedTupileState()); @@ -284,8 +345,8 @@ void test('strict persisted JSON keeps original source/cleanup epochs and reject assert.throws(() => quoteOccupiedTupileCleanup(game, state, 'other-epoch'), HomeworldCustodyError); }); -void test('suppression and Advanced expiry preserve actual historical private intelligence and lifetime faction use', () => { - const { game } = freshTupileFixture(); +for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} suppression and expiry preserve actual historical intelligence and lifetime faction use`, () => { + const { game } = freshTupileFixture(advanced); clearDefenseNative(game, TUPILE_WORLD); restoreTupileNative(game, 1); addTupileForces(game, 'occupier'); @@ -306,7 +367,8 @@ void test('suppression and Advanced expiry preserve actual historical private in tupileIntelligenceUsedFactions(game.tupileIntelligence!), first.authority.status, 'occupier', 'defenses'), HomeworldCustodyError); removeTupileForces(game, 'occupier'); recordTupilePosition(game, 'intelligence-owner-epoch-expires'); - syncOccupiedTupileLease(game, first.state); + const closed = syncOccupiedTupileLease(game, first.state); + assert.equal(closed.authority.lowIntelligence, 'available'); validateTupileIntelligenceState(game.tupileIntelligence!, game.players, game.turn); assert.deepEqual(game.tupileIntelligence, observed); assert.deepEqual(tupileIntelligenceUsedFactions(game.tupileIntelligence!), ['guild']); @@ -363,7 +425,7 @@ void test('original Atomics hand reduction composes with one Tupile slot and cle assertDefenseInventory(game); }); -void test('direct reciprocal ally replacement freezes former-holder cleanup and Basic uncertainty blocks its settlement without changing receipts', () => { +void test('Basic departure preserves a former ally’s original cleanup and allows its normal-limit settlement', () => { const { game } = occupiedTupileFixture(false); const first = syncOccupiedTupileLease(game, createOccupiedTupileState()); stageBiddingHandSize(game, 'ally', 9); @@ -379,19 +441,17 @@ void test('direct reciprocal ally replacement freezes former-holder cleanup and removeTupileForces(game, 'occupier'); recordTupilePosition(game, 'pending-basic-expiry-with-original-former-ally-cleanup'); const before = structuredClone({ game, state: replacement.state }); - const unknown = syncOccupiedTupileLease(game, replacement.state); - assert.equal(unknown.authority.status, 'unknown'); - assert.equal(unknown.state, replacement.state); - const blocked = quoteOccupiedTupileCleanup(game, replacement.state, event); - assert.ok(blocked.blocked); - assert.equal(blocked.receipt.player, 'ally'); - assert.equal(blocked.source.occupier, 'occupier'); + const closed = syncOccupiedTupileLease(game, replacement.state); + assert.equal(closed.authority.status, 'unoccupied'); + const owed = quoteOccupiedTupileCleanup(game, closed.state, event); + assert.equal(owed.blocked, null); + assert.equal(owed.receipt.player, 'ally'); + assert.equal(owed.source.occupier, 'occupier'); assert.deepEqual({ game, state: replacement.state }, before); - // A separate conserved hand transfer is not permission to settle unknown expiry. + // Model the owner's exact physical discard before committing that receipt. stageBiddingHandSize(game, 'ally', 8); - const blockedBefore = structuredClone({ game, state: replacement.state }); - assert.throws(() => completeOccupiedTupileCleanup(game, replacement.state, event, 'ally'), HomeworldCustodyError); - assert.deepEqual({ game, state: replacement.state }, blockedBefore); + const settled = completeOccupiedTupileCleanup(game, closed.state, event, 'ally'); + assert.equal(settled.cleanups.find(row => row.event === event)!.status, 'completed'); }); void test('an active Tupile marker without its original occupation profile is unknown and cannot close existing grants', () => {