Integrate Harkonnen Nexus traitor retirement and Mentat replacement
This commit is contained in:
1 parent
0c25aad62f
commit
1cf8ff72c7
25 files changed
+3049
-8
No files matched your search
@@ -0,0 +1,17 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
|
||||
/** All four policies acknowledge only their own legal view. Neither the native
|
||||
* source nor any rival card or private traitor estimate participates. */
|
||||
export function botNexusHarkonnenBetrayalActions(game: GameView): Action[] {
|
||||
const reaction = game.nexusHarkonnenBetrayalReaction;
|
||||
if (game.status !== 'playing' || game.phase !== 6 || !reaction?.event ||
|
||||
!reaction.canPass || reaction.hasPassed || game.roomControl?.paused || game.roomControl?.closed ||
|
||||
game.automaticContinuationPending || game.truthtrance || game.response || game.decision ||
|
||||
game.phaseOpening || game.nexusCards?.waiting.length || game.nexusTraitors?.pending) return [];
|
||||
const own = game.players.find((player) => player.id === game.me);
|
||||
if (!(own?.bot ?? own?.autopilot)) return [];
|
||||
return [{
|
||||
type: reaction.canUse && !reaction.blocked ? 'nexusHarkonnenBetrayalUse' : 'nexusHarkonnenBetrayalPass',
|
||||
event: reaction.event,
|
||||
}];
|
||||
}
|
||||
@@ -106,6 +106,7 @@ import { botChoamKullActions, botChoamKullCounterActions } from './bot-choam-kul
|
||||
import { botNexusRicheseBetrayalActions } from './bot-nexus-richese-betrayal';
|
||||
import { botNexusGuildBetrayalActions } from './bot-nexus-guild-betrayal';
|
||||
import { botNexusIxianBetrayalActions } from './bot-nexus-ixian-betrayal';
|
||||
import { botNexusHarkonnenBetrayalActions } from './bot-nexus-harkonnen-betrayal';
|
||||
import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
|
||||
import { fighterCount, isAdvisor } from './advisors';
|
||||
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
|
||||
@@ -3832,6 +3833,13 @@ 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.nexusHarkonnenBetrayalReaction) {
|
||||
if (g.status !== 'playing' || g.roomControl?.paused || g.roomControl?.closed ||
|
||||
g.automaticContinuationPending) return [];
|
||||
// Native counter and existing private decisions still own their source.
|
||||
if (g.response || g.truthtrance || g.decision) return policyActions(g);
|
||||
return botNexusHarkonnenBetrayalActions(g);
|
||||
}
|
||||
if (g.nexusIxianBetrayalReaction) {
|
||||
if (g.status !== 'playing' || g.roomControl?.paused || g.roomControl?.closed ||
|
||||
g.automaticContinuationPending) return [];
|
||||
|
||||
+258
-1
@@ -49,6 +49,7 @@ 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 { createIxianReplacementSource, validateIxianReplacementSource, closeIxianReplacementSource, validateIxianReplacementHistory, initialIxianReplacementCursor, ixianReplacementEvent, type IxianReplacementSource, type IxianReplacementReceipt, type IxianReplacementCursor } from './nexus-ixian-replacement';
|
||||
import { createIxianBetrayalSource, validateIxianBetrayalFrame, initialIxianBetrayalCursor, closeIxianBetrayalSource, validateIxianBetrayalHistory, ixianBetrayalResponders, ixianBetrayalEligible, type IxianBetrayalSource, type IxianBetrayalCursor, type IxianBetrayalReceipt } from './nexus-ixian-betrayal';
|
||||
import { createHarkonnenBetrayalSource, validateHarkonnenBetrayalFrame, initialHarkonnenBetrayalCursor, closeHarkonnenBetrayalSource, validateHarkonnenBetrayalHistory, harkonnenBetrayalResponders, harkonnenBetrayalEligible, createHarkonnenBetrayalReplacement, validateHarkonnenBetrayalReplacements, drawHarkonnenBetrayalReplacement, type HarkonnenBetrayalSource, type HarkonnenBetrayalCursor, type HarkonnenBetrayalReceipt, type HarkonnenBetrayalReplacement, type HarkonnenBetrayalAuthority } from './nexus-harkonnen-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';
|
||||
@@ -724,6 +725,13 @@ export type Battle = {
|
||||
plans: Record<string, Plan>;
|
||||
revealed: boolean;
|
||||
traitorCalls: Record<string, boolean>;
|
||||
/** Original native allowance, retained separately from the Nexus source. */
|
||||
nexusHarkonnenAllowance?: {
|
||||
provider: string;
|
||||
nativeWindow: 'direct' | 'harkonnenTraitor';
|
||||
required: string[];
|
||||
passed: string[];
|
||||
};
|
||||
preparation?: {
|
||||
kind: 'voice' | 'prescience' | 'prescienceAnswer' | 'nexusPrescienceAnswer';
|
||||
owner: string;
|
||||
@@ -1139,6 +1147,17 @@ export type Game = {
|
||||
} | null;
|
||||
nexusIxianBetrayalHistory?: IxianBetrayalReceipt[];
|
||||
nexusIxianBetrayalCursor?: IxianBetrayalCursor;
|
||||
/** Fresh bounded native Harkonnen traitor-cancellation profile. */
|
||||
nexusHarkonnenBetrayalPreview?: true;
|
||||
pendingNexusHarkonnenBetrayal?: {
|
||||
source: HarkonnenBetrayalSource;
|
||||
required: string[];
|
||||
passed: string[];
|
||||
} | null;
|
||||
nexusHarkonnenBetrayalHistory?: HarkonnenBetrayalReceipt[];
|
||||
nexusHarkonnenBetrayalCursor?: HarkonnenBetrayalCursor;
|
||||
pendingNexusHarkonnenReplacement?: HarkonnenBetrayalReplacement | null;
|
||||
nexusHarkonnenReplacementHistory?: HarkonnenBetrayalReplacement[];
|
||||
kullSequence?: number;
|
||||
kullRestrictions?: KullPhaseRestriction[];
|
||||
pendingKull?: {
|
||||
@@ -8125,6 +8144,217 @@ export function initializeNexusGameForAudit(state: Game): Game {
|
||||
'Enable Nexus cards in a fresh audit lobby first.');
|
||||
return initializeSetupGameForAudit(state, !!state.homeworlds, true);
|
||||
}
|
||||
/** Fresh audit admission only; ordinary games and public starts never opt in. */
|
||||
export function initializeHarkonnenNexusBetrayalGameForAudit(state: Game): Game {
|
||||
requireRule(harkonnenNexusBetrayalModeSupported(state) &&
|
||||
state.nexusCards?.cards === null && state.nexusCards.phase === null &&
|
||||
state.nexusHarkonnenBetrayalPreview === undefined &&
|
||||
state.pendingNexusHarkonnenBetrayal === undefined &&
|
||||
state.nexusHarkonnenBetrayalHistory === undefined &&
|
||||
state.nexusHarkonnenBetrayalCursor === undefined &&
|
||||
state.pendingNexusHarkonnenReplacement === undefined &&
|
||||
state.nexusHarkonnenReplacementHistory === undefined,
|
||||
'Harkonnen Nexus Betrayal requires a fresh classic Harkonnen lobby with only base Treachery and Nexus.');
|
||||
requireFreshBaseRuntime(state);
|
||||
const g = initializeNexusGameForAudit(state);
|
||||
g.nexusHarkonnenBetrayalPreview = true;
|
||||
g.pendingNexusHarkonnenBetrayal = null;
|
||||
g.nexusHarkonnenBetrayalHistory = [];
|
||||
g.nexusHarkonnenBetrayalCursor = initialHarkonnenBetrayalCursor();
|
||||
g.pendingNexusHarkonnenReplacement = null;
|
||||
g.nexusHarkonnenReplacementHistory = [];
|
||||
return g;
|
||||
}
|
||||
function harkonnenNexusBetrayalModeSupported(g: Game): boolean {
|
||||
return typeof g.advanced === 'boolean' && !!g.nexusCards && g.expansions.length === 0 &&
|
||||
g.players.length >= 2 && g.players.length <= 6 && !!byFaction(g,'harkonnen') &&
|
||||
new Set(g.players.map(p => p.faction)).size === g.players.length &&
|
||||
g.players.every(p => CLASSIC_FACTIONS[p.faction]) &&
|
||||
!g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries &&
|
||||
!g.discoveryStash && !g.greatMaker && !g.techTokens && !g.strongholdCards &&
|
||||
!g.ecazTreachery && !g.semutaPreview && !g.kullPreview && !g.nexusKullPreview &&
|
||||
!g.richeseBetrayalPreview && !g.guildBetrayalPreview &&
|
||||
!g.nexusIxianReplacementPreview && !g.nexusIxianBetrayalPreview && !g.moritaniAssassinatePreview;
|
||||
}
|
||||
function harkonnenNexusBetrayalContext(g: Game) {
|
||||
const b = g.battle!;
|
||||
const plans = Object.fromEntries([b.attacker,b.defender].map(id => [id,{
|
||||
leader:b.plans[id].leader,...(b.plans[id].kwisatz === undefined ? {} : {kwisatz:b.plans[id].kwisatz}),
|
||||
}]));
|
||||
const battle = {event:b.event!,attacker:b.attacker,defender:b.defender,plans,heroLeaderIds:[]};
|
||||
return {status:g.status,phase:g.phase,turn:g.turn,advanced:g.advanced,
|
||||
sequence:g.nexusHarkonnenBetrayalCursor!.sequence,players:g.players,
|
||||
cards:g.nexusCards!.cards!,physicalCards:physicalTreacheryCards(g),
|
||||
traitors:nexusTraitorSnapshot(g),universe:nexusTraitorUniverse(g),battle};
|
||||
}
|
||||
function harkonnenNexusBetrayalAuthority(g: Game): HarkonnenBetrayalAuthority {
|
||||
const b = g.battle, allowance = b?.nexusHarkonnenAllowance;
|
||||
requireRule(b?.revealed && b.event && allowance && g.phase === 6 &&
|
||||
b.traitorCalls[allowance.provider] === true &&
|
||||
traitorVoters(g,b).includes(allowance.provider) &&
|
||||
getPlayer(g,allowance.provider).faction === 'harkonnen',
|
||||
'The Harkonnen reaction lost its original legal native call.');
|
||||
const declaration = nexusRule(() => createTraitorDeclaration(traitorDeclarationContext(g),allowance.provider));
|
||||
requireRule(JSON.stringify(declaration) === JSON.stringify(b.traitorDeclarations?.[allowance.provider]) &&
|
||||
allowance.nativeWindow === ([b.attacker,b.defender].includes(allowance.provider) ? 'direct' : 'harkonnenTraitor'),
|
||||
'The Harkonnen call lost its original canonical declaration or native window.');
|
||||
const required = allowance.nativeWindow === 'direct' ? [] :
|
||||
g.players.filter(p => responseCancelCards(g,p,{kind:'harkonnenTraitor',owner:allowance.provider,passed:[]}).length > 0).map(p => p.id);
|
||||
requireRule(JSON.stringify(required) === JSON.stringify(allowance.required) &&
|
||||
new Set(allowance.passed).size === allowance.passed.length &&
|
||||
allowance.passed.every(id => g.players.some(p => p.id === id)) &&
|
||||
required.every(id => allowance.passed.includes(id)) &&
|
||||
(allowance.nativeWindow !== 'direct' || allowance.passed.length === 0),
|
||||
'The original native Harkonnen counters were not legally completed.');
|
||||
return {provider:allowance.provider,declaration,nativeWindow:allowance.nativeWindow,
|
||||
nativeRequired:required,nativePassed:allowance.passed,
|
||||
nativeContext:JSON.stringify({event:b.event,territory:b.territory,attacker:b.attacker,
|
||||
defender:b.defender,plans:b.plans,allowance})};
|
||||
}
|
||||
function harkonnenNexusBetrayalParent(g: Game): string {
|
||||
return JSON.stringify({status:g.status,turn:g.turn,phase:g.phase,advanced:g.advanced,
|
||||
active:g.active,order:g.order,ready:g.ready,battle:g.battle,
|
||||
players:g.players.map(p => ({id:p.id,faction:p.faction,ally:p.ally,spice:p.spice,
|
||||
hand:p.hand,traitors:p.traitors,revealedTraitors:p.revealedTraitors,leaders:p.leaders,
|
||||
forces:p.forces,reserves:p.reserves,tanks:p.tanks,elites:p.elites,advisors:p.advisors})),
|
||||
deck:g.deck,discard:g.discard,traitorReserve:g.traitorReserve,nexus:g.nexusCards,
|
||||
response:g.response,decision:g.decision,truthtrance:g.truthtrance,
|
||||
phaseOpening:g.phaseOpening,pendingKarama:g.pendingKarama,
|
||||
pendingNullentropy:g.pendingNullentropy,pendingTreacheryDiscard:g.pendingTreacheryDiscard,
|
||||
replacement:g.pendingNexusHarkonnenReplacement});
|
||||
}
|
||||
function harkonnenNexusBetrayalIntegrity(g: Game, mentatOpening = false): void {
|
||||
const frame = g.pendingNexusHarkonnenBetrayal;
|
||||
if (!g.nexusHarkonnenBetrayalPreview) {
|
||||
requireRule(g.nexusHarkonnenBetrayalPreview === undefined && !frame &&
|
||||
g.nexusHarkonnenBetrayalHistory === undefined && g.nexusHarkonnenBetrayalCursor === undefined &&
|
||||
!g.pendingNexusHarkonnenReplacement && g.nexusHarkonnenReplacementHistory === undefined,
|
||||
'Harkonnen Nexus Betrayal lost its explicit fresh profile.');
|
||||
return;
|
||||
}
|
||||
requireRule(harkonnenNexusBetrayalModeSupported(g) && g.nexusHarkonnenBetrayalCursor &&
|
||||
Array.isArray(g.nexusHarkonnenBetrayalHistory) && Array.isArray(g.nexusHarkonnenReplacementHistory),
|
||||
'The Harkonnen profile lost its bounded history.');
|
||||
const universe = nexusTraitorUniverse(g);
|
||||
nexusCardsIntegrity(g);
|
||||
nexusRule(() => validateHarkonnenBetrayalHistory(g.nexusHarkonnenBetrayalHistory!,g.nexusHarkonnenBetrayalCursor!,universe));
|
||||
nexusRule(() => validateHarkonnenBetrayalReplacements(g.nexusHarkonnenBetrayalHistory!,
|
||||
g.nexusHarkonnenBetrayalCursor!,[...g.nexusHarkonnenReplacementHistory!,
|
||||
...(g.pendingNexusHarkonnenReplacement ? [g.pendingNexusHarkonnenReplacement] : [])],universe));
|
||||
requireRule(g.nexusHarkonnenBetrayalHistory!.every(r => r.source.turn <= g.turn) &&
|
||||
g.nexusHarkonnenReplacementHistory!.every(r => r.status === 'drawn' && r.turn <= g.turn) &&
|
||||
(!g.pendingNexusHarkonnenReplacement || (g.status === 'playing' &&
|
||||
g.pendingNexusHarkonnenReplacement.status === 'due' &&
|
||||
g.pendingNexusHarkonnenReplacement.turn === g.turn &&
|
||||
(g.phase === 6 || g.phase === 7 || (mentatOpening && g.phase === 8)))),
|
||||
'The private replacement lost its current native Mentat obligation.');
|
||||
if (g.status !== 'setup' && g.status !== 'lobby')
|
||||
nexusRule(() => validateNexusTraitorSnapshot(nexusTraitorSnapshot(g),universe));
|
||||
if (!frame) {
|
||||
const battle = g.battle, allowance = battle?.nexusHarkonnenAllowance;
|
||||
if (allowance) {
|
||||
const closed = g.nexusHarkonnenBetrayalHistory!.find(receipt =>
|
||||
receipt.source.declaration.event === battle!.event && receipt.source.provider === allowance.provider);
|
||||
requireRule(closed && closed.source.nativeWindow === allowance.nativeWindow &&
|
||||
JSON.stringify(closed.source.nativeRequired) === JSON.stringify(allowance.required) &&
|
||||
JSON.stringify(closed.source.nativePassed) === JSON.stringify(allowance.passed) &&
|
||||
JSON.stringify(closed.source.declaration) === JSON.stringify(battle!.traitorDeclarations?.[allowance.provider]) &&
|
||||
battle!.traitorCalls[allowance.provider] === (closed.outcome === 'pass'),
|
||||
'The original native Harkonnen allowance lost its pending or closed Nexus acknowledgement.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
requireRule(Object.keys(frame).sort().join(',') === 'passed,required,source' &&
|
||||
Array.isArray(frame.required) && Array.isArray(frame.passed) &&
|
||||
frame.required.some(id => !frame.passed.includes(id)) &&
|
||||
!g.response && !g.decision && !g.phaseOpening && !g.truthtrance &&
|
||||
!g.pendingKarama && !g.pendingNullentropy && !g.pendingTreacheryDiscard,
|
||||
'The Harkonnen acknowledgement lost original native priority.');
|
||||
nexusRule(() => validateHarkonnenBetrayalFrame(harkonnenNexusBetrayalContext(g),frame.source,
|
||||
harkonnenNexusBetrayalAuthority(g),harkonnenNexusBetrayalParent(g),frame.required,frame.passed));
|
||||
}
|
||||
function offerHarkonnenNexusBetrayal(g: Game, provider: string, response?: ResponseWindow): boolean {
|
||||
if (!g.nexusHarkonnenBetrayalPreview || getPlayer(g,provider).faction !== 'harkonnen') return false;
|
||||
const b = g.battle!;
|
||||
const required = nexusRule(() => harkonnenBetrayalResponders(g.nexusCards!.cards!,g.players));
|
||||
if (!required.length) return false;
|
||||
requireRule(!b.nexusHarkonnenAllowance && (!response || response.kind === 'harkonnenTraitor'),
|
||||
'The original Harkonnen declaration was already acknowledged.');
|
||||
b.nexusHarkonnenAllowance = {provider,nativeWindow:response ? 'harkonnenTraitor' : 'direct',
|
||||
required:response ? g.players.filter(p => responseCancelCards(g,p,response).length > 0).map(p => p.id) : [],
|
||||
passed:response ? [...response.passed] : []};
|
||||
const declaration = harkonnenNexusBetrayalAuthority(g).declaration;
|
||||
const owner = getPlayer(g,provider);
|
||||
owner.revealedTraitors = [...new Set([...(owner.revealedTraitors ?? []),declaration.identity])];
|
||||
const name = g.players.flatMap(p => p.leaders).find(l => l.id === declaration.leader)?.name ?? declaration.leader;
|
||||
log(g,`${owner.name} revealed ${name} as a traitor.`);
|
||||
const source = nexusRule(() => createHarkonnenBetrayalSource(harkonnenNexusBetrayalContext(g),
|
||||
harkonnenNexusBetrayalAuthority(g),harkonnenNexusBetrayalParent(g)));
|
||||
g.pendingNexusHarkonnenBetrayal = {source,required,passed:[]};
|
||||
return true;
|
||||
}
|
||||
function harkonnenNexusBetrayalView(g: Game,id: string) {
|
||||
const frame = g.pendingNexusHarkonnenBetrayal;
|
||||
if (!frame) return null;
|
||||
const hasPassed = frame.passed.includes(id), canPass = frame.required.includes(id) && !hasPassed;
|
||||
const d = frame.source.declaration;
|
||||
return {event:frame.source.event,provider:frame.source.provider,beneficiary:d.beneficiary,
|
||||
target:d.target,leader:d.leader,identity:d.identity,canPass,hasPassed,
|
||||
canUse:canPass && nexusRule(() => harkonnenBetrayalEligible(g.nexusCards!.cards!,g.players,id)),blocked:null as string | null};
|
||||
}
|
||||
function decideHarkonnenNexusBetrayal(g: Game,id: string,action: Action): void {
|
||||
harkonnenNexusBetrayalIntegrity(g);
|
||||
const frame = g.pendingNexusHarkonnenBetrayal!;
|
||||
requireRule(['nexusHarkonnenBetrayalPass','nexusHarkonnenBetrayalUse'].includes(action?.type) &&
|
||||
action.event === frame.source.event && Object.keys(action).length === 2 &&
|
||||
Object.keys(action).every(key => key === 'type' || key === 'event') &&
|
||||
frame.required.includes(id) && !frame.passed.includes(id),
|
||||
'A current required seat must acknowledge only the original Harkonnen event.');
|
||||
const use = action.type === 'nexusHarkonnenBetrayalUse';
|
||||
if (use) requireRule(!g.pendingNexusHarkonnenReplacement &&
|
||||
nexusRule(() => harkonnenBetrayalEligible(g.nexusCards!.cards!,g.players,id)),
|
||||
'You do not own an eligible Harkonnen Nexus card or the prior replacement is still due.');
|
||||
if (!use) {
|
||||
frame.passed.push(id);
|
||||
if (frame.required.some(seat => !frame.passed.includes(seat))) return;
|
||||
}
|
||||
const closed = nexusRule(() => closeHarkonnenBetrayalSource(frame.source,use ? 'use' : 'pass',
|
||||
use ? id : null,g.nexusHarkonnenBetrayalCursor!));
|
||||
if (use) {
|
||||
const owner = getPlayer(g,frame.source.provider), identity = frame.source.declaration.identity;
|
||||
requireRule(owner.traitors.filter(card => card === identity).length === 1 && g.traitorReserve,
|
||||
'The original called physical traitor is no longer held exactly once.');
|
||||
const replacement = nexusRule(() => createHarkonnenBetrayalReplacement(closed.receipt));
|
||||
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,id,g.players));
|
||||
owner.traitors = owner.traitors.filter(card => card !== identity);
|
||||
owner.revealedTraitors = (owner.revealedTraitors ?? []).filter(card => card !== identity);
|
||||
g.traitorReserve = shuffle([...g.traitorReserve,identity]);
|
||||
g.battle!.traitorCalls[owner.id] = false;
|
||||
g.pendingNexusHarkonnenReplacement = replacement;
|
||||
log(g,`${getPlayer(g,id).name} spent Harkonnen Nexus Betrayal. The declared Traitor Card returned to the shuffled Traitor Deck; Harkonnen will draw one private replacement during this turn’s Mentat Pause.`);
|
||||
}
|
||||
g.nexusHarkonnenBetrayalHistory!.push(closed.receipt);
|
||||
g.nexusHarkonnenBetrayalCursor = closed.cursor;
|
||||
g.pendingNexusHarkonnenBetrayal = null;
|
||||
if (traitorVoters(g,g.battle!).every(voter => g.battle!.traitorCalls[voter] !== undefined))
|
||||
advanceBattleOutcome(g);
|
||||
}
|
||||
function drawHarkonnenNexusBetrayalReplacement(g: Game): void {
|
||||
const due = g.pendingNexusHarkonnenReplacement;
|
||||
if (!due) return;
|
||||
harkonnenNexusBetrayalIntegrity(g,true);
|
||||
requireRule(g.status === 'playing' && g.phase === 8 && due.turn === g.turn &&
|
||||
g.traitorReserve && g.traitorReserve.length > 0,
|
||||
'The private Harkonnen replacement requires its actual current Mentat opening and Traitor Deck.');
|
||||
const identity = g.traitorReserve[0];
|
||||
const drawn = nexusRule(() => drawHarkonnenBetrayalReplacement(due,g.nexusHarkonnenBetrayalHistory!,
|
||||
g.nexusHarkonnenBetrayalCursor!,identity,nexusTraitorUniverse(g)));
|
||||
g.pendingNexusHarkonnenReplacement = null;
|
||||
g.nexusHarkonnenReplacementHistory!.push(drawn);
|
||||
g.traitorReserve.shift();
|
||||
getPlayer(g,due.provider).traitors.push(identity);
|
||||
log(g,`${getPlayer(g,due.provider).name} drew the one private Traitor Card replacement owed by Harkonnen Nexus Betrayal.`);
|
||||
}
|
||||
/** Offline-only, fresh native Ixian/Ix47 + Nexus profile. */
|
||||
export function initializeIxianNexusBetrayalGameForAudit(state: Game): Game {
|
||||
requireRule(ixianNexusBetrayalModeSupported(state) &&
|
||||
@@ -16945,6 +17175,7 @@ function beginPhase(g: Game) {
|
||||
collect(g);
|
||||
}
|
||||
if (g.phase === 8) {
|
||||
drawHarkonnenNexusBetrayalReplacement(g);
|
||||
replaceMoritaniAssassinationTraitors(g);
|
||||
if (g.inflation && g.inflation.updatedTurn < g.turn) {
|
||||
if (g.inflation.flipped) {
|
||||
@@ -19869,6 +20100,8 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
|
||||
for (const revelation of quote.revelations) {
|
||||
const holder = getPlayer(g, revelation.player),
|
||||
identity = revelation.identity;
|
||||
if (b.nexusHarkonnenAllowance?.provider === holder.id &&
|
||||
b.traitorDeclarations?.[holder.id]?.identity === identity) continue;
|
||||
holder.revealedTraitors = [
|
||||
...new Set([...(holder.revealedTraitors ?? []), identity]),
|
||||
];
|
||||
@@ -22732,6 +22965,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
|
||||
} else if (response.kind === 'harkonnenTraitor') {
|
||||
const b = g.battle!;
|
||||
if (canceled) b.traitorCalls[response.owner] = false;
|
||||
else if (offerHarkonnenNexusBetrayal(g,response.owner,response)) return;
|
||||
if (
|
||||
traitorVoters(g, b).every((voter) => b.traitorCalls[voter] !== undefined)
|
||||
)
|
||||
@@ -25464,6 +25698,7 @@ function normalizeCardNames(g: Game) {
|
||||
]);
|
||||
}
|
||||
export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
harkonnenNexusBetrayalIntegrity(state);
|
||||
ixianNexusBetrayalIntegrity(state);
|
||||
ixianNexusReplacementIntegrity(state);
|
||||
guildBetrayalIntegrity(state);
|
||||
@@ -25552,6 +25787,8 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
return normalizeAutomaticGame(state);
|
||||
}
|
||||
const g = applyActionInner(state, id, action);
|
||||
harkonnenNexusBetrayalIntegrity(g);
|
||||
if (g.pendingNexusHarkonnenBetrayal) return g;
|
||||
ixianNexusBetrayalIntegrity(g);
|
||||
if (g.pendingNexusIxianBetrayal) return g;
|
||||
ixianNexusReplacementIntegrity(g);
|
||||
@@ -25686,9 +25923,11 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
guildRateIntegrity(g);
|
||||
arrivalOverlapIntegrity(g);
|
||||
guildBetrayalIntegrity(g);
|
||||
harkonnenNexusBetrayalIntegrity(g);
|
||||
return g;
|
||||
}
|
||||
function finishActionContinuations(g: Game) {
|
||||
if (g.pendingNexusHarkonnenBetrayal) return;
|
||||
if (g.pendingNexusIxianBetrayal) return;
|
||||
if (g.pendingGuildBetrayal) return;
|
||||
if (g.pendingRicheseBetrayal) return;
|
||||
@@ -25754,6 +25993,7 @@ function finishActionContinuations(g: Game) {
|
||||
finishMoritaniPlacement(g);
|
||||
}
|
||||
function settleAutomaticContinuations(g: Game) {
|
||||
if (g.pendingNexusHarkonnenBetrayal) return;
|
||||
if (g.pendingNexusIxianBetrayal) return;
|
||||
if (g.pendingNexusIxianReplacement) return;
|
||||
if (g.pendingGuildBetrayal) return;
|
||||
@@ -25762,6 +26002,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
if (pendingNexusTraitors(g)) return;
|
||||
if (g.pendingNullentropy) return;
|
||||
for (let iteration = 0; iteration < 128; iteration++) {
|
||||
if (g.pendingNexusHarkonnenBetrayal) return;
|
||||
if (g.pendingNexusIxianBetrayal) return;
|
||||
if (g.pendingGuildBetrayal) return;
|
||||
if (g.pendingNexusIxianReplacement) return;
|
||||
@@ -25787,6 +26028,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
finishResponse(g, false);
|
||||
} else if (!finishAutomaticDecision(g)) return;
|
||||
finishActionContinuations(g);
|
||||
if (g.pendingNexusHarkonnenBetrayal) return;
|
||||
if (g.pendingNexusIxianBetrayal) return;
|
||||
if (g.pendingGuildBetrayal) return;
|
||||
if (g.pendingRicheseBetrayal) return;
|
||||
@@ -25805,6 +26047,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
}
|
||||
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
|
||||
export function normalizeAutomaticGame(state: Game): Game {
|
||||
harkonnenNexusBetrayalIntegrity(state);
|
||||
ixianNexusBetrayalIntegrity(state);
|
||||
ixianNexusReplacementIntegrity(state);
|
||||
guildBetrayalIntegrity(state);
|
||||
@@ -25843,6 +26086,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
ecazCollectionIntegrity(state);
|
||||
ecazAllianceIntegrity(state);
|
||||
const g = structuredClone(state);
|
||||
if (g.pendingNexusHarkonnenBetrayal) return g;
|
||||
if (g.pendingNexusIxianBetrayal) return g;
|
||||
if (g.pendingNexusIxianReplacement) return g;
|
||||
if (g.pendingGuildBetrayal) return g;
|
||||
@@ -25886,6 +26130,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
moritaniExtortionIntegrity(g);
|
||||
guildRateIntegrity(g);
|
||||
arrivalOverlapIntegrity(g);
|
||||
harkonnenNexusBetrayalIntegrity(g);
|
||||
return g;
|
||||
}
|
||||
|
||||
@@ -25897,6 +26142,12 @@ function applyActionInner(
|
||||
): Game {
|
||||
ecazTreacheryIntegrity(state);
|
||||
const g = structuredClone(state);
|
||||
if (g.pendingNexusHarkonnenBetrayal) {
|
||||
getPlayer(g,id);
|
||||
if (action?.type === 'advanceBots') return g;
|
||||
decideHarkonnenNexusBetrayal(g,id,action);
|
||||
return g;
|
||||
}
|
||||
if (g.pendingNexusIxianBetrayal) {
|
||||
getPlayer(g,id);
|
||||
if (action?.type === 'advanceBots') return g;
|
||||
@@ -29592,6 +29843,7 @@ function applyActionInner(
|
||||
g.response = { kind: 'harkonnenTraitor', owner: id, passed: [] };
|
||||
return g;
|
||||
}
|
||||
if (action.call && offerHarkonnenNexusBetrayal(g,id)) return g;
|
||||
|
||||
if (
|
||||
traitorVoters(g, b).every((voter) => b.traitorCalls[voter] !== undefined)
|
||||
@@ -29758,6 +30010,7 @@ function applyActionInner(
|
||||
throw new RuleError('That action is not available.');
|
||||
}
|
||||
export function viewGame(state: Game, id: string) {
|
||||
harkonnenNexusBetrayalIntegrity(state);
|
||||
ixianNexusBetrayalIntegrity(state);
|
||||
ixianNexusReplacementIntegrity(state);
|
||||
guildBetrayalIntegrity(state);
|
||||
@@ -30133,6 +30386,8 @@ export function viewGame(state: Game, id: string) {
|
||||
? {...nexusRule(() => quoteDiscoveryEntry({...g,discoveries:g.discoveries!},g.discoveryEntry!,id)),event:g.decision.event} : null,
|
||||
greatMaker: g.greatMaker ? { event:g.greatMaker.event, turn:g.greatMaker.turn, stage:g.greatMaker.stage, votes:structuredClone(g.greatMaker.votes), order:[...g.greatMaker.order], ride:greatMakerRideOptions(g,id) } : null,
|
||||
nexusCards: projectedNexusCards(g, id),
|
||||
nexusHarkonnenBetrayalPreview: !!g.nexusHarkonnenBetrayalPreview,
|
||||
nexusHarkonnenBetrayalReaction: harkonnenNexusBetrayalView(g,id),
|
||||
nexusIxianBetrayalPreview: !!g.nexusIxianBetrayalPreview,
|
||||
nexusIxianBetrayalReaction: ixianNexusBetrayalView(g,id),
|
||||
nexusIxianReplacementPreview: !!g.nexusIxianReplacementPreview,
|
||||
@@ -30204,7 +30459,9 @@ export function viewGame(state: Game, id: string) {
|
||||
storm: g.status === 'lobby' ? STORM_START_SECTOR : g.storm,
|
||||
playerPositions: normalizedPlayerPositions(g),
|
||||
order: g.order,
|
||||
active: g.pendingNexusIxianBetrayal
|
||||
active: g.pendingNexusHarkonnenBetrayal
|
||||
? (() => {const remaining = g.pendingNexusHarkonnenBetrayal.required.filter(seat => !g.pendingNexusHarkonnenBetrayal!.passed.includes(seat)); return remaining.length === 1 ? remaining[0] : null;})()
|
||||
: g.pendingNexusIxianBetrayal
|
||||
? (() => {const remaining = g.pendingNexusIxianBetrayal.required.filter(seat => !g.pendingNexusIxianBetrayal!.passed.includes(seat)); return remaining.length === 1 ? remaining[0] : null;})()
|
||||
: g.active,
|
||||
movementRemaining: g.movementRemaining ?? null,
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
import { baseDeck, leaders, type Card } from './cards';
|
||||
import {
|
||||
nexusCardMode, validateNexusCards, validateNexusPlayers,
|
||||
type NexusPlayer, type NexusState,
|
||||
} from './nexus-cards';
|
||||
import { validateNexusTraitorSnapshot, type NexusTraitorSnapshot } from './nexus-traitor-exchange';
|
||||
import {
|
||||
validateTraitorDeclaration, type TraitorDeclaration, type TraitorDeclarationContext,
|
||||
} from './traitor-declarations';
|
||||
import { traitorDeck } from './traitors';
|
||||
|
||||
export type HarkonnenBetrayalAuthority = {
|
||||
provider: string;
|
||||
declaration: TraitorDeclaration;
|
||||
nativeWindow: 'direct' | 'harkonnenTraitor';
|
||||
nativeContext: string;
|
||||
nativeRequired: readonly string[];
|
||||
nativePassed: readonly string[];
|
||||
};
|
||||
type Battle = Omit<TraitorDeclarationContext, 'players'>;
|
||||
export type HarkonnenBetrayalContext = {
|
||||
status: string;
|
||||
phase: number;
|
||||
turn: number;
|
||||
advanced: boolean;
|
||||
sequence: number;
|
||||
players: readonly NexusPlayer[];
|
||||
cards: NexusState;
|
||||
physicalCards: readonly Card[];
|
||||
traitors: NexusTraitorSnapshot;
|
||||
universe: readonly string[];
|
||||
battle: Battle;
|
||||
};
|
||||
export type HarkonnenBetrayalSource = HarkonnenBetrayalAuthority & {
|
||||
version: 1;
|
||||
event: string;
|
||||
turn: number;
|
||||
phase: 6;
|
||||
advanced: boolean;
|
||||
sequence: number;
|
||||
required: readonly string[];
|
||||
eligible: string | null;
|
||||
players: readonly NexusPlayer[];
|
||||
battle: Battle;
|
||||
universe: readonly string[];
|
||||
/** Current committed census only, never a requirement on later custody. */
|
||||
inventory: string;
|
||||
parent: string;
|
||||
signature: string;
|
||||
};
|
||||
export type HarkonnenBetrayalCursor = { sequence: number; signature: string };
|
||||
export type HarkonnenBetrayalReceipt = {
|
||||
source: HarkonnenBetrayalSource;
|
||||
outcome: 'pass' | 'use';
|
||||
holder: string | null;
|
||||
previous: string;
|
||||
signature: string;
|
||||
};
|
||||
export type HarkonnenBetrayalReplacement = {
|
||||
version: 1;
|
||||
event: string;
|
||||
sequence: number;
|
||||
provider: string;
|
||||
turn: number;
|
||||
identity: string;
|
||||
/** Bounded head of the exact successful Use, not the latest cursor. */
|
||||
receipt: string;
|
||||
status: 'due' | 'drawn';
|
||||
drawn: string | null;
|
||||
signature: string;
|
||||
};
|
||||
export class HarkonnenBetrayalError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'HarkonnenBetrayalError';
|
||||
}
|
||||
}
|
||||
const genesis = 'harkonnen-betrayal:0';
|
||||
const classic: Readonly<Record<string, true>> = {
|
||||
atreides: true, harkonnen: true, emperor: true, fremen: true, guild: true, beneGesserit: true,
|
||||
};
|
||||
const canonicalCards = new Map(baseDeck().map(card => [card.id, card]));
|
||||
const declarationKeys = ['event', 'voter', 'beneficiary', 'target', 'leader', 'identity', 'signature'] as const;
|
||||
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 text = (value: unknown): value is string => typeof value === 'string' &&
|
||||
value.trim().length > 0 && !['__proto__', 'constructor', 'prototype'].includes(value);
|
||||
const whole = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
const array = (value: unknown): boolean => Array.isArray(value);
|
||||
function requireBetrayal(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new HarkonnenBetrayalError(message);
|
||||
}
|
||||
function exact(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
|
||||
return plain(value) && Object.keys(value).sort().join(',') === [...keys].sort().join(',');
|
||||
}
|
||||
function members(value: unknown): value is readonly string[] {
|
||||
return Array.isArray(value) && value.every(text) && new Set(value).size === value.length;
|
||||
}
|
||||
const equal = (one: unknown, two: unknown): boolean => JSON.stringify(one) === JSON.stringify(two);
|
||||
function validateRoster(players: readonly NexusPlayer[]): void {
|
||||
validateNexusPlayers(players);
|
||||
requireBetrayal(players.every(player => Object.hasOwn(classic, player.faction)) &&
|
||||
players.filter(player => player.faction === 'harkonnen').length === 1,
|
||||
'Harkonnen Betrayal requires native Harkonnen and two through six classic seats.');
|
||||
}
|
||||
function roster(players: readonly NexusPlayer[]): NexusPlayer[] {
|
||||
return players.map(({ id, faction, ally }) => ({ id, faction, ally: ally ?? null }));
|
||||
}
|
||||
function canonicalUniverse(players: readonly NexusPlayer[]): string[] {
|
||||
return traitorDeck(players.map(player => ({ leaders: leaders(player.faction) })));
|
||||
}
|
||||
function validateUniverse(universe: readonly string[], players: readonly NexusPlayer[]): void {
|
||||
const expected = canonicalUniverse(players);
|
||||
requireBetrayal(members(universe) && universe.length === expected.length &&
|
||||
universe.every(identity => expected.includes(identity)),
|
||||
'Harkonnen Betrayal needs the original complete canonical classic Traitor universe.');
|
||||
}
|
||||
function validatePhysical(cards: readonly Card[]): void {
|
||||
requireBetrayal(Array.isArray(cards) && cards.length === canonicalCards.size &&
|
||||
new Set(cards.map(card => card?.id)).size === canonicalCards.size && cards.every(card => {
|
||||
if (!plain(card) || !text(card.id)) return false;
|
||||
const expected = canonicalCards.get(card.id);
|
||||
return !!expected && exact(card, ['id', 'name', 'kind',
|
||||
...(expected.effect === undefined ? [] : ['effect'])]) && card.name === expected.name &&
|
||||
card.kind === expected.kind && card.effect === expected.effect;
|
||||
}), 'Harkonnen Betrayal requires complete unique canonical base33 Treachery custody.');
|
||||
}
|
||||
function validateBattle(battle: Battle): void {
|
||||
requireBetrayal(exact(battle, ['event', 'attacker', 'defender', 'plans', 'heroLeaderIds']) &&
|
||||
text(battle.event) && text(battle.attacker) && text(battle.defender) &&
|
||||
battle.attacker !== battle.defender && exact(battle.plans, [battle.attacker, battle.defender]) &&
|
||||
Array.isArray(battle.heroLeaderIds) && battle.heroLeaderIds.length === 0 &&
|
||||
Object.values(battle.plans).every(plan => plain(plan) &&
|
||||
exact(plan, ['leader', ...(Object.hasOwn(plan, 'kwisatz') ? ['kwisatz'] : [])]) &&
|
||||
(plan.leader === null || text(plan.leader)) &&
|
||||
(plan.kwisatz === undefined || typeof plan.kwisatz === 'boolean')),
|
||||
'Harkonnen Betrayal requires original sealed classic battle plans, without Ix heroes.');
|
||||
}
|
||||
function declarationContext(
|
||||
battle: Battle, players: readonly NexusPlayer[], traitors?: NexusTraitorSnapshot,
|
||||
): TraitorDeclarationContext {
|
||||
return { ...battle, players: players.map(player => ({ ...player,
|
||||
traitors: traitors?.players.find(seat => seat.id === player.id)?.traitors ?? [],
|
||||
})) };
|
||||
}
|
||||
function validateAuthority(
|
||||
authority: HarkonnenBetrayalAuthority, players: readonly NexusPlayer[], battle: Battle,
|
||||
): void {
|
||||
requireBetrayal(exact(authority, ['provider', 'declaration', 'nativeWindow', 'nativeContext',
|
||||
'nativeRequired', 'nativePassed']) && text(authority.provider) && text(authority.nativeContext) &&
|
||||
members(authority.nativeRequired) && members(authority.nativePassed) &&
|
||||
authority.nativeRequired.every(id => authority.nativePassed.includes(id)) &&
|
||||
[...authority.nativeRequired, ...authority.nativePassed].every(id => players.some(p => p.id === id)) &&
|
||||
players.find(player => player.id === authority.provider)?.faction === 'harkonnen' &&
|
||||
authority.declaration?.voter === authority.provider,
|
||||
'Harkonnen Betrayal must follow its original allowed native Harkonnen declaration.');
|
||||
try {
|
||||
validateTraitorDeclaration(declarationContext(battle, players), authority.declaration);
|
||||
} catch {
|
||||
throw new HarkonnenBetrayalError('Harkonnen Betrayal lost its canonical native declaration or roles.');
|
||||
}
|
||||
const direct = authority.declaration.beneficiary === authority.provider;
|
||||
requireBetrayal(direct ? authority.nativeWindow === 'direct' &&
|
||||
authority.nativeRequired.length === 0 && authority.nativePassed.length === 0 :
|
||||
authority.nativeWindow === 'harkonnenTraitor',
|
||||
'Harkonnen Betrayal cannot confuse personal calls with native allowed allied calls.');
|
||||
requireBetrayal(players.some(player => leaders(player.faction).some(leader =>
|
||||
leader.id === authority.declaration.leader)) && authority.declaration.identity === authority.declaration.leader,
|
||||
'Harkonnen Betrayal requires the opposing canonical classic leader identity.');
|
||||
}
|
||||
export function harkonnenBetrayalResponders(cards: NexusState, players: readonly NexusPlayer[]): string[] {
|
||||
validateRoster(players);
|
||||
validateNexusCards(cards, players);
|
||||
return players.filter(player => !player.ally && player.faction !== 'harkonnen' &&
|
||||
cards.hands[player.id] !== null).map(player => player.id);
|
||||
}
|
||||
export function harkonnenBetrayalEligible(
|
||||
cards: NexusState, players: readonly NexusPlayer[], holder: string,
|
||||
): boolean {
|
||||
validateRoster(players);
|
||||
validateNexusCards(cards, players);
|
||||
const player = players.find(seat => seat.id === holder);
|
||||
return !!player && !player.ally && player.faction !== 'harkonnen' && cards.hands[holder] === 'harkonnen' &&
|
||||
nexusCardMode('harkonnen', player.faction, players.map(seat => seat.faction)) === 'betrayal';
|
||||
}
|
||||
export function harkonnenBetrayalEvent(
|
||||
turn: number, sequence: number, declaration: TraitorDeclaration, provider: string,
|
||||
): string {
|
||||
requireBetrayal(whole(turn) && turn > 0 && whole(sequence) && text(provider) &&
|
||||
plain(declaration) && declarationKeys.every(key => text(declaration[key])) && declaration.voter === provider,
|
||||
'Harkonnen Betrayal requires its original public declared-card event.');
|
||||
return JSON.stringify(['nexusHarkonnenBetrayal', turn, sequence, declaration.event, provider,
|
||||
declaration.beneficiary, declaration.target, declaration.leader, declaration.identity]);
|
||||
}
|
||||
function validateContext(ctx: HarkonnenBetrayalContext): void {
|
||||
requireBetrayal(plain(ctx) && ctx.status === 'playing' && ctx.phase === 6 &&
|
||||
whole(ctx.turn) && ctx.turn > 0 && whole(ctx.sequence) && typeof ctx.advanced === 'boolean',
|
||||
'Harkonnen Betrayal requires its current Battle phase and independent cursor.');
|
||||
validateRoster(ctx.players);
|
||||
validateNexusCards(ctx.cards, ctx.players);
|
||||
validatePhysical(ctx.physicalCards);
|
||||
validateUniverse(ctx.universe, ctx.players);
|
||||
validateNexusTraitorSnapshot(ctx.traitors, ctx.universe);
|
||||
requireBetrayal(ctx.traitors.players.length === ctx.players.length &&
|
||||
ctx.players.every(player => ctx.traitors.players.some(seat =>
|
||||
seat.id === player.id && seat.faction === player.faction)),
|
||||
'Harkonnen Betrayal physical Traitor custody must match its original roster.');
|
||||
validateBattle(ctx.battle);
|
||||
}
|
||||
function sourceAuthority(source: HarkonnenBetrayalSource): HarkonnenBetrayalAuthority {
|
||||
return { provider: source.provider, declaration: source.declaration, nativeWindow: source.nativeWindow,
|
||||
nativeContext: source.nativeContext, nativeRequired: source.nativeRequired, nativePassed: source.nativePassed };
|
||||
}
|
||||
function inventory(ctx: HarkonnenBetrayalContext): string {
|
||||
return JSON.stringify([ctx.cards, ctx.traitors, ctx.physicalCards]);
|
||||
}
|
||||
function sourceSignature(source: HarkonnenBetrayalSource): string {
|
||||
return JSON.stringify(['harkonnenBetrayalSource', source.version, source.event, source.turn,
|
||||
source.phase, source.advanced, source.sequence, sourceAuthority(source), source.required,
|
||||
source.eligible, source.players, source.battle, source.universe, source.inventory, source.parent]);
|
||||
}
|
||||
function validateSavedSource(value: unknown): asserts value is HarkonnenBetrayalSource {
|
||||
requireBetrayal(exact(value, ['provider', 'declaration', 'nativeWindow', 'nativeContext',
|
||||
'nativeRequired', 'nativePassed', 'version', 'event', 'turn', 'phase', 'advanced', 'sequence',
|
||||
'required', 'eligible', 'players', 'battle', 'universe', 'inventory', 'parent', 'signature']) &&
|
||||
value.version === 1 && whole(value.turn) && value.turn > 0 && value.phase === 6 &&
|
||||
typeof value.advanced === 'boolean' && whole(value.sequence) && members(value.required) &&
|
||||
(value.eligible === null || text(value.eligible)) && text(value.event) && text(value.parent) &&
|
||||
text(value.signature) && text(value.inventory) && Array.isArray(value.players) &&
|
||||
plain(value.battle) && members(value.universe),
|
||||
'Harkonnen Betrayal lost its original canonical source.');
|
||||
// Shape checked above; nested values are validated by their native protocols.
|
||||
const source = value as HarkonnenBetrayalSource;
|
||||
validateRoster(source.players);
|
||||
requireBetrayal(source.players.every(player => exact(player, ['id', 'faction', 'ally'])),
|
||||
'Harkonnen Betrayal history must retain only the original public seat roles.');
|
||||
validateUniverse(source.universe, source.players);
|
||||
validateBattle(source.battle);
|
||||
validateAuthority(sourceAuthority(source), source.players, source.battle);
|
||||
// This is the original census, not today's holdings. Returning or redrawing
|
||||
// the declared card later cannot invalidate this historical declaration.
|
||||
let stored: unknown;
|
||||
try {
|
||||
stored = JSON.parse(source.inventory);
|
||||
} catch {
|
||||
throw new HarkonnenBetrayalError('Harkonnen Betrayal lost its original physical census.');
|
||||
}
|
||||
requireBetrayal(Array.isArray(stored) && stored.length === 3,
|
||||
'Harkonnen Betrayal lost its original physical census.');
|
||||
type SavedInventory = [NexusState, NexusTraitorSnapshot, readonly Card[]];
|
||||
const [cards, traitors, physicalCards] = stored as SavedInventory;
|
||||
validateNexusCards(cards, source.players);
|
||||
validateNexusTraitorSnapshot(traitors, source.universe);
|
||||
validatePhysical(physicalCards);
|
||||
requireBetrayal(traitors.players.length === source.players.length &&
|
||||
source.players.every(player => traitors.players.some(seat =>
|
||||
seat.id === player.id && seat.faction === player.faction)) &&
|
||||
traitors.players.find(player => player.id === source.provider)?.traitors.includes(source.declaration.identity) &&
|
||||
equal(source.required, harkonnenBetrayalResponders(cards, source.players)) &&
|
||||
source.eligible === (source.players.find(player =>
|
||||
harkonnenBetrayalEligible(cards, source.players, player.id))?.id ?? null),
|
||||
'Harkonnen Betrayal changed its original canonical custody or response eligibility.');
|
||||
requireBetrayal(source.required.every(id => source.players.some(player => player.id === id &&
|
||||
!player.ally && player.faction !== 'harkonnen')) &&
|
||||
(source.eligible === null || source.required.includes(source.eligible)) &&
|
||||
source.universe.includes(source.declaration.identity) &&
|
||||
source.event === harkonnenBetrayalEvent(source.turn, source.sequence, source.declaration, source.provider) &&
|
||||
source.signature === sourceSignature(source),
|
||||
'Harkonnen Betrayal changed its original event, roles or native source.');
|
||||
}
|
||||
export function validateHarkonnenBetrayalSource(
|
||||
ctx: HarkonnenBetrayalContext, source: HarkonnenBetrayalSource,
|
||||
authority: HarkonnenBetrayalAuthority, parent: string,
|
||||
): void {
|
||||
validateSavedSource(source);
|
||||
validateContext(ctx);
|
||||
validateAuthority(authority, ctx.players, ctx.battle);
|
||||
requireBetrayal(source.turn === ctx.turn && source.sequence === ctx.sequence &&
|
||||
source.advanced === ctx.advanced && source.parent === parent && text(parent) &&
|
||||
source.provider === authority.provider && source.nativeWindow === authority.nativeWindow &&
|
||||
source.nativeContext === authority.nativeContext && equal(source.declaration, authority.declaration) &&
|
||||
equal(source.nativeRequired, authority.nativeRequired) && equal(source.nativePassed, authority.nativePassed) &&
|
||||
equal(source.players, roster(ctx.players)) &&
|
||||
equal(source.battle, ctx.battle) && equal(source.universe, ctx.universe) &&
|
||||
source.inventory === inventory(ctx) &&
|
||||
ctx.traitors.players.find(player => player.id === source.provider)?.traitors.includes(source.declaration.identity) &&
|
||||
equal(source.required, harkonnenBetrayalResponders(ctx.cards, ctx.players)) &&
|
||||
source.eligible === (ctx.players.find(player => harkonnenBetrayalEligible(ctx.cards, ctx.players, player.id))?.id ?? null),
|
||||
'Harkonnen Betrayal no longer matches its current native attempt, custody or public responders.');
|
||||
}
|
||||
export function createHarkonnenBetrayalSource(
|
||||
ctx: HarkonnenBetrayalContext, authority: HarkonnenBetrayalAuthority, parent: string,
|
||||
): HarkonnenBetrayalSource {
|
||||
validateContext(ctx);
|
||||
validateAuthority(authority, ctx.players, ctx.battle);
|
||||
const source: HarkonnenBetrayalSource = {
|
||||
...structuredClone(authority), version: 1,
|
||||
event: harkonnenBetrayalEvent(ctx.turn, ctx.sequence, authority.declaration, authority.provider),
|
||||
turn: ctx.turn, phase: 6, advanced: ctx.advanced, sequence: ctx.sequence,
|
||||
required: harkonnenBetrayalResponders(ctx.cards, ctx.players),
|
||||
eligible: ctx.players.find(player => harkonnenBetrayalEligible(ctx.cards, ctx.players, player.id))?.id ?? null,
|
||||
players: roster(ctx.players), battle: structuredClone(ctx.battle), universe: [...ctx.universe],
|
||||
inventory: inventory(ctx), parent, signature: '',
|
||||
};
|
||||
source.signature = sourceSignature(source);
|
||||
validateHarkonnenBetrayalSource(ctx, source, authority, parent);
|
||||
return source;
|
||||
}
|
||||
export function validateHarkonnenBetrayalFrame(
|
||||
ctx: HarkonnenBetrayalContext, source: HarkonnenBetrayalSource,
|
||||
authority: HarkonnenBetrayalAuthority, parent: string,
|
||||
required: readonly string[], passed: readonly string[],
|
||||
): void {
|
||||
validateHarkonnenBetrayalSource(ctx, source, authority, parent);
|
||||
requireBetrayal(members(required) && members(passed) && equal(required, source.required) &&
|
||||
passed.every(id => required.includes(id)),
|
||||
'Harkonnen Betrayal lost its exact public required or passed membership.');
|
||||
}
|
||||
export function initialHarkonnenBetrayalCursor(): HarkonnenBetrayalCursor {
|
||||
return { sequence: 0, signature: genesis };
|
||||
}
|
||||
function validateCursor(value: unknown): asserts value is HarkonnenBetrayalCursor {
|
||||
requireBetrayal(exact(value, ['sequence', 'signature']) && whole(value.sequence) && text(value.signature) &&
|
||||
(value.sequence !== 0 || value.signature === genesis),
|
||||
'Harkonnen Betrayal lost its independent closed-event cursor.');
|
||||
if (value.sequence === 0) return;
|
||||
let head: unknown, event: unknown;
|
||||
try {
|
||||
head = JSON.parse(value.signature);
|
||||
if (Array.isArray(head) && typeof head[1] === 'string') event = JSON.parse(head[1]);
|
||||
} catch {
|
||||
throw new HarkonnenBetrayalError('Harkonnen Betrayal has a forged or recursive cursor head.');
|
||||
}
|
||||
requireBetrayal(Array.isArray(head) && head.length === 4 && head[0] === 'harkonnenBetrayalHead' &&
|
||||
(head[2] === 'pass' ? head[3] === null : head[2] === 'use' && text(head[3])) &&
|
||||
Array.isArray(event) && event.length === 9 && event[0] === 'nexusHarkonnenBetrayal' &&
|
||||
whole(event[1]) && event[1] > 0 && event[2] === value.sequence - 1 && event.slice(3).every(text) &&
|
||||
event[4] !== event[6] && event[5] !== event[6] && event[7] === event[8] &&
|
||||
value.signature === JSON.stringify(head) && head[1] === JSON.stringify(event),
|
||||
'Harkonnen Betrayal has a forged or recursive cursor head.');
|
||||
}
|
||||
function receiptSignature(receipt: HarkonnenBetrayalReceipt): string {
|
||||
return JSON.stringify(['harkonnenBetrayalReceipt', receipt.source.signature,
|
||||
receipt.outcome, receipt.holder, receipt.previous]);
|
||||
}
|
||||
function receiptHead(receipt: HarkonnenBetrayalReceipt): string {
|
||||
return JSON.stringify(['harkonnenBetrayalHead', receipt.source.event, receipt.outcome, receipt.holder]);
|
||||
}
|
||||
export function closeHarkonnenBetrayalSource(
|
||||
source: HarkonnenBetrayalSource, outcome: 'pass' | 'use', holder: string | null,
|
||||
cursor: HarkonnenBetrayalCursor,
|
||||
): { receipt: HarkonnenBetrayalReceipt; cursor: HarkonnenBetrayalCursor } {
|
||||
validateSavedSource(source);
|
||||
validateCursor(cursor);
|
||||
requireBetrayal(source.sequence === cursor.sequence &&
|
||||
cursor.sequence < Number.MAX_SAFE_INTEGER && (outcome === 'pass' ? holder === null :
|
||||
outcome === 'use' && text(holder) && holder === source.eligible),
|
||||
'Harkonnen Betrayal cannot close twice or lose its actual response outcome.');
|
||||
const receipt: HarkonnenBetrayalReceipt = {
|
||||
source: structuredClone(source), outcome, holder, previous: cursor.signature, signature: '',
|
||||
};
|
||||
receipt.signature = receiptSignature(receipt);
|
||||
return { receipt, cursor: { sequence: cursor.sequence + 1, signature: receiptHead(receipt) } };
|
||||
}
|
||||
/** Historical declarations do not claim continued possession of recycled cards.
|
||||
* Signatures establish consistency, not authentication of rewritten saves. */
|
||||
export function validateHarkonnenBetrayalHistory(
|
||||
history: readonly HarkonnenBetrayalReceipt[], cursor: HarkonnenBetrayalCursor,
|
||||
universe?: readonly string[],
|
||||
): void {
|
||||
validateCursor(cursor);
|
||||
requireBetrayal(array(history) && history.length === cursor.sequence,
|
||||
'Harkonnen Betrayal history has missing or duplicated closed attempts.');
|
||||
let previous = genesis, turn = 0;
|
||||
let originalRoster: readonly NexusPlayer[] | undefined;
|
||||
let originalUniverse: readonly string[] | undefined;
|
||||
for (const [index, receipt] of history.entries()) {
|
||||
requireBetrayal(exact(receipt, ['source', 'outcome', 'holder', 'previous', 'signature']),
|
||||
'Harkonnen Betrayal history has a malformed receipt.');
|
||||
validateSavedSource(receipt.source);
|
||||
if (universe !== undefined) {
|
||||
validateUniverse(universe, receipt.source.players);
|
||||
requireBetrayal(equal(universe, receipt.source.universe), 'Harkonnen Betrayal changed its original universe.');
|
||||
}
|
||||
const identities = receipt.source.players.map(({ id, faction }) => ({ id, faction }));
|
||||
requireBetrayal(receipt.source.sequence === index && receipt.source.turn >= turn &&
|
||||
receipt.previous === previous &&
|
||||
(originalRoster === undefined || equal(originalRoster, identities)) &&
|
||||
(originalUniverse === undefined || equal(originalUniverse, receipt.source.universe)) &&
|
||||
(receipt.outcome === 'pass' ? receipt.holder === null : receipt.outcome === 'use' &&
|
||||
text(receipt.holder) && receipt.holder === receipt.source.eligible) &&
|
||||
receipt.signature === receiptSignature(receipt),
|
||||
'Harkonnen Betrayal history changed its native attempt, outcome or continuation chain.');
|
||||
originalRoster = identities;
|
||||
originalUniverse = receipt.source.universe;
|
||||
previous = receiptHead(receipt);
|
||||
turn = receipt.source.turn;
|
||||
}
|
||||
requireBetrayal(cursor.signature === previous,
|
||||
'Harkonnen Betrayal history no longer matches its independent cursor.');
|
||||
}
|
||||
function replacementSignature(replacement: HarkonnenBetrayalReplacement): string {
|
||||
return JSON.stringify(['harkonnenBetrayalReplacement', replacement.version, replacement.event,
|
||||
replacement.sequence, replacement.provider, replacement.turn, replacement.identity,
|
||||
replacement.receipt, replacement.status, replacement.drawn]);
|
||||
}
|
||||
export function createHarkonnenBetrayalReplacement(receipt: HarkonnenBetrayalReceipt): HarkonnenBetrayalReplacement {
|
||||
requireBetrayal(exact(receipt, ['source', 'outcome', 'holder', 'previous', 'signature']),
|
||||
'Harkonnen Betrayal replacement requires its exact closed native receipt.');
|
||||
validateSavedSource(receipt.source);
|
||||
validateCursor({ sequence: receipt.source.sequence, signature: receipt.previous });
|
||||
requireBetrayal(receipt.outcome === 'use' && text(receipt.holder) && receipt.holder === receipt.source.eligible &&
|
||||
text(receipt.previous) && receipt.signature === receiptSignature(receipt),
|
||||
'Only a successful Harkonnen Betrayal Use creates a Mentat replacement obligation.');
|
||||
const replacement: HarkonnenBetrayalReplacement = {
|
||||
version: 1, event: receipt.source.event, sequence: receipt.source.sequence,
|
||||
provider: receipt.source.provider, turn: receipt.source.turn, identity: receipt.source.declaration.identity,
|
||||
receipt: receiptHead(receipt), status: 'due', drawn: null, signature: '',
|
||||
};
|
||||
replacement.signature = replacementSignature(replacement);
|
||||
return replacement;
|
||||
}
|
||||
function validateReplacement(
|
||||
replacement: HarkonnenBetrayalReplacement, history: readonly HarkonnenBetrayalReceipt[],
|
||||
): void {
|
||||
requireBetrayal(exact(replacement, ['version', 'event', 'sequence', 'provider', 'turn', 'identity',
|
||||
'receipt', 'status', 'drawn', 'signature']) && replacement.version === 1 &&
|
||||
whole(replacement.sequence) && whole(replacement.turn) && replacement.turn > 0 &&
|
||||
text(replacement.event) && text(replacement.provider) && text(replacement.identity) &&
|
||||
text(replacement.receipt) && text(replacement.signature) &&
|
||||
(replacement.status === 'due' ? replacement.drawn === null :
|
||||
replacement.status === 'drawn' && text(replacement.drawn)),
|
||||
'Harkonnen Betrayal has a malformed or repeated Mentat replacement.');
|
||||
const receipt = history[replacement.sequence];
|
||||
requireBetrayal(receipt?.outcome === 'use' && replacement.receipt === receiptHead(receipt) &&
|
||||
replacement.event === receipt.source.event && replacement.provider === receipt.source.provider &&
|
||||
replacement.turn === receipt.source.turn && replacement.identity === receipt.source.declaration.identity &&
|
||||
(replacement.drawn === null || receipt.source.universe.includes(replacement.drawn)) &&
|
||||
replacement.signature === replacementSignature(replacement),
|
||||
'Harkonnen Betrayal replacement lost its exact successful Use obligation or actual drawn identity.');
|
||||
}
|
||||
export function validateHarkonnenBetrayalReplacement(
|
||||
replacement: HarkonnenBetrayalReplacement, history: readonly HarkonnenBetrayalReceipt[],
|
||||
cursor: HarkonnenBetrayalCursor, universe?: readonly string[],
|
||||
): void {
|
||||
validateHarkonnenBetrayalHistory(history, cursor, universe);
|
||||
validateReplacement(replacement, history);
|
||||
}
|
||||
export function drawHarkonnenBetrayalReplacement(
|
||||
replacement: HarkonnenBetrayalReplacement, history: readonly HarkonnenBetrayalReceipt[],
|
||||
cursor: HarkonnenBetrayalCursor, drawn: string, universe?: readonly string[],
|
||||
): HarkonnenBetrayalReplacement {
|
||||
validateHarkonnenBetrayalReplacement(replacement, history, cursor, universe);
|
||||
requireBetrayal(replacement.status === 'due' && text(drawn) &&
|
||||
history[replacement.sequence].source.universe.includes(drawn),
|
||||
'Harkonnen Betrayal replacement draws exactly once from its actual canonical Traitor Deck.');
|
||||
const result: HarkonnenBetrayalReplacement = { ...replacement, status: 'drawn', drawn, signature: '' };
|
||||
result.signature = replacementSignature(result);
|
||||
return result;
|
||||
}
|
||||
export function validateHarkonnenBetrayalReplacements(
|
||||
history: readonly HarkonnenBetrayalReceipt[], cursor: HarkonnenBetrayalCursor,
|
||||
replacements: readonly HarkonnenBetrayalReplacement[], universe?: readonly string[],
|
||||
): void {
|
||||
validateHarkonnenBetrayalHistory(history, cursor, universe);
|
||||
requireBetrayal(Array.isArray(replacements), 'Harkonnen Betrayal lost its replacement provenance.');
|
||||
for (const replacement of replacements) validateReplacement(replacement, history);
|
||||
const used = history.filter(receipt => receipt.outcome === 'use');
|
||||
requireBetrayal(replacements.length === used.length &&
|
||||
new Set(replacements.map(replacement => replacement.sequence)).size === replacements.length &&
|
||||
replacements.filter(replacement => replacement.status === 'due').length <= 1,
|
||||
'Every successful Harkonnen Betrayal Use requires exactly one due or completed private draw.');
|
||||
}
|
||||
@@ -4252,6 +4252,37 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Genuine native Basic/Advanced Bidding and Advanced Technology allow/prevent, printed/BG priority, exact stock/count/attempt/suffix, public face parity, legal bots and authenticated restart/races/rejection pass. Actual backed-up CLI/phone Use/Pass/partial refresh and saved stock prove both endpoints. Native Harkonnen/BG and public admin ownership regressions retain prior continuation; wider modes and deployment remain open.', evidence: ['tests/nexus-ixian-betrayal.test.ts', 'tests/nexus-ixian-betrayal-runtime.test.ts', 'tests/fixture-nexus-ixian-betrayal.ts', 'tests/nexus-ixian-betrayal-controls.test.tsx', 'tests/bot-nexus-ixian-betrayal.test.ts', 'tests/nexus-ixian-betrayal-recovery.test.ts', 'tests/prototype-room.test.ts', 'tests/ix-technology.test.ts', 'tests/admin-directory.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nexus-harkonnen-betrayal',
|
||||
title: 'Harkonnen Nexus Betrayal: cancel a declared traitor',
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Verified',
|
||||
summary: 'The fresh local harkonnen-betrayal preview cancels one original native Harkonnen traitor call, returns that physical card immediately and gives Harkonnen a private replacement during Mentat Pause.',
|
||||
searchText: 'Harkonnen Nexus Betrayal revealed declared traitor ally caller beneficiary shuffle replacement Mentat neutral acknowledgement',
|
||||
steps: [
|
||||
'The bounded development profile requires native Harkonnen and two to six unique classic factions, Basic or Advanced, the base Treachery Deck and Nexus alone. No public start, existing-game retrofit, expansion deck, Homeworld, Leader Skill, Discovery or other overlay is enabled.',
|
||||
'Nexus has no initial deal. An actual alliance-qualified closing Nexus must supply the unallied holder. An effect scenario needs at least three seats; two seats can continue ordinary play without inventing a card. Two simultaneous receivers need four actual seats.',
|
||||
'The photographed publisher face cancels a revealed Harkonnen traitor, shuffles that exact card back immediately, and gives Harkonnen one replacement during Mentat Pause. It is not a draw-now exchange, whole-battle ban or refund of an already resolved outcome. Native Cunning and absent-Harkonnen Secret Ally remain separate effects.',
|
||||
'Only a genuine legally matching native personal or allied Harkonnen call is a source. Preserve the actual caller, combatant beneficiary, target, original revealed plans and physical identity. A false or invalid call, protected leader, another faction or finished battle cannot offer this effect.',
|
||||
'Resolve the original allied Harkonnen Karama counter first, including legal Advanced Bene Gesserit conversion. Native cancellation opens no Nexus gate or cost. A personal Harkonnen call gains no invented native counter. After native allowance, declare the actual matched traitor before its outcome and await the neutral acknowledgements.',
|
||||
'Every publicly possible unallied non-Harkonnen held-Nexus owner sees the same declared-traitor acknowledgement, regardless of secret face. Only an actual privately eligible Harkonnen-card holder sees Use and their own unavailable reason. Native-counter-first and this uniform local preview timing are bounded inferences, not an answer to the outstanding universal reaction-policy question.',
|
||||
'Pass retains both cards and cannot be withdrawn for that event; all required passes retain the original call and resume it once. Use spends the physical singleton Nexus, returns only that declared Harkonnen traitor to the actual shuffled reserve, prevents only that call and resumes the surviving original battle once.',
|
||||
'The replacement is automatic at the actual current Mentat opening, before optional actions and victory acceptance in this bounded profile. Draw the actual reserve top privately once; it may be the returned card after its shuffle. No extra confirmation, selected replacement, Treachery slot, activation spice, force or leader cost is added.',
|
||||
'The public declaration exposes only its actual matched traitor and roles, not other held Harkonnen cards, rival Nexus faces, reserve order or private source. Inspect that declared Traitor Card through its public inspector, and inspect only your own actually held Nexus. A replacement identity remains private to Harkonnen; prior public history must not claim continued ownership of the returned card.',
|
||||
'Both actions submit only the offered event. Refresh retains the same acknowledgement and partial passes; due and completed replacement provenance survives without reshuffling, redrawing or replaying native battle and phase effects. Stale, foreign, malformed or changed committed sources reject before any cost.',
|
||||
'Finish the source before ordinary votes or competing card mutation. Existing response, decision and Truthtrance priority remain intact; every seat retains independent own autopilot control. Four difficulties use minimal legal projected Use/Pass, not calibrated strategy.',
|
||||
'Frozen genuine personal/allied Basic/Advanced, exact retirement and actual Mentat continuation, private controls, legal bots, authenticated races/rejection, backed-up same-setup CLI and phone evidence verify this bounded path. Raw administrator progress follows the same sole public remaining acknowledgement. The separate universal response and competing-module questions remain open; this is not complete Harkonnen/Nexus or deployed acceptance.',
|
||||
],
|
||||
related: ['nexus-cards', 'battle', 'card-karama', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Original personal/allied call and native counter, exact immediate retirement, canceled call, surviving battle once and automatic private actual Mentat replacement are connected through a strict fresh local profile with bounded custody/history checks.', evidence: ['game/engine.ts', 'game/nexus-harkonnen-betrayal.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Public declared-Traitor inspector, own eligible event-only Use/Pass/Nexus inspector, sole/multiple public ownership and every-seat independent autopilot are connected. Actual phone Use, wrong-face Pass, partial refresh and raw administrator owner agree.', evidence: ['components/nexus-harkonnen-betrayal.tsx', 'components/game-table.tsx', 'game/table-turn.ts', 'db/admin-directory.ts'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four minimal policies consume canonical own-view choices and native pending priority with legal paced continuation; full strategy and calibration remain separate.', evidence: ['game/bot-nexus-harkonnen-betrayal.ts', 'game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Printed immediate/delayed operations, personal/allied roles, inferred local counter/privacy convention, fresh natural card rights and unchanged outside-profile/module/public/deployment questions are explicit.', evidence: ['docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_CARD_RUNTIME.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Genuine native personal/allied Use/all-pass and actual Mentat top, printed/BG preemption, canonical stock, own/public entitlement, four bots and authenticated restart/races/rejection pass. Four privately backed-up original-setup CLI/phone rooms prove Use, wrong-face Pass, partial refresh, administrator owner and saved stock through real HTTP Mentat once. Independent native/privacy reviews have no finding; wider and deployed acceptance remain open.', evidence: ['tests/fixture-nexus-harkonnen-betrayal.ts', 'tests/nexus-harkonnen-betrayal.test.ts', 'tests/nexus-harkonnen-betrayal-runtime.test.ts', 'tests/nexus-harkonnen-betrayal-controls.test.tsx', 'tests/bot-nexus-harkonnen-betrayal.test.ts', 'tests/nexus-harkonnen-betrayal-recovery.test.ts', 'tests/prototype-room.test.ts', 'tests/admin-directory.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nexus-cards',
|
||||
title: 'Nexus Cards',
|
||||
@@ -4274,6 +4305,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Harkonnen Cunning draws one Traitor Card before you choose one held card to return. It can pause another action, including a response or decision. Finish the private return before that action continues; active Truthtrance takes priority. Returning an identity already declared as a traitor does not cancel that declaration.',
|
||||
'With Harkonnen absent, Secret Ally draws two Traitor Cards during Mentat Pause, then you choose exactly two distinct held identities to return. Newly drawn cards may be returned. Tleilaxu receives Face Dancers and can return newly drawn or existing unrevealed dancers; previously revealed dancers stay protected.',
|
||||
'The Harkonnen exchange spends the Nexus Card when you draw and shuffles the returned cards into the Traitor Deck after your selection. Your original hand size is restored. Other players see that the exchange is pending, without learning your drawn or returned identities.',
|
||||
'Harkonnen Betrayal has a separate fresh local harkonnen-betrayal profile for an actual personal or allied native traitor call. After any original allied counter allows it, uniform public possible held-Nexus owners acknowledge the actually declared traitor before its outcome. Use returns that exact physical traitor immediately and schedules one private actual Mentat replacement; all-pass keeps the original call. This provisional bounded convention does not answer the outstanding universal reaction policy; see its dedicated guide for bounded verified native/private/recovery/CLI/phone evidence and unchanged module/public/deployment gates.',
|
||||
'Tleilaxu Cunning sets aside all your revealed Face Dancers, draws their replacements secretly, and only then shuffles the set-aside cards into the Traitor Deck. Unrevealed dancers stay in your hand. This single operation does not use or renew your ordinary Mentat replacement; the native all-three-revealed reset keeps its separate shuffle-before-draw order.',
|
||||
'Ixian Cunning is used in your battle before submitting your Battle Plan. Your Suboids then count as strength one without spice support in every battle for the rest of the turn, in Basic and Advanced. Cyborg strength and support are unchanged. Earlier inspected or promised plan elements remain binding; you cannot activate the card if the new strength would make them impossible.',
|
||||
'Ixian Secret Ally has a fresh local ixian-replacement profile for normal paid or printed Karama-paid auction purchases in classic base-deck/Nexus Basic or Advanced games. A supported unallied buyer may spend its actual Nexus to discard exactly the just-purchased card and draw the real deck top privately; Pass keeps both cards. Full-hand replacement and empty-deck same-card redraw are allowed, and original payment/income/auction continuation occur once. Harkonnen buyers, cache/Black Market/special origins and combined modules remain guarded. The neutral buyer choice is an inferred privacy convention, not publisher timing. See the dedicated guide for bounded verified native, private, recovery and phone evidence; this is not full-module certification.',
|
||||
|
||||
+3
-3
@@ -12,7 +12,7 @@ type TurnView = Pick<
|
||||
| 'phaseOpening'
|
||||
| 'automaticContinuationPending'
|
||||
> &
|
||||
Partial<Pick<GameView, 'biddingEnd' | 'kullReaction' | 'richeseBetrayalReaction' | 'guildBetrayalReaction' | 'nexusIxianReplacement' | 'nexusIxianBetrayalReaction'>>;
|
||||
Partial<Pick<GameView, 'biddingEnd' | 'kullReaction' | 'richeseBetrayalReaction' | 'guildBetrayalReaction' | 'nexusIxianReplacement' | 'nexusIxianBetrayalReaction' | 'nexusHarkonnenBetrayalReaction'>>;
|
||||
|
||||
/** The single current decision owner for the table banner and seat highlight.
|
||||
* Shared response windows have no single acting seat. Only public view fields
|
||||
@@ -33,8 +33,8 @@ export function tableActionOwner(g: TurnView): string | null {
|
||||
const seated = (id: string | null | undefined) =>
|
||||
g.players.some((p) => p.id === id) ? id! : null;
|
||||
// active is the runtime's public sole unfinished acknowledgement seat,
|
||||
// never the private card holder or native advantage provider.
|
||||
if (g.nexusIxianBetrayalReaction) return seated(g.active);
|
||||
// never the private card holder or native declaration provider.
|
||||
if (g.nexusIxianBetrayalReaction || (g.nexusHarkonnenBetrayalReaction && !g.decision)) return seated(g.active);
|
||||
if (g.nexusIxianReplacement) return seated(g.nexusIxianReplacement.buyer);
|
||||
if (g.decision) return seated(g.decision.player);
|
||||
if (g.biddingEnd) {
|
||||
|
||||
Reference in new issue
Block a user