Prototype unlimited alliance variant
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 00:33:42 +11:00
1 parent 37acaeb9d2
commit 9cb65a21f5
23 files changed
+677 -52

No files matched your search

+1 -1
View File
@@ -39,7 +39,7 @@ function currentShipmentAllies(context: ShipmentContext, player: OccupancySeat):
id: seat.id, ally: seat.ally ?? null, allySinceTurn: seat.allySinceTurn,
}));
const groups = coalitionGroups(seats, context.coalitionState);
if (groups.some(group => group.members.length === 3) &&
if (groups.some(group => group.members.length >= 3) &&
(context.advanced || context.players.some(seat => seat.faction === 'ecaz')))
throw new Error('Three-member shipment rules require Basic play without Ecaz.');
return coalitionAllies(seats, context.coalitionState, player.id);
+1 -1
View File
@@ -42,7 +42,7 @@ function validateBoardCoalitions(g: BoardContext) {
if (g.coalitionState === undefined) return;
try {
for (const group of coalitionGroups(g.players, g.coalitionState))
if (group.members.length === 3 && (g.advanced || g.players.some(player => player.faction === 'ecaz')))
if (group.members.length >= 3 && (g.advanced || g.players.some(player => player.faction === 'ecaz')))
throw new Error('Three-member board rules require Basic play without Ecaz.');
} catch (error) {
throw new BoardResolutionError((error as Error).message);
+7 -1
View File
@@ -3226,6 +3226,12 @@ function policyActions(g: GameView): Action[] {
const card = me.hand?.find((c) => c.effect === 'harvester');
if (card) return [{ type: 'card', card: card.id }];
}
if (g.nexus && !g.spiceWindow && !g.ready.includes(me.id) && group?.variant === 'unlimited' &&
!group.groups.some(alliance => alliance.members.includes(me.id))) {
const alliance = group.groups.find(candidate => candidate.members.length >= 2 && candidate.members.length < 6);
if (alliance) return [{type:'coalitionPropose', groupId:alliance.id, entrant:me.id,
expectedRevision:group.revision}];
}
if (g.nexus && !g.spiceWindow && !me.ally &&
!group?.groups.some(alliance => alliance.members.includes(me.id))) {
const offer = g.players.find(
@@ -3238,7 +3244,7 @@ function policyActions(g: GameView): Action[] {
const target = g.players.find(
(player) => player.id !== me.id &&
!player.ally && !g.homeworldAllianceBlocks?.[player.id] &&
!group?.groups.some(alliance => alliance.members.length === 3 && alliance.members.includes(player.id)),
!group?.groups.some(alliance => alliance.members.length >= 3 && alliance.members.includes(player.id)),
);
if (target) return [{ type: 'alliance', target: target.id }];
}
+27 -20
View File
@@ -1,4 +1,4 @@
/** Foundation for the optional three-member alliance. The engine must still
/** Foundation for optional larger alliances. The engine must still
* authorize Nexus timing, apply legacy-field updates atomically, and connect
* each native alliance benefit before enabling this option in play. */
export type CoalitionSeat = { id: string; ally: string | null; allySinceTurn?: number };
@@ -20,7 +20,7 @@ export type CoalitionAdmission = {
nexus: string;
turn: number;
groupId: string;
/** Snapshot binds the offer to the exact existing pair and its edge age. */
/** Snapshot binds the offer to the exact existing group and its edge ages. */
original: CoalitionGroup;
entrant: string;
proposer: string;
@@ -28,6 +28,8 @@ export type CoalitionAdmission = {
};
export type CoalitionState = {
version: 1;
/** Absent preserves the existing three-member maximum. */
variant?: 'unlimited';
revision: number;
groups: CoalitionGroup[];
proposal: CoalitionAdmission | null;
@@ -62,8 +64,8 @@ const same = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.st
const copy = <T>(value: T): T => structuredClone(value);
const legacyId = (members: readonly string[]) => `legacy:${JSON.stringify(sorted(members))}`;
export function emptyCoalitionState(): CoalitionState {
return { version: 1, revision: 0, groups: [], proposal: null, usedEvents: [] };
export function emptyCoalitionState(variant?: 'unlimited'): CoalitionState {
return { version: 1, ...(variant === undefined ? {} : {variant}), revision: 0, groups: [], proposal: null, usedEvents: [] };
}
function requireSeats(seats: readonly CoalitionSeat[]): void {
check(Array.isArray(seats) && seats.length >= 2 && seats.length <= 6 &&
@@ -72,9 +74,9 @@ function requireSeats(seats: readonly CoalitionSeat[]): void {
new Set(seats.map(seat => seat.id)).size === seats.length,
'Coalitions need two through six distinct seated players.');
}
function requireGroup(group: CoalitionGroup, seats: readonly CoalitionSeat[]): void {
function requireGroup(group: CoalitionGroup, seats: readonly CoalitionSeat[], maximum: number): void {
check(record(group) && groupId(group.id) && Array.isArray(group.members) &&
(group.members.length === 2 || group.members.length === 3) && group.members.every(id) &&
group.members.length >= 2 && group.members.length <= maximum && group.members.every(id) &&
new Set(group.members).size === group.members.length && same(group.members, sorted(group.members)) &&
group.members.every(member => seats.some(seat => seat.id === member)) && age(group.formedTurn) &&
Array.isArray(group.edges), 'Invalid saved coalition.');
@@ -93,7 +95,8 @@ function requireGroup(group: CoalitionGroup, seats: readonly CoalitionSeat[]): v
check(same(actual, expected), 'A coalition must retain exactly one ordered edge for each pair.');
}
function requireState(state: CoalitionState): void {
check(record(state) && state.version === 1 && Number.isSafeInteger(state.revision) && state.revision >= 0 &&
check(record(state) && state.version === 1 && (state.variant === undefined || state.variant === 'unlimited') &&
Number.isSafeInteger(state.revision) && state.revision >= 0 &&
Array.isArray(state.groups) && Array.isArray(state.usedEvents) && state.usedEvents.every(id) &&
new Set(state.usedEvents).size === state.usedEvents.length &&
(state.proposal === null || record(state.proposal)), 'Invalid saved coalition state.');
@@ -108,13 +111,13 @@ export function coalitionGroups(seats: readonly CoalitionSeat[], value?: Coaliti
requireState(state);
const seen = new Set<string>(), ids = new Set<string>();
for (const group of state.groups) {
requireGroup(group, seats);
requireGroup(group, seats, state.variant === 'unlimited' ? 6 : 3);
check(!ids.has(group.id) && group.members.every(member => !seen.has(member)),
'A seat or coalition ID cannot belong to multiple groups.');
ids.add(group.id); group.members.forEach(member => seen.add(member));
if (group.members.length === 3) {
if (group.members.length >= 3) {
check(group.members.every(member => seats.find(seat => seat.id === member)!.ally === null),
'Three-member groups cannot use a fictitious legacy pair.');
'Larger groups cannot use a fictitious legacy pair.');
} else {
const [a, b] = group.members;
check(seats.find(seat => seat.id === a)!.ally === b && seats.find(seat => seat.id === b)!.ally === a,
@@ -166,10 +169,10 @@ export function coalitionGroups(seats: readonly CoalitionSeat[], value?: Coaliti
offer.approvals.every(id) && new Set(offer.approvals).size === offer.approvals.length &&
state.usedEvents.includes(offer.event), 'Invalid saved coalition invitation.');
const group = groups.find(candidate => candidate.id === offer.groupId);
check(group && group.members.length === 2 && same(group, offer.original) &&
check(group && group.members.length < (state.variant === 'unlimited' ? 6 : 3) && same(group, offer.original) &&
seats.some(seat => seat.id === offer.entrant) &&
!groups.some(candidate => candidate.members.includes(offer.entrant)),
'The saved invitation no longer matches its pair or entrant.');
'The saved invitation no longer matches its group or entrant.');
const participants = [...group.members, offer.entrant];
check(participants.includes(offer.proposer) && offer.approvals.includes(offer.proposer) &&
offer.approvals.every(actor => participants.includes(actor)) && offer.approvals.length < participants.length &&
@@ -213,7 +216,7 @@ function unchanged(state: CoalitionState): CoalitionTransition {
return {state, allyUpdates: {}, allySinceUpdates: {}};
}
/** A proposer is either member of the pair or the free entrant; its offer is
/** A proposer is either member of the group or the free entrant; its offer is
* its explicit consent. A later decline by any participant cancels the offer. */
export function beginCoalitionAdmission(seats: readonly CoalitionSeat[], value: CoalitionState | undefined,
request: CoalitionAdmissionRequest): CoalitionTransition {
@@ -222,19 +225,19 @@ export function beginCoalitionAdmission(seats: readonly CoalitionSeat[], value:
const {state, groups} = currentState(seats, value, request.expectedRevision);
check(state.proposal === null && !state.usedEvents.includes(request.event), 'Finish the current invitation and use a new event.');
const group = groups.find(candidate => candidate.id === request.groupId);
check(group && group.members.length === 2 && seats.some(seat => seat.id === request.entrant) &&
check(group && group.members.length < (state.variant === 'unlimited' ? 6 : 3) && seats.some(seat => seat.id === request.entrant) &&
!groups.some(candidate => candidate.members.includes(request.entrant)) &&
(group.members.includes(request.proposer) || request.proposer === request.entrant) &&
(group.formedTurn === null || group.formedTurn <= request.turn) &&
group.edges.every(edge => (edge.formedTurn === null || edge.formedTurn <= request.turn) &&
(!edge.legacySince || edge.legacySince.every(date => date === null || date <= request.turn))),
'Invite one unallied seated player to an existing pair.');
'Invite one unallied seated player to an eligible existing alliance.');
return unchanged({ ...copy(state), revision: state.revision + 1, usedEvents: [...state.usedEvents, request.event],
proposal: {event: request.event, nexus: request.nexus, turn: request.turn, groupId: group.id,
original: copy(group), entrant: request.entrant, proposer: request.proposer, approvals: [request.proposer]} });
}
/** Every current pair member and the entrant must explicitly approve. */
/** Every current group member and the entrant must explicitly approve. */
export function answerCoalitionAdmission(seats: readonly CoalitionSeat[], value: CoalitionState | undefined,
answer: CoalitionAdmissionAnswer): CoalitionTransition {
check(record(answer) && id(answer.event) && id(answer.nexus) && turn(answer.turn) &&
@@ -246,9 +249,9 @@ export function answerCoalitionAdmission(seats: readonly CoalitionSeat[], value:
offer.approvals.every(id) && new Set(offer.approvals).size === offer.approvals.length &&
state.usedEvents.includes(offer.event), 'The saved coalition invitation is invalid.');
const group = groups.find(candidate => candidate.id === offer.groupId);
check(group && group.members.length === 2 && same(group, offer.original) &&
check(group && group.members.length < (state.variant === 'unlimited' ? 6 : 3) && same(group, offer.original) &&
!groups.some(candidate => candidate.members.includes(offer.entrant)) &&
seats.some(seat => seat.id === offer.entrant), 'The original pair or entrant changed.');
seats.some(seat => seat.id === offer.entrant), 'The original group or entrant changed.');
const participants = [...group.members, offer.entrant];
check(participants.includes(offer.proposer) && offer.approvals.includes(offer.proposer) &&
offer.approvals.every(actor => participants.includes(actor)) &&
@@ -273,8 +276,8 @@ export function answerCoalitionAdmission(seats: readonly CoalitionSeat[], value:
allyUpdates: Object.fromEntries(members.map(member => [member, null])), allySinceUpdates: {}};
}
/** Provisional online policy: the departing member leaves; the remaining pair
* stays allied, preserving that pair's original edge date and group identity. */
/** Provisional online policy: remaining members stay allied, preserving each
* surviving edge and group identity. A pair restores its legacy scalar fields. */
export function leaveCoalition(seats: readonly CoalitionSeat[], value: CoalitionState | undefined,
request: {actor: string; expectedRevision: number}): CoalitionTransition {
check(record(request) && id(request.actor), 'Choose the departing player.');
@@ -292,6 +295,10 @@ export function leaveCoalition(seats: readonly CoalitionSeat[], value: Coalition
allyEntries.push([remaining[0], remaining[1]], [remaining[1], remaining[0]]);
const retainedDates = retained.legacySince ?? [retained.formedTurn, retained.formedTurn];
ageEntries.push([remaining[0], retainedDates[0]], [remaining[1], retainedDates[1]]);
} else if (remaining.length >= 3) {
const edges = group.edges.filter(edge => edge.members.every(member => remaining.includes(member))).map(copy);
nextGroups.push({id: group.id, members: remaining, formedTurn: group.formedTurn, edges});
for (const member of remaining) allyEntries.push([member, null]);
} else for (const member of remaining) { allyEntries.push([member, null]); ageEntries.push([member, null]); }
return {state: {...copy(state), revision: state.revision + 1, groups: nextGroups},
allyUpdates: Object.fromEntries(allyEntries), allySinceUpdates: Object.fromEntries(ageEntries)};
+1 -1
View File
@@ -45,7 +45,7 @@ export function coalitionBattleBeneficiary(
const seats = game.players.map(player => ({id: player.id, ally: player.ally ?? null,
...(player.allySinceTurn !== undefined ? {allySinceTurn: player.allySinceTurn} : {})}));
const group = coalitionOf(seats, game.coalitionState, ownerId);
if (group?.members.length === 3) {
if (group && group.members.length >= 3) {
const members = combatants.filter(player => group.members.includes(player));
return members.length === 1 ? members[0] : null;
}
+2 -2
View File
@@ -51,7 +51,7 @@ function threatened(g: Game, owner: string, territory: string): string[] | null
if (error instanceof CoalitionAllianceError) throw new CoalitionWormProtectionError(error.message);
throw error;
}
if (!group || group.members.length !== 3) return null;
if (!group || group.members.length < 3) return null;
requireProtection(Array.isArray(g.order) && g.order.length === g.players.length &&
new Set(g.order).size === g.order.length && g.order.every(seat => g.players.some(player => player.id === seat)),
'Group worm protection requires the current complete storm order.');
@@ -75,7 +75,7 @@ export function validateCoalitionWormProtection(g: Game, frame: CoalitionWormPro
requireProtection(object(frame) && Object.keys(frame).sort().join(',') === 'event,order,outcomes,owner,stage,territory,turn' &&
id(frame.event) && id(frame.owner) && id(frame.territory) && validTerritory(frame.territory) &&
Number.isSafeInteger(frame.turn) && frame.turn === g.turn && Array.isArray(frame.order) &&
frame.order.length > 0 && frame.order.length <= 2 && frame.order.every(id) &&
frame.order.length > 0 && frame.order.length <= 5 && frame.order.every(id) &&
new Set(frame.order).size === frame.order.length && Array.isArray(frame.outcomes) &&
['decision', 'response', 'complete'].includes(frame.stage),
'The group worm protection has lost its original occurrence.');
+6 -5
View File
@@ -16112,7 +16112,7 @@ function coalitionFundingAvailable(g: Game, p: Player, committedBid = false): bo
g.revivalPromises, g.leaderRevivalPromises, g.revivalExpressionPromises,
g.bribePromises, g.bribeExpressionPromises].some(unsettledPromises))) return false;
const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id));
return group?.members.length === 3;
return !!group && group.members.length >= 3;
}
function coalitionEmperorGroup(g: Game, p: Player) {
if (!g.coalitionState || p.faction !== 'emperor' || g.status !== 'playing' || g.advanced ||
@@ -16120,7 +16120,7 @@ function coalitionEmperorGroup(g: Game, p: Player) {
g.strongholdCards || g.ecazTreachery || g.discoveryEnabled ||
!g.players.every(seat => ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction))) return null;
const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id));
return group?.members.length === 3 ? group : null;
return group && group.members.length >= 3 ? group : null;
}
function guildGroupAlly(g: Game, p: Player): boolean {
return coalitionRule(() => groupGuildShipmentRate(g.players, p.id, g.coalitionState));
@@ -44546,14 +44546,14 @@ function applyActionInner(
g.allianceOffers = Object.fromEntries(Object.entries(g.allianceOffers)
.filter(([from, to]) => !members.has(from) && !members.has(to)));
}
log(g, formed ? 'All three players agreed to the group alliance.'
log(g, formed ? 'All participants agreed to the group alliance.'
: action.accept ? `${p.name} approved the group invitation.`
: `${p.name} declined the group invitation; the existing alliance remains.`);
} else {
const result = coalitionRule(() => leaveCoalition(g.players, g.coalitionState,
{actor: id, expectedRevision: revision}));
applyCoalitionTransition(g, result);
log(g, `${p.name} left the group alliance. Remaining partners stay allied if two remain.`);
log(g, `${p.name} left the group alliance. Remaining partners stay allied if at least two remain.`);
}
g.ready = [];
return g;
@@ -44568,7 +44568,7 @@ function applyActionInner(
requireRule(!g.coalitionState.proposal, 'Resolve the group invitation before changing alliances.');
const own = coalitionRule(() => coalitionOf(g.players, g.coalitionState, id));
const otherGroup = target ? coalitionRule(() => coalitionOf(g.players, g.coalitionState, target)) : null;
requireRule(own?.members.length !== 3 && otherGroup?.members.length !== 3,
requireRule(!(own && own.members.length >= 3) && !(otherGroup && otherGroup.members.length >= 3),
'Group members must use the group alliance controls.');
if (target === null && own && g.coalitionState.groups.some(group => group.id === own.id)) {
requireRule(coalitionNexusAvailable(g), 'Group negotiations are unavailable in this Nexus.');
@@ -46825,6 +46825,7 @@ export function viewGame(state: Game, id: string, now?: number) {
groups: coalitionRule(() => coalitionGroups(g.players, g.coalitionState)),
proposal: g.coalitionState!.proposal,
revision: g.coalitionState!.revision,
...(g.coalitionState!.variant ? {variant: g.coalitionState!.variant} : {}),
} : null,
homeworldAllianceBlocks: Object.fromEntries(g.players.filter((p) => p.id !== id)
.flatMap((p) => { const reason = homeworldAllianceReason(g, id, p.id); return reason ? [[p.id, reason]] : []; })),
+1 -1
View File
@@ -35,7 +35,7 @@ function groupRecipients(g: FremenRevivalGrantContext, owner: string): string[]
return null;
try {
const group = coalitionOf(g.players, g.coalitionState, owner);
return group?.members.length === 3 ? group.members.filter(member => member !== owner) : null;
return group && group.members.length >= 3 ? group.members.filter(member => member !== owner) : null;
} catch (error) {
if (error instanceof CoalitionAllianceError) throw new FremenRevivalGrantError(error.message);
throw error;
+1 -1
View File
@@ -48,7 +48,7 @@ export function territoryEntryBlock(
const seats = players.map(p => ({ id: p.id, ally: p.ally ?? null, allySinceTurn: p.allySinceTurn }));
if (coalitionState !== undefined) {
const groups = coalitionGroups(seats, coalitionState);
if (players.some(player => player.faction === 'ecaz') && groups.some(group => group.members.length === 3))
if (players.some(player => player.faction === 'ecaz') && groups.some(group => group.members.length >= 3))
throw new Error('Three-member board rules do not yet support Ecaz.');
}
if (advisors) return null;
+2 -2
View File
@@ -4501,11 +4501,11 @@ export const RULE_TOPICS: RuleTopic[] = [
example:
'At the final turn with neither Guild nor Fremen, two players each hold two qualifying strongholds and a third holds one. The two leaders of the count share the win.',
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'A host-selectable shorter two-member target shares victory calculation. Unexposed staged Basic groups connect Nexus, selected funding including purchased Karama shipment prices, Guild transport, Emperor and Fremen aid, Fremen worm protection, Voice, Prescience and Harkonnen traitor support; other group benefits remain open.', evidence: ['game/alliance-victory-option.ts', 'game/victory-progress.ts', 'game/victory-quote.ts', 'game/engine.ts', 'game/coalition-worm-protection.ts', 'game/coalition-battle-preparation.ts', 'game/traitor-declarations.ts', 'game/terminal-cancellation.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'A host-selectable shorter two-member target shares victory calculation. Unexposed staged Basic groups connect Nexus, selected funding including purchased Karama shipment prices, Guild transport, Emperor and Fremen aid, Fremen worm protection, Voice, Prescience and Harkonnen traitor support. An explicit staged unlimited variant extends membership to six and uses the three-stronghold target; other group interactions remain open.', evidence: ['game/alliance-victory-option.ts', 'game/victory-progress.ts', 'game/victory-quote.ts', 'game/engine.ts', 'game/coalition-worm-protection.ts', 'game/coalition-battle-preparation.ts', 'game/traitor-declarations.ts', 'game/terminal-cancellation.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'The lobby offers the pair option. A staged group Emperor can choose either ally for spice and extra revival; group games cannot yet be started from the lobby.', evidence: ['components/game-table.tsx', 'components/coalition-emperor-aid.tsx', 'tests/short-alliance-victory-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Legal alliance and victory paths use the shared target; staged group Emperor bots can offer a targeted revival. Strategic tuning remains deferred.', evidence: ['game/bots.ts', 'game/victory-progress.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'The printed option and provisional two-controller precedence are recorded.', evidence: ['docs/OPTIONAL_ALLIANCE_VICTORY.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, funding after staged settled Truthtrance records, Guild transport, purchased-rate funding, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; larger alliances and full combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-nexus-engine.test.ts', 'tests/coalition-shipment-funding.test.ts', 'tests/coalition-bid-funding.test.ts', 'tests/coalition-guild-shipment.test.ts', 'tests/coalition-guild-transport.test.ts', 'tests/coalition-fremen-worm.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, four-member unlimited admission, funding and victory, staged larger-group faction helpers, funding after settled Truthtrance records, Guild transport, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; complete group play and combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-unlimited-engine.test.ts', 'tests/coalition-unlimited-powers.test.ts', 'tests/coalition-unlimited-emperor.test.ts', 'tests/coalition-nexus-engine.test.ts', 'tests/coalition-shipment-funding.test.ts', 'tests/coalition-bid-funding.test.ts', 'tests/coalition-guild-shipment.test.ts', 'tests/coalition-guild-transport.test.ts', 'tests/coalition-fremen-worm.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] },
],
related: [
'mentat',
+1 -1
View File
@@ -144,7 +144,7 @@ export function validateTerminalCancellation(
if (!allied && g.coalitionState) {
try {
const group = coalitionOf(g.players, g.coalitionState, owner.id);
allied = !!group && group.members.length === 3 && !!response.recipient &&
allied = !!group && group.members.length >= 3 && !!response.recipient &&
group.members.includes(response.recipient);
} catch (error) {
if (error instanceof CoalitionAllianceError) throw new TerminalCancellationError(error.message);
+2 -2
View File
@@ -54,9 +54,9 @@ function canonicalSides(order: readonly string[], sides: readonly (readonly stri
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 <= 3 &&
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 one through three distinct seated players.');
'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);
}
+2 -2
View File
@@ -47,7 +47,7 @@ export function victoryMembers(g: BoardContext, player: string): string[] {
if (g.coalitionState === undefined)
return g.order.filter(member => member === player || member === seat.ally);
const groups = coalitionGroups(g.players, g.coalitionState);
requireProgress(!groups.some(group => group.members.length === 3 &&
requireProgress(!groups.some(group => group.members.length >= 3 &&
(g.advanced || g.players.some(candidate => candidate.faction === 'ecaz'))),
'Three-member victory rules require Basic play without Ecaz.');
const group = groups.find(candidate => candidate.members.includes(player));
@@ -154,7 +154,7 @@ export function strongholdProgress(
);
const target = allianceVictoryTarget(
players.map((player) => player.id), g.twoPlayerControllers, members.length > 1,
members.length === 3 ? false : g.shortAllianceVictory,
g.coalitionState?.variant === 'unlimited' ? true : members.length >= 3 ? false : g.shortAllianceVictory,
) + extra;
return {
player: id,