Enable Advanced selected-deck Harkonnen Nexus reaction
Verify and publish checkpoint container / container (push) Successful in 10m1s

This commit is contained in:
Quintine committed 2026-10-30 19:22:31 +11:00
1 parent 3bfcd10140
commit eaaab566e2
9 files changed
+288 -27

No files matched your search

+1 -4
View File
@@ -12657,6 +12657,7 @@ function harkonnenNexusBetrayalContext(g: Game) {
heroLeaderIds:g.expansions.includes('ix') ? physicalTreacheryCards(g).filter(card => card.kind === 'hero').map(card => card.id) : []};
return {status:g.status,phase:g.phase,turn:g.turn,advanced:g.advanced,ecazTreachery:g.ecazTreachery === true,
...(g.expansions.length ? {expansions:g.expansions} : {}),
...(g.ecazLoyalty ? {ecazLoyalty:g.ecazLoyalty} : {}),
sequence:g.nexusHarkonnenBetrayalCursor!.sequence,players:g.players,
cards:g.nexusCards!.cards!,physicalCards:physicalTreacheryCards(g),
traitors:nexusTraitorSnapshot(g),universe:nexusTraitorUniverse(g),battle};
@@ -12752,10 +12753,6 @@ function harkonnenNexusBetrayalIntegrity(g: Game, mentatOpening = false): void {
}
function offerHarkonnenNexusBetrayal(g: Game, provider: string, response?: ResponseWindow): boolean {
if (!g.nexusHarkonnenBetrayalPreview || getPlayer(g,provider).faction !== 'harkonnen') return false;
// The expanded physical census below currently covers Basic. Avoid blocking
// the original call in Advanced selected-deck games until their distinct
// Auditor and Loyalty inventories are supported.
if (g.advanced && g.expansions.length) return false;
const b = g.battle!;
const required = nexusRule(() => harkonnenBetrayalResponders(g.nexusCards!.cards!,g.players));
if (!required.length) return false;
+32 -18
View File
@@ -1,4 +1,4 @@
import { leaders, treacheryDeck, type Card } from './cards';
import { createAuditorLeader, leaders, treacheryDeck, type Card } from './cards';
import { FACTIONS } from './catalog';
import { ecazTreacheryCards } from './ecaz-cards';
import { richeseCards } from './richese-cards';
@@ -29,7 +29,9 @@ export type HarkonnenBetrayalContext = {
sequence: number;
/** Public selection; absence retains the original base33 census. */
ecazTreachery?: boolean;
/** Selected physical decks for a fresh Basic expansion game. */
/** Original public Loyalty marker; absent on older games that never set it. */
ecazLoyalty?: { player: string; card: string | null };
/** Selected physical decks for the original game. */
expansions?: readonly string[];
players: readonly NexusPlayer[];
cards: NexusState;
@@ -111,18 +113,25 @@ function validateRoster(players: readonly NexusPlayer[]): void {
function roster(players: readonly NexusPlayer[]): NexusPlayer[] {
return players.map(({ id, faction, ally }) => ({ id, faction, ally: ally ?? null }));
}
function selectedExpansions(value: unknown, players: readonly NexusPlayer[], advanced: boolean): string[] {
function selectedExpansions(value: unknown, players: readonly NexusPlayer[]): string[] {
const expansions = value === undefined ? [] : value;
requireBetrayal(Array.isArray(expansions) && expansions.every(id => ['ix', 'choam', 'ecaz'].includes(id)) &&
new Set(expansions).size === expansions.length && (!advanced || expansions.length === 0) &&
new Set(expansions).size === expansions.length &&
players.every(player => {
const faction = FACTIONS.find(item => item.id === player.faction);
return !!faction && (faction.expansion === 'base' || expansions.includes(faction.expansion));
}), 'Harkonnen Betrayal requires registered Basic selected faction decks.');
}), 'Harkonnen Betrayal requires registered selected faction decks.');
return expansions;
}
function validateUniverse(universe: readonly string[], players: readonly NexusPlayer[], expansions: readonly string[]): void {
const expected = traitorDeck(players.map(player => ({ leaders: leaders(player.faction) })), expansions.includes('ix'));
function validateUniverse(universe: readonly string[], players: readonly NexusPlayer[], expansions: readonly string[],
advanced: boolean, loyalty?: {player:string;card:string | null}): void {
if (loyalty !== undefined) requireBetrayal(advanced && exact(loyalty, ['player', 'card']) &&
players.some(player => player.id === loyalty.player && player.faction === 'ecaz') &&
typeof loyalty.card === 'string' && leaders('ecaz').some(leader => leader.id === loyalty.card),
'Harkonnen Betrayal needs the original public Ecaz Loyalty identity.');
const expected = traitorDeck(players.map(player => ({ leaders: [
...leaders(player.faction), ...(advanced && player.faction === 'choam' ? [createAuditorLeader()] : []),
] })), expansions.includes('ix')).filter(identity => identity !== loyalty?.card);
requireBetrayal(members(universe) && universe.length === expected.length &&
universe.every(identity => expected.includes(identity)),
'Harkonnen Betrayal needs the original complete canonical Traitor universe.');
@@ -161,7 +170,7 @@ function declarationContext(
})) };
}
function validateAuthority(
authority: HarkonnenBetrayalAuthority, players: readonly NexusPlayer[], battle: Battle,
authority: HarkonnenBetrayalAuthority, players: readonly NexusPlayer[], battle: Battle, advanced: boolean,
): void {
requireBetrayal(exact(authority, ['provider', 'declaration', 'nativeWindow', 'nativeContext',
'nativeRequired', 'nativePassed']) && text(authority.provider) && text(authority.nativeContext) &&
@@ -181,7 +190,8 @@ function validateAuthority(
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 =>
requireBetrayal((players.some(player => [...leaders(player.faction),
...(advanced && player.faction === 'choam' ? [createAuditorLeader()] : [])].some(leader =>
leader.id === authority.declaration.leader)) && authority.declaration.identity === authority.declaration.leader) ||
(battle.heroLeaderIds.includes(authority.declaration.leader) && authority.declaration.identity === CHEAP_HERO_TRAITOR),
'Harkonnen Betrayal requires the opposing canonical leader or physical hero identity.');
@@ -216,10 +226,10 @@ function validateContext(ctx: HarkonnenBetrayalContext): void {
(ctx.ecazTreachery === undefined || typeof ctx.ecazTreachery === 'boolean'),
'Harkonnen Betrayal requires its current Battle phase and independent cursor.');
validateRoster(ctx.players);
const expansions = selectedExpansions(ctx.expansions, ctx.players, ctx.advanced);
const expansions = selectedExpansions(ctx.expansions, ctx.players);
validateNexusCards(ctx.cards, ctx.players);
validatePhysical(ctx.physicalCards, ctx.ecazTreachery === true, expansions, ctx.players);
validateUniverse(ctx.universe, ctx.players, expansions);
validateUniverse(ctx.universe, ctx.players, expansions, ctx.advanced, ctx.ecazLoyalty);
validateNexusTraitorSnapshot(ctx.traitors, ctx.universe);
requireBetrayal(ctx.traitors.players.length === ctx.players.length &&
ctx.players.every(player => ctx.traitors.players.some(seat =>
@@ -234,7 +244,8 @@ function sourceAuthority(source: HarkonnenBetrayalSource): HarkonnenBetrayalAuth
function inventory(ctx: HarkonnenBetrayalContext): string {
const census: unknown[] = [ctx.cards, ctx.traitors, ctx.physicalCards];
if (ctx.ecazTreachery) census.push(true);
if (ctx.expansions?.length) census.push({ expansions: [...ctx.expansions] });
if (ctx.expansions?.length) census.push({ expansions: [...ctx.expansions],
...(ctx.ecazLoyalty ? {ecazLoyalty:structuredClone(ctx.ecazLoyalty)} : {}) });
return JSON.stringify(census);
}
function sourceSignature(source: HarkonnenBetrayalSource): string {
@@ -271,12 +282,15 @@ function validateSavedSource(value: unknown): asserts value is HarkonnenBetrayal
const independent = stored[3] === true;
const selected = stored[independent ? 4 : 3];
requireBetrayal(stored.length === 3 + Number(independent) + Number(selected !== undefined) &&
(selected === undefined || (exact(selected, ['expansions']) && Array.isArray(selected.expansions) && selected.expansions.length > 0)),
(selected === undefined || (plain(selected) && exact(selected, ['expansions',
...(Object.hasOwn(selected, 'ecazLoyalty') ? ['ecazLoyalty'] : [])]) &&
Array.isArray(selected.expansions) && selected.expansions.length > 0)),
'Harkonnen Betrayal lost its selected physical inventory.');
const expansions = selectedExpansions(selected?.expansions, source.players, source.advanced);
validateUniverse(source.universe, source.players, expansions);
const expansions = selectedExpansions(selected?.expansions, source.players);
validateUniverse(source.universe, source.players, expansions, source.advanced,
selected?.ecazLoyalty as {player:string;card:string | null} | undefined);
validateBattle(source.battle, expansions);
validateAuthority(sourceAuthority(source), source.players, source.battle);
validateAuthority(sourceAuthority(source), source.players, source.battle, source.advanced);
validateNexusCards(cards, source.players);
validateNexusTraitorSnapshot(traitors, source.universe);
validatePhysical(physicalCards, independent, expansions, source.players);
@@ -302,7 +316,7 @@ export function validateHarkonnenBetrayalSource(
): void {
validateSavedSource(source);
validateContext(ctx);
validateAuthority(authority, ctx.players, ctx.battle);
validateAuthority(authority, ctx.players, ctx.battle, ctx.advanced);
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 &&
@@ -320,7 +334,7 @@ export function createHarkonnenBetrayalSource(
ctx: HarkonnenBetrayalContext, authority: HarkonnenBetrayalAuthority, parent: string,
): HarkonnenBetrayalSource {
validateContext(ctx);
validateAuthority(authority, ctx.players, ctx.battle);
validateAuthority(authority, ctx.players, ctx.battle, ctx.advanced);
const source: HarkonnenBetrayalSource = {
...structuredClone(authority), version: 1,
event: harkonnenBetrayalEvent(ctx.turn, ctx.sequence, authority.declaration, authority.provider),
+1 -1
View File
@@ -6238,7 +6238,7 @@ export const RULE_TOPICS: RuleTopic[] = [
summary: 'Fresh supported classic starts cancel one original native Harkonnen traitor call, return that physical card immediately and give Harkonnen a private replacement during Mentat Pause.',
searchText: 'Harkonnen Nexus Betrayal revealed declared traitor ally caller beneficiary shuffle replacement Mentat neutral acknowledgement',
steps: [
'Fresh Basic Nexus starts with selected Ix and/or CHOAM decks and no other modules initialize this reaction even when every selected family is seated, including the supported Richese/Semuta start. Native expansion leaders and the Ix Cheap Hero Traitor can be declared. An owed replacement waits through the Ix phase-opening acknowledgements before the original Mentat draw. Advanced selected-deck reactions remain unfinished and their original calls continue without this interruption.',
'Fresh supported Basic and Advanced Nexus core starts with selected faction decks initialize this reaction for native expansion leaders and the Ix Cheap Hero Traitor. Advanced CHOAM’s Auditor has its real Traitor identity. An owed replacement waits through Ix phase-opening acknowledgements before the original Mentat draw. Advanced Ecaz/Moritani roster initialization and wider combinations remain unfinished.',
'Original standalone preview requires native Harkonnen, classic2–6 Basic/Advanced, base33 and Nexus alone. Fresh classic Nexus core initializes the same effect with base33 or selected36, optional14, original Tech3+ and Advanced Strongholds2+, alongside actual Guild/Ixian reactions. Public selection binds the census; real held-card effects and cleanup stay native. No played game is reflagged; other native/module envelopes remain separate.',
'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.',