Implement private Tupile intelligence and Homeworld occupation evidence
This commit is contained in:
1 parent
1db11a0187
commit
61689514a0
43 files changed
+3420
-188
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { tupileIntelligenceActions } from './tupile-intelligence-options';
|
||||
import { biddingEndActions, choamMarketPolicy } from './bidding-end-options';
|
||||
import { homeworldRevivalActionBlock, homeworldRevivalDeploymentActions } from './homeworld-revival-deployment-options';
|
||||
import { caladanReinforcementActions } from './caladan-reinforcement-options';
|
||||
@@ -3295,6 +3296,8 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
|
||||
/** Obligations apply across policy branches, including choosing to move first. */
|
||||
export function botActions(g: GameView): Action[] {
|
||||
if (g.automaticContinuationPending) return [];
|
||||
const intelligence = tupileIntelligenceActions(g);
|
||||
if (intelligence.length) return intelligence;
|
||||
const actions = [...junctionTransportActions(g, rank(g)), ...policyActions(g)].flatMap((action) => {
|
||||
const sourced = withNativeShipmentSources(g, action);
|
||||
return sourced && botHomeworldShipmentPaymentAllowed(g, sourced) && !homeworldRevivalActionBlock(g, sourced) ? [sourced] : [];
|
||||
|
||||
+106
-1
@@ -1,3 +1,7 @@
|
||||
import { createHomeworldOccupationHistory, observeHomeworldOccupation, validateHomeworldOccupationHistory, tupileOccupationStatus, type HomeworldOccupationHistory } from './homeworld-occupation-history';
|
||||
import { tupileIntelligenceTargets, quoteTupileIntelligenceRequest, type TupileIntelligenceCategory } from './tupile-intelligence';
|
||||
import { quoteTupileIntelligenceAnswer } from './tupile-intelligence-answer';
|
||||
import { createTupileIntelligenceState, appendTupileIntelligenceObservation, validateTupileIntelligenceState, type TupileIntelligenceState } from './tupile-intelligence-state';
|
||||
import { quoteHomeworldCustody } from './homeworld-custody';
|
||||
import { quoteHomeworldShipment, type HomeworldShipmentIntent } from './homeworld-shipment';
|
||||
import { quoteGuildHomeworldShipment } from './guild-homeworld-shipment';
|
||||
@@ -1241,7 +1245,9 @@ export type Game = {
|
||||
techTokens?: TechState | null;
|
||||
strongholdCards?: StrongholdState | null;
|
||||
/** Null/absent disables the module; custody is installed at force placement. */
|
||||
homeworlds?: { custody: HomeworldCustody | null } | null;
|
||||
homeworlds?: { custody: HomeworldCustody | null; historyVersion?: 1 } | null;
|
||||
homeworldOccupationHistory?: HomeworldOccupationHistory;
|
||||
tupileIntelligence?: TupileIntelligenceState;
|
||||
pendingTech?: { player: string; loser: string; choices: TechId[] } | null;
|
||||
summonedBeforeBlow?: boolean;
|
||||
summonedNexusBeforeRides?: boolean;
|
||||
@@ -1489,6 +1495,7 @@ function revealPlayerNoField(g: Game, p: Player, cause: NoFieldRevealCause) {
|
||||
p.reserves -= result.forces;
|
||||
if (result.forces)
|
||||
place(p, result.location.territory, result.location.sector, result.forces);
|
||||
observeOccupation(g);
|
||||
log(
|
||||
g,
|
||||
`${p.name} revealed the ${result.value} No-Field in ${territory(result.location.territory).name} (${cause}) and placed ${result.forces} physical forces from reserves.${result.forces < result.value ? ' Only the remaining reserves could be placed.' : ''}`,
|
||||
@@ -3574,6 +3581,8 @@ function withdrawNativeReserves(
|
||||
p.reserves = changed.reserves;
|
||||
if (p.elites) p.elites.reserves = changed.eliteReserves;
|
||||
g.homeworlds.custody = result.state;
|
||||
// All Homeworld changes are committed here; the destination is on Arrakis.
|
||||
observeOccupation(g);
|
||||
return result.receipts;
|
||||
}
|
||||
function addRevivedReserves(g: Game, p: Player, amount: number, elite: number) {
|
||||
@@ -3594,6 +3603,7 @@ function addRevivedReserves(g: Game, p: Player, amount: number, elite: number) {
|
||||
p.reserves = changed.reserves;
|
||||
if (p.elites) p.elites.reserves = changed.eliteReserves;
|
||||
g.homeworlds.custody = result.state;
|
||||
observeOccupation(g);
|
||||
}
|
||||
function kill(
|
||||
g: Game,
|
||||
@@ -3796,6 +3806,7 @@ function settleHomeworldExplosion(g: Game, choice: HomeworldForces, automatic: b
|
||||
requireRule(pending.kind === 'explosion' && pending.options.some((p) => p.normal === choice.normal && p.elite === choice.elite),
|
||||
'Choose a native explosion casualty allocation.');
|
||||
commitHomeworldLoss(g, pending.player, pending.territory, choice);
|
||||
observeOccupation(g);
|
||||
const player = getPlayer(g, pending.player);
|
||||
log(g, `${player.name} lost ${choice.normal} normal and ${choice.elite} special forces on ${combatLocationName(g, pending.territory)}. The remaining native forces survived the Lasgun–shield explosion.${automatic ? ' The only physical allocation was applied automatically.' : ''}`,
|
||||
automatic ? { faction: player.faction, name: 'Native explosion casualties' } : undefined);
|
||||
@@ -4195,6 +4206,12 @@ function finishSetup(g: Game) {
|
||||
.map((leader) => ({ leader, revealed: false }));
|
||||
}
|
||||
g.status = 'playing';
|
||||
if (g.homeworlds?.custody) {
|
||||
g.homeworldOccupationHistory = homeworldRule(() => createHomeworldOccupationHistory(homeworldContext(g), g.homeworlds!.custody!, g.turn, crypto.randomUUID()));
|
||||
g.homeworlds.historyVersion = 1;
|
||||
const choam = byFaction(g, 'choam');
|
||||
if (choam) g.tupileIntelligence = createTupileIntelligenceState(choam.id);
|
||||
}
|
||||
log(g, 'Setup complete. The first storm awaits two secret dials.');
|
||||
openPhase(g, false);
|
||||
}
|
||||
@@ -7693,6 +7710,7 @@ function commitAmbassadorShipment(g: Game, shipment: PendingShipment) {
|
||||
p.reserves -= order.amount;
|
||||
if (p.elites) p.elites.reserves -= order.elite;
|
||||
place(p, order.territory, order.sector, order.amount, order.elite);
|
||||
observeOccupation(g);
|
||||
if (order.advisors) (p.advisors ??= {})[order.territory] ??= {};
|
||||
if (p.faction !== 'fremen') techIncome(g, 'heighliners', p);
|
||||
entry.stage = 'arrival';
|
||||
@@ -9119,6 +9137,7 @@ function decideTerror(g: Game, p: Player, action: Action) {
|
||||
);
|
||||
p.reserves -= amount;
|
||||
place(p, entry.territory, entry.sector, amount);
|
||||
observeOccupation(g);
|
||||
log(
|
||||
g,
|
||||
`${p.name} sent ${amount} reserves into ${territory(entry.territory).name} through Sneak Attack.`,
|
||||
@@ -9913,8 +9932,10 @@ function completePhase(g: Game) {
|
||||
g.phase++;
|
||||
if (g.phase === 9) {
|
||||
if (g.dukeVidal) g.dukeVidal = expireDuke(g.dukeVidal, g.turn);
|
||||
observeOccupation(g, 'turnEnd');
|
||||
g.turn++;
|
||||
g.phase = 0;
|
||||
observeOccupation(g, 'turnStart');
|
||||
}
|
||||
openPhase(g);
|
||||
}
|
||||
@@ -10280,6 +10301,65 @@ function creditGiediCollection(g: Game, player: string, desert: number) {
|
||||
log(g, 'Harkonnen received 2 spice from the bank: high-population Giedi Prime rewards positive desert collection once this phase.',
|
||||
{ faction: 'harkonnen', name: 'Giedi Prime collection' });
|
||||
}
|
||||
/** Qualification history records facts only; disputed expiry and benefits remain separate. */
|
||||
function observeOccupation(g: Game, cause: 'change' | 'turnStart' | 'turnEnd' = 'change') {
|
||||
if (g.homeworlds?.historyVersion !== 1 || !g.homeworldOccupationHistory) return;
|
||||
g.homeworldOccupationHistory = homeworldRule(() => observeHomeworldOccupation(
|
||||
g.homeworldOccupationHistory!, homeworldContext(g), g.homeworlds!.custody!, g.turn, cause,
|
||||
cause === 'change'
|
||||
? `homeworld-change-${g.homeworldOccupationHistory!.sources[0].event}-${g.homeworldOccupationHistory!.sources.length}`
|
||||
: `homeworld-turn-${g.turn}-${cause}`));
|
||||
}
|
||||
function homeworldHistoryIntegrity(g: Game) {
|
||||
const initialized = g.homeworlds?.historyVersion === 1;
|
||||
requireRule(initialized === (g.homeworldOccupationHistory !== undefined),
|
||||
'The Homeworld qualification history has lost its initialized record.');
|
||||
if (initialized) {
|
||||
homeworldRule(() => validateHomeworldOccupationHistory(g.homeworldOccupationHistory!, homeworldContext(g), g.turn));
|
||||
const choam = byFaction(g, 'choam');
|
||||
requireRule(!!choam === (g.tupileIntelligence !== undefined), 'The original Tupile intelligence ledger is missing.');
|
||||
if (g.tupileIntelligence) homeworldRule(() => validateTupileIntelligenceState(g.tupileIntelligence!, g.players, g.turn));
|
||||
} else requireRule(g.tupileIntelligence === undefined, 'Tupile intelligence requires its original Homeworld history.');
|
||||
}
|
||||
function tupileIntelligenceBlock(g: Game): string | null {
|
||||
if (!g.homeworlds?.custody || !g.tupileIntelligence || !g.homeworldOccupationHistory)
|
||||
return 'This saved game lacks the original occupation and intelligence history required for Tupile.';
|
||||
if (g.status !== 'playing') return 'Tupile intelligence is available during play.';
|
||||
if (g.phaseOpening || g.response || g.decision || g.truthtrance || g.pendingKarama ||
|
||||
g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingExchange ||
|
||||
g.pendingRicheseGift || g.pendingRichesePurchaseIncome || g.battle?.revealed)
|
||||
return 'Finish the current response or committed action before requesting Tupile intelligence.';
|
||||
return null;
|
||||
}
|
||||
function projectedTupileIntelligence(g: Game, id: string) {
|
||||
const owner = byFaction(g, 'choam');
|
||||
if (!owner || owner.id !== id || !g.homeworlds?.custody) return null;
|
||||
const state = g.tupileIntelligence;
|
||||
const targets = homeworldRule(() => tupileIntelligenceTargets(homeworldContext(g), g.homeworlds!.custody!, id,
|
||||
state?.receipts.map((receipt) => receipt.faction) ?? [],
|
||||
tupileOccupationStatus(g.homeworldOccupationHistory, homeworldContext(g))));
|
||||
return {owner: id, blocked: tupileIntelligenceBlock(g), targets,
|
||||
receipts: (state?.receipts ?? []).map(({event, target, faction, category, spice, count, turn, phase}) =>
|
||||
({event, target, faction, category, spice, count, turn, phase}))};
|
||||
}
|
||||
function requestTupileIntelligence(g: Game, p: Player, action: Action) {
|
||||
const blocked = tupileIntelligenceBlock(g);
|
||||
requireRule(!blocked, blocked ?? 'Tupile intelligence is unavailable.');
|
||||
requireRule(p.faction === 'choam' && g.tupileIntelligence!.owner === p.id, 'Only CHOAM may request its private Tupile intelligence.');
|
||||
requireRule(Object.keys(action).every((key) => ['type', 'target', 'category'].includes(key)) &&
|
||||
typeof action.target === 'string' && (action.category === 'weapons' || action.category === 'defenses'),
|
||||
'Choose one opposing faction and either weapons or defenses.');
|
||||
const request = homeworldRule(() => quoteTupileIntelligenceRequest(homeworldContext(g), g.homeworlds!.custody!, p.id,
|
||||
g.tupileIntelligence!.receipts.map((receipt) => receipt.faction),
|
||||
tupileOccupationStatus(g.homeworldOccupationHistory, homeworldContext(g)), action.target as string,
|
||||
action.category as TupileIntelligenceCategory));
|
||||
const target = getPlayer(g, request.target);
|
||||
const answer = homeworldRule(() => quoteTupileIntelligenceAnswer(target.hand, target.spice, request.category));
|
||||
g.tupileIntelligence = homeworldRule(() => appendTupileIntelligenceObservation(g.tupileIntelligence!, {
|
||||
event: crypto.randomUUID(), ...request, ...answer, turn: g.turn, phase: g.phase}));
|
||||
log(g, `${p.name} used Tupile intelligence against ${faction(target.faction).name}. The spice balance and chosen card count were recorded privately; this faction cannot be questioned again.`,
|
||||
{faction: 'choam', name: 'Tupile intelligence'});
|
||||
}
|
||||
function grummanCollectionIntegrity(g: Game) {
|
||||
homeworldRule(() => validateGrummanCollection(g));
|
||||
const frame = g.grummanCollection;
|
||||
@@ -11145,6 +11225,7 @@ function gholaOptions(g: Game, p: Player) {
|
||||
};
|
||||
}
|
||||
function marketGholaIntegrity(g: Game) {
|
||||
homeworldHistoryIntegrity(g);
|
||||
grummanCollectionIntegrity(g);
|
||||
homeworldVictoryReturnIntegrity(g);
|
||||
homeworldRevivalReturnIntegrity(g);
|
||||
@@ -12214,6 +12295,7 @@ function resolveBattle(g: Game) {
|
||||
dead(dl);
|
||||
killTerritory(g, a, b.territory, Infinity, true);
|
||||
killTerritory(g, d, b.territory, Infinity, true);
|
||||
observeOccupation(g);
|
||||
log(g, 'Both leaders were traitors. Both armies were destroyed.');
|
||||
} else if (ac || dc) {
|
||||
const loser = ac ? d : a;
|
||||
@@ -12221,6 +12303,7 @@ function resolveBattle(g: Game) {
|
||||
dead(l);
|
||||
winner!.spice += quote.bounty!.amount;
|
||||
killTerritory(g, loser, b.territory, Infinity, true);
|
||||
observeOccupation(g);
|
||||
log(
|
||||
g,
|
||||
`${faction(winner!.faction).name} revealed a traitor and won without losses.`,
|
||||
@@ -12232,6 +12315,7 @@ function resolveBattle(g: Game) {
|
||||
'a Lasgun–shield explosion destroyed it',
|
||||
);
|
||||
for (const player of quote.destroyedArmies) killTerritory(g, getPlayer(g, player), b.territory, Infinity, true);
|
||||
observeOccupation(g);
|
||||
dead(al);
|
||||
dead(dl);
|
||||
for (const [p, plan] of [
|
||||
@@ -12261,8 +12345,10 @@ function resolveBattle(g: Game) {
|
||||
const loser = winner === a ? d : a;
|
||||
if (quote.bounty) winner!.spice += quote.bounty.amount;
|
||||
killTerritory(g, loser, b.territory, Infinity, true);
|
||||
observeOccupation(g);
|
||||
if (quote.basicWinnerLosses !== null)
|
||||
killTerritory(g, winner!, b.territory, quote.basicWinnerLosses, true);
|
||||
observeOccupation(g);
|
||||
log(
|
||||
g,
|
||||
stoneResult
|
||||
@@ -12456,6 +12542,7 @@ function settleWinnerCasualties(
|
||||
) {
|
||||
if (to.startsWith('homeworld:')) homeworldBattleLossIntegrity(g);
|
||||
const losses = takeBattleLosses(g, p, to, choice);
|
||||
observeOccupation(g);
|
||||
if (to.startsWith('homeworld:')) g.homeworldBattleLoss = null;
|
||||
log(
|
||||
g,
|
||||
@@ -12705,6 +12792,7 @@ function decideHomeworldVictoryReturn(g: Game, p: Player, action: Action) {
|
||||
const seat = quote.transfer.players.find((seat) => seat.id === p.id)!;
|
||||
p.reserves = seat.reserves;
|
||||
g.homeworlds!.custody = quote.transfer.state;
|
||||
observeOccupation(g);
|
||||
frame.destination = quote.selected.id; frame.stage = 'arrival'; frame.ambassadors = [];
|
||||
frame.arrivalSignature = homeworldArrivalSignature(frame);
|
||||
stampHomeworldVictoryStage(g);
|
||||
@@ -13147,6 +13235,7 @@ function decideHomeworldRevivalReturn(g: Game, p: Player, action: Action) {
|
||||
p.reserves = seat.reserves;
|
||||
if (p.elites) p.elites.reserves = seat.eliteReserves;
|
||||
g.homeworlds!.custody = quote.transfer.state;
|
||||
observeOccupation(g);
|
||||
frame.destination = quote.selected.id;
|
||||
frame.stage = 'arrival';
|
||||
stampHomeworldRevivalProgress(g);
|
||||
@@ -13852,6 +13941,7 @@ function finishResponse(g: Game, canceled: boolean) {
|
||||
target.reserves++;
|
||||
if (target.elites) target.elites.reserves += pending.elite!;
|
||||
}
|
||||
observeOccupation(g);
|
||||
settleAdvisors(g);
|
||||
log(
|
||||
g,
|
||||
@@ -14061,6 +14151,7 @@ function finishResponse(g: Game, canceled: boolean) {
|
||||
`${player.name} substituted surviving suboids for cyborgs lost in this battle.`,
|
||||
);
|
||||
}
|
||||
observeOccupation(g);
|
||||
g.pendingIxSubstitution = null;
|
||||
if (quote) {
|
||||
log(
|
||||
@@ -14614,6 +14705,7 @@ function performJunctionTransport(g: Game, p: Player, action: Action) {
|
||||
owner.reserves = seat.reserves;
|
||||
if (owner.elites) owner.elites.reserves = seat.eliteReserves;
|
||||
}
|
||||
observeOccupation(g);
|
||||
if (arrival) {
|
||||
place(p, arrival.territory, arrival.sector, quote.amount, quote.elite);
|
||||
if (advisors) (p.advisors ??= {})[arrival.territory] = {
|
||||
@@ -14711,6 +14803,7 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) {
|
||||
owner.reserves = seat.reserves;
|
||||
if (owner.elites) owner.elites.reserves = seat.eliteReserves;
|
||||
}
|
||||
observeOccupation(g);
|
||||
p.shipped = true;
|
||||
g.pendingHomeworldShipment = null;
|
||||
// The E3 FAQ explicitly includes Homeworld shipment, including Fremen.
|
||||
@@ -16026,6 +16119,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
return normalizeAutomaticGame(state);
|
||||
}
|
||||
const g = applyActionInner(state, id, action);
|
||||
observeOccupation(g);
|
||||
homeworldRule(() => homeworldGameIntegrity(g));
|
||||
homeworldBattleLossIntegrity(g);
|
||||
homeworldSubstitutionIntegrity(g);
|
||||
@@ -16117,6 +16211,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
reconcileBattlePromises(g, { actor: id, action });
|
||||
reconcileShipmentPromises(g, { actor: id, action });
|
||||
settleAutomaticContinuations(g);
|
||||
observeOccupation(g);
|
||||
marketGholaIntegrity(g);
|
||||
homeworldRule(() => homeworldGameIntegrity(g));
|
||||
homeworldBattleLossIntegrity(g);
|
||||
@@ -16131,6 +16226,7 @@ function finishActionContinuations(g: Game) {
|
||||
// discard. Retire that next physical batch before exposing optional choices.
|
||||
for (let i = 0; g.pendingTreacheryDiscard && i < 16; i++) finishTreacheryDiscard(g);
|
||||
requireRule(!g.pendingTreacheryDiscard, 'The automatic discard chain did not finish.');
|
||||
observeOccupation(g);
|
||||
resumeHomeworldRevivalReturn(g);
|
||||
resumeHomeworldVictoryReturn(g);
|
||||
resumeMarketGhola(g);
|
||||
@@ -16234,6 +16330,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
reconcileBattlePromises(g);
|
||||
reconcileShipmentPromises(g);
|
||||
settleAutomaticContinuations(g);
|
||||
observeOccupation(g);
|
||||
marketGholaIntegrity(g);
|
||||
homeworldRule(() => homeworldGameIntegrity(g));
|
||||
homeworldBattleLossIntegrity(g);
|
||||
@@ -16343,6 +16440,10 @@ function applyActionInner(
|
||||
playSapho(g, p, action);
|
||||
return g;
|
||||
}
|
||||
if (t === 'tupileIntelligence') {
|
||||
requestTupileIntelligence(g, p, action);
|
||||
return g;
|
||||
}
|
||||
if (t === 'portableSnooper') {
|
||||
requireRule(
|
||||
Object.keys(action).every((key) =>
|
||||
@@ -17198,6 +17299,7 @@ function applyActionInner(
|
||||
delete leader.concealed;
|
||||
}
|
||||
if (total && !home) place(p, decision.territory, sector, total);
|
||||
observeOccupation(g);
|
||||
dancer.revealed = true;
|
||||
log(
|
||||
g,
|
||||
@@ -18830,6 +18932,7 @@ function applyActionInner(
|
||||
};
|
||||
else if (p.advisors) delete p.advisors[to];
|
||||
}
|
||||
observeOccupation(g);
|
||||
finishShipmentPromises(
|
||||
g,
|
||||
p,
|
||||
@@ -18879,6 +18982,7 @@ function applyActionInner(
|
||||
),
|
||||
);
|
||||
g.homeworlds!.custody = quote.state;
|
||||
observeOccupation(g);
|
||||
finishShipmentPromises(g, p, null);
|
||||
p.shipped = true;
|
||||
p.moved++;
|
||||
@@ -19555,6 +19659,7 @@ export function viewGame(state: Game, id: string) {
|
||||
moritaniPendingPlacement:
|
||||
me.faction === 'moritani' ? (g.pendingMoritaniPlacement ?? null) : null,
|
||||
grummanCollection: projectedGrummanCollection(g, id),
|
||||
tupileIntelligence: projectedTupileIntelligence(g, id),
|
||||
terrorEntry: g.pendingTerrorEntry
|
||||
? (() => {
|
||||
const entry = g.pendingTerrorEntry!;
|
||||
|
||||
@@ -28,7 +28,8 @@ export function homeworldGameIntegrity(g: Game): void {
|
||||
if (
|
||||
typeof state !== 'object' ||
|
||||
Array.isArray(state) ||
|
||||
Object.keys(state).length !== 1 ||
|
||||
Object.keys(state).some((key) => !['custody', 'historyVersion'].includes(key)) ||
|
||||
(state.historyVersion !== undefined && state.historyVersion !== 1) ||
|
||||
!Object.hasOwn(state, 'custody')
|
||||
)
|
||||
throw new HomeworldCustodyError(
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
import type { FactionId } from './catalog';
|
||||
import {
|
||||
HomeworldCustodyError,
|
||||
homeworldForceGroups,
|
||||
homeworldLocations,
|
||||
type HomeworldCustody,
|
||||
type HomeworldCustodyContext,
|
||||
} from './homeworld-custody';
|
||||
|
||||
type Cause = 'change' | 'turnStart' | 'turnEnd';
|
||||
type Source = {
|
||||
event: string;
|
||||
turn: number;
|
||||
cause: Cause | 'setup';
|
||||
snapshot: number;
|
||||
signature: string;
|
||||
};
|
||||
export type HomeworldOccupationQualification = {
|
||||
event: string;
|
||||
world: string;
|
||||
player: string;
|
||||
faction: FactionId;
|
||||
turn: number;
|
||||
cause: 'sole' | 'turnStart' | 'turnEnd';
|
||||
};
|
||||
export type HomeworldOccupationHistory = {
|
||||
version: 1;
|
||||
coverage: 'setup';
|
||||
advanced: boolean;
|
||||
seats: [string, FactionId][];
|
||||
/** Interned canonical public typed force groups, shared across event receipts. */
|
||||
snapshots: string[];
|
||||
sources: Source[];
|
||||
qualifications: HomeworldOccupationQualification[];
|
||||
signature: string;
|
||||
};
|
||||
type Snapshot = [string, [string, number, number][]][];
|
||||
const validText = (s: unknown): s is string =>
|
||||
typeof s === 'string' && s.trim().length > 0;
|
||||
const whole = (n: unknown): n is number =>
|
||||
Number.isSafeInteger(n) && (n as number) >= 0;
|
||||
function requireHistory(
|
||||
condition: unknown,
|
||||
message: string,
|
||||
): asserts condition {
|
||||
if (!condition)
|
||||
throw new HomeworldCustodyError(`Homeworld occupation history: ${message}`);
|
||||
}
|
||||
function keys(value: unknown, expected: string[]): boolean {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).length === expected.length &&
|
||||
expected.every((key) => Object.hasOwn(value, key))
|
||||
);
|
||||
}
|
||||
function seats(context: HomeworldCustodyContext): [string, FactionId][] {
|
||||
homeworldLocations(context);
|
||||
return context.players
|
||||
.map((p): [string, FactionId] => [p.id, p.faction])
|
||||
.sort((a, b) => a[0].localeCompare(b[0]));
|
||||
}
|
||||
function sourceSnapshot(
|
||||
context: HomeworldCustodyContext,
|
||||
custody: HomeworldCustody,
|
||||
): string {
|
||||
const snapshot: Snapshot = homeworldForceGroups(context, custody).map(
|
||||
(home) => [
|
||||
home.id,
|
||||
Object.entries(home.forces)
|
||||
.filter(
|
||||
([player, forces]) =>
|
||||
player === home.native || forces.normal + forces.elite > 0,
|
||||
)
|
||||
.map(([player, forces]): [string, number, number] => [
|
||||
player,
|
||||
forces.normal,
|
||||
forces.elite,
|
||||
])
|
||||
.sort((a, b) => a[0].localeCompare(b[0])),
|
||||
],
|
||||
);
|
||||
snapshot.sort((a, b) => a[0].localeCompare(b[0]));
|
||||
return JSON.stringify(snapshot);
|
||||
}
|
||||
function readSnapshot(
|
||||
value: string,
|
||||
context: HomeworldCustodyContext,
|
||||
): Snapshot {
|
||||
requireHistory(typeof value === 'string', 'a source snapshot is missing.');
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(value);
|
||||
} catch {
|
||||
requireHistory(false, 'a source snapshot is malformed.');
|
||||
}
|
||||
const homes = homeworldLocations(context);
|
||||
requireHistory(
|
||||
Array.isArray(raw) && raw.length === homes.length,
|
||||
'a source snapshot needs every seated world.',
|
||||
);
|
||||
const visited = new Set<string>();
|
||||
const reserves = new Map(
|
||||
context.players.map((p) => [p.id, { normal: 0, elite: 0 }]),
|
||||
);
|
||||
const custody: HomeworldCustody = { visitors: {}, salusa: null };
|
||||
for (const row of raw) {
|
||||
requireHistory(
|
||||
Array.isArray(row) &&
|
||||
row.length === 2 &&
|
||||
validText(row[0]) &&
|
||||
Array.isArray(row[1]),
|
||||
'invalid world source row.',
|
||||
);
|
||||
const home = homes.find((h) => h.id === row[0]);
|
||||
requireHistory(
|
||||
home && !visited.has(home.id),
|
||||
'a source world is unknown or repeated.',
|
||||
);
|
||||
visited.add(home.id);
|
||||
const players = new Set<string>();
|
||||
for (const group of row[1]) {
|
||||
requireHistory(
|
||||
Array.isArray(group) &&
|
||||
group.length === 3 &&
|
||||
validText(group[0]) &&
|
||||
reserves.has(group[0]) &&
|
||||
!players.has(group[0]) &&
|
||||
whole(group[1]) &&
|
||||
whole(group[2]),
|
||||
'invalid typed source army.',
|
||||
);
|
||||
const [player, normal, elite] = group as [string, number, number];
|
||||
players.add(player);
|
||||
if (player === home.native) {
|
||||
const total = reserves.get(player)!;
|
||||
total.normal += normal;
|
||||
total.elite += elite;
|
||||
if (home.secondary) custody.salusa = { normal, elite };
|
||||
} else (custody.visitors[home.id] ??= {})[player] = { normal, elite };
|
||||
}
|
||||
requireHistory(
|
||||
players.has(home.native),
|
||||
'the native physical pool is missing.',
|
||||
);
|
||||
}
|
||||
const historical: HomeworldCustodyContext = {
|
||||
advanced: context.advanced,
|
||||
players: context.players.map((p) => {
|
||||
const total = reserves.get(p.id)!;
|
||||
return {
|
||||
id: p.id,
|
||||
faction: p.faction,
|
||||
reserves: total.normal + total.elite,
|
||||
eliteReserves: total.elite,
|
||||
};
|
||||
}),
|
||||
};
|
||||
requireHistory(
|
||||
sourceSnapshot(historical, custody) === value,
|
||||
'source armies are not canonical or physically valid.',
|
||||
);
|
||||
return raw as Snapshot;
|
||||
}
|
||||
function sourceSignature(
|
||||
source: Omit<Source, 'signature'>,
|
||||
snapshot: string,
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
'homeworldOccupationSource',
|
||||
source.event,
|
||||
source.turn,
|
||||
source.cause,
|
||||
snapshot,
|
||||
]);
|
||||
}
|
||||
function historySignature(history: HomeworldOccupationHistory): string {
|
||||
return JSON.stringify([
|
||||
'homeworldOccupationHistory',
|
||||
history.version,
|
||||
history.coverage,
|
||||
history.advanced,
|
||||
history.seats,
|
||||
history.snapshots,
|
||||
history.sources,
|
||||
history.qualifications,
|
||||
]);
|
||||
}
|
||||
function facts(
|
||||
source: Source,
|
||||
snapshot: Snapshot,
|
||||
context: HomeworldCustodyContext,
|
||||
): HomeworldOccupationQualification[] {
|
||||
if (source.cause === 'setup') return [];
|
||||
const homes = homeworldLocations(context);
|
||||
const result: HomeworldOccupationQualification[] = [];
|
||||
for (const [world, groups] of snapshot) {
|
||||
const native = homes.find((home) => home.id === world)!.native;
|
||||
const present = groups.filter(([, normal, elite]) => normal + elite > 0);
|
||||
const foreign =
|
||||
source.cause === 'change'
|
||||
? present.length === 1 && present[0][0] !== native
|
||||
? present
|
||||
: []
|
||||
: present.filter(([player]) => player !== native);
|
||||
for (const [player] of foreign)
|
||||
result.push({
|
||||
event: source.event,
|
||||
world,
|
||||
player,
|
||||
faction: context.players.find((p) => p.id === player)!.faction,
|
||||
turn: source.turn,
|
||||
cause: source.cause === 'change' ? 'sole' : source.cause,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const factKey = (fact: HomeworldOccupationQualification) =>
|
||||
JSON.stringify([fact.world, fact.player, fact.turn, fact.cause]);
|
||||
|
||||
/** Validate stored evidence independently of today's physical garrisons. This
|
||||
* proves recorded qualification, never current entitlement or a spice award. */
|
||||
export function validateHomeworldOccupationHistory(
|
||||
history: HomeworldOccupationHistory,
|
||||
context: HomeworldCustodyContext,
|
||||
currentTurn?: number,
|
||||
): void {
|
||||
requireHistory(
|
||||
keys(history, [
|
||||
'version',
|
||||
'coverage',
|
||||
'advanced',
|
||||
'seats',
|
||||
'snapshots',
|
||||
'sources',
|
||||
'qualifications',
|
||||
'signature',
|
||||
]) &&
|
||||
history.version === 1 &&
|
||||
history.coverage === 'setup' &&
|
||||
history.advanced === context.advanced &&
|
||||
JSON.stringify(history.seats) === JSON.stringify(seats(context)) &&
|
||||
Array.isArray(history.snapshots) &&
|
||||
history.snapshots.length > 0 &&
|
||||
Array.isArray(history.sources) &&
|
||||
history.sources.length > 0 &&
|
||||
Array.isArray(history.qualifications),
|
||||
'the initialized record or seated world roster is invalid.',
|
||||
);
|
||||
if (currentTurn !== undefined)
|
||||
requireHistory(
|
||||
whole(currentTurn) && currentTurn > 0,
|
||||
'invalid current turn.',
|
||||
);
|
||||
requireHistory(
|
||||
new Set(history.snapshots).size === history.snapshots.length,
|
||||
'source snapshots must be interned.',
|
||||
);
|
||||
const snapshots = history.snapshots.map((value) =>
|
||||
readSnapshot(value, context),
|
||||
);
|
||||
const events = new Set<string>(),
|
||||
used = new Set<number>(),
|
||||
seenFacts = new Set<string>();
|
||||
const expected: HomeworldOccupationQualification[] = [];
|
||||
let lastTurn = 0;
|
||||
for (const [index, source] of history.sources.entries()) {
|
||||
requireHistory(
|
||||
keys(source, ['event', 'turn', 'cause', 'snapshot', 'signature']) &&
|
||||
validText(source.event) &&
|
||||
!events.has(source.event) &&
|
||||
whole(source.turn) &&
|
||||
source.turn > 0 &&
|
||||
source.turn >= lastTurn &&
|
||||
(currentTurn === undefined || source.turn <= currentTurn) &&
|
||||
(index === 0
|
||||
? source.cause === 'setup'
|
||||
: ['change', 'turnStart', 'turnEnd'].includes(source.cause)) &&
|
||||
whole(source.snapshot) &&
|
||||
source.snapshot < snapshots.length,
|
||||
'a source event is duplicated, stale, future or malformed.',
|
||||
);
|
||||
events.add(source.event);
|
||||
used.add(source.snapshot);
|
||||
lastTurn = source.turn;
|
||||
requireHistory(
|
||||
source.signature ===
|
||||
sourceSignature(source, history.snapshots[source.snapshot]),
|
||||
'source event physical facts changed.',
|
||||
);
|
||||
if (index === 0) {
|
||||
const homes = homeworldLocations(context);
|
||||
requireHistory(
|
||||
snapshots[source.snapshot].every(([world, groups]) =>
|
||||
groups.every(
|
||||
([player]) =>
|
||||
player === homes.find((home) => home.id === world)!.native,
|
||||
),
|
||||
),
|
||||
'setup cannot prove history while foreign armies are present.',
|
||||
);
|
||||
}
|
||||
for (const fact of facts(source, snapshots[source.snapshot], context)) {
|
||||
const key = factKey(fact);
|
||||
if (!seenFacts.has(key)) {
|
||||
expected.push(fact);
|
||||
seenFacts.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
requireHistory(
|
||||
used.size === snapshots.length,
|
||||
'unreferenced source snapshots are invalid.',
|
||||
);
|
||||
requireHistory(
|
||||
history.qualifications.every((fact) =>
|
||||
keys(fact, ['event', 'world', 'player', 'faction', 'turn', 'cause']),
|
||||
) && JSON.stringify(history.qualifications) === JSON.stringify(expected),
|
||||
'qualification evidence no longer matches the independent source facts.',
|
||||
);
|
||||
requireHistory(
|
||||
history.signature === historySignature(history),
|
||||
'the signed history was changed.',
|
||||
);
|
||||
}
|
||||
|
||||
export function createHomeworldOccupationHistory(
|
||||
context: HomeworldCustodyContext,
|
||||
custody: HomeworldCustody,
|
||||
turn: number,
|
||||
event: string,
|
||||
): HomeworldOccupationHistory {
|
||||
requireHistory(
|
||||
Object.keys(custody.visitors).length === 0,
|
||||
'setup requires empty foreign custody.',
|
||||
);
|
||||
const snapshot = sourceSnapshot(context, custody);
|
||||
const source: Source = {
|
||||
event,
|
||||
turn,
|
||||
cause: 'setup',
|
||||
snapshot: 0,
|
||||
signature: '',
|
||||
};
|
||||
source.signature = sourceSignature(source, snapshot);
|
||||
const history: HomeworldOccupationHistory = {
|
||||
version: 1,
|
||||
coverage: 'setup',
|
||||
advanced: context.advanced,
|
||||
seats: seats(context),
|
||||
snapshots: [snapshot],
|
||||
sources: [source],
|
||||
qualifications: [],
|
||||
signature: '',
|
||||
};
|
||||
history.signature = historySignature(history);
|
||||
validateHomeworldOccupationHistory(history, context, turn);
|
||||
return history;
|
||||
}
|
||||
|
||||
/** Observe only committed semantic changes. Simultaneous destruction belongs to
|
||||
* one caller event; separately ordered loser/winner losses need separate events. */
|
||||
export function observeHomeworldOccupation(
|
||||
history: HomeworldOccupationHistory,
|
||||
context: HomeworldCustodyContext,
|
||||
custody: HomeworldCustody,
|
||||
turn: number,
|
||||
cause: Cause,
|
||||
event: string,
|
||||
): HomeworldOccupationHistory {
|
||||
validateHomeworldOccupationHistory(history, context, turn);
|
||||
requireHistory(
|
||||
whole(turn) &&
|
||||
turn > 0 &&
|
||||
validText(event) &&
|
||||
['change', 'turnStart', 'turnEnd'].includes(cause),
|
||||
'invalid observation event.',
|
||||
);
|
||||
const snapshot = sourceSnapshot(context, custody);
|
||||
const prior = history.sources.find((source) => source.event === event);
|
||||
if (prior) {
|
||||
requireHistory(
|
||||
prior.turn === turn &&
|
||||
prior.cause === cause &&
|
||||
history.snapshots[prior.snapshot] === snapshot,
|
||||
'a repeated source event has different physical facts.',
|
||||
);
|
||||
return structuredClone(history);
|
||||
}
|
||||
const latest = history.sources.at(-1)!;
|
||||
requireHistory(
|
||||
turn >= latest.turn,
|
||||
'observations cannot move backwards in time.',
|
||||
);
|
||||
if (
|
||||
cause === 'change' &&
|
||||
latest.turn === turn &&
|
||||
history.snapshots[latest.snapshot] === snapshot
|
||||
)
|
||||
return structuredClone(history);
|
||||
const result = structuredClone(history);
|
||||
let index = result.snapshots.indexOf(snapshot);
|
||||
if (index < 0) {
|
||||
index = result.snapshots.length;
|
||||
result.snapshots.push(snapshot);
|
||||
}
|
||||
const source: Source = { event, turn, cause, snapshot: index, signature: '' };
|
||||
source.signature = sourceSignature(source, snapshot);
|
||||
result.sources.push(source);
|
||||
const existing = new Set(result.qualifications.map(factKey));
|
||||
for (const fact of facts(source, readSnapshot(snapshot, context), context)) {
|
||||
if (!existing.has(factKey(fact))) {
|
||||
result.qualifications.push(fact);
|
||||
existing.add(factKey(fact));
|
||||
}
|
||||
}
|
||||
result.signature = historySignature(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** No qualification since proven setup is affirmative evidence. A past qualifier
|
||||
* or absent legacy history needs the pending entitlement/expiry ruling. */
|
||||
export function tupileOccupationStatus(
|
||||
history: HomeworldOccupationHistory | undefined,
|
||||
context: HomeworldCustodyContext,
|
||||
currentTurn?: number,
|
||||
): 'unoccupied' | 'unknown' {
|
||||
if (history === undefined) return 'unknown';
|
||||
validateHomeworldOccupationHistory(history, context, currentTurn);
|
||||
const native = context.players.find((p) => p.faction === 'choam');
|
||||
if (!native) return 'unknown';
|
||||
return history.qualifications.some((fact) => fact.world === 'homeworld:choam')
|
||||
? 'unknown'
|
||||
: 'unoccupied';
|
||||
}
|
||||
+16
-3
@@ -3126,14 +3126,16 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'In supported payment cases, low Kaitain halves Emperor’s Treachery payment income and low Junction halves eligible Guild shipping income, rounded up. The payer still pays the full cost. For example, a five-spice auction payment gives low-population Emperor three spice and leaves two in the bank. The income opportunity shows the original payment, current receipt and bank remainder. A population change before collection updates the split; ordinary Karama can still prevent the separate faction income.',
|
||||
'Guild’s own shipment contribution goes to the bank before any low-Junction reduction. Splits with two odd contributions from other factions remain unavailable while their rounding rule is unresolved; choose another funding split. Single-contributor payments and splits where both rounding interpretations agree remain supported. Occupier income is still unfinished.',
|
||||
'With at least seven native forces on Giedi Prime, Harkonnen automatically gains two bank spice after collecting positive desert spice during Spice Collection, once for the whole phase. Several deposits still earn only two. Shared Ecaz spice qualifies when Harkonnen actually receives a positive allocation; a zero share, stronghold income or technology income does not qualify. Refreshing preserves the completed bonus.',
|
||||
'With fewer than eight native forces on Grumman, Moritani can reveal Terror only when at least three forces enter together. Count each incoming physical counter once, including advisors, and a concealed No-Field as one regardless of its hidden value. Forces already at the destination do not count. Grumman’s high-population Collection token action remains unfinished.',
|
||||
'With fewer than eight native forces on Grumman, Moritani can reveal Terror only when at least three forces enter together. Count each incoming physical counter once, including advisors, and a concealed No-Field as one regardless of its hidden value. Forces already at the destination do not count. High Grumman’s separate Collection choice can add a token to an existing stronghold stack and collect four bank spice; removal still requires a custody ruling.',
|
||||
'While Ecaz has at least seven native reserves, it automatically receives three bank spice for each discarded poison weapon, including its own. Poison Blade and Poison Tooth qualify; Chemistry qualifies only when used as a weapon. Poison defenses and Residual Poison do not qualify. Ecaz sees its income in a private Homeworld record; private discarded faces stay private.',
|
||||
'Battle cleanup checks population when each card is actually discarded. Losing-card disposal comes before winner casualties. Mandatory winning cards, including a used Poison Tooth, are discarded after winner casualties and any Ixian substitution, before optional winning-card cleanup. If Ecaz falls below seven during its winning losses, its later card discards earn no poison income.',
|
||||
'If Tleilax starts Revival at low population, it receives no bank income for other factions’ ordinary free revivals throughout that phase, even if its population rises. Paid revival receipts and the separately named Ghola reward remain available.',
|
||||
'Low Caladan prevents the phase-opening Spice Blow peek. Low Wallach IX prevents free Spiritual Advisors, while high Wallach IX allows one or two to Polar Sink after another faction’s qualifying shipment. Accompanying elsewhere still uses one. Low Ix prevents moving the mobile stronghold; low Richese prevents moving a No-Field token. Physical-force movement and ordinary paid shipments retain their separate rules.',
|
||||
'After a supported Atreides victory, Caladan with at least six native reserves may add one reserve force to the battle location if an Atreides force survives there. The owned choice follows ordinary battle cleanup and offers an eligible sector or the original foreign Homeworld. This spends no spice or shipment allowance. Leaving the force in reserves is optional; a battle on Caladan itself cannot create an extra counter from reserves already there.',
|
||||
'Caladan reinforcement keeps the original battle identity through refresh and supported Ambassador reactions, so the same victory cannot add a second force. Placement involving Face Dancer ordering, storm, the mobile stronghold or unresolved entry reactions remains guarded. These guards do not certify those combined rules.',
|
||||
'Low Tupile’s intelligence advantage requires contact with another faction’s Homeworld or that faction’s presence on Tupile, and is usable once per faction. Its public eligibility checks are implemented, but the private disclosure action and occupation-dependent availability remain unfinished.',
|
||||
'At low population, Tupile lets CHOAM learn an opposing faction’s current spice and either its weapon count or defense count, once per faction for the entire game. CHOAM must have forces on that faction’s Homeworld, or that faction must have forces on Tupile. Either Emperor Homeworld supplies the same single entitlement. The answer is recorded privately as a historical observation and does not update when that faction’s hand or spice changes.',
|
||||
'Choose weapons or defenses in the private Tupile panel at a settled action boundary. The server answers immediately without an opponent confirmation, and does not expose individual cards. Count physical held Treachery cards by their default category: Weirding Way is a weapon, Chemistry is a defense, while Worthless cards and leader substitutes count as neither. Selecting the other category later cannot renew a faction’s used entitlement.',
|
||||
'Occupation removes Tupile’s low advantage. New Homeworld games record qualifying foreign presence from setup, including brief sole occupation during ordered battle losses and presence at turn boundaries. Earlier saves without that history, or histories with a Tupile qualifier whose continuing entitlement is unresolved, cannot make a new intelligence request. Already learned observations remain readable. Recorded qualification does not itself award occupied income or settle its expiry.',
|
||||
'During Spice Collection, Grumman with at least eight native reserves may add one available Terror token to an ordinary stronghold that already has Terror, then collect four bank spice. This opportunity is separate from Mentat placement and follows any pending shared spice allocation. Declining collects nothing. Removing a token remains unavailable while its destination after removal awaits a ruling.',
|
||||
'When several Terror tokens share a stronghold, Moritani privately selects one for an eligible entry, then chooses whether to reveal it or offer an alliance. The other tokens remain hidden. The board shows the public stack count; refresh preserves the selected token and original arrival.',
|
||||
'While Junction has high population, Guild may offer another faction transport at half or full price during that faction’s shipment. The recipient can choose exact forces from one territory or Homeworld, including a return to their own world. An allied Homeworld remains forbidden. Half-price totals round up; five forces to a Homeworld cost three spice. The Junction route and offered rate resist Karama.',
|
||||
@@ -3183,6 +3185,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'game/homeworld-card-economy.ts',
|
||||
'game/homeworld-payment-income.ts',
|
||||
'game/homeworld-collection.ts',
|
||||
'game/homeworld-occupation-history.ts',
|
||||
'game/tupile-intelligence.ts',
|
||||
'game/tupile-intelligence-answer.ts',
|
||||
'game/tupile-intelligence-state.ts',
|
||||
'components/tupile-intelligence.tsx',
|
||||
'game/giedi-collection.ts',
|
||||
'game/terror-entry-receipt.ts',
|
||||
'game/bidding-end.ts',
|
||||
@@ -3212,7 +3219,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'AI',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Shared public choices supply native shipment counters, Imperial transfers and current low revival allowances. All four profiles use Salusa’s per-type support legality and respect unresolved special-Karama and mid-battle Ghola timing boundaries. All four profiles handle world-to-world shipment, Guild transport from Arrakis, native returns and interception, Homeworld battle choices, typed losses, late defense and supported replacement actions. All four profiles can offer and use Junction transport through public quotes, respect population-based movement restrictions and choose legal Spiritual Advisor quantities. Market choices exclude high-Tupile Worthless sales. All four profiles use the shared closing opportunity for CHOAM actions, affordable Kaitain discards and readiness. Ecaz income needs no separate claim. Concealed transport, occupation and remaining card-effect strategies are unfinished.',
|
||||
'Shared public choices supply native shipment counters, Imperial transfers and current low revival allowances. All four profiles use Salusa’s per-type support legality and respect unresolved special-Karama and mid-battle Ghola timing boundaries. All four profiles handle world-to-world shipment, Guild transport from Arrakis, native returns and interception, Homeworld battle choices, typed losses, late defense and supported replacement actions. All four profiles can offer and use Junction transport through public quotes, respect population-based movement restrictions and choose legal Spiritual Advisor quantities. Market choices exclude high-Tupile Worthless sales. All four profiles use eligible unused low-Tupile requests without reading opponents’ private hands or balances. All four profiles use the shared closing opportunity for CHOAM actions, affordable Kaitain discards and readiness. Ecaz income needs no separate claim. Concealed transport, occupation and remaining card-effect strategies are unfinished.',
|
||||
evidence: ['game/homeworld-options.ts', 'game/homeworld-shipment-options.ts', 'game/guild-homeworld-shipment-options.ts', 'game/junction-transport-options.ts'],
|
||||
},
|
||||
{
|
||||
@@ -3310,6 +3317,12 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/caladan-reinforcement-controls.test.ts',
|
||||
'tests/homeworld-victory-reinforcement-recovery.test.ts',
|
||||
'tests/tupile-intelligence.test.ts',
|
||||
'tests/tupile-intelligence-answer.test.ts',
|
||||
'tests/tupile-intelligence-state.test.ts',
|
||||
'tests/tupile-intelligence-controls.test.ts',
|
||||
'tests/tupile-intelligence-engine.test.ts',
|
||||
'tests/tupile-intelligence-recovery.test.ts',
|
||||
'tests/homeworld-occupation-history.test.ts',
|
||||
'tests/grumman-collection.test.ts',
|
||||
'tests/grumman-collection-engine.test.ts',
|
||||
'tests/grumman-collection-controls.test.ts',
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { baseDeck, ixDeck, type Card } from './cards';
|
||||
import { ecazTreacheryCards } from './ecaz-cards';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import { richeseCards, richeseCardDefinition } from './richese-cards';
|
||||
import type { TupileIntelligenceCategory } from './tupile-intelligence';
|
||||
|
||||
const inventory = new Map(
|
||||
[...baseDeck(), ...ixDeck(), ...richeseCards(), ...ecazTreacheryCards()].map(
|
||||
(card) => [card.id, card] as const,
|
||||
),
|
||||
);
|
||||
|
||||
/** Printed primary categories, never permissive battle-slot eligibility.
|
||||
* Weirding Way and Chemistry follow the documented default-role composition;
|
||||
* no dedicated Tupile FAQ ruling is claimed. */
|
||||
function primaryCategory(card: Card): TupileIntelligenceCategory | null {
|
||||
const richese = richeseCardDefinition(card);
|
||||
if (richese)
|
||||
return richese.printedType === 'Weapon - Special'
|
||||
? 'weapons'
|
||||
: richese.printedType === 'Defense - Poison'
|
||||
? 'defenses'
|
||||
: null;
|
||||
switch (card.kind) {
|
||||
case 'projectile':
|
||||
case 'poison':
|
||||
case 'lasgun':
|
||||
case 'poisonBlade':
|
||||
case 'poisonTooth':
|
||||
case 'artillery':
|
||||
case 'weirdingWay':
|
||||
return 'weapons';
|
||||
case 'shield':
|
||||
case 'snooper':
|
||||
case 'shieldSnooper':
|
||||
case 'chemistry':
|
||||
return 'defenses';
|
||||
case 'worthless':
|
||||
case 'hero':
|
||||
case 'special':
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function fail(): never {
|
||||
// Never include a private identity, balance or partially computed count.
|
||||
throw new HomeworldCustodyError(
|
||||
'Tupile intelligence needs a valid private hand, spice balance and selected category.',
|
||||
);
|
||||
}
|
||||
|
||||
/** The caller authorizes and records this disclosure atomically. This returns
|
||||
* only a detached historical answer; it does not check contact, reveal cards,
|
||||
* consume usage or expose a second category. No expansion is activated here. */
|
||||
export function quoteTupileIntelligenceAnswer(
|
||||
hand: readonly Card[],
|
||||
spice: number,
|
||||
category: TupileIntelligenceCategory,
|
||||
): { spice: number; count: number } {
|
||||
if (
|
||||
!Array.isArray(hand) ||
|
||||
!Number.isSafeInteger(spice) ||
|
||||
spice < 0 ||
|
||||
(category !== 'weapons' && category !== 'defenses')
|
||||
)
|
||||
fail();
|
||||
const seen = new Set<string>();
|
||||
let count = 0;
|
||||
for (const card of hand) {
|
||||
if (
|
||||
!card ||
|
||||
typeof card !== 'object' ||
|
||||
Array.isArray(card) ||
|
||||
typeof card.id !== 'string' ||
|
||||
seen.has(card.id)
|
||||
)
|
||||
fail();
|
||||
const canonical = inventory.get(card.id);
|
||||
if (
|
||||
!canonical ||
|
||||
canonical.kind !== card.kind ||
|
||||
canonical.effect !== card.effect ||
|
||||
(canonical.name !== card.name &&
|
||||
!(card.id === 'treachery-7' && card.name === 'Basilia Weapon'))
|
||||
)
|
||||
fail();
|
||||
seen.add(card.id);
|
||||
if (primaryCategory(canonical) === category) count++;
|
||||
}
|
||||
return { spice, count };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
import type { TupileIntelligenceCategory } from './tupile-intelligence';
|
||||
|
||||
/** Eligibility is projected by the server. Never inspect hands or reconstruct
|
||||
* occupation history, card categories or balances in a client or bot. */
|
||||
export function tupileIntelligenceCanAct(g: GameView): boolean {
|
||||
return !!(
|
||||
g.status === 'playing' &&
|
||||
g.tupileIntelligence?.owner === g.me &&
|
||||
!g.tupileIntelligence.blocked
|
||||
);
|
||||
}
|
||||
|
||||
export function tupileIntelligenceChoice(
|
||||
g: GameView,
|
||||
target: string,
|
||||
category: TupileIntelligenceCategory,
|
||||
): { blocked: string | null; action: Action | null } {
|
||||
const offer = g.tupileIntelligence;
|
||||
let blocked: string | null = null;
|
||||
if (!offer || offer.owner !== g.me || g.status !== 'playing')
|
||||
blocked = 'Tupile intelligence is not available to this seat.';
|
||||
else if (offer.blocked) blocked = offer.blocked;
|
||||
else if (category !== 'weapons' && category !== 'defenses')
|
||||
blocked = 'Choose the weapon count or defense count.';
|
||||
else {
|
||||
const targets = offer.targets.filter((candidate) => candidate.player === target);
|
||||
if (targets.length !== 1 || target === g.me)
|
||||
blocked = 'Choose a current opposing faction.';
|
||||
else blocked = targets[0].blocked;
|
||||
}
|
||||
return {
|
||||
blocked,
|
||||
action: blocked ? null : { type: 'tupileIntelligence', target, category },
|
||||
};
|
||||
}
|
||||
|
||||
/** A deterministic legal request shared by every bot profile. Historical
|
||||
* answers never determine whether another faction is currently eligible. */
|
||||
export function tupileIntelligenceActions(g: GameView): Action[] {
|
||||
if (!tupileIntelligenceCanAct(g)) return [];
|
||||
for (const target of g.tupileIntelligence!.targets) {
|
||||
const choice = tupileIntelligenceChoice(g, target.player, 'weapons');
|
||||
if (choice.action) return [choice.action];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { FACTIONS, type FactionId } from './catalog';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import type { TupileIntelligenceCategory } from './tupile-intelligence';
|
||||
|
||||
export type TupileIntelligenceObservation = {
|
||||
event: string;
|
||||
owner: string;
|
||||
target: string;
|
||||
faction: FactionId;
|
||||
category: TupileIntelligenceCategory;
|
||||
spice: number;
|
||||
count: number;
|
||||
turn: number;
|
||||
phase: number;
|
||||
contact: string[];
|
||||
signature: string;
|
||||
};
|
||||
export type TupileIntelligenceState = {
|
||||
version: 1;
|
||||
owner: string;
|
||||
receipts: TupileIntelligenceObservation[];
|
||||
signature: string;
|
||||
};
|
||||
type ObservationInput = Omit<TupileIntelligenceObservation, 'signature'> & {
|
||||
signature?: string;
|
||||
};
|
||||
type PublicSeat = { id: string; faction: FactionId };
|
||||
|
||||
function fail(): never {
|
||||
throw new HomeworldCustodyError(
|
||||
'The saved Tupile intelligence ledger does not match its original private observations and seated factions.',
|
||||
);
|
||||
}
|
||||
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;
|
||||
const faction = (value: unknown): value is FactionId =>
|
||||
FACTIONS.some((candidate) => candidate.id === value);
|
||||
const keys = (value: unknown, allowed: readonly string[]): boolean =>
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).every((key) => allowed.includes(key));
|
||||
|
||||
/** Consistency signatures bind canonical ordered facts, not cryptographic authority. */
|
||||
export function tupileIntelligenceObservationSignature(
|
||||
observation: Omit<TupileIntelligenceObservation, 'signature'>,
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
'tupileIntelligenceObservation',
|
||||
observation.event,
|
||||
observation.owner,
|
||||
observation.target,
|
||||
observation.faction,
|
||||
observation.category,
|
||||
observation.spice,
|
||||
observation.count,
|
||||
observation.turn,
|
||||
observation.phase,
|
||||
observation.contact,
|
||||
]);
|
||||
}
|
||||
export function tupileIntelligenceStateSignature(
|
||||
state: Omit<TupileIntelligenceState, 'signature'>,
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
'tupileIntelligenceState',
|
||||
state.version,
|
||||
state.owner,
|
||||
state.receipts.map((receipt) => [
|
||||
tupileIntelligenceObservationSignature(receipt),
|
||||
receipt.signature,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
function validateObservation(
|
||||
observation: ObservationInput,
|
||||
owner: string,
|
||||
): void {
|
||||
if (
|
||||
!keys(observation, [
|
||||
'event',
|
||||
'owner',
|
||||
'target',
|
||||
'faction',
|
||||
'category',
|
||||
'spice',
|
||||
'count',
|
||||
'turn',
|
||||
'phase',
|
||||
'contact',
|
||||
'signature',
|
||||
]) ||
|
||||
!text(observation.event) ||
|
||||
observation.owner !== owner ||
|
||||
!text(observation.target) ||
|
||||
observation.target === owner ||
|
||||
!faction(observation.faction) ||
|
||||
observation.faction === 'choam' ||
|
||||
!['weapons', 'defenses'].includes(observation.category) ||
|
||||
!whole(observation.spice) ||
|
||||
!whole(observation.count) ||
|
||||
!whole(observation.turn) ||
|
||||
observation.turn < 1 ||
|
||||
!whole(observation.phase) ||
|
||||
observation.phase > 8 ||
|
||||
!Array.isArray(observation.contact) ||
|
||||
observation.contact.length === 0 ||
|
||||
new Set(observation.contact).size !== observation.contact.length
|
||||
)
|
||||
fail();
|
||||
const contacts = [
|
||||
'homeworld:choam',
|
||||
`homeworld:${observation.faction}`,
|
||||
...(observation.faction === 'emperor' ? ['homeworld:emperor:salusa'] : []),
|
||||
];
|
||||
if (
|
||||
observation.contact.some(
|
||||
(location) =>
|
||||
typeof location !== 'string' || !contacts.includes(location),
|
||||
) ||
|
||||
(observation.signature !== undefined &&
|
||||
observation.signature !==
|
||||
tupileIntelligenceObservationSignature(observation))
|
||||
)
|
||||
fail();
|
||||
}
|
||||
|
||||
function validateLedger(state: TupileIntelligenceState): void {
|
||||
if (
|
||||
!keys(state, ['version', 'owner', 'receipts', 'signature']) ||
|
||||
state.version !== 1 ||
|
||||
!text(state.owner) ||
|
||||
!Array.isArray(state.receipts) ||
|
||||
typeof state.signature !== 'string'
|
||||
)
|
||||
fail();
|
||||
const events = new Set<string>(),
|
||||
factions = new Set<FactionId>(),
|
||||
targets = new Set<string>();
|
||||
for (const receipt of state.receipts) {
|
||||
validateObservation(receipt, state.owner);
|
||||
if (
|
||||
typeof receipt.signature !== 'string' ||
|
||||
events.has(receipt.event) ||
|
||||
factions.has(receipt.faction) ||
|
||||
targets.has(receipt.target)
|
||||
)
|
||||
fail();
|
||||
events.add(receipt.event);
|
||||
factions.add(receipt.faction);
|
||||
targets.add(receipt.target);
|
||||
}
|
||||
if (state.signature !== tupileIntelligenceStateSignature(state)) fail();
|
||||
}
|
||||
|
||||
/** Only genuine new CHOAM Homeworld setup initializes this ledger. Readers and
|
||||
* normalization must not use this constructor to replace a missing saved one. */
|
||||
export function createTupileIntelligenceState(
|
||||
owner: string,
|
||||
): TupileIntelligenceState {
|
||||
if (!text(owner)) fail();
|
||||
const state: TupileIntelligenceState = {
|
||||
version: 1,
|
||||
owner,
|
||||
receipts: [],
|
||||
signature: '',
|
||||
};
|
||||
state.signature = tupileIntelligenceStateSignature(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Appending consumes a faction's lifetime use and snapshots its authorized
|
||||
* answer atomically. The caller proves live eligibility and seated identity. */
|
||||
export function appendTupileIntelligenceObservation(
|
||||
state: TupileIntelligenceState,
|
||||
observation: ObservationInput,
|
||||
): TupileIntelligenceState {
|
||||
validateLedger(state);
|
||||
validateObservation(observation, state.owner);
|
||||
if (
|
||||
state.receipts.some(
|
||||
(receipt) =>
|
||||
receipt.event === observation.event ||
|
||||
receipt.faction === observation.faction ||
|
||||
receipt.target === observation.target,
|
||||
)
|
||||
)
|
||||
fail();
|
||||
const receipt: TupileIntelligenceObservation = {
|
||||
...structuredClone(observation),
|
||||
signature: tupileIntelligenceObservationSignature(observation),
|
||||
};
|
||||
const result = {
|
||||
...state,
|
||||
receipts: [...structuredClone(state.receipts), receipt],
|
||||
};
|
||||
result.signature = tupileIntelligenceStateSignature(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Historical answers do not follow current balances, hands or later contact.
|
||||
* Only public seated identity and the current turn are needed to validate them. */
|
||||
export function validateTupileIntelligenceState(
|
||||
state: TupileIntelligenceState,
|
||||
players: readonly PublicSeat[],
|
||||
currentTurn: number,
|
||||
): void {
|
||||
validateLedger(state);
|
||||
if (!Array.isArray(players) || !whole(currentTurn) || currentTurn < 1) fail();
|
||||
const ids = new Set<string>(),
|
||||
factions = new Set<FactionId>();
|
||||
for (const seat of players) {
|
||||
if (
|
||||
!seat ||
|
||||
typeof seat !== 'object' ||
|
||||
!text(seat.id) ||
|
||||
!faction(seat.faction) ||
|
||||
ids.has(seat.id) ||
|
||||
factions.has(seat.faction)
|
||||
)
|
||||
fail();
|
||||
ids.add(seat.id);
|
||||
factions.add(seat.faction);
|
||||
}
|
||||
if (
|
||||
players.filter(
|
||||
(seat) => seat.id === state.owner && seat.faction === 'choam',
|
||||
).length !== 1 ||
|
||||
state.receipts.some(
|
||||
(receipt) =>
|
||||
receipt.turn > currentTurn ||
|
||||
!players.some(
|
||||
(seat) =>
|
||||
seat.id === receipt.target && seat.faction === receipt.faction,
|
||||
),
|
||||
)
|
||||
)
|
||||
fail();
|
||||
}
|
||||
|
||||
export function tupileIntelligenceUsedFactions(
|
||||
state: TupileIntelligenceState,
|
||||
): FactionId[] {
|
||||
validateLedger(state);
|
||||
return state.receipts.map((receipt) => receipt.faction);
|
||||
}
|
||||
Reference in new issue
Block a user