Integrate full funded Guild Nexus Betrayal

This commit is contained in:
Quintine committed 2026-10-01 10:57:05 +10:00
1 parent d3bf12fcd8
commit 8a56299155
28 files changed
+3005 -58

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
import type { Action, GameView } from './engine';
/** Public acknowledgement never depends on a hidden card or another seat's
* resources. Only the server's own-seat offer authorizes taking the payment. */
export function botNexusGuildBetrayalActions(game: GameView): Action[] {
const reaction = game.guildBetrayalReaction;
if (game.status !== 'playing' || !reaction?.event ||
!reaction.canPass || reaction.hasPassed) return [];
const own = game.players.find((player) => player.id === game.me);
if (!(own?.bot ?? own?.autopilot)) return [];
return [{
type: reaction.canUse && !reaction.blocked ? 'guildBetrayalUse' : 'guildBetrayalPass',
event: reaction.event,
}];
}
+2
View File
@@ -104,6 +104,7 @@ import { botArrivalBlock, botWormRideArrivalBlock } from './bot-arrival';
import { botSemutaActions } from './bot-semuta';
import { botChoamKullActions, botChoamKullCounterActions } from './bot-choam-kull';
import { botNexusRicheseBetrayalActions } from './bot-nexus-richese-betrayal';
import { botNexusGuildBetrayalActions } from './bot-nexus-guild-betrayal';
import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
import { fighterCount, isAdvisor } from './advisors';
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
@@ -3827,6 +3828,7 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
/** Obligations apply across policy branches, including choosing to move first. */
export function botActions(g: GameView): Action[] {
if (g.guildBetrayalReaction) return botNexusGuildBetrayalActions(g);
if (g.richeseBetrayalReaction) return botNexusRicheseBetrayalActions(g);
if (g.kullReaction) return botChoamKullActions(g);
if (g.kullCounterEvent)
+372 -18
View File
@@ -46,6 +46,7 @@ import { truthKnowledgeOf } from './truthtrance-knowledge';
import { createMoritaniAssassinateOpportunity, moritaniAssassinateTrigger, moritaniAssassinateChoices, quoteMoritaniAssassinate, moritaniAssassinateSignature, validateMoritaniAssassinate, type MoritaniAssassinateState, type MoritaniAssassinateReceipt } from './moritani-assassinate';
import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally';
import { shipmentAvailable } from './shipment-opportunity';
import { createGuildBetrayalInvoice, createGuildBetrayalReceipt, guildBetrayalEligible, guildBetrayalResponders, validateGuildBetrayalInvoice, validateGuildBetrayalHistory, nexusGuildBetrayalSourceReceipt, type GuildBetrayalInvoice, type GuildBetrayalCursor, type GuildBetrayalReceipt, type GuildBetrayalAuthority } from './nexus-guild-betrayal';
import { createNexusGuildCunning, validateNexusGuildCunning, nexusGuildCunningMoves, type NexusGuildCunningReceipt } from './nexus-guild-cunning';
import { createNexusRichese, validateNexusRichese, quoteNexusRicheseShipment, type NexusRicheseReceipt } from './nexus-richese';
import { createNexusInspection, allowNexusInspection, answerNexusInspection, reopenNexusInspection, cancelNexusInspection, reopenNexusNative, answerNexusNative, validateNexusInspection, committedPlanElements, type NexusInspection, type BattleInspectionContext } from './battle-inspections';
@@ -1070,6 +1071,20 @@ type PendingGuildTransport = {
advisors: boolean;
};
type GuildTransportOrder = Omit<PendingGuildTransport, 'event' | 'signature' | 'fromReserves'>;
type JunctionTransportDeclaration = {
player: string;
turn: number;
event: string;
offer: string;
destination: string;
sources: HomeworldShipmentIntent['sources'];
allyPayment: number;
};
type GuildBetrayalContinuation =
| {source: 'reserve'; shipment: PendingShipment}
| {source: 'guildTransport'; frame: GuildTransportOrder; fromReserves: boolean}
| {source: 'homeworld'; shipment: PendingHomeworldShipment}
| {source: 'junction'; transport: JunctionTransportDeclaration};
type RicheseAllyOffer = {
event: string;
owner: string;
@@ -1081,6 +1096,18 @@ type RicheseAllyOffer = {
payer: string;
};
export type Game = {
/** Fresh native Guild/Nexus payment-replacement profile, never inferred from a save. */
guildBetrayalPreview?: boolean;
guildBetrayal?: GuildBetrayalCursor;
nexusGuildBetrayalHistory?: GuildBetrayalReceipt[];
guildBetrayalSourceReceipts?: {event: string; turn: number; shipper: string; sourceSignature: string; receipt: string}[];
pendingGuildBetrayal?: {
invoice: GuildBetrayalInvoice;
continuation: GuildBetrayalContinuation;
required: string[];
passed: string[];
signature: string;
} | null;
/** Explicit bounded Nexus/Richese auction profile; never inferred for old saves. */
richeseBetrayalPreview?: boolean;
richeseBetrayal?: RicheseBetrayalCursor;
@@ -8079,6 +8106,253 @@ export function initializeNexusGameForAudit(state: Game): Game {
'Enable Nexus cards in a fresh audit lobby first.');
return initializeSetupGameForAudit(state, !!state.homeworlds, true);
}
/** Opt-in fresh classic setup through the real physical Nexus/Homeworld setup. */
export function initializeGuildBetrayalGameForAudit(state: Game): Game {
requireRule(!state.guildBetrayalPreview && !state.guildBetrayal && !state.pendingGuildBetrayal &&
!state.nexusGuildBetrayalHistory && !state.guildBetrayalSourceReceipts &&
guildBetrayalModeSupported(state),
'Guild Betrayal needs a fresh classic native-Guild lobby with only Nexus and optional Homeworlds.');
const g = initializeNexusGameForAudit(state);
g.guildBetrayalPreview = true;
g.guildBetrayal = {sequence: 0, event: null, turn: null, sourceSignature: null, status: 'completed'};
g.nexusGuildBetrayalHistory = [];
g.guildBetrayalSourceReceipts = [];
g.pendingGuildBetrayal = null;
return g;
}
function guildBetrayalModeSupported(g: Game): boolean {
return !!g.nexusCards && g.expansions.length === 0 && g.players.length >= 2 &&
g.players.length <= 6 && !!byFaction(g, 'guild') &&
g.players.every(p => CLASSIC_FACTIONS[p.faction] === true) &&
!g.leaderSkills && !g.discoveryEnabled && !g.discoveries && !g.techTokens &&
!g.strongholdCards && !g.ecazTreachery && !g.kullPreview && !g.nexusKullPreview &&
!g.richeseBetrayalPreview && !g.mobileStronghold;
}
function guildBetrayalParentSignature(g: Game, continuation: GuildBetrayalContinuation): string {
return JSON.stringify({
continuation, status: g.status, turn: g.turn, phase: g.phase, active: g.active,
order: g.order, storm: g.storm, movementRemaining: g.movementRemaining,
players: g.players.map(p => ({id: p.id, faction: p.faction, ally: p.ally,
spice: p.spice, forces: p.forces, reserves: p.reserves, tanks: p.tanks,
elites: p.elites, advisors: p.advisors, shipped: p.shipped, moved: p.moved,
hand: p.hand, specialKaramaUsed: p.specialKaramaUsed})),
aid: g.aid, homeworlds: g.homeworlds, nexus: g.nexusCards,
promises: g.shipmentPromises, karama: g.karamaShipping, rate: g.guildRateBlocked,
offer: g.junctionOffer, decision: g.decision, response: g.response,
phaseOpening: g.phaseOpening, truthtrance: g.truthtrance,
pendingShipment: g.pendingShipment, pendingHomeworldShipment: g.pendingHomeworldShipment,
pendingGuildTransport: g.pendingGuildTransport,
});
}
function guildBetrayalCleanParent(g: Game): void {
requireRule(guildBetrayalModeSupported(g) && g.status === 'playing' && g.phase === 5 &&
!g.decision && !g.response && !g.phaseOpening && !g.truthtrance && !g.battle &&
!g.pendingNullentropy && !g.pendingTreacheryDiscard && !g.pendingExchange &&
!g.pendingRicheseGift && !g.pendingKarama && !g.pendingKull && !pendingNexusTraitors(g) &&
!g.pendingShipment && !g.pendingHomeworldShipment && !g.pendingGuildTransport &&
!g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingRicheseBetrayal &&
!g.bureaucratPayments?.pending,
'Guild Betrayal requires the original clean native shipment before funded payment.');
}
function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation): GuildBetrayalAuthority {
requireRule(continuation && typeof continuation === 'object' && !Array.isArray(continuation),
'The saved Guild shipment producer is malformed.');
const producerKeys: Record<GuildBetrayalContinuation['source'], string> = {
reserve: 'shipment,source', guildTransport: 'frame,fromReserves,source',
homeworld: 'shipment,source', junction: 'source,transport',
};
requireRule(Object.hasOwn(producerKeys, continuation.source) &&
Object.keys(continuation).sort().join(',') === producerKeys[continuation.source],
'The saved Guild shipment producer contains foreign action fields.');
let shipper: string, price: number, allyPayment: number, originalReceiver: string | null;
const guild = byFaction(g, 'guild')!;
switch (continuation.source) {
case 'reserve': {
const shipment = continuation.shipment;
requireRule(shipment && shipment.turn === g.turn &&
Object.keys(shipment).every(key => ['turn', 'player', 'territory', 'sector', 'amount', 'elite',
'cost', 'allyPayment', 'advisors', 'homeworldSources', 'guildRateEvent',
'smuggler', 'smugglerCompanion', 'guildSecretEvent', 'guildNexusEvent', 'nexusEvent',
'richesePair', 'noField', 'alliedNoField', 'noFieldSkillProof'].includes(key)),
'The saved reserve shipment contains foreign source fields.');
requireRule(!shipment.source && !shipment.noField && !shipment.alliedNoField &&
!shipment.smuggler && !shipment.smugglerCompanion && !shipment.richesePair &&
!shipment.guildSecretEvent && !shipment.guildNexusEvent && !shipment.nexusEvent,
'This Guild Betrayal profile supports native physical reserve shipments only.');
validatePhysicalShipment(g, shipment);
validateShipmentArrival(g, shipment);
checkShipmentIncomeRounding(g, getPlayer(g, shipment.player), shipment.cost, shipment.allyPayment);
checkShipmentPromises(g, getPlayer(g, shipment.player), shipment);
shipper = shipment.player; price = shipment.cost; allyPayment = shipment.allyPayment;
const p = getPlayer(g, shipper);
originalReceiver = guildShipmentIncome({guild: guild.id, shipper, ally: p.ally,
cost: price, allyPayment, bankOnly: g.karamaShipping?.player === shipper}) > 0 ? guild.id : null;
break;
}
case 'guildTransport': {
requireRule(typeof continuation.fromReserves === 'boolean', 'The Guild transport source flag is malformed.');
const frame = continuation.frame;
requireRule(frame && Object.keys(frame).sort().join(',') ===
'advisors,allyPayment,amount,cost,elite,eliteGroup,group,origin,player,sector,to,turn',
'The saved Guild transport frame is malformed.');
const p = getPlayer(g, frame.player);
validateGuildTransportForCommit(g, p, frame, continuation.fromReserves);
shipper = p.id; price = frame.cost; allyPayment = frame.allyPayment;
originalReceiver = guildShipmentIncome({guild: guild.id, shipper, ally: p.ally,
cost: price, allyPayment, bankOnly: g.karamaShipping?.player === shipper}) > 0 ? guild.id : null;
break;
}
case 'homeworld': {
const shipment = continuation.shipment;
requireRule(shipment && Object.keys(shipment).every(key =>
['player', 'destination', 'sources', 'route', 'event', 'turn', 'amount', 'elite', 'cost',
'allyPayment', 'pools', 'guildSecretEvent', 'guildNexusEvent'].includes(key)),
'The saved Homeworld shipment contains foreign source fields.');
requireRule(!shipment.guildSecretEvent && !shipment.guildNexusEvent,
'This Guild Betrayal profile supports native Homeworld shipment routes only.');
const quote = validateHomeworldShipment(g, shipment);
shipper = shipment.player; price = quote.cost; allyPayment = shipment.allyPayment;
originalReceiver = getPlayer(g, shipper).ally !== guild.id && allyPayment > 0 ? guild.id : null;
const income = getPlayer(g, shipper).ally !== guild.id ? allyPayment : 0;
if (income > 0) validateGuildPaymentRounding(g, guild.id, income, [income]);
break;
}
case 'junction': {
const declaration = continuation.transport;
requireRule(declaration && Object.keys(declaration).sort().join(',') ===
'allyPayment,destination,event,offer,player,sources,turn',
'The saved Junction declaration is malformed.');
const quote = quoteJunctionDeclaration(g, getPlayer(g, declaration.player), declaration);
shipper = declaration.player; price = quote.quote.cost; allyPayment = declaration.allyPayment;
originalReceiver = quote.option.owner;
break;
}
default: throw new RuleError('The saved Guild Betrayal producer is unsupported.');
}
const p = getPlayer(g, shipper);
contribution(g, p, price, allyPayment);
return {source: continuation.source, sourceSignature: guildBetrayalParentSignature(g, continuation),
shipper, price, allyPayment, ownSpice: p.spice, allyEscrow: aidFor(g, p)?.amount ?? 0,
donor: allyPayment ? p.ally! : null, originalReceiver};
}
function guildBetrayalFrameSignature(frame: NonNullable<Game['pendingGuildBetrayal']>): string {
return JSON.stringify([frame.invoice.signature, frame.invoice.sourceSignature, frame.required, frame.passed]);
}
function guildBetrayalIntegrity(g: Game): void {
const cursor = g.guildBetrayal, frame = g.pendingGuildBetrayal;
if (!g.guildBetrayalPreview) {
requireRule(!cursor && !frame && !g.nexusGuildBetrayalHistory && !g.guildBetrayalSourceReceipts,
'Guild Betrayal lost its explicit native profile.');
return;
}
requireRule(guildBetrayalModeSupported(g) && cursor &&
Array.isArray(g.nexusGuildBetrayalHistory) && Array.isArray(g.guildBetrayalSourceReceipts),
'The Guild Betrayal profile lost its cursor or independent native receipts.');
nexusCardsIntegrity(g);
nexusRule(() => validateGuildBetrayalHistory({turn: g.turn, players: g.players},
g.nexusGuildBetrayalHistory!, cursor!, frame?.invoice ?? null));
requireRule(g.guildBetrayalSourceReceipts!.length === g.nexusGuildBetrayalHistory!.length,
'A completed Guild invoice lost its native shipment receipt.');
for (let i = 0; i < g.nexusGuildBetrayalHistory!.length; i++) {
const receipt = g.nexusGuildBetrayalHistory![i], source = g.guildBetrayalSourceReceipts![i];
requireRule(source && source.event === receipt.invoice.event && source.turn === receipt.invoice.turn &&
source.shipper === receipt.invoice.shipper && source.sourceSignature === receipt.invoice.sourceSignature &&
source.receipt === receipt.sourceReceipt,
'A completed Guild invoice differs from its independently committed shipment.');
if (source.turn === g.turn && g.phase === 5)
requireRule(getPlayer(g, source.shipper).shipped,
'A completed Guild invoice reopened its already used shipment.');
}
if (!frame) return;
requireRule(Object.keys(frame).sort().join(',') === 'continuation,invoice,passed,required,signature' &&
frame.continuation && Array.isArray(frame.required) && Array.isArray(frame.passed),
'The saved Guild Betrayal continuation is malformed.');
guildBetrayalCleanParent(g);
const authority = guildBetrayalAuthority(g, frame.continuation);
nexusRule(() => validateGuildBetrayalInvoice({turn: g.turn, players: g.players},
frame.invoice, authority, cursor!.sequence));
requireRule(frame.signature === guildBetrayalFrameSignature(frame) &&
JSON.stringify(frame.required) === JSON.stringify(guildBetrayalResponders(g.nexusCards!.cards!, g.players)) &&
new Set(frame.passed).size === frame.passed.length &&
frame.passed.every(id => frame.required.includes(id)) && frame.passed.length < frame.required.length,
'The saved Guild invoice lost its original custody, funding, acknowledgements or parent.');
}
function beginGuildBetrayal(g: Game, continuation: GuildBetrayalContinuation): boolean {
if (!g.guildBetrayalPreview) return false;
// Release only the already accepted native declaration. No fee or custody
// changes have happened, and no suspended decision can authorize a new gate.
if (continuation.source === 'homeworld') g.pendingHomeworldShipment = null;
guildBetrayalCleanParent(g);
const authority = guildBetrayalAuthority(g, continuation);
if (authority.price === 0) return false;
const required = guildBetrayalResponders(g.nexusCards!.cards!, g.players);
if (!required.length) return false;
const sequence = g.guildBetrayal!.sequence + 1;
const invoice = nexusRule(() => createGuildBetrayalInvoice({turn: g.turn, players: g.players}, authority, sequence));
g.pendingGuildBetrayal = {invoice, continuation: structuredClone(continuation), required,
passed: [], signature: ''};
g.pendingGuildBetrayal.signature = guildBetrayalFrameSignature(g.pendingGuildBetrayal);
g.guildBetrayal = {sequence, event: invoice.event, turn: g.turn,
sourceSignature: invoice.sourceSignature, status: 'pending'};
return true;
}
function guildBetrayalUseBlock(g: Game, id: string): string | null {
const frame = g.pendingGuildBetrayal;
if (!frame || !frame.required.includes(id) || frame.passed.includes(id))
return 'This seat has no pending shipment acknowledgement.';
if (!nexusRule(() => guildBetrayalEligible(g.nexusCards!.cards!, g.players, id)))
return 'Your held Nexus card cannot take this shipment payment.';
const promises = liveShipmentPromises(g.shipmentPromises ?? [], id, g.turn);
if (promises.length && id !== frame.invoice.shipper) {
const projected: Game = {...g, nexusCards: {...g.nexusCards!,
cards: nexusRule(() => discardNexusCard(g.nexusCards!.cards!, id, g.players))}};
if (!findShipmentCompletion(projected, getPlayer(projected, id), promises))
return 'Spending this Nexus card would prevent your committed shipment answer.';
}
return null;
}
function decideGuildBetrayal(g: Game, p: Player, action: Action): void {
const frame = g.pendingGuildBetrayal!;
requireRule(['guildBetrayalPass', 'guildBetrayalUse'].includes(action?.type) &&
action.event === frame.invoice.event &&
Object.keys(action).every(key => key === 'type' || key === 'event') &&
frame.required.includes(p.id) && !frame.passed.includes(p.id),
'Choose your exact current Guild Betrayal acknowledgement.');
const use = action.type === 'guildBetrayalUse';
if (use) {
const blocked = guildBetrayalUseBlock(g, p.id);
requireRule(!blocked, blocked ?? 'This Nexus card cannot be used.');
} else {
frame.passed.push(p.id);
frame.signature = guildBetrayalFrameSignature(frame);
if (frame.passed.length < frame.required.length) return;
}
const discardIndex = use ? g.nexusCards!.cards!.discard.length : null;
if (use) g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
g.pendingGuildBetrayal = null;
const settlement = {recipient: use ? p.id : null};
const continuation = frame.continuation;
switch (continuation.source) {
case 'reserve': commitShipment(g, continuation.shipment, settlement); break;
case 'guildTransport': commitGuildTransport(g, getPlayer(g, continuation.frame.player),
continuation.frame, continuation.fromReserves, undefined, settlement); break;
case 'homeworld': commitHomeworldShipment(g, continuation.shipment, settlement); break;
case 'junction': commitJunctionTransport(g, getPlayer(g, continuation.transport.player),
continuation.transport, settlement); break;
}
requireRule(getPlayer(g, frame.invoice.shipper).shipped, 'The funded native shipment did not complete.');
const sourceReceipt = nexusGuildBetrayalSourceReceipt(frame.invoice);
const receipt = nexusRule(() => createGuildBetrayalReceipt({turn: g.turn, players: g.players},
frame.invoice, {recipient: settlement.recipient, holderFaction: use ? p.faction : null,
holderUnallied: use ? !p.ally : null, nexusDiscardIndex: discardIndex, sourceReceipt}));
g.nexusGuildBetrayalHistory!.push(receipt);
g.guildBetrayalSourceReceipts!.push({event: frame.invoice.event, turn: g.turn,
shipper: frame.invoice.shipper, sourceSignature: frame.invoice.sourceSignature, receipt: sourceReceipt});
g.guildBetrayal = {sequence: frame.invoice.sequence, event: frame.invoice.event, turn: g.turn,
sourceSignature: frame.invoice.sourceSignature, status: 'completed'};
if (use) log(g, `${p.name} spent Guild Nexus Betrayal to receive the full ${frame.invoice.price}-spice shipment payment. The original funded shipment completed once.`,
{faction: 'guild', name: 'Betrayal'});
}
/** Offline paired-faction Nexus setup; public expansion/module starts remain gated. */
export function initializePairedNexusGameForAudit(state: Game): Game {
const expansion = state.expansions[0];
@@ -22300,22 +22574,23 @@ function junctionTransportWindow(g: Game) {
canOffer: !blocked, offerEvent: junctionOfferEvent(g),
event: JSON.stringify([homeworldShipmentEvent(g, recipient, 'arrakis'), offer])};
}
function performJunctionTransport(g: Game, p: Player, action: Action) {
function quoteJunctionDeclaration(g: Game, p: Player, declaration: JunctionTransportDeclaration, automaticFunding = false) {
requireRule(declaration.player === p.id && declaration.turn === g.turn,
'This Junction shipment lost its original player or turn.');
const action = declaration;
const option = junctionTransportWindow(g);
requireRule(option && option.recipient === p.id && !option.blocked && option.offer,
option?.blocked ?? 'Wait for Guild to offer Junction transport during your unused shipment.');
requireRule(action.event === option.event && action.offer === option.offer.event,
'This Junction offer or physical source selection has changed.');
requireRule(Object.keys(action).every((key) =>
['type', 'event', 'offer', 'destination', 'sources', 'allyPayment'].includes(key)),
'Use the offered tariff and explicit physical forces without concealed tokens.');
const rate = option.offer.rate;
const destination = stringField(action.destination);
const context = {...homeworldContext(g), storm: g.storm, discoveries:g.discoveries,
players: homeworldContext(g).players.map((seat) => ({...seat, ally: getPlayer(g, seat.id).ally})),
mobileStronghold: g.mobileStronghold?.location ?? null,
board: {[p.id]: {forces: p.forces, eliteForces: p.elites?.forces ?? {}, advisors: p.advisors}}};
const quote = homeworldRule(() => quoteJunctionTransport(context, g.homeworlds!.custody!,
{player: p.id, sponsor: option.owner, rate: option.offer!.rate, destination,
{player: p.id, sponsor: option.owner, rate, destination,
sources: action.sources as HomeworldShipmentIntent['sources']}));
const arrival = quote.destinationKind === 'arrakis' ? splitLocation(destination) : null;
const nativeDeparture = quote.originKind === 'homeworld' && quote.sources.every((source) =>
@@ -22333,10 +22608,24 @@ function performJunctionTransport(g: Game, p: Player, action: Action) {
'New advisors cannot become fighters this turn.');
allowedEntry(g, p, arrival.territory, arrival.sector, false, advisors);
}
const allyPayment = contribution(g, p, quote.cost, action.allyPayment);
const allyPayment = contribution(g, p, quote.cost, automaticFunding ? undefined : action.allyPayment);
requireRule(automaticFunding || allyPayment === declaration.allyPayment, 'The original Junction funding changed.');
const ordinaryIncome = !!arrival && nativeDeparture && p.faction !== 'fremen';
const contributions = [ordinaryIncome ? quote.cost - allyPayment : 0,
p.ally === option.owner ? 0 : allyPayment];
validateGuildPaymentRounding(g, option.owner,
contributions.reduce((sum, amount) => sum + amount, 0), contributions);
return {option, rate, quote, arrival, nativeDeparture, promise, sourceLock, advisors, allyPayment, destination};
}
function commitJunctionTransport(g: Game, p: Player, declaration: JunctionTransportDeclaration,
settlement?: {recipient: string | null}) {
const {option, rate, quote, arrival, nativeDeparture, promise, sourceLock, advisors, allyPayment, destination} =
quoteJunctionDeclaration(g, p, declaration);
if (!settlement && beginGuildBetrayal(g, {source: 'junction', transport: declaration})) return;
// Junction permission and tariff are Homeworld effects and resist Karama.
// All validation precedes this atomic settlement; there is no special-stop frame.
payWithAlly(g, p, quote.cost, allyPayment);
if (settlement?.recipient) getPlayer(g, settlement.recipient).spice += quote.cost;
g.homeworlds!.custody = quote.state;
p.forces = quote.boardForces;
if (p.elites) p.elites.forces = quote.boardEliteForces;
@@ -22363,11 +22652,11 @@ function performJunctionTransport(g: Game, p: Player, action: Action) {
const ordinaryIncome = !!arrival && nativeDeparture && p.faction !== 'fremen';
const income = (ordinaryIncome ? quote.cost - allyPayment : 0) +
(p.ally === option.owner ? 0 : allyPayment);
if (income > 0) g.response = guildPaymentResponse(g, option.owner,
if (!settlement?.recipient && income > 0) g.response = guildPaymentResponse(g, option.owner,
[ordinaryIncome ? quote.cost - allyPayment : 0, p.ally === option.owner ? 0 : allyPayment]);
const sourceName = quote.originKind === 'arrakis' ? territory(quote.origin).name :
quote.sources.map((s) => combatLocationName(g, s.key)).join(' and ');
log(g, `${p.name} accepted ${getPlayer(g, option.owner).name}’s Junction ${option.offer.rate}-price offer and transported ${quote.amount} physical forces (${quote.elite} special) from ${sourceName} to ${arrival ? `${territory(arrival.territory).name}, sector ${arrival.sector}` : combatLocationName(g, destination)} for ${quote.cost} spice (${quote.cost - allyPayment} own, ${allyPayment} pledged). The shipment is used; movement remains available.`,
log(g, `${p.name} accepted ${getPlayer(g, option.owner).name}’s Junction ${rate}-price offer and transported ${quote.amount} physical forces (${quote.elite} special) from ${sourceName} to ${arrival ? `${territory(arrival.territory).name}, sector ${arrival.sector}` : combatLocationName(g, destination)} for ${quote.cost} spice (${quote.cost - allyPayment} own, ${allyPayment} pledged). The shipment is used; movement remains available.`,
{faction: 'guild', name: 'Junction transport'});
if (arrival) {
const bg = byFaction(g, 'beneGesserit');
@@ -22378,6 +22667,22 @@ function performJunctionTransport(g: Game, p: Player, action: Action) {
openTerritoryEntry(g, p, arrival.territory, arrival.sector, quote.amount, quote.elite, 'shipment');
}
}
function performJunctionTransport(g: Game, p: Player, action: Action) {
requireRule(Object.keys(action).every(key =>
['type', 'event', 'offer', 'destination', 'sources', 'allyPayment'].includes(key)),
'Use the offered tariff and explicit physical forces without concealed tokens.');
// Decode once into a source-specific declaration. Saved continuations are
// never actions and never dispatch through the action engine.
const declaration: JunctionTransportDeclaration = {
player: p.id, turn: g.turn, event: stringField(action.event), offer: stringField(action.offer),
destination: stringField(action.destination), sources: action.sources as HomeworldShipmentIntent['sources'],
allyPayment: typeof action.allyPayment === 'number' ? action.allyPayment : 0,
};
const quote = quoteJunctionDeclaration(g, p, declaration, action.allyPayment === undefined);
declaration.allyPayment = action.allyPayment === undefined ? quote.allyPayment :
contribution(g, p, quote.quote.cost, action.allyPayment);
commitJunctionTransport(g, p, declaration);
}
function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string}) {
requireRule(g.homeworlds?.custody, 'Homeworld shipment requires saved physical custody.');
const context = {
@@ -22436,12 +22741,14 @@ function homeworldShipmentIntegrity(g: Game) {
d.amount === shipment.amount && d.event === shipment.event,
'The saved Guild decision differs from its Homeworld declaration.');
}
function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) {
function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment, settlement?: {recipient: string | null}) {
const quote = validateHomeworldShipment(g, shipment);
if (!settlement && beginGuildBetrayal(g, {source: 'homeworld', shipment})) return;
if (shipment.guildSecretEvent) recordGuildSecretShipment(g,shipment.player,shipment.guildSecretEvent,'homeworld',shipment);
if (shipment.guildNexusEvent) finishGuildCunningShipment(g,shipment.player,'homeworld',shipment);
const p = getPlayer(g, shipment.player);
payWithAlly(g, p, quote.cost, shipment.allyPayment);
if (settlement?.recipient) getPlayer(g, settlement.recipient).spice += quote.cost;
g.homeworlds!.custody = quote.state;
if (shipment.route === 'arrakis') {
p.forces = quote.boardForces!;
@@ -22461,7 +22768,7 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) {
// November FAQ contributor routing remains independent of that trigger.
const guild = byFaction(g, 'guild');
const income = guild && p.ally !== guild.id ? shipment.allyPayment : 0;
if (guild && income > 0)
if (!settlement?.recipient && guild && income > 0)
g.response = guildPaymentResponse(g, guild.id, [income]);
finishShipmentPromises(g, p, null);
log(g, `${p.name} shipped ${quote.amount} physical forces (${quote.elite} special) from ${quote.sourceNames.join(' and ')} to ${combatLocationName(g, shipment.destination)} for ${quote.cost} spice (${quote.cost - shipment.allyPayment} own, ${shipment.allyPayment} pledged). This uses their shipment; movement remains available.${shipment.guildSecretEvent ? ' Guild Secret Ally is spent for this half-price shipment; the payment goes to the bank.' : ''}`,
@@ -22868,6 +23175,11 @@ function validateGuildTransportRateResponse(g: Game, response: ResponseWindow) {
'The saved Guild transport lost its original physical rate response.');
return { intent, shipper, fullCost };
}
function nativeGuildTransportPermission(g: Game, p: Player): boolean {
if (p.faction === 'guild') return true;
const guild = byFaction(g, 'guild');
return !!guild && p.ally === guild.id && guild.ally === p.id;
}
function validateGuildTransportForCommit(
g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean,
) {
@@ -22875,6 +23187,8 @@ function validateGuildTransportForCommit(
requireRule(g.status === 'playing' && g.phase === 5 && frame.turn === g.turn &&
frame.player === p.id && g.active === p.id && shipmentAvailable(g, p),
'This Guild transport no longer belongs to an unused shipment opportunity.');
requireRule(nativeGuildTransportPermission(g, p),
'Native Guild transport requires the Guild or its reciprocal ally.');
if (fromReserves) {
requireRule(p.faction === 'fremen' && origin === 'reserves' &&
group.length === 0 && Object.keys(eliteGroup).length === 0,
@@ -22913,19 +23227,25 @@ function validateGuildTransportForCommit(
function commitGuildTransport(
g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean,
guildSecretEvent?: string,
settlement?: {recipient: string | null},
) {
requireRule(!g.guildBetrayalPreview || !guildSecretEvent,
'Guild Betrayal native transport does not combine with a Guild Secret Ally producer.');
if (!guildSecretEvent) validateGuildTransportForCommit(g, p, frame, fromReserves);
if (!settlement && beginGuildBetrayal(g, {source: 'guildTransport', frame, fromReserves})) return;
const { origin, group, eliteGroup, to, sector, amount, elite, cost, allyPayment, advisors } = frame;
const sourceLock = p.advisors?.[origin]?.lockedTurn;
if (guildSecretEvent) recordGuildSecretShipment(g, p.id, guildSecretEvent, 'cross',
{ ...frame, guildSecretEvent });
bindGuildCunningShipment(g, p, 'guild', frame);
payWithAlly(g, p, cost, allyPayment);
if (settlement?.recipient) getPlayer(g, settlement.recipient).spice += cost;
const guild = byFaction(g, 'guild');
const guildPayment = guildShipmentIncome({
guild: guild?.id, shipper: p.id, ally: p.ally, cost, allyPayment,
bankOnly: g.karamaShipping?.player === p.id,
});
if (guild && guildPayment > 0)
if (!settlement?.recipient && guild && guildPayment > 0)
g.response = guildPaymentResponse(g, guild.id,
shipmentIncomeContributions(g, p, cost, allyPayment),
allyPayment ? undefined : p.id);
@@ -23016,13 +23336,14 @@ function offerShipment(g: Game, shipment: PendingShipment) {
);
} else if (!openGuildRate(g, shipment)) commitShipment(g, shipment);
}
function commitShipment(g: Game, shipment: PendingShipment) {
function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipient: string | null}) {
if (shipment.source === 'ambassador') {
commitAmbassadorShipment(g, shipment);
return;
}
validatePhysicalShipment(g, shipment);
validateShipmentArrival(g, shipment);
if (!settlement && beginGuildBetrayal(g, {source: 'reserve', shipment})) return;
if (shipment.guildSecretEvent) recordGuildSecretShipment(g,shipment.player,shipment.guildSecretEvent,'reserve',shipment);
finishNexusRicheseShipment(g,shipment,'shipped');
finishRichesePairShipment(g, shipment, 'shipped');
@@ -23104,6 +23425,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
owner.spice -= quote.ownerPayment;
p.spice -= quote.recipientPayment;
} else payWithAlly(g, p, cost, allyPayment);
if (settlement?.recipient) getPlayer(g, settlement.recipient).spice += cost;
if (pairRevealed) {
p.reserves -= pairRevealed.forces;
if (pairRevealed.forces) place(p, to, s, pairRevealed.forces);
@@ -23146,7 +23468,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
allyPayment,
bankOnly: g.karamaShipping?.player === p.id,
});
if (guild && guildPayment > 0)
if (!settlement?.recipient && guild && guildPayment > 0)
g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment),allyPayment ? undefined : p.id);
g.karamaShipping = null;
log(
@@ -23283,7 +23605,8 @@ function projectedNexusGuildCunning(g: Game, owner: string) {
hajrAvailable:record.stage !== 'pending' && !g.hajr.includes(owner)}};
if (g.nexusCards?.cards?.hands[owner] !== 'guild') return null;
let blocked: string | null = null;
if (p.ally) blocked = 'Guild Cunning requires an unallied native Guild player.';
if (g.guildBetrayalPreview) blocked = 'This payment audit profile supports the original native shipment, not a Cunning second-shipment overlay.';
else if (p.ally) blocked = 'Guild Cunning requires an unallied native Guild player.';
else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner)
blocked = 'Use Cunning when finishing your own Shipment and Movement turn.';
else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard ||
@@ -23470,7 +23793,8 @@ function nexusRicheseOffer(g: Game, owner: string) {
const p = g.players.find(p => p.id === owner);
if (!p || g.nexusCards?.cards?.hands[owner] !== 'richese' || byFaction(g,'richese')) return null;
let blocked: string | null = null;
if (p.ally) blocked = 'Richese Secret Ally requires an unallied holder.';
if (g.guildBetrayalPreview) blocked = 'This payment audit profile supports the original native shipment, not a Richese Secret Ally shipment overlay.';
else if (p.ally) blocked = 'Richese Secret Ally requires an unallied holder.';
else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner || p.shipped)
blocked = 'Use Secret Ally for your unused reserve shipment before moving.';
else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingKarama ||
@@ -24817,6 +25141,7 @@ function normalizeCardNames(g: Game) {
]);
}
export function applyAction(state: Game, id: string, action: Action): Game {
guildBetrayalIntegrity(state);
state = migratePrintedKullCounter(state);
richeseBetrayalIntegrity(state);
kullIntegrity(state);
@@ -24902,6 +25227,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
return normalizeAutomaticGame(state);
}
const g = applyActionInner(state, id, action);
if (g.pendingGuildBetrayal) {guildBetrayalIntegrity(g); return g;}
if (g.pendingRicheseBetrayal) {richeseBetrayalIntegrity(g);return g;}
if (g.pendingKull) {
settleAutomaticContinuations(g);
@@ -25030,9 +25356,11 @@ export function applyAction(state: Game, id: string, action: Action): Game {
moritaniExtortionIntegrity(g);
guildRateIntegrity(g);
arrivalOverlapIntegrity(g);
guildBetrayalIntegrity(g);
return g;
}
function finishActionContinuations(g: Game) {
if (g.pendingGuildBetrayal) return;
if (g.pendingRicheseBetrayal) return;
if (g.pendingKull) return;
finishLeaderSkillCustody(g);
@@ -25096,11 +25424,13 @@ function finishActionContinuations(g: Game) {
finishMoritaniPlacement(g);
}
function settleAutomaticContinuations(g: Game) {
if (g.pendingGuildBetrayal) return;
if (g.pendingRicheseBetrayal) return;
if (g.pendingKull?.stage === 'offer') return;
if (pendingNexusTraitors(g)) return;
if (g.pendingNullentropy) return;
for (let iteration = 0; iteration < 128; iteration++) {
if (g.pendingGuildBetrayal) return;
if (g.pendingRicheseBetrayal) return;
if (g.truthtrance || g.phaseOpening || g.status === 'finished') return;
const response = g.response;
@@ -25123,6 +25453,7 @@ function settleAutomaticContinuations(g: Game) {
finishResponse(g, false);
} else if (!finishAutomaticDecision(g)) return;
finishActionContinuations(g);
if (g.pendingGuildBetrayal) return;
if (g.pendingRicheseBetrayal) return;
// Opposing/automatic consequences can release a now-impossible promise;
// they are not the original actor voluntarily spending a promised resource.
@@ -25139,6 +25470,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
guildBetrayalIntegrity(state);
state = migratePrintedKullCounter(state);
richeseBetrayalIntegrity(state);
kullIntegrity(state);
@@ -25174,6 +25506,7 @@ export function normalizeAutomaticGame(state: Game): Game {
ecazCollectionIntegrity(state);
ecazAllianceIntegrity(state);
const g = structuredClone(state);
if (g.pendingGuildBetrayal) return g;
if (g.pendingRicheseBetrayal) return g;
if (g.pendingKull?.stage === 'offer') return g;
ornithopterIntegrity(g);
@@ -25225,6 +25558,12 @@ function applyActionInner(
): Game {
ecazTreacheryIntegrity(state);
const g = structuredClone(state);
if (g.pendingGuildBetrayal) {
const player = getPlayer(g, id);
if (action?.type === 'advanceBots') return g;
decideGuildBetrayal(g, player, action);
return g;
}
if (g.pendingRicheseBetrayal) {
const p = getPlayer(g,id);
if (action?.type === 'advanceBots') return g;
@@ -25293,6 +25632,8 @@ function applyActionInner(
);
requireRule(g.status !== 'finished', 'This game has ended.');
const t = action.type;
requireRule(t !== 'guildBetrayalPass' && t !== 'guildBetrayalUse',
'This Guild Betrayal opportunity is no longer pending.');
requireRule(t !== 'richeseBetrayalPass' && t !== 'richeseBetrayalUse',
'This Richese Betrayal opportunity is no longer pending.');
if (g.pendingKull) {
@@ -28283,7 +28624,7 @@ function applyActionInner(
g.phase === 5 &&
g.active === id &&
shipmentAvailable(g,p) &&
(p.faction === 'guild' || byFaction(g, 'guild')?.id === p.ally || !!guildSecretEvent),
(nativeGuildTransportPermission(g, p) || !!guildSecretEvent),
'Guild shipment is not available.',
);
const fromReserves = action.from === 'reserves';
@@ -29053,6 +29394,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string) {
guildBetrayalIntegrity(state);
state = migratePrintedKullCounter(state);
richeseBetrayalIntegrity(state);
kullIntegrity(state);
@@ -29556,6 +29898,18 @@ export function viewGame(state: Game, id: string) {
? g.stormDials
: null,
me: id,
guildBetrayalPreview: !!g.guildBetrayalPreview,
guildBetrayalReaction: g.pendingGuildBetrayal
? (() => {
const frame = g.pendingGuildBetrayal;
const hasPassed = frame.passed.includes(id);
const canPass = frame.required.includes(id) && !hasPassed;
const eligible = nexusRule(() => guildBetrayalEligible(g.nexusCards!.cards!, g.players, id));
const blocked = eligible ? guildBetrayalUseBlock(g, id) : null;
return {event: frame.invoice.event, shipper: frame.invoice.shipper,
canPass, hasPassed, canUse: canPass && eligible && !blocked, blocked};
})()
: null,
richeseBetrayalPreview: !!g.richeseBetrayalPreview,
richeseBetrayalReaction: g.pendingRicheseBetrayal
? (() => {
@@ -29706,7 +30060,7 @@ export function viewGame(state: Game, id: string) {
g.moritaniRetention.keep,
) ?? null)
: null,
decision: g.pendingRicheseBetrayal || g.pendingKull?.stage === 'offer' ? null :
decision: g.pendingGuildBetrayal || g.pendingRicheseBetrayal || g.pendingKull?.stage === 'offer' ? null :
g.decision?.kind === 'capturedLeader' &&
!g.leaderSkills?.assignments.some((a) => a.leader === (g.decision as Extract<Decision,{kind:'capturedLeader'}>).leader) &&
![g.decision.player, g.decision.controller ?? g.decision.owner].includes(
@@ -29718,7 +30072,7 @@ export function viewGame(state: Game, id: string) {
: g.decision?.kind === 'guildShipment' && g.decision.noFieldSkillProof !== undefined
? { ...g.decision, noFieldSkillProof: undefined }
: (g.decision ?? null),
response: g.pendingRicheseBetrayal || g.pendingKull?.stage === 'offer' ? null : g.response
response: g.pendingGuildBetrayal || g.pendingRicheseBetrayal || g.pendingKull?.stage === 'offer' ? null : g.response
? {
...g.response,
...(g.response.bureaucratPayment ? {bureaucratPayment:undefined,bureaucratPaymentEvent:undefined} : {}),
+272
View File
@@ -0,0 +1,272 @@
import type { FactionId } from './catalog';
import {
nexusCardMode, validateNexusCards, validateNexusPlayers,
type NexusPlayer, type NexusState,
} from './nexus-cards';
export type GuildBetrayalSource = 'reserve' | 'guildTransport' | 'homeworld' | 'junction';
export type GuildBetrayalContext = { turn: number; players: readonly NexusPlayer[] };
export type GuildBetrayalFunding = {
shipper: string;
price: number;
ownSpice: number;
allyPayment: number;
allyEscrow: number;
donor: string | null;
players: readonly NexusPlayer[];
};
/** Produced by the native adapter, never reconstructed from the saved invoice. */
export type GuildBetrayalAuthority = Omit<GuildBetrayalFunding, 'players'> & {
source: GuildBetrayalSource;
sourceSignature: string;
originalReceiver: string | null;
};
export type GuildBetrayalInvoice = GuildBetrayalAuthority & {
event: string;
sequence: number;
turn: number;
phase: 5;
ownPayment: number;
signature: string;
};
export type GuildBetrayalCursor = {
sequence: number;
event: string | null;
turn: number | null;
sourceSignature: string | null;
status: 'pending' | 'completed';
};
export type GuildBetrayalReceipt = {
invoice: GuildBetrayalInvoice;
recipient: string | null;
holderFaction: FactionId | null;
holderUnallied: boolean | null;
card: 'guild' | null;
mode: 'betrayal' | null;
nexusDiscardIndex: number | null;
sourceReceipt: string;
signature: string;
};
export type GuildBetrayalOutcome = Pick<GuildBetrayalReceipt,
'recipient' | 'holderFaction' | 'holderUnallied' | 'nexusDiscardIndex' | 'sourceReceipt'>;
export class GuildBetrayalError extends Error {
constructor(message: string) {
super(message);
this.name = 'GuildBetrayalError';
}
}
function requireBetrayal(condition: unknown, message: string): asserts condition {
if (!condition) throw new GuildBetrayalError(message);
}
const whole = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
const positive = (value: unknown): value is number => whole(value) && value > 0;
const text = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0 &&
!['__proto__', 'constructor', 'prototype'].includes(value);
const plain = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value) &&
[Object.prototype, null].includes(Object.getPrototypeOf(value));
const exact = (value: unknown, fields: string): boolean =>
plain(value) && Object.keys(value).sort().join(',') === fields;
const sources: readonly GuildBetrayalSource[] = ['reserve', 'guildTransport', 'homeworld', 'junction'];
function validateContext(context: GuildBetrayalContext): void {
requireBetrayal(plain(context) && positive(context.turn), 'Guild Betrayal needs a valid current turn.');
validateNexusPlayers(context.players);
requireBetrayal(context.players.some(player => player.faction === 'guild'),
'Guild Betrayal needs the native Spacing Guild seated.');
}
/** Public waits depend only on held-card presence, not its hidden face. */
export function guildBetrayalResponders(cards: NexusState, players: readonly NexusPlayer[]): string[] {
validateNexusCards(cards, players);
if (!players.some(player => player.faction === 'guild')) return [];
return players.filter(player => !player.ally && player.faction !== 'guild' &&
cards.hands[player.id] !== null).map(player => player.id);
}
/** A saved faction, mode, or owner object is never its own identity authority. */
export function guildBetrayalEligible(
cards: NexusState, players: readonly NexusPlayer[], holder: string,
): boolean {
validateNexusCards(cards, players);
const seat = players.find(player => player.id === holder);
return !!seat && !seat.ally && seat.faction !== 'guild' && cards.hands[seat.id] === 'guild' &&
nexusCardMode('guild', seat.faction, players.map(player => player.faction)) === 'betrayal';
}
function funding(input: GuildBetrayalFunding, liveAlliance: boolean): void {
requireBetrayal(plain(input) && positive(input.price) && whole(input.ownSpice) &&
whole(input.allyPayment) && input.allyPayment <= input.price && whole(input.allyEscrow) &&
input.allyPayment <= input.allyEscrow && input.ownSpice >= input.price - input.allyPayment,
'The original whole shipment payment must be fully funded before any Guild Betrayal receipt.');
const shipper = input.players.find(player => player.id === input.shipper);
const donor = input.players.find(player => player.id === input.donor);
requireBetrayal(shipper && (input.allyPayment === 0
? input.donor === null
: donor && donor.id !== shipper.id && (!liveAlliance ||
(shipper.ally === donor.id && donor.ally === shipper.id))),
'The original shipment contribution needs its authorized seated escrow donor.');
}
/** Escrow has already left the donor's spice. A refund is never upfront funding. */
export function quoteGuildBetrayalFunding(input: GuildBetrayalFunding): void {
requireBetrayal(plain(input), 'Guild Betrayal needs an original funded invoice.');
validateNexusPlayers(input.players);
funding(input, true);
}
/** Opaque public identifier: no route, source, cost, donor, or card face. */
export function nexusGuildBetrayalEvent(turn: number, sequence: number, shipper: string): string {
requireBetrayal(positive(turn) && positive(sequence) && text(shipper),
'Guild Betrayal needs a positive event sequence and seated shipper identity.');
return JSON.stringify(['nexusGuildBetrayal', turn, sequence, shipper]);
}
/** Consistency signatures bind server facts; they are not cryptographic authentication. */
export function signGuildBetrayalInvoice(invoice: GuildBetrayalInvoice): string {
return JSON.stringify(['guildBetrayalInvoice', invoice.event, invoice.sequence, invoice.turn,
invoice.phase, invoice.shipper, invoice.source, invoice.sourceSignature, invoice.price,
invoice.ownPayment, invoice.allyPayment, invoice.donor, invoice.ownSpice,
invoice.allyEscrow, invoice.originalReceiver]);
}
function validateInvoiceFacts(context: GuildBetrayalContext, invoice: GuildBetrayalInvoice): void {
requireBetrayal(exact(invoice,
'allyEscrow,allyPayment,donor,event,originalReceiver,ownPayment,ownSpice,phase,price,sequence,shipper,signature,source,sourceSignature,turn') &&
positive(invoice.turn) && invoice.turn <= context.turn && invoice.phase === 5 &&
positive(invoice.sequence) && text(invoice.shipper) && sources.includes(invoice.source) &&
text(invoice.sourceSignature) && whole(invoice.ownPayment) &&
invoice.ownPayment === invoice.price - invoice.allyPayment &&
(invoice.originalReceiver === null || context.players.some(player => player.id === invoice.originalReceiver)),
'Guild Betrayal lost its original positive native shipment invoice.');
funding({ ...invoice, players: context.players }, false);
requireBetrayal(invoice.event === nexusGuildBetrayalEvent(invoice.turn, invoice.sequence, invoice.shipper) &&
invoice.signature === signGuildBetrayalInvoice(invoice),
'Guild Betrayal lost its event or original funded source signature.');
}
export function createGuildBetrayalInvoice(
context: GuildBetrayalContext, authority: GuildBetrayalAuthority, sequence: number,
): GuildBetrayalInvoice {
validateContext(context);
quoteGuildBetrayalFunding({ ...authority, players: context.players });
const invoice: GuildBetrayalInvoice = {
shipper: authority.shipper, price: authority.price, ownSpice: authority.ownSpice,
allyPayment: authority.allyPayment, allyEscrow: authority.allyEscrow, donor: authority.donor,
source: authority.source, sourceSignature: authority.sourceSignature,
originalReceiver: authority.originalReceiver,
event: nexusGuildBetrayalEvent(context.turn, sequence, authority.shipper),
sequence, turn: context.turn, phase: 5, ownPayment: authority.price - authority.allyPayment,
signature: '',
};
invoice.signature = signGuildBetrayalInvoice(invoice);
validateInvoiceFacts(context, invoice);
return invoice;
}
/** Re-quote native custody/route/tariff separately before passing authority here. */
export function validateGuildBetrayalInvoice(
context: GuildBetrayalContext, invoice: GuildBetrayalInvoice,
authority: GuildBetrayalAuthority, sequence: number,
): void {
validateContext(context);
validateInvoiceFacts(context, invoice);
quoteGuildBetrayalFunding({ ...authority, players: context.players });
requireBetrayal(invoice.turn === context.turn && invoice.sequence === sequence &&
(['shipper', 'price', 'ownSpice', 'allyPayment', 'allyEscrow', 'donor',
'source', 'sourceSignature', 'originalReceiver'] as const)
.every(key => invoice[key] === authority[key]),
'Guild Betrayal no longer matches the independent native shipment and funding.');
}
/** Runtime emits this only after the original native fee and delivery suffix succeeds. */
export function nexusGuildBetrayalSourceReceipt(invoice: GuildBetrayalInvoice): string {
return JSON.stringify(['guildBetrayalSourceCompleted', invoice.event, invoice.sequence,
invoice.turn, invoice.shipper, invoice.source, invoice.sourceSignature,
invoice.price, invoice.ownPayment, invoice.allyPayment, invoice.donor]);
}
export function signGuildBetrayalReceipt(receipt: GuildBetrayalReceipt): string {
return JSON.stringify(['guildBetrayalReceipt', signGuildBetrayalInvoice(receipt.invoice),
receipt.recipient, receipt.holderFaction, receipt.holderUnallied, receipt.card,
receipt.mode, receipt.nexusDiscardIndex, receipt.sourceReceipt]);
}
function validateReceipt(context: GuildBetrayalContext, receipt: GuildBetrayalReceipt): void {
requireBetrayal(exact(receipt,
'card,holderFaction,holderUnallied,invoice,mode,nexusDiscardIndex,recipient,signature,sourceReceipt'),
'Guild Betrayal has an incomplete or foreign completed receipt.');
validateInvoiceFacts(context, receipt.invoice);
const holder = context.players.find(player => player.id === receipt.recipient);
requireBetrayal(receipt.recipient === null
? receipt.holderFaction === null && receipt.holderUnallied === null && receipt.card === null &&
receipt.mode === null && receipt.nexusDiscardIndex === null
: holder && holder.faction === receipt.holderFaction && holder.faction !== 'guild' &&
receipt.holderUnallied === true && receipt.card === 'guild' && receipt.mode === 'betrayal' &&
whole(receipt.nexusDiscardIndex) &&
nexusCardMode('guild', holder.faction, context.players.map(player => player.faction)) === 'betrayal',
'Guild Betrayal history lost the canonical native Guild or original unallied non-native holder.');
requireBetrayal(receipt.sourceReceipt === nexusGuildBetrayalSourceReceipt(receipt.invoice) &&
receipt.signature === signGuildBetrayalReceipt(receipt),
'Guild Betrayal lost its completed original source receipt.');
}
export function createGuildBetrayalReceipt(
context: GuildBetrayalContext, invoice: GuildBetrayalInvoice, outcome: GuildBetrayalOutcome,
): GuildBetrayalReceipt {
validateContext(context);
validateInvoiceFacts(context, invoice);
requireBetrayal(invoice.turn === context.turn && plain(outcome),
'Only the current original shipment may issue a new completed Guild receipt.');
const holder = context.players.find(player => player.id === outcome.recipient);
requireBetrayal(outcome.recipient === null || (holder && !holder.ally),
'Guild Betrayal can only be used by the actual unallied holder.');
const receipt: GuildBetrayalReceipt = {
invoice: { ...invoice }, recipient: outcome.recipient, holderFaction: outcome.holderFaction,
holderUnallied: outcome.holderUnallied, nexusDiscardIndex: outcome.nexusDiscardIndex,
sourceReceipt: outcome.sourceReceipt,
card: outcome.recipient === null ? null : 'guild', mode: outcome.recipient === null ? null : 'betrayal',
signature: '',
};
receipt.signature = signGuildBetrayalReceipt(receipt);
validateReceipt(context, receipt);
return receipt;
}
/** Intrinsic historic facts, not today's forces/spice/alliances/discard or Nexus custody. */
export function validateGuildBetrayalHistory(
context: GuildBetrayalContext, history: readonly GuildBetrayalReceipt[],
cursor: GuildBetrayalCursor, pendingInvoice: GuildBetrayalInvoice | null,
): void {
validateContext(context);
requireBetrayal(Array.isArray(history) && exact(cursor,
'event,sequence,sourceSignature,status,turn') && whole(cursor.sequence) &&
(cursor.status === 'pending' || cursor.status === 'completed'),
'Guild Betrayal lost its independent pending/completed cursor.');
let previousTurn = 0;
for (let index = 0; index < history.length; index++) {
const receipt = history[index];
validateReceipt(context, receipt);
requireBetrayal(receipt.invoice.sequence === index + 1 && receipt.invoice.turn >= previousTurn,
'Guild Betrayal has a duplicate, stale or out-of-order completed source.');
previousTurn = receipt.invoice.turn;
}
if (cursor.sequence === 0) {
requireBetrayal(cursor.status === 'completed' && cursor.event === null && cursor.turn === null &&
cursor.sourceSignature === null && history.length === 0 && pendingInvoice === null,
'Guild Betrayal has an orphan source at its initial cursor.');
return;
}
requireBetrayal(history.length === cursor.sequence - (cursor.status === 'pending' ? 1 : 0) &&
(cursor.status === 'pending' ? pendingInvoice !== null : pendingInvoice === null),
'Guild Betrayal has an orphan, missing or double-settled original source.');
const invoice = cursor.status === 'pending' ? pendingInvoice! : history[history.length - 1].invoice;
validateInvoiceFacts(context, invoice);
requireBetrayal(invoice.sequence === cursor.sequence && invoice.event === cursor.event &&
invoice.turn === cursor.turn && invoice.sourceSignature === cursor.sourceSignature &&
(cursor.status !== 'pending' || (invoice.turn === context.turn && invoice.turn >= previousTurn)),
'Guild Betrayal cursor no longer identifies its original event, turn or native source.');
}
+38 -4
View File
@@ -4113,6 +4113,39 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: '80 focused cases and 5,953 offline checks cover both alternatives/all-pass, exact custody/escrow/bonus, identity parity, four legal bot profiles, real table controls and authenticated SQLite restart/races/orphan rejection. Actual CLI/runtime and phone controls prove Basic veto, Advanced all-pass and cache/Black Market diversion with refresh. Local HTTP 51/55 retains three 503s and one timeout; full-family, wider-module and deployed acceptance remain open.', evidence: ['tests/nexus-richese-betrayal.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-controls.test.tsx', 'tests/bot-nexus-richese-betrayal.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'docs/NEXUS_RICHESE_RULES.md'] },
],
},
{
id: 'nexus-guild-betrayal',
title: 'Guild Nexus Betrayal: take one funded shipment payment',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Verified',
summary: 'In the explicit Guild Betrayal preview, an unallied rival holding Guild Nexus may take one full original positive shipment payment, including their own pre-funded payment, instead of Guild, bank or occupied Junction income.',
steps: [
'This is the fresh local guild-betrayal development profile, not a public module start or an upgrade to saved games. It admits two to six ready classic-faction seats in Basic or Advanced, with native Guild and at least one other faction, physical base decks and Nexus. Homeworlds are optional only when genuinely enabled and seeded at setup. Other expansion factions/decks, Leader Skills, Discoveries, Tech Tokens, Stronghold Cards, Semuta, Kull and Richese previews remain outside this boundary.',
'Remain unallied and hold the actual singleton Guild Nexus while another seated player is native Guild. Use spends that physical Nexus once, with no Treachery Card, hand slot or extra activation spice. Cunning and absent-Guild Secret Ally are separate modes. An empty Guild board or temporary leader control does not change the mode.',
'Only original native shipment producers are admitted in this profile. Guild Nexus Cunning’s second shipment and Richese Nexus Secret Ally’s discounted shipment are blocked before spending a card or creating a native parent; those effects outside this profile remain unchanged. Guild Secret Ally is already unavailable because native Guild is seated, not because Betrayal adds a new prohibition to that panel.',
'Wait for a legal positive shipment fee after any applicable native stop and rate responses. The original source, tariff and physical forces must already be legal and fully funded. Stopped, declined, invalid or zero-cost shipments cannot award spice or spend this Nexus; ordinary free Fremen reinforcement and free Guild Ambassador transport stay distinct.',
'Use takes the full original charged payment, not merely eligible Guild income or its reduced half. The original payer and authorized ally contribution pay once, but Guild, bank and any occupied-Junction receiver get none of that invoice. This is replacement of one fee, not an extra award or second charge.',
'You may take your own payment, but you must afford it before receiving it back. A wholly self-funded payment is paid and returned for net zero spice; future receipt cannot finance the declaration. An ally’s pledge has already debited that donor into escrow: the original escrow is consumed once, not charged to the donor again. Taking the fee does not change the payer’s own remainder or donor split. Your live voluntary-spend promises and reservations still apply.',
'The printed occupied-Junction override delivers the entire fee: a four-spice payment gives the holder four, not two while an occupier gets two. It does not decide ordinary low-Junction contribution rounding, occupation entitlement after departure/replacement/contest or retained penalties. Non-overridden settlement keeps its native restrictions; a hypothetical Nexus play cannot bypass an unresolved original invoice.',
'Supported original producers are ordinary physical reserve shipment onto Arrakis, native Guild cross-planet/return transport, existing native Homeworld shipment and an accepted Junction-sponsored transport. Keep the same tariff, physical elite/advisor group, selected native Homeworld reserve sources, source/destination custody and sponsor/offer. No new foreign Homeworld route or Secret Ally return permission is granted.',
'The original native shipment completes once: pay the bound fee, move the exact physical forces, mark that shipment used and finish applicable native arrival and response decisions. Ordinary movement afterward remains available under the shipper’s own rules. Betrayal does not grant another shipment, move, rate or sponsorship.',
'Every publicly possible unallied non-Guild seat with held-Nexus presence receives the same event and shipper, including an eligible shipper. A secret irrelevant face still uses Pass; only a privately eligible Guild-card holder sees Use and their own unavailable reason. This neutral acknowledgement is an application privacy protocol, not publisher timing authority, blanket Karama immunity or a new general response priority.',
'The response adds no price, source/family/world, destination, force count, donor, balance, recipient or saved-receipt disclosure. Existing own-source and public inspector entitlements do not expand. Neutral acknowledgement logs no secret fee or source; the original committed shipment retains its normal result logging. Inspecting a printed Nexus reference is not evidence of another player’s held card.',
'Pass spends nothing and keeps Nexus. A recorded pass cannot be undone for this event. All required passes settle the original shipment once with its original receiver; with no publicly possible responder it settles automatically. Accepted Use spends the physical Nexus and redirects the same fee. Both actions submit only the offered event: the server binds payment and recipient. Finish this gate before shipping, moving, pledging, playing cards or opening another decision.',
'Refresh resumes the same typed producer, original invoice, passes and pending/completed ownership without a second donor debit, fee, card spend or force move. Stale or foreign events, malformed/orphaned saves and competing Use/final-Pass requests cannot select a new shipment or replay a raw action. Bounded direct engine, authenticated SQLite and actual Basic/Advanced CLI/phone source paths are verified locally; this does not certify complete Guild or Nexus rules.',
'Easy, Medium, Hard and Brutal share a minimal legal projected Use/Pass path; this is not Guild strategy or calibrated difficulty. Full Guild/Nexus/Homeworld families, wider compositions, full games, public starts and live deployment remain separate closed gates.',
],
example: 'Your original shipment costs four spice and you already have four available: Use pays that fee once and returns all four to you, spending Guild Nexus but granting no extra shipment. If another shipper pays four, you receive four instead of Guild, bank or an occupied Junction receiver. Passing leaves the original routing unchanged.',
related: ['nexus-cards', 'movement', 'alliance-funding', 'homeworlds', 'privacy', 'faction-guild', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Bounded reserve, native Guild transport, Homeworld and Junction producers bind the full funded invoice and original typed continuation with independent pending/completed ownership. Own refund, donor escrow and occupied-Junction replacement retain once-only native payment/delivery; wider producers remain excluded.', evidence: ['game/nexus-guild-betrayal.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'The bounded contract separates uniform acknowledgement from private Use and own blocked reason, with event-only actions, unchanged inspector entitlements and no new fee/source disclosure. Broader presentation and acceptance remain open.', evidence: ['components/nexus-guild-betrayal.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles use only canonical projected legal Use/Pass choices while the payment gate suspends underlying actions. Full strategy and calibration remain unfinished.', evidence: ['game/bot-nexus-guild-betrayal.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Printed full-fee and own-payment clauses, escrow funding, explicit occupied-Junction override, four native adapters, privacy protocol and unchanged open occupation/rounding/priority boundaries are recorded. Full-family acceptance remains open.', evidence: ['docs/NEXUS_GUILD_RULES.md', 'docs/NEXUS_CARD_RULES.md', 'docs/HOMEWORLD_PAYMENT_INCOME_RULES.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: '147 selected offline cases and 6,114 full offline checks cover four native adapters, funded own refunds, original escrow, full low-Junction override, face-neutral acknowledgement, four legal bot profiles, actual handlers, authenticated restart/races and independent native-grant/orphan rejection. Eight real backed-up CLI entries and 390-pixel controls prove own/other/escrow/Guild-return/Homeworld/Junction Use, all-pass, free Fremen and refresh with exact saved force/card/spice custody. Local HTTP 48/55 has five 503s and two timeouts; broader and deployed acceptance remain open.', evidence: ['tests/nexus-guild-betrayal.test.ts', 'tests/nexus-guild-betrayal-engine.test.ts', 'tests/nexus-guild-betrayal-controls.test.tsx', 'tests/bot-nexus-guild-betrayal.test.ts', 'tests/nexus-guild-betrayal-recovery.test.ts', 'docs/NEXUS_GUILD_RULES.md'] },
],
},
{
id: 'nexus-cards',
title: 'Nexus Cards',
@@ -4161,18 +4194,19 @@ export const RULE_TOPICS: RuleTopic[] = [
'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Select the explicit shipment checkbox; forces, elite identity, destination restrictions, Homeworld reserve sources and normal movement remain unchanged. Fremen remain free within their normal reinforcement range; Guild and an active Karama rate retain their existing tariff and payment routing. This is not a concealed No-Field or an extra shipment.',
'Native Richese Cunning has a bounded two-No-Field shipment in genuine Richese/CHOAM Nexus games without other optional modules. Choose distinct unused tokens: one reveals immediately into reserve-capped public forces, the other remains concealed; pay for one marker and spend the Nexus. The previous shipment token cannot be either selection; the concealed token becomes next last-used. Karama or Guild special prevention preserves physical tokens and unpaid spice, with the ordinary stopped-shipment allowance policy. Other module combinations and the full faceup-component display remain gated.',
'Richese Betrayal has a separate opt-in auction preview. Before payment, an unallied rival may spend its physical Richese Nexus card to discard a public self-cache win without any purchase debit, or redirect another buyer’s cache/Black Market payment to the bank while preserving delivery and earned bonus. Every publicly possible holder acknowledges the same opportunity; private Use eligibility cannot determine public timing. Normal hidden self-purchases and private special-Karama acquisitions are not connected. See the dedicated guide for its existing local runtime/browser evidence and remaining release gates; that evidence does not verify Nexus Kull.',
'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable.',
'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable. The separate guild-betrayal profile admits only original native shipment producers and explicitly blocks this second-shipment offer before Nexus spending; Cunning outside that profile is unchanged.',
'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to abstract reserves, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Homeworld reserve return awaits a ruling; arbitrary foreign-world transport and concealed No-Fields are not granted.',
'Guild Betrayal has a separate fresh classic/native-Guild development preview. Before a fully funded positive shipment settles, an unallied holder may spend the physical Guild Nexus to take the full original fee, including their own pre-funded payment, instead of Guild, bank or occupied Junction income. Original donor escrow is consumed once and original physical delivery is unchanged. Neutral public acknowledgements reveal no fee/source; only private eligibility exposes Use. Zero/stopped shipments create no award or Nexus cost. See the dedicated guide; new verification remains Planned and ordinary Homeworld occupation/rounding questions stay local.',
'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules. If Extortion was revealed this turn, its bank award and payment choices follow that placement response; this does not implement Atomics or complete Moritani games.',
'Hidden reactive Betrayal timing remains unresolved for other effects. Complete module games are unfinished, so public module starts remain disabled.',
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal'],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, Face Dancers, Suboids, advisors, native Voice, Emperor Sardaukar and Betrayal, bounded Fremen revival/Cunning, CHOAM effects, Moritani Cunning/Betrayal, Ecaz Betrayal, native Duke Cunning and private Secret Ally inquiry, Richese shipment and bounded auction Betrayal, and bounded Emperor bank-auction purchase are integrated. Ecaz Cunning transfers the existing Duke at a quiet Battle boundary with a real card and distinct turn-end expiry. Ecaz inquiry scope/audience and Emperor purchase timing are provisional. Borrowed Voice, exceptional Duke custody, other reactions, Tleilaxu revival, Fremen worm protection and combined Cunning interactions remain unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects, Moritani Betrayal, Ecaz Betrayal and owner-only Secret Ally inquiry, native Duke Cunning, Richese shipment and bounded auction Betrayal, Emperor purchase and Fremen Cunning controls are connected. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles choose legal actions through private offers, including Moritani/Ecaz Betrayal, Ecaz Secret Ally inquiry and native Duke Cunning, Fremen Cunning, Emperor purchase and bounded Richese auction Betrayal. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and bounded runtime contracts retain explicit unresolved Ecaz Duke capture/Tanks/Ghola and inquiry audience/scope, Fremen nested Cunning, Great Maker, combined modules, fresh advisors, storm and borrowed Voice. Richese auction Betrayal has a separate cost/prepayment/privacy contract and excluded-producer boundaries.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, controls, legal bots, Basic/Advanced and in-memory SQLite recovery regressions include native Ecaz Duke override and private Secret Ally inquiry. A read-only local browser fixture showed the owner-only Duke choice and no rival offer; no room was written. Bounded Richese auction Betrayal additionally has local CLI/runtime, authenticated SQLite and refreshed phone Use/Pass evidence in owned disposable rooms. Full module games, exceptional Duke custody, combined interactions and deployed acceptance remain separate.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and bounded runtime contracts retain explicit unresolved Ecaz Duke capture/Tanks/Ghola and inquiry audience/scope, Fremen nested Cunning, Great Maker, combined modules, fresh advisors, storm and borrowed Voice. Richese auction Betrayal has a separate cost/prepayment/privacy contract and excluded-producer boundaries. Guild Betrayal separately records full funded payment, own pre-funding, donor escrow once, the printed full-Junction override, four native adapters and neutral privacy; ordinary occupation/rounding and general response-priority questions remain unresolved.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, controls, legal bots, Basic/Advanced and in-memory SQLite recovery regressions include native Ecaz Duke override and private Secret Ally inquiry. A read-only local browser fixture showed the owner-only Duke choice and no rival offer; no room was written. Bounded Richese auction Betrayal additionally has local CLI/runtime, authenticated SQLite and refreshed phone Use/Pass evidence in owned disposable rooms. New Guild Betrayal verification is separately Planned pending frozen direct evidence; these earlier results do not certify it. Full module games, exceptional Duke custody, combined interactions and deployed acceptance remain separate.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] },
],
},
{
+2 -2
View File
@@ -12,13 +12,13 @@ type TurnView = Pick<
| 'phaseOpening'
| 'automaticContinuationPending'
> &
Partial<Pick<GameView, 'biddingEnd' | 'kullReaction' | 'richeseBetrayalReaction'>>;
Partial<Pick<GameView, 'biddingEnd' | 'kullReaction' | 'richeseBetrayalReaction' | 'guildBetrayalReaction'>>;
/** The single current decision owner for the table banner and seat highlight.
* Shared response windows have no single acting seat. Only public view fields
* participate; this does not decide which gameplay actions are legal. */
export function tableActionOwner(g: TurnView): string | null {
if (g.richeseBetrayalReaction) return null;
if (g.richeseBetrayalReaction || g.guildBetrayalReaction) return null;
if (g.kullReaction && g.status === 'playing')
return g.players.some(p => p.id === g.kullReaction!.player)
? g.kullReaction.player : null;