Files
Dune/game/nexus-ecaz-betrayal.ts

280 lines
16 KiB
TypeScript

import { gameTerritories, splitLocation, validGameLocation } from './board';
import type { Game, Player } from './engine';
import { nexusCoreProfile } from './faction-module-profile';
import { mixedE3NexusLeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile } from './leader-skill-profile';
import { nexusCardMode, validateNexusCards } from './nexus-cards';
import { mixedE3NexusProfile, pairedE3NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
import { isBoardNoFieldLocation, validateRicheseNoField } from './richese-no-field';
export type EcazBetrayalCounts = Readonly<{ normal: number; elite: number }>;
export type EcazBetrayalTerritory = Readonly<{
territory: string;
/** Visible physical ally group, keyed by current board location. */
sectors: Readonly<Record<string, EcazBetrayalCounts>>;
total: EcazBetrayalCounts;
/** One public concealed marker; its denomination is never a force count here. */
concealed?: true;
}>;
export type EcazBetrayalOffer = Readonly<{
event: string;
blocked: string | null;
ally: string | null;
territories: readonly EcazBetrayalTerritory[];
}>;
export type EcazBetrayalSnapshot = Readonly<{
event: string;
owner: string;
ally: string;
turn: number;
discardIndex: number;
territory: string;
sectors: Readonly<Record<string, EcazBetrayalCounts>>;
total: EcazBetrayalCounts;
/** Private source for a face-down recall, not a reveal/materialization quote. */
noField?: EcazBetrayalNoFieldSource;
}>;
export type EcazBetrayalNoFieldSource = Readonly<{
supplier: string;
event: string;
tokenId: string;
location: Readonly<{ territory: string; sector: number }>;
}>;
const allowedFactions: Record<string, true> = {
atreides: true, harkonnen: true, emperor: true, fremen: true,
guild: true, beneGesserit: true, ecaz: true, moritani: true,
};
const plain = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value) &&
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
const count = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
const exactKeys = (value: unknown, keys: readonly string[]): value is Record<string, unknown> =>
plain(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
function openingReady(g: Game): boolean {
const remaining = g.movementRemaining;
const guildFirst = g.advanced && g.guildTimingGranted === true && !g.guildTimingLocked &&
g.players.some(player => player.faction === 'guild' && player.id === g.active);
return g.status === 'playing' && g.phase === 5 &&
Array.isArray(g.order) && g.order.length === g.players.length &&
new Set(g.order).size === g.players.length &&
g.players.every(p => g.order.includes(p.id) && p.shipped === false && p.moved === 0) &&
Array.isArray(remaining) && remaining.length === g.order.length &&
new Set(remaining).size === remaining.length && remaining.every(id => g.order.includes(id)) &&
remaining.includes(g.active!) && (g.active === remaining[0] || guildFirst);
}
/** Original native E3 envelopes and the selected-independent Nexus core share
* the same effect. Quiet timing and actual alliance/card custody are separate. */
export function ecazBetrayalModeSupported(g: Game): boolean {
if (!g.nexusCards?.cards || !g.players.some(p => p.faction === 'ecaz') || g.sandtrout) return false;
if (nexusCoreProfile(g) || standaloneE3NexusProfile(g) || pairedE3NexusProfile(g) || mixedE3NexusProfile(g) ||
(g.leaderSkills && (standaloneE3NexusLeaderSkillsProfile(g) || pairedE3NexusLeaderSkillsProfile(g) ||
mixedE3NexusLeaderSkillsProfile(g)))) return true;
// Preserve the original unselected-deck audit/runtime shape. Fresh combined
// configurations use the canonical predicates above, not this legacy seam.
return g.expansions.length === 0 && g.players.length >= 2 && g.players.length <= 6 &&
new Set(g.players.map(p => p.faction)).size === g.players.length &&
g.players.every(p => Object.hasOwn(allowedFactions, p.faction)) &&
!g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries &&
!g.discoveryStash && !g.greatMaker && !g.ecazTreachery && !g.techTokens && !g.strongholdCards &&
!g.semutaPreview && !g.advancedPreview && !g.kullPreview && !g.nexusKullPreview &&
!g.guildBetrayalPreview && !g.richeseBetrayalPreview && !g.nexusIxianReplacementPreview &&
!g.nexusIxianBetrayalPreview && !g.nexusHarkonnenBetrayalPreview;
}
function boardLocation(g: Game, key: string): boolean {
const { territory, sector } = splitLocation(key);
return Number.isSafeInteger(sector) && key === `${territory}:${sector}` &&
gameTerritories(g).some(t => t.id === territory) && validGameLocation(g, territory, sector);
}
function locations(g: Game, player: Player): Map<string, Record<string, EcazBetrayalCounts>> {
if (!plain(player.forces) || (player.elites && !plain(player.elites.forces)))
throw new Error('Ecaz Betrayal needs plain physical force custody.');
const elites = player.elites?.forces;
for (const [key, elite] of Object.entries(elites ?? {})) {
if (!count(elite) || (elite > 0 && !Object.hasOwn(player.forces, key)))
throw new Error('Ecaz Betrayal has invalid elite custody.');
}
const result = new Map<string, Record<string, EcazBetrayalCounts>>();
for (const [key, total] of Object.entries(player.forces)) {
const { territory } = splitLocation(key);
if (!boardLocation(g, key) || !count(total))
throw new Error('Ecaz Betrayal has invalid physical board custody.');
const elite = elites && Object.hasOwn(elites, key) ? elites[key] : 0;
if (!count(elite) || elite > total)
throw new Error('Ecaz Betrayal has invalid typed force custody.');
if (!total) continue;
const sectors = result.get(territory) ?? {};
sectors[key] = { normal: total - elite, elite };
result.set(territory, sectors);
}
return result;
}
/** Public presence only. Do not inspect the selected token or its value when
* constructing offers, even for a zero marker or a controller with no reserves. */
function markerSupplier(g: Game, ally: string, territory: string): Player | undefined {
const suppliers = g.players.filter(p => p.noField?.deployed?.controller === ally &&
isBoardNoFieldLocation(p.noField.deployed.location) && p.noField.deployed.location.territory === territory);
if (suppliers.length > 1) throw new Error('Ecaz Betrayal has multiple concealed marker sources.');
return suppliers[0];
}
function markerSource(g: Game, ally: string, territory: string): EcazBetrayalNoFieldSource | undefined {
const supplier = markerSupplier(g, ally, territory);
if (!supplier) return undefined;
const marker = supplier.noField!.deployed!;
validateRicheseNoField(supplier.noField!);
if (!isBoardNoFieldLocation(marker.location)) return undefined;
if (supplier.faction !== 'richese' || typeof supplier.noFieldEvent !== 'string' || !supplier.noFieldEvent ||
!boardLocation(g, `${marker.location.territory}:${marker.location.sector}`))
throw new Error('Ecaz Betrayal has invalid concealed marker custody.');
return { supplier: supplier.id, event: supplier.noFieldEvent, tokenId: marker.tokenId,
location: { ...marker.location } };
}
function allyLocations(g: Game, ally: Player): Map<string, Record<string, EcazBetrayalCounts>> {
const result = locations(g, ally);
for (const supplier of g.players) {
const marker = supplier.noField?.deployed;
if (!marker || marker.controller !== ally.id || !isBoardNoFieldLocation(marker.location)) continue;
if (!boardLocation(g, `${marker.location.territory}:${marker.location.sector}`))
throw new Error('Ecaz Betrayal has invalid concealed board custody.');
if (!result.has(marker.location.territory)) result.set(marker.location.territory, {});
}
return result;
}
function territoryQuote(territory: string, sectors: Record<string, EcazBetrayalCounts>): EcazBetrayalTerritory {
let normal = 0, elite = 0;
for (const group of Object.values(sectors)) {
normal += group.normal;
elite += group.elite;
}
if (!Number.isSafeInteger(normal) || !Number.isSafeInteger(elite))
throw new Error('Ecaz Betrayal force totals exceed physical custody.');
return { territory, sectors, total: { normal, elite } };
}
/** Public-only offer for an unallied holder of Ecaz's actual Betrayal card.
* The engine separately authorizes card custody and handles Karama response. */
export function ecazBetrayalOffer(g: Game, owner: string, automaticPending = false): EcazBetrayalOffer | null {
const holder = g.players.find(p => p.id === owner);
const ecaz = g.players.find(p => p.faction === 'ecaz');
const cards = g.nexusCards?.cards;
if (!holder || !ecaz || holder.id === ecaz.id || holder.faction === 'ecaz' ||
!cards || !Object.hasOwn(cards.hands, owner) || cards.hands[owner] !== 'ecaz' ||
nexusCardMode('ecaz', holder.faction, g.players.map(p => p.faction)) !== 'betrayal') return null;
const ally = g.players.find(p => p.id === ecaz.ally && p.ally === ecaz.id);
const event = JSON.stringify(['nexusEcazBetrayal', g.turn, owner, ally?.id ?? null, cards.discard.length]);
let blocked: string | null = null;
if (holder.ally) blocked = 'An allied player cannot hold or play a Nexus card.';
else if (!openingReady(g))
blocked = 'Use Ecaz Betrayal before the first Shipment and Movement action.';
else if (!ally) blocked = 'Ecaz needs a real reciprocal alliance.';
else if (!ecazBetrayalModeSupported(g))
blocked = 'This Ecaz Betrayal roster and module combination is unavailable.';
else if (automaticPending || g.response || g.decision || g.truthtrance ||
g.phaseOpening || g.battle || g.pendingTreacheryDiscard || g.pendingExchange ||
g.pendingKarama || g.pendingRevival || g.pendingAmbassador || g.pendingCapture ||
g.pendingFremenMove || g.pendingMobileMove || g.pendingIxMove || g.pendingNullentropy ||
g.pendingTerrorEntry || g.pendingMoritaniPlacement || g.pendingRicheseGift ||
g.nexusTraitorPending || g.nexusCards?.phase?.stage === 'drawing')
blocked = 'Finish the current interaction before using Ecaz Betrayal.';
if (!blocked) validateNexusCards(cards, g.players);
const territories: EcazBetrayalTerritory[] = [];
if (!blocked && ally) {
const occupied = locations(g, ecaz);
for (const [territory, sectors] of allyLocations(g, ally))
if (occupied.has(territory)) territories.push({ ...territoryQuote(territory, sectors),
...(markerSupplier(g, ally.id, territory) ? { concealed: true as const } : {}) });
if (!territories.length) blocked = 'Ecaz and its ally share no territory containing physical forces.';
}
return { event, blocked, ally: ally?.id ?? null, territories };
}
/** Quote an accepted *entire* ally group before spending the card or returning
* forces. All inputs are the authoritative live Game and authenticated owner,
* plus the submitted event and one offered public territory. Throws if stale. */
export function quoteEcazBetrayal(g: Game, owner: string, event: string, territory: string): EcazBetrayalSnapshot {
const offer = ecazBetrayalOffer(g, owner);
if (!offer || offer.blocked || event !== offer.event)
throw new Error(offer?.blocked ?? 'This Ecaz Betrayal opportunity is stale.');
const selected = offer.territories.find(row => row.territory === territory);
if (!selected) throw new Error('Choose one shared territory with Ecaz and its ally.');
const noField = markerSource(g, offer.ally!, territory);
return { event, owner, ally: offer.ally!, turn: g.turn, territory,
discardIndex: g.nexusCards!.cards!.discard.length,
sectors: selected.sectors, total: selected.total, ...(noField ? { noField } : {}) };
}
/** Validate a saved pre-return quote against frozen authoritative physical
* custody. pending=true is only for the engine's own saved, already-spent card
* response frame; the caller must authenticate that frame separately. */
export function validateEcazBetrayalSnapshot(
g: Game, snapshot: EcazBetrayalSnapshot, pending = false,
): void {
if (!exactKeys(snapshot, ['event', 'owner', 'ally', 'turn', 'territory', 'discardIndex', 'sectors', 'total',
...(snapshot?.noField !== undefined ? ['noField'] : [])]) ||
typeof snapshot.event !== 'string' || typeof snapshot.owner !== 'string' ||
typeof snapshot.ally !== 'string' || typeof snapshot.territory !== 'string' ||
!Number.isSafeInteger(snapshot.turn) || snapshot.turn !== g.turn ||
!Number.isSafeInteger(snapshot.discardIndex) || snapshot.discardIndex < 0 ||
!exactKeys(snapshot.total, ['normal', 'elite']) ||
!count(snapshot.total.normal) || !count(snapshot.total.elite) ||
!plain(snapshot.sectors) || (!Object.keys(snapshot.sectors).length && !snapshot.noField) ||
!gameTerritories(g).some(t => t.id === snapshot.territory))
throw new Error('Ecaz Betrayal has an invalid saved force snapshot.');
if (snapshot.noField !== undefined && (!exactKeys(snapshot.noField, ['supplier', 'event', 'tokenId', 'location']) ||
typeof snapshot.noField.supplier !== 'string' || !snapshot.noField.supplier ||
typeof snapshot.noField.event !== 'string' || !snapshot.noField.event ||
typeof snapshot.noField.tokenId !== 'string' || !snapshot.noField.tokenId ||
!exactKeys(snapshot.noField.location, ['territory', 'sector']) ||
snapshot.noField.location.territory !== snapshot.territory ||
!boardLocation(g, `${snapshot.noField.location.territory}:${snapshot.noField.location.sector}`)))
throw new Error('Ecaz Betrayal has an invalid saved concealed marker.');
for (const [key, group] of Object.entries(snapshot.sectors)) {
const { territory } = splitLocation(key);
if (territory !== snapshot.territory || !boardLocation(g, key) ||
!exactKeys(group, ['normal', 'elite']) || !count(group.normal) || !count(group.elite) ||
group.normal + group.elite <= 0 || !Number.isSafeInteger(group.normal + group.elite))
throw new Error('Ecaz Betrayal has an invalid saved sector group.');
}
let fresh: EcazBetrayalSnapshot;
if (pending) {
const cards = g.nexusCards?.cards;
const ecaz = g.players.find(p => p.faction === 'ecaz');
const ally = g.players.find(p => p.id === snapshot.ally);
const holder = g.players.find(p => p.id === snapshot.owner);
if (!openingReady(g) || !ecazBetrayalModeSupported(g) || !cards || cards.hands[snapshot.owner] !== null ||
cards.discard[snapshot.discardIndex] !== 'ecaz' || !ecaz || !ally ||
ecaz.ally !== ally.id || ally.ally !== ecaz.id || !holder ||
holder.ally || holder.faction === 'ecaz' ||
snapshot.event !== JSON.stringify(['nexusEcazBetrayal', g.turn,
snapshot.owner, ally.id, snapshot.discardIndex]))
throw new Error('Ecaz Betrayal has lost its pending card or alliance.');
validateNexusCards(cards, g.players);
const occupied = locations(g, ecaz);
const selected = allyLocations(g, ally).get(snapshot.territory);
if (!occupied.has(snapshot.territory) || !selected)
throw new Error('Ecaz Betrayal has lost the shared physical territory.');
const noField = markerSource(g, ally.id, snapshot.territory);
fresh = { event: snapshot.event, turn: g.turn, owner: snapshot.owner,
ally: ally.id, discardIndex: snapshot.discardIndex,
...territoryQuote(snapshot.territory, selected), ...(noField ? { noField } : {}) };
} else fresh = quoteEcazBetrayal(g, snapshot.owner, snapshot.event, snapshot.territory);
if (snapshot.ally !== fresh.ally || snapshot.discardIndex !== fresh.discardIndex ||
snapshot.total.normal !== fresh.total.normal || snapshot.total.elite !== fresh.total.elite ||
snapshot.noField?.supplier !== fresh.noField?.supplier || snapshot.noField?.event !== fresh.noField?.event ||
snapshot.noField?.tokenId !== fresh.noField?.tokenId ||
snapshot.noField?.location.territory !== fresh.noField?.location.territory ||
snapshot.noField?.location.sector !== fresh.noField?.location.sector ||
Object.keys(snapshot.sectors).length !== Object.keys(fresh.sectors).length ||
Object.entries(fresh.sectors).some(([key, group]) =>
!Object.hasOwn(snapshot.sectors, key) ||
snapshot.sectors[key].normal !== group.normal || snapshot.sectors[key].elite !== group.elite))
throw new Error('Ecaz Betrayal no longer matches its original physical ally group.');
}