Files
Dune/game/victory-progress.ts
Quintine 9cb65a21f5
Verify and publish checkpoint container / container (push) Canceled after 0s
Prototype unlimited alliance variant
2026-10-29 00:33:42 +11:00

180 lines
6.9 KiB
TypeScript

import type { Game } from './engine';
import {
settledBoard,
type AdvisorRelease,
type BoardContext,
} from './board-resolution-quote';
import { ecazOccupancyIdentity } from './ecaz-occupy';
import { gameTerritories } from './board';
import { fighterCount } from './advisors';
import { TECH_TOKENS } from './tech-tokens';
import { isDiscoveryLocationId, JACURUTU_SIETCH } from './discoveries';
import type { TwoPlayerControllers } from './two-player-controllers';
import { allianceVictoryTarget } from './alliance-victory-option';
import { coalitionGroups } from './coalition-alliance';
export type StrongholdProgress = {
player: string;
members: string[];
strongholds: string[];
techStronghold: boolean;
target: number;
jointlyOccupied: string[];
occupyTarget: 3 | null;
qualifies: boolean;
};
export class VictoryProgressError extends Error {
constructor(message: string) {
super(message);
this.name = 'VictoryProgressError';
}
}
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
function requireProgress(
condition: unknown,
message: string,
): asserts condition {
if (!condition) throw new VictoryProgressError(message);
}
/** Ordered side membership for the bounded coalition victory quote. Three-member
* Advanced and Ecaz combinations remain unavailable until their board rules
* and faction interactions are integrated. No game mode is enabled here. */
export function victoryMembers(g: BoardContext, player: string): string[] {
const seat = g.players.find(candidate => candidate.id === player);
requireProgress(seat, 'Victory needs a seated player.');
if (g.coalitionState === undefined)
return g.order.filter(member => member === player || member === seat.ally);
const groups = coalitionGroups(g.players, g.coalitionState);
requireProgress(!groups.some(group => group.members.length >= 3 &&
(g.advanced || g.players.some(candidate => candidate.faction === 'ecaz'))),
'Three-member victory rules require Basic play without Ecaz.');
const group = groups.find(candidate => candidate.members.includes(player));
return g.order.filter(member => member === player || !!group?.members.includes(member));
}
/** Discovery locations use stronghold shipping and occupancy rules, but only
* Jacurutu Sietch counts toward the normal stronghold victory. */
export function countsForStrongholdVictory(id: string) {
return !isDiscoveryLocationId(id) || id === JACURUTU_SIETCH;
}
/** Public prospective stronghold victory only. No prediction, special endgame
* victory, combat resolution, resource mutation or Stronghold Card ownership is
* considered. Mandatory advisor releases are returned for the caller to commit. */
export function strongholdProgress(
g: BoardContext & Pick<Game, 'techTokens'> & {
victoryExtraStrongholds?: number;
twoPlayerControllers?: TwoPlayerControllers;
shortAllianceVictory?: boolean;
},
): { released: AdvisorRelease[]; progress: StrongholdProgress[] } {
try {
requireProgress(
record(g) &&
typeof g.advanced === 'boolean' &&
Array.isArray(g.players) &&
g.players.length > 0 &&
g.players.every((p) => record(p) && record(p.forces)),
'Victory progress requires a valid public board and seated players.',
);
const extra = g.victoryExtraStrongholds ?? 0;
requireProgress(Number.isSafeInteger(extra) && extra >= 0 && extra <= 200,
'The agreed extra stronghold target must be a safe nonnegative count.');
requireProgress(g.shortAllianceVictory === undefined || typeof g.shortAllianceVictory === 'boolean',
'Choose whether the shorter alliance victory option is enabled.');
// This validates every ID, faction and reciprocal alliance, even where no
// stronghold has a force. It does not merge ordinary allies for occupancy.
ecazOccupancyIdentity(g.players, g.players[0].id, {
kind: 'territory',
id: 'polar_sink',
});
const boardTerritories = gameTerritories(g);
for (const p of g.players)
if (p.advisors !== undefined)
requireProgress(
record(p.advisors) &&
Object.entries(p.advisors).every(
([id, stance]) =>
boardTerritories.some((t) => t.id === id) && record(stance),
),
'Victory progress requires valid advisor stances.',
);
const { players, released } = settledBoard(g);
const tech = g.techTokens;
if (tech !== undefined && tech !== null) {
requireProgress(
record(tech) &&
Object.keys(tech).length === TECH_TOKENS.length &&
TECH_TOKENS.every(
({ id }) =>
record(tech[id]) &&
(tech[id].owner === null ||
players.some((p) => p.id === tech[id].owner)),
),
'Victory progress requires the complete technology token ownership map.',
);
}
const sites = boardTerritories.filter(
(t) => t.type === 'stronghold' && countsForStrongholdVictory(t.id),
);
const progress = g.order.map((id) => {
const p = players.find((p) => p.id === id)!;
const members = victoryMembers(g, id);
const partner = players.find((other) => other.id === p.ally);
const ecazPair =
!!partner && (p.faction === 'ecaz' || partner.faction === 'ecaz');
const strongholds = sites
.filter((t) => {
const occupants = players.filter(
(other) => fighterCount(other, t.id) > 0,
);
return (
occupants.length > 0 &&
occupants.every((other) => members.includes(other.id))
);
})
.map((t) => t.id);
const jointlyOccupied = ecazPair
? strongholds.filter((t) =>
members.every(
(member) =>
fighterCount(
players.find((p) => p.id === member)!,
t,
) > 0,
),
)
: [];
const techStronghold =
!!tech &&
members.some((member) =>
TECH_TOKENS.every(({ id }) => tech[id].owner === member),
);
const target = allianceVictoryTarget(
players.map((player) => player.id), g.twoPlayerControllers, members.length > 1,
g.coalitionState?.variant === 'unlimited' ? true : members.length >= 3 ? false : g.shortAllianceVictory,
) + extra;
return {
player: id,
members,
strongholds,
techStronghold,
target,
jointlyOccupied,
occupyTarget: ecazPair ? (3 as const) : null,
qualifies:
strongholds.length + Number(techStronghold) >= target ||
jointlyOccupied.length >= 3,
};
});
return { released, progress };
} catch (error) {
if (error instanceof VictoryProgressError) throw error;
throw new VictoryProgressError(
error instanceof Error ? error.message : 'Invalid public victory state.',
);
}
}