Files
Dune/game/discovery-collection.ts
T

275 lines
8.9 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 & {
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,
);
}
/**
* 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.
*
* The publisher does not specify whether a stacked physical pile is one or
* several blows. One positive collected board deposit is treated as one
* observable blow. Contested location benefits and unresolved shared lots
* withhold only their uncertain effect; they never block ordinary collection.
*/
export function quoteDiscoveryCollection(
context: DiscoveryCollectionContext,
collection: DiscoveryBaseCollectionQuote,
): DiscoveryCollectionQuote {
requireCollection(
record(context) &&
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)!;
for (const key of sourceKeys) {
if (context.spice[key] <= collection.spice[key]) continue;
const { territory, sector } = splitLocation(key);
if (
collection.shared.some(
(lot) => lot.amount > 0 && lot.territory === territory,
)
) continue;
let collector: DiscoveryCollectionSeat | undefined;
let contested = false;
for (const player of context.players) {
if (!isCollectingFighter(player, territory, collection.released)) continue;
const marker = player.noField?.deployed?.location;
if (
(player.forces[key] ?? 0) <= 0 &&
!(marker?.territory === territory && marker.sector === sector)
) continue;
if (collector) {
contested = true;
break;
}
collector = player;
}
if (contested || !collector || collector.id === owner.id) continue;
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: owner.id,
from: collector.id,
location: key,
amount: 1,
source: 'player',
});
}
}
return { receipts, effects };
}