Implement Bene Gesserit Nexus advisor conversion with durable responses
This commit is contained in:
1 parent
40912498b1
commit
b05125de9a
23 files changed
+2298
-16
No files matched your search
+125
@@ -2,6 +2,7 @@ import { createNexusInspection, allowNexusInspection, answerNexusInspection, reo
|
||||
import { beginNexusTraitorExchange, finishNexusTraitorExchange, validateNexusTraitorExchange, validateNexusTraitorHistory, validateNexusTraitorSnapshot, type NexusTraitorExchange, type NexusTraitorSnapshot } from './nexus-traitor-exchange';
|
||||
import { replaceNexusFaceDancers, validateNexusFaceDancerHistory, type NexusFaceDancerReceipt } from './nexus-face-dancers';
|
||||
import { createNexusSuboids, validateNexusSuboids, nexusSuboidsActive, type NexusSuboidReceipt } from './nexus-suboids';
|
||||
import { quoteNexusAdvisors, createNexusAdvisors, validateNexusAdvisors, type NexusAdvisorReceipt } from './nexus-advisors';
|
||||
import { createTraitorDeclaration, validateTraitorDeclarations, type TraitorDeclaration, type TraitorDeclarationContext } from './traitor-declarations';
|
||||
import { createNexusCards, validateNexusCards, drawNexusCard, replaceNexusCard, discardNexusCard, projectNexusCards, nexusCardMode, type NexusState } from './nexus-cards';
|
||||
import { createNexusCardPhase, markNexusCardOccurred, validateNexusCardPhase, closeNexusCardPhase, nexusCardChoices, finishNexusCardChoice, type NexusCardPhase, type NexusCardChoice } from './nexus-card-phase';
|
||||
@@ -791,6 +792,7 @@ export type ResponseWindow = {
|
||||
| 'eliteStrength'
|
||||
| 'fremenSupport'
|
||||
| 'advisorFlip'
|
||||
| 'nexusAdvisorFlip'
|
||||
| 'bgCharity'
|
||||
| 'choamCharity'
|
||||
| 'choamInflation'
|
||||
@@ -1263,6 +1265,8 @@ export type Game = {
|
||||
nexusFaceDancerHistory?: NexusFaceDancerReceipt[];
|
||||
nexusSuboidHistory?: NexusSuboidReceipt[];
|
||||
nexusSuboidLast?: { event: string; owner: string; turn: number };
|
||||
nexusAdvisorHistory?: { receipt: NexusAdvisorReceipt; stage: 'pending' | 'completed' | 'canceled'; frame: string; signature: string }[];
|
||||
nexusAdvisorLast?: { event: string; stage: 'pending' | 'completed' | 'canceled' };
|
||||
nexusTraitorPending?: string | null;
|
||||
nexusTraitorParent?: { event: string; signature: string } | null;
|
||||
/** Null/absent disables the module; custody is installed at force placement. */
|
||||
@@ -1746,6 +1750,118 @@ function commitNexusSuboids(g: Game, p: Player) {
|
||||
{ faction: p.faction, name: 'Nexus Suboid strength' });
|
||||
}
|
||||
|
||||
function nexusAdvisorFrame(g: Game, owner: string) {
|
||||
const p = getPlayer(g, owner);
|
||||
return JSON.stringify([g.status, g.turn, g.phase, g.active, g.movementRemaining,
|
||||
p.shipped, p.moved, g.hajr, g.guildTimingGranted, g.guildTimingLocked]);
|
||||
}
|
||||
function nexusAdvisorRecordSignature(record: NonNullable<Game['nexusAdvisorHistory']>[number]) {
|
||||
return JSON.stringify([record.receipt.signature, record.stage, record.frame]);
|
||||
}
|
||||
function pendingNexusAdvisors(g: Game) {
|
||||
return g.nexusAdvisorHistory?.find(record => record.stage === 'pending') ?? null;
|
||||
}
|
||||
function validateNexusAdvisorResponse(g: Game, response: ResponseWindow) {
|
||||
const record = pendingNexusAdvisors(g);
|
||||
requireRule(record && response.kind === 'nexusAdvisorFlip' &&
|
||||
response.owner === record.receipt.owner && response.intent === record.receipt.event &&
|
||||
Object.keys(response).every(key => ['kind', 'owner', 'intent', 'passed'].includes(key)) &&
|
||||
Array.isArray(response.passed) && new Set(response.passed).size === response.passed.length &&
|
||||
response.passed.every(id => g.players.some(p => p.id === id)),
|
||||
'The Nexus advisor conversion has lost its original response.');
|
||||
requireRule(g.status === 'playing' && g.phase === 5 && g.active === record.receipt.owner &&
|
||||
record.receipt.turn === g.turn && record.frame === nexusAdvisorFrame(g, record.receipt.owner),
|
||||
'The Nexus advisor conversion has lost its original Shipment and Movement action.');
|
||||
nexusRule(() => validateNexusAdvisors(g, record.receipt, true));
|
||||
}
|
||||
function nexusAdvisorIntegrity(g: Game) {
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume,
|
||||
g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume,
|
||||
continuation && 'resume' in continuation ? continuation.resume : null];
|
||||
const responses = contexts.flatMap(context => {
|
||||
const pending = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
|
||||
return [context?.response, pending?.use.kind === 'cancel' ? pending.use.response : null];
|
||||
}).filter(response => response?.kind === 'nexusAdvisorFlip');
|
||||
const history = g.nexusAdvisorHistory;
|
||||
if (history === undefined) {
|
||||
requireRule(!g.nexusAdvisorLast && !responses.length, 'The Nexus advisor conversion has lost its saved history.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0,
|
||||
'Nexus advisor history requires its original module and played card.');
|
||||
const turns = new Set<number>();
|
||||
for (const [index, record] of history.entries()) {
|
||||
requireRule(record && record.receipt && typeof record.receipt === 'object' &&
|
||||
Object.keys(record).sort().join(',') === 'frame,receipt,signature,stage' &&
|
||||
['pending', 'completed', 'canceled'].includes(record.stage) &&
|
||||
typeof record.frame === 'string' && record.signature === nexusAdvisorRecordSignature(record) &&
|
||||
record.receipt.event === JSON.stringify(['nexusAdvisors', record.receipt.turn, record.receipt.owner, index]) &&
|
||||
!turns.has(record.receipt.turn) &&
|
||||
(record.stage !== 'pending' || index === history.length - 1),
|
||||
'The Nexus advisor conversion history is malformed.');
|
||||
nexusRule(() => validateNexusAdvisors(g, record.receipt, record.stage === 'pending'));
|
||||
turns.add(record.receipt.turn);
|
||||
}
|
||||
const last = history.at(-1)!;
|
||||
requireRule(JSON.stringify(g.nexusAdvisorLast) === JSON.stringify({event: last.receipt.event, stage: last.stage}),
|
||||
'The Nexus advisor conversion has lost its latest outcome.');
|
||||
requireRule((last.stage === 'pending') === (responses.length > 0),
|
||||
'The Nexus advisor conversion has lost or reopened its cancellation response.');
|
||||
if (last.receipt.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('beneGesserit'),
|
||||
'The advisor conversion has lost its spent Nexus card.');
|
||||
for (const response of responses) validateNexusAdvisorResponse(g, response!);
|
||||
}
|
||||
function projectedNexusAdvisors(g: Game, id: string) {
|
||||
if (!g.nexusCards?.cards) return null;
|
||||
const record = pendingNexusAdvisors(g);
|
||||
const pending = record ? {owner: record.receipt.owner, event: record.receipt.event,
|
||||
territories: record.receipt.selections.map(selection => selection.territory)} : null;
|
||||
const p = getPlayer(g, id);
|
||||
if (p.faction !== 'beneGesserit' || g.nexusCards.cards.hands[id] !== 'beneGesserit') return {offer: null, pending};
|
||||
let blocked: string | null = null;
|
||||
if (!g.advanced) blocked = 'Advisors are available only in the Advanced game.';
|
||||
else if (g.status !== 'playing' || g.phase !== 5 || g.active !== id)
|
||||
blocked = 'Use Cunning during your own Shipment and Movement action.';
|
||||
else if (p.ally) blocked = 'Allied players cannot use a Nexus card.';
|
||||
else if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingKarama ||
|
||||
g.pendingTreacheryDiscard || g.pendingNullentropy || pendingNexusTraitors(g) || record)
|
||||
blocked = 'Finish the current interaction before converting advisors.';
|
||||
else if (g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the closing Nexus draws first.';
|
||||
const territories = nexusRule(() => quoteNexusAdvisors(g, id)).territories;
|
||||
if (!blocked && !territories.some(group => !group.blocked)) blocked = 'No advisor group is currently available to convert.';
|
||||
return {offer: {event: JSON.stringify(['nexusAdvisors', g.turn, id, g.nexusAdvisorHistory?.length ?? 0]), blocked, territories}, pending};
|
||||
}
|
||||
function playNexusAdvisors(g: Game, p: Player, action: Action) {
|
||||
const offer = projectedNexusAdvisors(g, p.id)?.offer;
|
||||
requireRule(Object.keys(action).every(key => ['type', 'event', 'territories'].includes(key)) &&
|
||||
offer && action.event === offer.event && !offer.blocked,
|
||||
offer?.blocked ?? 'Choose your current Nexus advisor conversion.');
|
||||
requireRule(Array.isArray(action.territories) && action.territories.every(t => typeof t === 'string'),
|
||||
'Select whole advisor territories.');
|
||||
const receipt = nexusRule(() => createNexusAdvisors(g, p.id, offer.event, action.territories as string[]));
|
||||
const record = {receipt, stage: 'pending' as const, frame: nexusAdvisorFrame(g, p.id), signature: ''};
|
||||
record.signature = nexusAdvisorRecordSignature(record);
|
||||
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
|
||||
(g.nexusAdvisorHistory ??= []).push(record);
|
||||
g.nexusAdvisorLast = {event: receipt.event, stage: record.stage};
|
||||
g.response = {kind: 'nexusAdvisorFlip', owner: p.id, intent: receipt.event, passed: []};
|
||||
log(g, `${p.name} spent Bene Gesserit Nexus Cunning to convert all advisors in ${receipt.selections.map(s => territory(s.territory).name).join(', ')}. Karama may prevent this entire conversion.`,
|
||||
{faction: p.faction, name: 'Nexus advisor conversion declared'});
|
||||
}
|
||||
function finishNexusAdvisors(g: Game, canceled: boolean) {
|
||||
const record = pendingNexusAdvisors(g)!;
|
||||
const p = getPlayer(g, record.receipt.owner);
|
||||
if (!canceled) for (const selection of record.receipt.selections) delete p.advisors![selection.territory];
|
||||
record.stage = canceled ? 'canceled' : 'completed';
|
||||
record.signature = nexusAdvisorRecordSignature(record);
|
||||
g.nexusAdvisorLast = {event: record.receipt.event, stage: record.stage};
|
||||
log(g, canceled
|
||||
? 'Karama prevented the entire Nexus advisor conversion. Every selected group remains advisors; the Nexus card stays spent.'
|
||||
: `${p.name} converted every advisor in ${record.receipt.selections.map(s => territory(s.territory).name).join(', ')} to fighters. The forces remain in place and no shipment, movement or spice was spent.`,
|
||||
{faction: p.faction, name: canceled ? 'Nexus advisor conversion prevented' : 'Nexus advisors become fighters'});
|
||||
}
|
||||
|
||||
function battleInspectionContext(g: Game): BattleInspectionContext {
|
||||
const b = g.battle!;
|
||||
return { event: b.event!, attacker: b.attacker, defender: b.defender,
|
||||
@@ -6371,6 +6487,7 @@ function validateKaramaUse(
|
||||
currentIxAuctionDrawQuote(g, use.response, true, spendingCard);
|
||||
moritaniAllianceCancellationQuote(g, use.response);
|
||||
if (use.response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, use.response);
|
||||
if (use.response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, use.response);
|
||||
if (isCombatResponseKind(use.response.kind))
|
||||
currentCombatResponseQuote(g, {
|
||||
kind: 'response',
|
||||
@@ -11760,6 +11877,7 @@ function marketGholaIntegrity(g: Game) {
|
||||
nexusTraitorIntegrity(g);
|
||||
nexusFaceDancerIntegrity(g);
|
||||
nexusSuboidIntegrity(g);
|
||||
nexusAdvisorIntegrity(g);
|
||||
traitorDeclarationIntegrity(g);
|
||||
nexusInspectionIntegrity(g);
|
||||
homeworldHistoryIntegrity(g);
|
||||
@@ -14146,6 +14264,7 @@ function finishResponse(g: Game, canceled: boolean) {
|
||||
currentFactionPayment(g);
|
||||
const response = g.response!;
|
||||
if (response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, response);
|
||||
if (response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, response);
|
||||
const ecazCollectionQuote =
|
||||
response.kind === 'ecazCollection'
|
||||
? currentEcazCollectionQuote(g, response, canceled)
|
||||
@@ -14174,6 +14293,10 @@ function finishResponse(g: Game, canceled: boolean) {
|
||||
? moritaniAllianceCancellationQuote(g, response)
|
||||
: null;
|
||||
g.response = null;
|
||||
if (response.kind === 'nexusAdvisorFlip') {
|
||||
finishNexusAdvisors(g, canceled);
|
||||
return;
|
||||
}
|
||||
if (ecazCollectionQuote) {
|
||||
if (canceled)
|
||||
log(
|
||||
@@ -16970,6 +17093,7 @@ function applyActionInner(
|
||||
if (t === 'nexusTraitorDraw') { playNexusTraitorDraw(g, p, action); return g; }
|
||||
if (t === 'nexusFaceDancers') { playNexusFaceDancers(g, p, action); return g; }
|
||||
if (t === 'nexusSuboids') { playNexusSuboids(g, p, action); return g; }
|
||||
if (t === 'nexusAdvisors') { playNexusAdvisors(g, p, action); return g; }
|
||||
if (t === 'nexusTraitorReturn') {
|
||||
requireRule(!g.truthtrance, 'Finish the active Truthtrance before returning Nexus cards.');
|
||||
finishNexusTraitorReturn(g, p, action); return g;
|
||||
@@ -20439,6 +20563,7 @@ export function viewGame(state: Game, id: string) {
|
||||
nexusTraitors: projectedNexusTraitors(g, id),
|
||||
nexusTleilaxu: projectedNexusTleilaxu(g, id),
|
||||
nexusSuboids: projectedNexusSuboids(g, id),
|
||||
nexusAdvisors: projectedNexusAdvisors(g, id),
|
||||
nexusAtreides: nexusAtreidesOffer(g, id),
|
||||
homeworldRevivalDeployment: projectedHomeworldRevivalReturn(g, id),
|
||||
caladanReinforcement: projectedHomeworldVictoryReturn(g, id),
|
||||
|
||||
Reference in new issue
Block a user