import { nativeHandLimit } from './engine'; import { HomeworldCustodyError } from './homeworld-custody'; import { homeworldContext } from './homeworld-game'; import { validateHomeworldOccupationHistory, readHomeworldOccupationSnapshot, homeworldOccupationSnapshotPresence } from './homeworld-occupation-history'; import { basicTupileActivations } from './basic-tupile-occupation'; import { quoteStableHomeworldOccupation, type StableHomeworldOccupationContext, } from './homeworld-stable-occupation'; export type OccupiedTupileContext = StableHomeworldOccupationContext & { homeworldTupilePreview?: true; }; export type OccupiedTupileSource = Readonly<{ world: 'homeworld:choam'; card: 'tupile'; native: string; occupier: string; qualification: string; /** The original observation, not a current garrison or substitute provider. */ signature: string; }>; export type OccupiedTupileCapacity = Readonly<{ player: string; normal: number; occupied: number; }>; export type OccupiedTupileAuthority = Readonly<{ status: 'occupied' | 'unoccupied' | 'unknown'; provider: string | null; source: OccupiedTupileSource | null; holders: readonly string[]; capacities: readonly OccupiedTupileCapacity[]; /** This gates NEW low-threshold intelligence only, never historical knowledge * or the original once-per-faction usage receipts. */ lowIntelligence: 'available' | 'suppressed' | 'unknown'; blocked: string | null; }>; export type OccupiedTupileLease = Readonly<{ id: number; source: OccupiedTupileSource; holders: readonly string[]; turn: number; /** null while current; the immutable removed holders when this lease closes. */ lost: readonly string[] | null; }>; export type OccupiedTupileCleanup = Readonly<{ event: string; lease: number; player: string; status: 'pending' | 'completed'; }>; export type OccupiedTupileState = Readonly<{ version: 1; leases: readonly OccupiedTupileLease[]; current: number | null; cleanups: readonly OccupiedTupileCleanup[]; signature: string; }>; export type OccupiedTupileSync = Readonly<{ state: OccupiedTupileState; authority: OccupiedTupileAuthority; lease: OccupiedTupileLease | null; /** Only these IDs may back Player.tupileHandSlot. A new lease cannot bypass * an outstanding original normal-limit cleanup of the same player. */ slotHolders: readonly string[]; pending: readonly OccupiedTupileCleanup[]; }>; export type OccupiedTupileCleanupQuote = Readonly<{ receipt: OccupiedTupileCleanup; source: OccupiedTupileSource; limit: number; excess: number; blocked: string | null; }>; const text = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; const whole = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0; function requireTupile(condition: unknown, message: string): asserts condition { if (!condition) throw new HomeworldCustodyError(`Occupied Tupile: ${message}`); } function keys(value: unknown, expected: readonly string[]): boolean { return !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === expected.length && expected.every(key => Object.hasOwn(value, key)); } function sourceCopy(source: OccupiedTupileSource): OccupiedTupileSource { return Object.freeze({ world: source.world, card: source.card, native: source.native, occupier: source.occupier, qualification: source.qualification, signature: source.signature }); } function sameSource(a: OccupiedTupileSource, b: OccupiedTupileSource): boolean { return a.world === b.world && a.card === b.card && a.native === b.native && a.occupier === b.occupier && a.qualification === b.qualification && a.signature === b.signature; } const sameHolders = (a: readonly string[], b: readonly string[]) => a.length === b.length && a.every((id, index) => id === b[index]); const cleanupEvent = (lease: OccupiedTupileLease, player: string) => JSON.stringify(['homeworldTupileCleanup', lease.id, lease.source.qualification, player]); /** 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 * 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'); const base = { provider: native?.id ?? null, source: null, holders: Object.freeze([]) as readonly string[], capacities: Object.freeze([]) as readonly OccupiedTupileCapacity[] }; const none: OccupiedTupileAuthority = Object.freeze({ ...base, status: 'unoccupied', lowIntelligence: 'available', blocked: null }); 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'); 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; const original = game.homeworldOccupationHistory!.sources.find(source => source.event === entitlement.qualification); requireTupile(original && original.cause !== 'setup', 'the original qualification source is missing.'); const source = sourceCopy({ world: 'homeworld:choam', card: 'tupile', native: native.id, occupier: entitlement.occupier, qualification: entitlement.qualification, signature: original.signature }); const holders = Object.freeze([entitlement.occupier, ...(entitlement.ally ? [entitlement.ally] : [])]); const capacities = Object.freeze(holders.map(id => { const normal = nativeHandLimit(game.players.find(player => player.id === id)!); return Object.freeze({ player: id, normal, occupied: normal + 1 }); })); return Object.freeze({ provider: native.id, source, holders, capacities, status: 'occupied', lowIntelligence: 'suppressed', blocked: null }); } function stateWith(leases: readonly OccupiedTupileLease[], current: number | null, cleanups: readonly OccupiedTupileCleanup[]): OccupiedTupileState { return Object.freeze({ version: 1, leases: Object.freeze(leases), current, cleanups: Object.freeze(cleanups), signature: JSON.stringify([1, leases, current, cleanups]) }); } export function createOccupiedTupileState(): OccupiedTupileState { return stateWith([], null, []); } /** Original historical epochs, independently of today's force population. A * retained Advanced epoch does not restart at native-high, contest or turn change. * Sources remain in history after departure; no private knowledge is erased. */ function originalEpochs(game: OccupiedTupileContext): Map { const history = game.homeworldOccupationHistory, native = game.players.find(player => player.faction === 'choam'); requireTupile(history && native && game.homeworlds?.historyVersion === 1, 'saved leases require their original fresh occupation history and native provider.'); validateHomeworldOccupationHistory(history, homeworldContext(game), game.turn); const epochs = new 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 = readHomeworldOccupationSnapshot(history.snapshots[observation.snapshot], homeworldContext(game)); const present = homeworldOccupationSnapshotPresence(snapshot, 'homeworld:choam'); if (retained && !present.includes(retained)) retained = null; const candidate = observation.cause !== 'setup' && present.length === 1 && present[0] !== native.id ? present[0] : null; 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 })); retained = occupier; } } return epochs; } /** Strict JSON ledger validation. Frozen ally grants cannot be rewritten to a * current ally, and a closed original epoch cannot be resurrected as a new one. */ export function validateOccupiedTupileState(state: OccupiedTupileState, game: OccupiedTupileContext): void { requireTupile(keys(state, ['version', 'leases', 'current', 'cleanups', 'signature']) && state.version === 1 && Array.isArray(state.leases) && Array.isArray(state.cleanups) && typeof state.signature === 'string' && (state.current === null || whole(state.current)), 'invalid persisted lease ledger.'); const epochs = state.leases.length > 0 ? originalEpochs(game) : null; requireTupile(state.current === null || state.current === state.leases.length - 1, 'current lease must be the latest original grant.'); const expected: { event: string; lease: number; player: string }[] = []; for (let index = 0; index < state.leases.length; index++) { const lease = state.leases[index]; requireTupile(keys(lease, ['id', 'source', 'holders', 'turn', 'lost']) && lease.id === index && whole(lease.turn) && lease.turn > 0 && lease.turn <= game.turn && Array.isArray(lease.holders) && lease.holders.length >= 1 && lease.holders.length <= 2 && new Set(lease.holders).size === lease.holders.length && keys(lease.source, ['world', 'card', 'native', 'occupier', 'qualification', 'signature']) && text(lease.source.qualification) && text(lease.source.signature), 'invalid frozen original grant.'); const observation = game.homeworldOccupationHistory!.sources.find(row => row.event === lease.source.qualification); requireTupile(observation && observation.turn <= lease.turn, 'grant predates its original qualification source.'); const original = epochs!.get(lease.source.qualification); requireTupile(original && sameSource(original, lease.source) && lease.holders[0] === original.occupier && lease.holders.every((id: string) => game.players.some(player => player.id === id && player.id !== original.native)), 'grant lost its original source, provider or beneficiary.'); if (index > 0) { const previous = state.leases[index - 1]; requireTupile(lease.turn >= previous.turn && !(sameSource(previous.source, lease.source) && sameHolders(previous.holders, lease.holders)), 'duplicate or out-of-order original grant.'); if (!sameSource(previous.source, lease.source)) { const sources = game.homeworldOccupationHistory!.sources; requireTupile(sources.findIndex(row => row.event === previous.source.qualification) < sources.findIndex(row => row.event === lease.source.qualification) && !state.leases.slice(0, index).some(prior => sameSource(prior.source, lease.source)), 'closed or earlier original epoch was resurrected.'); } } if (state.current === index) requireTupile(lease.lost === null, 'current grant was already closed.'); else { const next = state.leases[index + 1]; requireTupile(!next || keys(next, ['id', 'source', 'holders', 'turn', 'lost']) && keys(next.source, ['world', 'card', 'native', 'occupier', 'qualification', 'signature']) && Array.isArray(next.holders), 'invalid following original grant.'); const lost = next && sameSource(lease.source, next.source) ? lease.holders.filter((id: string) => !next.holders.includes(id)) : lease.holders; requireTupile(Array.isArray(lease.lost) && sameHolders(lease.lost, lost), 'closed grant changed its original lost holders.'); for (const player of lost) expected.push({ event: cleanupEvent(lease, player), lease: index, player }); } } requireTupile(state.cleanups.length === expected.length, 'cleanup epochs do not cover exactly the original lost grants.'); for (let index = 0; index < expected.length; index++) { const row = state.cleanups[index], original = expected[index]; requireTupile(keys(row, ['event', 'lease', 'player', 'status']) && row.event === original.event && row.lease === original.lease && row.player === original.player && (row.status === 'pending' || row.status === 'completed'), 'invalid original cleanup receipt.'); } requireTupile(state.signature === JSON.stringify([1, state.leases, state.current, state.cleanups]), 'saved lease ledger changed.'); } function syncResult(state: OccupiedTupileState, authority: OccupiedTupileAuthority): OccupiedTupileSync { const lease = state.current === null ? null : state.leases[state.current]; const pending = Object.freeze(state.cleanups.filter(row => row.status === 'pending')); const slotHolders = Object.freeze(authority.status === 'occupied' && lease && authority.source && sameSource(lease.source, authority.source) && sameHolders(lease.holders, authority.holders) ? lease.holders.filter(id => !pending.some(row => row.player === id)) : []); return Object.freeze({ state, authority, lease, slotHolders, pending }); } /** Persist next state and backing flags atomically at the original occupation * observation / reciprocal-alliance boundary. Repeated view/sync is idempotent. * 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); const authority = quoteOccupiedTupileAuthority(game); if (authority.status === 'unknown' || !game.homeworldTupilePreview) return syncResult(state, authority); const current = state.current === null ? null : state.leases[state.current]; if (current && authority.source && sameSource(current.source, authority.source) && sameHolders(current.holders, authority.holders) || !current && !authority.source) return syncResult(state, authority); if (authority.source && (!current || !sameSource(current.source, authority.source))) requireTupile( !state.leases.some(lease => sameSource(lease.source, authority.source!)), 'a cleared original occupation epoch cannot grant again.'); const leases = [...state.leases], cleanups = [...state.cleanups]; if (current) { const lost = Object.freeze(authority.source && sameSource(current.source, authority.source) ? current.holders.filter(id => !authority.holders.includes(id)) : [...current.holders]); leases[current.id] = Object.freeze({ ...current, lost }); for (const player of lost) cleanups.push(Object.freeze({ event: cleanupEvent(current, player), lease: current.id, player, status: 'pending' })); } let next: number | null = null; if (authority.source) { next = leases.length; leases.push(Object.freeze({ id: next, source: sourceCopy(authority.source), holders: Object.freeze([...authority.holders]), turn: game.turn, lost: null })); } return syncResult(stateWith(leases, next, cleanups), authority); } /** Read-only normal-limit projection; the parent filters actual HELD card IDs * against its original committed/reserved-card protections. No face aliases, * occupied-limit allowance or random discard is used for this cleanup. */ export function quoteOccupiedTupileCleanup(game: OccupiedTupileContext, state: OccupiedTupileState, event: string): OccupiedTupileCleanupQuote { validateOccupiedTupileState(state, game); const receipt = state.cleanups.find(row => row.event === event); requireTupile(receipt && receipt.status === 'pending', 'cleanup event is missing, completed or replayed.'); const player = game.players.find(seat => seat.id === receipt.player)!; const limit = nativeHandLimit(player), authority = quoteOccupiedTupileAuthority(game); return Object.freeze({ receipt, source: state.leases[receipt.lease].source, limit, excess: Math.max(0, player.hand.length - limit), blocked: authority.blocked }); } /** The original queue validates actor/event and protected distinct held IDs, * performs exactly the quoted excess discards, then commits this next ledger in * the SAME transaction. A pure receipt cannot prevent double physical discard * by a caller that neglects atomic persistence. Complete zero-excess plans too. * Later leases cannot reuse/consume a completed original cleanup event. */ export function completeOccupiedTupileCleanup(game: OccupiedTupileContext, state: OccupiedTupileState, event: string, player: string): OccupiedTupileState { const quote = quoteOccupiedTupileCleanup(game, state, event); requireTupile(quote.receipt.player === player && !quote.blocked && quote.excess === 0, 'wrong original cleanup owner, blocked source or remaining normal-limit excess.'); const cleanups = state.cleanups.map(row => row.event === event ? Object.freeze({ ...row, status: 'completed' as const }) : row); return stateWith([...state.leases], state.current, cleanups); }