162 lines
5.6 KiB
TypeScript
162 lines
5.6 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';
|
|
|
|
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);
|
|
}
|
|
|
|
/** 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.');
|
|
// 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 = g.order.filter(
|
|
(member) => member === id || member === p.ally,
|
|
);
|
|
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, !!partner, 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.',
|
|
);
|
|
}
|
|
}
|