Connect Fremen Nexus fixed quota to native partial revival
This commit is contained in:
1 parent
985cf85e12
commit
25996ec8a7
26 files changed
+1386
-354
No files matched your search
+2
-9
@@ -4037,15 +4037,8 @@ export function botActions(g: GameView): Action[] {
|
||||
return [{ type: use ? 'nexusIxianReplacementUse' : 'nexusIxianReplacementPass', event: replacement.event }];
|
||||
}
|
||||
if (g.decision?.kind === 'nexusIxianReplacement') return [];
|
||||
const fremenRevival = g.nexusFremenRevival;
|
||||
if (fremenRevival && !fremenRevival.blocked && fremenRevival.eliteOptions.length) {
|
||||
const own = g.players.find(player => player.id === g.me);
|
||||
const elite = own?.bot === 'Easy'
|
||||
? Math.min(...fremenRevival.eliteOptions)
|
||||
: Math.max(...fremenRevival.eliteOptions);
|
||||
const action = nexusFremenRevivalAction(g, elite);
|
||||
if (action) return [action];
|
||||
}
|
||||
const fremenRevival = nexusFremenRevivalAction(g);
|
||||
if (fremenRevival) return [fremenRevival];
|
||||
const emperorRevival = g.nexusEmperorSecretAlly?.revival;
|
||||
if (emperorRevival && !emperorRevival.blocked && emperorRevival.eliteOptions.length) {
|
||||
const own = g.players.find((player) => player.id === g.me);
|
||||
|
||||
+54
-20
@@ -46,7 +46,7 @@ import { quoteEcazInquiry, ecazInquiryAnswer, ecazInquiryHistory, ecazInquirySig
|
||||
import { nexusCleanPlayBlocked } from './nexus-play-boundary';
|
||||
import { EMPEROR_NEXUS_REVIVALS, emperorNexusEvent, emperorNexusSignature, emperorNexusModeSupported, emperorNexusPools, emperorNexusRevivalElites, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally';
|
||||
import { emperorNexusPurchaseEvent, quoteEmperorNexusPurchase, signEmperorNexusPurchase, validateEmperorNexusPurchase, type EmperorNexusPurchase } from './nexus-emperor-purchase';
|
||||
import { FREMEN_NEXUS_FREE_FORCES, fremenNexusRevivalOffer, fremenNexusRevivalPools, fremenNexusRevivalSignature, validateFremenNexusRevival, type FremenNexusRevival } from './nexus-fremen-revival';
|
||||
import { FREMEN_NEXUS_FREE_FORCES, fremenNexusRevivalOffer, createFremenNexusRevival, upgradeFremenNexusRevival, validateFremenNexusRevival, type FremenNexusRevival } from './nexus-fremen-revival';
|
||||
import { captureFremenCunningOccurrence, quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection, type FremenCunningOccurrence, type FremenCunningRideAuthorization, type FremenCunningSelection } from './nexus-fremen-cunning';
|
||||
import { createNexusFremenProtection, currentNexusFremenProtectionOwner, answerNexusFremenProtection, validateNexusFremenProtection, type NexusFremenProtectionFrame, type NexusFremenProtectionInput } from './nexus-fremen-protection';
|
||||
import { truthKnowledgeOf } from './truthtrance-knowledge';
|
||||
@@ -2955,10 +2955,34 @@ function nexusFremenCunningIntegrity(g: Game) {
|
||||
'A worm occurrence cannot be both offered and already spent.');
|
||||
}
|
||||
|
||||
function migrateFremenNexusRevival(state: Game): Game {
|
||||
const history = state.nexusFremenRevivalHistory;
|
||||
if (!Array.isArray(history)) return state;
|
||||
let adopted: FremenNexusRevival[] | null = null;
|
||||
const owners: string[] = [];
|
||||
for (const [index, record] of history.entries()) {
|
||||
if (record?.version === 2) continue;
|
||||
const next = nexusRule(() => upgradeFremenNexusRevival(state, record));
|
||||
adopted ??= [...history];
|
||||
adopted[index] = next;
|
||||
if (state.phase === 4 && next.turn === state.turn) owners.push(next.owner);
|
||||
}
|
||||
if (!adopted) return state;
|
||||
const next = { ...state, nexusFremenRevivalHistory: adopted };
|
||||
if (owners.length) {
|
||||
const prior = state.revivalRules?.nexusFremen;
|
||||
next.revivalRules = { ...(state.revivalRules ?? newRevivalRules()), nexusFremen: {
|
||||
turn: state.turn,
|
||||
owners: [...new Set([...(prior?.turn === state.turn ? prior.owners : []), ...owners])],
|
||||
} };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
function nexusFremenRevivalIntegrity(g: Game) {
|
||||
const history = g.nexusFremenRevivalHistory, events = g.nexusFremenRevivalEvents;
|
||||
if (history === undefined) {
|
||||
requireRule(events === undefined, 'Fremen Nexus has lost its saved revival history.');
|
||||
requireRule(events === undefined && !g.revivalRules?.nexusFremen,
|
||||
'Fremen Nexus has lost its saved quota history.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0 &&
|
||||
@@ -2975,29 +2999,36 @@ function nexusFremenRevivalIntegrity(g: Game) {
|
||||
if (last.turn === g.turn)
|
||||
requireRule(g.nexusCards.cards.discard.includes('fremen'),
|
||||
'Fremen Nexus has reopened its already spent physical card.');
|
||||
const permission = g.revivalRules?.nexusFremen;
|
||||
if (permission) requireRule(Number.isSafeInteger(permission.turn) &&
|
||||
permission.turn > 0 && permission.turn <= g.turn && Array.isArray(permission.owners) &&
|
||||
permission.owners.length > 0 && new Set(permission.owners).size === permission.owners.length &&
|
||||
permission.owners.every(owner => history.some(record =>
|
||||
record.turn === permission.turn && record.owner === owner)),
|
||||
'The Fremen quota has lost its actual phase-bound source grant.');
|
||||
if (g.phase === 4) for (const record of history.filter(entry => entry.turn === g.turn)) {
|
||||
const player = getPlayer(g, record.owner);
|
||||
requireRule(permission?.turn === g.turn && permission.owners.includes(record.owner) &&
|
||||
player.revived >= record.revivedAtUse &&
|
||||
(player.freeForcesRevived ?? (player.revived === 0 ? 0 : -1)) >= record.freeAtUse,
|
||||
'The Fremen quota has lost its source permission or actual free-return ledger.');
|
||||
}
|
||||
}
|
||||
function playNexusFremenRevive(g: Game, p: Player, action: Action) {
|
||||
function playNexusFremenRevival(g: Game, p: Player, action: Action) {
|
||||
const offer = fremenNexusRevivalOffer(g, p.id);
|
||||
requireRule(offer && !offer.blocked && action.event === offer.event &&
|
||||
Object.keys(action).sort().join(',') === 'elite,event,type' &&
|
||||
offer.eliteOptions.includes(action.elite as number),
|
||||
offer?.blocked ?? 'Choose a current eligible three-force Fremen Nexus return.');
|
||||
const elite = action.elite as number, before = fremenNexusRevivalPools(p);
|
||||
Object.keys(action).sort().join(',') === 'event,type',
|
||||
offer?.blocked ?? 'Choose your current Fremen ordinary free-quota grant.');
|
||||
const record = nexusRule(() => createFremenNexusRevival(g, p.id));
|
||||
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
|
||||
addNativeReserves(g, p, FREMEN_NEXUS_FREE_FORCES, elite);
|
||||
p.tanks -= FREMEN_NEXUS_FREE_FORCES;
|
||||
p.revived += FREMEN_NEXUS_FREE_FORCES;
|
||||
p.freeForcesRevived = (p.freeForcesRevived ?? 0) + FREMEN_NEXUS_FREE_FORCES;
|
||||
if (p.elites) { p.elites.tanks -= elite; p.elites.revived += elite; }
|
||||
techIncome(g, 'axlotl', p);
|
||||
const record: FremenNexusRevival = { event: offer.event, owner: p.id, turn: g.turn,
|
||||
phase: 4, faction: p.faction, advanced: g.advanced, elite, before,
|
||||
after: fremenNexusRevivalPools(p), signature: '' };
|
||||
record.signature = fremenNexusRevivalSignature(record);
|
||||
g.revivalRules ??= newRevivalRules();
|
||||
const prior = g.revivalRules.nexusFremen;
|
||||
g.revivalRules.nexusFremen = { turn: g.turn,
|
||||
owners: [...(prior?.turn === g.turn ? prior.owners : []), p.id] };
|
||||
(g.nexusFremenRevivalHistory ??= []).push(record);
|
||||
(g.nexusFremenRevivalEvents ??= []).push(record.event);
|
||||
log(g, `${p.name} spent Fremen Nexus Secret Ally to return three forces${elite ? `, including ${elite} elite` : ''} from Tanks to reserves for free. This uses the ordinary three-force revival allowance.`,
|
||||
{ faction: p.faction, name: 'Fremen Nexus revival' });
|
||||
log(g, `${p.name} spent Fremen Nexus Secret Ally to enable a fixed ordinary free quota of ${FREMEN_NEXUS_FREE_FORCES} for this Revival phase. Use the normal controls for partial and typed returns. Prior free usage counts; paid returns are not refunded. Provisional policy: native limits, prevention and higher rates remain; Recruits does not double this card quota.`,
|
||||
{ faction: p.faction, name: 'Fremen Nexus free quota' });
|
||||
}
|
||||
function playNexusChoamTrade(g: Game, p: Player, action: Action) {
|
||||
const offer = currentNexusChoamTrade(g, p.id);
|
||||
@@ -28420,6 +28451,7 @@ function normalizeCardNames(g: Game) {
|
||||
}
|
||||
export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
state = migrateAtomicsTargets(state);
|
||||
state = migrateFremenNexusRevival(state);
|
||||
if (state.spiceBankerIncomePreview) {
|
||||
const keysBySource: Record<string, readonly string[]> = {
|
||||
bid: ['type','amount','allyPayment'], passBid: ['type'],
|
||||
@@ -28821,6 +28853,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
|
||||
export function normalizeAutomaticGame(state: Game): Game {
|
||||
state = migrateAtomicsTargets(state);
|
||||
state = migrateFremenNexusRevival(state);
|
||||
harkonnenNexusBetrayalIntegrity(state);
|
||||
ixianNexusBetrayalIntegrity(state);
|
||||
ixianNexusReplacementIntegrity(state);
|
||||
@@ -29072,7 +29105,7 @@ function applyActionInner(
|
||||
if (t === 'nexusEmperorPurchase') { playNexusEmperorPurchase(g,p,action); return g; }
|
||||
if (t === 'nexusEmperorBetrayal') { playNexusEmperorBetrayal(g,p,action); return g; }
|
||||
if (t === 'nexusBgBetrayal') { playNexusBgBetrayal(g,p,action); return g; }
|
||||
if (t === 'nexusFremenRevive') { playNexusFremenRevive(g,p,action); return g; }
|
||||
if (t === 'nexusFremenRevival') { playNexusFremenRevival(g,p,action); return g; }
|
||||
if (t === 'nexusMoritaniBetrayal') { playMoritaniBetrayal(g, p, action); return g; }
|
||||
if (t === 'nexusEcazBetrayal') { playEcazBetrayal(g, p, action); return g; }
|
||||
if (t === 'nexusFremenBetrayal') { playNexusFremenBetrayal(g, p, action); return g; }
|
||||
@@ -32876,6 +32909,7 @@ function applyActionInner(
|
||||
}
|
||||
export function viewGame(state: Game, id: string) {
|
||||
state = migrateAtomicsTargets(state);
|
||||
state = migrateFremenNexusRevival(state);
|
||||
harkonnenNexusBetrayalIntegrity(state);
|
||||
ixianNexusBetrayalIntegrity(state);
|
||||
ixianNexusReplacementIntegrity(state);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
|
||||
/** Server-owned private offer binds the selected physical group and turn. */
|
||||
export function nexusFremenRevivalAction(game: GameView, elite: number): Action | null {
|
||||
/** Enable the server-owned phase quota; original revival controls choose the forces. */
|
||||
export function nexusFremenRevivalAction(game: GameView): Action | null {
|
||||
const offer = game.nexusFremenRevival;
|
||||
const owner = game.players.find(player => player.id === game.me);
|
||||
if (!offer || !owner || owner.ally || game.nexusCards?.card !== 'fremen' ||
|
||||
@@ -9,7 +9,6 @@ export function nexusFremenRevivalAction(game: GameView, elite: number): Action
|
||||
game.status !== 'playing' || game.phase !== 4 ||
|
||||
game.response || game.decision || game.truthtrance || game.phaseOpening ||
|
||||
game.automaticContinuationPending || game.nexusCards.waiting.length ||
|
||||
game.nexusTraitors?.pending || offer.blocked ||
|
||||
!Number.isSafeInteger(elite) || !offer.eliteOptions.includes(elite)) return null;
|
||||
return { type: 'nexusFremenRevive', event: offer.event, elite };
|
||||
game.nexusTraitors?.pending || offer.blocked || offer.maxForces < 1) return null;
|
||||
return { type: 'nexusFremenRevival', event: offer.event };
|
||||
}
|
||||
+161
-70
@@ -1,101 +1,192 @@
|
||||
import type { Game, Player } from './engine';
|
||||
import { eliteRevivalRemaining, forceRevivalLimit, freeRevivalRate } from './revival';
|
||||
import { classicNexusModulesProfile } from './nexus-module-profile';
|
||||
import { classicNexusLeaderSkillsProfile } from './leader-skill-profile';
|
||||
import {
|
||||
eliteRevivalRemaining,
|
||||
forceRevivalRemaining,
|
||||
normalRevivalAllowance,
|
||||
revivalPrevented,
|
||||
} from './revival';
|
||||
|
||||
export const FREMEN_NEXUS_FREE_FORCES = 3;
|
||||
type RevivalPools = { reserves: number; tanks: number; eliteReserves: number; eliteTanks: number;
|
||||
eliteRevived: number; revived: number; freeForcesRevived: number; spice: number };
|
||||
const POLICY = 'ordinary-free-quota-three' as const;
|
||||
type RevivalPools = {
|
||||
reserves: number; tanks: number; eliteReserves: number; eliteTanks: number;
|
||||
eliteRevived: number; revived: number; freeForcesRevived: number; spice: number;
|
||||
};
|
||||
type LegacyReturn = { elite: number; before: RevivalPools; after: RevivalPools };
|
||||
export type FremenNexusRevival = {
|
||||
version: 2;
|
||||
event: string;
|
||||
owner: string;
|
||||
turn: number;
|
||||
phase: 4;
|
||||
faction: Player['faction'];
|
||||
advanced: boolean;
|
||||
elite: number;
|
||||
before: RevivalPools;
|
||||
after: RevivalPools;
|
||||
revivedAtUse: number;
|
||||
freeAtUse: number;
|
||||
policy: typeof POLICY;
|
||||
legacyReturn?: LegacyReturn;
|
||||
signature: string;
|
||||
};
|
||||
export function fremenNexusRevivalPools(p: Player): RevivalPools {
|
||||
return {
|
||||
reserves: p.reserves, tanks: p.tanks,
|
||||
eliteReserves: p.elites?.reserves ?? 0, eliteTanks: p.elites?.tanks ?? 0,
|
||||
eliteRevived: p.elites?.revived ?? 0, revived: p.revived,
|
||||
freeForcesRevived: p.freeForcesRevived ?? 0, spice: p.spice,
|
||||
};
|
||||
}
|
||||
export const fremenNexusRevivalEvent = (turn: number, owner: string) =>
|
||||
JSON.stringify(['nexusFremenSecretRevival', turn, owner]);
|
||||
export const fremenNexusRevivalSignature = (record: FremenNexusRevival) =>
|
||||
JSON.stringify({ ...record, signature: undefined });
|
||||
|
||||
/** Bounded first path: base-faction, ordinary force allowance, no competing
|
||||
* revival modifiers or prior force returns. Other combinations stay gated. */
|
||||
const count = (value: unknown): value is number =>
|
||||
Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
function plain(value: unknown): value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const prototype: unknown = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
function shape(value: unknown, keys: string): value is Record<string, unknown> {
|
||||
return plain(value) && Object.keys(value).sort().join(',') === keys;
|
||||
}
|
||||
function freeUsed(p: Player): number | null {
|
||||
if (!count(p.revived)) return null;
|
||||
if (p.freeForcesRevived === undefined) return p.revived === 0 ? 0 : null;
|
||||
return count(p.freeForcesRevived) && p.freeForcesRevived <= p.revived
|
||||
? p.freeForcesRevived : null;
|
||||
}
|
||||
|
||||
/** Spending enables a phase-bound ordinary quota; it never returns a force,
|
||||
* raises the ordinary total limit, or doubles the fixed quota with Recruits. */
|
||||
export function fremenNexusRevivalOffer(g: Game, owner: string) {
|
||||
const p = g.players.find(seat => seat.id === owner);
|
||||
if (!p || g.nexusCards?.cards?.hands[owner] !== 'fremen' ||
|
||||
if (!p || !g.nexusCards?.cards ||
|
||||
g.nexusCards.cards.hands[owner] !== 'fremen' ||
|
||||
g.players.some(seat => seat.faction === 'fremen')) return null;
|
||||
const used = freeUsed(p);
|
||||
let blocked: string | null = null;
|
||||
if (p.ally) blocked = 'An allied player cannot use a Nexus card.';
|
||||
else if (g.status !== 'playing' || g.phase !== 4) blocked = 'Use Fremen Secret Ally during Revival.';
|
||||
else if (!classicNexusModulesProfile(g) && !classicNexusLeaderSkillsProfile(g) && (g.expansions.length || g.homeworlds || g.leaderSkills || g.discoveryEnabled ||
|
||||
g.discoveries || g.discoveryStash || g.greatMaker || g.ecazTreachery ||
|
||||
g.techTokens || g.strongholdCards ||
|
||||
g.players.some(seat => !['atreides', 'harkonnen', 'emperor', 'guild', 'beneGesserit'].includes(seat.faction))))
|
||||
blocked = 'Combined expansion and module revival interactions remain unavailable.';
|
||||
else if (g.status !== 'playing' || g.phase !== 4)
|
||||
blocked = 'Use Fremen Secret Ally during Revival.';
|
||||
else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingRevival ||
|
||||
g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy ||
|
||||
g.pendingExchange || g.pendingRicheseGift || g.pendingAmbassador || g.pendingCapture ||
|
||||
g.battle || g.nexusTraitorPending || g.nexusCards?.phase?.stage === 'drawing')
|
||||
g.battle || g.nexusTraitorPending || g.nexusCards.phase?.stage === 'drawing')
|
||||
blocked = 'Finish the current interaction before using Fremen Secret Ally.';
|
||||
else if (g.recruits?.turn === g.turn || g.revivalRules?.limitBlocked ||
|
||||
g.revivalRules?.expanded.length || g.revivalRules?.freeBlocked?.length ||
|
||||
g.freeRevival.includes(owner) || g.revivalPrevention?.turn === g.turn ||
|
||||
forceRevivalLimit(g,p) !== 3 || freeRevivalRate(g,p) >= 3)
|
||||
blocked = 'Combined or higher-rate revival accounting remains unavailable.';
|
||||
else if (p.revived !== 0 || (p.freeForcesRevived ?? 0) !== 0)
|
||||
blocked = 'Use this free-three return before ordinary force revivals; late accounting remains unavailable.';
|
||||
const eliteOptions: number[] = [];
|
||||
for (let elite = 0; elite <= FREMEN_NEXUS_FREE_FORCES; elite++)
|
||||
if (elite <= eliteRevivalRemaining(p,g.advanced) &&
|
||||
elite <= (p.elites?.tanks ?? 0) &&
|
||||
FREMEN_NEXUS_FREE_FORCES - elite <= p.tanks - (p.elites?.tanks ?? 0))
|
||||
eliteOptions.push(elite);
|
||||
if (!blocked && !eliteOptions.length)
|
||||
blocked = 'An exact three-force return is unavailable; a smaller return awaits a ruling.';
|
||||
return { event: fremenNexusRevivalEvent(g.turn,owner), blocked, eliteOptions };
|
||||
else if (g.nexusFremenRevivalEvents?.includes(fremenNexusRevivalEvent(g.turn, owner)) ||
|
||||
g.nexusFremenRevivalHistory?.some(record => record.turn === g.turn) ||
|
||||
(g.revivalRules?.nexusFremen?.turn === g.turn &&
|
||||
g.revivalRules.nexusFremen.owners.includes(owner)))
|
||||
blocked = 'Fremen Secret Ally has already been used this turn.';
|
||||
else if (used === null)
|
||||
blocked = 'Prior free-force revival usage is unknown; the Fremen quota cannot be granted safely.';
|
||||
else if (revivalPrevented(g, owner))
|
||||
blocked = 'Tleilaxu prevention blocks ordinary revival this turn.';
|
||||
else if (g.revivalRules?.freeBlocked?.includes(owner))
|
||||
blocked = 'Free ordinary revival is blocked for this player.';
|
||||
|
||||
let freeRemaining = 0, maxForces = 0, ordinaryRemaining = 0;
|
||||
if (used !== null) {
|
||||
const native = normalRevivalAllowance(g, p);
|
||||
ordinaryRemaining = native.remaining;
|
||||
const eligible = Math.max(0, p.tanks - (p.elites?.tanks ?? 0)) +
|
||||
eliteRevivalRemaining(p, g.advanced);
|
||||
const ordinaryForces = forceRevivalRemaining(g, p);
|
||||
if (!g.revivalRules?.freeBlocked?.includes(owner)) {
|
||||
freeRemaining = Math.min(ordinaryRemaining,
|
||||
Math.max(0, Math.max(native.freeRate, FREMEN_NEXUS_FREE_FORCES) - used));
|
||||
maxForces = Math.min(ordinaryForces, freeRemaining, eligible);
|
||||
}
|
||||
if (!blocked && native.freeRate >= FREMEN_NEXUS_FREE_FORCES)
|
||||
blocked = 'The native free revival quota is already at least three; this card adds nothing.';
|
||||
if (!blocked && maxForces <= Math.min(ordinaryForces, eligible,
|
||||
Math.max(0, native.freeRate - used)))
|
||||
blocked = 'Fremen Secret Ally cannot improve the available free revival quota.';
|
||||
if (!blocked && maxForces < 1)
|
||||
blocked = 'No eligible ordinary force return remains in Tanks.';
|
||||
}
|
||||
return {
|
||||
event: fremenNexusRevivalEvent(g.turn, owner), blocked,
|
||||
quota: FREMEN_NEXUS_FREE_FORCES, freeRemaining, maxForces, ordinaryRemaining,
|
||||
policy: 'Enable a fixed ordinary free quota of three for this Revival phase, then use the normal revival controls. Prior free returns count; paid returns are not refunded. Native limits, typed-force caps, prevention and higher free rates still apply; Recruits does not double this fixed quota.',
|
||||
};
|
||||
}
|
||||
|
||||
export function validateFremenNexusRevival(g: Game, record: FremenNexusRevival) {
|
||||
const p = g.players.find(seat => seat.id === record?.owner);
|
||||
const before = record?.before, after = record?.after;
|
||||
if (!p || !g.nexusCards?.cards || !record ||
|
||||
Object.keys(record).sort().join(',') !== 'advanced,after,before,elite,event,faction,owner,phase,signature,turn' ||
|
||||
record.phase !== 4 || record.faction !== p.faction || record.advanced !== g.advanced ||
|
||||
!Number.isSafeInteger(record.turn) || record.turn < 1 || record.turn > g.turn ||
|
||||
record.event !== fremenNexusRevivalEvent(record.turn,record.owner) ||
|
||||
/** Pure receipt creation: custody and permission are committed by the engine. */
|
||||
export function createFremenNexusRevival(g: Game, owner: string): FremenNexusRevival {
|
||||
const offer = fremenNexusRevivalOffer(g, owner);
|
||||
if (!offer || offer.blocked)
|
||||
throw new Error(offer?.blocked ?? 'Hold an eligible Fremen Secret Ally card.');
|
||||
const p = g.players.find(seat => seat.id === owner)!;
|
||||
const record: FremenNexusRevival = {
|
||||
version: 2, event: offer.event, owner, turn: g.turn, phase: 4,
|
||||
faction: p.faction, advanced: g.advanced,
|
||||
revivedAtUse: p.revived, freeAtUse: freeUsed(p)!, policy: POLICY, signature: '',
|
||||
};
|
||||
record.signature = fremenNexusRevivalSignature(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
function validIdentity(g: Game, record: Record<string, unknown>) {
|
||||
const p = g.players.find(seat => seat.id === record.owner);
|
||||
return !!p && !!g.nexusCards?.cards && typeof record.owner === 'string' &&
|
||||
record.owner.trim().length > 0 &&
|
||||
!['__proto__', 'constructor', 'prototype'].includes(record.owner) &&
|
||||
record.phase === 4 && record.faction === p.faction && record.advanced === g.advanced &&
|
||||
count(record.turn) && record.turn >= 1 && record.turn <= g.turn &&
|
||||
record.event === fremenNexusRevivalEvent(record.turn, record.owner);
|
||||
}
|
||||
function validLegacyReturn(g: Game, owner: string, value: unknown): value is LegacyReturn {
|
||||
if (!shape(value, 'after,before,elite')) return false;
|
||||
const { before, after, elite } = value;
|
||||
const keys = 'eliteReserves,eliteRevived,eliteTanks,freeForcesRevived,reserves,revived,spice,tanks';
|
||||
if (!shape(before, keys) || !shape(after, keys) ||
|
||||
!Object.values(before).every(count) || !Object.values(after).every(count) ||
|
||||
!count(elite) || elite > FREMEN_NEXUS_FREE_FORCES) return false;
|
||||
const b = before as RevivalPools, a = after as RevivalPools;
|
||||
const p = g.players.find(seat => seat.id === owner)!;
|
||||
return b.eliteReserves <= b.reserves && b.eliteTanks <= b.tanks &&
|
||||
b.reserves + b.tanks <= 20 && b.revived === 0 && b.freeForcesRevived === 0 &&
|
||||
b.tanks >= 3 && elite <= b.eliteTanks && 3 - elite <= b.tanks - b.eliteTanks &&
|
||||
(!g.advanced || b.eliteRevived + elite <= 1) &&
|
||||
(p.faction === 'emperor' || !(elite || b.eliteReserves || b.eliteTanks)) &&
|
||||
JSON.stringify(a) === JSON.stringify({
|
||||
...b, reserves: b.reserves + 3, tanks: b.tanks - 3,
|
||||
eliteReserves: b.eliteReserves + elite, eliteTanks: b.eliteTanks - elite,
|
||||
eliteRevived: b.eliteRevived + elite, revived: 3, freeForcesRevived: 3,
|
||||
});
|
||||
}
|
||||
|
||||
export function validateFremenNexusRevival(g: Game, record: FremenNexusRevival): void {
|
||||
const keys = 'advanced,event,faction,freeAtUse,owner,phase,policy,revivedAtUse,signature,turn,version';
|
||||
if (!(shape(record, keys) ||
|
||||
shape(record, 'advanced,event,faction,freeAtUse,legacyReturn,owner,phase,policy,revivedAtUse,signature,turn,version')) ||
|
||||
!validIdentity(g, record) || record.version !== 2 || record.policy !== POLICY ||
|
||||
!count(record.revivedAtUse) || !count(record.freeAtUse) ||
|
||||
record.freeAtUse > record.revivedAtUse ||
|
||||
record.signature !== fremenNexusRevivalSignature(record) ||
|
||||
!Number.isSafeInteger(record.elite) || record.elite < 0 || record.elite > 3 ||
|
||||
!before || !after ||
|
||||
Object.keys(before).sort().join(',') !== 'eliteReserves,eliteRevived,eliteTanks,freeForcesRevived,reserves,revived,spice,tanks' ||
|
||||
Object.keys(after).sort().join(',') !== 'eliteReserves,eliteRevived,eliteTanks,freeForcesRevived,reserves,revived,spice,tanks' ||
|
||||
Object.values(before).some(n => !Number.isSafeInteger(n) || n < 0) ||
|
||||
Object.values(after).some(n => !Number.isSafeInteger(n) || n < 0) ||
|
||||
before.eliteReserves > before.reserves || before.eliteTanks > before.tanks ||
|
||||
before.reserves + before.tanks > 20 || before.revived !== 0 || before.freeForcesRevived !== 0 ||
|
||||
before.tanks < 3 || record.elite > before.eliteTanks ||
|
||||
3 - record.elite > before.tanks - before.eliteTanks ||
|
||||
(g.advanced && before.eliteRevived + record.elite > 1) ||
|
||||
(p.faction !== 'emperor' && (record.elite || before.eliteReserves || before.eliteTanks)) ||
|
||||
JSON.stringify(after) !== JSON.stringify({
|
||||
...before,
|
||||
reserves: before.reserves + 3, tanks: before.tanks - 3,
|
||||
eliteReserves: before.eliteReserves + record.elite,
|
||||
eliteTanks: before.eliteTanks - record.elite,
|
||||
eliteRevived: before.eliteRevived + record.elite,
|
||||
revived: 3, freeForcesRevived: 3,
|
||||
}))
|
||||
(Object.hasOwn(record, 'legacyReturn') &&
|
||||
(!validLegacyReturn(g, record.owner, record.legacyReturn) ||
|
||||
record.revivedAtUse !== 0 || record.freeAtUse !== 0)))
|
||||
throw new Error('Fremen Nexus revival lost its ordinary free-quota source grant.');
|
||||
}
|
||||
|
||||
/** Saved exact-three receipts are adopted as source metadata only. Their
|
||||
* original physical return stays evidence, never another gameplay mutation. */
|
||||
export function upgradeFremenNexusRevival(g: Game, value: unknown): FremenNexusRevival {
|
||||
if (plain(value) && value.version === 2) {
|
||||
validateFremenNexusRevival(g, value as FremenNexusRevival);
|
||||
return value as FremenNexusRevival;
|
||||
}
|
||||
if (!shape(value, 'advanced,after,before,elite,event,faction,owner,phase,signature,turn') ||
|
||||
!validIdentity(g, value) ||
|
||||
value.signature !== JSON.stringify({ ...value, signature: undefined }) ||
|
||||
!validLegacyReturn(g, value.owner as string,
|
||||
{ elite: value.elite, before: value.before, after: value.after }))
|
||||
throw new Error('Fremen Nexus revival lost its original physical return.');
|
||||
const record: FremenNexusRevival = {
|
||||
version: 2, event: value.event as string, owner: value.owner as string,
|
||||
turn: value.turn as number, phase: 4, faction: value.faction as Player['faction'],
|
||||
advanced: value.advanced as boolean, revivedAtUse: 0, freeAtUse: 0, policy: POLICY,
|
||||
legacyReturn: {
|
||||
elite: value.elite as number,
|
||||
before: { ...value.before as RevivalPools }, after: { ...value.after as RevivalPools },
|
||||
}, signature: '',
|
||||
};
|
||||
record.signature = fremenNexusRevivalSignature(record);
|
||||
validateFremenNexusRevival(g, record);
|
||||
return record;
|
||||
}
|
||||
+5
-5
@@ -4570,7 +4570,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Emperor Cunning requires an Advanced battle with at least five ordinary Emperor counters and no actual Sardaukar, before submitting your plan. The played Nexus card opens one Karama response. Allowing it makes five ordinary counters count as Sardaukar for this battle; cancellation prevents the entire enhancement and leaves the Nexus card spent.',
|
||||
'Temporary Sardaukar use the applicable strength and support rules, including the Fremen exception, but remain physically ordinary counters. Their losses go to Tanks as ordinary counters, without changing starred reserves or Tanks. Existing inspected or promised plans remain binding. The role ends after this battle; fewer-than-five groups remain unavailable. The separate Secret Ally revival and normal bank-auction purchase have connected controls; seller-paid and forced Emperor payments remain unfinished.',
|
||||
'Emperor Betrayal has a battle alternative in Advanced play: an unallied opponent holding the Emperor Nexus card can spend it during the ordinary pre-plan Sardaukar response to suppress actual Sardaukar strength for this battle. Every seat receives the same response opportunity even with no card, keeping hidden custody private. The starred pieces remain physical Sardaukar; no card grants a new force or cancels their separate support benefit.',
|
||||
'With Fremen absent, an unallied holder may spend Fremen Secret Ally during Revival to return exactly three eligible forces from Tanks to reserves at no spice cost. Unlike Emperor Secret Ally, these three consume the ordinary force and free-force allowances, including the Advanced one-elite cap. This bounded base-faction path keeps prior-return and competing-revival guards; supported classic Tech/Stronghold composition invokes actual Axlotl activity after the batch, independently of allowance accounting. Both free returns collect Tech only at phase end, never as spendable advance spice. Partial returns, prior returns and higher free rates remain guarded; independent sandworm protection is the separate connected operation below.',
|
||||
'With Fremen absent, an actual unallied Fremen-card holder may enable a fixed ordinary free quota of three at a quiet Revival boundary, then use normal partial/typed revival controls. Activation moves no counters, leader or spice. Actual prior free use counts; paid returns keep their cost and total usage. Native total limits, higher free rates, typed caps, prevention, Homeworld deposits/deployment, Axlotl activity and once-only Tleilaxu income remain original. Recruits doubles its native rate, not this fixed quota. Useless or unknown-ledger grants are unavailable. This is the visibly provisional recommended interpretation; strict old physical receipts adopt metadata without replay. Independent sandworm protection remains the separate operation below.',
|
||||
'Before one accepted worm devours forces with Fremen absent, publicly possible unallied threatened held-Nexus seats acknowledge in original storm order regardless of secret face. Only the actual Fremen holder may spend to save their own groups. Pass keeps the card; all acknowledgements precede casualties. Under the explicit provisional independent-operation policy, no extra Karama provider or riding is invented. Original other casualties, spice removal, Great Maker vote and next blow/Nexus remain. Protected No-Field uses native protected-victim skipping; wider marker/module acceptance remains open.',
|
||||
'Native Fremen Cunning has a bounded classic Nexus path, optionally retaining original Tech Tokens and Advanced Stronghold Cards: when a natural, accepted additional, or Advanced special-Karama summoned worm appears in a territory initially containing no forces, Fremen may spend their own Nexus card before the worm resolves. The special summon retains its original Spice Blow parent. After the applicable Nexus, choose some forces from one occupied desert territory and ride to a legal Arrakis destination without using normal movement or changing original component custody/income. The choice appears even without the secret card, so declining does not disclose custody. Karama provisionally prevents only the additional remote ride; the original worm still resolves and the card remains spent. An interrupted existing Cunning control is restored, and a selected ride waits for the summoned Nexus without granting a second Cunning offer; Great Maker, dynamic destinations and other optional-module combinations await integration.',
|
||||
'CHOAM Secret Ally has a working Collection trade: when CHOAM is absent, an unallied holder may discard one Worthless card and the Nexus card for two bank spice. After a battle victory, every winner in an eligible Nexus game receives the same use-or-continue window; an unallied CHOAM-card holder may instead spend that card to inspect one random unused card in the opposing combatant’s current hand. Played battle cards remain excluded even if retained. The inspected face is private, while use is public. The universal window protects concealed card custody; this is an interface interpretation, not a publisher-prescribed prompt. An ordinary Karama response to this Secret Ally inspection remains unestablished.',
|
||||
@@ -4597,11 +4597,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Existing physical effects, Tleilaxu revival, borrowed Voice and independent worm protection remain connected. Native BG Cunning now permits fresh and storm-bound/separated whole advisor territories under the visible provisional card exception. Exact original stance/groups, physical card and one Karama response remain; ordinary flip/arrival, capacity, movement, casualties and battle geometry are unchanged. Dead uncertainty helper is removed and quotes avoid receipt allocations. Wider custody and combinations remain open.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Existing connected controls remain. Cunning offers actual fresh/storm whole groups with a visible in-place interpretation, native allowance or whole-batch cancellation. Live proof repaired an existing ordinary Move button mismatch using projected route geometry/native range; storm-origin and over-range cross-storm routes now disable with accessible explanation, without engine relaxation. Original movement/turn handover remains. Complete modes and wider module controls are open.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four unchanged minimal legal profiles retain existing effects and now execute actual favorable two-advisor versus one-fighter Cunning for fresh, storm-bound and separated sources through the shared quote. Original response and movement/turn continuation remain legal. Projected geometry is shared with corrected controls; no new strategy or calibration is claimed, and both remain deferred and unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Existing physical effects remain. Fremen Secret Ally now enables a fixed ordinary free quota3 without moving forces, then original partial/typed revival preserves prior actual free/paid usage, native limits/rates/Recruits, prevention, income, Tech and Homeworld continuation. Strict old physical receipts adopt source metadata without replay. Removed early-only direct-transfer action/selector. Borrowed Voice, independent protection and fresh/storm BG Cunning retain their separate provisional policies. Wider combinations and custody remain open.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Fremen activation visibly explains the provisional total-free-quota policy, returns no counters and hands over to original partial force/elite controls and native Tleilaxu Allow decisions. Real390px one-plus-two returns exhaust the quota and disable further returns. Existing Voice/protection/Cunning controls and shared native movement route guidance remain. Complete modes and wider module controls are open.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Existing minimal legal profiles retain original responses and continuation. Fremen holders use the shared event-only quota activation before original ordinary revival selection rather than a Nexus-only elite action. Genuine Basic/Advanced classic/native-Tleilaxu Easy paths return eligible free counters; strategy and four-tier calibration remain deferred and unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Common rules and twelve faces retain remaining custody, inquiry, native nested Cunning and combination questions. Source documents distinguish cancellable named Voice, independent protection and the recommended in-place fresh/storm Cunning exceptions without claiming publisher answers. Actual Advanced card-distance chronology and native ordinary restrictions are explicit. Existing Richese/Guild contracts and complete-mode gates remain.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Cunning related10-file union65/65 and types/lint pass. Genuine current-turn Shield Wall2, sheltered Arrakeen2 under storm10 and rock False Wall East5/9 separated by storm7 exercise allow/cancel, immutable rejection, exact custody, original geometry and four legal profiles. Three real390px surfaces prove fresh conversion/fighter move/Battle handover, storm stance-only allowance and entire separated-batch Karama cancellation. A live offered but engine-rejected storm move is corrected to disabled route/range guidance. Costs/pools/usage unchanged by conversion; no overflow/errors. Wider modules, assurance and deployed acceptance stay open.', evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Quota affected20-file union329/329 passes with genuine Basic/Advanced classic/native-Tleilaxu one-plus-two, late free/paid returns, expiry, original income, typed module consumers and legacy metadata adoption. Types/lint pass. Four actual API profiles reach Movement after late activation. Actual390px activation then one/two returns with original Tleilaxu Allow decisions preserves spice13, Tanks8→5/reserves10→13/free3 and native income1 once; one discard, no overflow/browser errors. Prior BG Cunning65/65 evidence remains. Wider combinations, assurance and deployed acceptance stay open.', evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+12
-1
@@ -19,6 +19,8 @@ export type RevivalRules = {
|
||||
expanded: string[];
|
||||
/** Provisional shared five-return permission from the actual absent-faction Nexus grant. */
|
||||
nexusTleilaxu?: string[];
|
||||
/** Independent fixed ordinary free quota; native Recruits rates remain separate. */
|
||||
nexusFremen?: { turn: number; owners: string[] };
|
||||
choamBlocked?: boolean;
|
||||
freeBlocked?: string[];
|
||||
limitBlocked: boolean;
|
||||
@@ -78,7 +80,7 @@ export function normalRevivalAllowance(
|
||||
p: Pick<Player, 'id' | 'faction' | 'revived' | 'freeForcesRevived'>,
|
||||
) {
|
||||
const currentFreeRate = freeRevivalRate(g, p);
|
||||
return recruitsRevivalAllowance(g, {
|
||||
const native = recruitsRevivalAllowance(g, {
|
||||
currentFreeRate,
|
||||
currentLimit: Math.max(forceRevivalLimit(g, p), currentFreeRate),
|
||||
revived: p.revived,
|
||||
@@ -88,6 +90,15 @@ export function normalRevivalAllowance(
|
||||
g.recruits?.turn === g.turn ? (p.freeForcesRevived ?? p.revived) : p.revived,
|
||||
freeRateCap: 20,
|
||||
});
|
||||
if (g.revivalRules?.nexusFremen?.turn !== g.turn ||
|
||||
!g.revivalRules.nexusFremen.owners.includes(p.id) ||
|
||||
g.revivalRules.freeBlocked?.includes(p.id)) return native;
|
||||
const used = p.freeForcesRevived ?? (p.revived === 0 ? 0 : undefined);
|
||||
if (used === undefined || !Number.isSafeInteger(used) || used < 0 || used > p.revived)
|
||||
throw new RangeError('The Fremen Nexus quota needs the actual prior free-return ledger.');
|
||||
const freeRate = Math.max(native.freeRate, 3);
|
||||
return { ...native, freeRate,
|
||||
freeRemaining: Math.min(native.remaining, Math.max(0, freeRate - used)) };
|
||||
}
|
||||
export function freeRevivalRemaining(
|
||||
g: RevivalRateContext,
|
||||
|
||||
Reference in new issue
Block a user