Prototype charity Truthtrance and coalition rule foundations
Verify and publish checkpoint container / container (push) Successful in 10m14s
Verify and publish checkpoint container / container (push) Successful in 10m14s
This commit is contained in:
1 parent
e896570f07
commit
cea310b030
17 files changed
+1024
-35
No files matched your search
+28
-5
@@ -16,6 +16,26 @@ open mode gates.
|
|||||||
|
|
||||||
|
|
||||||
## Current checkpoint and work
|
## Current checkpoint and work
|
||||||
|
**In progress: printed three-member alliance options.** Establish a canonical
|
||||||
|
group and unanimous admission/voluntary leaving model for the printed
|
||||||
|
three- and four-stronghold variants. The tested, unexposed foundation handles
|
||||||
|
membership, admission, per-edge formation age, board quotes and victory quotes;
|
||||||
|
the existing shorter two-member target does not apply to three-member groups.
|
||||||
|
A usable preview must connect every group member to native
|
||||||
|
alliance consent, movement and battle restrictions, funding, faction ally
|
||||||
|
benefits, victory and minimal legal AI. Consent, leaving behavior and shared
|
||||||
|
benefit allocation are provisional online conventions. No group option is
|
||||||
|
enabled or claimed complete by the standalone foundation. Allied shipment
|
||||||
|
receipts and separation still assume a single ally, as do other engine paths.
|
||||||
|
The combined related batch passes 189 cases, with types and lint passing.
|
||||||
|
|
||||||
|
**Conditional future-battle Truthtrance in Basic Charity — 28 October.
|
||||||
|
Prototyped / Partial.** The named-leader question also works in a quiet
|
||||||
|
Charity window, retaining its current-leader witness, first actual battle and
|
||||||
|
no-battle expiry. Six related offline files pass 34 cases including native
|
||||||
|
Charity continuation and both answers. Other phase windows, factions and
|
||||||
|
promise combinations remain separately guarded.
|
||||||
|
|
||||||
**Optional three-stronghold two-member alliance — 28 October. Prototyped /
|
**Optional three-stronghold two-member alliance — 28 October. Prototyped /
|
||||||
Partial.** The host can select the printed shorter target; changes clear human
|
Partial.** The host can select the printed shorter target; changes clear human
|
||||||
readiness and the normal target remains the default. Public progress and the
|
readiness and the normal target remains the default. Public progress and the
|
||||||
@@ -74,22 +94,25 @@ parents, optional modules and deployed acceptance remain open.
|
|||||||
|
|
||||||
**In progress: Gitea migration — 28 October.** The local `origin` now tracks
|
**In progress: Gitea migration — 28 October.** The local `origin` now tracks
|
||||||
`https://git.juiced.computer/admin/Dune`. A repository-scoped deploy key pushes
|
`https://git.juiced.computer/admin/Dune`. A repository-scoped deploy key pushes
|
||||||
over verified SSH on the local network; `main` reached `71009d7`. A dedicated
|
over verified SSH on the local network. A dedicated
|
||||||
repository-scoped TrueNAS runner is active with its own Docker daemon and a
|
repository-scoped TrueNAS runner is active with its own Docker daemon and a
|
||||||
package-write Actions secret is configured for Gitea's registry. The first
|
package-write Actions secret is configured for Gitea's registry. The first
|
||||||
Gitea-native job built the application but its isolated HTTP check failed:
|
Gitea-native job built the application but its isolated HTTP check failed:
|
||||||
job and test-container loopback addresses are in separate nested Docker
|
job and test-container loopback addresses are in separate nested Docker
|
||||||
network namespaces. With user approval the dedicated runner now shares its
|
network namespaces. With user approval the dedicated runner now shares its
|
||||||
own network namespace with its jobs, while retaining its separate Docker
|
own network namespace with its jobs, while retaining its separate Docker
|
||||||
daemon; the runner reconnected. A fresh end-to-end build and registry
|
daemon; the runner reconnected. Build `02695ae` passed container startup and
|
||||||
publication remain unverified. Build `02695ae` passed container startup and
|
|
||||||
25 administrator checks, then failed one of 69 HTTP cases: the mixed Richese
|
25 administrator checks, then failed one of 69 HTTP cases: the mixed Richese
|
||||||
Homeworld fixture allowed an unrelated earlier automated shipment before its
|
Homeworld fixture allowed an unrelated earlier automated shipment before its
|
||||||
scripted two-force move. The fixture now suppresses those unrelated turns and
|
scripted two-force move. The fixture now suppresses those unrelated turns and
|
||||||
asserts the zero-to-two transition; its focused HTTP case passes locally. The
|
asserts the zero-to-two transition; its focused HTTP case passes locally. The
|
||||||
CI log did not preserve the precise earlier action, so the exact cause of its
|
CI log did not preserve the precise earlier action, so the exact cause of its
|
||||||
extra visitor remains unproven. Existing NAS deployment and its persistent
|
extra visitor remains unproven. Gitea run 4 subsequently passed all 69 HTTP
|
||||||
storage remain untouched.
|
cases and published verified image `sha-8fb9f591060ba501ff9def8e32f8aa6e1786fc7d`.
|
||||||
|
Run 5 for `e896570` also passed its 25 administrator checks, all 69 HTTP
|
||||||
|
cases, restart/replacement and origin verification, and published both its
|
||||||
|
revision-specific image and `checkpoint`. Deployment remains unverified.
|
||||||
|
Existing NAS deployment and its persistent storage remain untouched.
|
||||||
|
|
||||||
**Semuta before Basic Moritani ally retention — 28 October. Prototyped /
|
**Semuta before Basic Moritani ally retention — 28 October. Prototyped /
|
||||||
Partial.** In the bare Basic Semuta preview, mandatory loser cards form an
|
Partial.** In the bare Basic Semuta preview, mandatory loser cards form an
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Conditional next-battle leader question — 28 October 2026
|
## Conditional next-battle leader question — 28 October 2026
|
||||||
|
|
||||||
**Prototyped / Partial.** In a quiet Basic Revival or Shipment and Movement window with
|
**Prototyped / Partial.** In a quiet Basic Charity, Revival or Shipment and Movement window with
|
||||||
classic factions and no optional modules, a player can ask whether another
|
classic factions and no optional modules, a player can ask whether another
|
||||||
player will use a named native leader in that player's first later battle this
|
player will use a named native leader in that player's first later battle this
|
||||||
turn, if one occurs. A definite answer creates a waiting receipt; it does not
|
turn, if one occurs. A definite answer creates a waiting receipt; it does not
|
||||||
@@ -13,7 +13,7 @@ ends. An independently impossible answer is released by the existing battle
|
|||||||
promise reconciliation. Another structured action answer for that respondent
|
promise reconciliation. Another structured action answer for that respondent
|
||||||
is guarded while the conditional answer is active; broader promise composition,
|
is guarded while the conditional answer is active; broader promise composition,
|
||||||
expansion factions, optional modules and Advanced are not supported here.
|
expansion factions, optional modules and Advanced are not supported here.
|
||||||
During Revival, definite answers use currently available native leaders or
|
During Charity and Revival, definite answers use currently available native leaders or
|
||||||
Cheap Heroes; they do not forecast a future revival or spend its resources.
|
Cheap Heroes; they do not forecast a future revival or spend its resources.
|
||||||
|
|
||||||
The publisher's [November 2020 FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
|
The publisher's [November 2020 FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { Player } from './engine';
|
import type { Player } from './engine';
|
||||||
import { advisorStanceAfterSettlement } from './advisor-automatic-flip';
|
import { advisorStanceAfterSettlement } from './advisor-automatic-flip';
|
||||||
import type { ForcePresence } from './force-presence';
|
import type { ForcePresence } from './force-presence';
|
||||||
import { presenceByLocation } from './force-presence';
|
import { presenceAt, presenceByLocation } from './force-presence';
|
||||||
import { isHomeworldNoFieldLocation } from './richese-no-field';
|
import { isHomeworldNoFieldLocation } from './richese-no-field';
|
||||||
import { fighterCount, isAdvisor } from './advisors';
|
import { fighterCount, isAdvisor } from './advisors';
|
||||||
import { quoteAidRefunds, PhaseResourceError } from './phase-resource-quote';
|
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 type { SharedSpiceLot } from './ecaz-spice-allocation';
|
||||||
import {
|
import {
|
||||||
gameDistance,
|
gameDistance,
|
||||||
@@ -23,7 +25,7 @@ function requireBoard(condition: unknown, message: string): asserts condition {
|
|||||||
}
|
}
|
||||||
const whole = (n: unknown): n is number =>
|
const whole = (n: unknown): n is number =>
|
||||||
Number.isSafeInteger(n) && (n as number) >= 0;
|
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;
|
ForcePresence;
|
||||||
export type BoardContext = MobileBoard & {
|
export type BoardContext = MobileBoard & {
|
||||||
advanced: boolean;
|
advanced: boolean;
|
||||||
@@ -32,7 +34,20 @@ export type BoardContext = MobileBoard & {
|
|||||||
storm: number;
|
storm: number;
|
||||||
order: readonly string[];
|
order: readonly string[];
|
||||||
players: readonly BoardSeat[];
|
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 };
|
export type AdvisorRelease = { player: string; territory: string };
|
||||||
function validKey(g: MobileBoard, key: string) {
|
function validKey(g: MobileBoard, key: string) {
|
||||||
const p = splitLocation(key);
|
const p = splitLocation(key);
|
||||||
@@ -95,6 +110,7 @@ export function settledBoard(g: BoardContext) {
|
|||||||
id: p.id,
|
id: p.id,
|
||||||
faction: p.faction,
|
faction: p.faction,
|
||||||
ally: p.ally,
|
ally: p.ally,
|
||||||
|
allySinceTurn: p.allySinceTurn,
|
||||||
forces: p.forces,
|
forces: p.forces,
|
||||||
noField: p.noField,
|
noField: p.noField,
|
||||||
advisors,
|
advisors,
|
||||||
@@ -111,6 +127,7 @@ export type BattleBoardBattle = {
|
|||||||
};
|
};
|
||||||
export function quoteBattleBoard(g: BoardContext) {
|
export function quoteBattleBoard(g: BoardContext) {
|
||||||
const { players, released } = settledBoard(g);
|
const { players, released } = settledBoard(g);
|
||||||
|
validateBoardCoalitions(g);
|
||||||
const battles: BattleBoardBattle[] = [];
|
const battles: BattleBoardBattle[] = [];
|
||||||
const connected = (a: BoardSeat, b: BoardSeat, site: string) => {
|
const connected = (a: BoardSeat, b: BoardSeat, site: string) => {
|
||||||
const left = Object.entries(presenceByLocation(a))
|
const left = Object.entries(presenceByLocation(a))
|
||||||
@@ -137,6 +154,7 @@ export function quoteBattleBoard(g: BoardContext) {
|
|||||||
if (
|
if (
|
||||||
other.id === id ||
|
other.id === id ||
|
||||||
other.id === p.ally ||
|
other.id === p.ally ||
|
||||||
|
(g.coalitionState !== undefined && areCoalitionAllies(players, g.coalitionState, id, other.id)) ||
|
||||||
!fighterCount(other, t.id) ||
|
!fighterCount(other, t.id) ||
|
||||||
g.order.indexOf(other.id) < g.order.indexOf(id)
|
g.order.indexOf(other.id) < g.order.indexOf(id)
|
||||||
)
|
)
|
||||||
@@ -156,6 +174,44 @@ export function quoteBattleBoard(g: BoardContext) {
|
|||||||
}
|
}
|
||||||
return { released, battles };
|
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'> & {
|
export type CollectionContext = Omit<BoardContext, 'players'> & {
|
||||||
players: readonly (BoardSeat & Pick<Player, 'spice' | 'elites'>)[];
|
players: readonly (BoardSeat & Pick<Player, 'spice' | 'elites'>)[];
|
||||||
spice: Readonly<Record<string, number>>;
|
spice: Readonly<Record<string, number>>;
|
||||||
|
|||||||
@@ -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
@@ -31699,7 +31699,7 @@ function futureBattleLeaderTruthAnswers(g: Game, p: Player, q: FutureBattleLeade
|
|||||||
...(g.bidPromises ?? []), ...(g.phaseBidPromises ?? []), ...(g.richeseBidPromises ?? []),
|
...(g.bidPromises ?? []), ...(g.phaseBidPromises ?? []), ...(g.richeseBidPromises ?? []),
|
||||||
...(g.earlyRicheseBidPromises ?? []), ...(g.alliancePromises ?? []), ...(g.allianceExpressionPromises ?? []),
|
...(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 &&
|
(g.futureBattleLeaderPromises ?? []).some(row => row.turn === g.turn && row.player === p.id &&
|
||||||
(row.status === 'waiting' || row.status === 'bound')) ||
|
(row.status === 'waiting' || row.status === 'bound')) ||
|
||||||
otherPromises.some(row => row.player === p.id && row.turn === g.turn && !row.fulfilled && !row.released) ||
|
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' &&
|
truthFutureBattleLeaderAnswers: g.truthtrance?.stage === 'answer' &&
|
||||||
g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'futureBattleLeader'
|
g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'futureBattleLeader'
|
||||||
? futureBattleLeaderTruthAnswers(g, me, g.truthtrance.question) : null,
|
? 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.expansions.length && !g.techTokens && !g.strongholdCards && !g.nexusCards && !g.homeworlds &&
|
||||||
!g.leaderSkills && !g.discoveryEnabled && !g.ecazTreachery && !g.battle && !g.response &&
|
!g.leaderSkills && !g.discoveryEnabled && !g.ecazTreachery && !g.battle && !g.response &&
|
||||||
!g.decision && !g.phaseOpening && !g.pendingRevival && !g.pendingKarama && !g.pendingTreacheryDiscard
|
!g.decision && !g.phaseOpening && !g.pendingRevival && !g.pendingKarama && !g.pendingTreacheryDiscard
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { fighterCount } from './advisors';
|
|||||||
import { settledBoard, type BoardContext } from './board-resolution-quote';
|
import { settledBoard, type BoardContext } from './board-resolution-quote';
|
||||||
import { ecazOccupancyIdentity } from './ecaz-occupy';
|
import { ecazOccupancyIdentity } from './ecaz-occupy';
|
||||||
import { presenceAt } from './force-presence';
|
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;
|
const PROTECTED_SIETCHES = ['sietch_tabr', 'habbanya_ridge_sietch'] as const;
|
||||||
|
|
||||||
@@ -39,13 +39,12 @@ export function fremenSpecialVictory(
|
|||||||
id: 'polar_sink',
|
id: 'polar_sink',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (g.coalitionState !== undefined) victoryMembers(g, players[0].id);
|
||||||
const fremen = players.find((player) => player.faction === 'fremen');
|
const fremen = players.find((player) => player.faction === 'fremen');
|
||||||
if (!fremen) return null;
|
if (!fremen) return null;
|
||||||
const ally = players.find((player) => player.id === fremen.ally);
|
const ally = players.find((player) => player.id === fremen.ally);
|
||||||
const ecaz = ally?.faction === 'ecaz' ? ally : null;
|
const ecaz = ally?.faction === 'ecaz' ? ally : null;
|
||||||
const members = g.order.filter(
|
const members = victoryMembers(g, fremen.id);
|
||||||
(player) => player === fremen.id || player === ally?.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
const sietches = PROTECTED_SIETCHES.map((territory) => {
|
const sietches = PROTECTED_SIETCHES.map((territory) => {
|
||||||
const ecazCooccupation = !!(
|
const ecazCooccupation = !!(
|
||||||
|
|||||||
+18
-6
@@ -3,6 +3,7 @@ import { territory } from './board';
|
|||||||
import { fighterCount } from './advisors';
|
import { fighterCount } from './advisors';
|
||||||
import { presenceAt, type ForcePresence } from './force-presence';
|
import { presenceAt, type ForcePresence } from './force-presence';
|
||||||
import { ecazOccupancyIdentity, ecazOccupancyRelation } from './ecaz-occupy';
|
import { ecazOccupancyIdentity, ecazOccupancyRelation } from './ecaz-occupy';
|
||||||
|
import { coalitionAllies, coalitionGroups, type CoalitionState } from './coalition-alliance';
|
||||||
|
|
||||||
/** Public board information only: a concealed No-Field contributes presence,
|
/** Public board information only: a concealed No-Field contributes presence,
|
||||||
* never its hidden denomination. Advisor groups do not fill strongholds. */
|
* never its hidden denomination. Advisor groups do not fill strongholds. */
|
||||||
@@ -10,6 +11,7 @@ export type OccupancySeat = ForcePresence & {
|
|||||||
id: string;
|
id: string;
|
||||||
faction: FactionId;
|
faction: FactionId;
|
||||||
ally?: string | null;
|
ally?: string | null;
|
||||||
|
allySinceTurn?: number;
|
||||||
advisors?: Readonly<Record<string, { lockedTurn?: number }>>;
|
advisors?: Readonly<Record<string, { lockedTurn?: number }>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,21 +40,31 @@ export function territoryEntryBlock(
|
|||||||
to: string,
|
to: string,
|
||||||
advisors = false,
|
advisors = false,
|
||||||
temporaryAlliedShipment = false,
|
temporaryAlliedShipment = false,
|
||||||
|
coalitionState?: CoalitionState,
|
||||||
): string | null {
|
): string | null {
|
||||||
// Validate the complete identity roster even for an empty destination.
|
// Validate the complete identity roster even for an empty destination.
|
||||||
ecazOccupancyIdentity(players, playerId, { kind: 'territory', id: to });
|
ecazOccupancyIdentity(players, playerId, { kind: 'territory', id: to });
|
||||||
if (advisors) return null;
|
|
||||||
const player = players.find((p) => p.id === playerId)!;
|
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 (
|
if (
|
||||||
!temporaryAlliedShipment &&
|
!temporaryAlliedShipment &&
|
||||||
to !== 'polar_sink' &&
|
to !== 'polar_sink' &&
|
||||||
ally &&
|
allies.some(id => {
|
||||||
presenceAt(ally, to) > 0 &&
|
const ally = players.find(p => p.id === id);
|
||||||
ecazOccupancyRelation(players, playerId, ally.id, {
|
return !!ally && presenceAt(ally, to) > 0 &&
|
||||||
|
ecazOccupancyRelation(players, playerId, id, {
|
||||||
kind: 'territory',
|
kind: 'territory',
|
||||||
id: to,
|
id: to,
|
||||||
}) !== 'ecazAlliance'
|
}) !== 'ecazAlliance';
|
||||||
|
})
|
||||||
)
|
)
|
||||||
return 'You cannot enter a territory occupied by your ally.';
|
return 'You cannot enter a territory occupied by your ally.';
|
||||||
if (
|
if (
|
||||||
|
|||||||
+1
-1
@@ -3305,7 +3305,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
|
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.',
|
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: [
|
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.',
|
'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.',
|
'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.',
|
'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
@@ -602,11 +602,11 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
|||||||
check(Object.keys(v).sort().join(',') === 'kind,leader,target,turn' &&
|
check(Object.keys(v).sort().join(',') === 'kind,leader,target,turn' &&
|
||||||
v.turn === g.turn && typeof v.leader === 'string' &&
|
v.turn === g.turn && typeof v.leader === 'string' &&
|
||||||
leaders(target.faction).some(leader => leader.id === v.leader) &&
|
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.techTokens && !g.strongholdCards && !g.nexusCards && !g.homeworlds &&
|
||||||
!g.leaderSkills && !g.discoveryEnabled && !g.ecazTreachery && !g.battle &&
|
!g.leaderSkills && !g.discoveryEnabled && !g.ecazTreachery && !g.battle &&
|
||||||
!g.response && !g.decision && !g.phaseOpening && !g.pendingRevival && !g.pendingKarama && !g.pendingTreacheryDiscard,
|
!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 };
|
return { kind: 'futureBattleLeader', target: v.target, turn: g.turn, leader: v.leader };
|
||||||
}
|
}
|
||||||
if (v.kind === 'battlePlan') {
|
if (v.kind === 'battlePlan') {
|
||||||
|
|||||||
@@ -48,15 +48,15 @@ function validExtra(value: unknown): value is number {
|
|||||||
value >= 0 && value <= MAX_VICTORY_EXTRA_STRONGHOLDS;
|
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. */
|
* Distinct sides must be disjoint and contain only actual seated players. */
|
||||||
function canonicalSides(order: readonly string[], sides: readonly (readonly string[])[]): string[][] {
|
function canonicalSides(order: readonly string[], sides: readonly (readonly string[])[]): string[][] {
|
||||||
requireContinuation(Array.isArray(sides), 'Retain the qualifying victory sides.');
|
requireContinuation(Array.isArray(sides), 'Retain the qualifying victory sides.');
|
||||||
const unique: string[][] = [];
|
const unique: string[][] = [];
|
||||||
for (const side of sides) {
|
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)),
|
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));
|
const members = order.filter(player => side.includes(player));
|
||||||
if (!unique.some(existing => same(existing, members))) unique.push(members);
|
if (!unique.some(existing => same(existing, members))) unique.push(members);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { TECH_TOKENS } from './tech-tokens';
|
|||||||
import { isDiscoveryLocationId, JACURUTU_SIETCH } from './discoveries';
|
import { isDiscoveryLocationId, JACURUTU_SIETCH } from './discoveries';
|
||||||
import type { TwoPlayerControllers } from './two-player-controllers';
|
import type { TwoPlayerControllers } from './two-player-controllers';
|
||||||
import { allianceVictoryTarget } from './alliance-victory-option';
|
import { allianceVictoryTarget } from './alliance-victory-option';
|
||||||
|
import { coalitionGroups } from './coalition-alliance';
|
||||||
|
|
||||||
export type StrongholdProgress = {
|
export type StrongholdProgress = {
|
||||||
player: string;
|
player: string;
|
||||||
@@ -37,6 +38,22 @@ function requireProgress(
|
|||||||
if (!condition) throw new VictoryProgressError(message);
|
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
|
/** Discovery locations use stronghold shipping and occupancy rules, but only
|
||||||
* Jacurutu Sietch counts toward the normal stronghold victory. */
|
* Jacurutu Sietch counts toward the normal stronghold victory. */
|
||||||
export function countsForStrongholdVictory(id: string) {
|
export function countsForStrongholdVictory(id: string) {
|
||||||
@@ -65,6 +82,8 @@ export function strongholdProgress(
|
|||||||
const extra = g.victoryExtraStrongholds ?? 0;
|
const extra = g.victoryExtraStrongholds ?? 0;
|
||||||
requireProgress(Number.isSafeInteger(extra) && extra >= 0 && extra <= 200,
|
requireProgress(Number.isSafeInteger(extra) && extra >= 0 && extra <= 200,
|
||||||
'The agreed extra stronghold target must be a safe nonnegative count.');
|
'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
|
// This validates every ID, faction and reciprocal alliance, even where no
|
||||||
// stronghold has a force. It does not merge ordinary allies for occupancy.
|
// stronghold has a force. It does not merge ordinary allies for occupancy.
|
||||||
ecazOccupancyIdentity(g.players, g.players[0].id, {
|
ecazOccupancyIdentity(g.players, g.players[0].id, {
|
||||||
@@ -102,9 +121,7 @@ export function strongholdProgress(
|
|||||||
);
|
);
|
||||||
const progress = g.order.map((id) => {
|
const progress = g.order.map((id) => {
|
||||||
const p = players.find((p) => p.id === id)!;
|
const p = players.find((p) => p.id === id)!;
|
||||||
const members = g.order.filter(
|
const members = victoryMembers(g, id);
|
||||||
(member) => member === id || member === p.ally,
|
|
||||||
);
|
|
||||||
const partner = players.find((other) => other.id === p.ally);
|
const partner = players.find((other) => other.id === p.ally);
|
||||||
const ecazPair =
|
const ecazPair =
|
||||||
!!partner && (p.faction === 'ecaz' || partner.faction === 'ecaz');
|
!!partner && (p.faction === 'ecaz' || partner.faction === 'ecaz');
|
||||||
@@ -136,7 +153,8 @@ export function strongholdProgress(
|
|||||||
TECH_TOKENS.every(({ id }) => tech[id].owner === member),
|
TECH_TOKENS.every(({ id }) => tech[id].owner === member),
|
||||||
);
|
);
|
||||||
const target = allianceVictoryTarget(
|
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;
|
) + extra;
|
||||||
return {
|
return {
|
||||||
player: id,
|
player: id,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { TECH_TOKENS, techStronghold } from './tech-tokens';
|
|||||||
import {
|
import {
|
||||||
countsForStrongholdVictory,
|
countsForStrongholdVictory,
|
||||||
strongholdProgress,
|
strongholdProgress,
|
||||||
|
victoryMembers,
|
||||||
VictoryProgressError,
|
VictoryProgressError,
|
||||||
} from './victory-progress';
|
} from './victory-progress';
|
||||||
import {
|
import {
|
||||||
@@ -210,10 +211,13 @@ export function quoteVictory(g: VictoryContext, ecazContext?: EcazHomeworldVicto
|
|||||||
const fremen = players.find((p) => p.faction === 'fremen');
|
const fremen = players.find((p) => p.faction === 'fremen');
|
||||||
const guild = players.find((p) => p.faction === 'guild');
|
const guild = players.find((p) => p.faction === 'guild');
|
||||||
if (fremen && fremenSpecialVictory(g)?.qualifies)
|
if (fremen && fremenSpecialVictory(g)?.qualifies)
|
||||||
winner = fremen.ally ? [fremen.id, fremen.ally] : [fremen.id];
|
winner = g.coalitionState === undefined
|
||||||
else if (guild) winner = guild.ally ? [guild.id, guild.ally] : [guild.id];
|
? (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)
|
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 {
|
else {
|
||||||
const scores = players.map((p) => ({
|
const scores = players.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
answerCoalitionAdmission, areCoalitionAllies, beginCoalitionAdmission, coalitionAllies,
|
||||||
|
coalitionEdgeTurn, coalitionGroups, coalitionOf, emptyCoalitionState, leaveCoalition,
|
||||||
|
type CoalitionSeat, type CoalitionState, type CoalitionTransition,
|
||||||
|
} from '../game/coalition-alliance';
|
||||||
|
|
||||||
|
function seats(): CoalitionSeat[] {
|
||||||
|
return [
|
||||||
|
{id: 'a', ally: 'b', allySinceTurn: 2},
|
||||||
|
{id: 'b', ally: 'a', allySinceTurn: 2},
|
||||||
|
{id: 'c', ally: null},
|
||||||
|
{id: 'd', ally: null},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
function apply(players: CoalitionSeat[], transition: CoalitionTransition): CoalitionSeat[] {
|
||||||
|
return players.map(player => {
|
||||||
|
const next = {...player};
|
||||||
|
if (Object.hasOwn(transition.allyUpdates, player.id)) next.ally = transition.allyUpdates[player.id];
|
||||||
|
if (Object.hasOwn(transition.allySinceUpdates, player.id)) {
|
||||||
|
const value = transition.allySinceUpdates[player.id];
|
||||||
|
if (value === null) delete next.allySinceTurn;
|
||||||
|
else next.allySinceTurn = value;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function begin(players = seats(), proposer = 'a') {
|
||||||
|
const state = emptyCoalitionState();
|
||||||
|
const group = coalitionOf(players, state, 'a')!;
|
||||||
|
return beginCoalitionAdmission(players, state, {event: 'invite-1', nexus: 'nexus-1', turn: 5,
|
||||||
|
groupId: group.id, entrant: 'c', proposer, expectedRevision: 0});
|
||||||
|
}
|
||||||
|
function answer(players: CoalitionSeat[], state: CoalitionState, actor: string, accept = true) {
|
||||||
|
return answerCoalitionAdmission(players, state, {event: 'invite-1', nexus: 'nexus-1', turn: 5,
|
||||||
|
actor, accept, expectedRevision: state.revision});
|
||||||
|
}
|
||||||
|
function triple(players = seats()) {
|
||||||
|
const first = begin(players);
|
||||||
|
const second = answer(players, first.state, 'b');
|
||||||
|
const accepted = answer(players, second.state, 'c');
|
||||||
|
return {players: apply(players, accepted), transition: accepted, original: players};
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('legacy reciprocal pairs are derived without inventing an unknown age', () => {
|
||||||
|
const original = seats(), before = structuredClone(original);
|
||||||
|
const group = coalitionOf(original, undefined, 'a'); assert.ok(group);
|
||||||
|
assert.deepEqual(group.members, ['a', 'b']);
|
||||||
|
assert.equal(group.formedTurn, 2); assert.equal(coalitionEdgeTurn(original, undefined, 'a', 'b'), 2);
|
||||||
|
assert.deepEqual(coalitionAllies(original, undefined, 'a'), ['b']);
|
||||||
|
assert.equal(areCoalitionAllies(original, undefined, 'a', 'b'), true);
|
||||||
|
assert.equal(areCoalitionAllies(original, undefined, 'a', 'c'), false);
|
||||||
|
assert.equal(areCoalitionAllies(original, undefined, 'a', 'a'), false);
|
||||||
|
assert.deepEqual(original, before);
|
||||||
|
delete original[1].allySinceTurn;
|
||||||
|
assert.equal(coalitionEdgeTurn(original, undefined, 'a', 'b'), null);
|
||||||
|
assert.equal(areCoalitionAllies(original, undefined, 'a', 'b'), true);
|
||||||
|
assert.equal(coalitionOf(original, undefined, 'c'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a real pair and free entrant need all three distinct approvals', () => {
|
||||||
|
const players = seats(), original = structuredClone(players);
|
||||||
|
const first = begin(players); assert.deepEqual(first.state.proposal?.approvals, ['a']);
|
||||||
|
assert.deepEqual(first.allyUpdates, {}); assert.deepEqual(players, original);
|
||||||
|
const second = answer(players, first.state, 'b');
|
||||||
|
assert.deepEqual(second.state.proposal?.approvals, ['a', 'b']);
|
||||||
|
assert.deepEqual(second.allyUpdates, {});
|
||||||
|
const accepted = answer(players, second.state, 'c');
|
||||||
|
assert.equal(accepted.state.proposal, null);
|
||||||
|
assert.deepEqual(accepted.allyUpdates, {a: null, b: null, c: null});
|
||||||
|
const next = apply(players, accepted);
|
||||||
|
assert.deepEqual(coalitionAllies(next, accepted.state, 'a'), ['b', 'c']);
|
||||||
|
assert.deepEqual(coalitionAllies(next, accepted.state, 'b'), ['a', 'c']);
|
||||||
|
assert.equal(coalitionEdgeTurn(next, accepted.state, 'a', 'b'), 2);
|
||||||
|
assert.equal(coalitionEdgeTurn(next, accepted.state, 'a', 'c'), 5);
|
||||||
|
assert.equal(coalitionEdgeTurn(next, accepted.state, 'b', 'c'), 5);
|
||||||
|
assert.deepEqual(players, original);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('the free entrant may propose, but both existing members must independently approve', () => {
|
||||||
|
const players = seats(), first = begin(players, 'c');
|
||||||
|
const second = answer(players, first.state, 'a');
|
||||||
|
assert.ok(second.state.proposal); assert.deepEqual(second.state.proposal.approvals, ['c', 'a']);
|
||||||
|
const accepted = answer(players, second.state, 'b');
|
||||||
|
assert.equal(accepted.state.proposal, null);
|
||||||
|
assert.deepEqual(coalitionOf(apply(players, accepted), accepted.state, 'c')?.members, ['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('any participant can decline before commitment; event replay and duplicate consent are rejected', () => {
|
||||||
|
const players = seats(), first = begin(players), snapshot = structuredClone(first.state);
|
||||||
|
assert.throws(() => answer(players, first.state, 'a'));
|
||||||
|
assert.throws(() => answer(players, first.state, 'd'));
|
||||||
|
assert.throws(() => answerCoalitionAdmission(players, first.state, {event: 'wrong', nexus: 'nexus-1',
|
||||||
|
turn: 5, actor: 'b', accept: true, expectedRevision: first.state.revision}));
|
||||||
|
assert.deepEqual(first.state, snapshot);
|
||||||
|
const declined = answer(players, first.state, 'a', false);
|
||||||
|
assert.equal(declined.state.proposal, null); assert.deepEqual(declined.allyUpdates, {});
|
||||||
|
assert.throws(() => answer(players, declined.state, 'b'));
|
||||||
|
assert.throws(() => beginCoalitionAdmission(players, declined.state, {event: 'invite-1', nexus: 'nexus-1', turn: 5,
|
||||||
|
groupId: coalitionOf(players, declined.state, 'a')!.id, entrant: 'c', proposer: 'a', expectedRevision: declined.state.revision}));
|
||||||
|
assert.deepEqual(players, seats());
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('stale revision, changed Nexus and changed original membership never change their inputs', () => {
|
||||||
|
const players = seats(), first = begin(players), before = structuredClone({players, state: first.state});
|
||||||
|
for (const update of [{expectedRevision: 0}, {nexus: 'different'}, {turn: 6}])
|
||||||
|
assert.throws(() => answerCoalitionAdmission(players, first.state, {event: 'invite-1', nexus: 'nexus-1', turn: 5,
|
||||||
|
actor: 'b', accept: true, expectedRevision: first.state.revision, ...update}));
|
||||||
|
const changed = structuredClone(players); changed[0].ally = null; changed[1].ally = null;
|
||||||
|
assert.throws(() => answer(changed, first.state, 'b'));
|
||||||
|
assert.deepEqual({players, state: first.state}, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('leaving a triple preserves the remaining pair, stable ID and exact pairwise formation turn', () => {
|
||||||
|
for (const actor of ['a', 'b', 'c']) {
|
||||||
|
const {players, transition} = triple();
|
||||||
|
const before = structuredClone({players, state: transition.state});
|
||||||
|
const original = coalitionOf(players, transition.state, actor)!;
|
||||||
|
const remaining = original.members.filter(member => member !== actor);
|
||||||
|
const expectedAge = coalitionEdgeTurn(players, transition.state, remaining[0], remaining[1]);
|
||||||
|
const left = leaveCoalition(players, transition.state, {actor, expectedRevision: transition.state.revision});
|
||||||
|
const after = apply(players, left);
|
||||||
|
const pair = coalitionOf(after, left.state, remaining[0]); assert.ok(pair);
|
||||||
|
assert.equal(pair.id, original.id); assert.deepEqual(pair.members, remaining);
|
||||||
|
assert.equal(coalitionEdgeTurn(after, left.state, remaining[0], remaining[1]), expectedAge);
|
||||||
|
assert.equal(after.find(seat => seat.id === remaining[0])!.ally, remaining[1]);
|
||||||
|
assert.equal(after.find(seat => seat.id === remaining[1])!.ally, remaining[0]);
|
||||||
|
assert.equal(coalitionOf(after, left.state, actor), null);
|
||||||
|
assert.deepEqual({players, state: transition.state}, before);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('leaving a legacy pair dissolves it without changing unrelated groups', () => {
|
||||||
|
const players: CoalitionSeat[] = [...seats(), {id: 'e', ally: 'f', allySinceTurn: 3},
|
||||||
|
{id: 'f', ally: 'e', allySinceTurn: 3}];
|
||||||
|
const left = leaveCoalition(players, undefined, {actor: 'a', expectedRevision: 0});
|
||||||
|
const after = apply(players, left);
|
||||||
|
assert.equal(coalitionOf(after, left.state, 'a'), null);
|
||||||
|
assert.equal(coalitionOf(after, left.state, 'b'), null);
|
||||||
|
assert.equal(coalitionEdgeTurn(after, left.state, 'e', 'f'), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a missing legacy edge date stays unknown when the old pair is restored', () => {
|
||||||
|
const initial = seats(); delete initial[0].allySinceTurn; delete initial[1].allySinceTurn;
|
||||||
|
const {players, transition} = triple(initial);
|
||||||
|
assert.equal(coalitionEdgeTurn(players, transition.state, 'a', 'b'), null);
|
||||||
|
assert.equal(coalitionEdgeTurn(players, transition.state, 'a', 'c'), 5);
|
||||||
|
const left = leaveCoalition(players, transition.state, {actor: 'c', expectedRevision: transition.state.revision});
|
||||||
|
assert.equal(left.allySinceUpdates.a, null); assert.equal(left.allySinceUpdates.b, null);
|
||||||
|
const restored = apply(players, left);
|
||||||
|
assert.equal(coalitionEdgeTurn(restored, left.state, 'a', 'b'), null);
|
||||||
|
assert.equal(areCoalitionAllies(restored, left.state, 'a', 'b'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('invalid saved membership, malformed edges and fake scalar triple pairs are rejected', () => {
|
||||||
|
const initial = seats();
|
||||||
|
const dangling = structuredClone(initial); dangling[1].ally = null;
|
||||||
|
assert.throws(() => coalitionGroups(dangling, undefined));
|
||||||
|
const {players, transition} = triple();
|
||||||
|
assert.throws(() => coalitionGroups(initial, transition.state));
|
||||||
|
const duplicate = structuredClone(transition.state);
|
||||||
|
duplicate.groups.push({...structuredClone(duplicate.groups[0]), id: 'duplicate'});
|
||||||
|
assert.throws(() => coalitionGroups(players, duplicate));
|
||||||
|
const missing = structuredClone(transition.state); missing.groups[0].edges.pop();
|
||||||
|
assert.throws(() => coalitionGroups(players, missing));
|
||||||
|
const wrongAge = structuredClone(transition.state); wrongAge.groups[0].edges[0].formedTurn = -1;
|
||||||
|
assert.throws(() => coalitionGroups(players, wrongAge));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('saved invitations reject unrelated seats, duplicate approvals and state changed under the offer', () => {
|
||||||
|
const players = seats(), first = begin(players);
|
||||||
|
for (const corrupt of [
|
||||||
|
(state: CoalitionState) => { state.proposal!.approvals.push('d'); },
|
||||||
|
(state: CoalitionState) => { state.proposal!.approvals.push('a'); },
|
||||||
|
(state: CoalitionState) => { state.proposal!.original.edges[0].formedTurn = 4; },
|
||||||
|
(state: CoalitionState) => { state.usedEvents = []; },
|
||||||
|
]) {
|
||||||
|
const broken = structuredClone(first.state); corrupt(broken);
|
||||||
|
assert.throws(() => coalitionGroups(players, broken));
|
||||||
|
assert.throws(() => answer(players, broken, 'b'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('unequal legacy scalar ages survive joining and restoring the original pair', () => {
|
||||||
|
for (const dates of [[2, undefined], [undefined, 3], [2, 3]] as const) {
|
||||||
|
for (const persistedPair of [false, true]) {
|
||||||
|
const initial = seats();
|
||||||
|
for (const [index, date] of dates.entries()) {
|
||||||
|
if (date === undefined) delete initial[index].allySinceTurn;
|
||||||
|
else initial[index].allySinceTurn = date;
|
||||||
|
}
|
||||||
|
const derived = coalitionOf(initial, undefined, 'a')!;
|
||||||
|
assert.equal(derived.edges[0].formedTurn, null);
|
||||||
|
// An older persisted pair may predate the per-seat metadata.
|
||||||
|
const oldPair = structuredClone(derived); delete oldPair.edges[0].legacySince;
|
||||||
|
const state = {...emptyCoalitionState(), groups: persistedPair ? [oldPair] : []};
|
||||||
|
const first = beginCoalitionAdmission(initial, state, {event: 'invite-1', nexus: 'nexus-1', turn: 5,
|
||||||
|
groupId: derived.id, entrant: 'c', proposer: 'a', expectedRevision: 0});
|
||||||
|
const second = answer(initial, first.state, 'b');
|
||||||
|
const accepted = answer(initial, second.state, 'c');
|
||||||
|
const players = apply(initial, accepted);
|
||||||
|
assert.equal(coalitionEdgeTurn(players, accepted.state, 'a', 'b'), null);
|
||||||
|
const left = leaveCoalition(players, accepted.state, {actor: 'c', expectedRevision: accepted.state.revision});
|
||||||
|
const restored = apply(players, left);
|
||||||
|
assert.equal(restored[0].allySinceTurn, dates[0]);
|
||||||
|
assert.equal(restored[1].allySinceTurn, dates[1]);
|
||||||
|
assert.equal(Object.hasOwn(restored[0], 'allySinceTurn'), dates[0] !== undefined);
|
||||||
|
assert.equal(Object.hasOwn(restored[1], 'allySinceTurn'), dates[1] !== undefined);
|
||||||
|
assert.equal(coalitionEdgeTurn(restored, left.state, 'a', 'b'), null);
|
||||||
|
assert.equal(coalitionOf(restored, left.state, 'a')!.id, derived.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('maximum-length seat IDs with escaped characters can be admitted and persisted', () => {
|
||||||
|
const a = '\u0001'.repeat(256), b = '\u0002'.repeat(256);
|
||||||
|
const players: CoalitionSeat[] = [{id: a, ally: b, allySinceTurn: 2},
|
||||||
|
{id: b, ally: a, allySinceTurn: 2}, {id: 'entrant', ally: null}];
|
||||||
|
const group = coalitionOf(players, undefined, a)!;
|
||||||
|
assert.ok(group.id.length > 256);
|
||||||
|
const first = beginCoalitionAdmission(players, undefined, {event: 'long-id', nexus: 'nexus', turn: 5,
|
||||||
|
groupId: group.id, entrant: 'entrant', proposer: a, expectedRevision: 0});
|
||||||
|
const second = answerCoalitionAdmission(players, first.state, {event: 'long-id', nexus: 'nexus', turn: 5,
|
||||||
|
actor: b, accept: true, expectedRevision: first.state.revision});
|
||||||
|
const accepted = answerCoalitionAdmission(players, second.state, {event: 'long-id', nexus: 'nexus', turn: 5,
|
||||||
|
actor: 'entrant', accept: true, expectedRevision: second.state.revision});
|
||||||
|
const joined = apply(players, accepted);
|
||||||
|
assert.equal(coalitionOf(joined, accepted.state, a)!.id, group.id);
|
||||||
|
const left = leaveCoalition(joined, accepted.state, {actor: 'entrant', expectedRevision: accepted.state.revision});
|
||||||
|
assert.equal(coalitionOf(apply(joined, left), left.state, a)!.id, group.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('an older unknown edge retains matching saved scalar dates without inventing a shared date', () => {
|
||||||
|
const initial = seats();
|
||||||
|
initial[0].allySinceTurn = 2; initial[1].allySinceTurn = 2;
|
||||||
|
const oldPair = structuredClone(coalitionOf(initial, undefined, 'a')!);
|
||||||
|
oldPair.formedTurn = null; oldPair.edges[0].formedTurn = null;
|
||||||
|
const state = {...emptyCoalitionState(), groups: [oldPair]};
|
||||||
|
const first = beginCoalitionAdmission(initial, state, {event: 'old-pair', nexus: 'nexus-1', turn: 5,
|
||||||
|
groupId: oldPair.id, entrant: 'c', proposer: 'a', expectedRevision: 0});
|
||||||
|
const second = answerCoalitionAdmission(initial, first.state, {event: 'old-pair', nexus: 'nexus-1', turn: 5,
|
||||||
|
actor: 'b', accept: true, expectedRevision: first.state.revision});
|
||||||
|
const accepted = answerCoalitionAdmission(initial, second.state, {event: 'old-pair', nexus: 'nexus-1', turn: 5,
|
||||||
|
actor: 'c', accept: true, expectedRevision: second.state.revision});
|
||||||
|
const joined = apply(initial, accepted);
|
||||||
|
assert.equal(coalitionEdgeTurn(joined, accepted.state, 'a', 'b'), null);
|
||||||
|
const left = leaveCoalition(joined, accepted.state, {actor: 'c', expectedRevision: accepted.state.revision});
|
||||||
|
const restored = apply(joined, left);
|
||||||
|
assert.equal(restored[0].allySinceTurn, 2);
|
||||||
|
assert.equal(restored[1].allySinceTurn, 2);
|
||||||
|
assert.equal(coalitionEdgeTurn(restored, left.state, 'a', 'b'), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createGame, newPlayer } from '../game/engine';
|
||||||
|
import { quoteBattleBoard, quoteCoalitionAlliedSeparation } from '../game/board-resolution-quote';
|
||||||
|
import { territoryEntryBlock } from '../game/occupancy';
|
||||||
|
import type { CoalitionState } from '../game/coalition-alliance';
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const g = createGame('COALBOARD', newPlayer('a', 'Atreides', 'atreides'), false);
|
||||||
|
g.players.push(newPlayer('b', 'Emperor', 'emperor'), newPlayer('c', 'Fremen', 'fremen'),
|
||||||
|
newPlayer('d', 'Harkonnen', 'harkonnen'));
|
||||||
|
Object.assign(g, { advanced: false, turn: 4, storm: 18, order: ['a', 'b', 'c', 'd'] });
|
||||||
|
for (const p of g.players) { p.forces = {}; p.ally = null; }
|
||||||
|
const coalitionState: CoalitionState = { version: 1, revision: 1, proposal: null, usedEvents: [],
|
||||||
|
groups: [{ id: 'abc', members: ['a', 'b', 'c'], formedTurn: 2, edges: [
|
||||||
|
{ members: ['a', 'b'], formedTurn: 2 },
|
||||||
|
{ members: ['a', 'c'], formedTurn: 4 },
|
||||||
|
{ members: ['b', 'c'], formedTurn: 4 },
|
||||||
|
] }] };
|
||||||
|
return { g, coalitionState };
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('coalition battles exclude every ally while preserving separate battles against outsiders', () => {
|
||||||
|
const { g, coalitionState } = fixture();
|
||||||
|
for (const p of g.players) p.forces = { 'red_chasm:7': 1 };
|
||||||
|
const before = structuredClone({ g, coalitionState });
|
||||||
|
assert.deepEqual(quoteBattleBoard({ ...g, coalitionState }).battles, [
|
||||||
|
{ territory: 'red_chasm', attacker: 'a', defender: 'd' },
|
||||||
|
{ territory: 'red_chasm', attacker: 'b', defender: 'd' },
|
||||||
|
{ territory: 'red_chasm', attacker: 'c', defender: 'd' },
|
||||||
|
]);
|
||||||
|
assert.equal(quoteBattleBoard(g).battles.length, 6);
|
||||||
|
assert.deepEqual({ g, coalitionState }, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('entry checks each coalition ally and retains polar, temporary shipment and capacity rules', () => {
|
||||||
|
const { g, coalitionState } = fixture();
|
||||||
|
g.players[1].forces = { 'red_chasm:7': 1 };
|
||||||
|
g.players[2].forces = { 'arrakeen:10': 1 };
|
||||||
|
const block = 'You cannot enter a territory occupied by your ally.';
|
||||||
|
assert.equal(territoryEntryBlock(g.players, 'a', 'red_chasm', false, false, coalitionState), block);
|
||||||
|
assert.equal(territoryEntryBlock(g.players, 'a', 'arrakeen', false, false, coalitionState), block);
|
||||||
|
assert.equal(territoryEntryBlock(g.players, 'a', 'red_chasm'), null);
|
||||||
|
assert.equal(territoryEntryBlock(g.players, 'a', 'red_chasm', false, true, coalitionState), null);
|
||||||
|
g.players[1].forces['polar_sink:0'] = 1;
|
||||||
|
assert.equal(territoryEntryBlock(g.players, 'a', 'polar_sink', false, false, coalitionState), null);
|
||||||
|
g.players[3].forces = { 'arrakeen:10': 1 };
|
||||||
|
assert.equal(territoryEntryBlock(g.players, 'a', 'arrakeen', false, true, coalitionState),
|
||||||
|
'A stronghold cannot contain three occupying factions.');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Basic separation follows each alliance edge and preserves old formation age', () => {
|
||||||
|
const { g, coalitionState } = fixture();
|
||||||
|
g.players[0].forces = { 'red_chasm:7': 1, 'arrakeen:10': 1 };
|
||||||
|
g.players[1].forces = { 'red_chasm:7': 1 };
|
||||||
|
g.players[2].forces = { 'arrakeen:10': 1 };
|
||||||
|
const context = { ...g, coalitionState, remaining: ['a'] };
|
||||||
|
const before = structuredClone(context);
|
||||||
|
assert.deepEqual(quoteCoalitionAlliedSeparation(context, 'a'), ['red_chasm']);
|
||||||
|
assert.deepEqual(quoteCoalitionAlliedSeparation({ ...context, remaining: ['a', 'b'] }, 'a'), []);
|
||||||
|
assert.deepEqual(new Set(quoteCoalitionAlliedSeparation({ ...context, turn: 5 }, 'a')),
|
||||||
|
new Set(['red_chasm', 'arrakeen']));
|
||||||
|
assert.deepEqual(context, before);
|
||||||
|
coalitionState.groups[0].edges[0].formedTurn = null;
|
||||||
|
assert.deepEqual(quoteCoalitionAlliedSeparation(context, 'a'), ['red_chasm']);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('legacy pair timing remains unchanged and unsupported coalition combinations fail closed', () => {
|
||||||
|
const { g, coalitionState } = fixture();
|
||||||
|
g.players[0].forces = { 'red_chasm:7': 1 };
|
||||||
|
g.players[1].forces = { 'red_chasm:7': 1 };
|
||||||
|
g.players[0].ally = 'b'; g.players[1].ally = 'a';
|
||||||
|
g.players[0].allySinceTurn = 4; g.players[1].allySinceTurn = 4;
|
||||||
|
assert.deepEqual(quoteCoalitionAlliedSeparation({ ...g, remaining: ['a'] }, 'a'), []);
|
||||||
|
g.players[1].allySinceTurn = 2;
|
||||||
|
assert.deepEqual(quoteCoalitionAlliedSeparation({ ...g, remaining: ['a'] }, 'a'), ['red_chasm']);
|
||||||
|
assert.equal(territoryEntryBlock(g.players, 'a', 'red_chasm'),
|
||||||
|
'You cannot enter a territory occupied by your ally.');
|
||||||
|
g.players[0].ally = null; g.players[1].ally = null;
|
||||||
|
assert.throws(() => quoteBattleBoard({ ...g, advanced: true, coalitionState }), /Basic play without Ecaz/);
|
||||||
|
g.players[2] = newPlayer('c', 'Ecaz', 'ecaz');
|
||||||
|
assert.throws(() => territoryEntryBlock(g.players, 'a', 'red_chasm', false, false, coalitionState), /do not yet support Ecaz/);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a malformed null coalition ledger cannot turn a group into enemies', () => {
|
||||||
|
const { g } = fixture();
|
||||||
|
for (const p of g.players) p.forces = { 'red_chasm:7': 1 };
|
||||||
|
const invalid = null as unknown as CoalitionState;
|
||||||
|
assert.throws(() => quoteBattleBoard({ ...g, coalitionState: invalid }), /ledger cannot be null/);
|
||||||
|
assert.throws(() => territoryEntryBlock(g.players, 'a', 'red_chasm', false, false, invalid), /ledger cannot be null/);
|
||||||
|
assert.throws(() => territoryEntryBlock(g.players, 'a', 'red_chasm', true, false, invalid), /ledger cannot be null/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { beginCoalitionAdmission, answerCoalitionAdmission, coalitionOf, type CoalitionState } from '../game/coalition-alliance';
|
||||||
|
import { createGame, newPlayer, type Game } from '../game/engine';
|
||||||
|
import type { FactionId } from '../game/catalog';
|
||||||
|
import { strongholdProgress, VictoryProgressError } from '../game/victory-progress';
|
||||||
|
import { quoteVictory, VictoryQuoteError } from '../game/victory-quote';
|
||||||
|
import { fremenSpecialVictory } from '../game/fremen-victory';
|
||||||
|
import { createVictoryContinuation, voteVictoryContinuation } from '../game/victory-continuation';
|
||||||
|
import { createTechTokens } from '../game/tech-tokens';
|
||||||
|
|
||||||
|
type CoalitionGame = Game & {coalitionState: CoalitionState};
|
||||||
|
function game(factions: FactionId[] = ['atreides', 'emperor', 'harkonnen', 'beneGesserit']): CoalitionGame {
|
||||||
|
const g = createGame('COALITIONVICTORY', newPlayer('p0', factions[0], factions[0]), false);
|
||||||
|
factions.slice(1).forEach((faction, i) => g.players.push(newPlayer(`p${i + 1}`, faction, faction)));
|
||||||
|
Object.assign(g, { status: 'playing', phase: 8, turn: 2, storm: 18,
|
||||||
|
order: g.players.map(player => player.id) });
|
||||||
|
for (const p of g.players) Object.assign(p, {forces: {}, ally: null});
|
||||||
|
g.players[0].ally = 'p1'; g.players[1].ally = 'p0';
|
||||||
|
const group = coalitionOf(g.players, undefined, 'p0')!;
|
||||||
|
const start = beginCoalitionAdmission(g.players, undefined, {event: 'invite', nexus: 'nexus', turn: 2,
|
||||||
|
groupId: group.id, entrant: 'p2', proposer: 'p0', expectedRevision: 0});
|
||||||
|
const second = answerCoalitionAdmission(g.players, start.state, {event: 'invite', nexus: 'nexus',
|
||||||
|
turn: 2, actor: 'p1', accept: true, expectedRevision: start.state.revision});
|
||||||
|
const joined = answerCoalitionAdmission(g.players, second.state, {event: 'invite', nexus: 'nexus',
|
||||||
|
turn: 2, actor: 'p2', accept: true, expectedRevision: second.state.revision});
|
||||||
|
for (const p of g.players) if (Object.hasOwn(joined.allyUpdates, p.id)) p.ally = joined.allyUpdates[p.id];
|
||||||
|
return Object.assign(g, {coalitionState: joined.state});
|
||||||
|
}
|
||||||
|
function hold(g: CoalitionGame, player: number, ...sites: string[]) {
|
||||||
|
g.players[player].forces = Object.fromEntries(sites.map(site => [site, 1]));
|
||||||
|
}
|
||||||
|
const a = 'arrakeen:10', b = 'carthag:11', c = 'sietch_tabr:14', d = 'habbanya_ridge_sietch:17';
|
||||||
|
|
||||||
|
void test('three members share a distinct uncontested stronghold union with ordered members', () => {
|
||||||
|
const g = game();
|
||||||
|
g.order = ['p2', 'p0', 'p3', 'p1'];
|
||||||
|
hold(g, 0, a, b); hold(g, 1, b, c); hold(g, 2, d);
|
||||||
|
const before = structuredClone(g);
|
||||||
|
const rows = strongholdProgress(g).progress.filter(row => row.members.length === 3);
|
||||||
|
assert.equal(rows.length, 3);
|
||||||
|
for (const row of rows) {
|
||||||
|
assert.deepEqual(row.members, ['p2', 'p0', 'p1']);
|
||||||
|
assert.equal(row.strongholds.length, 4); assert.equal(row.target, 4); assert.equal(row.qualifies, true);
|
||||||
|
}
|
||||||
|
assert.deepEqual(quoteVictory(g).winner, ['p2', 'p0', 'p1']);
|
||||||
|
assert.deepEqual(g, before);
|
||||||
|
hold(g, 3, d);
|
||||||
|
assert.equal(strongholdProgress(g).progress.find(row => row.player === 'p0')!.qualifies, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('two-member shortened target does not apply to three-member alliances', () => {
|
||||||
|
const g = game(); hold(g, 0, a); hold(g, 1, b); hold(g, 2, c);
|
||||||
|
let row = strongholdProgress(g).progress[0];
|
||||||
|
assert.equal(row.target, 4); assert.equal(row.qualifies, false);
|
||||||
|
g.shortAllianceVictory = true;
|
||||||
|
row = strongholdProgress(g).progress[0]; assert.equal(row.target, 4); assert.equal(row.qualifies, false);
|
||||||
|
g.victoryExtraStrongholds = 1;
|
||||||
|
row = strongholdProgress(g).progress[0]; assert.equal(row.target, 5); assert.equal(row.qualifies, false);
|
||||||
|
g.victoryExtraStrongholds = 0;
|
||||||
|
g.techTokens = createTechTokens();
|
||||||
|
for (const token of Object.values(g.techTokens)) token.owner = 'p2';
|
||||||
|
row = strongholdProgress(g).progress[0]; assert.equal(row.techStronghold, true); assert.equal(row.qualifies, true);
|
||||||
|
g.techTokens.production.owner = 'p1';
|
||||||
|
assert.equal(strongholdProgress(g).progress[0].techStronghold, false);
|
||||||
|
g.twoPlayerControllers = {version: 1, controllers: [
|
||||||
|
{id: 'p0', seats: ['p0', 'p1']}, {id: 'p2', seats: ['p2', 'p3']},
|
||||||
|
]};
|
||||||
|
assert.equal(strongholdProgress(g).progress[0].target, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('ordinary prediction checks every coalition winner while final Fremen special still wins', () => {
|
||||||
|
const g = game(); hold(g, 0, a, b); hold(g, 1, c); hold(g, 2, d);
|
||||||
|
g.players[3].prediction = {faction: 'harkonnen', turn: 2};
|
||||||
|
assert.deepEqual(quoteVictory(g).winner, ['p3']);
|
||||||
|
g.players[3].prediction.turn = 3;
|
||||||
|
assert.deepEqual(quoteVictory(g).winner, ['p0', 'p1', 'p2']);
|
||||||
|
const f = game(['fremen', 'atreides', 'emperor', 'beneGesserit']);
|
||||||
|
f.turn = 10; f.players[3].prediction = {faction: 'atreides', turn: 10};
|
||||||
|
assert.deepEqual(quoteVictory(f).winner, ['p0', 'p1', 'p2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Fremen, Guild and no-Guild Fremen final branches award the full alliance', () => {
|
||||||
|
const fremen = game(['fremen', 'atreides', 'emperor', 'guild']); fremen.turn = 10;
|
||||||
|
assert.deepEqual(fremenSpecialVictory(fremen)!.members, ['p0', 'p1', 'p2']);
|
||||||
|
assert.deepEqual(quoteVictory(fremen).winner, ['p0', 'p1', 'p2']);
|
||||||
|
hold(fremen, 1, c);
|
||||||
|
assert.equal(fremenSpecialVictory(fremen)!.qualifies, false);
|
||||||
|
assert.deepEqual(quoteVictory(fremen).winner, ['p3']);
|
||||||
|
const guild = game(['guild', 'atreides', 'emperor', 'beneGesserit']); guild.turn = 10;
|
||||||
|
assert.deepEqual(quoteVictory(guild).winner, ['p0', 'p1', 'p2']);
|
||||||
|
const noGuild = game(['fremen', 'atreides', 'emperor', 'harkonnen']); noGuild.turn = 10;
|
||||||
|
hold(noGuild, 1, c);
|
||||||
|
assert.equal(fremenSpecialVictory(noGuild)!.qualifies, false);
|
||||||
|
assert.deepEqual(quoteVictory(noGuild).winner, ['p0', 'p1', 'p2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('unsupported triples and malformed coalition records fail closed across quotes', () => {
|
||||||
|
const g = game(); g.advanced = true;
|
||||||
|
assert.throws(() => strongholdProgress(g), VictoryProgressError);
|
||||||
|
assert.throws(() => fremenSpecialVictory(g), VictoryProgressError);
|
||||||
|
assert.throws(() => quoteVictory(g), VictoryQuoteError);
|
||||||
|
g.advanced = false; g.players[3].faction = 'ecaz';
|
||||||
|
assert.throws(() => strongholdProgress(g), VictoryProgressError);
|
||||||
|
assert.throws(() => quoteVictory(g), VictoryQuoteError);
|
||||||
|
g.players[3].faction = 'beneGesserit'; g.coalitionState.groups[0].edges.pop();
|
||||||
|
assert.throws(() => strongholdProgress(g), VictoryProgressError);
|
||||||
|
assert.throws(() => quoteVictory(g), VictoryQuoteError);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a continuation deduplicates a triple side and requires every participant to consent', () => {
|
||||||
|
let frame = createVictoryContinuation({event: 'continue', turn: 2, resume: 'mentat',
|
||||||
|
order: ['p2', 'p0', 'p3', 'p1'], sides: [['p0', 'p1', 'p2'], ['p2', 'p0', 'p1'], ['p3']]});
|
||||||
|
assert.deepEqual(frame.sides, [['p2', 'p0', 'p1'], ['p3']]);
|
||||||
|
assert.deepEqual(frame.voters, ['p2', 'p0', 'p3', 'p1']);
|
||||||
|
for (const [index, player] of frame.voters.entries()) {
|
||||||
|
const vote = voteVictoryContinuation(frame, {event: 'continue', player, choice: 'continue',
|
||||||
|
...(index === 0 ? {extraStrongholds: 1} : {})});
|
||||||
|
if (vote.outcome === 'pending') frame = vote.frame;
|
||||||
|
else { assert.equal(index, 3); assert.deepEqual(vote, {outcome: 'continue', extraStrongholds: 1}); }
|
||||||
|
}
|
||||||
|
assert.throws(() => createVictoryContinuation({event: 'x', turn: 2, resume: 'mentat',
|
||||||
|
order: ['p0', 'p1', 'p2', 'p3'], sides: [['p0', 'p1', 'p2'], ['p2', 'p3']]}));
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { type Action, type Game } from '../game/engine';
|
||||||
|
import { advanceTruthRevival, truthRevivalClean, truthRevivalInventory, truthRevivalPlayer } from './fixture-truth-revival';
|
||||||
|
import {
|
||||||
|
actFutureBattleRevival, askFutureBattleRevival, createFutureBattleRevivalFixture,
|
||||||
|
futureBattleRevivalRecords, reachFutureBattleRevivalBattle, reachFutureBattleRevivalMovement,
|
||||||
|
} from './fixture-truth-future-battle-revival';
|
||||||
|
|
||||||
|
export const actFutureBattleCharity = actFutureBattleRevival;
|
||||||
|
export const askFutureBattleCharity = askFutureBattleRevival;
|
||||||
|
export const futureBattleCharityRecords = futureBattleRevivalRecords;
|
||||||
|
export const futureBattleCharityInventory = truthRevivalInventory;
|
||||||
|
export const reachFutureBattleCharityBattle = reachFutureBattleRevivalBattle;
|
||||||
|
export const reachFutureBattleCharityMovement = reachFutureBattleRevivalMovement;
|
||||||
|
|
||||||
|
/** Start from the original public classic setup and actual turn-two Storm,
|
||||||
|
* then advance the source's real native actions to a quiet Charity window. */
|
||||||
|
export function createFutureBattleCharityFixture() {
|
||||||
|
const origin = createFutureBattleRevivalFixture();
|
||||||
|
const game = advanceTruthRevival(structuredClone(origin.base.storm.after),
|
||||||
|
state => state.turn === origin.game.turn && state.phase === 2 && truthRevivalClean(state));
|
||||||
|
assert.equal(game.status, 'playing');
|
||||||
|
assert.equal(game.phase, 2); assert.equal(game.advanced, false); assert.deepEqual(game.expansions, []);
|
||||||
|
assert.ok(truthRevivalClean(game));
|
||||||
|
assert.ok(truthRevivalPlayer(game, origin.asker).hand.some(card => card.id === origin.truth));
|
||||||
|
assert.deepEqual(truthRevivalInventory(game), origin.originalCards);
|
||||||
|
const target = truthRevivalPlayer(game, origin.target);
|
||||||
|
const available = target.leaders.filter(leader => !leader.dead && !leader.capturedBy && !leader.gholaBy && !leader.usedAt);
|
||||||
|
assert.ok(available.length >= 2);
|
||||||
|
const leader = available[0].id, alternative = available[1].id;
|
||||||
|
const question = { kind: 'futureBattleLeader', target: origin.target, turn: game.turn, leader };
|
||||||
|
return { ...origin, game, source: structuredClone(game), leader, alternative, question,
|
||||||
|
ask: { type: 'truthAsk', question } as unknown as Action };
|
||||||
|
}
|
||||||
|
export type FutureBattleCharityFixture = ReturnType<typeof createFutureBattleCharityFixture>;
|
||||||
|
|
||||||
|
/** Drive the real, earlier phase source to the original target movement turn. */
|
||||||
|
export function reachFutureBattleCharityWindow(game: Game, fixture: FutureBattleCharityFixture) {
|
||||||
|
return reachFutureBattleCharityMovement(game, fixture);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { type Action, type Game } from '../game/engine';
|
||||||
|
import { nextTruthRevival, truthRevivalClean, truthRevivalPlayer } from './fixture-truth-revival';
|
||||||
|
import {
|
||||||
|
actFutureBattleCharity as act, askFutureBattleCharity as ask,
|
||||||
|
createFutureBattleCharityFixture as fixture, futureBattleCharityInventory as inventory,
|
||||||
|
futureBattleCharityRecords as records, reachFutureBattleCharityBattle as reachBattle,
|
||||||
|
reachFutureBattleCharityWindow as reachMovement,
|
||||||
|
} from './fixture-truth-future-battle-charity';
|
||||||
|
|
||||||
|
function rejected(game: Game, actor: string, action: Action) {
|
||||||
|
const before = structuredClone(game);
|
||||||
|
assert.throws(() => act(game, actor, action));
|
||||||
|
assert.deepEqual(game, before);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const answer of ['yes', 'no'] as const) {
|
||||||
|
void test(`Basic Charity future-battle leader ${answer} binds the first actual later battle`, () => {
|
||||||
|
const f = fixture(), asked = ask(f);
|
||||||
|
assert.equal(asked.phase, 2); assert.deepEqual(asked.truthtrance?.question, f.question);
|
||||||
|
let game = act(asked, f.target, {type: 'truthAnswer', answer});
|
||||||
|
assert.equal(records(game).length, 1); assert.equal(records(game)[0].status, 'waiting');
|
||||||
|
game = reachMovement(game, f);
|
||||||
|
assert.equal(records(game)[0].status, 'waiting');
|
||||||
|
game = reachBattle(game, f);
|
||||||
|
assert.equal(records(game)[0].status, 'bound');
|
||||||
|
assert.equal(records(game)[0].battle, game.battle!.event);
|
||||||
|
const plan = (leader: string): Action => ({type: 'battlePlan', leader, dial: 0, support: 0, weapon: null, defense: null});
|
||||||
|
rejected(game, f.target, plan(answer === 'yes' ? f.alternative : f.leader));
|
||||||
|
game = act(game, f.target, plan(answer === 'yes' ? f.leader : f.alternative));
|
||||||
|
assert.equal(records(game)[0].status, 'bound');
|
||||||
|
const opponent = game.battle!.attacker === f.target ? game.battle!.defender : game.battle!.attacker;
|
||||||
|
const other = truthRevivalPlayer(game, opponent).leaders.find(leader =>
|
||||||
|
!leader.dead && !leader.capturedBy && !leader.gholaBy && !leader.usedAt);
|
||||||
|
assert.ok(other);
|
||||||
|
game = act(game, opponent, plan(other.id));
|
||||||
|
assert.ok(game.battle?.revealed); assert.equal(records(game)[0].status, 'settled');
|
||||||
|
assert.equal(records(game).length, 1);
|
||||||
|
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
|
||||||
|
assert.deepEqual(inventory(game), f.originalCards);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('Basic Charity conditional leader answer expires if the respondent never fights', () => {
|
||||||
|
const f = fixture();
|
||||||
|
for (const answer of ['yes', 'no'] as const) {
|
||||||
|
let game = act(ask(f), f.target, {type: 'truthAnswer', answer});
|
||||||
|
game = reachMovement(game, f);
|
||||||
|
assert.ok(Object.values(truthRevivalPlayer(game, f.target).forces).every(amount => amount === 0));
|
||||||
|
for (let count = 0; count < 1200 && game.turn === f.game.turn && game.phase <= 6 && game.status === 'playing'; count++) {
|
||||||
|
if (game.phase === 5 && game.active && truthRevivalClean(game)) {
|
||||||
|
game = act(game, game.active, {type: 'endMovement'}); continue;
|
||||||
|
}
|
||||||
|
const next = nextTruthRevival(game); assert.ok(next, 'Finish the real turn without inventing a target battle.');
|
||||||
|
game = act(game, next.actor, next.action);
|
||||||
|
}
|
||||||
|
assert.equal(records(game).length, 1); assert.equal(records(game)[0].status, 'expired');
|
||||||
|
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
|
||||||
|
assert.deepEqual(inventory(game), f.originalCards);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a pending Truthtrance priority must finish before its Charity-phase question can be asked', () => {
|
||||||
|
const f = fixture();
|
||||||
|
const game = act(f.game, f.asker, {type: 'card', card: f.truth});
|
||||||
|
assert.equal(game.truthtrance?.stage, 'priority');
|
||||||
|
rejected(game, f.asker, f.ask);
|
||||||
|
assert.deepEqual(inventory(game), f.originalCards);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user