Connect bounded Fremen Nexus worm-riding Betrayal

This commit is contained in:
Quintine committed 2026-09-29 19:48:43 +10:00
1 parent 66a427ab55
commit 54cbf4d06f
14 files changed
+457 -55

No files matched your search

+1 -1
View File
@@ -4050,7 +4050,7 @@ export function runBots(state: Game, limit = 96): Game {
? actors.find(actor => g.nexusCards!.cards!.hands[actor.id] === 'ecaz' &&
ecazBetrayalOffer(g, actor.id)?.blocked === null)
: undefined;
const fremenHolder = g.phase === 5 && g.nexusCards?.cards
const fremenHolder = (g.phase === 1 || g.phase === 5) && g.nexusCards?.cards
? actors.find(actor => g.nexusCards!.cards!.hands[actor.id] === 'fremen' &&
fremenBetrayalOffer(g, actor.id)?.blocked === null)
: undefined;
+46 -17
View File
@@ -57,7 +57,7 @@ import { createNexusSardaukar, validateNexusSardaukar, type NexusSardaukarReceip
import { createNexusMoritani, validateNexusMoritani, quoteNexusMoritaniPlacement, type NexusMoritaniReceipt } from './nexus-moritani';
import { moritaniBetrayalOffer } from './nexus-moritani-betrayal';
import { ecazBetrayalOffer, quoteEcazBetrayal, validateEcazBetrayalSnapshot, type EcazBetrayalSnapshot } from './nexus-ecaz-betrayal';
import { fremenBetrayalOffer, fremenBetrayalSignature, validateFremenBetrayal, type FremenNexusBetrayal } from './nexus-fremen-betrayal';
import { fremenBetrayalOffer, fremenBetrayalSignature, validateFremenBetrayal, type FremenNexusBetrayal, type StoredFremenNexusBetrayal } from './nexus-fremen-betrayal';
import { atomicsAllianceChangeBlocked, atomicsAllianceStatus, atomicsShipmentBlocked, quoteMoritaniAtomics, type AtomicsAftermath, type AtomicsQuote } from './moritani-atomics';
import { nexusMoritaniRecordSignature, terrorLocationAllowed, terrorEntryLocationAllowed } from './terror-location';
import { CHOAM_NEXUS_EFFECTS, createNexusChoam, validateNexusChoam, type NexusChoamEffect, type NexusChoamReceipt } from './nexus-choam';
@@ -592,6 +592,7 @@ export type Player = {
ixMovementBlocked?: { turn: number; move: number };
fremenMovementBlocked?: { turn: number; move: number };
fremenNexusMovementBlockedTurn?: number;
fremenNexusWormBlockedTurn?: number;
/** Permanent printed one-card reduction after Atomics; bound to its Aftermath receipt. */
atomicsHandLimitPenalty?: boolean;
elites?: {
@@ -1569,8 +1570,8 @@ export type Game = {
}[];
/** A spent Ecaz card binds one exact allied force group until Karama settles. */
nexusEcazBetrayalHistory?: { snapshot: EcazBetrayalSnapshot; stage: 'pending' | 'complete' }[];
/** A spent Fremen card suppresses the native two-territory advantage through its turn. */
nexusFremenBetrayalHistory?: FremenNexusBetrayal[];
/** Spent Fremen card receipts include the earlier movement-only saved form. */
nexusFremenBetrayalHistory?: StoredFremenNexusBetrayal[];
/** One actual Sardaukar battle advantage suppressed by a spent Emperor card. */
nexusEmperorBetrayalHistory?: { event: string; turn: number; territory: string; owner: string; target: string }[];
/** Original native Voice canceled by a spent Bene Gesserit Nexus card. */
@@ -2135,41 +2136,60 @@ function nexusFremenBetrayalIntegrity(g: Game) {
const history = g.nexusFremenBetrayalHistory;
const fremen = byFaction(g, 'fremen');
if (history === undefined) {
requireRule(fremen?.fremenNexusMovementBlockedTurn === undefined,
'Fremen movement suppression has no spent Nexus card receipt.');
requireRule(fremen?.fremenNexusMovementBlockedTurn === undefined &&
fremen?.fremenNexusWormBlockedTurn === undefined,
'Fremen Betrayal restrictions require a spent Nexus card receipt.');
return;
}
requireRule(g.nexusCards?.cards && fremen && Array.isArray(history) &&
history.length > 0, 'Fremen Betrayal has lost its physical card module.');
const turns = new Set<number>();
let latestMovement: number | undefined;
let latestWorm: number | undefined;
let lastMode: FremenNexusBetrayal['mode'] = 'movement';
for (const record of history) {
nexusRule(() => validateFremenBetrayal(g, record));
const mode = nexusRule(() => validateFremenBetrayal(g, record));
requireRule(!turns.has(record.turn),
'Fremen Betrayal cannot suppress movement twice in one turn.');
'Fremen Betrayal cannot suppress two advantages in one turn.');
turns.add(record.turn);
if (mode === 'worm') latestWorm = record.turn;
else latestMovement = record.turn;
lastMode = mode;
}
const last = history.at(-1)!;
requireRule(fremen.fremenNexusMovementBlockedTurn === last.turn,
'Fremen Betrayal has lost its turn-long movement suppression.');
if (last.turn === g.turn)
requireRule(g.nexusCards.cards.discard.includes('fremen'),
'Fremen Betrayal has lost its spent physical card.');
requireRule(fremen.fremenNexusMovementBlockedTurn === latestMovement &&
fremen.fremenNexusWormBlockedTurn === latestWorm,
'Fremen Betrayal has lost its turn-long restriction.');
if (last.turn === g.turn) {
requireRule(lastMode !== 'worm' || (g.nexusCards.phase?.turn === g.turn &&
!g.wormRides.length && !g.nexusFremenCunningOffer &&
!g.nexusFremenCunningRides?.some(row =>
row.occurrence.turn === g.turn && row.stage !== 'complete')),
'Fremen Betrayal cannot leave a current worm ride or Cunning offer.');
if (lastMode === 'movement' || g.nexusCards.phase?.stage === 'spice')
requireRule(g.nexusCards.cards.discard.includes('fremen'),
'Fremen Betrayal has lost its spent physical card.');
}
}
function playNexusFremenBetrayal(g: Game, p: Player, action: Action) {
const offer = fremenBetrayalOffer(g, p.id);
requireRule(offer && !offer.blocked && action.event === offer.event &&
Object.keys(action).sort().join(',') === 'event,type',
'Use Fremen Betrayal before Fremen movement begins.');
'Use Fremen Betrayal before its current printed advantage begins.');
const record: FremenNexusBetrayal = {
event: offer.event, owner: p.id, target: offer.target, turn: g.turn,
phase: 5, signature: '',
phase: offer.mode === 'worm' ? 1 : 5, mode: offer.mode, signature: '',
};
record.signature = fremenBetrayalSignature(record);
g.nexusCards!.cards = nexusRule(() =>
discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
getPlayer(g, offer.target).fremenNexusMovementBlockedTurn = g.turn;
const fremen = getPlayer(g, offer.target);
if (offer.mode === 'worm') fremen.fremenNexusWormBlockedTurn = g.turn;
else fremen.fremenNexusMovementBlockedTurn = g.turn;
(g.nexusFremenBetrayalHistory ??= []).push(record);
log(g, `${p.name} spent Fremen Nexus Betrayal to suppress the Fremen two-territory movement advantage this turn. Independent ornithopters and movement abilities remain available.`,
log(g, offer.mode === 'worm'
? `${p.name} spent Fremen Nexus Betrayal before the first blow to suppress Fremen worm riding for this turn. Worm appearances and protection still resolve; ordinary and remote rides do not.`
: `${p.name} spent Fremen Nexus Betrayal to suppress the Fremen two-territory movement advantage this turn. Independent ornithopters and movement abilities remain available.`,
{ faction: p.faction, name: 'Fremen Nexus Betrayal' });
}
function bgBetrayalWindow(g: Game) {
@@ -6105,6 +6125,7 @@ function requireFreshSetup(g: Game, allowIxElites = false) {
p.noField === undefined && p.noFieldEvent === undefined && p.noFieldBlockedTurn === undefined &&
p.advisors === undefined && p.gholaBlocked === undefined && p.ixMovementBlocked === undefined &&
p.fremenMovementBlocked === undefined && p.fremenNexusMovementBlockedTurn === undefined &&
p.fremenNexusWormBlockedTurn === undefined &&
p.atomicsHandLimitPenalty === undefined && p.faceDancers === undefined &&
p.faceDancerReplacedTurn === undefined && p.revealedTraitors === undefined;
}), 'Starting setup requires unused native leaders and no prior alliance, revival or battle history.');
@@ -7413,7 +7434,8 @@ function devour(
for (const k of Object.keys(g.spice))
if (splitLocation(k).territory === t) delete g.spice[k];
const fremen = byFaction(g, 'fremen');
if (fremen && at(fremen, t)) g.wormRides.push(t);
if (fremen && fremen.fremenNexusWormBlockedTurn !== g.turn && at(fremen, t))
g.wormRides.push(t);
log(g, `Shai-Hulud appeared in ${territory(t).name}.`);
}
function beginWorm(g: Game, t: string, origin: 'natural' | 'additional' | 'summoned' = 'natural') {
@@ -7429,6 +7451,7 @@ function beginWorm(g: Game, t: string, origin: 'natural' | 'additional' | 'summo
!g.techTokens && !g.strongholdCards &&
g.players.every(seat => ['atreides', 'harkonnen', 'emperor', 'guild', 'beneGesserit', 'fremen'].includes(seat.faction)) &&
fremen && !fremen.ally && g.phase === 1 &&
fremen.fremenNexusWormBlockedTurn !== g.turn &&
Object.entries(fremen.forces).some(([key, amount]) => {
if (amount <= 0) return false;
const source = splitLocation(key);
@@ -8021,6 +8044,7 @@ function nextWormRide(g: Game) {
return;
}
const fremen = byFaction(g, 'fremen');
if (fremen?.fremenNexusWormBlockedTurn === g.turn) g.wormRides.length = 0;
while (g.wormRides.length) {
const t = g.wormRides.shift()!;
if (
@@ -8038,6 +8062,10 @@ function nextWormRide(g: Game) {
}
for (const ride of g.nexusFremenCunningRides ?? []) {
if (ride.stage !== 'queued' || ride.occurrence.turn !== g.turn) continue;
if (fremen?.fremenNexusWormBlockedTurn === g.turn) {
ride.stage = 'complete';
continue;
}
const source = nexusRule(() => quoteFremenCunningRide(
g, ride.authorization.owner, ride.occurrence, ride.authorization,
));
@@ -27734,6 +27762,7 @@ export function viewGame(state: Game, id: string) {
(p.fremenMovementBlocked?.turn === g.turn &&
p.fremenMovementBlocked.move === p.moved),
fremenNexusMovementBlocked: p.fremenNexusMovementBlockedTurn === g.turn,
fremenNexusWormBlocked: p.fremenNexusWormBlockedTurn === g.turn,
ixMovementBlocked:
p.ixMovementBlocked?.turn === g.turn &&
p.ixMovementBlocked.move === p.moved,
+50 -17
View File
@@ -6,45 +6,78 @@ export type FremenNexusBetrayal = {
owner: string;
target: string;
turn: number;
phase: 5;
phase: 1 | 5;
mode: 'worm' | 'movement';
signature: string;
};
export const fremenBetrayalEvent = (turn: number, owner: string, target: string) =>
JSON.stringify(['nexusFremenBetrayal', turn, owner, target]);
/** The first movement-only checkpoint wrote this six-field physical receipt. */
export type StoredFremenNexusBetrayal = FremenNexusBetrayal |
(Omit<FremenNexusBetrayal, 'mode' | 'phase'> & { phase: 5; mode?: never });
export type FremenBetrayalOffer = {
event: string;
blocked: string | null;
target: string;
mode: FremenNexusBetrayal['mode'];
};
export const fremenBetrayalEvent = (
turn: number, owner: string, target: string, mode: FremenNexusBetrayal['mode'],
) => JSON.stringify(['nexusFremenBetrayal', turn, owner, target, mode]);
export const fremenBetrayalSignature = (record: FremenNexusBetrayal) =>
JSON.stringify({ ...record, signature: undefined });
/** Clean pre-movement use prevents retroactive suppression and never tests
* secret card custody of another seat when constructing public turn state. */
export function fremenBetrayalOffer(g: Game, owner: string, automaticPending = false) {
/** Clean advance declarations avoid a holder-dependent worm interruption
* while preserving both mutually exclusive printed alternatives. */
export function fremenBetrayalOffer(
g: Game, owner: string, automaticPending = false,
): FremenBetrayalOffer | null {
const holder = g.players.find(p => p.id === owner);
const fremen = g.players.find(p => p.faction === 'fremen');
if (!holder || !fremen || g.nexusCards?.cards?.hands[owner] !== 'fremen' ||
holder.faction === 'fremen') return null;
const mode = g.phase === 1 ? 'worm' : 'movement';
let blocked: string | null = null;
if (holder.ally) blocked = 'An allied player cannot use a Nexus card.';
else if (g.status !== 'playing' || g.phase !== 5)
blocked = 'Use Fremen Betrayal before Fremen move during Shipment and Movement.';
else if (g.status !== 'playing' || (g.phase !== 1 && g.phase !== 5))
blocked = 'Use Fremen Betrayal before the first Spice Blow or before Fremen movement.';
else if (g.expansions.length || g.homeworlds || g.leaderSkills || g.discoveryEnabled ||
g.discoveries || g.discoveryStash || g.greatMaker || g.ecazTreachery ||
g.techTokens || g.strongholdCards ||
g.players.some(p => !['atreides','harkonnen','emperor','fremen','guild','beneGesserit'].includes(p.faction)))
blocked = 'Combined expansion and module movement interactions remain unavailable.';
blocked = 'Combined expansion and module interactions remain unavailable.';
else if (nexusCleanPlayBlocked(g, automaticPending))
blocked = 'Finish the current interaction before using Fremen Betrayal.';
else if (!g.movementRemaining?.includes(fremen.id) || fremen.moved !== 0 ||
else if (g.nexusFremenBetrayalHistory?.some(record => record.turn === g.turn))
blocked = 'Fremen Betrayal already spent its one physical card this turn.';
else if (mode === 'worm') {
if (g.spiceSequence || g.spiceResolution || g.spiceWindow || g.nexus ||
g.wormRides.length || g.summonedBeforeBlow || g.nexusFremenCunningOffer ||
g.nexusFremenCunningRides?.some(row => row.stage !== 'complete'))
blocked = 'Declare worm-riding prevention before the first worm or spice blow this turn.';
} else if (!g.movementRemaining?.includes(fremen.id) || fremen.moved !== 0 ||
fremen.fremenNexusMovementBlockedTurn === g.turn)
blocked = 'Fremen have already moved or completed their turn.';
return { event: fremenBetrayalEvent(g.turn,owner,fremen.id), blocked, target: fremen.id };
return { event: fremenBetrayalEvent(g.turn, owner, fremen.id, mode),
blocked, target: fremen.id, mode };
}
export function validateFremenBetrayal(g: Game, record: FremenNexusBetrayal) {
if (!record || Object.keys(record).sort().join(',') !== 'event,owner,phase,signature,target,turn' ||
export function validateFremenBetrayal(
g: Game, record: StoredFremenNexusBetrayal,
): FremenNexusBetrayal['mode'] {
const keys = record && Object.keys(record).sort().join(',');
const legacy = keys === 'event,owner,phase,signature,target,turn';
const mode = legacy ? 'movement' : record?.mode;
if (!record ||
(!legacy && keys !== 'event,mode,owner,phase,signature,target,turn') ||
!Number.isSafeInteger(record.turn) || record.turn < 1 || record.turn > g.turn ||
record.phase !== 5 || record.owner === record.target ||
(mode !== 'worm' && mode !== 'movement') ||
record.phase !== (mode === 'worm' ? 1 : 5) ||
record.owner === record.target ||
!g.players.some(p => p.id === record.owner && p.faction !== 'fremen') ||
!g.players.some(p => p.id === record.target && p.faction === 'fremen') ||
record.event !== fremenBetrayalEvent(record.turn,record.owner,record.target) ||
record.signature !== fremenBetrayalSignature(record))
throw new Error('Fremen Betrayal has lost its original movement restriction.');
record.event !== (legacy
? JSON.stringify(['nexusFremenBetrayal', record.turn, record.owner, record.target])
: fremenBetrayalEvent(record.turn, record.owner, record.target, mode)) ||
record.signature !== JSON.stringify({ ...record, signature: undefined }))
throw new Error('Fremen Betrayal has lost its original turn-long restriction.');
return mode;
}
+1 -1
View File
@@ -4047,7 +4047,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'When Moritani is absent, a defeated combatant with an eligible publicly played card receives the same postbattle choice whether or not they hold Moritani Nexus. An unallied holder may spend it to retain one played card that a winner could keep; other played cards follow ordinary discard. Without a publicly eligible card there is no prompt. The choice is after the battle result and before later income/capture; the public prompt protects hidden custody and is an interface inference. This Secret Ally effect has no invented ordinary Karama response. Native Moritani allied retention remains a different, cancelable ability.',
'Ecaz Betrayal is available to an unallied holder with Ecaz seated and reciprocally allied in a shared Arrakis territory. At the opening of Shipment and Movement, choose one such territory; the ally returns its entire normal and starred force group from every sector there to reserves, leaving Ecaz forces in place. The Nexus card is spent before a Karama response. Cancellation preserves the board but not the card. Only the base/Ecaz Nexus roster without combined modules is supported.',
'Ecaz Cunning lets an unallied native holder spend the physical Nexus card at a quiet Battle boundary to take the existing living Duke Vidal for the turn, even from Moritani. The Duke can be selected through ordinary Battle plans and is set aside after use or at turn end. This first version is limited to paired Ecaz/Moritani with other classic factions and no combined optional modules; Advanced Harkonnen is excluded because the existing battle path cannot select Duke there. Taking Duke from the Tanks, capture or Ghola custody remains unimplemented. The Battle-only timing is a product boundary, not printed text.',
'Fremen Betrayal currently supports its movement-range alternative for an unallied holder in a classic Nexus table. Spend the card before Fremen move during Shipment and Movement to suppress their native two-territory advantage for that turn; ordinary one-territory movement and independent ornithopters remain. The separate printed worm-riding prevention is not yet connected. The owner-only control shows this boundary and a spent card cannot be used again.',
'Fremen Betrayal has two bounded advance-declaration alternatives for an unallied holder in a classic Nexus table. Before the first Spice Blow or worm appearance, spend the physical card to prevent Fremen ordinary and Cunning worm rides for the turn; worm survival, destruction and Nexus still resolve. This clean timing is a product boundary, not an official post-appearance priority ruling. Instead, before Fremen move during Shipment and Movement, spend it to suppress their native two-territory advantage for that turn while ordinary movement and independent ornithopters remain. Only one alternative can be used even if the card is recycled later that turn. Owner controls show the currently available choice; combined modules and reactive post-worm timing remain gated.',
'When Ecaz is absent, an unallied holder may spend its Nexus card at a quiet play boundary to ask whether any one player, including themself, holds one of the holder’s native faction leaders as an ordinary Traitor Card. The owner receives a private yes/no answer saved at the moment of use; no name or count is disclosed. Dead or captured native leaders count, but foreign Gholas, Duke Vidal, Cheap Hero and Face Dancers do not. Native-leader scope and private-only audience are explicit product interpretations, not publisher rulings; exceptional controlled-leader and public-disclosure combinations remain open.',
'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Select the explicit shipment checkbox; forces, elite identity, destination restrictions, Homeworld reserve sources and normal movement remain unchanged. Fremen remain free within their normal reinforcement range; Guild and an active Karama rate retain their existing tariff and payment routing. This is not a concealed No-Field or an extra shipment.',
'Native Richese Cunning has a bounded two-No-Field shipment in genuine Richese/CHOAM Nexus games without other optional modules. Choose distinct unused tokens: one reveals immediately into reserve-capped public forces, the other remains concealed; pay for one marker and spend the Nexus. The previous shipment token cannot be either selection; the concealed token becomes next last-used. Karama or Guild special prevention preserves physical tokens and unpaid spice, with the ordinary stopped-shipment allowance policy. Other module combinations and the full faceup-component display remain gated.',