Prototype unanimous Advanced victory continuation
This commit is contained in:
1 parent
3c62c284a7
commit
a3af6b755d
16 files changed
+740
-20
No files matched your search
@@ -1236,6 +1236,10 @@ function policyActions(g: GameView): Action[] {
|
||||
const level = rank(g);
|
||||
if (!(me.bot ?? me.autopilot) || g.status === 'finished') return [];
|
||||
if (g.status === 'lobby') return me.ready ? [] : [{ type: 'ready' }];
|
||||
if (g.victoryContinuation)
|
||||
return g.victoryContinuation.actor === me.id
|
||||
? [{ type: 'victoryContinuation', event: g.victoryContinuation.event, mode: 'finish' }]
|
||||
: [];
|
||||
if (g.homeworldTupileBlocked) return [];
|
||||
const dukeGrant = g.homeworldEcazDuke;
|
||||
if (dukeGrant && dukeGrant.occupier === me.id && !dukeGrant.blocked && dukeGrant.event && dukeGrant.recipients.length)
|
||||
@@ -4284,6 +4288,9 @@ function botAdvisorMatchingAllowed(g:GameView,action:Action) {
|
||||
} catch(error) { if(error instanceof AdvisorMatchingError) return false; throw error; }
|
||||
}
|
||||
export function botActions(g: GameView): Action[] {
|
||||
if (g.victoryContinuation)
|
||||
return g.status === 'playing' && !g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending
|
||||
? policyActions(g) : [];
|
||||
if (g.homeworldTupileBlocked) return [];
|
||||
if (g.decision?.kind === 'homeworldTupileCleanup' || g.homeworldTupileCleanup)
|
||||
return tupileCleanupActions(g);
|
||||
|
||||
+92
-3
@@ -1,4 +1,7 @@
|
||||
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
|
||||
import { createVictoryContinuation, validateVictoryContinuation, voteVictoryContinuation,
|
||||
VictoryContinuationError, MAX_VICTORY_EXTRA_STRONGHOLDS, type VictoryContinuation,
|
||||
type VictoryContinuationResume } from './victory-continuation';
|
||||
import { SabotageGiftError, quoteSabotageGift, quoteSabotageGiftDiscard } from './sabotage-gift';
|
||||
import { kullDefaultEligible } from './kull-default-profile';
|
||||
import { advancedKullLobbyEligible } from './kull-advanced-profile';
|
||||
@@ -1402,6 +1405,9 @@ type RicheseAllyOffer = {
|
||||
payer: string;
|
||||
};
|
||||
export type Game = {
|
||||
/** Agreed addition to the ordinary stronghold win targets. Older games omit it. */
|
||||
victoryExtraStrongholds?: number;
|
||||
victoryContinuation?: VictoryContinuation | null;
|
||||
charityPromises?: CharityPromise[];
|
||||
revivalOrder?: RevivalOrder;
|
||||
charityOrder?: CharityOrder;
|
||||
@@ -29126,12 +29132,51 @@ function decideSharedSpice(g: Game, id: string, action: Action) {
|
||||
}
|
||||
}
|
||||
|
||||
function victory(g: Game, quote?: VictoryQuote) {
|
||||
function victoryAgreementRule<T>(calculate: () => T): T {
|
||||
try { return calculate(); }
|
||||
catch (error) {
|
||||
if (error instanceof VictoryContinuationError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function victoryAgreementSides(g: Game, quote: VictoryQuote): string[][] {
|
||||
const sides = strongholdProgress(g).progress.filter(row => row.qualifies).map(row => row.members);
|
||||
if (quote.ecazHomeworld) sides.push(quote.ecazHomeworld.members);
|
||||
return sides;
|
||||
}
|
||||
function victoryContinuationIntegrity(g: Game) {
|
||||
const pending = g.victoryContinuation;
|
||||
if (!pending) return;
|
||||
victoryAgreementRule(() => validateVictoryContinuation(pending));
|
||||
requireRule(g.status === 'playing' && g.advanced && g.phase === 8 && g.turn === pending.turn &&
|
||||
!g.winner.length && !g.decision && !g.response,
|
||||
'The shared victory agreement must retain its original Mentat opportunity.');
|
||||
const expected = victoryAgreementRule(() => createVictoryContinuation({
|
||||
event: pending.event, turn: g.turn, resume: pending.resume, order: g.order,
|
||||
sides: victoryAgreementSides(g, currentVictoryQuote(g)),
|
||||
}));
|
||||
requireRule(JSON.stringify(expected.sides) === JSON.stringify(pending.sides) &&
|
||||
JSON.stringify(expected.voters) === JSON.stringify(pending.voters),
|
||||
'The qualifying sides changed during their shared victory agreement.');
|
||||
}
|
||||
function victory(g: Game, quote?: VictoryQuote, resume: VictoryContinuationResume = 'mentat', skipOffer = false) {
|
||||
requireRule(!g.pendingAutomaticAdvisorFlips?.length && !g.pendingAutomaticAdvisorTransport &&
|
||||
!g.pendingAutomaticAdvisorGamont && !g.pendingAutomaticAdvisorBattle && !g.pendingAutomaticAdvisorHomeworldTransport &&
|
||||
!g.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm && !g.pendingAutomaticAdvisorRelocation && !g.pendingAdvisorMatching && !g.pendingAdvisorAmbassadorMatching && !g.pendingAdvisorTransportMatching && !g.pendingAdvisorGatherMatching && !g.pendingSpiritualAdvisor,
|
||||
'Resolve mandatory advisor conversions before checking victory.');
|
||||
quote ??= currentVictoryQuote(g);
|
||||
if (!skipOffer && g.advanced && g.turn < 10 && g.status === 'playing' && !g.winner.length) {
|
||||
const sides = victoryAgreementSides(g, quote);
|
||||
const distinct = new Set(sides.map(side => [...side].sort().join('\u0000')));
|
||||
if (distinct.size >= 2 && sides.some(side => side.length === 2)) {
|
||||
applyAdvisorReleases(g, quote.released);
|
||||
g.victoryContinuation = victoryAgreementRule(() => createVictoryContinuation({
|
||||
event: crypto.randomUUID(), turn: g.turn, resume, order: g.order, sides,
|
||||
}));
|
||||
log(g, 'Multiple sides qualify for victory. Their participants may unanimously agree to another turn and a changed stronghold target.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
applyAdvisorReleases(g, quote.released);
|
||||
g.winner = quote.winner;
|
||||
g.status = quote.status;
|
||||
@@ -39811,6 +39856,7 @@ function normalizeCardNames(g: Game) {
|
||||
]);
|
||||
}
|
||||
export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
victoryContinuationIntegrity(state);
|
||||
bidPromiseIntegrity(state);
|
||||
phaseBidIntegrity(state);
|
||||
richeseBidIntegrity(state);
|
||||
@@ -39906,6 +39952,35 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
}
|
||||
return g;
|
||||
}
|
||||
if (state.victoryContinuation) {
|
||||
const frame = state.victoryContinuation;
|
||||
if (action.type === 'advanceBots') return state;
|
||||
requireRule(action.type === 'victoryContinuation' &&
|
||||
Object.keys(action).every(key => ['type','event','mode','extra'].includes(key)) &&
|
||||
(action.mode === 'finish' || action.mode === 'continue' || action.mode === 'accept') &&
|
||||
(frame.extraStrongholds === null ? action.mode !== 'accept' : action.mode !== 'continue'),
|
||||
'Finish the current shared victory agreement before another action.');
|
||||
const result = victoryAgreementRule(() => voteVictoryContinuation(frame, {
|
||||
event: action.event as string, player: id,
|
||||
choice: action.mode === 'finish' ? 'finish' : 'continue',
|
||||
...(action.extra === undefined ? {} : {extraStrongholds: action.extra as number}),
|
||||
}));
|
||||
const g = structuredClone(state);
|
||||
if (result.outcome === 'pending') g.victoryContinuation = result.frame;
|
||||
else {
|
||||
g.victoryContinuation = null;
|
||||
if (result.outcome === 'finish') victory(g, undefined, frame.resume, true);
|
||||
else {
|
||||
const extra = (g.victoryExtraStrongholds ?? 0) + result.extraStrongholds;
|
||||
requireRule(Number.isSafeInteger(extra) && extra <= 200, 'The agreed stronghold target is too large.');
|
||||
g.victoryExtraStrongholds = extra;
|
||||
log(g, `The qualifying participants agreed to continue for another turn${result.extraStrongholds ? ` and increase ordinary stronghold targets by ${result.extraStrongholds}` : ' with unchanged stronghold targets'}.`);
|
||||
if (frame.resume === 'advance') completePhase(g);
|
||||
return normalizeAutomaticGame(g);
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
if (state.pendingTreacheryDiscard) {
|
||||
getPlayer(state, id);
|
||||
if (state.pendingTreacheryDiscard.reaction &&
|
||||
@@ -40177,6 +40252,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
return g;
|
||||
}
|
||||
function finishActionContinuations(g: Game) {
|
||||
if (g.victoryContinuation) return;
|
||||
if (g.pendingNexusHarkonnenBetrayal) return;
|
||||
if (g.pendingNexusIxianBetrayal) return;
|
||||
if (g.pendingGuildBetrayal) return;
|
||||
@@ -40248,6 +40324,7 @@ function finishActionContinuations(g: Game) {
|
||||
finishMoritaniPlacement(g);
|
||||
}
|
||||
function settleAutomaticContinuations(g: Game) {
|
||||
if (g.victoryContinuation) return;
|
||||
if (g.pendingNexusHarkonnenBetrayal) return;
|
||||
if (g.pendingNexusIxianBetrayal) return;
|
||||
if (g.pendingNexusIxianReplacement) return;
|
||||
@@ -40259,6 +40336,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
if (pendingNexusTraitors(g)) return;
|
||||
if (g.pendingNullentropy) return;
|
||||
for (let iteration = 0; iteration < 128; iteration++) {
|
||||
if (g.victoryContinuation) return;
|
||||
if (g.pendingNexusHarkonnenBetrayal) return;
|
||||
if (g.pendingNexusIxianBetrayal) return;
|
||||
if (g.pendingGuildBetrayal) return;
|
||||
@@ -40388,6 +40466,8 @@ function settleAutomaticContinuations(g: Game) {
|
||||
}
|
||||
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
|
||||
export function normalizeAutomaticGame(state: Game): Game {
|
||||
victoryContinuationIntegrity(state);
|
||||
if (state.victoryContinuation) return state;
|
||||
bidPromiseIntegrity(state);
|
||||
phaseBidIntegrity(state);
|
||||
richeseBidIntegrity(state);
|
||||
@@ -41642,8 +41722,8 @@ function applyActionInner(
|
||||
'Finish CHOAM’s Mentat opportunity before checking victory.',
|
||||
);
|
||||
g.choamMentatPending = false;
|
||||
victory(g);
|
||||
if (g.status === 'playing') completePhase(g);
|
||||
victory(g, undefined, 'advance');
|
||||
if (g.status === 'playing' && !g.victoryContinuation) completePhase(g);
|
||||
return g;
|
||||
}
|
||||
if (decision.kind === 'choamFreeRevival') {
|
||||
@@ -44594,6 +44674,7 @@ function applyActionInner(
|
||||
throw new RuleError('That action is not available.');
|
||||
}
|
||||
export function viewGame(state: Game, id: string, now?: number) {
|
||||
victoryContinuationIntegrity(state);
|
||||
revivalOrderIntegrity(state);
|
||||
charityOrderIntegrity(state);
|
||||
state = migrateAtomicsTargets(state);
|
||||
@@ -45530,6 +45611,14 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
g.moritaniRetention.keep,
|
||||
) ?? null)
|
||||
: null,
|
||||
victoryContinuation: g.victoryContinuation ? {
|
||||
event: g.victoryContinuation.event,
|
||||
actor: g.victoryContinuation.voters[g.victoryContinuation.accepted.length],
|
||||
voters: [...g.victoryContinuation.voters],
|
||||
sides: g.victoryContinuation.sides.map(side => [...side]),
|
||||
extra: g.victoryContinuation.extraStrongholds,
|
||||
maxExtra: MAX_VICTORY_EXTRA_STRONGHOLDS,
|
||||
} : null,
|
||||
decision: g.pendingGuildBetrayal || g.pendingRicheseBetrayal || g.pendingKull?.stage === 'offer' ? null :
|
||||
g.decision?.kind === 'capturedLeader' &&
|
||||
!g.leaderSkills?.assignments.some((a) => a.leader === (g.decision as Extract<Decision,{kind:'capturedLeader'}>).leader) &&
|
||||
|
||||
+1
-1
@@ -4445,7 +4445,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'A correct Bene Gesserit faction-and-turn prediction replaces a normal win, including an allied win, with a Bene Gesserit solo win. Guild and Fremen special wins cannot be predicted.',
|
||||
'On turn ten, a normal stronghold victory comes first. Otherwise, check Fremen’s special condition, then award the game to Guild and its ally if present. If Guild is absent, Fremen and its ally win even when the usual Fremen special condition fails.',
|
||||
'When neither Guild nor Fremen is playing and nobody has reached the normal target, compare individual qualifying stronghold counts. Every player tied for the highest count wins. A correct Bene Gesserit prediction can replace this stronghold win.',
|
||||
'With tech tokens enabled, owning all three adds one stronghold. One player must hold all three, even in an alliance. In Advanced, when multiple unallied players qualify, the highest stronghold count wins; equal counts provisionally share. A qualifying alliance and other qualifying sides share before a correct prediction replaces the result. The optional unanimous decision to play another turn remains unfinished.',
|
||||
'With tech tokens enabled, owning all three adds one stronghold. One player must hold all three, even in an alliance. In Advanced, when multiple unallied players qualify, the highest stronghold count wins; equal counts provisionally share. When several sides including an alliance qualify before turn ten, every qualifying participant may instead agree to continue another turn and optionally increase ordinary stronghold targets. A finish vote settles the original shared result before any correct prediction replaces it. The turn-ten continuation and other exceptional compositions remain unfinished.',
|
||||
'Stronghold victory progress shows current territories, a complete Tech Token set, Ecaz joint occupation and Fremen final-turn conditions. Basic permits reciprocal Fremen/Ecaz cooccupation at Tabr; supplied Advanced p32 permits both protected sietches. Solitary/nonallied Ecaz still blocks. Public counts do not award victory before the proper checkpoint; Tech Tokens cannot supply joint occupation.',
|
||||
'Other expansion additions to victory remain in development. Advanced preview supports selected factions and combinations; complete rules acceptance remains open.',
|
||||
],
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/** Provisional online agreement for the 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 && unique.some(side => side.length === 2),
|
||||
'The provisional continuation needs multiple qualifying sides including an alliance.');
|
||||
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.turn <= 9 && (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 current turn before turn ten 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.turn <= 9 &&
|
||||
(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 };
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export type StrongholdProgress = {
|
||||
members: string[];
|
||||
strongholds: string[];
|
||||
techStronghold: boolean;
|
||||
target: 3 | 4;
|
||||
target: number;
|
||||
jointlyOccupied: string[];
|
||||
occupyTarget: 3 | null;
|
||||
qualifies: boolean;
|
||||
@@ -45,7 +45,7 @@ export function countsForStrongholdVictory(id: string) {
|
||||
* victory, combat resolution, resource mutation or Stronghold Card ownership is
|
||||
* considered. Mandatory advisor releases are returned for the caller to commit. */
|
||||
export function strongholdProgress(
|
||||
g: BoardContext & Pick<Game, 'techTokens'>,
|
||||
g: BoardContext & Pick<Game, 'techTokens'> & { victoryExtraStrongholds?: number },
|
||||
): { released: AdvisorRelease[]; progress: StrongholdProgress[] } {
|
||||
try {
|
||||
requireProgress(
|
||||
@@ -56,6 +56,9 @@ export function strongholdProgress(
|
||||
g.players.every((p) => record(p) && record(p.forces)),
|
||||
'Victory progress requires a valid public board and seated players.',
|
||||
);
|
||||
const extra = g.victoryExtraStrongholds ?? 0;
|
||||
requireProgress(Number.isSafeInteger(extra) && extra >= 0 && extra <= 200,
|
||||
'The agreed extra stronghold target must be a safe nonnegative count.');
|
||||
// This validates every ID, faction and reciprocal alliance, even where no
|
||||
// stronghold has a force. It does not merge ordinary allies for occupancy.
|
||||
ecazOccupancyIdentity(g.players, g.players[0].id, {
|
||||
@@ -126,7 +129,7 @@ export function strongholdProgress(
|
||||
members.some((member) =>
|
||||
TECH_TOKENS.every(({ id }) => tech[id].owner === member),
|
||||
);
|
||||
const target: 3 | 4 = partner || players.length === 2 ? 4 : 3;
|
||||
const target = (partner || players.length === 2 ? 4 : 3) + extra;
|
||||
return {
|
||||
player: id,
|
||||
members,
|
||||
|
||||
@@ -37,7 +37,7 @@ const record = (value: unknown): value is Record<string, unknown> =>
|
||||
export type VictoryContext = Omit<BoardContext, 'players'> &
|
||||
Pick<
|
||||
Game,
|
||||
'turn' | 'phase' | 'status' | 'winner' | 'techTokens' | 'strongholdCards'
|
||||
'turn' | 'phase' | 'status' | 'winner' | 'techTokens' | 'strongholdCards' | 'victoryExtraStrongholds'
|
||||
> & {
|
||||
players: readonly (BoardSeat & Pick<Player, 'prediction'>)[];
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user