Files
Dune/game/board-resolution-quote.ts
Quintine 3ba9087405
Verify and publish checkpoint container / container (push) Successful in 10m53s
Connect staged Advanced classic group alliances
2026-10-29 02:37:23 +11:00

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 };
}