Connect Emperor Nexus extra revival with private saved continuation

Add the absent-Emperor Secret Ally alternative returning exactly three eligible forces free in Basic/Advanced base-faction Nexus games. Preserve ordinary force/free-revival allowances and the shared Advanced Fedaykin cap. Keep the action optional and private, consume the physical Nexus once, and validate durable source receipts through later ordinary revival and Ghola. Purchase timing remains pending the earlier uniform winner-payment UX question; partial returns and combined-module interactions remain guarded. No mode or publication gate opens.

Connect private controls, explicit physical elite selection, all four legal AI profiles and a dedicated rules checklist entry. Extend the reusable local prototype writer with genuine twelve-card Nexus setup. Independent review covers rules timing, private projection, physical custody, JSON and production SQLite restore, lost-response replay and competing requests. Seventeen focused effect cases and four prototype-writer cases pass.

Validation: typecheck, lint, 4649 offline tests, production build and 43 HTTP tests passed with unchanged source. Genuine-start Basic and Advanced samples completed in 715 and 263 actions with 19 and 7 periodic JSON restores, respectively, and no Emperor uses. Two explicitly staged continuations completed in 18 and 17 actions with one normal-force Emperor return each. All four had zero rejections and preserved exact Treachery, force, elite and Nexus custody; all fourteen final seat views restored unchanged. They are bounded continuation evidence, not natural full-game acceptance of the effect or AI calibration.

In the conserved staged browser scenario, Advanced Fremen returned three forces including one Fedaykin using the Nexus, then three normal forces using the ordinary allowance. Reserves rose from three to nine, Tanks fell from seven to one, spice stayed three and the physical Nexus was spent once. The inspector was readable, the action notice displayed, and pending/resolved refresh restoration, physical custody and private projection passed.

After the reported power outage, all 733 opening games and original seat/recovery records were preserved (767 after HTTP checks); database integrity and foreign keys passed. All 734 rooms immediately before broad verification remained unchanged. Reused the healthy server without restart, reset or recurring automation.

Verified source tree: fd09a4bc71018cb7ef98c7e25c25dacc495ffed304dbd0cdfa223e539e8fa5e1
This commit is contained in:
Quintine committed 2026-09-14 01:05:11 +10:00
1 parent d0dc7007d9
commit 8e1cd7b75b
20 files changed
+1332 -17

No files matched your search

+3 -1
View File
@@ -11,6 +11,7 @@ import type { NexusCardChoice } from '@/game/nexus-card-phase';
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 { Dialog, DialogClose, DialogDescription, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } from './ui/dialog';
const panels: NexusCardMode[] = ['betrayal', 'cunning', 'secretAlly'];
@@ -118,7 +119,7 @@ export function NexusCards({ game, act, busy }: { game: GameView; act: (action:
<div>
<h3>Nexus cards</h3>
<p className="fine">{offer.deckCount} in deck · {offer.discardCount} discarded · {Object.values(offer.held).filter((held) => held === true).length} held</p>
<p>Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit, Emperor, CHOAM and Moritani Cunning, plus 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.</p>
<p>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.</p>
</div>
{game.nexusAtreides && offer.card === 'atreides' && (
<div aria-label="Atreides Nexus effect" className="space-y-2">
@@ -143,6 +144,7 @@ export function NexusCards({ game, act, busy }: { game: GameView; act: (action:
)}
</div>
)}
<NexusEmperorSecretAlly game={game} act={act} busy={busy} />
<div aria-label="Your private Nexus card" className="max-w-xl">
{offer.card ? <NexusCardFace card={offer.card} mode={mode} /> : <p>You do not hold a Nexus card.</p>}
</div>
+82
View File
@@ -0,0 +1,82 @@
'use client';
import { useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import { nexusEmperorRevivalAction } from '@/game/nexus-emperor-secret-ally-options';
import { Button } from '@/components/ui/button';
export function NexusEmperorSecretAlly({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const [selectedElite, setSelectedElite] = useState<number | null>(null);
const offer = game.nexusEmperorSecretAlly;
const owner = game.players.find((player) => player.id === game.me);
const emperorSeated = game.players.some((player) => player.faction === 'emperor');
const eligible =
offer?.revival &&
game.nexusCards?.card === 'emperor' &&
!!owner &&
!owner.ally &&
!emperorSeated &&
game.status === 'playing' &&
game.phase === 4 &&
!game.response &&
!game.decision &&
!game.truthtrance &&
!game.phaseOpening &&
!game.automaticContinuationPending &&
game.nexusCards.waiting.length === 0 &&
!game.nexusTraitors?.pending;
if (!eligible) return null;
const options = offer.revival.eliteOptions;
const elite = options.length === 1
? options[0]
: selectedElite !== null && options.includes(selectedElite)
? selectedElite
: null;
const action = elite === null ? null : nexusEmperorRevivalAction(game, elite);
const blocked = busy || !action;
return (
<section className="min-w-0 space-y-3" aria-label="Emperor Secret Ally revival">
<h3 className="font-serif text-xl">Emperor Secret Ally</h3>
<p className="text-base leading-7">
Spend the Emperor Nexus card to revive 3 additional forces for free,
beyond your ordinary revival quota. The elite cap still applies.
</p>
{offer.revival.blocked && <p className="notice">{offer.revival.blocked}</p>}
{options.length > 1 && (
<label className="block space-y-2">
<span className="font-semibold">Elite forces to revive</span>
<select
aria-label="Elite forces to revive"
className="min-h-11 w-full rounded border border-[#65644b] bg-[#171b17] px-3 py-2"
value={elite ?? ''}
disabled={busy || !!offer.revival.blocked}
onChange={(event) => setSelectedElite(event.currentTarget.value === '' ? null : Number(event.currentTarget.value))}
>
<option value="">Choose an elite count</option>
{options.map((count) => (
<option key={count} value={count}>{count}</option>
))}
</select>
</label>
)}
<Button
className="min-h-11 whitespace-normal"
disabled={blocked}
onClick={() => {
if (!blocked && action) act(action);
}}
>
Revive 3 additional forces{elite === null ? '' : ` · ${elite} elite`}
</Button>
</section>
);
}
+22 -8
View File
@@ -6,19 +6,18 @@ gated.** Prototypes do not open completion or publication gates.
## Current checkpoint and work
Latest verified checkpoint: `9f1f783`, private Mentat question preview.
Types, lint, **4,605 offline tests**, build and **43 HTTP tests** passed.
All 665 opening rooms and original seat/recovery records were preserved
(699 after HTTP checks). The existing server was reused after the outage;
Ecaz, Ixian and Mentat browser seats restored their private state and decisions.
The only post-verification edit removed an extra newline in a developer document.
Previous verified checkpoint: `d0dc700`, Bureaucrat payment redirection.
Types, lint, **4,631 offline tests**, build and **43 HTTP tests** passed.
All 699 opening rooms and original seat/recovery records were preserved
(733 after HTTP checks). All 700 rooms present immediately before verification
also remained unchanged. The healthy server was reused after the outage.
[Mentat](MENTAT_QUESTION.md) connects private naming/disclosure before posture
and faction powers, all four AI profiles and saved continuation in explicitly
opted-in previews. Two controlled-shuffle genuine-setup samples finish. Ordinary
activation still awaits the uniform private response UX decision.
The current milestone connects [Bureaucrat payments](BUREAUCRAT_PAYMENTS.md):
[Bureaucrat payments](BUREAUCRAT_PAYMENTS.md) connect
ordinary auctions, shipment income, Richese sale proceeds and actual bribe
transfers, with a once-per-phase optional redirection and preserved continuation.
Qualifying allied funding, other payment families and combined-module acceptance
@@ -29,6 +28,21 @@ All 699 opening rooms and original seat/recovery records survived the reported
outage. The healthy server was reused. The completed checkpoint's Git record
contains final broad verification and post-HTTP preservation totals.
Current checkpoint: [Emperor Nexus Secret Ally revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md).
Exactly three free returns, independent ordinary allowances, shared Fedaykin caps,
private controls, four AI profiles and saved continuation are connected. Independent
review and 17 focused effect tests pass. Two genuine-start and two explicitly staged
samples finish with zero rejections; only the staged pair uses this effect. A staged
browser exercise verifies a Fedaykin return, three ordinary returns and refresh.
Final broad checks, preservation counts and Git delivery are recorded in the
checkpoint commit and private report before handoff.
The purchase alternative still needs a private post-win payment choice. The earlier
question asks whether to allow a uniform winner payment step, including otherwise
forced spice payments. No answer or purchase policy is assumed. All 733 opening
rooms and seat/recovery records survived the latest outage unchanged; a new snapshot
protects all 734 rooms before final verification. The healthy server is reused.
## Authoritative navigation
- [Rules checklist](../game/reference.ts), topic `implementation-checklist` and
@@ -56,7 +70,7 @@ These links define bounded working behavior, not complete module certification.
| Expansion factions | [Ixians/Tleilaxu](IX_PROTOTYPE.md) and [all seven faction selections](EXPANSION_FACTIONS_PROTOTYPE.md): genuine setup, Ecaz six-force placement, separate optional card variants, private controls and saved per-lot Ixian/Richese decline. Actual special-lot exchange remains pending. |
| Leader Skills | [Common lifecycle](LEADER_SKILLS_RUNTIME.md), [known skilled capture](LEADER_SKILLS_CAPTURE.md), [Mentat question preview](MENTAT_QUESTION.md), [Bureaucrat payments](BUREAUCRAT_PAYMENTS.md), five battle disciplines, [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md), [Rihani and other battle effects](LEADER_BATTLE_EFFECTS.md), [Smuggler shipment](SMUGGLER_SHIPMENT.md) and [No-Field](SMUGGLER_NO_FIELD.md), [Sandmaster routes](SANDMASTER_MOVEMENT.md), [Banker spending](SPICE_BANKER_RUNTIME.md), [Diplomat defense](DIPLOMAT_DEFENSE.md). Remaining bands and combinations are explicit in those contracts. |
| Discoveries | [Prototype](DISCOVERY_PROTOTYPE.md): genuine setup, Great Maker, seven cards/eight tokens, inspection and stash rewards, nested sites, signed later free entry, carried Ornithopter, sole Cistern, bounded Jacurutu income, Testing Station and Shrine. Orgiz and contested/mixed cases remain pending. |
| Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Collection trade](NEXUS_CHOAM_SECRET_ALLY.md) is connected; its after-victory inspection remains pending. A source audit or helper alone is not a completed effect. |
| Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Collection trade](NEXUS_CHOAM_SECRET_ALLY.md) and [Emperor extra revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) are connected; CHOAM inspection and Emperor purchase remain pending. A source audit or helper alone is not a completed effect. |
| Truthtrance | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card counts](TRUTHTRANCE_CARD_COUNT.md), [hand inventory](TRUTHTRANCE_HAND_INVENTORY.md), and [Basic/no-Guild Advanced reserve-shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md) use authoritative private state. Earlier readiness audits do not supersede these follow-ups. |
| Other cards | [Sapho runtime](JUICE_OF_SAPHO_RUNTIME.md) includes first among remaining unstarted movement turns after Advanced Guild has finished. Richese card contracts and the checklist identify the other connected effects and explicit gaps. |
| Multiplayer | Saved rooms/seats, [recovery](../README.md#saved-seats-and-reconnecting), uncertain-request retry, [own-seat AI](AUTOPILOT.md), and [voluntary seat handover](SEAT_HANDOVER.md) have connected controls and recovery evidence. |
+27
View File
@@ -1,5 +1,32 @@
# Dune implementation status
## 14 September 2026 — Emperor Nexus revival prototype and outage recovery
[Emperor Secret Ally](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) now connects exactly
three additional free force returns in Basic/Advanced base-faction Nexus games.
It preserves ordinary force/free allowances, shared Advanced Fedaykin caps and
physical card custody, without a public pause or forced pass. Private legal
controls, four AI profiles, JSON continuation and production SQLite recovery are
connected. Independent review validates phase-wide timing and private receipts;
17 focused effect tests cover illegal/stale/competing requests and later Ghola.
The separate purchase alternative remains pending its uniform payment UX question.
Two genuine-start samples finish (Basic 715 actions/19 periodic JSON rounds;
Advanced 263/7), with no Emperor use. Two explicitly staged continuations finish
in 18 and 17 actions with one normal-force return each. All candidates are legal;
exact Treachery, force, elite and Nexus custody holds, and all 14 final seat views
restore unchanged. These samples retain their setup and coverage limitations.
The separate staged browser game exercises a Fedaykin return, then three ordinary
revivals, unchanged spice, readable inspection and refresh recovery. The reusable
local prototype writer adds genuine Nexus setup without opening public starts.
All 733 opening games and original seat/recovery records survived the reported
power failure unchanged. Integrity/foreign keys pass and the healthy server is
reused. A new snapshot protects all 734 rooms before final checks. The verified
commit and private report record required broad checks, source fingerprint,
post-HTTP preservation and Git delivery. Full module and publication gates remain.
## 14 September 2026 — Bureaucrat payment prototype and outage preservation
[Bureaucrat](BUREAUCRAT_PAYMENTS.md) now connects optional two-spice redirection
+2
View File
@@ -1,5 +1,7 @@
# Nexus cards: common lifecycle checkpoint
The later [Emperor Secret Ally revival prototype](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) adds exactly three additional free force returns, separate ordinary allowances, private controls and saved continuation. Its purchase alternative and full module acceptance remain pending.
**Later Guild Secret Ally runtime:** [NEXUS_GUILD_SECRET_ALLY_RUNTIME.md](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) records explicitly selected Guild prices, cross/return routes, typed Homeworld sources, paid Fremen reinforcement and committed shipment history. The card changes one shipment, without creating a native Guild or a real alliance. The Homeworld-return ruling and full module completion remain pending.
The later [Richese Secret Ally checkpoint](NEXUS_RICHESE_RUNTIME.md) adds an ordinary physical reserve shipment priced as one force; its other effects and complete module acceptance remain gated.
+97
View File
@@ -0,0 +1,97 @@
# Emperor Nexus Secret Ally revival
14 September 2026. **Prototyped**, with **Partial** rules coverage. This connects
the separate three-force revival alternative in Basic and Advanced base-faction
games with Nexus alone. Public module starts and publication remain gated.
## Rules and connected behavior
The printed Emperor Nexus has an alternative Secret Ally grant during Revival:
three additional forces return free, beyond ordinary revival limits. Emperor
must be absent and the holder unallied. The grant does not revive leaders, spend
spice, or consume ordinary force/free-revival allowances. It can precede or follow
ordinary returns while Revival remains open. Submitting the interface's ready
action does not itself end that phase or invent a new card deadline.
[Printed cards](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani),
[source audit](NEXUS_EMPEROR_RULES.md#secret-ally-three-additional-free-revivals)
The ordinary Advanced one-per-turn special-force revival cap remains in force.
For this roster, that means Fedaykin. The holder chooses among legal normal/elite
groups totaling exactly three; one printed counter cannot become two forces.
Fewer than three eligible counters remains a recorded source gap, not an implicit
up-to-three option. Recruits, Tleilaxu income/prevention, CHOAM suppression,
Homeworld deployment and other combined-module producers remain outside this
batch. A different player's ordinary Fremen ally grant does not block the holder.
[November FAQ, p. 2](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=2)
The engine consumes the physical Nexus and moves the exact force group in one
action. A private receipt records the original pools, mode and conserved delta;
independent event markers reject missing, duplicated or reordered history.
Historical pools are not compared with all later current pools: ordinary revivals,
Ghola and later battles can legitimately change them. The current card and event
must still authorize a fresh use. Saved views never expose that receipt or rival
hands/spice. The private offer creates no table-wide decision or forced pass.
All four AI profiles use the same projected legal choices. The UI supplies a
physical elite selector only when it offers a real choice, shows unavailability
reasons, and disables submission while busy. The existing Nexus inspector retains
all three printed panels. This is legal functional support, not calibrated strategy.
## Purchase alternative: pending interface decision
The printed purchase option preserves the actual buyer's spice after proving
the price is held. It supplies a choice when buying. A pause only for the hidden
holder would disclose eligibility; a per-bid instruction would commit earlier,
before opponents finish passing or bidding. Neither is silently substituted.
The user has been asked whether to permit a uniform winner payment step,
including otherwise forced spice payments, as an exception to the preference
against forced confirmations. The answer is pending. The purchase effect is
unimplemented; its source-specific seller-payment and non-Bidding boundaries also
remain in the [source contract](NEXUS_EMPEROR_RULES.md#secret-ally-retain-the-actual-purchase-price).
## Prototype entry and evidence
A fresh ready local lobby can use the existing reusable writer:
```sh
node --import tsx tools/start-prototype.ts --profile nexus \
--db PATH_TO_LOCAL_D1_SQLITE --room EIGHT_CHARACTER_CODE \
--version CURRENT_LOBBY_VERSION --out /private/new-prototype-checkpoint
```
It enables the real twelve-card Nexus setup, backs up saved games, compares the
original room/version atomically, preserves seats and other rooms, and rejects
redealing a started game. The Nexus is drawn through the ordinary closing-phase
opportunity; the writer does not deal a chosen card directly to a player.
Focused tests exercise ordinary-first and Nexus-first returns, zero-spice use,
Fedaykin caps, later Ghola, native/Betrayal exclusion, private controls and bots,
JSON recovery, corruption, SQLite restart, lost-response replay and competing
normal/elite choices. The fixture executes the real setup, alliance and Nexus
draw actions but controls starting hands, deck order and the later loss topology.
It is a targeted scenario, not an untouched complete game.
Independent source/privacy review and 17 focused effect tests pass; four prototype
writer tests also pass. Two genuine-start samples with all four AI profiles finish:
Basic 715 actions/19 periodic JSON restores and Advanced 263/7, both with zero
rejected candidates and no Emperor use. Two explicitly staged continuations finish
in 18 Basic and 17 Advanced actions, with one normal-force Nexus return each and no
rejections. All samples preserve exact Treachery, force, elite and Nexus custody;
all 14 final seat views also survive restoration. These are bounded continuation
checks, not natural full-game acceptance of the Emperor effect or AI calibration.
A separate staged Advanced Fremen browser game spends the Nexus on three forces,
including one Fedaykin, then revives three ordinary normal forces. Reserves rise
from three to nine, Tanks fall from seven to one, spice remains three and the
ordinary free allowance is used only by the second return. One Fedaykin remains
in Tanks under the shared elite cap; the card is discarded exactly once. The full
inspector is readable, the action notice appears, and pending/resolved states
restore through refresh. Private projections and unchanged physical totals pass.
After the power outage, all 733 opening rooms and original seat/recovery records
match the private backup; database integrity and foreign keys pass. The healthy
server is reused without restart. A separate pre-verification snapshot protects
all 734 rooms, including the new QA game. Required broad results, final
preservation totals, source fingerprint and push confirmation are recorded in the
checkpoint commit and private report.
+1
View File
@@ -52,6 +52,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Jacurutu physical dial | The user question asks whether the losing player should choose the physical normal/elite forces represented by its dial. When legal Advanced allocations produce different rewards, income remains explicitly unpaid; numeric wheel subtraction is not a ruling. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| Testing Station contest/timing | Only sole non-advisor occupation is prototyped. Two-occupant priority/stacking remains unresolved; opening after the ordinary storm-card window is an explicit source-timing inference. Weather Control is excluded by the printed text. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| CHOAM Secret Ally battle timing | The printed after-victory inspection is optional and the Nexus identity stays secret. No existing postbattle choice covers every winner: a pause only for a holder leaks ownership, precommitment removes the after-win choice, and a universal window makes nonholders pass. The product-exception question has been sent; no timing policy is implemented pending the answer. Ordinary Karama cancellation is also not established by the retrieved sources. | [After-victory inspection audit](NEXUS_CHOAM_SECRET_ALLY.md#after-victory-inspection-audit) |
| Emperor Secret Ally purchase timing | The purchase choice follows winning the bid. A holder-only payment pause exposes concealed card eligibility, while per-bid preauthorization moves the choice earlier. The user has been asked whether to permit a uniform winner payment confirmation, including sole spice payments, or retain the purchase gate. No answer is assumed; the independent three-force revival grant can proceed. | [Purchase source contract](NEXUS_EMPEROR_RULES.md#secret-ally-retain-the-actual-purchase-price) |
| Discovery prototype inferences | Free entry uses storm order and ordinary source-storm restrictions; these are general-rule inferences. Great Maker currently follows ordinary first-turn worm skipping and Sandtrout suppression; an exhausted token type creates no replacement token. These preserve current source interpretation and physical inventory but are not separately confirmed rulings or complete combined-module certification. | [Interpretations for refinement](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| Other Homeworld combinations | Population/force splitting, removal custody, replacement shortages, income accounting and placement ordering remain local to their detailed contracts. | [Homeworld rules](HOMEWORLD_RULES.md), [occupation](HOMEWORLD_OCCUPATION_RULES.md) and the effect documents above |
+10
View File
@@ -70,6 +70,7 @@ import { quoteSmugglerShipment } from './smuggler-shipment';
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 { nexusRicheseAction, nexusRicheseQuote } from './nexus-richese-options';
import { liveShipmentPromises, matchesShipment } from './shipment-promises';
import {
@@ -3659,6 +3660,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 emperorRevival = g.nexusEmperorSecretAlly?.revival;
if (emperorRevival && !emperorRevival.blocked && emperorRevival.eliteOptions.length) {
const own = g.players.find((player) => player.id === g.me);
const elite = own?.bot === 'Easy'
? Math.min(...emperorRevival.eliteOptions)
: Math.max(...emperorRevival.eliteOptions);
const action = nexusEmperorRevivalAction(g, elite);
if (action) return [action];
}
const bureaucrat = g.bureaucrat?.pending;
if (bureaucrat) {
if (bureaucrat.owner !== g.me || g.decision?.kind !== 'bureaucratPayment' ||
+60
View File
@@ -26,6 +26,7 @@ import { createDiscoveryState, validateDiscoveryState, placeDiscovery, rememberD
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 { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally';
import { shipmentAvailable } from './shipment-opportunity';
import { createNexusGuildCunning, validateNexusGuildCunning, nexusGuildCunningMoves, type NexusGuildCunningReceipt } from './nexus-guild-cunning';
@@ -975,6 +976,8 @@ export type Game = {
greatMaker?: GreatMaker;
nexusChoamTrades?: NexusChoamTrade[];
nexusChoamTradeLast?: { event: string; stage: NexusChoamTrade['stage'] };
nexusEmperorSecretHistory?: NexusEmperorRevival[];
nexusEmperorSecretEvents?: string[];
treacheryDiscardSequence?: number;
resolvedTreacheryDiscardSequence?: number;
/** Committed discard receipts are evidence, not additional card custody. */
@@ -1647,6 +1650,60 @@ function currentNexusChoamTrade(g: Game, owner: string) {
return nexusRule(() => quoteNexusChoamTrade(g, owner,
g.phase === 7 && grummanCollectionAutomatic(g)));
}
function nexusEmperorSecretAllyOffer(g:Game,owner:string) {
const p = g.players.find(p => p.id === owner);
if (!p || g.nexusCards?.cards?.hands[owner] !== 'emperor' || byFaction(g,'emperor')) return null;
const event = emperorNexusEvent(g.turn,g.phase,owner,g.nexusEmperorSecretHistory?.length ?? 0);
let blocked:string|null = null;
if (p.ally) blocked = 'Emperor Nexus Secret Ally requires an unallied holder.';
else if (!emperorNexusModeSupported(g)) blocked = 'Emperor Nexus Secret Ally currently requires base factions and Nexus alone; expansion and other module combinations remain pending.';
else if (g.status !== 'playing' || g.phase !== 4) blocked = 'Use the three-force Emperor Nexus return 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.pendingRichesePurchaseIncome || g.pendingAmbassador || g.pendingCapture || g.battle ||
g.nexusTraitorPending || g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the current interaction before using Emperor Nexus revival.';
else if ((g.revivalPrevention?.turn === g.turn && g.revivalPrevention.player === owner) || g.revivalRules?.freeBlocked?.includes(owner) ||
g.revivalRules?.expanded.includes(owner) || g.freeRevival.includes(owner))
blocked = 'Emperor Nexus revival with free-revival suppression or expanded revival effects remains pending.';
const eliteOptions = emperorNexusRevivalElites(p,g.advanced);
if (!blocked && !eliteOptions.length) blocked = 'Emperor Nexus needs exactly three eligible forces in Tanks; a smaller return remains pending.';
return {event,revival:{blocked,eliteOptions},purchase:null};
}
function nexusEmperorSecretIntegrity(g:Game) {
const history = g.nexusEmperorSecretHistory, events = g.nexusEmperorSecretEvents;
if (history === undefined) {
requireRule(events === undefined,'Emperor Nexus has lost its saved use history.'); return;
}
requireRule(g.nexusCards?.cards && emperorNexusModeSupported(g) && Array.isArray(history) && history.length > 0 &&
Array.isArray(events) && events.length === history.length && new Set(events).size === events.length,
'Emperor Nexus has lost its physical module or independent use markers.');
for (const [index,record] of history.entries()) {
nexusRule(() => validateEmperorNexusRevival(g,record));
requireRule(record.sequence === index && events[index] === record.event,
'Emperor Nexus has duplicated or reordered a completed use.');
}
const last = history.at(-1)!;
if (last.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('emperor'),
'Emperor Nexus has reopened its already spent physical card.');
}
function playNexusEmperorRevive(g:Game,p:Player,action:Action) {
const offer = nexusEmperorSecretAllyOffer(g,p.id);
requireRule(offer && !offer.revival.blocked && action.event === offer.event &&
Object.keys(action).sort().join(',') === 'elite,event,type' && offer.revival.eliteOptions.includes(action.elite as number),
offer?.revival.blocked ?? 'Choose the current Emperor Nexus revival and an eligible exact three-force group.');
const elite = action.elite as number, before = emperorNexusPools(p);
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players));
addRevivedReserves(g,p,EMPEROR_NEXUS_REVIVALS,elite); p.tanks -= EMPEROR_NEXUS_REVIVALS;
if (p.elites) {p.elites.tanks -= elite;p.elites.revived += elite;}
const record:NexusEmperorRevival = {kind:'revival',event:offer.event,owner:p.id,turn:g.turn,phase:4,
faction:p.faction,advanced:g.advanced,
sequence:g.nexusEmperorSecretHistory?.length ?? 0,elite,before,after:emperorNexusPools(p),signature:''};
record.signature = emperorNexusSignature(record);
(g.nexusEmperorSecretHistory ??= []).push(record);
(g.nexusEmperorSecretEvents ??= []).push(record.event);
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 playNexusChoamTrade(g: Game, p: Player, action: Action) {
const offer = currentNexusChoamTrade(g, p.id);
requireRule(Object.keys(action).every(key => ['type', 'event', 'card'].includes(key)) &&
@@ -13593,6 +13650,7 @@ function gholaOptions(g: Game, p: Player) {
}
function marketGholaIntegrity(g: Game) {
nexusCardsIntegrity(g);
nexusEmperorSecretIntegrity(g);
nexusTraitorIntegrity(g);
nexusFaceDancerIntegrity(g);
nexusSuboidIntegrity(g);
@@ -19972,6 +20030,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 === 'nexusAtreides') { playNexusAtreides(g, p, action); return g; }
requireRule(
!(
@@ -23646,6 +23705,7 @@ export function viewGame(state: Game, id: string) {
greatMaker: g.greatMaker ? { event:g.greatMaker.event, turn:g.greatMaker.turn, stage:g.greatMaker.stage, votes:structuredClone(g.greatMaker.votes), order:[...g.greatMaker.order], ride:greatMakerRideOptions(g,id) } : null,
nexusCards: projectedNexusCards(g, id),
nexusChoamTrade: currentNexusChoamTrade(g, id),
nexusEmperorSecretAlly: nexusEmperorSecretAllyOffer(g,id),
nexusMoritani: nexusMoritaniOffer(g,id),
nexusRichese: nexusRicheseOffer(g,id),
nexusGuildSecretAlly: nexusGuildSecretAllyOffer(g,id),
+31
View File
@@ -0,0 +1,31 @@
import type { Action, GameView } from './engine';
/** Bind the current private offer without inspecting another player's cards. */
export function nexusEmperorRevivalAction(
game: GameView,
elite: number,
): Action | null {
const offer = game.nexusEmperorSecretAlly;
const owner = game.players.find((player) => player.id === game.me);
if (
!offer ||
!owner ||
owner.ally ||
game.nexusCards?.card !== 'emperor' ||
game.players.some((player) => player.faction === 'emperor') ||
game.status !== 'playing' ||
game.phase !== 4 ||
game.response ||
game.decision ||
game.truthtrance ||
game.phaseOpening ||
game.automaticContinuationPending ||
game.nexusCards.waiting.length ||
game.nexusTraitors?.pending ||
offer.revival.blocked ||
!Number.isSafeInteger(elite) ||
!offer.revival.eliteOptions.includes(elite)
)
return null;
return { type: 'nexusEmperorRevive', event: offer.event, elite };
}
+147
View File
@@ -0,0 +1,147 @@
import type { Game, Player } from './engine';
import { eliteRevivalRemaining } from './revival';
export const EMPEROR_NEXUS_REVIVALS = 3;
export type EmperorNexusPools = {
reserves: number;
tanks: number;
eliteReserves: number;
eliteTanks: number;
eliteRevived: number;
revived: number;
freeForcesRevived: number | null;
spice: number;
};
export type NexusEmperorRevival = {
kind: 'revival';
event: string;
owner: string;
turn: number;
phase: 4;
sequence: number;
faction: Player['faction'];
advanced: boolean;
elite: number;
before: EmperorNexusPools;
after: EmperorNexusPools;
signature: string;
};
export function emperorNexusPools(p: Player): EmperorNexusPools {
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 ?? null,
spice: p.spice,
};
}
export function emperorNexusEvent(
turn: number,
phase: number,
owner: string,
sequence: number,
): string {
return JSON.stringify([
'nexusEmperorSecretAlly',
turn,
phase,
owner,
sequence,
]);
}
export function emperorNexusSignature(record: object): string {
return JSON.stringify({ ...record, signature: undefined });
}
export function emperorNexusModeSupported(g: Game): boolean {
return (
!g.expansions.length &&
g.players.every((p) =>
['atreides', 'harkonnen', 'fremen', 'guild', 'beneGesserit'].includes(
p.faction,
),
) &&
!g.homeworlds &&
!g.leaderSkills &&
!g.discoveryEnabled &&
!g.discoveries &&
!g.discoveryStash &&
!g.greatMaker &&
!g.techTokens &&
!g.strongholdCards
);
}
/** A fixed three-counter grant preserves the separate Advanced elite cap. */
export function emperorNexusRevivalElites(
p: Player,
advanced: boolean,
): number[] {
const choices: number[] = [];
for (let elite = 0; elite <= 3; elite++)
if (
elite <= eliteRevivalRemaining(p, advanced) &&
elite <= (p.elites?.tanks ?? 0) &&
3 - elite <= p.tanks - (p.elites?.tanks ?? 0)
)
choices.push(elite);
return choices;
}
export function validateEmperorNexusRevival(
g: Game,
r: NexusEmperorRevival,
): void {
const p = r && g.players.find((p) => p.id === r.owner);
const validPools = (pool: EmperorNexusPools) =>
pool &&
Object.keys(pool).sort().join(',') ===
'eliteReserves,eliteRevived,eliteTanks,freeForcesRevived,reserves,revived,spice,tanks' &&
Object.entries(pool).every(
([key, n]) =>
(key === 'freeForcesRevived' && n === null) ||
(Number.isSafeInteger(n) && n! >= 0),
) &&
pool.eliteReserves <= pool.reserves &&
pool.eliteTanks <= pool.tanks &&
pool.reserves + pool.tanks <= 20;
if (
!p ||
Object.keys(r).sort().join(',') !==
'advanced,after,before,elite,event,faction,kind,owner,phase,sequence,signature,turn' ||
r.kind !== 'revival' ||
r.phase !== 4 ||
!Number.isSafeInteger(r.turn) ||
r.turn < 1 ||
r.turn > g.turn ||
r.faction !== p.faction ||
r.advanced !== g.advanced ||
!Number.isSafeInteger(r.sequence) ||
r.sequence < 0 ||
!Number.isSafeInteger(r.elite) ||
r.elite < 0 ||
r.elite > 3 ||
r.event !== emperorNexusEvent(r.turn, 4, r.owner, r.sequence) ||
r.signature !== emperorNexusSignature(r) ||
!validPools(r.before) ||
!validPools(r.after) ||
r.before.tanks < 3 ||
r.elite > r.before.eliteTanks ||
3 - r.elite > r.before.tanks - r.before.eliteTanks ||
(g.advanced && r.before.eliteRevived + r.elite > 1) ||
(p.faction !== 'fremen' &&
(r.elite || r.before.eliteTanks || r.before.eliteReserves)) ||
JSON.stringify(r.after) !==
JSON.stringify({
...r.before,
reserves: r.before.reserves + 3,
tanks: r.before.tanks - 3,
eliteReserves: r.before.eliteReserves + r.elite,
eliteTanks: r.before.eliteTanks - r.elite,
eliteRevived: r.before.eliteRevived + r.elite,
})
)
throw new Error(
'Emperor Nexus revival has lost its exact original physical return or allowance.',
);
}
+24 -1
View File
@@ -3508,6 +3508,29 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: 'Focused rules, private controls, AI, saved continuation and competing SQLite actions. Complete family acceptance remains open.', evidence: ['tests/nexus-choam-trade.test.ts', 'tests/nexus-choam-trade-recovery.test.ts'] },
],
},
{
id: 'nexus-emperor-secret-ally',
title: 'Emperor Nexus Secret Ally: extra revivals',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'When Emperor is absent, an unallied holder can spend its Nexus card during Revival to return exactly three additional forces free. The purchase alternative and combined-module acceptance remain unfinished.',
steps: [
'Use the private Emperor Secret Ally controls during Revival. Select the physical elite count when more than one legal group is available. The card returns exactly three eligible forces from Tanks to reserves and is discarded once.',
'These returns cost no spice and do not consume ordinary force-revival or free-revival allowances. You may use the card before or after ordinary revivals, while Revival remains open.',
'The Advanced one-per-turn Fedaykin limit still applies across ordinary revivals and this card. The extra total allowance does not permit another elite revival. Fewer than three eligible counters is an unresolved case and remains unavailable.',
'This prototype supports Basic and Advanced base factions with Nexus alone. Other expansion, Homeworld, suppression and combined-module effects retain their separate boundaries. Keeping the card creates no public decision or extra confirmation.',
'The purchase alternative is not yet playable. It requires a choice when buying without revealing concealed card eligibility; its uniform winner-payment interaction awaits a product decision.',
],
related: ['nexus-cards', 'revival'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Connected fixed three-force return with preserved ordinary allowances, physical elite limits and saved card-use receipts. Purchase and combined producers remain unfinished.', evidence: ['game/nexus-emperor-secret-ally.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Private optional revival controls, legal elite selection, unavailable reasons and readable card reference.', evidence: ['components/nexus-emperor-secret-ally.tsx', 'components/nexus-cards.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles use the same projected legal return; no rival hand or spice is consulted. Complete Nexus strategy remains uncalibrated.', evidence: ['game/nexus-emperor-secret-ally-options.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Printed grant, separate allowances, elite cap and purchase timing boundary are explicit.', evidence: ['docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused rules, controls, bots, JSON and SQLite restoration with stale, duplicate and concurrent actions. Broader module acceptance remains open.', evidence: ['tests/nexus-emperor-secret-ally.test.ts', 'tests/nexus-emperor-secret-ally-controls.test.tsx', 'tests/nexus-emperor-secret-ally-bots.test.ts', 'tests/nexus-emperor-secret-ally-recovery.test.ts'] },
],
},
{
id: 'nexus-cards',
title: 'Nexus Cards',
@@ -3537,7 +3560,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'One Karama response covers the entire declared territory set. Cancellation keeps every selected group as advisors and leaves the Nexus card spent. Existing territory restrictions still apply; fresh accompanied advisors and storm-related conversion remain unavailable pending their rulings.',
'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 and other Emperor Nexus modes remain unavailable.',
'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.',
'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.',
@@ -0,0 +1,74 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { nexusEmperorRevivalAction } from '../game/nexus-emperor-secret-ally-options';
import {
nexusEmperorSecretAllyFixture,
emperorNexusPhysical,
} from './nexus-emperor-secret-ally-fixture';
void test('all four profiles use the actual additional revival without ordinary quota or hidden-information access', () => {
for (const advanced of [false, true]) {
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const { g, owner } = nexusEmperorSecretAllyFixture({ advanced });
const before = g.players.find((player) => player.id === owner)!;
before.bot = difficulty;
const view = viewGame(g, owner);
for (const rival of view.players.filter(
(player) => player.id !== owner,
)) {
for (const key of ['hand', 'spice', 'traitors'])
Object.defineProperty(rival, key, {
get() {
throw new Error('Rival private field read.');
},
});
}
const [action] = botActions(view);
assert.equal(action.type, 'nexusEmperorRevive');
const after = applyAction(g, owner, action);
const actual = after.players.find((player) => player.id === owner)!;
assert.equal(actual.reserves, before.reserves + 3);
assert.equal(actual.tanks, before.tanks - 3);
assert.equal(actual.spice, before.spice);
assert.equal(actual.revived, before.revived);
assert.equal(actual.freeForcesRevived, before.freeForcesRevived);
assert.deepEqual(emperorNexusPhysical(after), emperorNexusPhysical(g));
assert.equal(after.nexusCards!.cards!.hands[owner], null);
assert.equal(
after.nexusCards!.cards!.discard.filter((card) => card === 'emperor')
.length,
1,
);
assert.equal(nexusEmperorRevivalAction(viewGame(after, owner), 0), null);
}
}
});
void test('Fremen bot choices preserve real elite limits and unavailable grants are not offered', () => {
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const { g, owner, observer } = nexusEmperorSecretAllyFixture({
advanced: true,
owner: 'p',
eliteTanks: 2,
});
g.players.find((player) => player.id === owner)!.bot = difficulty;
const before = emperorNexusPhysical(g);
const [action] = botActions(viewGame(g, owner));
assert.equal(action.type, 'nexusEmperorRevive');
const after = applyAction(g, owner, action);
assert.ok(
after.players.find((player) => player.id === owner)!.elites!.revived <= 1,
);
assert.deepEqual(emperorNexusPhysical(after), before);
assert.equal(nexusEmperorRevivalAction(viewGame(g, owner), 2), null);
assert.equal(nexusEmperorRevivalAction(viewGame(g, observer), 0), null);
}
const { g, owner } = nexusEmperorSecretAllyFixture({ tanks: 2 });
assert.ok(
!botActions(viewGame(g, owner)).some(
(action) => action.type === 'nexusEmperorRevive',
),
);
});
@@ -0,0 +1,66 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { NexusEmperorSecretAlly } from '../components/nexus-emperor-secret-ally';
import type { GameView } from '../game/engine';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
},
});
function view(overrides: Record<string, unknown> = {}) {
return {
me: 'a',
players: [
{ id: 'a', name: 'House A', faction: 'atreides', ally: null },
{ id: 'b', name: 'House B', faction: 'harkonnen', ally: null },
],
status: 'playing',
phase: 4,
response: null,
truthtrance: null,
phaseOpening: null,
automaticContinuationPending: false,
nexusCards: { card: 'emperor', waiting: [] },
nexusTraitors: { pending: null },
nexusEmperorSecretAlly: {
event: 'emperor-1',
revival: { blocked: null, eliteOptions: [0, 1] },
purchase: null,
},
...overrides,
} as unknown as GameView;
}
function markup(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(NexusEmperorSecretAlly, { game, act() {}, busy }));
}
void test('eligible private Emperor card shows revival explanation and legal elite selector', () => {
const html = markup(view());
assert.match(html, /revive 3 additional forces for free/);
assert.match(html, /Elite forces to revive/);
assert.match(html, /value="0"/);
assert.match(html, /value="1"/);
assert.match(html, /Revive 3 additional forces/);
});
void test('stale, nonowner, blocked and busy states stay guarded', () => {
assert.equal(markup(view({ phase: 5 })), '');
assert.equal(markup(view({ me: 'b', nexusEmperorSecretAlly: null })), '');
assert.equal(markup(view({ nexusCards: { card: 'emperor', waiting: ['b'] } })), '');
const blocked = markup(view({ nexusEmperorSecretAlly: {
event: 'emperor-1', revival: { blocked: 'Elite revival is unavailable.', eliteOptions: [] }, purchase: null,
} }));
assert.match(blocked, /Elite revival is unavailable/);
assert.match(blocked, /disabled=""/);
assert.match(markup(view({ nexusEmperorSecretAlly: {
event: 'emperor-1', revival: { blocked: null, eliteOptions: [1] }, purchase: null,
} }), true), /disabled=""/);
});
aliases.deregister();
+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import {
nexusTurnTwo,
orderNexusSpice,
finishNexusSpice,
nexusReady,
nexusInventory,
} from './fixture-nexus-cards';
/** Genuine Basic/Advanced initializer and real Nexus draw/bidding actions.
* Shared setup returns starting hands to the deck; controlled physical spice
* and Nexus order supplies the draw. The focused loss topology moves existing
* reserve counters to Tanks; this is not an untouched full-game sample. */
export function nexusEmperorSecretAllyFixture(
options: {
advanced?: boolean;
owner?: 'p' | 'q' | 'r';
seatIds?: [string, string, string];
tanks?: number;
eliteTanks?: number;
} = {},
) {
const seatIds: [string,string,string] = options.seatIds ?? ['p', 'q', 'r'];
const owner = seatIds[['p', 'q', 'r'].indexOf(options.owner ?? 'q')];
let g = nexusTurnTwo({ advanced: options.advanced, seatIds });
orderNexusSpice(g, ['worm', 'land', 'land']);
g = nexusReady(nexusReady(g));
const others = g.players.filter((p) => p.id !== owner).map((p) => p.id);
g = applyAction(g, others[0], { type: 'alliance', target: others[1] });
g = applyAction(g, others[1], { type: 'alliance', target: others[0] });
g = finishNexusSpice(g);
assert.equal(g.nexusCards!.phase!.stage, 'drawing');
const cards = g.nexusCards!.cards!;
cards.deck = ['emperor', ...cards.deck.filter((card) => card !== 'emperor')];
g = applyAction(g, owner, {
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,
});
assert.equal(g.phase, 2);
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 === owner)!;
const tanks = options.tanks ?? 6,
elite = options.eliteTanks ?? 0;
assert.ok(tanks <= p.reserves && elite <= (p.elites?.reserves ?? 0));
p.reserves -= tanks;
p.tanks += tanks;
if (p.elites) {
p.elites.reserves -= elite;
p.elites.tanks += elite;
}
const offer = viewGame(g, owner).nexusEmperorSecretAlly!;
assert.ok(offer);
const action = {
type: 'nexusEmperorRevive',
event: offer.event,
elite: elite ? 1 : 0,
};
nexusInventory(g);
const [target, observer] = g.players
.filter((p) => p.id !== owner)
.map((p) => p.id);
return { g, owner, target, observer, action };
}
export function emperorNexusPhysical(g: Game) {
nexusInventory(g);
return g.players.map((p) => ({
id: p.id,
total:
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
elite:
(p.elites?.reserves ?? 0) +
(p.elites?.tanks ?? 0) +
Object.values(p.elites?.forces ?? {}).reduce((a, b) => a + b, 0),
}));
}
@@ -0,0 +1,207 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { unitStore } from './fixture-nexus-room-store';
import {
nexusEmperorSecretAllyFixture,
emperorNexusPhysical,
} from './nexus-emperor-secret-ally-fixture';
import { viewGame, type Game } from '../game/engine';
import type { RoomsClock } from '../db/rooms';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
const rows = (db: DatabaseSync) =>
db.prepare('SELECT state,version FROM rooms ORDER BY code').all();
async function fixture(
t: test.TestContext,
advanced: boolean,
owner: 'p' | 'q',
) {
const store = unitStore();
t.after(() => store.sqlite.close());
const made = await store.rooms.createRoom(
'Emperor Nexus recovery',
'fremen',
advanced,
[],
);
const code = made.view.code,
tokens = [made.token];
for (const faction of ['atreides', 'harkonnen'] as const)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const auths = await Promise.all(
tokens.map((token) => store.rooms.authenticate(code, token)),
);
const ids = auths.map((auth) => auth.playerId) as [string, string, string];
const f = nexusEmperorSecretAllyFixture({
advanced,
owner,
seatIds: ids,
eliteTanks: owner === 'p' ? 2 : 0,
});
f.g.code = code;
f.g.version = (await store.rooms.readRoom(code)).version;
store.sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(f.g), f.g.version, code);
store.writes.length = 0;
return {
...store,
...f,
code,
tokens,
auths,
ids,
ownerIndex: owner === 'p' ? 0 : 1,
};
}
type Fixture = Awaited<ReturnType<typeof fixture>>;
async function restored(f: Fixture, expected: Game) {
const before = rows(f.sqlite);
for (const [index, token] of f.tokens.entries()) {
const rooms = f.restart(),
auth = await rooms.authenticate(f.code, token);
const view = await rooms.readSeatView(f.code, auth);
assert.deepEqual(view, viewGame(expected, f.ids[index]));
assert.equal('nexusEmperorSecretHistory' in view, false);
assert.equal('nexusEmperorSecretEvents' in view, false);
if (index !== f.ownerIndex) assert.equal(view.nexusEmperorSecretAlly, null);
for (const rival of view.players.filter(
(player) => player.id !== f.ids[index],
)) {
assert.equal('hand' in rival, false);
assert.equal('spice' in rival, false);
}
}
await f.restart().continueRoomAutomatic(f.code, clock);
assert.deepEqual(rows(f.sqlite), before);
}
void test('private Emperor revival survives SQLite restart and lost-response replay with exactly one physical return', async (t) => {
for (const advanced of [false, true]) {
const f = await fixture(t, advanced, advanced ? 'p' : 'q');
const initial = f.g,
owner = initial.players.find((player) => player.id === f.owner)!;
const seats = f.sqlite
.prepare('SELECT * FROM seats ORDER BY player_id')
.all();
await restored(f, initial);
const unchanged = rows(f.sqlite);
await assert.rejects(
f.rooms.act(
f.code,
f.auths[(f.ownerIndex + 1) % 3],
initial.version,
f.action,
clock,
),
);
await assert.rejects(
f.rooms.act(
f.code,
f.auths[f.ownerIndex],
initial.version,
{ ...f.action, event: 'old' },
clock,
),
);
await assert.rejects(
f.rooms.act(
f.code,
f.auths[f.ownerIndex],
initial.version,
{ ...f.action, amount: 2 },
clock,
),
);
assert.deepEqual(rows(f.sqlite), unchanged);
const rooms = f.restart(),
auth = await rooms.authenticate(f.code, f.tokens[f.ownerIndex]);
await rooms.act(f.code, auth, initial.version, f.action, clock);
const done = await f.restart().readRoom(f.code);
const actual = done.players.find((player) => player.id === f.owner)!;
assert.equal(done.version, initial.version + 1);
assert.equal(actual.reserves, owner.reserves + 3);
assert.equal(actual.tanks, owner.tanks - 3);
assert.equal(actual.spice, owner.spice);
assert.equal(actual.revived, owner.revived);
assert.equal(actual.freeForcesRevived, owner.freeForcesRevived);
assert.deepEqual(actual.hand, owner.hand);
assert.deepEqual(actual.leaders, owner.leaders);
assert.deepEqual(emperorNexusPhysical(done), emperorNexusPhysical(initial));
assert.equal(done.nexusEmperorSecretHistory!.length, 1);
assert.equal(done.nexusCards!.cards!.hands[f.owner], null);
assert.equal(
done.nexusCards!.cards!.discard.filter((card) => card === 'emperor')
.length,
1,
);
if (advanced)
assert.equal(actual.elites!.revived, owner.elites!.revived + 1);
await restored(f, done);
const saved = rows(f.sqlite);
await assert.rejects(
f.restart().act(f.code, auth, initial.version, f.action, clock),
/table changed/,
);
await assert.rejects(
f.restart().act(f.code, auth, done.version, f.action, clock),
);
assert.deepEqual(rows(f.sqlite), saved);
assert.deepEqual(
f.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(),
seats,
);
assert.deepEqual(
f.writes.map((write) => write.changes),
[1],
);
}
});
void test('competing normal/elite Emperor Nexus choices commit only one return and one discarded card', async (t) => {
const f = await fixture(t, true, 'p');
let arrivals = 0,
release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
f.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await gate;
};
const timer = setTimeout(release, 2000);
let outcomes: PromiseSettledResult<unknown>[];
try {
outcomes = await Promise.allSettled(
[0, 1].map((elite) =>
f
.restart()
.act(f.code, f.auths[0], f.g.version, { ...f.action, elite }, clock),
),
);
} finally {
clearTimeout(timer);
delete f.hooks.beforeWrite;
}
assert.equal(arrivals, 2);
assert.equal(
outcomes.filter((result) => result.status === 'fulfilled').length,
1,
);
const done = await f.restart().readRoom(f.code),
owner = done.players[0];
assert.equal(done.version, f.g.version + 1);
assert.equal(owner.reserves, f.g.players[0].reserves + 3);
assert.equal(owner.tanks, f.g.players[0].tanks - 3);
assert.ok(owner.elites!.revived === 0 || owner.elites!.revived === 1);
assert.equal(done.nexusEmperorSecretHistory!.length, 1);
assert.deepEqual(emperorNexusPhysical(done), emperorNexusPhysical(f.g));
assert.deepEqual(
f.writes.map((write) => write.changes).sort((a, b) => a - b),
[0, 1],
);
await restored(f, done);
});
+313
View File
@@ -0,0 +1,313 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Game,
} from '../game/engine';
import {
emperorNexusPools,
emperorNexusSignature,
} from '../game/nexus-emperor-secret-ally';
import { forceRevivalRemaining, freeRevivalRemaining } from '../game/revival';
import {
nexusEmperorSecretAllyFixture,
emperorNexusPhysical,
} from './nexus-emperor-secret-ally-fixture';
import { nexusReject, nexusReload, nexusReady } from './fixture-nexus-cards';
import { nexusTraitorFixture } from './fixture-nexus-traitors';
void test('a later real Ghola return and phase advance preserve historical Nexus proof without replaying its physical grant', () => {
const f = nexusEmperorSecretAllyFixture();
const card = f.g.deck.find((c) => c.effect === 'ghola')!;
f.g.deck = f.g.deck.filter((c) => c.id !== card.id);
f.g.players.find((p) => p.id === f.owner)!.hand.push(card);
f.g.players.find((p) => p.id === f.owner)!.spice = 0;
const physical = emperorNexusPhysical(f.g);
let g = applyAction(f.g, f.owner, f.action);
const receipt = nexusReload(g).nexusEmperorSecretHistory;
g = applyAction(nexusReload(g), f.owner, {
type: 'card',
card: card.id,
amount: 3,
elite: 0,
});
assert.equal(g.players.find((p) => p.id === f.owner)!.tanks, 0);
assert.equal(g.players.find((p) => p.id === f.owner)!.revived, 0);
assert.equal(g.players.find((p) => p.id === f.owner)!.spice, 0);
assert.deepEqual(g.nexusEmperorSecretHistory, receipt);
assert.deepEqual(emperorNexusPhysical(g), physical);
g = nexusReload(nexusReady(g));
assert.equal(g.phase, 5);
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
nexusReject(g, f.owner, f.action);
});
for (const advanced of [false, true])
void test(`${advanced ? 'Advanced' : 'Basic'} Emperor Secret Ally revives exactly three physical forces without ordinary payment or quota usage`, () => {
const f = nexusEmperorSecretAllyFixture({ advanced }),
before = nexusReload(f.g),
p = before.players.find((p) => p.id === f.owner)!;
const physical = emperorNexusPhysical(before),
quota = forceRevivalRemaining(before, p),
free = freeRevivalRemaining(before, p);
const g = applyAction(f.g, f.owner, f.action),
after = g.players.find((p) => p.id === f.owner)!;
assert.deepEqual(f.g, before);
assert.deepEqual(emperorNexusPools(after), {
...emperorNexusPools(p),
reserves: p.reserves + 3,
tanks: p.tanks - 3,
});
assert.equal(forceRevivalRemaining(g, after), quota);
assert.equal(freeRevivalRemaining(g, after), free);
assert.deepEqual(
g.players.filter((p) => p.id !== f.owner),
before.players.filter((p) => p.id !== f.owner),
);
assert.deepEqual(after.hand, p.hand);
assert.deepEqual(after.leaders, p.leaders);
assert.deepEqual(emperorNexusPhysical(g), physical);
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
assert.equal(
g.nexusCards!.cards!.discard.filter((c) => c === 'emperor').length,
1,
);
assert.equal(g.nexusEmperorSecretHistory!.length, 1);
assert.equal(g.phase, 4);
assert.equal(g.response, null);
assert.equal(g.decision, null);
const ordinary = applyAction(g, f.owner, {
type: 'revive',
amount: 3,
elite: 0,
});
const revived = ordinary.players.find((p) => p.id === f.owner)!;
assert.equal(revived.tanks, 0);
assert.equal(revived.revived, 3);
assert.equal(revived.spice, p.spice - 2);
assert.deepEqual(emperorNexusPhysical(ordinary), physical);
nexusReject(ordinary, f.owner, f.action);
assert.deepEqual(normalizeAutomaticGame(nexusReload(ordinary)), ordinary);
});
void test('Emperor additional return still works after the ordinary three-force and free allowances are exhausted', () => {
const f = nexusEmperorSecretAllyFixture();
const paid = applyAction(f.g, f.owner, {
type: 'revive',
amount: 3,
elite: 0,
});
const before = emperorNexusPools(paid.players.find((p) => p.id === f.owner)!);
const done = applyAction(paid, f.owner, f.action);
assert.deepEqual(
emperorNexusPools(done.players.find((p) => p.id === f.owner)!),
{ ...before, reserves: before.reserves + 3, tanks: before.tanks - 3 },
);
});
void test('Advanced Fedaykin use typed Tanks and share the actual one-per-turn cap with ordinary revival', () => {
for (const nexusFirst of [false, true]) {
const f = nexusEmperorSecretAllyFixture({
advanced: true,
owner: 'p',
tanks: 7,
eliteTanks: 2,
});
const before = emperorNexusPhysical(f.g);
assert.deepEqual(
viewGame(f.g, f.owner).nexusEmperorSecretAlly!.revival.eliteOptions,
[0, 1],
);
nexusReject(f.g, f.owner, { ...f.action, elite: 2 });
let g: Game;
if (nexusFirst) {
g = applyAction(f.g, f.owner, f.action);
nexusReject(
g,
f.owner,
{ type: 'revive', amount: 1, elite: 1 },
/one elite/,
);
g = applyAction(g, f.owner, { type: 'revive', amount: 3, elite: 0 });
} else {
g = applyAction(f.g, f.owner, { type: 'revive', amount: 3, elite: 1 });
assert.deepEqual(
viewGame(g, f.owner).nexusEmperorSecretAlly!.revival.eliteOptions,
[0],
);
nexusReject(g, f.owner, f.action);
g = applyAction(g, f.owner, { ...f.action, elite: 0 });
}
assert.equal(g.players[0].elites!.revived, 1);
assert.equal(g.players[0].elites!.tanks, 1);
assert.deepEqual(emperorNexusPhysical(g), before);
}
});
void test('short total or typed groups are explicitly unavailable without spending a card or fabricating partial forces', () => {
for (const options of [
{ tanks: 2 },
{ advanced: true, owner: 'p' as const, tanks: 3, eliteTanks: 2 },
]) {
const f = nexusEmperorSecretAllyFixture(options);
assert.deepEqual(
viewGame(f.g, f.owner).nexusEmperorSecretAlly!.revival.eliteOptions,
[],
);
assert.match(
viewGame(f.g, f.owner).nexusEmperorSecretAlly!.revival.blocked!,
/exactly three/,
);
nexusReject(f.g, f.owner, f.action, /exactly three/);
assert.equal(f.g.nexusCards!.cards!.hands[f.owner], 'emperor');
}
});
void test('only the private absent-Emperor holder gets revival choices; nonholders retain ordinary phase progress', () => {
const f = nexusEmperorSecretAllyFixture();
assert.equal(viewGame(f.g, f.owner).nexusEmperorSecretAlly!.purchase, null);
for (const id of [f.target, f.observer]) {
const v = viewGame(f.g, id);
assert.equal(v.nexusEmperorSecretAlly, null);
assert.equal(v.nexusCards!.card, null);
assert.equal(v.decision, null);
assert.equal(Object.hasOwn(v, 'nexusEmperorSecretHistory'), false);
}
const next = nexusReady(f.g);
assert.equal(next.phase, 5);
assert.equal(next.nexusCards!.cards!.hands[f.owner], 'emperor');
nexusReject(next, f.owner, f.action, /Revival/);
const ready = applyAction(f.g, f.owner, { type: 'ready' });
// As with ordinary revival, a Ready vote does not end the printed phase
// while other players are still acting; the existing timing remains valid.
assert.equal(
viewGame(ready, f.owner).nexusEmperorSecretAlly!.revival.blocked,
null,
);
assert.equal(
applyAction(ready, f.owner, f.action).nexusEmperorSecretHistory!.length,
1,
);
});
void test('stale, wrong-owner, malformed and leader-revival requests reject before any resource or random commitment', () => {
const f = nexusEmperorSecretAllyFixture(),
before = nexusReload(f.g);
for (const action of [
{ ...f.action, event: 'stale' },
{ ...f.action, elite: -1 },
{ ...f.action, elite: 0.5 },
{ ...f.action, amount: 2 },
{ ...f.action, leader: f.g.players[0].leaders[0].id },
{ type: f.action.type, event: f.action.event },
])
nexusReject(f.g, f.owner, action);
nexusReject(f.g, f.target, f.action);
assert.deepEqual(f.g, before);
const card = f.g.deck.find((c) => c.effect === 'truthtrance')!;
f.g.deck = f.g.deck.filter((c) => c.id !== card.id);
f.g.players.find((p) => p.id === f.target)!.hand.push(card);
const pending = applyAction(f.g, f.target, { type: 'card', card: card.id });
assert.ok(pending.truthtrance);
nexusReject(pending, f.owner, f.action, /current interaction/);
});
void test('ordinary Fremen aid to the other allied seat does not suppress this unallied owner', () => {
const f = nexusEmperorSecretAllyFixture();
const granted = applyAction(f.g, 'p', { type: 'grantRevival' });
assert.ok(granted.freeRevival.includes('r'));
assert.equal(
viewGame(granted, f.owner).nexusEmperorSecretAlly!.revival.blocked,
null,
);
const done = applyAction(granted, f.owner, f.action);
assert.deepEqual(done.freeRevival, granted.freeRevival);
});
void test('combined modules and applicable revival suppression stay explicit, while native and Betrayal modes never expose Secret Ally', () => {
const f = nexusEmperorSecretAllyFixture();
for (const damage of [
(g: Game) => {
g.expansions = ['ix'];
},
(g: Game) => {
g.discoveryEnabled = true;
},
(g: Game) => {
g.revivalRules!.expanded = [f.owner];
},
(g: Game) => {
g.revivalRules!.freeBlocked = [f.owner];
},
(g: Game) => {
g.revivalPrevention = { player: f.owner, turn: g.turn };
},
]) {
const g = nexusReload(f.g);
damage(g);
nexusReject(g, f.owner, f.action, /Nexus|Discover|revival/);
}
for (const native of [false, true]) {
const g = nexusTraitorFixture({
ownerFaction: native ? 'emperor' : 'atreides',
opponentFaction: native ? 'guild' : 'emperor',
phase: 4,
});
const cards = g.nexusCards!.cards!,
old = cards.hands.p!;
cards.deck[cards.deck.indexOf('emperor')] = old;
cards.hands.p = 'emperor';
assert.equal(viewGame(g, 'p').nexusEmperorSecretAlly, null);
nexusReject(g, 'p', f.action);
}
});
void test('JSON restoration keeps signed source history private and rejects missing markers, duplicate uses and changed physical quotes', () => {
const f = nexusEmperorSecretAllyFixture(),
done = applyAction(f.g, f.owner, f.action);
for (const p of done.players)
assert.deepEqual(viewGame(nexusReload(done), p.id), viewGame(done, p.id));
assert.deepEqual(normalizeAutomaticGame(nexusReload(done)), done);
nexusReject(done, f.owner, f.action);
for (const damage of [
(g: Game) => {
delete g.nexusEmperorSecretHistory;
},
(g: Game) => {
delete g.nexusEmperorSecretEvents;
},
(g: Game) => {
g.nexusEmperorSecretEvents![0] = 'forged';
},
(g: Game) => {
g.nexusEmperorSecretHistory!.push(g.nexusEmperorSecretHistory![0]);
g.nexusEmperorSecretEvents!.push(g.nexusEmperorSecretEvents![0]);
},
(g: Game) => {
g.nexusEmperorSecretHistory![0].after.reserves++;
},
(g: Game) => {
const r = g.nexusEmperorSecretHistory![0];
r.elite = 1;
r.signature = emperorNexusSignature(r);
},
(g: Game) => {
const cards = g.nexusCards!.cards!;
cards.discard = cards.discard.filter((c) => c !== 'emperor');
cards.hands[f.owner] = 'emperor';
},
]) {
const g = nexusReload(done);
damage(g);
const before = nexusReload(g);
assert.throws(() => viewGame(g, f.owner), /Emperor Nexus/);
assert.throws(() => normalizeAutomaticGame(g), /Emperor Nexus/);
assert.throws(
() => applyAction(g, f.owner, { type: 'ready' }),
/Emperor Nexus/,
);
assert.deepEqual(g, before);
}
});
+52 -1
View File
@@ -1,7 +1,13 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { createGame, joinGame, newPlayer } from '../game/engine';
import {
createGame,
joinGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import {
startIxPrototypeRoom,
startPrototypeRoom,
@@ -146,3 +152,48 @@ void test('Leader Skills prototype saves the real private setup once without cha
other,
);
});
void test('Nexus prototype initializes all twelve hidden cards once and preserves unrelated saved state', (t) => {
const { db } = fixture(t);
const game = createGame(
'PROTOTYP',
newPlayer('i', 'Atreides', 'atreides'),
true,
);
joinGame(game, newPlayer('h', 'Harkonnen', 'harkonnen'));
game.players.forEach((player) => {
player.ready = true;
});
db.prepare('UPDATE rooms SET state = ? WHERE code = ?').run(
JSON.stringify(game),
game.code,
);
const seats = db.prepare('SELECT * FROM seats').all();
const other = db
.prepare('SELECT * FROM rooms WHERE code = ?')
.get('KEEPME00');
startPrototypeRoom(db, game.code, 7, 'nexus');
const saved = JSON.parse(
db.prepare('SELECT state FROM rooms WHERE code = ?').get(game.code)!
.state as string,
) as Game;
assert.equal(saved.version, 8);
assert.equal(saved.status, 'setup');
assert.equal(saved.nexusCards!.cards!.deck.length, 12);
assert.equal(new Set(saved.nexusCards!.cards!.deck).size, 12);
for (const player of saved.players) {
const view = viewGame(saved, player.id);
assert.equal(view.nexusCards!.card, null);
assert.equal(view.nexusCards!.deckCount, 12);
assert.equal('deck' in view.nexusCards!, false);
}
const rows = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, game.code, 7, 'nexus'), /changed/);
assert.throws(() => startPrototypeRoom(db, game.code, 8, 'nexus'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rows);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
assert.deepEqual(
db.prepare('SELECT * FROM rooms WHERE code = ?').get('KEEPME00'),
other,
);
});
+12 -5
View File
@@ -4,6 +4,7 @@ import {
initializeDiscoveryGameForAudit,
initializeLeaderSkillsGameForAudit,
initializeFactionExpansionsGameForAudit,
initializeNexusGameForAudit,
viewGame,
type Game,
} from '../game/engine';
@@ -13,6 +14,7 @@ export const PROTOTYPE_PROFILES = [
'discovery',
'leader-skills',
'factions',
'nexus',
] as const;
export type PrototypeProfile = (typeof PROTOTYPE_PROFILES)[number];
export function isPrototypeProfile(value: string): value is PrototypeProfile {
@@ -57,12 +59,17 @@ export function startPrototypeRoom(
? initializeIxGameForAudit(initial)
: profile === 'factions'
? initializeFactionExpansionsGameForAudit(initial)
: profile === 'leader-skills'
? initializeLeaderSkillsGameForAudit(initial)
: initializeDiscoveryGameForAudit({
: profile === 'nexus'
? initializeNexusGameForAudit({
...initial,
discoveryEnabled: true,
});
nexusCards: initial.nexusCards ?? { cards: null, phase: null },
})
: profile === 'leader-skills'
? initializeLeaderSkillsGameForAudit(initial)
: initializeDiscoveryGameForAudit({
...initial,
discoveryEnabled: true,
});
// Exercise the same player projection before accepting the new saved state.
for (const player of game.players) viewGame(game, player.id);
const version = expectedVersion + 1;
+1 -1
View File
@@ -20,7 +20,7 @@ async function main() {
});
if (values.help) {
console.log(
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|factions --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Factions supports selected expansion factions without optional modules; the separate Ecaz Treachery variant stays disabled. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|factions|nexus --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Nexus enables the existing private Nexus setup. Factions supports selected expansion factions without optional modules; the separate Ecaz Treachery variant stays disabled. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
);
return;
}