Files
Dune/game/discovery-collection.ts
T

352 lines
13 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 type { SpiceAllocationReceipt } from './ecaz-spice-allocation';
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);
}
export function discoveryCollectionOccupants(
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,
).sort((left,right) => context.order.indexOf(left.id)-context.order.indexOf(right.id));
}
/** 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 false;
const collectorReceipt = receipts.find(entry => entry.player === collector.id)!;
if (collectorReceipt.balance === 0) return false;
requireCollection(
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',
});
return true;
}
/**
* Compose each eligible occupant's Cistern income and original Orgiz transfers
* over the canonical ordinary quote. Ordinary `collected` and `desert` remain
* factual: Cistern is bank income; Orgiz transfers available player spice.
*
* Owners resolve in original storm order. Advanced chooses one actual foreign
* collector per territory in storm order; Basic retains the provisional
* positive-deposit trigger. Ambiguous collected sectors withhold the territory
* effect. Shared lots settle after bilateral allocation, not in this quote.
*/
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 = discoveryCollectionOccupants(context, CISTERN, collection.released);
for (const owner of cistern) {
const receipt = receipts.find((entry) => entry.player === owner.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 = discoveryCollectionOccupants(context, ORGIZ_PROCESSING_STATION, collection.released);
if (orgiz.length) {
const ownerReceipts = orgiz.map(owner => receipts.find(entry => entry.player === owner.id)!);
const transfers = context.advanced
? new Map<string, {
collectors: { location: string; collector: DiscoveryCollectionSeat }[];
ambiguous: boolean;
}>()
: 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) {
for (const ownerReceipt of ownerReceipts)
transferOrgiz(key, collector, ownerReceipt, receipts, effects);
continue;
}
let pending = transfers.get(territory);
if (!pending) {
pending = { collectors: [], ambiguous: false };
transfers.set(territory, pending);
}
if (!collector) pending.ambiguous = true;
else if (!pending.collectors.some(entry => entry.collector.id === collector.id))
pending.collectors.push({ location: key, collector });
}
if (transfers)
for (const pending of transfers.values()) {
if (pending.ambiguous) continue;
pending.collectors.sort((left, right) =>
context.order.indexOf(left.collector.id) - context.order.indexOf(right.collector.id));
for (const ownerReceipt of ownerReceipts)
for (const candidate of pending.collectors)
if (transferOrgiz(candidate.location, candidate.collector, ownerReceipt, receipts, effects))
break;
}
}
return { receipts, effects };
}
const SHARED_COLLECTION_RELEASES: readonly AdvisorRelease[] = [];
/** Advanced's one-territory event waits for the original bilateral allocation.
* Charge the first actual positive foreign recipient (Ecaz, then ally), never
* a guessed share, the owner itself, a city award or unavailable spice. */
export function quoteAdvancedSharedOrgiz(
context: DiscoveryCollectionContext,
allocation: SpiceAllocationReceipt,
balances: Readonly<Record<string,number>>,
): OrgizCollectionReceipt[] {
requireCollection(context.advanced && allocation.ecaz !== allocation.ally &&
context.order.includes(allocation.ecaz) && context.order.includes(allocation.ally) &&
whole(allocation.ecazAmount) && whole(allocation.allyAmount) &&
record(balances) && context.order.every(id => whole(balances[id])),
'Shared Orgiz needs the actual Advanced territory allocation and available balances.');
const owners = discoveryCollectionOccupants(context,ORGIZ_PROCESSING_STATION,SHARED_COLLECTION_RELEASES);
const effects: OrgizCollectionReceipt[] = [];
let ecazDebited=0, allyDebited=0;
for (const owner of owners) {
let payer: string;
if (owner.id !== allocation.ecaz && allocation.ecazAmount > 0 &&
balances[allocation.ecaz] > ecazDebited) {
payer=allocation.ecaz; ecazDebited++;
} else if (owner.id !== allocation.ally && allocation.allyAmount > 0 &&
balances[allocation.ally] > allyDebited) {
payer=allocation.ally; allyDebited++;
} else continue;
requireCollection(Number.isSafeInteger(balances[owner.id]+1),
'Shared Orgiz income would overflow the spice balance.');
effects.push({kind:'orgiz',player:owner.id,from:payer,location:allocation.territory,
amount:1,source:'player'});
}
return effects;
}