Connect occupied Homeworld economics and Bidding

This commit is contained in:
Quintine committed 2026-10-05 12:48:08 +11:00
1 parent a6e80c7e23
commit 0774ae6f83
32 files changed
+3220 -111

No files matched your search

+3
View File
@@ -32,6 +32,8 @@ const decisions = {
homeworldRevivalDeployment: true,
grummanCollection: true,
homeworldOccupiedIncome: true,
homeworldOccupiedPercentage: true,
homeworldOccupiedBonus: true,
caladanReinforcement: true,
choamAudit: true,
nexusChoamInspection: true,
@@ -40,6 +42,7 @@ const decisions = {
richeseBlackMarket: true,
richeseDeclaration: true,
richeseCache: true,
richeseCacheTerms: true,
richeseUnbid: true,
richeseAllyShipment: true,
richeseAllyOpportunity: true,
+3 -1
View File
@@ -27,6 +27,8 @@ export type AuctionContinuationInput = {
richeseAuction?: Game['richeseAuction'];
richeseBidding?: Game['richeseBidding'];
richeseCacheCount?: number;
/** Printed occupied bonus uses actual receiver capacity, not native buyer space. */
occupiedBonusAvailable?: boolean;
};
export type AuctionContinuationOperation =
| { kind: 'sale'; free: boolean }
@@ -394,7 +396,7 @@ function calculate(
if (
stage === 'bonus' &&
winner.faction === 'harkonnen' &&
handCount(winner) < 8
(input.occupiedBonusAvailable || handCount(winner) < 8)
)
return result({
kind: 'response',
+49 -21
View File
@@ -1137,6 +1137,52 @@ function policyActions(g: GameView): Action[] {
const level = rank(g);
if (!(me.bot ?? me.autopilot) || g.status === 'finished') return [];
if (g.status === 'lobby') return me.ready ? [] : [{ type: 'ready' }];
if (!g.decision && !g.response && g.richeseBidding?.stage === 'cacheOffer' && g.richeseBidding.offerBlocked)
return [];
// Resolve these original source choices before unrelated optional card plays.
// Private faces and eligible recipients come only from the personalized view.
if (g.decision?.kind === 'homeworldOccupiedPercentage') {
const offer = g.homeworldOccupiedPercentage;
return g.decision.player === me.id && offer && offer.owner === me.id &&
offer.event === g.decision.event && !offer.blocked
? [{ type: 'decision', event: offer.event, ownAmount: offer.maxOwnAmount }]
: [];
}
if (g.decision?.kind === 'homeworldOccupiedBonus') {
const offer = g.homeworldOccupiedBonus;
const recipient = offer?.recipients.find((player) => player.player === me.id) ?? offer?.recipients[0];
return g.decision.player === me.id && offer && offer.owner === me.id &&
offer.event === g.decision.event && !offer.blocked && recipient
? [{ type: 'decision', event: offer.event, recipient: recipient.player }]
: [];
}
if (g.decision?.kind === 'ixAuction') {
if (g.decision.player !== me.id || g.ixTechnology?.blocked) return [];
const cards = [...(g.ixTechnology?.pool ?? [])].sort(
(a, b) => technologyCardValue(g, a) - technologyCardValue(g, b),
);
return cards[0] ? [{ type: 'decision', card: cards[0].id, position: 'bottom' }] : [];
}
if (g.decision?.kind === 'richeseCacheTerms') {
const bidding = g.richeseBidding;
return g.decision.player === me.id && bidding?.owner === me.id && !bidding.offerBlocked
? [{ type: 'decision', event: bidding.event, method: 'onceAround', direction: 'clockwise' }]
: [];
}
if (g.decision?.kind === 'richeseCache') {
const bidding = g.richeseBidding;
if (bidding && bidding.owner !== me.id && !bidding.cacheTerms) return [];
if (g.decision.player !== me.id || !bidding || bidding.offerBlocked) return [];
const cards = [...(bidding.cache ?? [])].sort(
(a, b) => technologyCardValue(g, a) - technologyCardValue(g, b),
);
return cards[0] ? [{
type: 'decision', event: bidding.event, card: cards[0].id,
...(bidding.owner === me.id
? { method: level === 0 ? 'silent' as const : 'onceAround' as const, direction: 'clockwise' }
: {}),
}] : [];
}
if (g.decision?.kind === 'nullentropy') {
if (g.decision.player !== me.id) return [];
const search = g.nullentropy?.search;
@@ -1811,22 +1857,6 @@ function policyActions(g: GameView): Action[] {
position: level >= 2 ? 'last' : 'first',
},
];
if (d.kind === 'richeseCache') {
const cards = [...(g.richeseBidding?.cache ?? [])].sort(
(a, b) => technologyCardValue(g, a) - technologyCardValue(g, b),
);
return cards.length
? [
{
type: 'decision',
event: g.richeseBidding!.event,
card: cards[0].id,
method: level === 0 ? 'silent' : 'onceAround',
direction: 'clockwise',
},
]
: [];
}
if (d.kind === 'richeseUnbid')
return [
{
@@ -2054,9 +2084,8 @@ function policyActions(g: GameView): Action[] {
? [{ type: 'decision', accept: true }]
: [{ type: 'decision', decline: true }];
}
if (d.kind === 'ixSetup' || d.kind === 'ixAuction') {
const cards =
d.kind === 'ixSetup' ? g.ixTechnology?.setup : g.ixTechnology?.pool;
if (d.kind === 'ixSetup') {
const cards = g.ixTechnology?.setup;
const sorted = [...(cards ?? [])].sort(
(a, b) => technologyCardValue(g, b) - technologyCardValue(g, a),
);
@@ -2064,8 +2093,7 @@ function policyActions(g: GameView): Action[] {
return [
{
type: 'decision',
card: (d.kind === 'ixSetup' ? sorted[0] : sorted.at(-1)!).id,
position: 'bottom',
card: sorted[0].id,
},
];
}
+472 -39
View File
@@ -95,6 +95,9 @@ import { HOMEWORLD_CARDS } from './homeworld-cards';
import { lowGrummanRevealBlock, type HomeworldCollectionSource } from './homeworld-collection';
import { quoteOccupiedHomeworldVoice, quoteOccupiedHomeworldFaceDancer, quoteOccupiedHomeworldTerror, occupiedHomeworldSardaukarStatus } from './homeworld-occupied-defenses';
import { beginHomeworldOccupiedIncome, quoteHomeworldOccupiedIncomeChoice, homeworldOccupiedIncomeOffer, validateHomeworldOccupiedIncomeState, type HomeworldOccupiedIncomeState } from './homeworld-occupied-income';
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 { quoteGrummanCollection, quoteGrummanCollectionAction } from './grumman-collection';
import { grummanCollectionSignature, validateGrummanCollection, type GrummanCollection } from './grumman-collection-return';
import { ambassadorPhaseAllowed } from './ambassador-phase';
@@ -757,6 +760,7 @@ export type Battle = {
};
export type Auction = {
peekKnown?: boolean;
occupiedEvent?: string;
cards: Card[];
index: number;
bid: number;
@@ -776,6 +780,8 @@ export type Decision =
| { kind: 'homeworldRevivalDeployment'; player: string; event: string }
| { kind: 'grummanCollection'; player: string; event: string }
| { kind: 'homeworldOccupiedIncome'; player: string; event: string; world: string }
| { kind: 'homeworldOccupiedPercentage'; player: string; event: string }
| { kind: 'homeworldOccupiedBonus'; player: string; event: string }
| { kind: 'caladanReinforcement'; player: string; event: string }
| { kind: 'choamAudit'; player: string; event: string }
| { kind: 'nexusChoamInspection'; player: string; event: string }
@@ -789,6 +795,7 @@ export type Decision =
| { kind: 'richeseBlackMarket'; player: string }
| { kind: 'richeseDeclaration'; player: string }
| { kind: 'richeseCache'; player: string }
| { kind: 'richeseCacheTerms'; player: string }
| { kind: 'richeseUnbid'; player: string }
| { kind: 'richeseAllyShipment'; player: string; owner: string }
| { kind: 'richeseAllyOpportunity'; player: string; recipient: string }
@@ -961,6 +968,7 @@ export type ResponseWindow = {
/** Original eligible contributor amounts; private routing evidence, not a new payment. */
guildContributions?: number[];
guildPaymentProof?: string;
occupiedPayment?: { event: string; contributions: { payer: string; amount: number }[] };
/** Bank-paid portion of a frozen Homeworld charity claim. */
charityHomeworld?: number;
source?: 'ambassador';
@@ -1522,6 +1530,10 @@ export type Game = {
normalCount: number | null;
blackMarketSold: boolean;
cacheCanceled: boolean;
cacheAuthority?: string;
cacheTerms?: { method: 'onceAround' | 'silent'; direction: 'clockwise' | 'counterclockwise' };
cacheTermsEvent?: string;
cacheTermsProof?: string;
opener?: number;
} | null;
richeseAuction?: RicheseAuction | null;
@@ -1546,6 +1558,8 @@ export type Game = {
};
ixRicheseTechnologyEvent?: string;
currentAuctionSale?: {
occupiedEvent?: string;
occupiedContributions?: { payer: string; amount: number }[];
spiceBankerIncomePayment?: SpiceBankerIncomePayment;
bureaucratPayment?: BureaucratPaymentSource;
bureaucratPaymentEvent?: string;
@@ -1775,8 +1789,8 @@ export type Game = {
truthHistory?: TruthRecord[];
shipmentPromises?: ShipmentPromise[];
ixSetupCards?: Card[] | null;
ixAuction?: { count: number; cards: Card[] } | null;
ixAuctionKnown?: { turn: number; cards: Card[] } | null;
ixAuction?: { count: number; cards: Card[]; authority?: string } | null;
ixAuctionKnown?: { turn: number; cards: Card[]; controller?: string } | null;
ixTechnologyTurn?: number;
pendingIxTechnology?: { card: string } | null;
pendingIxAlly?: { player: string; card: string; free: boolean } | null;
@@ -1875,8 +1889,22 @@ export type Game = {
homeworldOccupationHistory?: HomeworldOccupationHistory;
/** Fresh development occupation entry only; never inferred for an older Homeworld save. */
homeworldOccupationPreview?: true;
homeworldAuctionInspection?: { lot: string; audience: string[]; native: string | null; occupation: string; occupiedKnown: boolean };
pendingHomeworldOccupiedIncome?: HomeworldOccupiedIncomeState | null;
homeworldOccupiedIncomeHistory?: HomeworldOccupiedIncomeState[];
homeworldOccupiedPercentageLedger?: OccupiedPercentageState;
pendingHomeworldOccupiedPercentage?: {
source: OccupiedPercentageSource;
proof: string;
suffix: OccupiedPercentageSuffix;
suffixSignature: string;
} | null;
pendingHomeworldOccupiedBonus?: {
event: string;
proof: string;
response: ResponseWindow;
} | null;
fremenCollectionCredits?: { turn: number; event: string; collected: { id: string; amount: number }[]; complete: boolean };
tupileIntelligence?: TupileIntelligenceState;
pendingTech?: { player: string; loser: string; choices: TechId[] } | null;
summonedBeforeBlow?: boolean;
@@ -1920,6 +1948,8 @@ export type Game = {
};
/** Purchase is already committed; only the Emperor's receipt remains pending. */
pendingRichesePurchaseIncome?: {
event?: string;
buyer?: string;
owner: string;
turn: number;
phase: number;
@@ -8493,6 +8523,9 @@ export function initializeHomeworldOccupationGameForAudit(state: Game): Game {
g.homeworldOccupationPreview = true;
g.pendingHomeworldOccupiedIncome = null;
g.homeworldOccupiedIncomeHistory = [];
g.homeworldOccupiedPercentageLedger = createOccupiedPercentageState();
g.pendingHomeworldOccupiedPercentage = null;
g.pendingHomeworldOccupiedBonus = null;
return g;
}
/** Offline-only seam for the independent Nexus module; public starts remain gated. */
@@ -10551,12 +10584,22 @@ function richeseDecision(
| 'richeseBlackMarket'
| 'richeseDeclaration'
| 'richeseCache'
| 'richeseCacheTerms'
| 'richeseUnbid',
) {
const round = g.richeseBidding!;
round.event = crypto.randomUUID();
g.decision = { kind, player: round.owner };
g.active = round.owner;
const authority = (kind === 'richeseCache' || kind === 'richeseCacheTerms') && g.homeworldOccupationPreview
? quoteOccupiedBiddingAuthority(g, 'richeseCache') : null;
const player = kind === 'richeseCache' ? authority?.controller ?? round.owner : round.owner;
if (authority) round.cacheAuthority = JSON.stringify(quoteStableHomeworldOccupation(g, 'richese'));
if (authority?.blocked) {
g.decision = null;
g.active = null;
return;
}
g.decision = { kind, player };
g.active = player;
}
function beginRicheseBidding(g: Game) {
const owner = byFaction(g, 'richese')!;
@@ -10597,7 +10640,8 @@ function prepareRicheseNormal(g: Game) {
}
function offerRicheseCache(g: Game) {
g.richeseBidding!.stage = 'cacheOffer';
richeseDecision(g, 'richeseCache');
const occupied = g.homeworldOccupationPreview && quoteOccupiedBiddingAuthority(g, 'richeseCache').occupied;
richeseDecision(g, occupied ? 'richeseCacheTerms' : 'richeseCache');
}
function finishNormalBidding(g: Game) {
const round = g.richeseBidding;
@@ -10666,6 +10710,7 @@ function finishRicheseLot(g: Game) {
}
}
g.richeseAuction = null;
if (g.homeworldOccupationPreview) g.homeworldAuctionInspection = undefined;
g.richeseFunding = {};
g.richesePeekKnown = false;
g.richeseClaim = null;
@@ -10908,6 +10953,9 @@ function settleRicheseSoldLot(g: Game, quote: Extract<RicheseSettlementQuote,{ki
else owner.hand = owner.hand.filter((c) => c.id !== card.id);
winner.hand.push(card);
g.currentAuctionSale = {
...(g.homeworldOccupationPreview ? { occupiedEvent: lot.event,
occupiedContributions: [{ payer: winner.id, amount: quote.ownPayment },
...(quote.allyPayment ? [{ payer: winner.ally!, amount: quote.allyPayment }] : [])] } : {}),
...(richeseContribution ? {richeseContribution} : {}),
...(incomePayment ? {spiceBankerIncomePayment:incomePayment} : {}),
winner: winner.id,
@@ -11065,10 +11113,14 @@ function decideRichese(g: Game, p: Player, decision: Decision, action: Action) {
requireRule(
g.phase === 3 &&
round &&
round.owner === p.id &&
(decision.kind === 'richeseCache' && g.homeworldOccupationPreview
? homeworldRule(() => requireOccupiedBiddingController(g, 'richeseCache', p.id)).provider === round.owner : round.owner === p.id) &&
action.event === round.event,
'This Richese bidding decision has expired.',
);
if ((decision.kind === 'richeseCache' || decision.kind === 'richeseCacheTerms') && round.cacheAuthority)
requireRule(round.cacheAuthority === JSON.stringify(quoteStableHomeworldOccupation(g, 'richese')),
'The original cache choice authority changed.');
if (decision.kind === 'richeseBlackMarket') {
requireRule(
round.stage === 'blackMarketOffer' && g.advanced,
@@ -11089,12 +11141,28 @@ function decideRichese(g: Game, p: Player, decision: Decision, action: Action) {
`${p.name} announced the Richese cache auction ${round.position}, before normal pool preparation. ${round.normalCount} normal lots are scheduled after the cache reduction${round.blackMarketSold ? ' and completed Black Market sale' : ''}.`,
);
g.response = { kind: 'richeseAuction', owner: p.id, passed: [] };
} else if (decision.kind === 'richeseCacheTerms') {
requireRule(round.stage === 'cacheOffer' && ['onceAround', 'silent'].includes(action.method as string) &&
['clockwise', 'counterclockwise'].includes(action.direction as string) &&
Object.keys(action).sort().join(',') === 'direction,event,method,type',
'The original seller must choose the cache auction terms.');
round.cacheTerms = { method: action.method as 'onceAround' | 'silent',
direction: action.direction as 'clockwise' | 'counterclockwise' };
round.cacheTermsEvent = round.event;
round.cacheTermsProof = JSON.stringify([round.cacheTermsEvent, round.turn, round.owner, round.cacheTerms, round.cacheAuthority]);
richeseDecision(g, 'richeseCache');
} else if (decision.kind === 'richeseCache') {
requireRule(
round.stage === 'cacheOffer',
'The cache offer is not available now.',
);
beginRicheseLot(g, 'cache', action);
if (p.id !== round.owner) {
requireRule(round.cacheTerms && Object.keys(action).sort().join(',') === 'card,event,type',
'The occupier chooses only the original physical cache card, not the seller terms.');
requireRule(round.cacheTermsProof === JSON.stringify([round.cacheTermsEvent, round.turn, round.owner,
round.cacheTerms, round.cacheAuthority]), 'The original seller terms changed before the physical cache choice.');
beginRicheseLot(g, 'cache', { ...action, ...round.cacheTerms });
} else beginRicheseLot(g, 'cache', action);
} else if (decision.kind === 'richeseUnbid') {
requireRule(
g.richeseAuction?.source === 'cache' &&
@@ -11144,9 +11212,15 @@ function setAuction(g: Game, cards?: Card[]) {
if (!cards && ixians && eligible.length && count) {
g.auction = null;
g.active = null;
g.ixAuction = { count, cards: [] };
g.ixAuction = { count, cards: [],
...(g.homeworldOccupationPreview ? { authority: JSON.stringify(quoteStableHomeworldOccupation(g, 'ix')) } : {}) };
g.ixAuctionKnown = null;
g.response = { kind: 'ixAuction', owner: ixians.id, passed: [] };
if (g.homeworldOccupationPreview && quoteOccupiedBiddingAuthority(g, 'ixAuction').occupied) {
const original = g.response;
g.response = null;
finishIxianNativeAttempt(g, original, false);
}
return;
}
if (!cards) {
@@ -11166,6 +11240,7 @@ function setAuction(g: Game, cards?: Card[]) {
(id) => eligible.includes(id),
)!;
g.auction = {
...(g.homeworldOccupationPreview ? { occupiedEvent: crypto.randomUUID() } : {}),
cards,
index: 0,
bid: 0,
@@ -11179,6 +11254,7 @@ function setAuction(g: Game, cards?: Card[]) {
}
function offerAuctionTechnology(g: Game) {
g.auction!.peekKnown = false;
if (g.homeworldOccupationPreview) g.homeworldAuctionInspection = undefined;
const ixians = byFaction(g, 'ixians');
if (
g.advanced &&
@@ -11198,10 +11274,23 @@ function auctionTechnologyQuote<T>(run: () => T): T {
throw error;
}
}
/** Card identity alone is not a lot: Technology can return a previously sold card. */
function originalAuctionInspectionLot(g: Game) {
return g.richeseAuction?.event ?? JSON.stringify([g.auction!.occupiedEvent ?? g.turn,
g.auction!.index, g.auction!.cards[g.auction!.index].id]);
}
function openOccupiedAuctionInspection(g: Game) {
if (!g.homeworldOccupationPreview) return;
const authority = quoteOccupiedBiddingAuthority(g, 'atreidesInspection');
const lot = originalAuctionInspectionLot(g);
g.homeworldAuctionInspection = { lot, audience: authority.blocked ? [] : [...authority.inspectionAudience],
native: authority.provider, occupation: JSON.stringify(quoteStableHomeworldOccupation(g, 'caladan')), occupiedKnown: false };
}
function offerAuctionPeek(g: Game) {
const quote = auctionTechnologyQuote(() => quoteNormalAuctionPeek(g));
g.auction!.peekKnown = quote.peekKnown;
g.response = quote.response;
openOccupiedAuctionInspection(g);
}
function aidFor(g: Game, p: Player) {
return p.ally && g.aid[p.ally]?.recipient === p.id
@@ -11733,6 +11822,9 @@ function settleAuction(g: Game, free = false, automatic = false, emperorNexus =
recipient: paysEmperor ? 'player' as const : 'bank' as const}] : []),
]) : undefined;
g.currentAuctionSale = {
...(g.homeworldOccupationPreview ? { occupiedEvent: crypto.randomUUID(),
occupiedContributions: [{ payer: winner.id, amount: free ? 0 : a.bid - (a.allyPayment ?? 0) },
...(!free && a.allyPayment ? [{ payer: winner.ally!, amount: a.allyPayment }] : [])] } : {}),
...(incomePayment ? {spiceBankerIncomePayment: incomePayment} : {}),
winner: winner.id,
amount: a.bid,
@@ -11781,6 +11873,9 @@ function currentAuctionContinuationQuote(
physicalCards: physicalTreacheryCards(g),
pendingIxAlly: g.pendingIxAlly,
ixTechnologyTurn: g.ixTechnologyTurn,
...(g.homeworldOccupationPreview ? { occupiedBonusAvailable:
(() => { const authority = quoteOccupiedBiddingAuthority(g, 'harkonnenBonus');
return !!authority.blocked || authority.occupied && authority.bonusRecipients.length > 0; })() } : {}),
richeseAuction: g.richeseAuction,
richeseBidding: g.richeseBidding,
richeseCacheCount: g.richeseCache?.length,
@@ -11824,11 +11919,22 @@ function commitAuctionContinuation(g: Game, quote: AuctionContinuationQuote,bure
`${seller.name} collected ${step.amount-(bureaucratDiversion ?? 0)} spice from the ${quote.sale.origin === 'cache' ? 'Richese cache' : 'Black Market'} sale.`,
{ faction: 'richese', name: 'Auction income' },
);
if (g.homeworldOccupationPreview && g.currentAuctionSale?.occupiedEvent) {
const sale = g.currentAuctionSale!, received = step.amount - (bureaucratDiversion ?? 0);
const source: OccupiedPercentageSource = { kind: 'richese-income', event: crypto.randomUUID(),
native: seller.id, amount: received, payment: { id: sale.occupiedEvent!,
binding: occupiedProducerProof(g, 'richese-income'), paid: sale.amount,
contributions: sale.occupiedContributions!, recipient: seller.id, received } };
if (admitOccupiedPercentage(g, source, received, { kind: 'auction', quote: { ...quote, steps: [] } })) return;
}
}
}
const next = quote.next;
if (next.kind === 'response') g.response = {...next.response,...(next.response.kind === 'emperorIncome' && g.currentAuctionSale?.bureaucratPayment ?
{bureaucratPayment:g.currentAuctionSale.bureaucratPayment,bureaucratPaymentEvent:g.currentAuctionSale.bureaucratPaymentEvent} : {})};
if (next.kind === 'response') {
g.response = {...next.response,...(next.response.kind === 'emperorIncome' && g.currentAuctionSale?.bureaucratPayment ?
{bureaucratPayment:g.currentAuctionSale.bureaucratPayment,bureaucratPaymentEvent:g.currentAuctionSale.bureaucratPaymentEvent} : {})};
offerOccupiedBonus(g, g.response);
}
else if (next.kind === 'richeseEnd') finishRicheseLot(g);
else {
g.currentAuctionSale = null;
@@ -11840,7 +11946,7 @@ function commitAuctionContinuation(g: Game, quote: AuctionContinuationQuote,bure
g.auction = next.auction;
g.active = next.active;
if (next.offer?.kind === 'ixTechnology') g.decision = next.offer;
else if (next.offer) g.response = next.offer;
else if (next.offer) offerAuctionPeek(g);
}
}
}
@@ -12382,16 +12488,21 @@ function currentIxAuctionDrawQuote(
* It consumes the accepted attempt directly; no declaration is replayed. */
function finishIxianNativeAttempt(g: Game, response: ResponseWindow, canceled: boolean): void {
if (response.kind === 'ixAuction') {
const request = currentIxAuctionDrawQuote(g, response, canceled)!;
const authority = g.homeworldOccupationPreview ? quoteOccupiedBiddingAuthority(g, 'ixAuction') : null;
requireRule(!authority?.blocked, authority?.blocked ?? 'The original Ixian pool is blocked.');
const request = currentIxAuctionDrawQuote(g, response, canceled && !authority?.occupied)!;
const pending = g.ixAuction!;
if (pending.authority) requireRule(pending.authority === JSON.stringify(quoteStableHomeworldOccupation(g, 'ix')),
'The original undrawn Ixian source authority changed.');
const cards: Card[] = [];
for (let i = 0; i < request.drawCount; i++) {
const card = draw(g);
if (card) cards.push(card);
}
if (!canceled && cards.length > 1) {
if ((!canceled || authority?.occupied) && cards.length > 1) {
pending.cards = cards;
g.decision = { kind: 'ixAuction', player: response.owner };
if (authority) pending.authority = JSON.stringify(quoteStableHomeworldOccupation(g, 'ix'));
g.decision = { kind: 'ixAuction', player: authority?.controller ?? response.owner };
} else {
g.ixAuction = null;
setAuction(g, cards);
@@ -12404,6 +12515,7 @@ function finishIxianNativeAttempt(g: Game, response: ResponseWindow, canceled: b
g.pendingIxTechnology = cancellation.pendingIxTechnology;
g.auction!.peekKnown = cancellation.peek.peekKnown;
g.response = cancellation.peek.response;
openOccupiedAuctionInspection(g);
log(g, 'Ixian auction substitution did not occur.');
return;
}
@@ -15644,7 +15756,9 @@ function ambassadorPurchaseBonus(g: Game) {
const entry = g.pendingAmbassador!;
const receipt = entry.purchaseReceipt!;
const buyer = getPlayer(g, receipt.buyer);
if (buyer.faction === 'harkonnen' && buyer.hand.length < handLimit(buyer)) {
const authority = g.homeworldOccupationPreview ? quoteOccupiedBiddingAuthority(g, 'harkonnenBonus') : null;
if (buyer.faction === 'harkonnen' && (authority?.blocked || authority?.occupied
? !!authority?.blocked || !!authority?.bonusRecipients.length : buyer.hand.length < handLimit(buyer))) {
entry.stage = receipt.stage = 'bonus';
g.response = {
kind: 'harkonnenBonus',
@@ -15653,8 +15767,219 @@ function ambassadorPurchaseBonus(g: Game) {
owner: buyer.id,
passed: [],
};
offerOccupiedBonus(g, g.response);
} else finishAmbassador(g);
}
/** A printed bonus consumes the original paid purchase's suffix, never a new buy. */
function offerOccupiedBonus(g: Game, response: ResponseWindow): boolean {
if (!g.homeworldOccupationPreview || response.kind !== 'harkonnenBonus') return false;
const authority = quoteOccupiedBiddingAuthority(g, 'harkonnenBonus');
if (response.source !== 'ambassador' && !g.currentAuctionSale?.occupiedEvent) return false;
if (!authority.occupied && !authority.blocked) return false;
if (response.source === 'ambassador') validateAmbassadorPurchaseResponse(g, response);
else currentAuctionContinuationQuote(g, { kind: 'cancel', response });
requireRule(!g.pendingHomeworldOccupiedBonus, 'The original bonus is already awaiting its receiver.');
const event = crypto.randomUUID();
const proof = JSON.stringify({ producer: occupiedProducerProof(g, 'bonus', response),
occupation: JSON.stringify(quoteStableHomeworldOccupation(g, 'giedi_prime')),
controller: authority.controller, recipients: authority.bonusRecipients });
g.homeworldOccupiedPercentageLedger ??= createOccupiedPercentageState();
g.pendingHomeworldOccupiedBonus = { event, proof, response: structuredClone(response) };
g.response = null;
g.decision = authority.controller && authority.bonusRecipients.length
? { kind: 'homeworldOccupiedBonus', player: authority.controller, event } : null;
if (!authority.blocked && !authority.bonusRecipients.length) {
g.pendingHomeworldOccupiedBonus = null;
if (response.source === 'ambassador') finishAmbassador(g); else nextAuction(g);
}
return true;
}
function decideOccupiedBonus(g: Game, p: Player, action: Action) {
const pending = g.pendingHomeworldOccupiedBonus;
requireRule(pending && action.event === pending.event && Object.keys(action).sort().join(',') === 'event,recipient,type',
'Choose the original occupied purchase bonus recipient.');
const authority = homeworldRule(() => requireOccupiedBiddingController(g, 'harkonnenBonus', p.id));
homeworldRule(() => requireOccupiedBiddingBonusRecipient(g, action.recipient as string));
const saved = JSON.parse(pending.proof) as { producer: string; controller: string; recipients: string[]; occupation: string };
requireRule(saved.controller === authority.controller &&
saved.occupation === JSON.stringify(quoteStableHomeworldOccupation(g, 'giedi_prime')) &&
saved.producer === occupiedProducerProof(g, 'bonus', pending.response) && saved.recipients.includes(action.recipient as string),
'The original purchase or reciprocal bonus recipient changed.');
if (pending.response.source === 'ambassador') validateAmbassadorPurchaseResponse(g, pending.response);
else currentAuctionContinuationQuote(g, { kind: 'cancel', response: pending.response });
const stock = [...g.deck, ...g.discard], physical = physicalTreacheryCards(g);
requireRule(stock.every(card => physical.filter(other => other.id === card.id).length === 1),
'The original bonus draw stock has conflicting physical custody.');
const card = draw(g);
requireRule(card, 'The original bonus requires one physical drawable card.');
getPlayer(g, action.recipient as string).hand.push(card);
g.pendingHomeworldOccupiedBonus = null; g.decision = null;
log(g, `${getPlayer(g, action.recipient as string).name} received the one private occupied Giedi Prime purchase bonus.`,
{ faction: 'harkonnen', name: 'Occupied purchase bonus' });
if (pending.response.source === 'ambassador') finishAmbassador(g); else nextAuction(g);
}
function projectedOccupiedBonus(g: Game) {
const pending = g.pendingHomeworldOccupiedBonus;
if (!pending) return null;
const authority = quoteOccupiedBiddingAuthority(g, 'harkonnenBonus');
const saved = JSON.parse(pending.proof) as { controller: string | null; recipients: string[]; occupation: string };
const owner = saved.controller ? getPlayer(g, saved.controller) : null;
const blocked = authority.blocked ?? (saved.occupation !== JSON.stringify(quoteStableHomeworldOccupation(g, 'giedi_prime'))
? 'The original occupied purchase bonus authority changed.' : null);
return { event: pending.event, owner: owner?.id ?? '', ownerName: owner?.name ?? '',
recipients: (blocked ? [] : authority.bonusRecipients.filter(id => saved.recipients.includes(id)))
.map(id => { const p = getPlayer(g, id); return { player: id, name: p.name, faction: p.faction }; }),
blocked };
}
type OccupiedPercentageSuffix =
| { kind: 'none'; decision: Decision | null }
| { kind: 'auction'; quote: AuctionContinuationQuote }
| { kind: 'auctionBonus' }
| { kind: 'ambassadorBonus' }
| { kind: 'richesePurchase'; resume: NonNullable<Game['pendingRichesePurchaseIncome']>['resume'] };
function occupiedProducerProof(g: Game, kind: OccupiedPercentageSource['kind'] | 'bonus', response?: ResponseWindow) {
if (kind === 'fremen-collection') return JSON.stringify(g.fremenCollectionCredits);
if (response?.source === 'ambassador') return JSON.stringify({ turn: g.turn, phase: g.phase, entry: g.pendingAmbassador });
if (response?.kind === 'richesePurchaseIncome') return JSON.stringify({ turn: g.turn, phase: g.phase, purchase: g.pendingRichesePurchaseIncome });
if (kind === 'guild-shipping') return JSON.stringify({ turn: g.turn, phase: g.phase, response,
movement: g.players.map(p => ({ id: p.id, shipped: p.shipped, forces: p.forces, elites: p.elites, noField: p.noField })),
homeworlds: g.homeworlds?.custody });
return JSON.stringify({ turn: g.turn, phase: g.phase, sale: g.currentAuctionSale,
auction: g.auction, lot: g.richeseAuction, round: g.richeseBidding });
}
function runOccupiedPercentageSuffix(g: Game, suffix: OccupiedPercentageSuffix) {
if (suffix.kind === 'auction') commitAuctionContinuation(g, suffix.quote, 0);
else if (suffix.kind === 'auctionBonus') auctionBonus(g);
else if (suffix.kind === 'ambassadorBonus') ambassadorPurchaseBonus(g);
else if (suffix.kind === 'richesePurchase') {
g.pendingRichesePurchaseIncome = null;
g.response = suffix.resume.response; g.decision = suffix.resume.decision; g.pendingKarama = suffix.resume.pendingKarama;
} else if (suffix.kind === 'none') {
g.decision = suffix.decision;
}
}
function occupiedPercentageIntegrity(g: Game) {
if (!g.homeworldOccupationPreview) {
requireRule(g.homeworldOccupiedPercentageLedger === undefined && g.pendingHomeworldOccupiedPercentage === undefined &&
g.pendingHomeworldOccupiedBonus === undefined, 'Occupied source receipts require their original fresh profile.');
return;
}
requireRule(!g.spiceBankerIncomePreview, 'Occupied percentage and deferred Banker routing require a separate source-composition profile.');
if (!g.homeworldOccupiedPercentageLedger) {
requireRule(!g.pendingHomeworldOccupiedPercentage && !g.pendingHomeworldOccupiedBonus,
'The original occupied percentage ledger is missing.');
return;
}
homeworldRule(() => validateOccupiedPercentageState(g.homeworldOccupiedPercentageLedger!, g));
const pending = g.pendingHomeworldOccupiedPercentage;
const unsettled = g.homeworldOccupiedPercentageLedger.receipts.filter(row => row.status !== 'settled');
requireRule(unsettled.length === Number(!!pending) && (!pending || unsettled[0].source.event === pending.source.event &&
JSON.stringify(unsettled[0].source) === JSON.stringify(pending.source)), 'The occupied source lost its exact unresolved receipt.');
if (pending) requireRule(pending.suffixSignature === JSON.stringify(pending.suffix),
'The exact occupied percentage suffix changed.');
}
/** The original payer has already paid; reconcile native cash only once. */
function admitOccupiedPercentage(g: Game, source: OccupiedPercentageSource, alreadyCredited: number,
suffix: OccupiedPercentageSuffix): boolean {
if (!g.homeworldOccupationPreview) return false;
occupiedPercentageIntegrity(g);
g.homeworldOccupiedPercentageLedger ??= createOccupiedPercentageState();
requireRule(!g.pendingHomeworldOccupiedPercentage, 'Settle the original percentage allocation first.');
const result = homeworldRule(() => quoteOccupiedPercentageSource(g, source, g.homeworldOccupiedPercentageLedger!));
const native = getPlayer(g, source.native), balance = native.spice - alreadyCredited + result.receipt.nativeRetained;
requireRule(Number.isSafeInteger(balance) && balance >= 0, 'The original percentage cash cannot be reconciled.');
native.spice = balance; g.homeworldOccupiedPercentageLedger = result.state;
log(g, `${native.name}'s original ${source.kind === 'fremen-collection' ? 'total Collection' : 'paid income'} retained ${result.receipt.nativeRetained} spice; ${result.receipt.occupiedAmount} spice belongs to the separate occupied portion and ${result.receipt.bankRetained} spice remains in the bank.${result.receipt.blocked ? ' The printed allocation is blocked by its unresolved original occupation source.' : ''}`,
{ faction: native.faction, name: 'Original Homeworld income' });
if (result.receipt.status === 'settled') return false;
g.pendingHomeworldOccupiedPercentage = { source: result.receipt.source,
proof: source.kind === 'fremen-collection' ? source.collection.binding : source.payment.binding,
suffix, suffixSignature: JSON.stringify(suffix) };
const owner = result.receipt.entitlement?.occupier;
g.decision = owner ? { kind: 'homeworldOccupiedPercentage', player: owner, event: source.event } : null;
return true;
}
function occupiedPaymentSource(g: Game, response: ResponseWindow, gross: number, canceled: boolean): OccupiedPercentageSource {
const kind = response.kind === 'guildIncome' ? 'guild-shipping' : 'emperor-treachery', sale = g.currentAuctionSale;
const buyer = response.source === 'ambassador' ? g.pendingAmbassador!.purchaseReceipt!.buyer :
response.kind === 'richesePurchaseIncome' ? g.pendingRichesePurchaseIncome!.buyer : sale?.winner;
let contributions = response.occupiedPayment?.contributions ?? [{ payer: buyer!, amount: gross }];
if (response.kind === 'emperorIncome' && response.source !== 'ambassador' && sale) {
contributions = sale.richeseContribution !== undefined ? [{ payer: sale.seller!, amount: gross }] :
sale.occupiedContributions!;
}
const paid = contributions.reduce((sum, leg) => sum + leg.amount, 0);
if (paid !== gross) {
requireRule(contributions.length === 1 && paid >= gross, 'The routed payment lost its eligible paid contributions.');
contributions = [{ ...contributions[0], amount: gross }];
}
const id = response.occupiedPayment?.event ?? (response.source === 'ambassador' ? g.pendingAmbassador!.event :
response.kind === 'richesePurchaseIncome' ? g.pendingRichesePurchaseIncome!.event! : sale!.occupiedEvent!);
return { kind, event: crypto.randomUUID(), native: response.owner, amount: gross,
payment: { id, binding: occupiedProducerProof(g, kind, response), paid: gross, contributions, recipient: response.owner },
nativeIncome: canceled ? 'karama-canceled' : 'received' };
}
function settleOccupiedPayment(g: Game, response: ResponseWindow, gross: number, canceled: boolean,
suffix: OccupiedPercentageSuffix): boolean {
return !!g.homeworldOccupationPreview && admitOccupiedPercentage(g, occupiedPaymentSource(g, response, gross, canceled), 0, suffix);
}
function decideOccupiedPercentage(g: Game, p: Player, action: Action) {
occupiedPercentageIntegrity(g);
const pending = g.pendingHomeworldOccupiedPercentage!;
requireRule(pending && action.event === pending.source.event && Object.keys(action).sort().join(',') === 'event,ownAmount,type',
'Choose the original occupied percentage event and allocation.');
const response = pending.source.kind === 'fremen-collection' ? undefined :
JSON.parse(pending.source.payment.binding).response as ResponseWindow | undefined;
const originalResponse = response ?? (pending.suffix.kind === 'ambassadorBonus' ? { source: 'ambassador' } as ResponseWindow :
pending.suffix.kind === 'richesePurchase' ? { kind: 'richesePurchaseIncome' } as ResponseWindow : undefined);
requireRule(occupiedProducerProof(g, pending.source.kind, originalResponse) === pending.proof,
'The original occupied percentage producer changed.');
const result = homeworldRule(() => allocateOccupiedPercentageReceipt(g.homeworldOccupiedPercentageLedger!, g,
p.id, action.event as string, action.ownAmount as number, pending.source));
for (const credit of result.credits) requireRule(Number.isSafeInteger(getPlayer(g, credit.player).spice + credit.amount), 'Occupied allocation exceeds a wallet.');
g.homeworldOccupiedPercentageLedger = result.state; g.pendingHomeworldOccupiedPercentage = null; g.decision = null;
for (const credit of result.credits) {
getPlayer(g, credit.player).spice += credit.amount;
if (pending.source.kind === 'fremen-collection') creditGiediCollection(g, credit.player, { kind: 'homeworld', amount: credit.amount });
log(g, `${getPlayer(g, credit.player).name} received ${credit.amount} spice from the original occupied percentage allocation.`,
{ faction: getPlayer(g, credit.player).faction, name: 'Occupied percentage income' });
}
runOccupiedPercentageSuffix(g, pending.suffix);
}
function projectedOccupiedPercentage(g: Game) {
const pending = g.pendingHomeworldOccupiedPercentage;
if (!pending) return null;
const row = g.homeworldOccupiedPercentageLedger!.receipts.find(row => row.source.event === pending.source.event)!;
const card = HOMEWORLD_CARDS.find(card => card.id === (row.entitlement?.card ??
({ 'emperor-treachery': 'kaitain', 'guild-shipping': 'junction', 'richese-income': 'richese', 'fremen-collection': 'southern_hemisphere' } as const)[row.source.kind]))!;
const owner = row.entitlement ? getPlayer(g, row.entitlement.occupier) : null;
const ally = row.entitlement?.ally ? getPlayer(g, row.entitlement.ally) : null;
const current = quoteStableHomeworldOccupation(g, card.id);
const blocked = row.blocked ?? current.blocked ??
(JSON.stringify(current.entitlement) !== JSON.stringify(row.entitlement) ? 'The original occupied percentage authority changed.' : null);
return { event: row.source.event, world: `homeworld:${card.faction}`, name: card.name, owner: owner?.id ?? '', ownerName: owner?.name ?? '',
ally: ally?.id ?? null, allyName: ally?.name ?? null, amount: row.occupiedAmount, minOwnAmount: ally ? 0 : row.occupiedAmount,
maxOwnAmount: row.occupiedAmount, blocked };
}
function recordFremenCollection(g: Game, player: string, id: string, amount: number) {
if (!g.homeworldOccupationPreview || getPlayer(g, player).faction !== 'fremen' || amount === 0) return;
const receipt = g.fremenCollectionCredits;
if (!receipt) return;
requireRule(receipt?.turn === g.turn && !receipt.complete && Number.isSafeInteger(amount) && amount > 0 &&
!receipt.collected.some(leg => leg.id === id), 'The original total Fremen Collection credit changed.');
receipt.collected.push({ id, amount });
}
function completeOccupiedFremenCollection(g: Game) {
const receipt = g.fremenCollectionCredits, native = byFaction(g, 'fremen');
if (!native || !receipt || receipt.complete || g.phase !== 7 || g.phaseOpening || g.decision || g.response ||
g.truthtrance || g.pendingHomeworldOccupiedIncome || g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingKarama ||
g.grummanCollection && g.grummanCollection.stage !== 'complete' || g.ecazCollection && g.ecazCollection.stage !== 'complete') return;
receipt.complete = true;
const amount = receipt.collected.reduce((sum, leg) => sum + leg.amount, 0);
admitOccupiedPercentage(g, { kind: 'fremen-collection', native: native.id, event: crypto.randomUUID(), amount,
collection: { id: receipt.event, binding: occupiedProducerProof(g, 'fremen-collection'), complete: true, collected: receipt.collected } },
amount, { kind: 'none', decision: null });
}
/** The payer has already paid. Only credit the eligible faction share once. */
function creditFactionPayment(g: Game, ownerId: string, kind: 'shipment' | 'treachery', gross: number) {
const payment = homeworldRule(() => quoteHomeworldPaymentIncome(g, ownerId, kind, gross));
@@ -15689,14 +16014,19 @@ function currentFactionPayment(g: Game) {
'The saved Guild payment no longer matches its original contributor receipt.');
validateGuildPaymentRounding(g, response.owner, gross, response.guildContributions);
}
return { owner: response.owner, kind,
...homeworldRule(() => quoteHomeworldPaymentIncome(g, response.owner, kind, gross)) };
const payment = homeworldRule(() => quoteHomeworldPaymentIncome(g, response.owner, kind, gross));
const stable = g.homeworldOccupationPreview
? homeworldRule(() => quoteStableHomeworldOccupation(g, kind === 'shipment' ? 'junction' : 'kaitain')) : null;
return { owner: response.owner, kind, ...payment,
...(stable?.entitlement || stable?.blocked ? { income: Math.ceil(gross / 2), bank: 0 } : {}) };
}
function validateGuildPaymentRounding(g: Game, owner: string, gross: number, contributions: number[]) {
if (!g.homeworlds?.custody) return;
const rounding = homeworldRule(() => quoteGuildPaymentRounding(gross, contributions));
const payment = homeworldRule(() => quoteHomeworldPaymentIncome(g, owner, 'shipment', gross));
requireRule(!payment.low || rounding.unambiguous,
const junction = g.homeworldOccupationPreview
? homeworldRule(() => quoteStableHomeworldOccupation(g, 'junction')) : null;
requireRule((!payment.low && !junction?.entitlement && !junction?.blocked) || rounding.unambiguous,
'Low Junction rounding for two odd allied contributions awaits a ruling. Choose a different payment split.');
}
function shipmentIncomeContributions(g: Game, p: Player, cost: number, allyPayment: number) {
@@ -15713,11 +16043,14 @@ function checkShipmentIncomeRounding(g: Game, p: Player, cost: number, allyPayme
...(allyPayment ? [{payer:p.ally!,amount:p.ally === guild.id ? 0 : allyPayment}] : [])],allyPayment > 0);
validateGuildPaymentRounding(g, guild.id, amounts.reduce((sum, amount) => sum + amount, 0), amounts);
}
function guildPaymentResponse(g: Game, owner: string, amounts: number[],payer?:string): ResponseWindow {
function guildPaymentResponse(g: Game, owner: string, amounts: number[],payer?:string,
named?: { payer: string; amount: number }[]): ResponseWindow {
const contributions = amounts.filter(amount => amount > 0);
const amount = contributions.reduce((sum, value) => sum + value, 0);
validateGuildPaymentRounding(g, owner, amount, contributions);
return { kind: 'guildIncome', owner, amount, passed: [],
...(g.homeworldOccupationPreview ? { occupiedPayment: { event: crypto.randomUUID(),
contributions: named ?? [{ payer: payer!, amount }] } } : {}),
...(contributions.length === 1 && payer ? stampBureaucratPayment(g,'shipment',payer,owner,amount) : {}),
...(g.homeworlds?.custody ? { guildContributions: contributions,
guildPaymentProof: guildPaymentSignature(g, owner, amount, contributions) } : {}) };
@@ -17996,6 +18329,7 @@ function homeworldHistoryIntegrity(g: Game) {
if (g.tupileIntelligence) homeworldRule(() => validateTupileIntelligenceState(g.tupileIntelligence!, g.players, g.turn));
} else requireRule(g.tupileIntelligence === undefined, 'Tupile intelligence requires its original Homeworld history.');
homeworldOccupiedIncomeIntegrity(g);
occupiedPercentageIntegrity(g);
}
function homeworldOccupiedIncomeIntegrity(g: Game) {
const pending = g.pendingHomeworldOccupiedIncome, history = g.homeworldOccupiedIncomeHistory;
@@ -18045,6 +18379,7 @@ function commitHomeworldOccupiedIncome(g: Game, actor: string, event: string, wo
g.pendingHomeworldOccupiedIncome = result.state;
for (const credit of result.credits) {
getPlayer(g, credit.player).spice += credit.amount;
recordFremenCollection(g, credit.player, `${event}:${world}:${credit.player}`, credit.amount);
creditGiediCollection(g, credit.player, { kind: 'homeworld', amount: credit.amount });
log(g, `${getPlayer(g, credit.player).name} received ${credit.amount} spice from ${world}'s occupied bank award.`,
{ faction: getPlayer(g, credit.player).faction, name: 'Occupied Homeworld Collection' });
@@ -18213,6 +18548,7 @@ function commitCollection(
for (const receipt of discovery.receipts) {
const p = getPlayer(g, receipt.player);
p.spice = receipt.balance;
recordFremenCollection(g, p.id, `ordinary:${p.id}`, receipt.strongholds + receipt.collected);
if (receipt.strongholds)
log(
g,
@@ -18227,6 +18563,7 @@ function commitCollection(
}
for (const effect of discovery.effects) {
const p = getPlayer(g, effect.player);
recordFremenCollection(g, p.id, `${effect.kind}:${effect.kind === 'orgiz' ? effect.location : 'bank'}:${p.id}`, effect.amount);
if (effect.kind === 'cistern')
log(
g,
@@ -18280,6 +18617,8 @@ function commitCollection(
stageGrummanCollection(g);
}
function collect(g: Game) {
if (g.homeworldOccupationPreview && byFaction(g, 'fremen') && g.fremenCollectionCredits?.turn !== g.turn)
g.fremenCollectionCredits = { turn: g.turn, event: crypto.randomUUID(), collected: [], complete: false };
if (g.homeworldOccupationPreview && !g.pendingHomeworldOccupiedIncome &&
!g.homeworldOccupiedIncomeHistory!.some(receipt => receipt.turn === g.turn))
g.pendingHomeworldOccupiedIncome = homeworldRule(() => beginHomeworldOccupiedIncome(g, crypto.randomUUID()));
@@ -18351,6 +18690,8 @@ function decideSharedSpice(g: Game, id: string, action: Action) {
pending.settled.push(receipt);
getPlayer(g, receipt.ecaz).spice += receipt.ecazAmount;
getPlayer(g, receipt.ally).spice += receipt.allyAmount;
recordFremenCollection(g, receipt.ecaz, `shared:${receipt.territory}:${receipt.ecaz}`, receipt.ecazAmount);
recordFremenCollection(g, receipt.ally, `shared:${receipt.territory}:${receipt.ally}`, receipt.allyAmount);
creditGiediCollection(g, receipt.ecaz, { kind: 'desert', amount: receipt.ecazAmount });
creditGiediCollection(g, receipt.ally, { kind: 'desert', amount: receipt.allyAmount });
log(
@@ -22751,6 +23092,22 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
: null;
if (!canceled && bureaucratDiversion === undefined && offerBureaucratPayment(g,response.bureaucratPayment,{kind:'response'})) return;
g.response = null;
if (g.homeworldOccupationPreview &&
(response.occupiedPayment || response.source === 'ambassador' || response.kind === 'richesePurchaseIncome' && g.pendingRichesePurchaseIncome?.event ||
response.kind === 'emperorIncome' && g.currentAuctionSale?.occupiedEvent) &&
['emperorIncome', 'guildIncome', 'richesePurchaseIncome'].includes(response.kind)) {
const gross = (response.kind === 'emperorIncome' ? response.source === 'ambassador' ? response.amount! :
auctionEmperorIncomeAmount(g.currentAuctionSale!) : response.amount!) - (bureaucratDiversion ?? 0);
const suffix: OccupiedPercentageSuffix = response.source === 'ambassador' ? { kind: 'ambassadorBonus' } :
response.kind === 'emperorIncome' ? { kind: 'auctionBonus' } : response.kind === 'richesePurchaseIncome'
? { kind: 'richesePurchase', resume: g.pendingRichesePurchaseIncome!.resume } : { kind: 'none', decision: g.decision };
if (response.kind === 'emperorIncome') awardBankerIncome(g, g.currentAuctionSale?.spiceBankerIncomePayment, canceled);
if (response.kind === 'guildIncome') awardBankerIncome(g, response.spiceBankerIncomePayment, canceled);
const pending = settleOccupiedPayment(g, response, gross, canceled, suffix);
if (!pending) runOccupiedPercentageSuffix(g, suffix);
return;
}
if (offerOccupiedBonus(g, response)) return;
if (response.kind === 'ecazOccupy') {
const b = g.battle, occupy = b?.ecazOccupy;
requireRule(b && occupy?.lead && occupy.ecaz === response.owner && !b.revealed &&
@@ -23204,6 +23561,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
owner: atreides.id,
passed: [],
};
if (g.response?.kind === 'atreidesAuction') openOccupiedAuctionInspection(g);
else settleRicheseLot(g);
}
} else if (response.kind === 'ixAuction' || response.kind === 'ixTechnology') {
@@ -23588,12 +23946,17 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
chooseGuildTiming(g, response.take!);
}
} else if (response.kind === 'atreidesAuction') {
if (g.homeworldOccupationPreview) {
if (!g.homeworldAuctionInspection) openOccupiedAuctionInspection(g);
const saved = g.homeworldAuctionInspection!, occupation = quoteStableHomeworldOccupation(g, 'caladan');
requireRule(saved.lot === originalAuctionInspectionLot(g) &&
saved.occupation === JSON.stringify(occupation), 'The original Caladan inspection source changed.');
saved.occupiedKnown = !!occupation.entitlement && !occupation.blocked;
}
if (g.richeseAuction) {
g.richesePeekKnown = !canceled;
settleRicheseLot(g);
} else
g.auction!.peekKnown =
terminal?.kind === 'atreidesAuction' ? terminal.known : !canceled;
} else g.auction!.peekKnown = terminal?.kind === 'atreidesAuction' ? terminal.known : !canceled;
} else if (response.kind === 'atreidesSpice') {
const blocked = homeworldRule(() => homeworldMovementForesightBlock(g, response.owner));
requireRule(!blocked, blocked ?? 'Caladan prevents this foresight.');
@@ -23936,7 +24299,9 @@ function commitJunctionTransport(g: Game, p: Player, declaration: JunctionTransp
const income = (ordinaryIncome ? quote.cost - allyPayment : 0) +
(p.ally === option.owner ? 0 : allyPayment);
if (!settlement?.recipient && income > 0) g.response = guildPaymentResponse(g, option.owner,
[ordinaryIncome ? quote.cost - allyPayment : 0, p.ally === option.owner ? 0 : allyPayment]);
[ordinaryIncome ? quote.cost - allyPayment : 0, p.ally === option.owner ? 0 : allyPayment], undefined,
[...(ordinaryIncome ? [{ payer: p.id, amount: quote.cost - allyPayment }] : []),
...(p.ally !== option.owner && allyPayment ? [{ payer: p.ally!, amount: allyPayment }] : [])]);
const sourceName = quote.originKind === 'arrakis' ? territory(quote.origin).name :
quote.sources.map((s) => combatLocationName(g, s.key)).join(' and ');
log(g, `${p.name} accepted ${getPlayer(g, option.owner).name}’s Junction ${rate}-price offer and transported ${quote.amount} physical forces (${quote.elite} special) from ${sourceName} to ${arrival ? `${territory(arrival.territory).name}, sector ${arrival.sector}` : combatLocationName(g, destination)} for ${quote.cost} spice (${quote.cost - allyPayment} own, ${allyPayment} pledged). The shipment is used; movement remains available.`,
@@ -24052,7 +24417,7 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment, se
const guild = byFaction(g, 'guild');
const income = guild && p.ally !== guild.id ? shipment.allyPayment : 0;
if (!settlement?.recipient && guild && income > 0)
g.response = guildPaymentResponse(g, guild.id, [income]);
g.response = guildPaymentResponse(g, guild.id, [income], p.ally!);
finishShipmentPromises(g, p, null);
log(g, `${p.name} shipped ${quote.amount} physical forces (${quote.elite} special) from ${quote.sourceNames.join(' and ')} to ${combatLocationName(g, shipment.destination)} for ${quote.cost} spice (${quote.cost - shipment.allyPayment} own, ${shipment.allyPayment} pledged). This uses their shipment; movement remains available.${shipment.guildSecretEvent ? ' Guild Secret Ally is spent for this half-price shipment; the payment goes to the bank.' : ''}`,
{faction: shipment.guildSecretEvent ? 'guild' : p.faction, name: shipment.guildSecretEvent ? 'Secret Ally Homeworld shipment' : 'Homeworld shipment'});
@@ -24546,9 +24911,9 @@ function commitGuildTransport(
bankOnly: g.karamaShipping?.player === p.id,
});
if (!settlement?.recipient && guild && guildPayment > 0)
g.response = guildPaymentResponse(g, guild.id,
shipmentIncomeContributions(g, p, cost, allyPayment),
allyPayment ? undefined : p.id);
g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment),
allyPayment ? undefined : p.id, [{ payer: p.id, amount: p.id === guild.id ? 0 : cost - allyPayment },
...(allyPayment && p.ally !== guild.id ? [{ payer: p.ally!, amount: allyPayment }] : [])]);
settleBankerShipmentIncome(g, p, cost, allyPayment);
if (fromReserves) {
p.reserves -= amount;
@@ -24770,7 +25135,9 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
bankOnly: g.karamaShipping?.player === p.id,
});
if (!settlement?.recipient && guild && guildPayment > 0)
g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment),allyPayment ? undefined : p.id);
g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment),
allyPayment ? undefined : p.id, [{ payer: p.id, amount: p.id === guild.id ? 0 : cost - allyPayment },
...(allyPayment && p.ally !== guild.id ? [{ payer: p.ally!, amount: allyPayment }] : [])]);
settleBankerShipmentIncome(g, p, cost, allyPayment);
g.karamaShipping = null;
log(
@@ -26163,6 +26530,7 @@ export function executeSpecialKaramaIntent(
const emperor = byFaction(g, 'emperor');
if (emperor) {
g.pendingRichesePurchaseIncome = {
...(g.homeworldOccupationPreview ? { event: crypto.randomUUID(), buyer: p.id } : {}),
owner: emperor.id,
turn: g.turn,
phase: g.phase,
@@ -26712,6 +27080,7 @@ function finishActionContinuations(g: Game) {
resumeMarketGhola(g);
resumeHomeworldOccupiedIncome(g);
resumeGrummanCollection(g);
completeOccupiedFremenCollection(g);
if (!g.truthtrance && !g.decision && !g.response) advanceSetup(g);
if (g.status === 'playing' && g.leaderSkills && !g.decision && !g.response && !g.truthtrance && !g.phaseOpening) {
const owner = g.order.find((id) => g.leaderSkills!.offers[id]);
@@ -26775,6 +27144,26 @@ function settleAutomaticContinuations(g: Game) {
if (g.truthtrance || g.phaseOpening || g.status === 'finished') return;
const response = g.response;
const before = JSON.stringify(g);
const bonus = g.pendingHomeworldOccupiedBonus;
if (bonus && g.decision?.kind === 'homeworldOccupiedBonus') {
const authority = quoteOccupiedBiddingAuthority(g, 'harkonnenBonus');
const saved = JSON.parse(bonus.proof) as { occupation: string; producer: string };
if (!authority.blocked && !authority.bonusRecipients.length &&
saved.occupation === JSON.stringify(quoteStableHomeworldOccupation(g, 'giedi_prime')) &&
saved.producer === occupiedProducerProof(g, 'bonus', bonus.response)) {
if (bonus.response.source === 'ambassador') validateAmbassadorPurchaseResponse(g, bonus.response);
else currentAuctionContinuationQuote(g, { kind: 'cancel', response: bonus.response });
g.pendingHomeworldOccupiedBonus = null;
g.decision = null;
log(g, 'The original purchase produced no occupied bonus card: its actual recipients have no hand space or no drawable stock remains.',
{ faction: 'harkonnen', name: 'Occupied purchase bonus' });
if (bonus.response.source === 'ambassador') finishAmbassador(g); else nextAuction(g);
continue;
}
}
if (g.pendingHomeworldOccupiedPercentage || g.pendingHomeworldOccupiedBonus && !g.decision) return;
if (response?.kind === 'ixAuction' && g.homeworldOccupationPreview &&
quoteOccupiedBiddingAuthority(g, 'ixAuction').blocked) return;
if (response) {
if (
!(
@@ -26993,6 +27382,12 @@ function applyActionInner(
g.guildTimingLocked ??= false;
normalizeBattle(g);
const p = getPlayer(g, id);
requireRule(!g.pendingHomeworldOccupiedPercentage || g.decision?.kind === 'homeworldOccupiedPercentage',
'The original displaced percentage source is blocked.');
requireRule(!g.pendingHomeworldOccupiedBonus || g.decision?.kind === 'homeworldOccupiedBonus',
'The original displaced bonus source is blocked.');
requireRule(!(g.homeworldOccupationPreview && g.richeseBidding?.stage === 'cacheOffer' && !g.decision &&
quoteOccupiedBiddingAuthority(g, 'richeseCache').blocked), 'The original displaced cache-card source is blocked.');
requireRule(
action && typeof action.type === 'string',
'Action type is required.',
@@ -27481,6 +27876,7 @@ function applyActionInner(
decision.kind === 'richeseBlackMarket' ||
decision.kind === 'richeseDeclaration' ||
decision.kind === 'richeseCache' ||
decision.kind === 'richeseCacheTerms' ||
decision.kind === 'richeseUnbid'
) {
decideRichese(g, p, decision, action);
@@ -27568,6 +27964,14 @@ function applyActionInner(
decideGrummanCollection(g, p, action);
return g;
}
if (decision.kind === 'homeworldOccupiedPercentage') {
decideOccupiedPercentage(g, p, action);
return g;
}
if (decision.kind === 'homeworldOccupiedBonus') {
decideOccupiedBonus(g, p, action);
return g;
}
if (decision.kind === 'homeworldOccupiedIncome') {
decideHomeworldOccupiedIncome(g, p, decision, action);
return g;
@@ -27898,9 +28302,12 @@ function applyActionInner(
);
} else if (decision.kind === 'ixAuction') {
requireRule(
g.ixAuction && p.faction === 'ixians',
g.ixAuction && (g.homeworldOccupationPreview
? homeworldRule(() => requireOccupiedBiddingController(g, 'ixAuction', p.id)).provider === byFaction(g, 'ixians')!.id : p.faction === 'ixians'),
'The auction pool is not available.',
);
if (g.ixAuction.authority) requireRule(g.ixAuction.authority === JSON.stringify(quoteStableHomeworldOccupation(g, 'ix')),
'The original Ixian pool authority changed before selection.');
const card = g.ixAuction.cards.find((c) => c.id === action.card);
requireRule(card, 'Choose a card from the auction pool.');
requireRule(
@@ -27910,7 +28317,8 @@ function applyActionInner(
if (action.position === 'top') g.deck.unshift(card);
else g.deck.push(card);
const rest = g.ixAuction.cards.filter((c) => c.id !== card.id);
g.ixAuctionKnown = { turn: g.turn, cards: [...rest] };
g.ixAuctionKnown = { turn: g.turn, cards: [...rest],
...(g.homeworldOccupationPreview ? { controller: p.id } : {}) };
g.ixAuction = null;
setAuction(g, shuffle(rest));
log(
@@ -30891,6 +31299,15 @@ export function viewGame(state: Game, id: string) {
const leaderRevivals = leaderRevivalOptions(visibleRevivalGame,
{ ...me, leaders: me.leaders.map((leader) => projectLeader(g, leader, id)) });
const b = g.battle;
const ixAuthority = g.homeworldOccupationPreview ? quoteOccupiedBiddingAuthority(g, 'ixAuction') : null;
const ixPoolStale = !!(g.homeworldOccupationPreview && g.ixAuction?.authority &&
g.ixAuction.authority !== JSON.stringify(quoteStableHomeworldOccupation(g, 'ix')));
const ixPoolAuthorized = !g.homeworldOccupationPreview || !ixPoolStale && !ixAuthority?.blocked &&
ixAuthority?.controller === id && g.decision?.kind === 'ixAuction' && g.decision.player === id;
const cacheAuthority = g.homeworldOccupationPreview && g.richeseBidding?.stage === 'cacheOffer'
? quoteOccupiedBiddingAuthority(g, 'richeseCache') : null;
const cacheStale = !!(cacheAuthority && g.richeseBidding?.cacheAuthority &&
g.richeseBidding.cacheAuthority !== JSON.stringify(quoteStableHomeworldOccupation(g, 'richese')));
const completion =
!g.pendingTreacheryDiscard &&
b &&
@@ -31006,6 +31423,8 @@ export function viewGame(state: Game, id: string) {
me.faction === 'moritani' ? (g.pendingMoritaniPlacement ?? null) : null,
grummanCollection: projectedGrummanCollection(g, id),
homeworldOccupiedIncome: projectedHomeworldOccupiedIncome(g, id),
homeworldOccupiedPercentage: projectedOccupiedPercentage(g),
homeworldOccupiedBonus: projectedOccupiedBonus(g),
tupileIntelligence: projectedTupileIntelligence(g, id),
terrorEntry: g.pendingTerrorEntry
? (() => {
@@ -31048,14 +31467,17 @@ export function viewGame(state: Game, id: string) {
})()
: null,
ixTechnology:
me.faction === 'ixians'
me.faction === 'ixians' || g.ixAuctionKnown?.controller === id ||
g.decision?.kind === 'ixAuction' && g.decision.player === id
? {
setup: g.ixSetupCards ?? null,
pool: g.ixAuction?.cards.length
blocked: ixAuthority?.blocked ?? (ixPoolStale ? 'The original Ixian pool authority changed.' : null),
setup: me.faction === 'ixians' ? g.ixSetupCards ?? null : null,
pool: ixPoolAuthorized && g.ixAuction?.cards.length
? [...g.ixAuction.cards].sort((a, b) => a.id.localeCompare(b.id))
: null,
known:
g.ixAuctionKnown?.turn === g.turn
g.ixAuctionKnown?.turn === g.turn &&
(!g.homeworldOccupationPreview || g.ixAuctionKnown.controller === id)
? [...g.ixAuctionKnown.cards].sort((a, b) =>
a.id.localeCompare(b.id),
)
@@ -31531,6 +31953,7 @@ export function viewGame(state: Game, id: string) {
...(g.response.bureaucratPayment ? {bureaucratPayment:undefined,bureaucratPaymentEvent:undefined} : {}),
...(g.response.guildContributions ? { guildContributions: undefined } : {}),
...(g.response.guildPaymentProof ? { guildPaymentProof: undefined } : {}),
...(g.response.occupiedPayment ? { occupiedPayment: undefined } : {}),
...(g.response.sandmasterProof ? { sandmasterProof: undefined } : {}),
...(g.response.noFieldSkillProof ? { noFieldSkillProof: undefined } : {}),
passed: g.response.passed.includes(id) ? [id] : [],
@@ -31814,9 +32237,11 @@ export function viewGame(state: Game, id: string) {
stage: g.richeseBidding.stage,
position: g.richeseBidding.position,
normalCount: g.richeseBidding.normalCount,
offerBlocked: null,
cache:
g.richeseBidding.owner === id ? (g.richeseCache ?? []) : null,
cacheTerms: g.richeseBidding.cacheTerms ?? null,
offerBlocked: cacheAuthority?.blocked ?? (cacheStale ? 'The original cache choice authority changed.' : null),
cache: g.richeseBidding.owner === id || cacheAuthority && !cacheStale && !cacheAuthority.blocked &&
g.decision?.kind === 'richeseCache' && g.decision.player === id && cacheAuthority.controller === id
? g.richeseCache ?? [] : null,
}
: null,
richeseAuction: g.richeseAuction
@@ -31825,7 +32250,10 @@ export function viewGame(state: Game, id: string) {
card:
g.richeseAuction.source === 'cache' ||
g.richeseAuction.owner === id ||
(me.faction === 'atreides' && g.richesePeekKnown)
(me.faction === 'atreides' && g.richesePeekKnown) ||
(g.homeworldOccupationPreview && g.homeworldAuctionInspection?.lot === originalAuctionInspectionLot(g) &&
g.homeworldAuctionInspection.occupiedKnown && id !== g.homeworldAuctionInspection.native &&
g.homeworldAuctionInspection.audience.includes(id))
? (g.richeseOfferedCard ?? richeseLotCard(g) ?? null)
: null,
claim: g.richeseClaim ?? null,
@@ -31835,6 +32263,7 @@ export function viewGame(state: Game, id: string) {
richeseOwnCommitment(g.richeseAuction, g.richeseFunding ?? {}, id),
allyAvailable: aidFor(g, me)?.amount ?? 0,
peekKnown: !!g.richesePeekKnown,
inspectionBlocked: g.homeworldOccupationPreview ? quoteOccupiedBiddingAuthority(g, 'atreidesInspection').blocked : null,
}
: null,
auction: g.auction
@@ -31846,8 +32275,12 @@ export function viewGame(state: Game, id: string) {
? { allyPayment: g.auction.allyPayment ?? 0 }
: {}),
remaining: g.auction.cards.length - g.auction.index,
inspectionBlocked: g.homeworldOccupationPreview ? quoteOccupiedBiddingAuthority(g, 'atreidesInspection').blocked : null,
card:
me.faction === 'atreides' && g.auction.peekKnown
(me.faction === 'atreides' && g.auction.peekKnown) ||
(g.homeworldOccupationPreview && g.homeworldAuctionInspection?.lot === originalAuctionInspectionLot(g) &&
g.homeworldAuctionInspection.occupiedKnown && id !== g.homeworldAuctionInspection.native &&
g.homeworldAuctionInspection.audience.includes(id))
? g.auction.cards[g.auction.index]
: null,
}
+124
View File
@@ -0,0 +1,124 @@
import type { FactionId } from './catalog';
import { handLimit, type Game } from './engine';
import type { HomeworldId } from './homeworld-cards';
import { HomeworldCustodyError } from './homeworld-custody';
import { quoteStableHomeworldOccupation } from './homeworld-stable-occupation';
export type OccupiedBiddingKind =
| 'atreidesInspection'
| 'ixAuction'
| 'richeseCache'
| 'harkonnenBonus';
export type OccupiedBiddingAuthority = {
kind: OccupiedBiddingKind;
source: 'auctionLot' | 'ixAuctionPool' | 'richeseCache' | 'treacheryStock';
/** Original native power provider, not a substitute buyer, seller or payer. */
provider: string | null;
controller: string | null;
inspectionAudience: string[];
/** Alternatives for ONE existing purchase bonus, never one card per player. */
bonusRecipients: string[];
bonusStockAvailable: boolean | null;
occupied: boolean;
blocked: string | null;
};
export class OccupiedBiddingError extends HomeworldCustodyError {}
const sources: Record<OccupiedBiddingKind, {
card: HomeworldId;
faction: FactionId;
source: OccupiedBiddingAuthority['source'];
}> = {
atreidesInspection: { card: 'caladan', faction: 'atreides', source: 'auctionLot' },
ixAuction: { card: 'ix', faction: 'ixians', source: 'ixAuctionPool' },
richeseCache: { card: 'richese', faction: 'richese', source: 'richeseCache' },
harkonnenBonus: { card: 'giedi_prime', faction: 'harkonnen', source: 'treacheryStock' },
};
/** Authority for an ORIGINAL native Bidding effect only. Its producer still
* validates the invoice/lot/window and physical card custody. This pure quote
* neither initiates another inspection/draw nor confers captured Nexus powers.
* Sources: original Caladan, Ix, Richese and Giedi Prime occupied card faces;
* HOMEWORLD_OCCUPATION_RULES7–33,35–56 through the shared stable-source gate. */
export function quoteOccupiedBiddingAuthority(
game: Game,
kind: OccupiedBiddingKind,
): OccupiedBiddingAuthority {
const rule = sources[kind];
if (!rule) throw new OccupiedBiddingError('Unknown original Homeworld Bidding effect.');
const native = game.players.find(player => player.faction === rule.faction);
const bonusStockAvailable = kind === 'harkonnenBonus'
? game.deck.length > 0 || game.discard.length > 0 : null;
const quote: OccupiedBiddingAuthority = {
kind, source: rule.source, provider: native?.id ?? null, controller: null,
inspectionAudience: [], bonusRecipients: [], bonusStockAvailable,
occupied: false, blocked: null,
};
if (!native) return quote;
const occupation = quoteStableHomeworldOccupation(game, rule.card);
if (occupation.blocked) return { ...quote, blocked: occupation.blocked };
const entitlement = occupation.entitlement;
quote.occupied = entitlement !== null;
quote.controller = entitlement?.occupier ?? native.id;
switch (kind) {
case 'atreidesInspection':
// SHARES: native inspection survives. No borrowing by the occupier's ally.
quote.controller = native.id;
quote.inspectionAudience = entitlement ? [native.id, entitlement.occupier] : [native.id];
break;
case 'ixAuction':
// CONTROLS: the actual drawn pool/selection has one decision owner and
// audience, not an extra native peek. Setup/Technology are separate powers.
quote.inspectionAudience = [quote.controller];
break;
case 'richeseCache':
// CHOOSES: only this cache-card choice changes owner. The original cache
// remains native custody/knowledge; declaration, Black Market, unbid-card
// disposal, seller/payment and special-lot substitution do not transfer.
quote.inspectionAudience = entitlement ? [native.id, entitlement.occupier] : [native.id];
break;
case 'harkonnenBonus': {
// The printed condition is the RECEIVER's real hand space, not the
// native buyer's post-purchase space. The same draw uses deck/discard;
// cards in cache, a pending pool or other hands are not drawable stock.
const candidates = entitlement
? [entitlement.occupier, ...(entitlement.ally ? [entitlement.ally] : [])]
: [native.id];
if (bonusStockAvailable) quote.bonusRecipients = candidates.filter(id => {
const recipient = game.players.find(player => player.id === id);
return recipient !== undefined && recipient.hand.length < handLimit(recipient);
});
break;
}
}
return quote;
}
/** Validate the decision actor without replacing the original source provider. */
export function requireOccupiedBiddingController(
game: Game,
kind: OccupiedBiddingKind,
player: string,
): OccupiedBiddingAuthority {
const quote = quoteOccupiedBiddingAuthority(game, kind);
if (quote.blocked) throw new OccupiedBiddingError(quote.blocked);
if (kind === 'atreidesInspection'
? !quote.inspectionAudience.includes(player)
: quote.controller !== player)
throw new OccupiedBiddingError('This player does not control the original Bidding effect.');
return quote;
}
/** Requote at acceptance: alliances, genuine hand space and draw stock can
* change while the original purchase continuation is awaiting its recipient. */
export function requireOccupiedBiddingBonusRecipient(
game: Game,
recipient: string,
): OccupiedBiddingAuthority {
const quote = quoteOccupiedBiddingAuthority(game, 'harkonnenBonus');
if (quote.blocked) throw new OccupiedBiddingError(quote.blocked);
if (!quote.bonusRecipients.includes(recipient))
throw new OccupiedBiddingError('This player cannot receive the original purchase bonus.');
return quote;
}
+279
View File
@@ -0,0 +1,279 @@
import { HomeworldCustodyError } from './homeworld-custody';
import type { HomeworldId } from './homeworld-cards';
import { quoteGuildPaymentRounding, quoteHomeworldPaymentIncome } from './homeworld-payment-income';
import {
quoteStableHomeworldOccupation,
type StableHomeworldOccupationContext,
type StableHomeworldOccupier,
} from './homeworld-stable-occupation';
export type OccupiedPercentageKind =
| 'emperor-treachery' | 'guild-shipping' | 'richese-income' | 'fremen-collection';
export type OccupiedPercentageContribution = Readonly<{ payer: string; amount: number }>;
/** The original producer supplies an immutable receipt identifier and its full
* payment proof, not a new debit instruction. Guild own contributions remain in
* this proof but are excluded from the eligible amount. */
export type OccupiedPercentagePayment = Readonly<{
id: string;
binding: string;
paid: number;
contributions: readonly OccupiedPercentageContribution[];
recipient: string;
}>;
export type OccupiedPercentageSource = Readonly<{
event: string;
native: string;
amount: number;
}> & (
| Readonly<{
kind: 'emperor-treachery' | 'guild-shipping';
payment: OccupiedPercentagePayment;
nativeIncome: 'received' | 'karama-canceled';
}>
| Readonly<{
kind: 'richese-income';
payment: OccupiedPercentagePayment & Readonly<{ received: number }>;
}>
| Readonly<{
kind: 'fremen-collection';
collection: Readonly<{
id: string;
binding: string;
complete: true;
/** All actual native Collection credits, after original shared allocation.
* These are settled credit legs, not pile capacities or forecast awards. */
collected: readonly Readonly<{ id: string; amount: number }>[];
}>;
}>
);
export type OccupiedPercentageContext = StableHomeworldOccupationContext;
export type OccupiedPercentageCredit = Readonly<{ player: string; amount: number }>;
export type OccupiedPercentageReceipt = Readonly<{
source: OccupiedPercentageSource;
turn: number;
entitlement: Readonly<StableHomeworldOccupier> | null;
nativeLow: boolean;
nativeRetained: number;
occupiedAmount: number;
bankRetained: number;
status: 'pending' | 'settled' | 'blocked';
blocked: string | null;
ownAmount: number | null;
credits: readonly OccupiedPercentageCredit[];
}>;
/** One persisted ledger owns source admission and allocation. Applying cash and
* replacing this state must be a single engine transaction; a pure quote cannot
* prevent a caller from applying the same returned credits twice. */
export type OccupiedPercentageState = Readonly<{
version: 1;
receipts: readonly OccupiedPercentageReceipt[];
signature: string;
}>;
export type OccupiedPercentageQuote = {
state: OccupiedPercentageState;
receipt: OccupiedPercentageReceipt;
};
export type OccupiedPercentageAllocation = OccupiedPercentageQuote & {
credits: readonly OccupiedPercentageCredit[];
};
const cards: Record<OccupiedPercentageKind, HomeworldId> = {
'emperor-treachery': 'kaitain', 'guild-shipping': 'junction',
'richese-income': 'richese', 'fremen-collection': 'southern_hemisphere',
};
const factions: Record<OccupiedPercentageKind, string> = {
'emperor-treachery': 'emperor', 'guild-shipping': 'guild',
'richese-income': 'richese', 'fremen-collection': 'fremen',
};
const whole = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0;
const text = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0;
function requireReceipt(condition: unknown, message: string): asserts condition {
if (!condition) throw new HomeworldCustodyError(`Occupied percentage receipt: ${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 total(amounts: readonly number[]): number {
let sum = 0;
for (const amount of amounts) {
requireReceipt(whole(amount) && Number.isSafeInteger(sum + amount), 'invalid or overflowing actual source amount.');
sum += amount;
}
return sum;
}
/** Canonical copied provenance also gives property-order-independent JSON binding. */
function freezeSource(source: OccupiedPercentageSource, game: OccupiedPercentageContext): OccupiedPercentageSource {
requireReceipt(source && Object.hasOwn(cards, source.kind) && text(source.event) && text(source.native) && whole(source.amount),
'unknown source or invalid event, native or amount.');
requireReceipt(game.players.some(player => player.id === source.native && player.faction === factions[source.kind]),
'the original source native has the wrong faction.');
const base = { kind: source.kind, event: source.event, native: source.native, amount: source.amount };
if (source.kind === 'fremen-collection') {
requireReceipt(keys(source, ['kind', 'event', 'native', 'amount', 'collection']) &&
keys(source.collection, ['id', 'binding', 'complete', 'collected']) && text(source.collection.id) &&
text(source.collection.binding) && source.collection.complete === true && Array.isArray(source.collection.collected),
'Collection requires its completed original aggregate.');
const seen: Record<string, true> = Object.create(null);
const collected = source.collection.collected.map(leg => {
requireReceipt(keys(leg, ['id', 'amount']) && text(leg.id) && whole(leg.amount) && !Object.hasOwn(seen, leg.id),
'duplicate or invalid actual Collection leg.');
seen[leg.id] = true;
return Object.freeze({ id: leg.id, amount: leg.amount });
});
requireReceipt(total(collected.map(leg => leg.amount)) === source.amount, 'Collection total lost actual native credits.');
return Object.freeze({ ...base, kind: source.kind, collection: Object.freeze({
id: source.collection.id, binding: source.collection.binding, complete: true as const, collected: Object.freeze(collected),
}) });
}
const richese = source.kind === 'richese-income';
requireReceipt(keys(source, richese ? ['kind', 'event', 'native', 'amount', 'payment'] :
['kind', 'event', 'native', 'amount', 'payment', 'nativeIncome']) &&
keys(source.payment, richese ? ['id', 'binding', 'paid', 'contributions', 'recipient', 'received'] :
['id', 'binding', 'paid', 'contributions', 'recipient']) && text(source.payment.id) && text(source.payment.binding) &&
whole(source.payment.paid) && source.payment.recipient === source.native && Array.isArray(source.payment.contributions),
'payment lost its original receipt, actual native recipient or paid provenance.');
const seen: Record<string, true> = Object.create(null);
const contributions = source.payment.contributions.map(leg => {
requireReceipt(keys(leg, ['payer', 'amount']) && text(leg.payer) && whole(leg.amount) &&
game.players.some(player => player.id === leg.payer) && !Object.hasOwn(seen, leg.payer),
'duplicate or invalid original payer contribution.');
seen[leg.payer] = true;
return Object.freeze({ payer: leg.payer, amount: leg.amount });
});
requireReceipt(total(contributions.map(leg => leg.amount)) === source.payment.paid,
'original payer contributions do not conserve the paid amount.');
const payment = { id: source.payment.id, binding: source.payment.binding, paid: source.payment.paid,
contributions: Object.freeze(contributions), recipient: source.payment.recipient };
if (source.kind === 'richese-income') {
requireReceipt(whole(source.payment.received) && source.payment.received <= source.payment.paid &&
source.amount === source.payment.received, 'Richese percentage requires actual net received income, not the gross sale.');
return Object.freeze({ ...base, kind: source.kind, payment: Object.freeze({ ...payment, received: source.payment.received }) });
}
requireReceipt(source.nativeIncome === 'received' || source.nativeIncome === 'karama-canceled', 'invalid native income outcome.');
const eligible = contributions.filter(leg => source.kind !== 'guild-shipping' || leg.payer !== source.native);
requireReceipt(total(eligible.map(leg => leg.amount)) === source.amount, 'eligible receipt includes excluded or unpaid spice.');
return Object.freeze({ ...base, kind: source.kind, payment: Object.freeze(payment), nativeIncome: source.nativeIncome });
}
function amounts(source: OccupiedPercentageSource, nativeLow: boolean, occupied: boolean) {
const occupiedAmount = occupied ? Math.floor(source.amount / 2) : 0;
const payment = source.kind === 'emperor-treachery' || source.kind === 'guild-shipping';
const nativeRetained = payment
? source.nativeIncome === 'karama-canceled' ? 0 : occupied || nativeLow ? Math.ceil(source.amount / 2) : source.amount
: source.amount - occupiedAmount;
return { nativeRetained, occupiedAmount, bankRetained: source.amount - nativeRetained - occupiedAmount };
}
function allocation(entitlement: Readonly<StableHomeworldOccupier> | null, amount: number, ownAmount: number): readonly OccupiedPercentageCredit[] {
const credits: OccupiedPercentageCredit[] = [];
if (entitlement && ownAmount > 0) credits.push(Object.freeze({ player: entitlement.occupier, amount: ownAmount }));
if (entitlement?.ally && ownAmount < amount) credits.push(Object.freeze({ player: entitlement.ally, amount: amount - ownAmount }));
return Object.freeze(credits);
}
function stateWith(receipts: readonly OccupiedPercentageReceipt[]): OccupiedPercentageState {
return Object.freeze({ version: 1, receipts: Object.freeze(receipts), signature: JSON.stringify([1, receipts]) });
}
export function createOccupiedPercentageState(): OccupiedPercentageState { return stateWith([]); }
/** Validate historical receipts without replacing their frozen source or owner
* with a current army. Static BANK icon amounts do not constrain these awards. */
export function validateOccupiedPercentageState(state: OccupiedPercentageState, game: OccupiedPercentageContext): void {
requireReceipt(keys(state, ['version', 'receipts', 'signature']) && state.version === 1 && Array.isArray(state.receipts) &&
typeof state.signature === 'string', 'invalid persisted ledger.');
for (let i = 0; i < state.receipts.length; i++) {
const row = state.receipts[i];
requireReceipt(keys(row, ['source', 'turn', 'entitlement', 'nativeLow', 'nativeRetained', 'occupiedAmount', 'bankRetained',
'status', 'blocked', 'ownAmount', 'credits']) && whole(row.turn) && row.turn > 0 && row.turn <= game.turn &&
typeof row.nativeLow === 'boolean' && Array.isArray(row.credits), 'invalid saved receipt.');
const source = freezeSource(row.source, game), entitlement = row.entitlement;
if (entitlement !== null) {
requireReceipt(keys(entitlement, ['world', 'card', 'native', 'occupier', 'ally', 'qualification', 'turn', 'spice']) &&
entitlement.card === cards[source.kind] && entitlement.native === source.native && entitlement.turn === row.turn &&
entitlement.world === `homeworld:${factions[source.kind]}` && text(entitlement.qualification) && whole(entitlement.spice) &&
game.players.some(player => player.id === entitlement.occupier && player.id !== source.native) &&
(entitlement.ally === null || game.players.some(player => player.id === entitlement.ally && player.id !== entitlement.occupier)),
'invalid frozen original entitlement.');
}
requireReceipt(row.blocked === null || text(row.blocked), 'invalid source blocker.');
const expected = amounts(source, row.nativeLow, entitlement !== null || row.blocked !== null);
requireReceipt(row.nativeRetained === expected.nativeRetained && row.occupiedAmount === expected.occupiedAmount &&
row.bankRetained === expected.bankRetained && row.bankRetained >= 0, 'saved cash legs changed the actual receipt.');
if (row.status === 'settled') {
requireReceipt(row.blocked === null && whole(row.ownAmount) && row.ownAmount <= row.occupiedAmount &&
(entitlement?.ally || row.ownAmount === row.occupiedAmount) &&
(row.occupiedAmount === 0 || entitlement !== null) &&
JSON.stringify(row.credits) === JSON.stringify(allocation(entitlement, row.occupiedAmount, row.ownAmount)),
'settled allocation does not conserve the occupied portion.');
} else {
requireReceipt(row.occupiedAmount > 0 && row.ownAmount === null && row.credits.length === 0 &&
(row.status === 'pending' && entitlement !== null && row.blocked === null ||
row.status === 'blocked' && entitlement === null && row.blocked !== null), 'invalid pending or blocked receipt.');
}
for (let j = 0; j < i; j++) requireReceipt(!sameSource(state.receipts[j], source, row.turn), 'duplicate original source/event.');
}
requireReceipt(state.signature === JSON.stringify([1, state.receipts]), 'saved receipt ledger changed.');
}
function sameSource(row: OccupiedPercentageReceipt, source: OccupiedPercentageSource, turn: number): boolean {
const originalId = row.source.kind === 'fremen-collection' ? row.source.collection.id : row.source.payment.id;
const candidateId = source.kind === 'fremen-collection' ? source.collection.id : source.payment.id;
return row.source.event === source.event || row.source.kind === source.kind && row.source.native === source.native &&
(originalId === candidateId || source.kind === 'fremen-collection' && row.turn === turn);
}
/** Quote already-paid/collected spice only. Native Karama removes native income,
* not the printed occupied effect. Richese sale-origin classification and other
* economic priorities stay in the original producer. Southern Hemisphere admits
* one completed total per native/turn, never independently rounded piles.
* Sources: original occupied card faces; HOMEWORLD_RULES48–62,98–100. */
export function quoteOccupiedPercentageSource(
game: OccupiedPercentageContext, original: OccupiedPercentageSource, state: OccupiedPercentageState,
): OccupiedPercentageQuote {
validateOccupiedPercentageState(state, game);
requireReceipt(whole(game.turn) && game.turn > 0, 'invalid source turn.');
const source = freezeSource(original, game);
requireReceipt(!state.receipts.some(row => sameSource(row, source, game.turn)), 'source/event already admitted or settled.');
const stable = quoteStableHomeworldOccupation(game, cards[source.kind]);
const payment = source.kind === 'emperor-treachery' || source.kind === 'guild-shipping'
? quoteHomeworldPaymentIncome(game, source.native, source.kind === 'guild-shipping' ? 'shipment' : 'treachery', source.amount)
: null;
// Keep the existing two-odd-contributor ruling gate. Exclusion happens before
// comparing rounding; no new transaction-versus-contribution policy is invented.
if (source.kind === 'guild-shipping' && (payment?.low || stable.entitlement || stable.blocked)) {
const eligible = source.payment.contributions.filter(leg => leg.payer !== source.native && leg.amount > 0).map(leg => leg.amount);
requireReceipt(quoteGuildPaymentRounding(source.amount, eligible).unambiguous,
'multiple-contributor shipping rounding awaits its original ruling.');
}
const candidate = Math.floor(source.amount / 2);
const blocked = candidate > 0 ? stable.blocked : null;
const entitlement = stable.entitlement ? Object.freeze({ ...stable.entitlement }) : null;
const split = amounts(source, payment?.low ?? false, entitlement !== null || blocked !== null);
const status = blocked ? 'blocked' : split.occupiedAmount > 0 ? 'pending' : 'settled';
const receipt: OccupiedPercentageReceipt = Object.freeze({ source, turn: game.turn, entitlement,
nativeLow: payment?.low ?? false, ...split, status, blocked,
ownAmount: status === 'settled' ? 0 : null, credits: Object.freeze([]) });
return { receipt, state: stateWith([...state.receipts, receipt]) };
}
/** Return occupied-only credits. Native/bank amounts are final SOURCE legs, not
* additional wallet awards. The engine preserves the original payer debit once,
* reconciles any already-credited native cash (Collection must not credit the
* same total again), and commits actual credits plus the ledger atomically.
* The live original producer proof must be supplied again; frozen amount,
* recipient, source and ally cannot silently follow a changed lot. */
export function allocateOccupiedPercentageReceipt(
state: OccupiedPercentageState, game: OccupiedPercentageContext, actor: string,
event: string, ownAmount: number, originalSource: OccupiedPercentageSource,
): OccupiedPercentageAllocation {
validateOccupiedPercentageState(state, game);
const index = state.receipts.findIndex(row => row.source.event === event), row = state.receipts[index];
requireReceipt(row && row.status === 'pending' && row.turn === game.turn && row.entitlement?.occupier === actor,
'wrong owner, stale event/turn, blocked or replayed receipt.');
requireReceipt(JSON.stringify(freezeSource(originalSource, game)) === JSON.stringify(row.source), 'original source changed before allocation.');
const current = quoteStableHomeworldOccupation(game, row.entitlement.card);
requireReceipt(!current.blocked && current.entitlement && JSON.stringify(current.entitlement) === JSON.stringify(row.entitlement),
'original continuous entitlement or reciprocal ally changed.');
requireReceipt(whole(ownAmount) && ownAmount <= row.occupiedAmount &&
(row.entitlement.ally !== null || ownAmount === row.occupiedAmount), 'invalid occupied income split.');
const credits = allocation(row.entitlement, row.occupiedAmount, ownAmount);
const receipt: OccupiedPercentageReceipt = Object.freeze({ ...row, status: 'settled', ownAmount, credits });
return { receipt, credits, state: stateWith(state.receipts.map((prior, i) => i === index ? receipt : prior)) };
}
+29 -5
View File
@@ -22,11 +22,10 @@ export type StableHomeworldOccupationQuote = {
};
type SourceWorld = [string, [string, number, number][]];
/** Printed qualification is not a current-controller shortcut. This fresh
* profile admits only one continuously present qualifier, with no competing
* foreign garrison or restored native high threshold since qualification.
* Departure/turnover/contested/repopulation questions remain unanswered.
* Sources: HOMEWORLD_OCCUPATION_RULES7–33,35–56; original occupied card faces. */
/** Original source history supplies entitlement, never a current-controller
* shortcut. Advanced uses the authorized supplied rulebook p.22: qualification
* requires sole foreign presence and survives until that occupier leaves.
* Basic retains the publisher-only pending lifecycle guards below. */
export function quoteStableHomeworldOccupation(
game: StableHomeworldOccupationContext,
cardId: HomeworldId,
@@ -45,6 +44,31 @@ export function quoteStableHomeworldOccupation(
const world = homeworldForceGroups(context, custody).find(home => home.native === native.id &&
home.secondary === (cardId === 'salusa_secundus'));
if (!world) throw new HomeworldCustodyError('The occupied card lost its original native Homeworld.');
if (game.advanced) {
let retained: { player: string; event: string } | null = null;
for (const source of history.sources) {
const snapshot = JSON.parse(history.snapshots[source.snapshot]) as SourceWorld[];
const groups = snapshot.find(row => row[0] === world.id)![1]
.filter(([, normal, elite]) => normal + elite > 0);
if (retained && !groups.some(([id]) => id === retained!.player)) retained = null;
if (!retained && source.cause !== 'setup' && groups.length === 1 && groups[0][0] !== native.id)
retained = { player: groups[0][0], event: source.event };
}
const present = Object.entries(world.forces).filter(([, forces]) => forces.normal + forces.elite > 0);
if (retained && !present.some(([id]) => id === retained!.player)) retained = null;
if (!retained) {
// A producer may query between a physical write and its semantic
// observation; do not invent a new qualifying event from that write.
return present.length === 1 && present[0][0] !== native.id
? { entitlement: null, blocked: 'The original Advanced sole-occupation change must be observed before its benefit settles.' }
: none;
}
const occupier = game.players.find(player => player.id === retained!.player)!;
const ally = occupier.ally && game.players.some(player => player.id === occupier.ally && player.ally === occupier.id)
? occupier.ally : null;
return { entitlement: { world: world.id, card: cardId, native: native.id, occupier: occupier.id,
ally, qualification: retained.event, turn: game.turn, spice: card.occupied.spiceIcons }, blocked: null };
}
const first = history.qualifications.find(f => f.world === world.id);
if (!first) return none;
const blocked = (detail: string): StableHomeworldOccupationQuote => ({ entitlement: null,
+25 -7
View File
@@ -3846,9 +3846,10 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'Homeworld shipment, battle and occupation have special rules. Occupiers receive the printed Collection income and card effects. An alliance can gain a special Homeworld victory through high-population Ecaz; this is separate from ordinary stronghold victory.',
'The fresh local homeworld-occupation profile now connects continuously proved, unambiguous occupation with original selected decks and no other modules. Qualification history must prove one still-present occupier without departure, competing foreigners or restored native high population; a current garrison alone grants nothing. Ambiguous histories retain the source guards, not a new expiry or replacement policy.',
'In that profile, printed occupied bank income is collected once and may immediately be allocated to a reciprocal ally through the original Collection choice. Actual credit receipts survive refresh; views and replay cannot pay again. Actual shared Homeworld credits feed the existing once-per-phase Giedi bonus. Percentage receipts remain a separate unfinished source.',
'The fresh local homeworld-occupation profile uses original selected decks and no other modules. Advanced occupation begins with sole foreign presence and its benefits remain until the occupier’s last force leaves, including across contests, native population recovery and later turns. A later sole arrival after departure creates a new source epoch. Basic still guards unresolved lifecycle cases; original history, not a current garrison shortcut, proves entitlement.',
'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 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:
@@ -3866,7 +3867,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 stable occupation additionally connects printed bank Collection/immediate ally allocation, actual Giedi Homeworld sources and Wallach/Tleilax/Grumman/Salusa protections. Departure/replacement/contested/repopulation, percentage receipts, other occupied powers and concealed transport remain unfinished; public starts stay disabled.',
'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.',
evidence: [
'game/homeworld-cards.ts',
'game/homeworld-game.ts',
@@ -3893,6 +3894,11 @@ export const RULE_TOPICS: RuleTopic[] = [
'game/homeworld-payment-income.ts',
'game/homeworld-collection.ts',
'game/homeworld-occupation-history.ts',
'game/homeworld-stable-occupation.ts',
'game/homeworld-occupied-income.ts',
'game/homeworld-occupied-percentage.ts',
'game/homeworld-occupied-bidding.ts',
'game/homeworld-occupied-defenses.ts',
'game/tupile-intelligence.ts',
'game/tupile-intelligence-answer.ts',
'game/tupile-intelligence-state.ts',
@@ -3909,7 +3915,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Player controls',
status: 'Partial',
detail:
'Card inspection is available in the reference. Development tables display native reserves, explicit Imperial shipment sources, world-to-world shipment and Guild transport from Arrakis with costs and pledged funding, Guild interception, optional Junction half/full offers and typed sponsored transport, transfers between Kaitain and Salusa, charity payment sources, current revival allowances, population-based movement restrictions and one/two-advisor choices, native battle bonuses, Homeworld casualties, replacement controls, Tupile sale explanations, shared end-of-Bidding actions, Ecaz’s private poison-income record, current low-population payment splits, automatic Giedi collection history, count-dependent Ambassador destinations and the optional revived-group placement; starting this module remains unavailable.',
'Card inspection is available in the reference. Development tables display native reserves, explicit Imperial shipment sources, world-to-world shipment and Guild transport from Arrakis with costs and pledged funding, Guild interception, optional Junction half/full offers and typed sponsored transport, transfers between Kaitain and Salusa, charity payment sources, current revival allowances, population-based movement restrictions and one/two-advisor choices, native battle bonuses, Homeworld casualties, replacement controls, Tupile sale explanations, shared end-of-Bidding actions, Ecaz’s private poison-income record, current low-population payment splits, automatic Giedi collection history, count-dependent Ambassador destinations and the optional revived-group placement. Fresh occupation adds bounded actual percentage allocation, real bonus receiver choice, source-authorized Ix pool/cache controls and separate native Richese terms. Normal starts remain unavailable.',
evidence: [
'components/homeworld-cards.tsx',
'components/homeworld-table.tsx',
@@ -3920,20 +3926,25 @@ export const RULE_TOPICS: RuleTopic[] = [
'components/junction-transport.tsx',
'components/bidding-end.tsx',
'components/ecaz-poison-income.tsx',
'components/homeworld-occupied-income.tsx',
'components/homeworld-occupied-percentage.tsx',
'components/homeworld-occupied-bonus.tsx',
'components/ix-technology.tsx',
'components/richese-auctions.tsx',
],
},
{
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 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.',
'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. Fresh occupied decisions supply legal bank/percentage allocation, one eligible real bonus receiver, displaced Ix/cache selection and native Richese terms. Concealed transport, remaining card-effect strategy and difficulty calibration are unfinished.',
evidence: ['game/homeworld-options.ts', 'game/homeworld-shipment-options.ts', 'game/guild-homeworld-shipment-options.ts', 'game/junction-transport-options.ts'],
},
{
area: 'Documentation',
status: 'Partial',
detail:
'All 26 faces, global rules and source discrepancies are documented. Imperial shipment and movement controls explain sources, costs and unavailable actions. Card-economy notes explain shared closing actions, private poison income, winner-discard ordering and low-population payment splits. Divergent allied rounding and occupation entitlement remain unresolved; full interaction guidance remains in progress.',
'All 26 faces, global rules and source discrepancies are documented. Imperial shipment and movement controls explain sources, costs and unavailable actions. Card-economy notes explain shared closing actions, private poison income, winner-discard ordering and low-population payment splits. The authorized Advanced sole/last-departure cutover and original occupied economic/Bidding ownership are explicit. Divergent allied rounding, Basic occupation lifecycle and remaining effects stay separate; full interaction guidance remains in progress.',
evidence: [
'docs/HOMEWORLD_COMPONENT_AUDIT.md',
'docs/HOMEWORLD_RULES.md',
@@ -3964,7 +3975,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification',
status: 'Partial',
detail:
'Tests cover genuine setup with implemented decks, Basic special-counter identity, actual transfer/shipment/revival, saved-state rejection and competing database submissions. Complete Homeworld games and module combinations are not yet verified.',
'Original setup, typed transfers, native payments, occupied percentages/Bidding/private-lot binding and preserved BG accompaniment have consumer regressions and actual390px controls. The bounded profile completes44/50 genuine samples; four Basic occupation/two CHOAM ruling captures remain. Five original Advanced captures resume at their original seeds, and five fresh six-seat Advanced games finish after the advisor repair. Full Homeworld rules, all combinations, final assurance and deployed acceptance remain unverified.',
evidence: [
'tests/homeworld-custody.test.ts',
'tests/homeworld-cards.test.ts',
@@ -4030,6 +4041,13 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/tupile-intelligence-engine.test.ts',
'tests/tupile-intelligence-recovery.test.ts',
'tests/homeworld-occupation-history.test.ts',
'tests/homeworld-occupied-income.test.ts',
'tests/homeworld-occupied-percentage.test.ts',
'tests/homeworld-occupied-bidding.test.ts',
'tests/homeworld-occupied-defenses.test.ts',
'tests/homeworld-occupation-runtime.test.ts',
'tests/homeworld-occupied-producers-engine.test.ts',
'tests/homeworld-occupied-private-lots.test.ts',
'tests/grumman-collection.test.ts',
'tests/grumman-collection-engine.test.ts',
'tests/grumman-collection-controls.test.ts',
+3
View File
@@ -6,6 +6,8 @@ import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
const DECISIONS = {
ecazBattleLead: true,
homeworldOccupiedIncome: true,
homeworldOccupiedPercentage: true,
homeworldOccupiedBonus: true,
leaderSkillVisibility: true,
leaderSkillRevival: true,
moritaniAssassinate: true,
@@ -23,6 +25,7 @@ const DECISIONS = {
richeseBlackMarket: true,
richeseDeclaration: true,
richeseCache: true,
richeseCacheTerms: true,
richeseUnbid: true,
richeseAllyShipment: true,
richeseAllyOpportunity: true,