From 1e690b7062126e62b0cc53f65bfa85f04581b6d5 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:48:31 +1000 Subject: [PATCH] Prototype Fremen Nexus free Revival for base factions --- components/nexus-cards.tsx | 4 +- components/nexus-fremen-revival.tsx | 43 ++++++++++ docs/NEXUS_FREMEN_RULES.md | 7 +- game/bots.ts | 10 +++ game/engine.ts | 49 +++++++++++ game/nexus-fremen-revival-options.ts | 15 ++++ game/nexus-fremen-revival.ts | 99 ++++++++++++++++++++++ game/reference.ts | 5 +- tests/fixture-nexus-cards.ts | 4 +- tests/nexus-fremen-revival.test.ts | 121 +++++++++++++++++++++++++++ 10 files changed, 350 insertions(+), 7 deletions(-) create mode 100644 components/nexus-fremen-revival.tsx create mode 100644 game/nexus-fremen-revival-options.ts create mode 100644 game/nexus-fremen-revival.ts create mode 100644 tests/nexus-fremen-revival.test.ts diff --git a/components/nexus-cards.tsx b/components/nexus-cards.tsx index bf79dc84..ed01eec4 100644 --- a/components/nexus-cards.tsx +++ b/components/nexus-cards.tsx @@ -12,6 +12,7 @@ import { nexusCardAction } from '@/game/nexus-card-options'; import { NEXUS_CARD_REFERENCE, NEXUS_PANEL_NAMES, nexusCardReference } from '@/game/nexus-card-reference'; import { Button } from './ui/button'; import { NexusEmperorSecretAlly } from './nexus-emperor-secret-ally'; +import { NexusFremenRevival } from './nexus-fremen-revival'; import { Dialog, DialogClose, DialogDescription, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } from './ui/dialog'; const panels: NexusCardMode[] = ['betrayal', 'cunning', 'secretAlly']; @@ -119,7 +120,7 @@ export function NexusCards({ game, act, busy }: { game: GameView; act: (action:

Nexus cards

{offer.deckCount} in deck · {offer.discardCount} discarded · {Object.values(offer.held).filter((held) => held === true).length} held

-

Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit, Emperor, CHOAM and Moritani Cunning, plus Emperor Secret Ally’s three extra free revivals, Richese Secret Ally reserve shipping and Guild Cunning’s second shipment or absent-Guild Secret Ally shipping. Use the controls shown at each effect’s timing. CHOAM’s Kull Wahad effect, Moritani Cunning relocation and Grumman combinations, and other unfinished effects remain unavailable.

+

Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit, Emperor, CHOAM and Moritani Cunning, plus Emperor Secret Ally’s three extra free revivals, Fremen Secret Ally’s ordinary three free revivals, Richese Secret Ally reserve shipping and Guild Cunning’s second shipment or absent-Guild Secret Ally shipping. Use the controls shown at each effect’s timing. CHOAM’s Kull Wahad effect, Moritani Cunning relocation and Grumman combinations, and other unfinished effects remain unavailable.

{game.nexusAtreides && offer.card === 'atreides' && (
@@ -145,6 +146,7 @@ export function NexusCards({ game, act, busy }: { game: GameView; act: (action:
)} +
{offer.card ? :

You do not hold a Nexus card.

}
diff --git a/components/nexus-fremen-revival.tsx b/components/nexus-fremen-revival.tsx new file mode 100644 index 00000000..26c46dfe --- /dev/null +++ b/components/nexus-fremen-revival.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { useState } from 'react'; +import type { Action, GameView } from '@/game/engine'; +import { nexusFremenRevivalAction } from '@/game/nexus-fremen-revival-options'; +import { Button } from '@/components/ui/button'; + +export function NexusFremenRevival({ game, act, busy }: { + game: GameView; + act: (action: Action) => void; + busy: boolean; +}) { + const [selectedElite, setSelectedElite] = useState(null); + const offer = game.nexusFremenRevival; + if (!offer || game.phase !== 4 || game.nexusCards?.card !== 'fremen') return null; + const options = offer.eliteOptions; + const elite = options.length === 1 ? options[0] : + selectedElite !== null && options.includes(selectedElite) ? selectedElite : null; + const action = elite === null ? null : nexusFremenRevivalAction(game, elite); + return ( +
+

Fremen Secret Ally

+

Spend your Fremen Nexus card to return 3 forces from Tanks to reserves for free. This uses your ordinary three-force Revival allowance; the Advanced elite cap still applies.

+ {offer.blocked &&

{offer.blocked}

} + {options.length > 1 && ( + + )} + +
+ ); +} diff --git a/docs/NEXUS_FREMEN_RULES.md b/docs/NEXUS_FREMEN_RULES.md index ad079d9c..4e240c9a 100644 --- a/docs/NEXUS_FREMEN_RULES.md +++ b/docs/NEXUS_FREMEN_RULES.md @@ -1,6 +1,6 @@ # Fremen Nexus: worm and revival contract -Primary-source and integration audit, 10 September 2026. This document does not implement an effect or open a release gate. The [common Nexus rules](NEXUS_CARD_RULES.md) apply. No new user question was sent. +Primary-source and integration audit, 10 September 2026; narrow Secret Ally Revival prototype integrated later. This document does not certify the other two Fremen effects or open a release gate. The [common Nexus rules](NEXUS_CARD_RULES.md) apply. No new user question was sent. ## Evidence @@ -46,8 +46,11 @@ A pause offered only when the threatened player secretly holds this card would d ## Secret Ally revival The card's total-of-three wording and the native alliance's ordinary free-three benefit support a **free-rate interpretation within ordinary revival accounting**, rather than three additional counters beyond the usual allowance. This is source composition, not an explicit Nexus FAQ. Preserve it as the proposed contract; do not silently apply Emperor's separate extra-return producer. +The current first path is a deliberate subset of that proposed contract: with Fremen absent, an unallied base-faction holder in Basic or Advanced Revival may spend the physical Nexus card to transfer exactly three eligible forces from Tanks to reserves at zero spice cost. It is available before any ordinary force return, with no competing expansion/module revival modifier, pending response or decision. The three returned forces consume the ordinary allowance and free-force ledger; they are **not** Emperor-style extra returns. The Advanced one-elite-per-turn cap and physical elite census apply. The server's private offer provides the eligible elite counts; the action validates its current turn and group. A signed before/after receipt and independent event markers persist through JSON saves. -`game/revival.ts` already computes free rate/remaining from `p.revived`, typed prices and ordinary limits. Engine `beginRevival`/`finishRevival` retains pending requests and Tleilaxu prevention/income continuations. The likely narrow adapter is an explicit source-labelled, phase-bound free-rate entitlement consumed by an actual normal revival request, retaining previous returns and exact free/paid normal/starred allocation. Never refund earlier paid revivals or reset `revived`/elite counters on card play or reload. +This direct transfer is safe only because the scoped seats exclude Tleilaxu income/Axlotl and Homeworld arrival continuations. It must not be generalized to those modes without using the native pending-revival pipeline. If fewer than three forces are in Tanks, if a force return already occurred, or if a higher free rate or modifier applies, the option stays unavailable rather than guessing a ruling. Protection and Cunning remain unimplemented. + +`game/revival.ts` already computes free rate/remaining from `p.revived`, typed prices and ordinary limits. Engine `beginRevival`/`finishRevival` retains pending requests and Tleilaxu prevention/income continuations. The future combined-mode adapter should be an explicit source-labelled, phase-bound free-rate entitlement consumed by an actual normal revival request, retaining previous returns and exact free/paid normal/starred allocation. Never refund earlier paid revivals or reset `revived`/elite counters on card play or reload. Keep these questions visible: how the three-total interacts with a higher native free rate, prior paid/free returns, low-Homeworld bonus and Recruits; whether a partial group below three is permitted when Tanks cannot supply three; and whether the benefit survives La La La or Tleilaxu prevention. The [existing revival audit](FREMEN_REVIVAL_AUDIT.md) and [Homeworld benefit audit](HOMEWORLD_BENEFITS_RULES.md) do not by themselves answer differently worded Nexus composition. diff --git a/game/bots.ts b/game/bots.ts index 4b9dce5f..3193463d 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -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); diff --git a/game/engine.ts b/game/engine.ts index 449a4940..d058800c 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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)), diff --git a/game/nexus-fremen-revival-options.ts b/game/nexus-fremen-revival-options.ts new file mode 100644 index 00000000..6ec9659c --- /dev/null +++ b/game/nexus-fremen-revival-options.ts @@ -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 }; +} diff --git a/game/nexus-fremen-revival.ts b/game/nexus-fremen-revival.ts new file mode 100644 index 00000000..1f3938cd --- /dev/null +++ b/game/nexus-fremen-revival.ts @@ -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.'); +} diff --git a/game/reference.ts b/game/reference.ts index 815e2aaa..d59b45a4 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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'] }, diff --git a/tests/fixture-nexus-cards.ts b/tests/fixture-nexus-cards.ts index 2e0212c6..00009d95 100644 --- a/tests/fixture-nexus-cards.ts +++ b/tests/fixture-nexus-cards.ts @@ -23,7 +23,7 @@ export function nexusFixture( options: { seatIds?: [string, string, string]; advanced?: boolean; - hostFaction?: 'fremen' | 'moritani' | 'ecaz'; + hostFaction?: 'fremen' | 'moritani' | 'ecaz' | 'guild' | 'emperor'; } = {}, ): Game { const [f, a, h] = options.seatIds ?? ['f', 'a', 'h']; @@ -140,7 +140,7 @@ export function nexusTurnTwo( options: { seatIds?: [string, string, string]; advanced?: boolean; - hostFaction?: 'fremen' | 'moritani' | 'ecaz'; + hostFaction?: 'fremen' | 'moritani' | 'ecaz' | 'guild' | 'emperor'; } = {}, ): Game { const first = enterNexusSpice(nexusFixture(options)); diff --git a/tests/nexus-fremen-revival.test.ts b/tests/nexus-fremen-revival.test.ts new file mode 100644 index 00000000..a48bdc49 --- /dev/null +++ b/tests/nexus-fremen-revival.test.ts @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { ecazTreacheryCards } from '../game/ecaz-cards'; +import { botActions } from '../game/bots'; +import { nexusFremenRevivalAction } from '../game/nexus-fremen-revival-options'; +import { NexusFremenRevival } from '../components/nexus-fremen-revival'; +import { nexusInventory, nexusReady, nexusReload, nexusTurnTwo, orderNexusSpice, finishNexusSpice } from './fixture-nexus-cards'; + +function fixture(advanced = false, elite = 0) { + let g = nexusTurnTwo({ advanced, hostFaction: advanced ? 'emperor' : 'guild', seatIds: ['q', 'p', 'r'] }); + orderNexusSpice(g, ['worm', 'land', 'land']); + g = nexusReady(nexusReady(g)); + g = applyAction(g, 'p', { type: 'alliance', target: 'r' }); + g = applyAction(g, 'r', { type: 'alliance', target: 'p' }); + g = finishNexusSpice(g); + const cards = g.nexusCards!.cards!; + cards.deck = ['fremen', ...cards.deck.filter(card => card !== 'fremen')]; + g = applyAction(g, 'q', { type: 'nexusCardChoice', turn: g.turn, card: null, choice: 'draw', ownRedraws: 0 }); + for (const id of g.nexusCards!.phase!.eligible) + if (!g.nexusCards!.phase!.done.includes(id)) + g = applyAction(g, id, { type: 'nexusCardChoice', turn: g.turn, card: null, choice: 'keep', ownRedraws: 0 }); + g = nexusReady(g); + for (let i = 0; g.phase === 3 && i < 15; i++) { + if (g.phaseOpening) + g = applyAction(g, g.players.find(p => !g.phaseOpening!.passed.includes(p.id))!.id, { type: 'ready' }); + else if (g.auction) g = applyAction(g, g.auction.active, { type: 'passBid' }); + else g = nexusReady(g); + } + assert.equal(g.phase, 4); + assert.equal(g.decision, null); + const p = g.players.find(p => p.id === 'q')!; + assert.ok(p.reserves >= 4 && (p.elites?.reserves ?? 0) >= elite); + p.reserves -= 4; + p.tanks += 4; + if (p.elites) { p.elites.reserves -= elite; p.elites.tanks += elite; } + nexusInventory(g); + const view = viewGame(g, 'q'); + const action = nexusFremenRevivalAction(view, elite)!; + assert.ok(action); + return { g, action }; +} + +for (const advanced of [false, true]) { + void test(`Fremen Secret Ally returns an exact free ordinary group in ${advanced ? 'Advanced' : 'Basic'} play`, () => { + const { g, action } = fixture(advanced); + const before = g.players.find(p => p.id === 'q')!; + const spice = before.spice, reserves = before.reserves, tanks = before.tanks; + const observer = viewGame(g, 'r'); + assert.equal(observer.nexusFremenRevival, null); + assert.equal(observer.nexusCards?.card, null); + const result = applyAction(nexusReload(g), 'q', action); + const p = result.players.find(p => p.id === 'q')!; + assert.equal(p.reserves, reserves + 3); + assert.equal(p.tanks, tanks - 3); + assert.equal(p.spice, spice); + assert.equal(p.revived, 3); + assert.equal(p.freeForcesRevived, 3); + assert.equal(result.nexusCards!.cards!.hands.q, null); + assert.ok(result.nexusCards!.cards!.discard.includes('fremen')); + assert.equal(viewGame(result, 'q').nexusFremenRevival, null); + assert.equal(viewGame(result, 'r').nexusFremenRevival, null); + nexusInventory(result); + assert.deepEqual(nexusReload(result).nexusFremenRevivalHistory, result.nexusFremenRevivalHistory); + assert.throws(() => applyAction(nexusReload(result), 'q', action)); + }); +} + +void test('Advanced elite cap, legal bot participation and accessible control', () => { + const { g } = fixture(true, 1); + const view = viewGame(g, 'q'); + assert.deepEqual(view.nexusFremenRevival?.eliteOptions, [0, 1]); + for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const botView = structuredClone(view); + botView.players.find(p => p.id === 'q')!.bot = level; + const action = botActions(botView)[0]; + assert.equal(action?.type, 'nexusFremenRevive'); + const result = applyAction(nexusReload(g), 'q', action); + const p = result.players.find(p => p.id === 'q')!; + assert.equal(p.elites?.revived, level === 'Easy' ? 0 : 1); + assert.equal(p.revived, 3); + } + const markup = renderToStaticMarkup(createElement(NexusFremenRevival, { game: view, act: () => {}, busy: false })); + assert.match(markup, /Fremen Secret Ally revival/); + assert.match(markup, /ordinary three-force Revival allowance/); + assert.match(markup, /Elite forces to revive/); +}); + +void test('rejected stale, illegal elite and unaffordable groups preserve original input', () => { + const { g, action } = fixture(true, 1); + for (const change of [ + (copy: Game) => { copy.players.find(p => p.id === 'q')!.revived = 1; }, + (copy: Game) => { copy.players.find(p => p.id === 'q')!.tanks = 2; }, + (copy: Game) => { copy.players.find(p => p.id === 'q')!.elites!.revived = 1; }, + ]) { + const copy = nexusReload(g); + change(copy); + const before = JSON.stringify(copy); + assert.throws(() => applyAction(copy, 'q', action)); + assert.equal(JSON.stringify(copy), before); + } + for (const invalid of [{ ...action, elite: 2 }, { ...action, event: 'old' }, { ...action, bonus: true }]) { + const copy = nexusReload(g); + const before = JSON.stringify(copy); + assert.throws(() => applyAction(copy, 'q', invalid)); + assert.equal(JSON.stringify(copy), before); + } +}); + +void test('independent Ecaz Treachery inventory cannot borrow the bounded revival path', () => { + const { g, action } = fixture(); + g.ecazTreachery = true; + g.deck.push(...ecazTreacheryCards()); + const offer = viewGame(g, 'q').nexusFremenRevival; + assert.match(offer?.blocked ?? '', /Combined expansion and module/); + const before = JSON.stringify(g); + assert.throws(() => applyAction(g, 'q', action)); + assert.equal(JSON.stringify(g), before); +});