/** 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 => !!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 duplicate rows representing one qualifying 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 <= order.length && new Set(side).size === side.length && side.every(player => order.includes(player)), 'Every qualifying side must contain 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 }; }