301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
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 };
|
||
}
|