Files
Dune/game/ecaz-spice-allocation.ts
T
2026-09-08 07:37:52 +10:00

188 lines
6.1 KiB
TypeScript

import { TERRITORIES } from './board';
export type SharedSpiceLot = {
territory: string;
ecaz: string;
ally: string;
amount: number;
};
export type AllocationState = {
lots: SharedSpiceLot[];
index: number;
offer: { by: string; ecazShare: number } | null;
player: string;
};
export type SpiceAllocationAction =
| { kind: 'propose'; ecazShare: number }
| { kind: 'accept' }
| { kind: 'equal' };
export type SpiceAllocationReceipt = {
territory: string;
ecaz: string;
ally: string;
ecazAmount: number;
allyAmount: number;
method: 'agreement' | 'equal';
};
export class SpiceAllocationError extends Error {
constructor(message: string) {
super(message);
this.name = 'SpiceAllocationError';
}
}
const whole = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0;
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
const keysAre = (value: Record<string, unknown>, keys: string[]) =>
Object.keys(value).length === keys.length &&
keys.every((key) => Object.hasOwn(value, key));
function requireAllocation(
condition: unknown,
message: string,
): asserts condition {
if (!condition) throw new SpiceAllocationError(message);
}
function copyLots(
lots: readonly SharedSpiceLot[],
allowZero: boolean,
): SharedSpiceLot[] {
requireAllocation(Array.isArray(lots), 'Shared spice lots must be an array.');
const territories = new Set<string>();
let pair: { ecaz: string; ally: string } | undefined;
return lots.map((lot) => {
requireAllocation(
record(lot) &&
keysAre(lot, ['territory', 'ecaz', 'ally', 'amount']) &&
typeof lot.territory === 'string' &&
TERRITORIES.some((t) => t.id === lot.territory && t.type === 'sand') &&
identifier(lot.ecaz) &&
identifier(lot.ally) &&
lot.ecaz !== lot.ally &&
whole(lot.amount) &&
(allowZero || lot.amount > 0),
'Each shared lot needs a desert territory, distinct parties and a valid spice amount.',
);
requireAllocation(
!territories.has(lot.territory),
'A desert territory appears in more than one shared lot.',
);
requireAllocation(
!pair || (pair.ecaz === lot.ecaz && pair.ally === lot.ally),
'All shared lots must belong to the same Ecaz alliance.',
);
territories.add(lot.territory);
pair = { ecaz: lot.ecaz, ally: lot.ally };
return {
territory: lot.territory,
ecaz: lot.ecaz,
ally: lot.ally,
amount: lot.amount,
};
});
}
/** Protocol convention: Ecaz proposes first for each nonzero lot. This does
* not claim the printed rule prescribes a first proposer. The caller computes
* collected amounts and binds the protocol to its collection event/version. */
export function createSpiceAllocation(
lots: readonly SharedSpiceLot[],
): AllocationState | null {
const active = copyLots(lots, true).filter((lot) => lot.amount > 0);
return active.length
? { lots: active, index: 0, offer: null, player: active[0].ecaz }
: null;
}
/** Quote a single bilateral choice. No board, resources, private information or
* random state is read. A valid old state is indistinguishable from its replay
* here: event binding and exactly-once commitment belong to the caller/CAS. */
export function quoteSpiceAllocation(
state: AllocationState,
actor: string,
action: SpiceAllocationAction,
): { next: AllocationState | null; receipt: SpiceAllocationReceipt | null } {
requireAllocation(
record(state) && keysAre(state, ['lots', 'index', 'offer', 'player']),
'The saved spice allocation has an invalid shape.',
);
const lots = copyLots(state.lots, false);
requireAllocation(
whole(state.index) && state.index < lots.length,
'The saved spice allocation has no current lot.',
);
const lot = lots[state.index];
requireAllocation(
identifier(state.player) &&
(state.player === lot.ecaz || state.player === lot.ally),
'The saved allocation player is not a member of this alliance.',
);
const offer = state.offer;
if (offer === null) {
requireAllocation(
state.player === lot.ecaz,
'An unproposed lot begins with Ecaz.',
);
} else {
requireAllocation(
record(offer) &&
keysAre(offer, ['by', 'ecazShare']) &&
(offer.by === lot.ecaz || offer.by === lot.ally) &&
whole(offer.ecazShare) &&
offer.ecazShare <= lot.amount &&
state.player !== offer.by,
'The saved proposal must await the other party and fit the current lot.',
);
}
requireAllocation(
identifier(actor) && actor === state.player,
'This spice allocation choice belongs to the other player.',
);
requireAllocation(
record(action) &&
((action.kind === 'propose' && keysAre(action, ['kind', 'ecazShare'])) ||
((action.kind === 'accept' || action.kind === 'equal') &&
keysAre(action, ['kind']))),
'Choose a proposal, acceptance or equal allocation.',
);
if (action.kind === 'propose') {
requireAllocation(
whole(action.ecazShare) && action.ecazShare <= lot.amount,
'Choose a whole-number Ecaz share within the collected amount.',
);
return {
next: {
lots,
index: state.index,
offer: { by: actor, ecazShare: action.ecazShare },
player: actor === lot.ecaz ? lot.ally : lot.ecaz,
},
receipt: null,
};
}
requireAllocation(
action.kind === 'equal' || offer !== null,
'There is no proposal to accept.',
);
const ecazAmount =
action.kind === 'equal' ? Math.floor(lot.amount / 2) : offer!.ecazShare;
const receipt: SpiceAllocationReceipt = {
territory: lot.territory,
ecaz: lot.ecaz,
ally: lot.ally,
ecazAmount,
allyAmount: lot.amount - ecazAmount,
method: action.kind === 'equal' ? 'equal' : 'agreement',
};
const index = state.index + 1;
return {
next:
index < lots.length
? { lots, index, offer: null, player: lots[index].ecaz }
: null,
receipt,
};
}