import type { Player } from './engine'; import { advisorStanceAfterSettlement } from './advisor-automatic-flip'; import type { ForcePresence } from './force-presence'; import { presenceByLocation } from './force-presence'; import { fighterCount, isAdvisor } from './advisors'; import { quoteAidRefunds, PhaseResourceError } from './phase-resource-quote'; import { ecazOccupancyIdentity } from './ecaz-occupy'; 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 & ForcePresence; export type BoardContext = MobileBoard & { advanced: boolean; /** Fresh Basic coalition protocol; existing played Basic tables are not retrofitted. */ ecazOccupyPreview?: boolean; storm: number; order: readonly string[]; players: readonly BoardSeat[]; }; 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( 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, 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); 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(); 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 || !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 }; } export type CollectionContext = Omit & { players: readonly (BoardSeat & Pick)[]; spice: Readonly>; }; export type CollectionQuote = { released: AdvisorRelease[]; spice: Record; 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 => !!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>(); 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(); 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(); 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(); 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>; }; /** 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; 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 }; }