Prototype Fremen Nexus free Revival for base factions

This commit is contained in:
Quintine committed 2026-09-27 14:48:31 +10:00
1 parent 5c1c944a4f
commit 1e690b7062
10 files changed
+350 -7

No files matched your search

+10
View File
@@ -76,6 +76,7 @@ import { quoteSmugglerNoField } from './smuggler-no-field';
import { spiceBankerBattleMaximum, spiceBankerModeSupported } from './spice-banker';
import { nexusGuildSecretAllyAction, nexusGuildSecretAllyCanAct, nexusGuildSecretAllyQuote } from './nexus-guild-secret-ally-options';
import { nexusEmperorRevivalAction } from './nexus-emperor-secret-ally-options';
import { nexusFremenRevivalAction } from './nexus-fremen-revival-options';
import { nexusRicheseAction, nexusRicheseQuote } from './nexus-richese-options';
import { liveShipmentPromises, matchesShipment } from './shipment-promises';
import {
@@ -3712,6 +3713,15 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
/** Obligations apply across policy branches, including choosing to move first. */
export function botActions(g: GameView): Action[] {
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 emperorRevival = g.nexusEmperorSecretAlly?.revival;
if (emperorRevival && !emperorRevival.blocked && emperorRevival.eliteOptions.length) {
const own = g.players.find((player) => player.id === g.me);
+49
View File
@@ -33,6 +33,7 @@ import { quoteJacurutuBattleIncome } from './discovery-battle';
import { greatMakerSignature, greatMakerMajority, validateGreatMaker, type GreatMaker } from './great-maker';
import { quoteNexusChoamTrade, validateNexusChoamTrade, nexusChoamTradeSignature, type NexusChoamTrade } from './nexus-choam-trade';
import { EMPEROR_NEXUS_REVIVALS, emperorNexusEvent, emperorNexusSignature, emperorNexusModeSupported, emperorNexusPools, emperorNexusRevivalElites, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally';
import { FREMEN_NEXUS_FREE_FORCES, fremenNexusRevivalOffer, fremenNexusRevivalPools, fremenNexusRevivalSignature, validateFremenNexusRevival, type FremenNexusRevival } from './nexus-fremen-revival';
import { truthKnowledgeOf } from './truthtrance-knowledge';
import { createMoritaniAssassinateOpportunity, moritaniAssassinateTrigger, moritaniAssassinateChoices, quoteMoritaniAssassinate, moritaniAssassinateSignature, validateMoritaniAssassinate, type MoritaniAssassinateState, type MoritaniAssassinateReceipt } from './moritani-assassinate';
import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally';
@@ -1023,6 +1024,8 @@ export type Game = {
nexusChoamTradeLast?: { event: string; stage: NexusChoamTrade['stage'] };
nexusEmperorSecretHistory?: NexusEmperorRevival[];
nexusEmperorSecretEvents?: string[];
nexusFremenRevivalHistory?: FremenNexusRevival[];
nexusFremenRevivalEvents?: string[];
treacheryDiscardSequence?: number;
resolvedTreacheryDiscardSequence?: number;
/** Committed discard receipts are evidence, not additional card custody. */
@@ -1788,6 +1791,49 @@ function playNexusEmperorRevive(g:Game,p:Player,action:Action) {
log(g,`${p.name} spent Emperor Nexus Secret Ally to return three forces${elite ? `, including ${elite} Fedaykin` : ''} from Tanks to reserves for free. Ordinary force and free-revival allowances are unchanged.`,
{faction:p.faction,name:'Emperor Nexus revival'});
}
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.');
return;
}
requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0 &&
Array.isArray(events) && history.length === events.length &&
new Set(events).size === events.length,
'Fremen Nexus has lost its physical card or independent use markers.');
for (const [index, record] of history.entries()) {
nexusRule(() => validateFremenNexusRevival(g, record));
requireRule(record.event === events[index] &&
(index === 0 || record.turn >= history[index - 1].turn),
'Fremen Nexus has duplicated or reordered a completed return.');
}
const last = history.at(-1)!;
if (last.turn === g.turn)
requireRule(g.nexusCards.cards.discard.includes('fremen'),
'Fremen Nexus has reopened its already spent physical card.');
}
function playNexusFremenRevive(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);
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
addRevivedReserves(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; }
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.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' });
}
function playNexusChoamTrade(g: Game, p: Player, action: Action) {
const offer = currentNexusChoamTrade(g, p.id);
requireRule(Object.keys(action).every(key => ['type', 'event', 'card'].includes(key)) &&
@@ -14515,6 +14561,7 @@ function marketGholaIntegrity(g: Game) {
saphoAggressorIntegrity(g);
nexusCardsIntegrity(g);
nexusEmperorSecretIntegrity(g);
nexusFremenRevivalIntegrity(g);
nexusTraitorIntegrity(g);
nexusFaceDancerIntegrity(g);
nexusSuboidIntegrity(g);
@@ -21160,6 +21207,7 @@ function applyActionInner(
if (t === 'discovery') { playDiscovery(g, p, action); return g; }
if (t === 'nexusChoamTrade') { playNexusChoamTrade(g, p, action); return g; }
if (t === 'nexusEmperorRevive') { playNexusEmperorRevive(g,p,action); return g; }
if (t === 'nexusFremenRevive') { playNexusFremenRevive(g,p,action); return g; }
if (t === 'nexusMoritaniBetrayal') { playMoritaniBetrayal(g, p, action); return g; }
if (t === 'nexusAtreides') { playNexusAtreides(g, p, action); return g; }
requireRule(
@@ -25007,6 +25055,7 @@ export function viewGame(state: Game, id: string) {
nexusCards: projectedNexusCards(g, id),
nexusChoamTrade: currentNexusChoamTrade(g, id),
nexusEmperorSecretAlly: nexusEmperorSecretAllyOffer(g,id),
nexusFremenRevival: fremenNexusRevivalOffer(g,id),
nexusMoritani: nexusMoritaniOffer(g,id),
nexusMoritaniBetrayal: moritaniBetrayalOffer(g,id,
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)),
+15
View File
@@ -0,0 +1,15 @@
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 {
const offer = game.nexusFremenRevival;
const owner = game.players.find(player => player.id === game.me);
if (!offer || !owner || owner.ally || game.nexusCards?.card !== 'fremen' ||
game.players.some(player => player.faction === 'fremen') ||
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 };
}
+99
View File
@@ -0,0 +1,99 @@
import type { Game, Player } from './engine';
import { eliteRevivalRemaining, forceRevivalLimit, freeRevivalRate } 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 };
export type FremenNexusRevival = {
event: string;
owner: string;
turn: number;
phase: 4;
faction: Player['faction'];
advanced: boolean;
elite: number;
before: RevivalPools;
after: RevivalPools;
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. */
export function fremenNexusRevivalOffer(g: Game, owner: string) {
const p = g.players.find(seat => seat.id === owner);
if (!p || g.nexusCards?.cards?.hands[owner] !== 'fremen' ||
g.players.some(seat => seat.faction === 'fremen')) return null;
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 (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.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')
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 };
}
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) ||
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,
}))
throw new Error('Fremen Nexus revival lost its original physical return.');
}
+3 -2
View File
@@ -3984,7 +3984,7 @@ export const RULE_TOPICS: RuleTopic[] = [
searchText: NEXUS_CARD_REFERENCE.map(card => `${card.faction} ${card.betrayal} ${card.cunning} ${card.secretAlly}`).join(' '),
category: 'Advanced & expansions',
coverage: 'Partial',
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws and selected Atreides, Harkonnen, Tleilaxu, Ixian, Bene Gesserit, Emperor, CHOAM, Moritani, Richese and Guild effects are connected. Other effects and complete module play remain in development.',
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws and selected Atreides, Harkonnen, Tleilaxu, Ixian, Bene Gesserit, Emperor, Fremen, CHOAM, Moritani, Richese and Guild effects are connected. Other effects and complete module play remain in development.',
steps: [
'This optional module can be combined with Basic or Advanced rules independently of the factions and other expansion modules selected. Include one Nexus Card for each of the twelve factions.',
'At the end of the entire Spice Blow and Nexus phase, if a Nexus occurred and at least one alliance exists, each unallied player may draw one card or discard their held card and draw a replacement. You may decline. The two Advanced spice piles do not grant two closing draws.',
@@ -4008,6 +4008,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Bene Gesserit Secret Ally borrowed Voice remains unavailable pending its cancellation and timing interpretation. Its printed permission does not make the other unfinished effects available.',
'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 has its own connected controls; other Emperor modes remain unfinished.',
'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 first path requires base factions and Nexus alone, before any normal force returns, without combined revival effects. Partial returns, prior returns, higher free rates and the separate sandworm protection and Cunning effects remain unavailable.',
'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. Its after-victory inspection remains unfinished.',
'CHOAM Cunning spends its Nexus card to use one held Treachery Card for one chosen Worthless effect at that effect’s normal timing, in Basic and Advanced. Choose the physical card separately from the effect. Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont are supported; Kull Wahad’s Karama-prevention reaction remains unavailable.',
'One Karama response covers the CHOAM effect. Cancellation leaves the chosen Treachery Card in hand and the Nexus spent. An allowed use discards the actual card once, retaining its printed identity and applicable discard consequences; it does not create a named Worthless card or a turn-long conversion permission.',
@@ -4021,7 +4022,7 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion, temporary Emperor Sardaukar, five source-aware CHOAM Cunning effects, Moritani Cunning placement and Betrayal token return, and Richese Secret Ally reserve shipping priced as one. Borrowed Voice, other Betrayal reactions, Tleilaxu revival and remaining effects are unfinished.', 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/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/engine.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion, temporary Emperor Sardaukar, Fremen Secret Ally ordinary free-three revival in a bounded base-faction path, five source-aware CHOAM Cunning effects, Moritani Cunning placement and Betrayal token return, and Richese Secret Ally reserve shipping priced as one. Borrowed Voice, other Betrayal reactions, Tleilaxu revival, Fremen worm protection/Cunning and remaining effects are unfinished.', 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/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects with native Karama responses, Moritani Betrayal token selection, and Richese shipment pricing. Remaining effect controls are unfinished.', 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'] },
{ area: 'AI', status: 'Partial', detail: 'All profiles handle integrated effects through private offers, including Moritani Betrayal public-token selection. Complete module strategy and calibration remain 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-richese-options.ts', 'game/nexus-guild-cunning-options.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and the integrated effect families retain explicit unresolved boundaries, including fresh advisors, storm and borrowed Voice.', 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_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_GUILD_CUNNING_RUNTIME.md'] },