Connect occupied Tupile capacity and original cleanup
This commit is contained in:
1 parent
d637dbaf63
commit
3e79021a6e
19 files changed
+1512
-14
No files matched your search
@@ -34,6 +34,7 @@ const decisions = {
|
||||
homeworldOccupiedIncome: true,
|
||||
homeworldOccupiedPercentage: true,
|
||||
homeworldOccupiedBonus: true,
|
||||
homeworldTupileCleanup: true,
|
||||
caladanReinforcement: true,
|
||||
choamAudit: true,
|
||||
nexusChoamInspection: true,
|
||||
|
||||
@@ -1132,11 +1132,37 @@ function saphoAction(g: GameView): Action | null {
|
||||
options.find((candidate) => candidate.mode === preferred) ?? options[0];
|
||||
return { type: 'card', card: card.id, ...option };
|
||||
}
|
||||
function tupileCleanupActions(g: GameView): Action[] {
|
||||
const me = g.players.find((player) => player.id === g.me);
|
||||
const offer = g.homeworldTupileCleanup;
|
||||
if (!me || !(me.bot ?? me.autopilot) || g.status !== 'playing' ||
|
||||
g.roomControl?.paused || g.roomControl?.closed || g.automaticContinuationPending ||
|
||||
g.response || g.truthtrance || !offer || offer.blocked ||
|
||||
offer.player !== g.me || g.decision?.kind !== 'homeworldTupileCleanup' ||
|
||||
g.decision.player !== g.me || g.decision.event !== offer.event ||
|
||||
!Number.isSafeInteger(offer.excess) || offer.excess <= 0)
|
||||
return [];
|
||||
const eligible = new Set(offer.eligibleCards);
|
||||
const seen = new Set<string>();
|
||||
const cards = (me.hand ?? []).filter((card) => {
|
||||
if (!eligible.has(card.id) || seen.has(card.id)) return false;
|
||||
seen.add(card.id);
|
||||
return true;
|
||||
}).sort((a, b) => technologyCardValue(g, a) - technologyCardValue(g, b) ||
|
||||
a.id.localeCompare(b.id));
|
||||
if (cards.length < offer.excess) return [];
|
||||
return [{ type: 'decision', event: offer.event,
|
||||
cards: cards.slice(0, offer.excess).map((card) => card.id) }];
|
||||
}
|
||||
|
||||
function policyActions(g: GameView): Action[] {
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const level = rank(g);
|
||||
if (!(me.bot ?? me.autopilot) || g.status === 'finished') return [];
|
||||
if (g.status === 'lobby') return me.ready ? [] : [{ type: 'ready' }];
|
||||
if (g.homeworldTupileBlocked) return [];
|
||||
if (g.decision?.kind === 'homeworldTupileCleanup' || g.homeworldTupileCleanup)
|
||||
return tupileCleanupActions(g);
|
||||
if (!g.decision && !g.response && g.richeseBidding?.stage === 'cacheOffer' && g.richeseBidding.offerBlocked)
|
||||
return [];
|
||||
// Resolve these original source choices before unrelated optional card plays.
|
||||
@@ -3886,6 +3912,9 @@ 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.homeworldTupileBlocked) return [];
|
||||
if (g.decision?.kind === 'homeworldTupileCleanup' || g.homeworldTupileCleanup)
|
||||
return tupileCleanupActions(g);
|
||||
if (g.nexusHarkonnenBetrayalReaction) {
|
||||
if (g.status !== 'playing' || g.roomControl?.paused || g.roomControl?.closed ||
|
||||
g.automaticContinuationPending) return [];
|
||||
|
||||
+185
-5
@@ -98,6 +98,7 @@ import { beginHomeworldOccupiedIncome, quoteHomeworldOccupiedIncomeChoice, homew
|
||||
import { createOccupiedPercentageState, quoteOccupiedPercentageSource, allocateOccupiedPercentageReceipt, validateOccupiedPercentageState, type OccupiedPercentageState, type OccupiedPercentageSource } from './homeworld-occupied-percentage';
|
||||
import { quoteOccupiedBiddingAuthority, requireOccupiedBiddingController, requireOccupiedBiddingBonusRecipient } from './homeworld-occupied-bidding';
|
||||
import { quoteStableHomeworldOccupation } from './homeworld-stable-occupation';
|
||||
import { createOccupiedTupileState, syncOccupiedTupileLease, quoteOccupiedTupileAuthority, quoteOccupiedTupileCleanup, completeOccupiedTupileCleanup, validateOccupiedTupileState, type OccupiedTupileState } from './homeworld-occupied-tupile';
|
||||
import { quoteGrummanCollection, quoteGrummanCollectionAction } from './grumman-collection';
|
||||
import { grummanCollectionSignature, validateGrummanCollection, type GrummanCollection } from './grumman-collection-return';
|
||||
import { ambassadorPhaseAllowed } from './ambassador-phase';
|
||||
@@ -615,6 +616,8 @@ export type Player = {
|
||||
fremenNexusWormBlockedTurn?: number;
|
||||
/** Permanent printed one-card reduction after Atomics; bound to its Aftermath receipt. */
|
||||
atomicsHandLimitPenalty?: boolean;
|
||||
/** Derived occupied Tupile slot, backed by the original fresh lease ledger. */
|
||||
tupileHandSlot?: true;
|
||||
elites?: {
|
||||
reserves: number;
|
||||
tanks: number;
|
||||
@@ -782,6 +785,7 @@ export type Decision =
|
||||
| { kind: 'homeworldOccupiedIncome'; player: string; event: string; world: string }
|
||||
| { kind: 'homeworldOccupiedPercentage'; player: string; event: string }
|
||||
| { kind: 'homeworldOccupiedBonus'; player: string; event: string }
|
||||
| { kind: 'homeworldTupileCleanup'; player: string; event: string }
|
||||
| { kind: 'caladanReinforcement'; player: string; event: string }
|
||||
| { kind: 'choamAudit'; player: string; event: string }
|
||||
| { kind: 'nexusChoamInspection'; player: string; event: string }
|
||||
@@ -1889,6 +1893,16 @@ export type Game = {
|
||||
homeworldOccupationHistory?: HomeworldOccupationHistory;
|
||||
/** Fresh development occupation entry only; never inferred for an older Homeworld save. */
|
||||
homeworldOccupationPreview?: true;
|
||||
/** Fresh Tupile capacity entry only; older occupation games are not converted. */
|
||||
homeworldTupilePreview?: true;
|
||||
homeworldTupileState?: OccupiedTupileState;
|
||||
pendingHomeworldTupileCleanup?: {
|
||||
event: string;
|
||||
player: string;
|
||||
hand: string[];
|
||||
opening: Game['phaseOpening'];
|
||||
signature: string;
|
||||
} | null;
|
||||
homeworldAuctionInspection?: { lot: string; audience: string[]; native: string | null; occupation: string; occupiedKnown: boolean };
|
||||
pendingHomeworldOccupiedIncome?: HomeworldOccupiedIncomeState | null;
|
||||
homeworldOccupiedIncomeHistory?: HomeworldOccupiedIncomeState[];
|
||||
@@ -4565,11 +4579,14 @@ function richeseGiftView(g: Game, viewer: Player) {
|
||||
: null,
|
||||
};
|
||||
}
|
||||
export const handLimit = (
|
||||
export const nativeHandLimit = (
|
||||
p: Pick<Player, 'faction'> & Partial<Pick<Player, 'atomicsHandLimitPenalty'>>,
|
||||
) =>
|
||||
(p.faction === 'harkonnen' ? 8 : p.faction === 'choam' ? 5 : 4) -
|
||||
Number(p.atomicsHandLimitPenalty === true);
|
||||
export const handLimit = (
|
||||
p: Pick<Player, 'faction'> & Partial<Pick<Player, 'atomicsHandLimitPenalty' | 'tupileHandSlot'>>,
|
||||
) => nativeHandLimit(p) + Number(p.tupileHandSlot === true);
|
||||
|
||||
function atomicsRule<T>(run: () => T): T {
|
||||
try { return run(); }
|
||||
@@ -7494,7 +7511,7 @@ function requireFreshSetup(g: Game, allowIxElites = false) {
|
||||
p.advisors === undefined && p.gholaBlocked === undefined && p.ixMovementBlocked === undefined &&
|
||||
p.fremenMovementBlocked === undefined && p.fremenNexusMovementBlockedTurn === undefined &&
|
||||
p.fremenNexusWormBlockedTurn === undefined &&
|
||||
p.atomicsHandLimitPenalty === undefined && p.faceDancers === undefined &&
|
||||
p.atomicsHandLimitPenalty === undefined && p.tupileHandSlot === undefined && p.faceDancers === undefined &&
|
||||
p.faceDancerReplacedTurn === undefined && p.revealedTraitors === undefined;
|
||||
}), 'Starting setup requires unused native leaders and no prior alliance, revival or battle history.');
|
||||
requireRule(Object.keys(g.playerPositions ?? {}).every(id => g.players.some(p => p.id === id)),
|
||||
@@ -8526,6 +8543,11 @@ export function initializeHomeworldOccupationGameForAudit(state: Game): Game {
|
||||
g.homeworldOccupiedPercentageLedger = createOccupiedPercentageState();
|
||||
g.pendingHomeworldOccupiedPercentage = null;
|
||||
g.pendingHomeworldOccupiedBonus = null;
|
||||
if (byFaction(g, 'choam')) {
|
||||
g.homeworldTupilePreview = true;
|
||||
g.homeworldTupileState = createOccupiedTupileState();
|
||||
g.pendingHomeworldTupileCleanup = null;
|
||||
}
|
||||
return g;
|
||||
}
|
||||
/** Offline-only seam for the independent Nexus module; public starts remain gated. */
|
||||
@@ -18317,6 +18339,7 @@ function observeOccupation(g: Game, cause: 'change' | 'turnStart' | 'turnEnd' =
|
||||
cause === 'change'
|
||||
? `homeworld-change-${g.homeworldOccupationHistory!.sources[0].event}-${g.homeworldOccupationHistory!.sources.length}`
|
||||
: `homeworld-turn-${g.turn}-${cause}`));
|
||||
syncTupileSlots(g);
|
||||
}
|
||||
function homeworldHistoryIntegrity(g: Game) {
|
||||
const initialized = g.homeworlds?.historyVersion === 1;
|
||||
@@ -18330,6 +18353,7 @@ function homeworldHistoryIntegrity(g: Game) {
|
||||
} else requireRule(g.tupileIntelligence === undefined, 'Tupile intelligence requires its original Homeworld history.');
|
||||
homeworldOccupiedIncomeIntegrity(g);
|
||||
occupiedPercentageIntegrity(g);
|
||||
tupileSlotsIntegrity(g);
|
||||
}
|
||||
function homeworldOccupiedIncomeIntegrity(g: Game) {
|
||||
const pending = g.pendingHomeworldOccupiedIncome, history = g.homeworldOccupiedIncomeHistory;
|
||||
@@ -18419,6 +18443,134 @@ function projectedHomeworldOccupiedIncome(g: Game, player: string) {
|
||||
if (g.decision?.kind !== 'homeworldOccupiedIncome' || !g.pendingHomeworldOccupiedIncome) return null;
|
||||
return homeworldRule(() => homeworldOccupiedIncomeOffer(g.pendingHomeworldOccupiedIncome!, g, player));
|
||||
}
|
||||
function tupileSlotsIntegrity(g: Game, dispatch?: Extract<Decision, { kind: 'homeworldTupileCleanup' }>) {
|
||||
const state = g.homeworldTupileState, pending = g.pendingHomeworldTupileCleanup;
|
||||
if (!g.homeworldTupilePreview) {
|
||||
requireRule(state === undefined && pending === undefined &&
|
||||
g.players.every(player => player.tupileHandSlot === undefined),
|
||||
'Occupied Tupile slots require their original fresh profile.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.homeworldOccupationPreview && state, 'The original Tupile lease ledger is missing.');
|
||||
homeworldRule(() => validateOccupiedTupileState(state, g));
|
||||
const lease = state.current === null ? null : state.leases[state.current];
|
||||
for (const player of g.players) {
|
||||
const backed = !!lease?.holders.includes(player.id) &&
|
||||
!state.cleanups.some(row => row.player === player.id && row.status === 'pending');
|
||||
requireRule(player.tupileHandSlot === (backed ? true : undefined),
|
||||
'The hand slot lost its original occupied Tupile grant.');
|
||||
}
|
||||
if (pending) {
|
||||
const row = state.cleanups.find(row => row.event === pending.event);
|
||||
const decision = g.decision ?? dispatch;
|
||||
requireRule(row?.status === 'pending' && row.player === pending.player &&
|
||||
pending.signature === JSON.stringify([pending.event, pending.player, pending.hand, pending.opening, g.turn, g.phase]) &&
|
||||
JSON.stringify(pending.hand) === JSON.stringify(getPlayer(g, pending.player).hand.map(card => card.id)) &&
|
||||
decision?.kind === 'homeworldTupileCleanup' && decision.player === pending.player &&
|
||||
decision.event === pending.event && !g.response && !g.phaseOpening,
|
||||
'The original Tupile hand cleanup or suspended opening changed.');
|
||||
} else requireRule(g.decision?.kind !== 'homeworldTupileCleanup', 'The Tupile cleanup lost its original source.');
|
||||
}
|
||||
function syncTupileSlots(g: Game) {
|
||||
if (!g.homeworldTupilePreview || g.status !== 'playing' || !g.homeworlds?.custody) return;
|
||||
const result = homeworldRule(() => syncOccupiedTupileLease(g, g.homeworldTupileState!));
|
||||
if (result.authority.status === 'unknown') return;
|
||||
g.homeworldTupileState = result.state;
|
||||
for (const player of g.players) {
|
||||
if (result.slotHolders.includes(player.id)) player.tupileHandSlot = true;
|
||||
else delete player.tupileHandSlot;
|
||||
}
|
||||
}
|
||||
function tupileSourceBlock(g: Game): string | null {
|
||||
return g.homeworldTupilePreview && g.status === 'playing' && g.homeworlds?.custody
|
||||
? homeworldRule(() => quoteOccupiedTupileAuthority(g)).blocked : null;
|
||||
}
|
||||
function tupileCleanupBoundary(g: Game): boolean {
|
||||
return g.status === 'playing' && !g.decision && !g.response && !g.truthtrance &&
|
||||
!g.pendingKarama && !g.pendingTreacheryDiscard && !g.pendingNullentropy &&
|
||||
!g.pendingExchange && !g.pendingRicheseGift && !g.pendingRichesePurchaseIncome && !g.battle;
|
||||
}
|
||||
function pendingTupileCleanup(g: Game) {
|
||||
const rows = g.homeworldTupileState?.cleanups;
|
||||
if (!rows) return null;
|
||||
for (const player of g.order) {
|
||||
const row = rows.find(row => row.player === player && row.status === 'pending');
|
||||
if (row) return row;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function prepareTupileCleanup(g: Game) {
|
||||
if (!g.homeworldTupilePreview || g.pendingHomeworldTupileCleanup ||
|
||||
!tupileCleanupBoundary(g) || tupileSourceBlock(g)) return;
|
||||
for (;;) {
|
||||
const row = pendingTupileCleanup(g);
|
||||
if (!row) return;
|
||||
const quote = homeworldRule(() => quoteOccupiedTupileCleanup(g, g.homeworldTupileState!, row.event));
|
||||
if (quote.blocked) return;
|
||||
if (!quote.excess) {
|
||||
g.homeworldTupileState = homeworldRule(() => completeOccupiedTupileCleanup(g, g.homeworldTupileState!, row.event, row.player));
|
||||
syncTupileSlots(g);
|
||||
continue;
|
||||
}
|
||||
const hand = getPlayer(g, row.player).hand.map(card => card.id), opening = g.phaseOpening ?? null;
|
||||
g.pendingHomeworldTupileCleanup = { event: row.event, player: row.player, hand, opening,
|
||||
signature: JSON.stringify([row.event, row.player, hand, opening, g.turn, g.phase]) };
|
||||
g.phaseOpening = null;
|
||||
g.decision = { kind: 'homeworldTupileCleanup', player: row.player, event: row.event };
|
||||
return;
|
||||
}
|
||||
}
|
||||
function tupileCleanupCards(g: Game, player: Player): string[] {
|
||||
return player.hand.filter(card => !transferCardBlock(g, player, card) &&
|
||||
!giftReserved(g, player.id, card.id) && !retentionReservesCard(g, player.id, card.id))
|
||||
.map(card => card.id);
|
||||
}
|
||||
function projectedTupileCleanup(g: Game, viewer: string) {
|
||||
const pending = g.pendingHomeworldTupileCleanup;
|
||||
if (!pending || pending.player !== viewer) return null;
|
||||
tupileSlotsIntegrity(g);
|
||||
const quote = homeworldRule(() => quoteOccupiedTupileCleanup(g, g.homeworldTupileState!, pending.event));
|
||||
const player = getPlayer(g, pending.player), eligibleCards = tupileCleanupCards(g, player);
|
||||
return { event: pending.event, player: player.id, playerName: player.name,
|
||||
limit: quote.limit, excess: quote.excess, eligibleCards,
|
||||
blocked: quote.blocked ?? (eligibleCards.length < quote.excess
|
||||
? 'The original committed cards must settle before this hand cleanup can complete.' : null) };
|
||||
}
|
||||
function decideTupileCleanup(g: Game, player: Player,
|
||||
decision: Extract<Decision, { kind: 'homeworldTupileCleanup' }>, action: Action) {
|
||||
tupileSlotsIntegrity(g, decision);
|
||||
const pending = g.pendingHomeworldTupileCleanup!;
|
||||
requireRule(pending && player.id === pending.player && action.event === pending.event &&
|
||||
Object.keys(action).sort().join(',') === 'cards,event,type' && Array.isArray(action.cards) &&
|
||||
action.cards.every(id => typeof id === 'string') && new Set(action.cards).size === action.cards.length,
|
||||
'Choose the distinct original held cards for this Tupile cleanup.');
|
||||
const quote = homeworldRule(() => quoteOccupiedTupileCleanup(g, g.homeworldTupileState!, pending.event));
|
||||
const selected = action.cards as string[], eligible = tupileCleanupCards(g, player), physical = physicalTreacheryCards(g);
|
||||
requireRule(!quote.blocked && selected.length === quote.excess &&
|
||||
selected.every(id => eligible.includes(id) && physical.filter(card => card.id === id).length === 1),
|
||||
'Discard exactly the normal-limit excess without consuming a reserved card.');
|
||||
for (const id of selected) discard(g, player, id);
|
||||
g.homeworldTupileState = homeworldRule(() => completeOccupiedTupileCleanup(g, g.homeworldTupileState!, pending.event, player.id));
|
||||
g.pendingHomeworldTupileCleanup = null;
|
||||
g.decision = null;
|
||||
g.phaseOpening = pending.opening;
|
||||
log(g, `${player.name} discarded ${selected.length} Treachery Card${selected.length === 1 ? '' : 's'} to the normal hand limit after losing the original occupied Tupile slot.`,
|
||||
{ faction: player.faction, name: 'Tupile hand cleanup' });
|
||||
syncTupileSlots(g);
|
||||
prepareTupileCleanup(g);
|
||||
}
|
||||
function tupileAutomatic(g: Game): boolean {
|
||||
if (!g.homeworldTupilePreview || g.pendingHomeworldTupileCleanup ||
|
||||
g.status !== 'playing' || !g.homeworlds?.custody || tupileSourceBlock(g)) return false;
|
||||
const result = homeworldRule(() => syncOccupiedTupileLease(g, g.homeworldTupileState!));
|
||||
return result.state.signature !== g.homeworldTupileState!.signature ||
|
||||
!!(result.pending.length && tupileCleanupBoundary(g));
|
||||
}
|
||||
function currentTupileOccupation(g: Game) {
|
||||
return g.homeworldTupilePreview
|
||||
? homeworldRule(() => quoteOccupiedTupileAuthority(g)).status
|
||||
: tupileOccupationStatus(g.homeworldOccupationHistory, homeworldContext(g));
|
||||
}
|
||||
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.';
|
||||
@@ -18435,7 +18587,7 @@ function projectedTupileIntelligence(g: Game, id: string) {
|
||||
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))));
|
||||
currentTupileOccupation(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}))};
|
||||
@@ -18449,7 +18601,7 @@ function requestTupileIntelligence(g: Game, p: Player, action: Action) {
|
||||
'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,
|
||||
currentTupileOccupation(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));
|
||||
@@ -27141,6 +27293,9 @@ function settleAutomaticContinuations(g: Game) {
|
||||
if (g.pendingGuildBetrayal) return;
|
||||
if (g.pendingNexusIxianReplacement) return;
|
||||
if (g.pendingRicheseBetrayal) return;
|
||||
syncTupileSlots(g);
|
||||
prepareTupileCleanup(g);
|
||||
if (g.pendingHomeworldTupileCleanup || tupileSourceBlock(g)) return;
|
||||
if (g.truthtrance || g.phaseOpening || g.status === 'finished') return;
|
||||
const response = g.response;
|
||||
const before = JSON.stringify(g);
|
||||
@@ -27240,6 +27395,9 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
ecazCollectionIntegrity(state);
|
||||
ecazAllianceIntegrity(state);
|
||||
const g = structuredClone(state);
|
||||
syncTupileSlots(g);
|
||||
prepareTupileCleanup(g);
|
||||
if (g.pendingHomeworldTupileCleanup || tupileSourceBlock(g)) return g;
|
||||
if (g.pendingNexusHarkonnenBetrayal) return g;
|
||||
if (g.pendingNexusIxianBetrayal) return g;
|
||||
if (g.pendingNexusIxianReplacement) return g;
|
||||
@@ -27255,6 +27413,8 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
normalizeCardNames(g);
|
||||
normalizeBattle(g);
|
||||
finishActionContinuations(g);
|
||||
prepareTupileCleanup(g);
|
||||
if (g.pendingHomeworldTupileCleanup || tupileSourceBlock(g)) return g;
|
||||
if (g.pendingTreacheryDiscard?.reaction) {
|
||||
treacheryDiscardIntegrity(g);
|
||||
return g;
|
||||
@@ -27274,6 +27434,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
richesePairIntegrity(g);
|
||||
richeseBetrayalIntegrity(g);
|
||||
marketGholaIntegrity(g);
|
||||
prepareTupileCleanup(g);
|
||||
homeworldRule(() => homeworldGameIntegrity(g));
|
||||
homeworldBattleLossIntegrity(g);
|
||||
homeworldSubstitutionIntegrity(g);
|
||||
@@ -27294,6 +27455,18 @@ function applyActionInner(
|
||||
action: Action,
|
||||
execution: 'live' | 'shipmentPreparation' = 'live',
|
||||
): Game {
|
||||
tupileSlotsIntegrity(state);
|
||||
const tupileBlock = tupileSourceBlock(state);
|
||||
requireRule(!tupileBlock, tupileBlock ?? 'The occupied Tupile source is unresolved.');
|
||||
if (state.homeworldTupilePreview && state.status === 'playing' && action?.type !== 'advanceBots') {
|
||||
const current = homeworldRule(() => syncOccupiedTupileLease(state, state.homeworldTupileState!));
|
||||
requireRule(current.state.signature === state.homeworldTupileState!.signature,
|
||||
'Resume the original occupied Tupile source before another action.');
|
||||
}
|
||||
requireRule(!state.pendingHomeworldTupileCleanup ||
|
||||
action?.type === 'advanceBots' || action?.type === 'decision' &&
|
||||
id === state.pendingHomeworldTupileCleanup.player && action.event === state.pendingHomeworldTupileCleanup.event,
|
||||
'Finish the original occupied Tupile hand cleanup before another action.');
|
||||
ecazTreacheryIntegrity(state);
|
||||
const g = structuredClone(state);
|
||||
if (g.pendingNexusHarkonnenBetrayal) {
|
||||
@@ -27976,6 +28149,10 @@ function applyActionInner(
|
||||
decideHomeworldOccupiedIncome(g, p, decision, action);
|
||||
return g;
|
||||
}
|
||||
if (decision.kind === 'homeworldTupileCleanup') {
|
||||
decideTupileCleanup(g, p, decision, action);
|
||||
return g;
|
||||
}
|
||||
if (decision.kind === 'moritaniTerror') {
|
||||
decideTerror(g, p, action);
|
||||
return g;
|
||||
@@ -31366,7 +31543,7 @@ export function viewGame(state: Game, id: string) {
|
||||
schema: g.schema,
|
||||
botsPending: g.botsPending ?? false,
|
||||
semutaReaction: projectedSemutaReaction(g, me),
|
||||
automaticContinuationPending: (!!g.pendingTreacheryDiscard && !g.pendingTreacheryDiscard.reaction) || homeworldRevealPending(g) || homeworldShipmentAutomatic(g) || homeworldOccupiedIncomeAutomatic(g) || grummanCollectionAutomatic(g),
|
||||
automaticContinuationPending: (!!g.pendingTreacheryDiscard && !g.pendingTreacheryDiscard.reaction) || homeworldRevealPending(g) || homeworldShipmentAutomatic(g) || homeworldOccupiedIncomeAutomatic(g) || grummanCollectionAutomatic(g) || tupileAutomatic(g),
|
||||
botNextActionAt: g.botNextActionAt ?? null,
|
||||
code: g.code,
|
||||
version: g.version,
|
||||
@@ -31378,6 +31555,7 @@ export function viewGame(state: Game, id: string) {
|
||||
bureaucrat: projectedBureaucrat(g, id),
|
||||
spiceBankerIncomePreview: g.spiceBankerIncomePreview === true,
|
||||
homeworldOccupationPreview: g.homeworldOccupationPreview === true,
|
||||
homeworldTupilePreview: g.homeworldTupilePreview === true,
|
||||
spiceBankerIncome: g.spiceBankerIncomePreview
|
||||
? bankerIncomeRule(() => projectBankerIncome(g.spiceBankerIncome!, bankerIncomeContext(g))) : null,
|
||||
bribeOptions: projectedBribes(g, me),
|
||||
@@ -31425,6 +31603,8 @@ export function viewGame(state: Game, id: string) {
|
||||
homeworldOccupiedIncome: projectedHomeworldOccupiedIncome(g, id),
|
||||
homeworldOccupiedPercentage: projectedOccupiedPercentage(g),
|
||||
homeworldOccupiedBonus: projectedOccupiedBonus(g),
|
||||
homeworldTupileCleanup: projectedTupileCleanup(g, id),
|
||||
homeworldTupileBlocked: tupileSourceBlock(g),
|
||||
tupileIntelligence: projectedTupileIntelligence(g, id),
|
||||
terrorEntry: g.pendingTerrorEntry
|
||||
? (() => {
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { nativeHandLimit } from './engine';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import { homeworldContext } from './homeworld-game';
|
||||
import { validateHomeworldOccupationHistory } from './homeworld-occupation-history';
|
||||
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
|
||||
* Basic ruling gates and the authorized Advanced retained-occupation lifecycle.
|
||||
* 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');
|
||||
const entitlement = quote.entitlement;
|
||||
const blocked = quote.blocked ?? (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<string, OccupiedTupileSource> {
|
||||
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<string, OccupiedTupileSource>();
|
||||
let retained: string | null = null;
|
||||
for (const observation of history.sources) {
|
||||
const snapshot = JSON.parse(history.snapshots[observation.snapshot]) as [string, [string, number, number][]][];
|
||||
const row = snapshot.find(([world]) => world === 'homeworld:choam');
|
||||
requireTupile(row, 'the original Tupile world is missing from history.');
|
||||
const present = row[1].filter(([, normal, elite]) => normal + elite > 0);
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
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.
|
||||
* Unknown Basic expiry cannot invent departure, cleanup or a new beneficiary.
|
||||
* 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);
|
||||
}
|
||||
+6
-1
@@ -3850,6 +3850,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Printed occupied bank income is collected once and can be immediately allocated to a reciprocal ally. Separate Kaitain and Junction paid-receipt shares and Richese’s actual net sale income use frozen original invoices without charging the payer again. Southern Hemisphere rounds one completed actual native Collection total, including settled shared and occupied-bank credits; it redistributes rather than collecting twice. Ordinary native income cancellation remains distinct from printed occupied effects.',
|
||||
'Proved Wallach IX occupation protects the occupier and ally from native Voice; Tleilax prevents native Face Dancers against them; Grumman prevents revealing Terror on their entry. Occupied Salusa removes Sardaukar advantage without changing starred physical identity. These printed effects cannot be restored or canceled with Karama. Other occupied powers and full Homeworld acceptance remain open.',
|
||||
'Caladan shares the admitted original Bidding inspection with the occupier, not its ally; native inspection can still be prevented independently. Ix gives the occupier the actual private drawn pool and selection, not unrelated setup or Technology. Richese sets its own method/direction before the occupier chooses only the physical cache card; seller, payment and other decisions stay native. Giedi gives one original purchase bonus to the occupier or its reciprocal ally with real receiver capacity, even when Harkonnen’s purchased card fills its hand.',
|
||||
'The separately marked fresh Tupile entry grants one hand slot to the proved occupier and reciprocal ally. Every server-derived hand limit includes it, so Harkonnen can hold nine without receiving a tenth bonus. After a proved loss, each old holder privately selects exactly its excess original eligible cards to return to the normal limit; committed cards remain protected. Original phase opening and remaining movement resume without another shipment or payment. Basic unknown lifecycle blocks rather than guesses cleanup. Advanced departure can restore CHOAM’s low intelligence without clearing its previous answers or used factions.',
|
||||
'The card collection below is available for reference. Starting a Homeworld game remains disabled while shipment, battles, occupation, economy and faction interactions are being completed.',
|
||||
],
|
||||
example:
|
||||
@@ -3867,7 +3868,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. Advanced retains sole-qualified ownership until last departure; Basic lifecycle, Tupile capacity, exceptional Duke, concealed transport and full/public acceptance remain unfinished.',
|
||||
'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.',
|
||||
evidence: [
|
||||
'game/homeworld-cards.ts',
|
||||
'game/homeworld-game.ts',
|
||||
@@ -3899,6 +3900,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'game/homeworld-occupied-percentage.ts',
|
||||
'game/homeworld-occupied-bidding.ts',
|
||||
'game/homeworld-occupied-defenses.ts',
|
||||
'game/homeworld-occupied-tupile.ts',
|
||||
'game/tupile-intelligence.ts',
|
||||
'game/tupile-intelligence-answer.ts',
|
||||
'game/tupile-intelligence-state.ts',
|
||||
@@ -3931,6 +3933,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'components/homeworld-occupied-bonus.tsx',
|
||||
'components/ix-technology.tsx',
|
||||
'components/richese-auctions.tsx',
|
||||
'components/homeworld-tupile-cleanup.tsx',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -4048,6 +4051,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/homeworld-occupation-runtime.test.ts',
|
||||
'tests/homeworld-occupied-producers-engine.test.ts',
|
||||
'tests/homeworld-occupied-private-lots.test.ts',
|
||||
'tests/homeworld-occupied-tupile.test.ts',
|
||||
'tests/homeworld-occupied-tupile-runtime.test.ts',
|
||||
'tests/grumman-collection.test.ts',
|
||||
'tests/grumman-collection-engine.test.ts',
|
||||
'tests/grumman-collection-controls.test.ts',
|
||||
|
||||
@@ -8,6 +8,7 @@ const DECISIONS = {
|
||||
homeworldOccupiedIncome: true,
|
||||
homeworldOccupiedPercentage: true,
|
||||
homeworldOccupiedBonus: true,
|
||||
homeworldTupileCleanup: true,
|
||||
leaderSkillVisibility: true,
|
||||
leaderSkillRevival: true,
|
||||
moritaniAssassinate: true,
|
||||
|
||||
Reference in new issue
Block a user