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

+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;
}