460 lines
18 KiB
TypeScript
460 lines
18 KiB
TypeScript
import type { Player } from './engine';
|
|
import { FACTIONS } from './catalog';
|
|
import { advisorStanceAfterSettlement } from './advisor-automatic-flip';
|
|
import type { ForcePresence } from './force-presence';
|
|
import { presenceAt, presenceByLocation } from './force-presence';
|
|
import { isHomeworldNoFieldLocation } from './richese-no-field';
|
|
import { fighterCount, isAdvisor } from './advisors';
|
|
import { quoteAidRefunds, PhaseResourceError } from './phase-resource-quote';
|
|
import { ecazOccupancyIdentity, ecazOccupancyRelation } from './ecaz-occupy';
|
|
import { quoteAlliedSeparation } from './allied-separation';
|
|
import { areCoalitionAllies, coalitionAllies, coalitionEdgeTurn, coalitionGroups, type CoalitionState } from './coalition-alliance';
|
|
import type { SharedSpiceLot } from './ecaz-spice-allocation';
|
|
import {
|
|
gameDistance,
|
|
gameTerritories,
|
|
MOBILE_LOCATION,
|
|
splitLocation,
|
|
TERRITORIES,
|
|
validGameLocation,
|
|
type MobileBoard,
|
|
} from './board';
|
|
|
|
export class BoardResolutionError extends Error {}
|
|
function requireBoard(condition: unknown, message: string): asserts condition {
|
|
if (!condition) throw new BoardResolutionError(message);
|
|
}
|
|
const whole = (n: unknown): n is number =>
|
|
Number.isSafeInteger(n) && (n as number) >= 0;
|
|
export type BoardSeat = Pick<Player, 'id' | 'faction' | 'ally' | 'allySinceTurn' | 'advisors'> &
|
|
ForcePresence;
|
|
export type BoardContext = MobileBoard & {
|
|
advanced: boolean;
|
|
/** Existing pair games retain their original protocol. */
|
|
ecazOccupyPreview?: boolean;
|
|
storm: number;
|
|
order: readonly string[];
|
|
players: readonly BoardSeat[];
|
|
coalitionState?: CoalitionState;
|
|
};
|
|
|
|
/** Advanced larger groups are currently scoped to the classic roster. */
|
|
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.players.some(player => player.faction === 'ecaz') ||
|
|
g.advanced && g.players.some(player => !FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'))))
|
|
throw new Error('Larger alliance board rules require a supported roster without Ecaz.');
|
|
} catch (error) {
|
|
throw new BoardResolutionError((error as Error).message);
|
|
}
|
|
}
|
|
export type AdvisorRelease = { player: string; territory: string };
|
|
function validKey(g: MobileBoard, key: string) {
|
|
const p = splitLocation(key);
|
|
return (
|
|
key === `${p.territory}:${p.sector}` &&
|
|
validGameLocation(g, p.territory, p.sector)
|
|
);
|
|
}
|
|
/** A public board projection: no leader, hand, secret No-Field value or resource
|
|
* reads. It returns stance changes instead of applying settleAdvisors to a clone. */
|
|
export function settledBoard(g: BoardContext) {
|
|
requireBoard(
|
|
whole(g.storm) &&
|
|
g.storm <= 18 &&
|
|
Array.isArray(g.order) &&
|
|
new Set(g.order).size === g.order.length &&
|
|
new Set(g.players.map((p) => p.id)).size === g.players.length &&
|
|
g.order.length === g.players.length &&
|
|
g.players.every((p) => g.order.includes(p.id)),
|
|
'Board resolution needs the current distinct player order and storm sector.',
|
|
);
|
|
if (g.mobileStronghold?.location) {
|
|
const pointer = splitLocation(g.mobileStronghold.location);
|
|
requireBoard(
|
|
validKey(g, g.mobileStronghold.location) &&
|
|
TERRITORIES.some(
|
|
(t) =>
|
|
t.id === pointer.territory && t.sectors.includes(pointer.sector),
|
|
),
|
|
'The mobile stronghold needs its board pointer.',
|
|
);
|
|
}
|
|
for (const p of g.players) {
|
|
requireBoard(
|
|
Object.entries(p.forces).every(
|
|
([key, n]) => validKey(g, key) && whole(n),
|
|
) && whole(Object.values(p.forces).reduce((a, n) => a + n, 0)),
|
|
'Board resolution needs valid physical force locations and counts.',
|
|
);
|
|
const marker = p.noField?.deployed?.location;
|
|
if (marker)
|
|
requireBoard(
|
|
isHomeworldNoFieldLocation(marker) || validGameLocation(g, marker.territory, marker.sector),
|
|
'The No-Field needs a valid board location.',
|
|
);
|
|
}
|
|
const released: AdvisorRelease[] = [];
|
|
const players = g.players.map((p) => {
|
|
if (!g.advanced || p.faction !== 'beneGesserit' || !p.advisors) return p;
|
|
const advisors = { ...p.advisors };
|
|
for (const t of Object.keys(advisors)) {
|
|
const stance = advisorStanceAfterSettlement(g, p, t);
|
|
if (stance) advisors[t] = stance;
|
|
else {
|
|
delete advisors[t];
|
|
released.push({ player: p.id, territory: t });
|
|
}
|
|
}
|
|
return {
|
|
id: p.id,
|
|
faction: p.faction,
|
|
ally: p.ally,
|
|
allySinceTurn: p.allySinceTurn,
|
|
forces: p.forces,
|
|
noField: p.noField,
|
|
advisors,
|
|
};
|
|
});
|
|
return { players, released };
|
|
}
|
|
export type BattleBoardBattle = {
|
|
territory: string;
|
|
attacker: string;
|
|
defender: string;
|
|
/** Actual co-side members, retained when one seat represents the joint army. */
|
|
coalition?: readonly string[];
|
|
};
|
|
export function quoteBattleBoard(g: BoardContext) {
|
|
const { players, released } = settledBoard(g);
|
|
validateBoardCoalitions(g);
|
|
const battles: BattleBoardBattle[] = [];
|
|
const connected = (a: BoardSeat, b: BoardSeat, site: string) => {
|
|
const left = Object.entries(presenceByLocation(a))
|
|
.filter(([key, n]) => n > 0 && splitLocation(key).territory === site);
|
|
const right = Object.entries(presenceByLocation(b))
|
|
.filter(([key, n]) => n > 0 && splitLocation(key).territory === site);
|
|
const clear = (x: string, y: string) => gameDistance(g, x, y,
|
|
key => splitLocation(key === MOBILE_LOCATION ? (g.mobileStronghold?.location ?? key) : key).sector === g.storm) === 0;
|
|
return g.advanced
|
|
? left.length > 0 && right.length > 0 && left.every(([x]) => right.every(([y]) => clear(x, y)))
|
|
: left.some(([x]) => right.some(([y]) => clear(x, y)));
|
|
};
|
|
const members = (seat: BoardSeat, site: string) => {
|
|
const ally = players.find(other => other.id === seat.ally && other.ally === seat.id);
|
|
return (g.advanced || g.ecazOccupyPreview === true) && ally && (seat.faction === 'ecaz' || ally.faction === 'ecaz') &&
|
|
fighterCount(ally, site) > 0 ? [seat, ally] : [seat];
|
|
};
|
|
const emitted = new Set<string>();
|
|
for (const id of g.order) {
|
|
const p = players.find((p) => p.id === id)!;
|
|
for (const t of gameTerritories(g)) {
|
|
if (t.type === 'polar' || !fighterCount(p, t.id)) continue;
|
|
for (const other of players) {
|
|
if (
|
|
other.id === id ||
|
|
other.id === p.ally ||
|
|
(g.coalitionState !== undefined && areCoalitionAllies(players, g.coalitionState, id, other.id)) ||
|
|
!fighterCount(other, t.id) ||
|
|
g.order.indexOf(other.id) < g.order.indexOf(id)
|
|
)
|
|
continue;
|
|
const left = members(p, t.id), right = members(other, t.id);
|
|
if (!left.every(a => right.every(b => connected(a, b, t.id)))) continue;
|
|
const sides = [left, right].map(side => side.sort((a, b) => g.order.indexOf(a.id) - g.order.indexOf(b.id)))
|
|
.sort((a, b) => g.order.indexOf(a[0].id) - g.order.indexOf(b[0].id));
|
|
const key = JSON.stringify([t.id, sides.map(side => side.map(seat => seat.id).sort())]);
|
|
if (emitted.has(key)) continue;
|
|
emitted.add(key);
|
|
const coalition = sides.find(side => side.length > 1);
|
|
battles.push({ territory: t.id, attacker: sides[0][0].id, defender: sides[1][0].id,
|
|
...(coalition ? { coalition: coalition.map(seat => seat.id) } : {}) });
|
|
}
|
|
}
|
|
}
|
|
return { released, battles };
|
|
}
|
|
|
|
/** Union per-edge separation obligations without inventing an age for an older
|
|
* pair when a third player joins. Advanced applies at each player's turn end. */
|
|
export function quoteCoalitionAlliedSeparation(
|
|
g: BoardContext & { turn: number; remaining: readonly string[] },
|
|
player: string,
|
|
): string[] {
|
|
settledBoard(g);
|
|
validateBoardCoalitions(g);
|
|
requireBoard(whole(g.turn) && g.turn > 0 && Array.isArray(g.remaining) &&
|
|
new Set(g.remaining).size === g.remaining.length &&
|
|
g.remaining.every(id => g.order.includes(id)),
|
|
'Allied separation needs the current turn and remaining player order.');
|
|
const own = g.players.find(p => p.id === player);
|
|
requireBoard(own, 'Allied separation needs a seated player.');
|
|
const allies = g.coalitionState === undefined
|
|
? (own.ally ? [own.ally] : [])
|
|
: coalitionAllies(g.players, g.coalitionState, player);
|
|
const losses = new Set<string>();
|
|
for (const id of allies) {
|
|
const ally = g.players.find(p => p.id === id);
|
|
requireBoard(ally, 'Allied separation needs a seated ally.');
|
|
const edgeTurn = g.coalitionState === undefined ? null : coalitionEdgeTurn(g.players, g.coalitionState, player, id);
|
|
for (const site of quoteAlliedSeparation({
|
|
advanced: g.advanced, turn: g.turn, player, ally: id,
|
|
playerAllySinceTurn: g.coalitionState === undefined ? own.allySinceTurn : edgeTurn ?? undefined,
|
|
allySinceTurn: g.coalitionState === undefined ? ally.allySinceTurn : edgeTurn ?? undefined,
|
|
remaining: g.remaining,
|
|
territories: gameTerritories(g).map(t => ({
|
|
territory: t.id, polar: t.type === 'polar', ownPresent: presenceAt(own, t.id) > 0,
|
|
allyPresent: presenceAt(ally, t.id) > 0, ownAdvisors: isAdvisor(own, t.id),
|
|
allyAdvisors: isAdvisor(ally, t.id),
|
|
ecazCoexist: ecazOccupancyRelation(g.players, player, id, { kind: 'territory', id: t.id }) === 'ecazAlliance',
|
|
})),
|
|
})) losses.add(site);
|
|
}
|
|
return gameTerritories(g).map(t => t.id).filter(id => losses.has(id));
|
|
}
|
|
export type CollectionContext = Omit<BoardContext, 'players'> & {
|
|
players: readonly (BoardSeat & Pick<Player, 'spice' | 'elites'>)[];
|
|
spice: Readonly<Record<string, number>>;
|
|
};
|
|
export type CollectionQuote = {
|
|
released: AdvisorRelease[];
|
|
spice: Record<string, number>;
|
|
receipts: {
|
|
player: string;
|
|
strongholds: number;
|
|
collected: number;
|
|
/** Actual desert deposits, excluding bank income and shared escrow. */
|
|
desert: number;
|
|
balance: number;
|
|
}[];
|
|
/** Positive ordinary map collections in actual storm order, before Orgiz.
|
|
* Shared escrow has no recipient until its original allocation settles. */
|
|
deposits: { player: string; location: string; amount: number }[];
|
|
/** Removed from the map, but not credited until the allies resolve allocation. */
|
|
shared: SharedSpiceLot[];
|
|
collectionBonus: {
|
|
owner: string;
|
|
ally: string;
|
|
strongholds: string[];
|
|
amount: number;
|
|
} | null;
|
|
};
|
|
/** Ordinary collection retains storm order. Ecaz's jointly collected desert
|
|
* lots are quoted once into escrow; this function never assumes agreement. */
|
|
export function quoteSpiceCollection(
|
|
g: CollectionContext,
|
|
ecazCollectionCanceled = false,
|
|
): CollectionQuote {
|
|
const record = (value: unknown): value is Record<string, unknown> =>
|
|
!!value && typeof value === 'object' && !Array.isArray(value);
|
|
requireBoard(
|
|
record(g) &&
|
|
typeof g.advanced === 'boolean' &&
|
|
typeof ecazCollectionCanceled === 'boolean' &&
|
|
Array.isArray(g.players) &&
|
|
g.players.length > 0 &&
|
|
g.players.every((p) => record(p) && record(p.forces)) &&
|
|
record(g.spice),
|
|
'Collection needs a public board, seated players and spice deposits.',
|
|
);
|
|
try {
|
|
ecazOccupancyIdentity(g.players, g.players[0].id, {
|
|
kind: 'territory',
|
|
id: 'polar_sink',
|
|
});
|
|
} catch (error) {
|
|
throw new BoardResolutionError((error as Error).message);
|
|
}
|
|
const sites = gameTerritories(g);
|
|
for (const p of g.players) {
|
|
requireBoard(whole(p.spice), 'Collection needs a valid spice balance.');
|
|
if (p.advisors !== undefined)
|
|
requireBoard(
|
|
record(p.advisors) &&
|
|
Object.entries(p.advisors).every(
|
|
([id, stance]) =>
|
|
sites.some((t) => t.id === id) &&
|
|
record(stance) &&
|
|
(stance.lockedTurn === undefined || whole(stance.lockedTurn)),
|
|
),
|
|
'Collection needs valid advisor stances.',
|
|
);
|
|
if (p.elites !== undefined)
|
|
requireBoard(
|
|
record(p.elites) &&
|
|
record(p.elites.forces) &&
|
|
Object.entries(p.elites.forces).every(
|
|
([key, n]) =>
|
|
validKey(g, key) && whole(n) && n <= (p.forces[key] ?? 0),
|
|
),
|
|
'Collection needs valid physical elite groups.',
|
|
);
|
|
}
|
|
const { players, released } = settledBoard(g);
|
|
const spice = { ...g.spice };
|
|
requireBoard(
|
|
Object.entries(spice).every(([key, n]) => validKey(g, key) && whole(n)),
|
|
'Collection needs valid spice locations and quantities.',
|
|
);
|
|
const capacities = new Map<string, Map<string, number>>();
|
|
for (const p of players) {
|
|
const original = g.players.find((other) => other.id === p.id)!;
|
|
const rate =
|
|
fighterCount(p, 'arrakeen') || fighterCount(p, 'carthag') ? 3 : 2;
|
|
const capacity = new Map<string, number>();
|
|
for (const [key, n] of Object.entries(presenceByLocation(p))) {
|
|
if (
|
|
splitLocation(key).sector === g.storm ||
|
|
isAdvisor(p, splitLocation(key).territory)
|
|
)
|
|
continue;
|
|
const cyborgs =
|
|
p.faction === 'ixians' ? (original.elites?.forces[key] ?? 0) : 0;
|
|
const amount = (n - cyborgs) * rate + cyborgs * 3;
|
|
requireBoard(
|
|
whole(n) && whole(amount),
|
|
'Collection capacity would overflow its physical group.',
|
|
);
|
|
capacity.set(key, amount);
|
|
}
|
|
capacities.set(p.id, capacity);
|
|
}
|
|
const ecaz = players.find((p) => p.faction === 'ecaz');
|
|
const ally = ecaz && players.find((p) => p.id === ecaz.ally);
|
|
const jointTerritories = new Set<string>();
|
|
if (ecaz && ally)
|
|
for (const t of sites.filter((site) => site.type === 'sand')) {
|
|
const collecting = (id: string) =>
|
|
[...capacities.get(id)!].some(
|
|
([key, n]) =>
|
|
n > 0 &&
|
|
(spice[key] ?? 0) > 0 &&
|
|
splitLocation(key).territory === t.id,
|
|
);
|
|
if (collecting(ecaz.id) && collecting(ally.id))
|
|
jointTerritories.add(t.id);
|
|
}
|
|
let collectionBonus: CollectionQuote['collectionBonus'] = null;
|
|
if (g.advanced && ecaz && ally) {
|
|
const strongholds = ['arrakeen', 'carthag', 'tueks_sietch'].filter(
|
|
(t) => fighterCount(ecaz, t) > 0 && fighterCount(ally, t) > 0,
|
|
);
|
|
if (strongholds.length)
|
|
collectionBonus = {
|
|
owner: ecaz.id,
|
|
ally: ally.id,
|
|
strongholds,
|
|
amount: strongholds.reduce(
|
|
(total, t) => total + (t === 'tueks_sietch' ? 1 : 2),
|
|
0,
|
|
),
|
|
};
|
|
}
|
|
const shared: SharedSpiceLot[] = [];
|
|
const pooled = new Set<string>();
|
|
const receipts: CollectionQuote['receipts'] = [];
|
|
const deposits: CollectionQuote['deposits'] = [];
|
|
for (const id of g.order) {
|
|
const p = players.find((p) => p.id === id)!,
|
|
original = g.players.find((p) => p.id === id)!;
|
|
let strongholds = g.advanced
|
|
? (fighterCount(p, 'arrakeen') ? 2 : 0) +
|
|
(fighterCount(p, 'carthag') ? 2 : 0) +
|
|
(fighterCount(p, 'tueks_sietch') ? 1 : 0)
|
|
: 0;
|
|
if (ecazCollectionCanceled && collectionBonus?.owner === id)
|
|
strongholds -= collectionBonus.amount;
|
|
let collected = 0;
|
|
let desert = 0;
|
|
for (const [key, capacity] of capacities.get(id)!) {
|
|
const t = splitLocation(key).territory;
|
|
if (
|
|
ecaz &&
|
|
ally &&
|
|
(id === ecaz.id || id === ally.id) &&
|
|
jointTerritories.has(t)
|
|
) {
|
|
if (pooled.has(t)) continue;
|
|
pooled.add(t);
|
|
let amount = 0;
|
|
for (const [at, remaining] of Object.entries(spice)) {
|
|
if (splitLocation(at).territory !== t) continue;
|
|
const jointCapacity =
|
|
(capacities.get(ecaz.id)!.get(at) ?? 0) +
|
|
(capacities.get(ally.id)!.get(at) ?? 0);
|
|
requireBoard(
|
|
whole(jointCapacity),
|
|
'Joint collection capacity would overflow.',
|
|
);
|
|
const take = Math.min(remaining, jointCapacity);
|
|
amount += take;
|
|
requireBoard(whole(amount), 'Joint collection would overflow.');
|
|
spice[at] = remaining - take;
|
|
}
|
|
if (amount > 0)
|
|
shared.push({ territory: t, ecaz: ecaz.id, ally: ally.id, amount });
|
|
continue;
|
|
}
|
|
const amount = Math.min(spice[key] ?? 0, capacity);
|
|
if (amount > 0) deposits.push({ player: id, location: key, amount });
|
|
collected += amount;
|
|
if (sites.some((site) => site.id === t && site.type === 'sand'))
|
|
desert += amount;
|
|
spice[key] = (spice[key] ?? 0) - amount;
|
|
}
|
|
const balance = original.spice + strongholds + collected;
|
|
requireBoard(
|
|
whole(collected) && whole(balance),
|
|
'Collection would overflow the spice balance.',
|
|
);
|
|
receipts.push({ player: id, strongholds, collected, desert, balance });
|
|
}
|
|
return { released, spice, receipts, deposits, shared, collectionBonus };
|
|
}
|
|
|
|
export type BattlePhaseEndContext = CollectionContext & {
|
|
expansions: readonly string[];
|
|
aid: Readonly<Record<string, { recipient: string; amount: number }>>;
|
|
};
|
|
/** Refund currently escrowed aid before the next phase. Ix opens a real phase
|
|
* opportunity before collection, so future collection is not pre-accepted. */
|
|
export function quoteBattlePhaseAdvance(g: BattlePhaseEndContext) {
|
|
let refunds: ReturnType<typeof quoteAidRefunds>;
|
|
try {
|
|
refunds = quoteAidRefunds(g.players, g.aid);
|
|
} catch (error) {
|
|
if (error instanceof PhaseResourceError)
|
|
throw new BoardResolutionError(error.message);
|
|
throw error;
|
|
}
|
|
const collection = g.expansions.includes('ix')
|
|
? null
|
|
: quoteSpiceCollection({
|
|
...g,
|
|
players: g.players.map((p) => ({
|
|
id: p.id,
|
|
faction: p.faction,
|
|
ally: p.ally,
|
|
advisors: p.advisors,
|
|
forces: p.forces,
|
|
elites: p.elites,
|
|
noField: p.noField,
|
|
spice: refunds.find((r) => r.player === p.id)?.balance ?? p.spice,
|
|
})),
|
|
});
|
|
return { refunds, collection };
|
|
}
|
|
|
|
/** Stop at a remaining battle or the CHOAM market; neither is an automatic
|
|
* permission to advance the phase. */
|
|
export function quoteBattleBoardContinuation(g: BattlePhaseEndContext) {
|
|
const board = quoteBattleBoard(g);
|
|
const phase =
|
|
board.battles.length || g.players.some((p) => p.faction === 'choam')
|
|
? null
|
|
: quoteBattlePhaseAdvance(g);
|
|
return { board, phase };
|
|
}
|