Files
Dune/game/board-resolution-quote.ts
T
Quintine f0256c1b38 Prototype Discovery spice encounters, private tokens and stash rewards
Connect real Basic/Advanced base-faction audit setup to all seven Discovery
Spice Cards and eight physical tokens. Add Great Maker's ordered majority
vote and Fremen reserve ride, destructive Discovery blows, private faction
peeks and Collection inspection/reveal, stash rewards and mandatory saved
card overflow, and revealed nested board locations. Share private legal
choices with human controls and all four AI profiles. Preserve normal gates.

Independent review repairs orphaned worm/vote/ride controls, retargeted
reserve arrivals, actor bindings and incomplete overflow discard receipts.
Harden room-aware admission against unrevealed nested locations. Extend the
backed-up local prototype starter with a Discovery profile; preserve seats.

Still missing: next-turn free entry, five special location benefits and
Ornithopter movement spending. Current prototype playthroughs do not certify
those effects or complete module/expansion compliance.

Verification: npm run check passed 4264 offline tests plus types/lint;
npm run build passed; npm run test:integration passed 40 tests. The single
source-bound broad run took 129358ms / 6782ms / 23918ms respectively.
Report: /home/quintine/.local/share/dune/checkpoints/20260913-discovery-prototype/verification/report.json
Source fingerprint: 4ea7b5209a58c369b0b1f8dc239b5dd2f3302bb759d83c32ba5406e80d8ae154

Two seeded Basic/Advanced games complete with component custody intact.
SQLite races pay stashes once and resume committed discards without replay.
Browser 3RJBH26Y verifies genuine human Fremen setup, a Discovery blow from
one reordered physical card, native private Hiereg visibility, enlarged
card/rules reading and refresh. Room remains paused at spice acceptance.
All 125 opening saved rooms unchanged; 31 added, 156 total. No server restart.

Efficiency: three reused bounded helpers; aggregate counter 60591430 to
61812513 after verification, 1221083 tokens before final Git/push overhead.
47 added offline tests. One stable broad run after focused review fixes;
no calibration or exhaustive optional-module interaction matrix.
2026-09-13 13:27:58 +10:00

385 lines
13 KiB
TypeScript

import type { Player } from './engine';
import type { ForcePresence } from './force-presence';
import { presenceAt, 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;
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))
if (
!presenceAt(p, t) ||
!g.players.some((other) => other.id !== p.id && presenceAt(other, t))
) {
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 function quoteBattleBoard(g: BoardContext) {
const { players, released } = settledBoard(g);
const battles: { territory: string; attacker: string; defender: 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 keys = (s: BoardSeat) =>
Object.entries(presenceByLocation(s))
.filter(
([key, n]) => n > 0 && splitLocation(key).territory === t.id,
)
.map(([key]) => key);
if (
keys(p).some((x) =>
keys(other).some(
(y) =>
gameDistance(
g,
x,
y,
(key) =>
splitLocation(
key === MOBILE_LOCATION
? (g.mobileStronghold?.location ?? key)
: key,
).sector === g.storm,
) === 0,
),
)
)
battles.push({ territory: t.id, attacker: id, defender: other.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;
}[];
/** 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'] = [];
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);
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, 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 };
}