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.
This commit is contained in:
Quintine committed 2026-10-24 12:19:04 +11:00
1 parent ae2d0371ce
commit 26553dd175
14 files changed
+630 -52

No files matched your search

+63
View File
@@ -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<string, string[]>();
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<string>(), 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 };
}
+32 -10
View File
@@ -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<string, OccupiedTupile
'saved leases require their original fresh occupation history and native provider.');
validateHomeworldOccupationHistory(history, homeworldContext(game), game.turn);
const epochs = new Map<string, OccupiedTupileSource>();
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<string, OccupiedTupile
if (retained && !present.some(([id]) => 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);
+4 -4
View File
@@ -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,
+6 -3
View File
@@ -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',