Files
Dune/game/victory-continuation.ts
T

136 lines
7.0 KiB
TypeScript

/** Provisional online agreement for the Basic FAQ and supplied Advanced rule allowing
* qualifying players and alliances to continue rather than share victory.
* Only public qualifiers belong here; a Bene Gesserit prediction is private. */
export const MAX_VICTORY_EXTRA_STRONGHOLDS = 20;
export type VictoryContinuationResume = 'mentat' | 'advance';
export type VictoryContinuation = {
version: 1;
event: string;
turn: number;
resume: VictoryContinuationResume;
order: string[];
sides: string[][];
voters: string[];
accepted: string[];
extraStrongholds: number | null;
};
export type VictoryContinuationChoice = {
event: string;
player: string;
choice: 'continue' | 'finish';
/** Only the first voter chooses the shared increment. */
extraStrongholds?: number;
};
export type VictoryContinuationVote =
| { outcome: 'pending'; frame: VictoryContinuation }
| { outcome: 'finish' }
| { outcome: 'continue'; extraStrongholds: number };
export class VictoryContinuationError extends Error {
constructor(message: string) {
super(message);
this.name = 'VictoryContinuationError';
}
}
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
const id = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value.trim() === value;
const ids = (value: unknown): value is string[] => Array.isArray(value) && value.every(id);
const same = (a: readonly string[], b: readonly string[]) =>
a.length === b.length && a.every((value, index) => value === b[index]);
function requireContinuation(condition: unknown, message: string): asserts condition {
if (!condition) throw new VictoryContinuationError(message);
}
function validExtra(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) &&
value >= 0 && value <= MAX_VICTORY_EXTRA_STRONGHOLDS;
}
/** Normalize duplicates from the two rows representing one reciprocal alliance.
* Distinct sides must be disjoint and contain only actual seated players. */
function canonicalSides(order: readonly string[], sides: readonly (readonly string[])[]): string[][] {
requireContinuation(Array.isArray(sides), 'Retain the qualifying victory sides.');
const unique: string[][] = [];
for (const side of sides) {
requireContinuation(ids(side) && side.length >= 1 && side.length <= 2 &&
new Set(side).size === side.length && side.every(player => order.includes(player)),
'Every qualifying side must contain one or two distinct seated players.');
const members = order.filter(player => side.includes(player));
if (!unique.some(existing => same(existing, members))) unique.push(members);
}
unique.sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]));
requireContinuation(unique.length >= 2,
'The continuation needs multiple distinct qualifying sides.');
const members = unique.flat();
requireContinuation(new Set(members).size === members.length,
'Qualifying victory sides cannot overlap.');
return unique;
}
export function createVictoryContinuation(input: {
event: string;
turn: number;
resume: VictoryContinuationResume;
order: readonly string[];
sides: readonly (readonly string[])[];
}): VictoryContinuation {
requireContinuation(record(input) && id(input.event) && Number.isSafeInteger(input.turn) &&
input.turn >= 1 && (input.resume === 'mentat' || input.resume === 'advance') &&
ids(input.order) && input.order.length >= 2 && input.order.length <= 6 &&
new Set(input.order).size === input.order.length,
'The shared victory agreement needs a positive safe turn and distinct seated players.');
const order = [...input.order], sides = canonicalSides(order, input.sides);
const voters = order.filter(player => sides.some(side => side.includes(player)));
return { version: 1, event: input.event, turn: input.turn, resume: input.resume,
order, sides, voters, accepted: [], extraStrongholds: null };
}
/** Check the full public receipt before projecting or accepting each vote. */
export function validateVictoryContinuation(frame: VictoryContinuation): void {
requireContinuation(record(frame) &&
Object.keys(frame).sort().join(',') === 'accepted,event,extraStrongholds,order,resume,sides,turn,version,voters' &&
frame.version === 1 && id(frame.event) && Number.isSafeInteger(frame.turn) && frame.turn >= 1 &&
(frame.resume === 'mentat' || frame.resume === 'advance') &&
ids(frame.order) && frame.order.length >= 2 && frame.order.length <= 6 &&
new Set(frame.order).size === frame.order.length && ids(frame.voters) && ids(frame.accepted),
'The saved shared victory agreement is malformed.');
const canonical = canonicalSides(frame.order, frame.sides);
requireContinuation(frame.sides.length === canonical.length &&
frame.sides.every((side, index) => same(side, canonical[index])),
'The saved qualifying sides no longer match their public order.');
const voters = frame.order.filter(player => canonical.some(side => side.includes(player)));
requireContinuation(same(frame.voters, voters) && frame.accepted.length < voters.length &&
same(frame.accepted, voters.slice(0, frame.accepted.length)) &&
(frame.accepted.length === 0 ? frame.extraStrongholds === null : validExtra(frame.extraStrongholds)),
'The saved shared victory agreement lost its original ordered votes or proposal.');
}
/** Continue only on unanimous acceptance of one exact public proposal. A vote
* to finish awards the original result through the caller's native settlement. */
export function voteVictoryContinuation(frame: VictoryContinuation,
choice: VictoryContinuationChoice): VictoryContinuationVote {
validateVictoryContinuation(frame);
requireContinuation(record(choice) &&
Object.keys(choice).every(key => ['event', 'player', 'choice', 'extraStrongholds'].includes(key)) &&
choice.event === frame.event && choice.player === frame.voters[frame.accepted.length] &&
(choice.choice === 'continue' || choice.choice === 'finish'),
'Choose at your current shared victory agreement opportunity.');
if (choice.choice === 'finish') {
requireContinuation(choice.extraStrongholds === undefined, 'Finishing cannot alter the victory target.');
return { outcome: 'finish' };
}
const first = frame.accepted.length === 0;
requireContinuation(first ? validExtra(choice.extraStrongholds) : choice.extraStrongholds === undefined,
first ? `Choose an extra stronghold count from 0 to ${MAX_VICTORY_EXTRA_STRONGHOLDS}.`
: 'Accept the original proposal without changing its target.');
const extra = first ? choice.extraStrongholds! : frame.extraStrongholds!;
const accepted = [...frame.accepted, choice.player];
if (accepted.length === frame.voters.length) return { outcome: 'continue', extraStrongholds: extra };
const next: VictoryContinuation = { ...frame, order: [...frame.order], sides: frame.sides.map(side => [...side]),
voters: [...frame.voters], accepted, extraStrongholds: extra };
validateVictoryContinuation(next);
return { outcome: 'pending', frame: next };
}