Files
Dune/game/guild-homeworld-shipment.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

216 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
MOBILE_LOCATION,
splitLocation,
TERRITORIES,
validGameLocation,
type MobileBoard,
} from './board';
import {
HomeworldCustodyError,
homeworldForceGroups,
quoteHomeworldCustody,
type HomeworldCustody,
type HomeworldForces,
} from './homeworld-custody';
import type { HomeworldShipmentContext } from './homeworld-shipment';
export type GuildHomeworldShipmentContext = HomeworldShipmentContext & {
storm: number;
board: Record<
string,
{ forces: Record<string, number>; eliteForces: Record<string, number> }
>;
/** The mobile stronghold's ordinary Arrakis pointer, not its interior. */
mobileStronghold?: string | null;
discoveries?: MobileBoard['discoveries'];
};
export type GuildHomeworldShipmentIntent = {
player: string;
destination: string;
sources: Record<string, HomeworldForces>;
};
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
const whole = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
function requireShipment(
condition: unknown,
message: string,
): asserts condition {
if (!condition) throw new HomeworldCustodyError(message);
}
function canonicalSector(context: GuildHomeworldShipmentContext, key: string) {
const { territory, sector } = splitLocation(key);
return (
key === `${territory}:${sector}` &&
validGameLocation({ discoveries: context.discoveries }, territory, sector)
);
}
function printedSector(key: string) {
const { territory: id, sector } = splitLocation(key);
return (
key === `${id}:${sector}` &&
TERRITORIES.some(
(territory) => territory.id === id && territory.sectors.includes(sector),
)
);
}
/** Guild's own special shipment from one Arrakis territory to a Homeworld.
* The caller owns timing, payment, alliance funding, responses and arrival
* effects. No private card, concealed-force value or transport usage is read.
* The mobile stronghold's interior remains protected when its pointer is in
* storm. A Homeworld destination never becomes a fabricated Arrakis sector. */
export function quoteGuildHomeworldShipment(
context: GuildHomeworldShipmentContext,
custody: HomeworldCustody,
intent: GuildHomeworldShipmentIntent,
) {
const worlds = homeworldForceGroups(context, custody);
requireShipment(
whole(context.storm) && context.storm <= 18 && record(context.board),
'Guild shipment needs the current board and storm sector.',
);
requireShipment(
context.players.every(
(p) =>
p.ally === null ||
(typeof p.ally === 'string' &&
p.ally !== p.id &&
context.players.some((other) => other.id === p.ally)),
),
'Homeworld shipment needs valid seated alliance identities.',
);
requireShipment(
record(intent) &&
Object.keys(intent).length === 3 &&
typeof intent.player === 'string' &&
typeof intent.destination === 'string' &&
record(intent.sources),
'Choose the Guild, a destination Homeworld and typed Arrakis source groups.',
);
const player = context.players.find((p) => p.id === intent.player);
const destination = worlds.find((w) => w.id === intent.destination);
requireShipment(
player?.faction === 'guild' && destination,
'Only the Guild may ship from Arrakis to an active Homeworld.',
);
const native = context.players.find((p) => p.id === destination.native)!;
requireShipment(
!(player.ally === native.id && native.ally === player.id),
'You cannot ship to your ally’s Homeworld.',
);
const board = Object.hasOwn(context.board, player.id)
? context.board[player.id]
: undefined;
requireShipment(
record(board) && record(board.forces) && record(board.eliteForces),
'Guild shipment needs its current physical board counters.',
);
const pointer = context.mobileStronghold;
requireShipment(
pointer === undefined ||
pointer === null ||
(typeof pointer === 'string' && printedSector(pointer)),
'The mobile stronghold needs a canonical Arrakis pointer.',
);
for (const [key, amount] of Object.entries(board.forces))
requireShipment(
canonicalSector(context, key) &&
whole(amount) &&
amount <= 20 &&
(key !== MOBILE_LOCATION || !!pointer),
'Guild shipment needs valid physical force locations and counts.',
);
for (const [key, amount] of Object.entries(board.eliteForces))
requireShipment(
canonicalSector(context, key) && whole(amount) && amount === 0,
'Guild counters cannot acquire special force identities.',
);
const boardTotal = Object.values(board.forces).reduce((sum, n) => sum + n, 0);
const worldTotal = worlds.reduce(
(sum, w) =>
sum +
(w.forces[player.id]?.normal ?? 0) +
(w.forces[player.id]?.elite ?? 0),
0,
);
requireShipment(
whole(boardTotal) && boardTotal + worldTotal <= 20,
'Guild board and Homeworld custody exceed its twenty physical counters.',
);
const entries = Object.entries(intent.sources);
requireShipment(
entries.length > 0,
'Choose forces from one Arrakis territory.',
);
let origin: string | undefined;
let amount = 0;
const boardForces = { ...board.forces };
const boardEliteForces = { ...board.eliteForces };
const boardSources: {
key: string;
before: HomeworldForces;
after: HomeworldForces;
}[] = [];
for (const [key, selected] of entries) {
requireShipment(
canonicalSector(context, key) && (key !== MOBILE_LOCATION || !!pointer),
'Choose a canonical, active Arrakis source sector.',
);
const source = splitLocation(key);
requireShipment(source.sector !== context.storm, 'The source is in storm.');
requireShipment(
origin === undefined || origin === source.territory,
'A Guild force group must come from one Arrakis territory.',
);
origin = source.territory;
requireShipment(
record(selected) &&
Object.keys(selected).length === 2 &&
whole(selected.normal) &&
whole(selected.elite) &&
selected.normal > 0 &&
selected.elite === 0 &&
selected.normal <= 20,
'Each source needs a positive physical Guild counter group.',
);
const before = {
normal: Object.hasOwn(board.forces, key) ? board.forces[key] : 0,
elite: 0,
};
requireShipment(
selected.normal <= before.normal,
'The selected Arrakis sector does not hold those physical counters.',
);
const after = { normal: before.normal - selected.normal, elite: 0 };
amount += selected.normal;
if (after.normal) boardForces[key] = after.normal;
else delete boardForces[key];
delete boardEliteForces[key];
boardSources.push({ key, before, after });
}
requireShipment(
amount <= 20,
'A shipment cannot exceed twenty physical counters.',
);
const transferred = quoteHomeworldCustody(context, custody, [
{
homeworld: destination.id,
player: player.id,
withdraw: { normal: 0, elite: 0 },
deposit: { normal: amount, elite: 0 },
},
]);
return {
...transferred,
amount,
elite: 0,
cost: Math.ceil(amount / 2),
origin: origin!,
boardSources,
boardForces,
boardEliteForces,
};
}