Prototype charity Truthtrance and coalition rule foundations
Verify and publish checkpoint container / container (push) Successful in 10m14s

This commit is contained in:
Quintine committed 2026-10-28 21:58:30 +11:00
1 parent e896570f07
commit cea310b030
17 files changed
+1024 -35

No files matched your search

+59 -3
View File
@@ -1,11 +1,13 @@
import type { Player } from './engine';
import { advisorStanceAfterSettlement } from './advisor-automatic-flip';
import type { ForcePresence } from './force-presence';
import { presenceByLocation } from './force-presence';
import { presenceAt, presenceByLocation } from './force-presence';
import { isHomeworldNoFieldLocation } from './richese-no-field';
import { fighterCount, isAdvisor } from './advisors';
import { quoteAidRefunds, PhaseResourceError } from './phase-resource-quote';
import { ecazOccupancyIdentity } from './ecaz-occupy';
import { ecazOccupancyIdentity, ecazOccupancyRelation } from './ecaz-occupy';
import { quoteAlliedSeparation } from './allied-separation';
import { areCoalitionAllies, coalitionAllies, coalitionEdgeTurn, coalitionGroups, type CoalitionState } from './coalition-alliance';
import type { SharedSpiceLot } from './ecaz-spice-allocation';
import {
gameDistance,
@@ -23,7 +25,7 @@ function requireBoard(condition: unknown, message: string): asserts condition {
}
const whole = (n: unknown): n is number =>
Number.isSafeInteger(n) && (n as number) >= 0;
export type BoardSeat = Pick<Player, 'id' | 'faction' | 'ally' | 'advisors'> &
export type BoardSeat = Pick<Player, 'id' | 'faction' | 'ally' | 'allySinceTurn' | 'advisors'> &
ForcePresence;
export type BoardContext = MobileBoard & {
advanced: boolean;
@@ -32,7 +34,20 @@ export type BoardContext = MobileBoard & {
storm: number;
order: readonly string[];
players: readonly BoardSeat[];
coalitionState?: CoalitionState;
};
/** Three-member board rules are provisionally restricted to Basic without Ecaz. */
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')))
throw new Error('Three-member board rules require Basic play without Ecaz.');
} catch (error) {
throw new BoardResolutionError((error as Error).message);
}
}
export type AdvisorRelease = { player: string; territory: string };
function validKey(g: MobileBoard, key: string) {
const p = splitLocation(key);
@@ -95,6 +110,7 @@ export function settledBoard(g: BoardContext) {
id: p.id,
faction: p.faction,
ally: p.ally,
allySinceTurn: p.allySinceTurn,
forces: p.forces,
noField: p.noField,
advisors,
@@ -111,6 +127,7 @@ export type BattleBoardBattle = {
};
export function quoteBattleBoard(g: BoardContext) {
const { players, released } = settledBoard(g);
validateBoardCoalitions(g);
const battles: BattleBoardBattle[] = [];
const connected = (a: BoardSeat, b: BoardSeat, site: string) => {
const left = Object.entries(presenceByLocation(a))
@@ -137,6 +154,7 @@ export function quoteBattleBoard(g: BoardContext) {
if (
other.id === id ||
other.id === p.ally ||
(g.coalitionState !== undefined && areCoalitionAllies(players, g.coalitionState, id, other.id)) ||
!fighterCount(other, t.id) ||
g.order.indexOf(other.id) < g.order.indexOf(id)
)
@@ -156,6 +174,44 @@ export function quoteBattleBoard(g: BoardContext) {
}
return { released, battles };
}
/** Union the per-edge Basic separation obligations without inventing an age for
* an older pair when a third player joins. No forces are removed by this quote. */
export function quoteCoalitionAlliedSeparation(
g: BoardContext & { turn: number; remaining: readonly string[] },
player: string,
): string[] {
settledBoard(g);
validateBoardCoalitions(g);
requireBoard(whole(g.turn) && g.turn > 0 && Array.isArray(g.remaining) &&
new Set(g.remaining).size === g.remaining.length &&
g.remaining.every(id => g.order.includes(id)),
'Allied separation needs the current turn and remaining player order.');
const own = g.players.find(p => p.id === player);
requireBoard(own, 'Allied separation needs a seated player.');
const allies = g.coalitionState === undefined
? (own.ally ? [own.ally] : [])
: coalitionAllies(g.players, g.coalitionState, player);
const losses = new Set<string>();
for (const id of allies) {
const ally = g.players.find(p => p.id === id);
requireBoard(ally, 'Allied separation needs a seated ally.');
const edgeTurn = g.coalitionState === undefined ? null : coalitionEdgeTurn(g.players, g.coalitionState, player, id);
for (const site of quoteAlliedSeparation({
advanced: g.advanced, turn: g.turn, player, ally: id,
playerAllySinceTurn: g.coalitionState === undefined ? own.allySinceTurn : edgeTurn ?? undefined,
allySinceTurn: g.coalitionState === undefined ? ally.allySinceTurn : edgeTurn ?? undefined,
remaining: g.remaining,
territories: gameTerritories(g).map(t => ({
territory: t.id, polar: t.type === 'polar', ownPresent: presenceAt(own, t.id) > 0,
allyPresent: presenceAt(ally, t.id) > 0, ownAdvisors: isAdvisor(own, t.id),
allyAdvisors: isAdvisor(ally, t.id),
ecazCoexist: ecazOccupancyRelation(g.players, player, id, { kind: 'territory', id: t.id }) === 'ecazAlliance',
})),
})) losses.add(site);
}
return gameTerritories(g).map(t => t.id).filter(id => losses.has(id));
}
export type CollectionContext = Omit<BoardContext, 'players'> & {
players: readonly (BoardSeat & Pick<Player, 'spice' | 'elites'>)[];
spice: Readonly<Record<string, number>>;
+298
View File
@@ -0,0 +1,298 @@
/** Foundation for the optional three-member alliance. 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 };
export type CoalitionEdge = {
members: [string, string];
formedTurn: number | null;
/** Original scalar dates when the shared age is unknown; null means absent. */
legacySince?: [number | null, number | null];
};
export type CoalitionGroup = {
id: string;
members: string[];
/** Original group formation; null preserves an unknown legacy date. */
formedTurn: number | null;
edges: CoalitionEdge[];
};
export type CoalitionAdmission = {
event: string;
nexus: string;
turn: number;
groupId: string;
/** Snapshot binds the offer to the exact existing pair and its edge age. */
original: CoalitionGroup;
entrant: string;
proposer: string;
approvals: string[];
};
export type CoalitionState = {
version: 1;
revision: number;
groups: CoalitionGroup[];
proposal: CoalitionAdmission | null;
usedEvents: string[];
};
export type CoalitionTransition = {
state: CoalitionState;
/** Apply these with the new state in one authoritative action. */
allyUpdates: Record<string, string | null>;
/** null means delete/unknown, rather than manufacturing a formation date. */
allySinceUpdates: Record<string, number | null>;
};
export class CoalitionAllianceError extends Error {
constructor(message: string) { super(message); this.name = 'CoalitionAllianceError'; }
}
function check(condition: unknown, message: string): asserts condition {
if (!condition) throw new CoalitionAllianceError(message);
}
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.length <= 256 && value.trim() === value;
// JSON escaping can expand each 256-character seat ID to six characters.
const groupId = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value.length <= 4096 && value.trim() === value;
const turn = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) > 0;
const age = (value: unknown): value is number | null => value === null || turn(value);
const sorted = (values: readonly string[]) => [...values].sort();
const pair = (a: string, b: string): [string, string] => sorted([a, b]) as [string, string];
const same = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right);
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: [] };
}
function requireSeats(seats: readonly CoalitionSeat[]): void {
check(Array.isArray(seats) && seats.length >= 2 && seats.length <= 6 &&
seats.every(seat => record(seat) && id(seat.id) && (seat.ally === null || id(seat.ally)) &&
(seat.allySinceTurn === undefined || turn(seat.allySinceTurn))) &&
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 {
check(record(group) && groupId(group.id) && Array.isArray(group.members) &&
(group.members.length === 2 || group.members.length === 3) && 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.');
const expected: string[] = [];
for (let i = 0; i < group.members.length; i++)
for (let j = i + 1; j < group.members.length; j++) expected.push(JSON.stringify([group.members[i], group.members[j]]));
const actual = group.edges.map(edge => {
check(record(edge) && Array.isArray(edge.members) && edge.members.length === 2 &&
edge.members.every(id) && edge.members[0] !== edge.members[1] &&
same(edge.members, sorted(edge.members)) && age(edge.formedTurn) &&
(edge.legacySince === undefined || (Array.isArray(edge.legacySince) && edge.legacySince.length === 2 &&
edge.legacySince.every(age) && edge.formedTurn === null && edge.legacySince.some(date => date !== null))),
'Invalid coalition edge.');
return JSON.stringify(edge.members);
});
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 &&
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.');
}
/** Combine explicit groups with validated, unmodified reciprocal legacy pairs.
* Derived IDs are deterministic; no historical age is invented. */
export function coalitionGroups(seats: readonly CoalitionSeat[], value?: CoalitionState): CoalitionGroup[] {
requireSeats(seats);
check(value !== null, 'A coalition ledger cannot be null.');
const state = value ?? emptyCoalitionState();
requireState(state);
const seen = new Set<string>(), ids = new Set<string>();
for (const group of state.groups) {
requireGroup(group, seats);
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) {
check(group.members.every(member => seats.find(seat => seat.id === member)!.ally === null),
'Three-member 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,
'A saved two-member group must retain its reciprocal legacy pair.');
const edgeTurn = group.edges[0].formedTurn;
if (edgeTurn !== null) check(group.members.every(member =>
seats.find(seat => seat.id === member)!.allySinceTurn === edgeTurn),
'A saved pair must retain the original edge formation turn.');
const legacySince = group.edges[0].legacySince;
if (legacySince) check(group.members.every((member, index) =>
(seats.find(seat => seat.id === member)!.allySinceTurn ?? null) === legacySince[index]),
'A saved pair must retain each legacy scalar formation turn.');
}
}
const groups = state.groups.map(copy);
// Older explicit pair records may have a shared unknown date but still hold
// per-seat scalars. Capture them without inferring a shared edge date.
for (const group of groups) if (group.members.length === 2 && group.edges[0].formedTurn === null &&
group.edges[0].legacySince === undefined) {
const dates = group.members.map(member => seats.find(seat => seat.id === member)!.allySinceTurn ?? null);
if (dates.some(date => date !== null)) group.edges[0].legacySince = dates as [number | null, number | null];
}
for (const seat of seats) {
if (seat.ally === null || seen.has(seat.id)) continue;
const other = seats.find(candidate => candidate.id === seat.ally);
check(other && other.id !== seat.id && other.ally === seat.id && !seen.has(other.id),
'Legacy alliances must be seated and reciprocal.');
const members = pair(seat.id, other.id);
const formedTurn = seat.allySinceTurn !== undefined && seat.allySinceTurn === other.allySinceTurn
? seat.allySinceTurn : null;
const group: CoalitionGroup = { id: legacyId(members), members, formedTurn,
edges: [{ members: [...members], formedTurn,
...(formedTurn === null && seat.allySinceTurn !== other.allySinceTurn ? {
legacySince: members.map(member => seats.find(candidate => candidate.id === member)!.allySinceTurn ?? null) as [number | null, number | null],
} : {}) }] };
check(!ids.has(group.id), 'A legacy alliance conflicts with a saved group ID.');
ids.add(group.id); members.forEach(member => seen.add(member)); groups.push(group);
}
// A seat in a persisted group may not be the dangling target of an outsider.
for (const seat of seats) if (seat.ally !== null) {
const other = seats.find(candidate => candidate.id === seat.ally);
check(other && other.id !== seat.id && other.ally === seat.id,
'Legacy alliances must be seated and reciprocal.');
}
if (state.proposal) {
const offer = state.proposal;
check(id(offer.event) && id(offer.nexus) && turn(offer.turn) && groupId(offer.groupId) &&
id(offer.entrant) && id(offer.proposer) && Array.isArray(offer.approvals) &&
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) &&
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.');
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 &&
(group.formedTurn === null || group.formedTurn <= offer.turn) &&
group.edges.every(edge => (edge.formedTurn === null || edge.formedTurn <= offer.turn) &&
(!edge.legacySince || edge.legacySince.every(date => date === null || date <= offer.turn))),
'The saved invitation has invalid approvals or timing.');
}
return groups;
}
export function coalitionOf(seats: readonly CoalitionSeat[], state: CoalitionState | undefined, player: string): CoalitionGroup | null {
const groups = coalitionGroups(seats, state);
check(id(player) && seats.some(seat => seat.id === player), 'Choose a seated player.');
return groups.find(group => group.members.includes(player)) ?? null;
}
export function coalitionAllies(seats: readonly CoalitionSeat[], state: CoalitionState | undefined, player: string): string[] {
return coalitionOf(seats, state, player)?.members.filter(member => member !== player) ?? [];
}
export function areCoalitionAllies(seats: readonly CoalitionSeat[], state: CoalitionState | undefined, a: string, b: string): boolean {
check(id(b) && seats.some(seat => seat.id === b), 'Choose a seated player.');
return a !== b && coalitionAllies(seats, state, a).includes(b);
}
/** null means either no edge or an unknown legacy date; test ally membership separately. */
export function coalitionEdgeTurn(seats: readonly CoalitionSeat[], state: CoalitionState | undefined, a: string, b: string): number | null {
if (!areCoalitionAllies(seats, state, a, b)) return null;
return coalitionOf(seats, state, a)!.edges.find(edge => same(edge.members, pair(a, b)))!.formedTurn;
}
export type CoalitionAdmissionRequest = { event: string; nexus: string; turn: number; groupId: string; entrant: string;
proposer: string; expectedRevision: number };
export type CoalitionAdmissionAnswer = { event: string; nexus: string; turn: number; actor: string; accept: boolean; expectedRevision: number };
function currentState(seats: readonly CoalitionSeat[], value: CoalitionState | undefined, expectedRevision: number) {
check(value !== null, 'A coalition ledger cannot be null.');
const state = value ?? emptyCoalitionState();
const groups = coalitionGroups(seats, state);
check(Number.isSafeInteger(expectedRevision) && expectedRevision === state.revision &&
expectedRevision < Number.MAX_SAFE_INTEGER, 'The coalition revision is stale or exhausted.');
return {state, groups};
}
function unchanged(state: CoalitionState): CoalitionTransition {
return {state, allyUpdates: {}, allySinceUpdates: {}};
}
/** A proposer is either member of the pair 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 {
check(record(request) && id(request.event) && id(request.nexus) && turn(request.turn) &&
groupId(request.groupId) && id(request.entrant) && id(request.proposer), 'Choose an event-bound coalition invitation.');
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) &&
!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.');
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. */
export function answerCoalitionAdmission(seats: readonly CoalitionSeat[], value: CoalitionState | undefined,
answer: CoalitionAdmissionAnswer): CoalitionTransition {
check(record(answer) && id(answer.event) && id(answer.nexus) && turn(answer.turn) &&
id(answer.actor) && typeof answer.accept === 'boolean', 'Choose an event-bound alliance answer.');
const {state, groups} = currentState(seats, value, answer.expectedRevision);
const offer = state.proposal;
check(offer && id(offer.event) && id(offer.nexus) && turn(offer.turn) && groupId(offer.groupId) &&
id(offer.entrant) && id(offer.proposer) && Array.isArray(offer.approvals) &&
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) &&
!groups.some(candidate => candidate.members.includes(offer.entrant)) &&
seats.some(seat => seat.id === offer.entrant), 'The original pair 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)) &&
answer.event === offer.event && answer.nexus === offer.nexus && answer.turn === offer.turn &&
participants.includes(answer.actor), 'This invitation answer is stale or belongs to another player.');
if (!answer.accept)
return unchanged({ ...copy(state), revision: state.revision + 1, proposal: null });
check(!offer.approvals.includes(answer.actor), 'This player already approved the invitation.');
const approvals = [...offer.approvals, answer.actor];
if (approvals.length < participants.length)
return unchanged({ ...copy(state), revision: state.revision + 1, proposal: {...copy(offer), approvals} });
const members = sorted(participants);
const edges: CoalitionEdge[] = [];
for (let i = 0; i < members.length; i++) for (let j = i + 1; j < members.length; j++) {
const membersPair = pair(members[i], members[j]);
const old = group.edges.find(edge => same(edge.members, membersPair));
edges.push(old ? copy(old) : {members: membersPair, formedTurn: offer.turn});
}
const nextGroup: CoalitionGroup = { id: group.id, members, formedTurn: group.formedTurn, edges };
const nextGroups = [...state.groups.filter(item => item.id !== group.id).map(copy), nextGroup];
return {state: {...copy(state), revision: state.revision + 1, groups: nextGroups, proposal: null},
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. */
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.');
const {state, groups} = currentState(seats, value, request.expectedRevision);
check(state.proposal === null, 'Finish or decline the pending invitation first.');
const group = groups.find(item => item.members.includes(request.actor));
check(group, 'This player is not in an alliance.');
const remaining = group.members.filter(member => member !== request.actor);
const retained = remaining.length === 2 ? group.edges.find(edge => same(edge.members, remaining)) : undefined;
const nextGroups = state.groups.filter(item => item.id !== group.id).map(copy);
const allyEntries: [string, string | null][] = [[request.actor, null]];
const ageEntries: [string, number | null][] = [[request.actor, null]];
if (retained) {
nextGroups.push({id: group.id, members: remaining, formedTurn: retained.formedTurn, edges: [copy(retained)]});
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 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)};
}
+2 -2
View File
@@ -31699,7 +31699,7 @@ function futureBattleLeaderTruthAnswers(g: Game, p: Player, q: FutureBattleLeade
...(g.bidPromises ?? []), ...(g.phaseBidPromises ?? []), ...(g.richeseBidPromises ?? []),
...(g.earlyRicheseBidPromises ?? []), ...(g.alliancePromises ?? []), ...(g.allianceExpressionPromises ?? []),
];
if (q.turn !== g.turn || q.target !== p.id || (g.phase !== 4 && g.phase !== 5) ||
if (q.turn !== g.turn || q.target !== p.id || ![2, 4, 5].includes(g.phase) ||
(g.futureBattleLeaderPromises ?? []).some(row => row.turn === g.turn && row.player === p.id &&
(row.status === 'waiting' || row.status === 'bound')) ||
otherPromises.some(row => row.player === p.id && row.turn === g.turn && !row.fulfilled && !row.released) ||
@@ -45807,7 +45807,7 @@ export function viewGame(state: Game, id: string, now?: number) {
truthFutureBattleLeaderAnswers: g.truthtrance?.stage === 'answer' &&
g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'futureBattleLeader'
? futureBattleLeaderTruthAnswers(g, me, g.truthtrance.question) : null,
truthFutureBattleLeaderTargets: g.status === 'playing' && !g.advanced && (g.phase === 4 || g.phase === 5) &&
truthFutureBattleLeaderTargets: g.status === 'playing' && !g.advanced && [2, 4, 5].includes(g.phase) &&
!g.expansions.length && !g.techTokens && !g.strongholdCards && !g.nexusCards && !g.homeworlds &&
!g.leaderSkills && !g.discoveryEnabled && !g.ecazTreachery && !g.battle && !g.response &&
!g.decision && !g.phaseOpening && !g.pendingRevival && !g.pendingKarama && !g.pendingTreacheryDiscard
+3 -4
View File
@@ -2,7 +2,7 @@ import { fighterCount } from './advisors';
import { settledBoard, type BoardContext } from './board-resolution-quote';
import { ecazOccupancyIdentity } from './ecaz-occupy';
import { presenceAt } from './force-presence';
import { VictoryProgressError } from './victory-progress';
import { victoryMembers, VictoryProgressError } from './victory-progress';
const PROTECTED_SIETCHES = ['sietch_tabr', 'habbanya_ridge_sietch'] as const;
@@ -39,13 +39,12 @@ export function fremenSpecialVictory(
id: 'polar_sink',
});
if (g.coalitionState !== undefined) victoryMembers(g, players[0].id);
const fremen = players.find((player) => player.faction === 'fremen');
if (!fremen) return null;
const ally = players.find((player) => player.id === fremen.ally);
const ecaz = ally?.faction === 'ecaz' ? ally : null;
const members = g.order.filter(
(player) => player === fremen.id || player === ally?.id,
);
const members = victoryMembers(g, fremen.id);
const sietches = PROTECTED_SIETCHES.map((territory) => {
const ecazCooccupation = !!(
+18 -6
View File
@@ -3,6 +3,7 @@ import { territory } from './board';
import { fighterCount } from './advisors';
import { presenceAt, type ForcePresence } from './force-presence';
import { ecazOccupancyIdentity, ecazOccupancyRelation } from './ecaz-occupy';
import { coalitionAllies, coalitionGroups, type CoalitionState } from './coalition-alliance';
/** Public board information only: a concealed No-Field contributes presence,
* never its hidden denomination. Advisor groups do not fill strongholds. */
@@ -10,6 +11,7 @@ export type OccupancySeat = ForcePresence & {
id: string;
faction: FactionId;
ally?: string | null;
allySinceTurn?: number;
advisors?: Readonly<Record<string, { lockedTurn?: number }>>;
};
@@ -38,21 +40,31 @@ export function territoryEntryBlock(
to: string,
advisors = false,
temporaryAlliedShipment = false,
coalitionState?: CoalitionState,
): string | null {
// Validate the complete identity roster even for an empty destination.
ecazOccupancyIdentity(players, playerId, { kind: 'territory', id: to });
if (advisors) return null;
const player = players.find((p) => p.id === playerId)!;
const ally = players.find((p) => p.id === player.ally);
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))
throw new Error('Three-member board rules do not yet support Ecaz.');
}
if (advisors) return null;
const allies = coalitionState === undefined ? (player.ally ? [player.ally] : [])
: coalitionAllies(seats, coalitionState, playerId);
if (
!temporaryAlliedShipment &&
to !== 'polar_sink' &&
ally &&
presenceAt(ally, to) > 0 &&
ecazOccupancyRelation(players, playerId, ally.id, {
allies.some(id => {
const ally = players.find(p => p.id === id);
return !!ally && presenceAt(ally, to) > 0 &&
ecazOccupancyRelation(players, playerId, id, {
kind: 'territory',
id: to,
}) !== 'ecazAlliance'
}) !== 'ecazAlliance';
})
)
return 'You cannot enter a territory occupied by your ally.';
if (
+1 -1
View File
@@ -3305,7 +3305,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
summary: 'Combine printed Karama shipment pricing and current-lot costs, original-window Weather/Atomics/Harvester plays, native Voice/Prescience declarations, future printed Hajr/Ghola plays, current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
steps: [
'In a quiet Basic Revival or Shipment and Movement window, ask whether a player will use a named native leader in their first later battle this turn, if one occurs. The answer waits for an actual battle; if none occurs, it expires. This separate provisional question currently supports classic factions without optional modules and uses only currently available leaders for its answer.',
'In a quiet Basic Charity, Revival or Shipment and Movement window, ask whether a player will use a named native leader in their first later battle this turn, if one occurs. The answer waits for an actual battle; if none occurs, it expires. This separate provisional question currently supports classic factions without optional modules and uses only currently available leaders for its answer.',
'Choose Combine printed card plays, native battle powers and this turn’s other actions. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of original-window card plays, native power declarations, printed Hajr/Ghola use, current battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.',
'A final-alliance clause names another current participant during this original quiet native Nexus. Existing invitations and reciprocal formation remain authoritative. Offers, temporary membership and your own Ready do not settle the clause; final actual membership is fixed only when the Nexus closes.',
'Whole No can permit a temporary paid pair and later require a break. Own Ready must preserve a completion after final membership is fixed; remaining payment or physical-action alternatives keep their later deadlines. Definite answers, including constants, uniformly reopen own Nexus readiness; later ordinary payments require confirmation again.',
+2 -2
View File
@@ -602,11 +602,11 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
check(Object.keys(v).sort().join(',') === 'kind,leader,target,turn' &&
v.turn === g.turn && typeof v.leader === 'string' &&
leaders(target.faction).some(leader => leader.id === v.leader) &&
g.status === 'playing' && !g.advanced && (g.phase === 4 || g.phase === 5) && !g.expansions.length &&
g.status === 'playing' && !g.advanced && [2, 4, 5].includes(g.phase) && !g.expansions.length &&
!g.techTokens && !g.strongholdCards && !g.nexusCards && !g.homeworlds &&
!g.leaderSkills && !g.discoveryEnabled && !g.ecazTreachery && !g.battle &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingRevival && !g.pendingKarama && !g.pendingTreacheryDiscard,
'Choose a native leader in a quiet Basic Revival or Shipment and Movement window.');
'Choose a native leader in a quiet Basic Charity, Revival or Shipment and Movement window.');
return { kind: 'futureBattleLeader', target: v.target, turn: g.turn, leader: v.leader };
}
if (v.kind === 'battlePlan') {
+3 -3
View File
@@ -48,15 +48,15 @@ function validExtra(value: unknown): value is number {
value >= 0 && value <= MAX_VICTORY_EXTRA_STRONGHOLDS;
}
/** Normalize duplicates from the two rows representing one reciprocal alliance.
/** 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 <= 2 &&
requireContinuation(ids(side) && side.length >= 1 && side.length <= 3 &&
new Set(side).size === side.length && side.every(player => order.includes(player)),
'Every qualifying side must contain one or two distinct seated players.');
'Every qualifying side must contain one through three distinct seated players.');
const members = order.filter(player => side.includes(player));
if (!unique.some(existing => same(existing, members))) unique.push(members);
}
+22 -4
View File
@@ -11,6 +11,7 @@ import { TECH_TOKENS } from './tech-tokens';
import { isDiscoveryLocationId, JACURUTU_SIETCH } from './discoveries';
import type { TwoPlayerControllers } from './two-player-controllers';
import { allianceVictoryTarget } from './alliance-victory-option';
import { coalitionGroups } from './coalition-alliance';
export type StrongholdProgress = {
player: string;
@@ -37,6 +38,22 @@ function requireProgress(
if (!condition) throw new VictoryProgressError(message);
}
/** Ordered side membership for the bounded coalition victory quote. Three-member
* Advanced and Ecaz combinations remain unavailable until their board rules
* and faction interactions are integrated. No game mode is enabled here. */
export function victoryMembers(g: BoardContext, player: string): string[] {
const seat = g.players.find(candidate => candidate.id === player);
requireProgress(seat, 'Victory needs a seated player.');
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 &&
(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));
return g.order.filter(member => member === player || !!group?.members.includes(member));
}
/** Discovery locations use stronghold shipping and occupancy rules, but only
* Jacurutu Sietch counts toward the normal stronghold victory. */
export function countsForStrongholdVictory(id: string) {
@@ -65,6 +82,8 @@ export function strongholdProgress(
const extra = g.victoryExtraStrongholds ?? 0;
requireProgress(Number.isSafeInteger(extra) && extra >= 0 && extra <= 200,
'The agreed extra stronghold target must be a safe nonnegative count.');
requireProgress(g.shortAllianceVictory === undefined || typeof g.shortAllianceVictory === 'boolean',
'Choose whether the shorter alliance victory option is enabled.');
// 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, {
@@ -102,9 +121,7 @@ export function strongholdProgress(
);
const progress = g.order.map((id) => {
const p = players.find((p) => p.id === id)!;
const members = g.order.filter(
(member) => member === id || member === p.ally,
);
const members = victoryMembers(g, id);
const partner = players.find((other) => other.id === p.ally);
const ecazPair =
!!partner && (p.faction === 'ecaz' || partner.faction === 'ecaz');
@@ -136,7 +153,8 @@ export function strongholdProgress(
TECH_TOKENS.every(({ id }) => tech[id].owner === member),
);
const target = allianceVictoryTarget(
players.map((player) => player.id), g.twoPlayerControllers, !!partner, g.shortAllianceVictory,
players.map((player) => player.id), g.twoPlayerControllers, members.length > 1,
members.length === 3 ? false : g.shortAllianceVictory,
) + extra;
return {
player: id,
+7 -3
View File
@@ -15,6 +15,7 @@ import { TECH_TOKENS, techStronghold } from './tech-tokens';
import {
countsForStrongholdVictory,
strongholdProgress,
victoryMembers,
VictoryProgressError,
} from './victory-progress';
import {
@@ -210,10 +211,13 @@ export function quoteVictory(g: VictoryContext, ecazContext?: EcazHomeworldVicto
const fremen = players.find((p) => p.faction === 'fremen');
const guild = players.find((p) => p.faction === 'guild');
if (fremen && fremenSpecialVictory(g)?.qualifies)
winner = fremen.ally ? [fremen.id, fremen.ally] : [fremen.id];
else if (guild) winner = guild.ally ? [guild.id, guild.ally] : [guild.id];
winner = g.coalitionState === undefined
? (fremen.ally ? [fremen.id, fremen.ally] : [fremen.id]) : victoryMembers(g, fremen.id);
else if (guild) winner = g.coalitionState === undefined
? (guild.ally ? [guild.id, guild.ally] : [guild.id]) : victoryMembers(g, guild.id);
else if (fremen)
winner = fremen.ally ? [fremen.id, fremen.ally] : [fremen.id];
winner = g.coalitionState === undefined
? (fremen.ally ? [fremen.id, fremen.ally] : [fremen.id]) : victoryMembers(g, fremen.id);
else {
const scores = players.map((p) => ({
id: p.id,