Files
Dune/game/discovery-collection.ts
T

301 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { isAdvisor } from './advisors';
import { gameTerritories, splitLocation, type MobileBoard } from './board';
import type { AdvisorRelease } from './board-resolution-quote';
import type { Player } from './engine';
import { presenceAt, type ForcePresence } from './force-presence';
export const CISTERN = 'cistern';
export const ORGIZ_PROCESSING_STATION = 'orgiz-processing-station';
export class DiscoveryCollectionError extends Error {
constructor(message: string) {
super(message);
this.name = 'DiscoveryCollectionError';
}
}
type DiscoveryCollectionSeat = Pick<Player, 'id' | 'faction' | 'advisors'> &
ForcePresence;
export type DiscoveryCollectionContext = MobileBoard & {
advanced: boolean;
order: readonly string[];
players: readonly DiscoveryCollectionSeat[];
/** Deposits before ordinary collection. */
spice: Readonly<Record<string, number>>;
};
export type DiscoveryBaseCollectionReceipt = {
player: string;
strongholds: number;
collected: number;
desert: number;
balance: number;
};
export type DiscoveryBaseCollectionQuote = {
/** Advisor stance changes already established by the ordinary quote. */
released: readonly AdvisorRelease[];
/** Deposits remaining after ordinary and shared collection. */
spice: Readonly<Record<string, number>>;
receipts: readonly DiscoveryBaseCollectionReceipt[];
shared: readonly {
territory: string;
ecaz: string;
ally: string;
amount: number;
}[];
};
export type CisternCollectionReceipt = {
kind: 'cistern';
player: string;
amount: 2;
source: 'bank';
};
export type OrgizCollectionReceipt = {
kind: 'orgiz';
player: string;
from: string;
location: string;
amount: 1;
source: 'player';
};
export type DiscoveryCollectionQuote = {
/** Drop-in replacement for the ordinary collection receipts. */
receipts: DiscoveryBaseCollectionReceipt[];
effects: (CisternCollectionReceipt | OrgizCollectionReceipt)[];
};
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
const whole = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
function requireCollection(
condition: unknown,
message: string,
): asserts condition {
if (!condition) throw new DiscoveryCollectionError(message);
}
function isCollectingFighter(
player: DiscoveryCollectionSeat,
territory: string,
released: readonly AdvisorRelease[],
): boolean {
return !isAdvisor(player, territory) ||
released.some((entry) => entry.player === player.id && entry.territory === territory);
}
function occupants(
context: DiscoveryCollectionContext,
location: string,
released: readonly AdvisorRelease[],
): DiscoveryCollectionSeat[] {
if (!gameTerritories(context).some((territory) => territory.id === location))
return [];
return context.players.filter(
(player) => isCollectingFighter(player, location, released) &&
presenceAt(player, location) > 0,
);
}
/** Public effective presence only; an ambiguous source cannot identify a payer. */
function collectorAt(
context: DiscoveryCollectionContext,
key: string,
released: readonly AdvisorRelease[],
): DiscoveryCollectionSeat | undefined {
const { territory, sector } = splitLocation(key);
let collector: DiscoveryCollectionSeat | undefined;
for (const player of context.players) {
if (!isCollectingFighter(player, territory, released)) continue;
const marker = player.noField?.deployed?.location;
if (
(player.forces[key] ?? 0) <= 0 &&
!(marker?.territory === territory && marker.sector === sector)
) continue;
if (collector) return undefined;
collector = player;
}
return collector;
}
function transferOrgiz(
key: string,
collector: DiscoveryCollectionSeat | undefined,
ownerReceipt: DiscoveryBaseCollectionReceipt,
receipts: DiscoveryBaseCollectionReceipt[],
effects: DiscoveryCollectionQuote['effects'],
) {
if (!collector || collector.id === ownerReceipt.player) return;
const collectorReceipt = receipts.find(entry => entry.player === collector.id)!;
requireCollection(
collectorReceipt.balance >= 1 &&
Number.isSafeInteger(ownerReceipt.balance + 1),
'Orgiz theft would invalidate a player spice balance.',
);
collectorReceipt.balance -= 1;
ownerReceipt.balance += 1;
effects.push({
kind: 'orgiz', player: ownerReceipt.player, from: collector.id,
location: key, amount: 1, source: 'player',
});
}
/**
* Compose sole-occupant Cistern income and a bounded Orgiz transfer over the
* canonical ordinary collection quote. The ordinary `collected` and `desert`
* fields remain factual: Cistern is bank income; Orgiz transfers player spice.
*
* Advanced uses the authorized revised rulebook's territory Collection event;
* Basic retains the provisional one-positive-deposit interpretation. Contested
* sources, different territory payers and shared lots withhold only their
* uncertain effect; they never block ordinary collection.
*/
export function quoteDiscoveryCollection(
context: DiscoveryCollectionContext,
collection: DiscoveryBaseCollectionQuote,
): DiscoveryCollectionQuote {
requireCollection(
record(context) &&
typeof context.advanced === 'boolean' &&
Array.isArray(context.players) &&
context.players.length > 0 &&
Array.isArray(context.order) &&
context.order.length === context.players.length &&
new Set(context.order).size === context.order.length &&
context.players.every(
(player) =>
record(player) &&
typeof player.id === 'string' &&
player.id.length > 0 &&
record(player.forces) &&
context.order.includes(player.id),
) &&
new Set(context.players.map((player) => player.id)).size ===
context.players.length &&
record(context.spice) &&
record(collection) &&
record(collection.spice) &&
Array.isArray(collection.receipts) &&
Array.isArray(collection.shared) &&
Array.isArray(collection.released) &&
collection.released.every(
(entry) => record(entry) &&
typeof entry.player === 'string' &&
context.order.includes(entry.player) &&
typeof entry.territory === 'string',
),
'Discovery collection needs the canonical public collection context and quote.',
);
const sourceKeys = Object.keys(context.spice).sort();
const remainingKeys = Object.keys(collection.spice).sort();
requireCollection(
sourceKeys.every((key) => Object.hasOwn(collection.spice, key)) &&
remainingKeys.every(
(key) =>
Object.hasOwn(context.spice, key) || collection.spice[key] === 0,
) &&
sourceKeys.every(
(key) =>
whole(context.spice[key]) &&
whole(collection.spice[key]) &&
collection.spice[key] <= context.spice[key],
),
'Discovery collection needs the ordinary quote’s conserved board deposits.',
);
const receipts = collection.receipts.map((receipt) => {
requireCollection(
receipt !== null &&
typeof receipt === 'object' &&
!Array.isArray(receipt) &&
typeof receipt.player === 'string' &&
context.order.includes(receipt.player) &&
whole(receipt.strongholds) &&
whole(receipt.collected) &&
whole(receipt.desert) &&
receipt.desert <= receipt.collected &&
whole(receipt.balance),
'Discovery collection needs one valid ordinary receipt per player.',
);
return { ...receipt };
});
requireCollection(
receipts.length === context.order.length &&
new Set(receipts.map((receipt) => receipt.player)).size ===
context.order.length &&
context.order.every((id) =>
receipts.some((receipt) => receipt.player === id),
),
'Discovery collection receipts must match the complete player order.',
);
requireCollection(
collection.shared.every(
(lot) =>
lot !== null &&
typeof lot === 'object' &&
!Array.isArray(lot) &&
typeof lot.territory === 'string' &&
typeof lot.ecaz === 'string' &&
typeof lot.ally === 'string' &&
lot.ecaz !== lot.ally &&
whole(lot.amount),
),
'Discovery collection needs valid retained shared-spice lots.',
);
const effects: DiscoveryCollectionQuote['effects'] = [];
const cistern = occupants(context, CISTERN, collection.released);
if (cistern.length === 1) {
const receipt = receipts.find((entry) => entry.player === cistern[0].id)!;
requireCollection(
Number.isSafeInteger(receipt.balance + 2),
'Cistern bank income would overflow the spice balance.',
);
receipt.balance += 2;
effects.push({
kind: 'cistern',
player: receipt.player,
amount: 2,
source: 'bank',
});
}
const orgiz = occupants(context, ORGIZ_PROCESSING_STATION, collection.released);
if (orgiz.length === 1) {
const owner = orgiz[0];
const ownerReceipt = receipts.find((entry) => entry.player === owner.id)!;
const transfers = context.advanced
? new Map<string, { location: string; collector: DiscoveryCollectionSeat | undefined }>()
: undefined;
for (const key of sourceKeys) {
if (context.spice[key] <= collection.spice[key]) continue;
const { territory } = splitLocation(key);
if (
collection.shared.some(
(lot) => lot.amount > 0 && lot.territory === territory,
)
) continue;
const collector = collectorAt(context, key, collection.released);
if (!transfers) {
transferOrgiz(key, collector, ownerReceipt, receipts, effects);
continue;
}
const pending = transfers.get(territory);
if (!pending) transfers.set(territory, { location: key, collector });
else if (pending.collector?.id !== collector?.id)
pending.collector = undefined;
}
if (transfers)
for (const pending of transfers.values())
transferOrgiz(pending.location, pending.collector, ownerReceipt, receipts, effects);
}
return { receipts, effects };
}