Files
Dune/game/homeworld-stable-occupation.ts

198 lines
11 KiB
TypeScript

import type { Game } from './engine';
import { HOMEWORLD_CARDS, type HomeworldId } from './homeworld-cards';
import { homeworldContext } from './homeworld-game';
import { homeworldForceGroups, HomeworldCustodyError } from './homeworld-custody';
import { currentHomeworldOccupationSnapshot, homeworldOccupationMarker, homeworldOccupationSnapshotPresence,
readHomeworldOccupationSnapshot, validateHomeworldOccupationHistory } from './homeworld-occupation-history';
export type StableHomeworldOccupationContext = Pick<Game,
'advanced' | 'turn' | 'players' | 'homeworlds' | 'homeworldOccupationHistory' | 'homeworldOccupationPreview'>;
export type StableHomeworldOccupier = {
world: string;
card: HomeworldId;
native: string;
occupier: string;
ally: string | null;
qualification: string;
turn: number;
spice: number;
};
export type StableHomeworldOccupationQuote = {
entitlement: StableHomeworldOccupier | null;
blocked: string | null;
};
type HomeworldCard = (typeof HOMEWORLD_CARDS)[number];
type HomeworldWorld = ReturnType<typeof homeworldForceGroups>[number];
type History = NonNullable<Game['homeworldOccupationHistory']>;
type Player = Game['players'][number];
/** Public marker presence is one, separate from the actual typed counters and
* native population. The hidden token identity and value never enter history. */
export function liveHomeworldOccupationMarker(game: StableHomeworldOccupationContext) {
return homeworldOccupationMarker(game.players);
}
export function liveHomeworldOccupationPresence(game: StableHomeworldOccupationContext, world: string): string[] {
const custody = game.homeworlds?.custody;
if (!custody) throw new HomeworldCustodyError('Homeworld occupation needs current physical custody.');
return homeworldOccupationSnapshotPresence(currentHomeworldOccupationSnapshot(
homeworldContext(game), custody, liveHomeworldOccupationMarker(game)), world);
}
export type StableHomeworldOccupationOptions = {
/** Basic-only policy selector. `currentSole` is the provisional default used
* by occupied income, Bidding, defenses and percentage effects. `epoch` keeps
* the historical conservative original-epoch selector. Tupile now separately
* replays observed Basic activations. Advanced ignores this option. */
basic?: 'currentSole' | 'epoch';
};
/** Original source history supplies entitlement, never a current-controller
* shortcut. Advanced uses the authorized supplied rulebook p.22: qualification
* 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 adds observed activation identity in
* its own module. Neither mode invents an unobserved event. */
export function quoteStableHomeworldOccupation(
game: StableHomeworldOccupationContext,
cardId: HomeworldId,
options: StableHomeworldOccupationOptions = {},
): StableHomeworldOccupationQuote {
const none: StableHomeworldOccupationQuote = { entitlement: null, blocked: null };
if (!game.homeworldOccupationPreview) return none;
const custody = game.homeworlds?.custody, history = game.homeworldOccupationHistory;
if (!custody || !history || game.homeworlds?.historyVersion !== 1)
return { entitlement: null, blocked: 'Occupation benefits require the original fresh Homeworld qualification history.' };
const context = homeworldContext(game);
validateHomeworldOccupationHistory(history, context, game.turn);
const card = HOMEWORLD_CARDS.find(c => c.id === cardId);
if (!card) throw new HomeworldCustodyError('Unknown occupied Homeworld card.');
const native = game.players.find(p => p.faction === card.faction);
if (!native || cardId === 'salusa_secundus' && !game.advanced) return none;
const world = homeworldForceGroups(context, custody).find(home => home.native === native.id &&
home.secondary === (cardId === 'salusa_secundus'));
if (!world) throw new HomeworldCustodyError('The occupied card lost its original native Homeworld.');
if (game.advanced) {
let retained: { player: string; event: string } | null = null;
for (const source of history.sources) {
const snapshot = readHomeworldOccupationSnapshot(history.snapshots[source.snapshot], context);
const present = homeworldOccupationSnapshotPresence(snapshot, world.id);
if (retained && !present.includes(retained.player)) retained = null;
if (!retained && source.cause !== 'setup' && present.length === 1 && present[0] !== native.id)
retained = { player: present[0], event: source.event };
}
const present = liveHomeworldOccupationPresence(game, world.id);
if (retained && !present.includes(retained.player)) retained = null;
if (!retained) {
// A producer may query between a physical write and its semantic
// observation; do not invent a new qualifying event from that write.
return present.length === 1 && present[0] !== native.id
? { entitlement: null, blocked: 'The original Advanced sole-occupation change must be observed before its benefit settles.' }
: none;
}
const occupier = game.players.find(player => player.id === retained!.player)!;
const ally = occupier.ally && game.players.some(player => player.id === occupier.ally && player.ally === occupier.id)
? occupier.ally : null;
return { entitlement: { world: world.id, card: cardId, native: native.id, occupier: occupier.id,
ally, qualification: retained.event, turn: game.turn, spice: card.occupied.spiceIcons }, blocked: null };
}
return options.basic === 'epoch'
? basicEpochOccupation(game, card, world, native, history)
: basicCurrentSoleOccupation(game, card, world, native, history);
}
/** Provisional Basic default: the currently present sole foreign faction
* controls the occupied benefit, expiring immediately on departure, contest or
* native repopulation. Entitlement still requires an observed qualification;
* the quote never manufactures one from a physical write. */
function basicCurrentSoleOccupation(
game: StableHomeworldOccupationContext,
card: HomeworldCard,
world: HomeworldWorld,
native: Player,
history: History,
): StableHomeworldOccupationQuote {
if (!history.qualifications.some(f => f.world === world.id)) return { entitlement: null, blocked: null };
let nativeHigh = false;
for (const [id, forces] of Object.entries(world.forces)) {
const amount = forces.normal + forces.elite;
if (id === native.id) {
const population = card.reserveType === 'sardaukar' ? forces.elite : amount;
if (population >= card.high.reserves.min) nativeHigh = true;
}
}
const foreign = liveHomeworldOccupationPresence(game, world.id).filter(id => id !== native.id);
if (nativeHigh || foreign.length !== 1) return { entitlement: null, blocked: null };
const qualified = history.qualifications.filter(f => f.world === world.id && f.player === foreign[0]).at(-1);
if (!qualified)
return { entitlement: null,
blocked: 'The original Basic sole-occupation change must be observed before its benefit settles.' };
const occupier = game.players.find(p => p.id === foreign[0])!;
const ally = occupier.ally && game.players.some(p => p.id === occupier.ally && p.ally === occupier.id)
? occupier.ally : null;
return { entitlement: { world: world.id, card: card.id, native: native.id, occupier: occupier.id,
ally, qualification: qualified.event, turn: game.turn, spice: card.occupied.spiceIcons }, blocked: null };
}
/** Conservative Basic epoch gate retained for Tupile slot leasing while the
* retention, composition and turnover ruling is open: the original first
* qualifier must be alone and have qualified this turn, and departure, contest
* or native repopulation reports an explicit unresolved reason instead of a
* silent hand-off. */
function basicEpochOccupation(
game: StableHomeworldOccupationContext,
card: HomeworldCard,
world: HomeworldWorld,
native: Player,
history: History,
): StableHomeworldOccupationQuote {
const none: StableHomeworldOccupationQuote = { entitlement: null, blocked: null };
const first = history.qualifications.find(f => f.world === world.id);
if (!first) return none;
const blocked = (detail: string): StableHomeworldOccupationQuote => ({ entitlement: null,
blocked: `${card.name} occupied benefits await the pending ${detail} ruling.` });
let qualifiedThisTurn = false;
for (const fact of history.qualifications) {
if (fact.world !== world.id) continue;
if (fact.player !== first.player) return blocked('competing-occupier');
if (fact.turn === game.turn) qualifiedThisTurn = true;
}
if (!qualifiedThisTurn) return blocked('entitlement/expiry');
const start = history.sources.findIndex(source => source.event === first.event);
if (start < 0) throw new HomeworldCustodyError('The occupier lost its original qualifying source.');
const check = (groups: readonly [string, number, number][], presence: readonly string[]): string | null => {
for (const [id, normal, elite] of groups) {
const amount = normal + elite;
if (id === native.id) {
const population = card.reserveType === 'sardaukar' ? elite : amount;
if (population >= card.high.reserves.min) return 'native-repopulation';
}
}
const foreign = presence.filter(id => id !== native.id);
if (foreign.some(id => id !== first.player)) return 'contested-occupation';
return foreign.includes(first.player) ? null : 'departure/expiry';
};
let previous = -1;
for (let i = start; i < history.sources.length; i++) {
const index = history.sources[i].snapshot;
if (index === previous) continue;
previous = index;
const snapshot = readHomeworldOccupationSnapshot(history.snapshots[index], homeworldContext(game));
const row = snapshot.groups.find(source => source[0] === world.id)!;
const reason = check(row[1], homeworldOccupationSnapshotPresence(snapshot, world.id));
if (reason) return blocked(reason);
}
// Actions can query an effect after a physical change but before its semantic
// observation. Never let the last recorded snapshot substitute for now.
const current = Object.entries(world.forces).map(([id, forces]): [string, number, number] =>
[id, forces.normal, forces.elite]);
const reason = check(current, liveHomeworldOccupationPresence(game, world.id));
if (reason) return blocked(reason);
const occupier = game.players.find(p => p.id === first.player)!;
const ally = occupier.ally && game.players.some(p => p.id === occupier.ally && p.ally === occupier.id)
? occupier.ally : null;
return { entitlement: { world: world.id, card: card.id, native: native.id, occupier: occupier.id,
ally, qualification: first.event, turn: game.turn, spice: card.occupied.spiceIcons }, blocked: null };
}