Allow Fremen Nexus Betrayal before native worm rides

This commit is contained in:
Quintine committed 2026-10-26 07:06:15 +11:00
1 parent 78bcf22bca
commit 725672364d
19 files changed
+1251 -20

No files matched your search

+1
View File
@@ -110,6 +110,7 @@ const decisions = {
wormRide: true,
nexusFremenCunningOffer: true,
nexusFremenProtection: true,
nexusFremenRideBetrayal: true,
nexusEmperorPayment: true,
nexusEmperorSellerPurchase: true,
nexusEmperorAmbassadorPurchase: true,
+5
View File
@@ -2752,6 +2752,11 @@ function policyActions(g: GameView): Action[] {
if (d.kind === 'jacurutuDial')
return g.jacurutuDial?.choices.map((_,allocation) => ({type:'decision',event:d.event,allocation})) ?? [];
if (d.kind === 'greatMakerVote' || d.kind === 'greatMakerRide') return greatMakerBotActions(g);
if (d.kind === 'nexusFremenRideBetrayal') {
const reaction = g.nexusFremenRideBetrayal;
return reaction && reaction.player === me.id
? [{ type: 'decision', event: reaction.event, use: reaction.canUse }] : [];
}
if (d.kind === 'nexusFremenProtection') {
const offer = g.nexusFremenProtection;
return offer && !offer.blocked
+83 -4
View File
@@ -94,6 +94,7 @@ import { createNexusMoritani, validateNexusMoritani, quoteNexusMoritaniPlacement
import { moritaniBetrayalOffer } from './nexus-moritani-betrayal';
import { ecazBetrayalOffer, quoteEcazBetrayal, validateEcazBetrayalSnapshot, type EcazBetrayalSnapshot } from './nexus-ecaz-betrayal';
import { fremenBetrayalOffer, fremenBetrayalSignature, validateFremenBetrayal, type FremenNexusBetrayal, type StoredFremenNexusBetrayal } from './nexus-fremen-betrayal';
import { createFremenRideBetrayal, fremenRideBetrayalCurrentOwner, validateFremenRideBetrayal, passFremenRideBetrayal, useFremenRideBetrayal, type FremenRideBetrayalParent, type FremenRideBetrayalFrame } from './nexus-fremen-ride-betrayal';
import { 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';
@@ -1059,6 +1060,7 @@ export type Decision =
| { kind: 'wormPlacement'; player: string }
| { kind: 'wormProtection'; player: string; territory: string; ally: string }
| { kind: 'nexusFremenProtection'; player: string; event: string }
| { kind: 'nexusFremenRideBetrayal'; player: string; event: string }
| { kind: 'discoveryDiscard'; player: string; event: string }
| { kind: 'discoveryEntry'; player: string; event: string }
| { kind: 'ecologicalStorm'; player: string; event: string }
@@ -2189,6 +2191,8 @@ export type Game = {
nexusEcazBetrayalHistory?: { snapshot: EcazBetrayalSnapshot; stage: 'pending' | 'complete' }[];
/** Spent Fremen card receipts include the earlier movement-only saved form. */
nexusFremenBetrayalHistory?: StoredFremenNexusBetrayal[];
/** Neutral public queue holding one exact unmade native ride. */
nexusFremenRideBetrayal?: FremenRideBetrayalFrame | null;
/** 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. */
@@ -2803,6 +2807,65 @@ function playNexusEmperorBetrayal(g: Game, p: Player, action: Action) {
log(g, `${p.name} spent Emperor Nexus Betrayal to suppress ${getPlayer(g, record.target).name}'s Sardaukar strength in this battle. Actual starred counters remain Sardaukar.`,
{ faction: p.faction, name: 'Emperor Nexus Betrayal' });
}
function fremenRideBetrayalIntegrity(g: Game) {
const frame = g.nexusFremenRideBetrayal;
if (!frame) {
requireRule(g.decision?.kind !== 'nexusFremenRideBetrayal', 'The pending ride has lost its original Betrayal opportunity.');
return;
}
nexusRule(() => validateFremenRideBetrayal(g, frame));
requireRule(frame.stage === 'choose' || g.decision?.kind !== 'nexusFremenRideBetrayal',
'The completed before-ride opportunity retained its old controls.');
}
function offerFremenRideBetrayal(g: Game) {
const decision = g.decision;
if (!decision || !['wormRide', 'nexusFremenCunningRide', 'greatMakerRide'].includes(decision.kind)) return;
requireRule(g.nexusFremenRideBetrayal?.stage !== 'choose', 'Finish the original before-ride Betrayal opportunity first.');
const frame = nexusRule(() => createFremenRideBetrayal(g, decision as FremenRideBetrayalParent, crypto.randomUUID()));
if (!frame) return;
g.nexusFremenRideBetrayal = frame;
g.decision = { kind: 'nexusFremenRideBetrayal', player: fremenRideBetrayalCurrentOwner(frame)!, event: frame.event };
}
function fremenRideBetrayalView(g: Game, viewer: string) {
const frame = g.nexusFremenRideBetrayal;
if (!frame || frame.stage !== 'choose') return null;
const player = fremenRideBetrayalCurrentOwner(frame)!;
const own = player === viewer ? fremenBetrayalOffer(g, viewer) : null;
return { event: frame.event, player, source: structuredClone(frame.source),
canUse: !!own && !own.blocked, passed: [...frame.passed] };
}
function decideFremenRideBetrayal(g: Game, p: Player, action: Action) {
const frame = g.nexusFremenRideBetrayal;
requireRule(frame?.stage === 'choose' && g.decision?.kind === 'nexusFremenRideBetrayal' &&
g.decision.player === p.id && typeof action.event === 'string' && typeof action.use === 'boolean' &&
Object.keys(action).sort().join(',') === 'event,type,use',
'Choose your current before-ride Betrayal opportunity.');
const resolved = nexusRule(() => action.use
? useFremenRideBetrayal(g, frame, p.id, action.event as string)
: passFremenRideBetrayal(g, frame, p.id, action.event as string));
if (action.use) {
const offer = fremenBetrayalOffer(g, p.id);
requireRule(offer && !offer.blocked && offer.mode === 'worm', 'Use your held Fremen Nexus card for this original ride opportunity.');
playNexusFremenBetrayal(g, p, { type: 'nexusFremenBetrayal', event: offer.event });
}
g.nexusFremenRideBetrayal = resolved;
if (resolved.stage === 'choose') {
g.decision = { kind: 'nexusFremenRideBetrayal', player: fremenRideBetrayalCurrentOwner(resolved)!, event: resolved.event };
return;
}
if (!action.use) {
g.decision = structuredClone(frame.source);
return;
}
// Suppress only unmade transfers. Original worm destruction, votes, spice
// and Nexus state have already settled and are never replayed here.
g.decision = null;
g.wormRides = [];
for (const ride of g.nexusFremenCunningRides ?? [])
if (ride.occurrence.turn === g.turn && ride.stage !== 'complete') ride.stage = 'complete';
if (frame.source.kind === 'greatMakerRide') completeGreatMaker(g);
else nextWormRide(g);
}
function nexusFremenBetrayalIntegrity(g: Game) {
const history = g.nexusFremenBetrayalHistory;
const fremen = byFaction(g, 'fremen');
@@ -2859,7 +2922,9 @@ function playNexusFremenBetrayal(g: Game, p: Player, action: Action) {
else fremen.fremenNexusMovementBlockedTurn = g.turn;
(g.nexusFremenBetrayalHistory ??= []).push(record);
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.`
? g.nexusFremenRideBetrayal?.stage === 'choose'
? `${p.name} spent Fremen Nexus Betrayal before this unmade ride to suppress remaining worm rides this turn. The resolved worm, spice, votes and Nexus remain.`
: `${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' });
}
@@ -3229,8 +3294,9 @@ function nexusFremenCunningIntegrity(g: Game) {
occurrence.turn === g.turn && g.nexusCards!.cards!.discard.includes('fremen'),
'A pending Fremen ride has lost its spent physical card.');
if (stage === 'select') {
const held = g.nexusFremenRideBetrayal?.stage === 'choose' ? g.nexusFremenRideBetrayal.source : null;
const decision = g.decision?.kind === 'nexusFremenCunningRide'
? g.decision : suspended?.decision;
? g.decision : suspended?.decision?.kind === 'nexusFremenCunningRide' ? suspended.decision : held;
requireRule(decision?.kind === 'nexusFremenCunningRide' &&
decision.player === authorization.owner &&
decision.event === occurrence.event,
@@ -13039,7 +13105,8 @@ function stampGreatMaker(g: Game) {
function greatMakerIntegrity(g: Game) {
const frame = g.greatMaker, continuation = g.pendingTreacheryDiscard?.continuation;
const controls = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume,
g.pendingRichesePurchaseIncome?.resume, continuation && 'resume' in continuation ? continuation.resume : null];
g.pendingRichesePurchaseIncome?.resume, continuation && 'resume' in continuation ? continuation.resume : null,
g.nexusFremenRideBetrayal?.stage === 'choose' ? { decision: g.nexusFremenRideBetrayal.source, response: null } : null];
const owned = controls.filter(control => control?.decision?.kind === 'greatMakerVote' || control?.decision?.kind === 'greatMakerRide');
if (!frame) {
requireRule(owned.length === 0, 'The Great Maker has lost its original encounter.');
@@ -13110,6 +13177,7 @@ function decideGreatMakerVote(g: Game, p: Player, action: Action) {
if (fremen && fremen.reserves > 0 && fremen.fremenNexusWormBlockedTurn !== g.turn) {
frame.stage = 'ride'; stampGreatMaker(g);
g.decision = {kind:'greatMakerRide', player:fremen.id, event:frame.event};
offerFremenRideBetrayal(g);
} else completeGreatMaker(g);
}
function decideGreatMakerRide(g: Game, p: Player, action: Action) {
@@ -13278,6 +13346,7 @@ function nextWormRide(g: Game) {
)
) {
g.decision = { kind: 'wormRide', player: fremen.id, territory: t };
offerFremenRideBetrayal(g);
return;
}
}
@@ -13298,6 +13367,7 @@ function nextWormRide(g: Game) {
ride.stage = 'select';
g.decision = { kind: 'nexusFremenCunningRide',
player: ride.authorization.owner, event: ride.occurrence.event };
offerFremenRideBetrayal(g);
return;
}
g.nexus = false;
@@ -25505,6 +25575,7 @@ function marketGholaIntegrity(g: Game) {
nexusEmperorSecretIntegrity(g);
nexusEmperorBetrayalIntegrity(g);
nexusFremenBetrayalIntegrity(g);
fremenRideBetrayalIntegrity(g);
nexusBgBetrayalIntegrity(g);
nexusBorrowedVoiceIntegrity(g);
nexusFremenRevivalIntegrity(g);
@@ -34874,6 +34945,11 @@ function applyActionInner(
);
requireRule(g.status !== 'finished', 'This game has ended.');
const t = action.type;
if (g.nexusFremenRideBetrayal?.stage === 'choose') {
if (t === 'advanceBots') return g;
requireRule(t === 'decision', 'Finish the current before-ride Betrayal opportunity first.');
decideFremenRideBetrayal(g, p, action); return g;
}
// Mandatory movement reactions and other seats' actions do not close the
// Advanced owner's immediate Hajr opportunity. Its next voluntary action does.
if (g.advanced && p.hajrWindow && !g.response && !g.decision && !g.truthtrance &&
@@ -35347,6 +35423,8 @@ function applyActionInner(
}
if (g.decision) {
const decision = g.decision;
requireRule(decision.kind !== 'nexusFremenRideBetrayal',
'The before-ride response needs its original active wrapper.');
requireRule(decision.kind !== 'nexusFremenProtection',
'Use only the original worm protection event with Use or Pass.');
requireRule(decision.kind !== 'nexusEmperorPayment',
@@ -39252,8 +39330,9 @@ export function viewGame(state: Game, id: string) {
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)),
nexusEcazBetrayal: ecazBetrayalOffer(g, id,
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)),
nexusFremenBetrayal: fremenBetrayalOffer(g, id,
nexusFremenBetrayal: g.nexusFremenRideBetrayal?.stage === 'choose' ? null : fremenBetrayalOffer(g, id,
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)),
nexusFremenRideBetrayal: fremenRideBetrayalView(g, id),
nexusRichese: nexusRicheseOffer(g,id),
nexusRicheseCunning: nexusRicheseCunningOffer(g, id),
nexusGuildSecretAlly: nexusGuildSecretAllyOffer(g,id),
+12 -7
View File
@@ -1,6 +1,7 @@
import type { Game } from './engine';
import { nexusCleanPlayBlocked } from './nexus-play-boundary';
import { fremenNexusModeSupported } from './fremen-nexus-profile';
import { fremenRideBetrayalCurrentOwner } from './nexus-fremen-ride-betrayal';
export type FremenNexusBetrayal = {
event: string;
@@ -26,8 +27,8 @@ export const fremenBetrayalEvent = (
export const fremenBetrayalSignature = (record: FremenNexusBetrayal) =>
JSON.stringify({ ...record, signature: undefined });
/** Clean advance declarations avoid a holder-dependent worm interruption
* while preserving both mutually exclusive printed alternatives. */
/** Existing advance declarations and an engine-owned neutral before-ride
* opportunity preserve both mutually exclusive printed alternatives. */
export function fremenBetrayalOffer(
g: Game, owner: string, automaticPending = false,
): FremenBetrayalOffer | null {
@@ -36,23 +37,27 @@ export function fremenBetrayalOffer(
if (!holder || !fremen || g.nexusCards?.cards?.hands[owner] !== 'fremen' ||
holder.faction === 'fremen') return null;
const mode = g.phase === 1 ? 'worm' : 'movement';
const reaction = g.nexusFremenRideBetrayal;
const reactive = reaction?.stage === 'choose' && g.decision?.kind === 'nexusFremenRideBetrayal' &&
g.decision.event === reaction.event && g.decision.player === owner &&
fremenRideBetrayalCurrentOwner(reaction) === owner;
let blocked: string | null = null;
if (holder.ally) blocked = 'An allied player cannot use a Nexus card.';
else if (g.status !== 'playing' || (g.phase !== 1 && g.phase !== 5))
blocked = 'Use Fremen Betrayal before the first Spice Blow or before Fremen movement.';
blocked = 'Use Fremen Betrayal before the first Spice Blow, at an original before-ride opportunity, or before Fremen movement.';
else if (!fremenNexusModeSupported(g))
blocked = 'This Fremen Nexus roster or module prerequisite is unavailable.';
else if (nexusCleanPlayBlocked(g, automaticPending))
else if (!reactive && nexusCleanPlayBlocked(g, automaticPending))
blocked = 'Finish the current interaction before using Fremen Betrayal.';
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') {
else if (mode === 'worm' && !reactive) {
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)
} else if (mode === 'movement' && (!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, mode),
blocked, target: fremen.id, mode };
+169
View File
@@ -0,0 +1,169 @@
import { TERRITORIES, splitLocation } from './board';
import type { Game } from './engine';
import { fremenNexusModeSupported } from './fremen-nexus-profile';
import { validateGreatMaker } from './great-maker';
import { validateNexusCards } from './nexus-cards';
import { quoteFremenCunningRide } from './nexus-fremen-cunning';
export type FremenRideBetrayalParent =
| { kind: 'wormRide'; player: string; territory: string }
| { kind: 'nexusFremenCunningRide' | 'greatMakerRide'; player: string; event: string };
export type FremenRideBetrayalFrame = {
event: string;
turn: number;
source: FremenRideBetrayalParent;
stage: 'choose' | 'complete';
order: string[];
passed: string[];
usedBy: string | null;
signature: string;
};
export class FremenRideBetrayalError extends Error {
constructor(message: string) { super(message); this.name = 'FremenRideBetrayalError'; }
}
function requireRide(condition: unknown, message: string): asserts condition {
if (!condition) throw new FremenRideBetrayalError(message);
}
const text = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0;
const exact = (value: unknown, keys: readonly string[]): boolean => !!value && typeof value === 'object' &&
!Array.isArray(value) && Object.keys(value).sort().join(',') === [...keys].sort().join(',');
const ids = (value: unknown): value is string[] => Array.isArray(value) && value.every(text) &&
new Set(value).size === value.length;
const equal = (a: readonly string[], b: readonly string[]) => a.length === b.length && a.every((id, i) => id === b[i]);
function validateParent(parent: FremenRideBetrayalParent): void {
requireRide(parent && text(parent.player) &&
(parent.kind === 'wormRide'
? exact(parent, ['kind', 'player', 'territory']) && TERRITORIES.some(t => t.id === parent.territory)
: ['nexusFremenCunningRide', 'greatMakerRide'].includes(parent.kind) &&
exact(parent, ['kind', 'player', 'event']) && text(parent.event)),
'Fremen ride Betrayal needs one exact original native ride decision.');
}
function parentKey(parent: FremenRideBetrayalParent) {
return [parent.kind, parent.player, parent.kind === 'wormRide' ? parent.territory : parent.event];
}
function signature(frame: Omit<FremenRideBetrayalFrame, 'signature'>): string {
return JSON.stringify(['nexusFremenRideBetrayal', frame.event, frame.turn, parentKey(frame.source),
frame.stage, frame.order, frame.passed, frame.usedBy]);
}
function seal(frame: Omit<FremenRideBetrayalFrame, 'signature'>): FremenRideBetrayalFrame {
return { ...frame, signature: signature(frame) };
}
function validateFrame(frame: FremenRideBetrayalFrame): void {
requireRide(exact(frame, ['event', 'turn', 'source', 'stage', 'order', 'passed', 'usedBy', 'signature']) &&
text(frame.event) && Number.isSafeInteger(frame.turn) && frame.turn > 0 &&
['choose', 'complete'].includes(frame.stage) && ids(frame.order) && frame.order.length > 0 &&
ids(frame.passed) && frame.passed.length <= frame.order.length &&
equal(frame.passed, frame.order.slice(0, frame.passed.length)),
'The original Fremen ride Betrayal acknowledgement progress is invalid.');
validateParent(frame.source);
requireRide(frame.stage === 'choose'
? frame.usedBy === null && frame.passed.length < frame.order.length
: frame.usedBy === null
? frame.passed.length === frame.order.length
: text(frame.usedBy) && frame.usedBy === frame.order[frame.passed.length],
'Fremen ride Betrayal must finish by all original passes or one current responder.');
requireRide(frame.signature === signature(frame), 'Fremen ride Betrayal lost its original parent or progress.');
}
/** Public participation only: no card face enters membership, order or event. */
function publicOrder(g: Game): string[] {
requireRide(ids(g.order) && g.order.length === g.players.length &&
g.players.every(p => g.order.includes(p.id)), 'Fremen ride Betrayal needs the complete original storm order.');
return g.order.filter(id => {
const p = g.players.find(p => p.id === id)!;
return p.faction !== 'fremen' && !p.ally && Boolean(g.nexusCards!.cards!.hands[id]);
});
}
function available(g: Game): boolean {
return g.status === 'playing' && g.phase === 1 && fremenNexusModeSupported(g) &&
!g.nexusCards!.cards!.discard.includes('fremen') &&
!g.players.some(p => p.faction === 'fremen' && p.fremenNexusWormBlockedTurn === g.turn);
}
function validateNativeParent(g: Game, parent: FremenRideBetrayalParent): void {
const native = g.players.find(p => p.id === parent.player && p.faction === 'fremen');
requireRide(native, 'Only the actual Fremen native ride can open this Betrayal opportunity.');
if (parent.kind === 'wormRide') {
// nextWormRide already shifted this territory out of the remaining queue.
requireRide(Object.entries(native.forces).some(([key, count]) => {
const at = splitLocation(key);
return count > 0 && at.territory === parent.territory && at.sector !== g.storm;
}), 'The original ordinary worm ride has no remaining clear Fremen source.');
} else if (parent.kind === 'greatMakerRide') {
requireRide(g.greatMaker?.stage === 'ride' && g.greatMaker.event === parent.event &&
g.greatMaker.turn === g.turn && native.reserves > 0,
'The original Great Maker reserve ride has changed.');
try { validateGreatMaker(g.greatMaker, g.turn, g.players); }
catch (error) { throw new FremenRideBetrayalError(error instanceof Error ? error.message : 'Invalid Great Maker ride.'); }
} else {
const row = g.nexusFremenCunningRides?.find(row => row.stage === 'select' &&
row.occurrence.event === parent.event && row.occurrence.turn === g.turn && row.authorization.owner === native.id);
requireRide(row && quoteFremenCunningRide(g, native.id, row.occurrence, row.authorization),
'The original selected Fremen Cunning ride has changed.');
}
}
/** Called only at the original native decision's creation. Public discard and
* public card backs decide whether anyone could respond. A valid Cunning ride
* currently keeps the Fremen card in discard, so it needs no neutral pause. */
export function createFremenRideBetrayal(g: Game, parent: FremenRideBetrayalParent,
event: string): FremenRideBetrayalFrame | null {
if (!available(g)) return null;
requireRide(Number.isSafeInteger(g.turn) && g.turn > 0, 'Fremen ride Betrayal needs its original positive turn.');
validateNexusCards(g.nexusCards!.cards!, g.players);
const order = publicOrder(g); if (!order.length) return null;
validateParent(parent);
requireRide(text(event) && g.decision && exact(g.decision, parent.kind === 'wormRide'
? ['kind', 'player', 'territory'] : ['kind', 'player', 'event']) &&
JSON.stringify(parentKey(g.decision as FremenRideBetrayalParent)) === JSON.stringify(parentKey(parent)),
'Open Betrayal only before the current original Fremen ride decision.');
validateNativeParent(g, parent);
return seal({ event, turn: g.turn, source: structuredClone(parent), stage: 'choose', order, passed: [], usedBy: null });
}
export function fremenRideBetrayalCurrentOwner(frame: FremenRideBetrayalFrame): string | null {
validateFrame(frame); return frame.stage === 'choose' ? frame.order[frame.passed.length] : null;
}
/** Active validation accepts the exact original producer before wrapping, or
* the exact published wrapper owner/event. Completed records are detached
* signed progress: native continuation may already have consumed its parent. */
export function validateFremenRideBetrayal(g: Game, frame: FremenRideBetrayalFrame): void {
validateFrame(frame);
requireRide(Number.isSafeInteger(g.turn) && g.turn >= frame.turn &&
g.players.some(p => p.id === frame.source.player && p.faction === 'fremen') &&
frame.order.every(id => g.players.some(p => p.id === id && p.faction !== 'fremen')),
'Fremen ride Betrayal lost its original turn or native roster.');
if (frame.stage === 'complete') return;
requireRide(g.turn === frame.turn && available(g), 'This original Fremen ride Betrayal opportunity has expired.');
validateNexusCards(g.nexusCards!.cards!, g.players);
requireRide(equal(frame.order, publicOrder(g)), 'The original public Fremen ride Betrayal responders changed.');
const decision = g.decision;
const original = decision && exact(decision, frame.source.kind === 'wormRide'
? ['kind', 'player', 'territory'] : ['kind', 'player', 'event']) &&
JSON.stringify(parentKey(decision as FremenRideBetrayalParent)) === JSON.stringify(parentKey(frame.source));
const wrapper = exact(decision, ['kind', 'player', 'event']) &&
decision?.kind === 'nexusFremenRideBetrayal' && decision.event === frame.event &&
decision.player === fremenRideBetrayalCurrentOwner(frame);
requireRide(original || wrapper, 'Fremen ride Betrayal is not holding its exact original decision or current wrapper.');
validateNativeParent(g, frame.source);
}
function current(g: Game, frame: FremenRideBetrayalFrame, actor: string, event: string): void {
validateFremenRideBetrayal(g, frame);
requireRide(frame.stage === 'choose' && event === frame.event && fremenRideBetrayalCurrentOwner(frame) === actor,
'Acknowledge only your current original Fremen ride Betrayal opportunity.');
}
export function passFremenRideBetrayal(g: Game, frame: FremenRideBetrayalFrame,
actor: string, event: string): FremenRideBetrayalFrame {
current(g, frame, actor, event);
const passed = [...frame.passed, actor];
return seal({ ...structuredClone(frame), passed, stage: passed.length === frame.order.length ? 'complete' : 'choose' });
}
/** Eligibility of the actual Fremen face is deliberately the engine's private
* responsibility. Finish this frame BEFORE spending that physical card. */
export function useFremenRideBetrayal(g: Game, frame: FremenRideBetrayalFrame,
actor: string, event: string): FremenRideBetrayalFrame {
current(g, frame, actor, event);
return seal({ ...structuredClone(frame), stage: 'complete', usedBy: actor });
}
+24 -1
View File
@@ -5337,6 +5337,29 @@ export const RULE_TOPICS: RuleTopic[] = [
{area:'Verification',status:'Partial',detail:"Fresh classic core base33 or selected36 optional14 initializes the original actual-Harkonnen source without a card-variant dependency. Genuine base33 all14 Use reaches one real Mentat draw; earlier independent Harass, Pass and allied-counter cases remain. Ten combined consumer cases and four legal policies pass. Other modes, universal timing and deferred assurance remain open.",evidence:['tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-reactions.test.ts','tests/fixture-classic-independent-nexus-reactions.ts','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-fremen-ride-betrayal',
title: 'Fremen Nexus: prevent an impending worm ride',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Respond after an actual worm appears and before its native ride transfers forces.',
steps: [
'At an actual ordinary or Great Maker reserve ride, unallied non-Fremen seats with any public Nexus back respond in storm order. The opportunity does not reveal the hidden face. A publicly discarded Fremen card creates no needless pause; its valid pending Cunning ride already keeps that singleton in discard.',
'The current responder may Continue to retain every card and allow original ride choices. Only an actual held Fremen Nexus permits the private prevention option. After all Continue replies, the original unmade decision is restored unchanged.',
'Use spends the one original card and prevents remaining ordinary, native Cunning and Great Maker rides this turn under the existing source composition. It moves or kills no group and does not repeat worm destruction, protection, spice, votes or Nexus. Already committed transfers stay committed.',
'The public response frame retains its original event, source and ordered acknowledgments. Controls persist until a reply; underlying gameplay waits. Existing clean predeclarations, movement alternative and legacy receipts remain. A redrawn card cannot spend another alternative in the same turn.',
'The selected neutral priority is visibly provisional software composition. Existing native module admission, complete-mode gates and final assurance remain separate.',
],
related: ['nexus-cards', 'spice-blow', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Neutral original-source wrapper, signed progress, private real-card use, exact all-pass restore and native no-transfer prevention connect ordinary and Great Maker decisions.', evidence: ['game/nexus-fremen-ride-betrayal.ts', 'game/nexus-fremen-betrayal.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Persistent current-responder Continue/private Use, public waiting and busy suppression connect through GameTable.', evidence: ['components/nexus-fremen-ride-betrayal.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles submit the actual projected current event and legal private Use/Continue. Strategic refinement remains deferred.', evidence: ['game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Printed effect, selected neutral priority, preserved native effects and earlier timing boundary are distinguished.', evidence: ['docs/NEXUS_FREMEN_RULES.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Controlled pure/controls cases accompany genuine ordinary Basic/Advanced and Advanced Maker sources. Current frozen counts and limits are in the canonical source contract; complete modes and final assurance stay open.', evidence: ['tests/nexus-fremen-ride-betrayal-quote.test.ts', 'tests/nexus-fremen-ride-betrayal-controls.test.tsx', 'tests/nexus-fremen-reactive-betrayal-engine.test.ts', 'tests/fixture-nexus-fremen-reactive-betrayal.ts'] },
],
},
{
id: 'nexus-cards',
title: 'Nexus Cards',
@@ -5401,7 +5424,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'With actual Advanced Stronghold Cards, Arrakeen bank support changes the real payer debit, not personal income. Emperor Cunning temporary Sardaukar remain physically ordinary counters and retain ordinary Tanks custody. Original losing Moritani retention precedes mandatory original-winner Tech, then CHOAM inspection excludes used opposing cards even if retained. Ordinary city Collection remains separate; none of these windows repeats the original support, card cost or casualties.',
'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. Original native and selected-independent Nexus profiles retain World, training and phase modules. Advisors return as native counters; a concealed No-Field provisionally returns face down without materializing counters. The full unspent opening remains available after an actual Guild first/wait or Sapho reorder.',
'Ecaz Cunning lets an unallied native holder spend its physical Nexus at an original quiet boundary in any phase to take the existing Duke for this turn, including living control, original captured custody or the set-aside disc in Tanks. Finish committed auctions, Battle, responses, Ambassador and other physical continuations first; no chooser or phase is stolen. New version3 records bind actual phase and custody; legacy ordinary/version2 Battle history remains strict. Tanks preserve deaths and independently clear old use without spice or ordinary allowance; living already-used Duke gains no fresh use. Standalone no-Skills Ecaz/Advanced Harkonnen connects original capture and provisional permanent liberation; one battle or unused current-turn expiry sets Duke aside, never back to the captor. Actual Ecaz Ghola returns set aside before separate Cunning; foreign-Ghola revival remains forbidden. Roster/module and shared-training guards remain.',
'Fremen Betrayal has two advance-declaration alternatives for an unallied holder in an original registered Nexus table with its selected modules. 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; Great Maker reserve riding is also prevented under the explicit source composition, while its original votes, casualties and Nexus continue. Reactive post-worm timing and complete-combination acceptance remain separate.',
'Fremen Betrayal has two advance-declaration alternatives for an unallied holder in an original registered Nexus table with its selected modules. 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. Actual ordinary/Great Maker ride creation now adds a neutral public Nexus-back response in storm order. All-pass restores the same native choice; private actual Fremen-card Use suppresses remaining rides without replaying completed effects. This timing is provisional online composition, 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; Great Maker reserve riding is also prevented under the explicit source composition, while its original votes, casualties and Nexus continue. Existing predeclarations and legacy receipts remain; complete-combination acceptance stays open.',
'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. Forces, elite identity, destination restrictions, native Homeworld sources and movement remain unchanged. Fremen retain free reinforcement. Native Guild rate cancellation changes the one-counter sand bill from1 to2 without canceling the Nexus source; an already canceled rate is reflected in the quote. Unfunded declared Nexus stays spent without refund/reusable coupon while ordinary shipment remains available under the selected affordability policy. An independently purchased ordinary price remains separate. This is no concealed marker or extra shipment.',
'Native Richese Cunning uses the original signed two-No-Field shipment in supported single/paired E2 or selected mixed Ix+CHOAM native Nexus tables with classics, optional Skills/Homeworlds/Discovery/Tech/Advanced Strongholds. Select two distinct unused physical tokens: one reveals immediately into reserve-capped forces, one remains concealed; pay one marker invoice and spend the Nexus. The previous shipment token cannot be either choice, and the concealed one becomes last-used. Native/Guild response and actual payer/counter sources remain; a nested destination must be revealed. Prevention keeps physical tokens and unpaid spice, after any printed card cost. Sandtrout, arbitrary companions, shared allocation and other pending combinations retain their guards.',
+1
View File
@@ -91,6 +91,7 @@ const DECISIONS = {
wormRide: true,
nexusFremenCunningOffer: true,
nexusFremenProtection: true,
nexusFremenRideBetrayal: true,
nexusEmperorPayment: true,
nexusEmperorSellerPurchase: true,
nexusEmperorAmbassadorPurchase: true,