Files
Dune/game/board-resolution-quote.ts
T
Quintine 3aca64412f Offer Karama against mandatory advisor ground conversions
Keep completed moves while allowing native advisor-to-fighter conversion to be prevented after empty arrival or the last outsider's ground departure. Bind each queued response to its actual whole group and movement. Shared settlement honors the explicit provisional continuously-alone cancellation duration; setup remains automatic and non-ground producers remain open.

Validation: 16 related files199/199, types/lint/build, five native move/response/GameTable SSR journeys and eight legal existing-profile paths. Native34/36-action public Advanced sources earn original setup/counters/Karama. Existing standard controls render; no new component-callback, browser/deployed or full-mode certificate. No saved-room/D1/restart/deployment operation.
2026-10-24 19:44:12 +11:00

401 lines
15 KiB
TypeScript

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<Player, 'id' | 'faction' | 'ally' | 'advisors'> &
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<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 ||
!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<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 };
}