180 lines
6.9 KiB
TypeScript
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.',
|
|
);
|
|
}
|
|
}
|