Connect Fremen Nexus fixed quota to native partial revival
This commit is contained in:
1 parent
985cf85e12
commit
25996ec8a7
26 files changed
+1386
-354
No files matched your search
@@ -1,6 +1,5 @@
|
||||
'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';
|
||||
@@ -10,33 +9,27 @@ export function NexusFremenRevival({ game, act, busy }: {
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const [selectedElite, setSelectedElite] = useState<number | null>(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);
|
||||
const action = nexusFremenRevivalAction(game);
|
||||
return (
|
||||
<section className="min-w-0 space-y-3" aria-label="Fremen Secret Ally revival">
|
||||
<h3 className="font-serif text-xl">Fremen Secret Ally</h3>
|
||||
<p className="text-base leading-7">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.</p>
|
||||
<p className="text-base leading-7">
|
||||
Spend your Fremen Nexus to enable a total ordinary free quota of three
|
||||
for this Revival phase. Then choose partial forces and actual elite
|
||||
counters with the normal revival controls below.
|
||||
</p>
|
||||
<p className="notice">Provisional policy: {offer.policy}</p>
|
||||
<p className="fine">
|
||||
{offer.freeRemaining} free returns may remain after activation;
|
||||
{offer.maxForces} are currently available from your eligible Tanks.
|
||||
No force moves automatically and the ordinary leader slot is unchanged.
|
||||
</p>
|
||||
{offer.blocked && <p className="notice">{offer.blocked}</p>}
|
||||
{options.length > 1 && (
|
||||
<label className="block space-y-2">
|
||||
<span className="font-semibold">Elite forces to revive</span>
|
||||
<select aria-label="Fremen Nexus 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.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={busy || !action}
|
||||
onClick={() => { if (!busy && action) act(action); }}>
|
||||
Revive 3 free forces{elite === null ? '' : ` · ${elite} elite`}
|
||||
Spend Nexus · Enable free revival quota
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -29,6 +29,22 @@ optional skill bands, combinations and public expansion starts remain open.
|
||||
Rules implementation continues; comprehensive assurance and administration
|
||||
remain deferred until all rules are implemented.
|
||||
|
||||
**Fremen Secret Ally ordinary free quota — 14 October.** Recommended
|
||||
provisional fixed-three permission now replaces the early-only exact-three
|
||||
transfer. Activation spends one Nexus and moves nothing; original revival
|
||||
controls support partial/late typed groups and original prevention, income,
|
||||
Tech and Homeworld continuations. Prior free use counts, paid returns are
|
||||
not refunded, higher native rates and Recruits' minimum-seven total survive.
|
||||
The affected20-file union passes329/329; types/lint pass. Four real API
|
||||
profiles reach Movement after late activation. Actual390px activation then
|
||||
one-plus-two returns uses original Tleilaxu Allow controls, preserves spice13,
|
||||
returns three counters and produces native income1 once, with no overflow
|
||||
or browser errors. Old physical receipts adopt metadata without replay.
|
||||
Final module, assurance and deployed acceptance remain open.
|
||||
[Canonical interpretation and proof](NEXUS_FREMEN_RULES.md#recommended-fixed-ordinary-quota--14-october-2026).
|
||||
Final connected UI build passes; existing route-classification/chunk-size
|
||||
warnings remain. Owned QA scaffolding is removed; proof receipts are outside Git.
|
||||
|
||||
**BG Cunning fresh/storm card exceptions — 14 October.** The recommended
|
||||
in-place any/all prototype now permits current-turn accompanied and storm-
|
||||
bound/separated advisor territories during BG's own Advanced action. Native
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 14 October 2026 — Fremen Nexus fixed ordinary free quota
|
||||
|
||||
Recommended provisional composition replaces the early-only direct transfer
|
||||
with a physical singleton activation and phase/turn-bound ordinary quota3.
|
||||
Normal revival owns partial/typed returns and original pending decisions,
|
||||
prevention, free-income, Tech and native Homeworld deposit/deployment.
|
||||
Prior actual free usage counts; prior paid returns keep their price and total
|
||||
usage. No forces/leader/spice move on activation, and no total limit grows.
|
||||
Higher native rates survive; Recruits doubles only its native rate and keeps
|
||||
its original minimum-seven ceiling. Useless or unknown-ledger grants reject.
|
||||
Strict old receipt adoption retains physical facts without gameplay replay.
|
||||
Removed direct-transfer action/selector; all affected module consumers migrate.
|
||||
|
||||
Genuine Basic/Advanced classic and single-native-Tleilaxu chronology covers
|
||||
partial1+2, late2+1, previously paid expanded returns, expiry, native income,
|
||||
original typed caps and actual legal AI. Obsolete wiring/wording/early-only
|
||||
pins are removed rather than updated. The frozen20-file union passes329/329;
|
||||
types/lint pass. Four API profiles retain original costs/card inventory and
|
||||
reach Shipment and Movement after late activation. Actual390px GameTable
|
||||
enables quota, requests1 then2 and hands each to original Tleilaxu Allow:
|
||||
Tanks8→5/reserves10→13/free3, spice13 unchanged, native income1 once,
|
||||
one Nexus discard, no horizontal overflow or browser errors. No saved room
|
||||
is reset, touched or deployed. Wider combinations/final assurance remain due.
|
||||
[Canonical contract](NEXUS_FREMEN_RULES.md#recommended-fixed-ordinary-quota--14-october-2026).
|
||||
Final UI build passes with existing route-classification/chunk-size warnings.
|
||||
Owned QA host/tab/scaffolding is removed; API and phone proofs remain outside Git.
|
||||
|
||||
## 14 October 2026 — Native BG Cunning fresh and storm card exceptions
|
||||
|
||||
Recommended provisional interpretation: printed any/all permission is an
|
||||
|
||||
@@ -12,6 +12,19 @@ movement, battle geometry and actual casualties stay unchanged. One original
|
||||
Karama response cancels the entire physical batch and keeps the Nexus spent.
|
||||
[Source, original native proofs and controls](NEXUS_BENE_GESSERIT_RULES.md#recommended-cunning-card-exceptions--14-october-2026).
|
||||
|
||||
## Recommended Fremen ordinary free quota — 14 October 2026
|
||||
|
||||
The absent-Fremen Secret Ally now enables a fixed ordinary free quota of
|
||||
three, followed by original normal revival requests. No counters move at
|
||||
activation. Actual prior free usage counts; paid returns keep their price
|
||||
and total usage. Partial/late returns, original typed caps, native prevention,
|
||||
Homeworld deposits/deployment, Axlotl activity and once-only Tleilaxu income
|
||||
use the original pipeline. Higher native rates survive; Recruits does not
|
||||
double the fixed quota or lose its native minimum-seven limit. Old exact-three
|
||||
receipts adopt strict source metadata without replay. This is the visibly
|
||||
provisional recommended composition, not a publisher answer.
|
||||
[Contract and changed-path proof](NEXUS_FREMEN_RULES.md#recommended-fixed-ordinary-quota--14-october-2026).
|
||||
|
||||
## Recommended absent-Fremen protection — 14 October 2026
|
||||
|
||||
The printed Secret Ally protection now connects before original worm
|
||||
|
||||
+44
-13
@@ -166,25 +166,56 @@ and deployed acceptance remain due.
|
||||
|
||||
## 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.
|
||||
### Recommended fixed ordinary quota — 14 October 2026
|
||||
|
||||
The bounded classic Tech/Advanced Stronghold composition now calls the existing Axlotl activity hook after this actual free batch; spice stays on the token until native phase-end settlement, without changing these ordinary ledgers. See [the composition contract](NEXUS_CARD_RULES.md#classic-tech-and-stronghold-composition--4-october-2026). Tleilaxu income and Homeworld arrivals remain excluded: those broader continuations require the native pending-revival pipeline, not this quiet transfer. Fewer-than-three Tanks, previous force returns and higher free rates/modifiers remain unavailable rather than guessing a ruling. The independent protection prototype above and native Cunning remain separate producers.
|
||||
Choose-recommended-option priority adopts the printed total-of-three as a
|
||||
phase-bound **ordinary free quota**, not Emperor-style additional returns.
|
||||
This is visibly provisional source composition, not a publisher clarification
|
||||
or a separately answered numerical user ruling.
|
||||
|
||||
The separate fresh classic `leader-skills --nexus-cards` composition preserves
|
||||
the same ordinary/free-three ledgers, zero wallet cost, actual counters and
|
||||
Advanced one-elite cap with all14 original skills, without Tech/Strongholds
|
||||
or other overlays. It does not change physical training, grant leader revival,
|
||||
reset prior returns or settle worm-protection reactions. [Scope and bounded
|
||||
proof](NEXUS_CARD_RULES.md#classic-nexus-and-leader-skills-composition--4-october-2026)
|
||||
remains separate from complete Fremen/Nexus certification.
|
||||
With native Fremen absent, an actual unallied Fremen-card holder may enable
|
||||
the quota at a quiet Revival boundary. `nexusFremenRevival` spends the single
|
||||
physical card and records its original turn/owner and actual total/free usage.
|
||||
It returns no counters, leader or spice. Then the **normal revival controls**
|
||||
request any legal partial group, retaining the original typed prices/caps.
|
||||
Prior actual free returns count toward three; prior paid returns keep their
|
||||
price and consume the total allowance. No refund, ledger reset, leader grant
|
||||
or new total-force limit is created. Partial Tanks and late activation work.
|
||||
|
||||
The effective free rate is the greater of the native rate and three. Higher
|
||||
native rates survive. Recruits still doubles its native rate and retains its
|
||||
original minimum-seven total limit, but does not double this fixed Nexus
|
||||
quota. Native La La La/free blocking and Tleilaxu prevention still apply.
|
||||
Unknown prior-free usage and a grant with no additional eligible free return
|
||||
are unavailable before disposal. An already-entitled short Tanks group keeps
|
||||
its card rather than paying for no improvement.
|
||||
|
||||
`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.
|
||||
`game/revival.ts` owns the shared allowance. Original `beginRevival`/
|
||||
`finishRevival` resolve every requested group through actual CHOAM/Tleilaxu
|
||||
decisions, typed reserves, Homeworld placement/deployment, Tech activity and
|
||||
income. Native Tleilaxu free-revival income occurs once per player/turn after
|
||||
actual returns, never on activation or once per partial request. Axlotl
|
||||
activity remains on the original token until phase-end settlement. Native
|
||||
Cyborg eligibility and Advanced Sardaukar caps retain their own rules.
|
||||
|
||||
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.
|
||||
New version-two receipts describe only the source grant. Strictly valid old
|
||||
exact-three receipts adopt that metadata without replaying original forces,
|
||||
payments or decisions; their physical before/after facts remain retained.
|
||||
Malformed signatures are not repaired. The old direct-transfer action and
|
||||
Nexus-only elite selector are removed; all affected consumers use activation
|
||||
followed by the original revival action.
|
||||
|
||||
Tleilaxu may be present even though Fremen is absent. `collectRevivalIncome` uses a per-player/turn free-income marker and a separate Ghola path; do not create another payment merely because a Nexus card was played. Axlotl activation and any eligible Homeworld deployment must follow the actual returned batch. Preserve the existing [revival-accounting questions](TLEILAXU_AMBASSADOR_RULES.md), exact typed caps, and signed return/arrival continuations. No leader, Cheap Hero or foreign leader revival is printed here.
|
||||
The twenty-file native/consumer union passes329/329. Genuine Basic/Advanced
|
||||
classic and single-native-Tleilaxu fixtures cover one-plus-two returns, late
|
||||
free and paid usage, expiry, real income, typed module returns and legacy
|
||||
metadata adoption. Four API runs reach original Shipment and Movement after
|
||||
late activation without changing costs. Actual390px GameTable enables the
|
||||
quota, requests one then two, and uses the native Tleilaxu Allow control for
|
||||
each: Tanks8→5, reserves10→13, free3, spice13 unchanged, native income1 once,
|
||||
one discard, no overflow or browser errors. Wider combinations and final
|
||||
rules/private/recovery/deployed acceptance remain open.
|
||||
Final types/lint and connected UI build pass with existing build warnings.
|
||||
Owned QA host/tab/scaffolding is removed; proofs remain outside Git.
|
||||
|
||||
## Verification and remaining boundaries
|
||||
|
||||
|
||||
@@ -66,6 +66,19 @@ These decisions do not open public module/faction or complete-mode gates.
|
||||
|
||||
These decisions do not open public module/faction or complete-mode gates.
|
||||
|
||||
## Recommended Fremen Nexus quota — 14 October 2026
|
||||
|
||||
The user's choose-recommended-option instruction selects the provisional
|
||||
ordinary total-free-quota-three composition: event-only card activation,
|
||||
then native partial/typed revival. Actual prior free returns count; paid
|
||||
returns consume total allowance without refund. Higher native free rates,
|
||||
Recruits' original doubled native rate/minimum-seven limit, La La La and
|
||||
Tleilaxu prevention remain; the independent fixed quota is not doubled.
|
||||
No leader grant or extra total-force allowance is inferred. A useless or
|
||||
unknown-ledger spend is unavailable. This is not publisher clarification
|
||||
or a separately answered numerical ruling.
|
||||
[Connected source, controls and evidence](NEXUS_FREMEN_RULES.md#recommended-fixed-ordinary-quota--14-october-2026).
|
||||
|
||||
## Content-first Basic prototypes — 8 October 2026
|
||||
|
||||
The user's Basic-first instruction authorizes usable first versions before
|
||||
|
||||
+2
-9
@@ -4037,15 +4037,8 @@ export function botActions(g: GameView): Action[] {
|
||||
return [{ type: use ? 'nexusIxianReplacementUse' : 'nexusIxianReplacementPass', event: replacement.event }];
|
||||
}
|
||||
if (g.decision?.kind === 'nexusIxianReplacement') return [];
|
||||
const fremenRevival = g.nexusFremenRevival;
|
||||
if (fremenRevival && !fremenRevival.blocked && fremenRevival.eliteOptions.length) {
|
||||
const own = g.players.find(player => player.id === g.me);
|
||||
const elite = own?.bot === 'Easy'
|
||||
? Math.min(...fremenRevival.eliteOptions)
|
||||
: Math.max(...fremenRevival.eliteOptions);
|
||||
const action = nexusFremenRevivalAction(g, elite);
|
||||
if (action) return [action];
|
||||
}
|
||||
const fremenRevival = nexusFremenRevivalAction(g);
|
||||
if (fremenRevival) return [fremenRevival];
|
||||
const emperorRevival = g.nexusEmperorSecretAlly?.revival;
|
||||
if (emperorRevival && !emperorRevival.blocked && emperorRevival.eliteOptions.length) {
|
||||
const own = g.players.find((player) => player.id === g.me);
|
||||
|
||||
+54
-20
@@ -46,7 +46,7 @@ import { quoteEcazInquiry, ecazInquiryAnswer, ecazInquiryHistory, ecazInquirySig
|
||||
import { nexusCleanPlayBlocked } from './nexus-play-boundary';
|
||||
import { EMPEROR_NEXUS_REVIVALS, emperorNexusEvent, emperorNexusSignature, emperorNexusModeSupported, emperorNexusPools, emperorNexusRevivalElites, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally';
|
||||
import { emperorNexusPurchaseEvent, quoteEmperorNexusPurchase, signEmperorNexusPurchase, validateEmperorNexusPurchase, type EmperorNexusPurchase } from './nexus-emperor-purchase';
|
||||
import { FREMEN_NEXUS_FREE_FORCES, fremenNexusRevivalOffer, fremenNexusRevivalPools, fremenNexusRevivalSignature, validateFremenNexusRevival, type FremenNexusRevival } from './nexus-fremen-revival';
|
||||
import { FREMEN_NEXUS_FREE_FORCES, fremenNexusRevivalOffer, createFremenNexusRevival, upgradeFremenNexusRevival, validateFremenNexusRevival, type FremenNexusRevival } from './nexus-fremen-revival';
|
||||
import { captureFremenCunningOccurrence, quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection, type FremenCunningOccurrence, type FremenCunningRideAuthorization, type FremenCunningSelection } from './nexus-fremen-cunning';
|
||||
import { createNexusFremenProtection, currentNexusFremenProtectionOwner, answerNexusFremenProtection, validateNexusFremenProtection, type NexusFremenProtectionFrame, type NexusFremenProtectionInput } from './nexus-fremen-protection';
|
||||
import { truthKnowledgeOf } from './truthtrance-knowledge';
|
||||
@@ -2955,10 +2955,34 @@ function nexusFremenCunningIntegrity(g: Game) {
|
||||
'A worm occurrence cannot be both offered and already spent.');
|
||||
}
|
||||
|
||||
function migrateFremenNexusRevival(state: Game): Game {
|
||||
const history = state.nexusFremenRevivalHistory;
|
||||
if (!Array.isArray(history)) return state;
|
||||
let adopted: FremenNexusRevival[] | null = null;
|
||||
const owners: string[] = [];
|
||||
for (const [index, record] of history.entries()) {
|
||||
if (record?.version === 2) continue;
|
||||
const next = nexusRule(() => upgradeFremenNexusRevival(state, record));
|
||||
adopted ??= [...history];
|
||||
adopted[index] = next;
|
||||
if (state.phase === 4 && next.turn === state.turn) owners.push(next.owner);
|
||||
}
|
||||
if (!adopted) return state;
|
||||
const next = { ...state, nexusFremenRevivalHistory: adopted };
|
||||
if (owners.length) {
|
||||
const prior = state.revivalRules?.nexusFremen;
|
||||
next.revivalRules = { ...(state.revivalRules ?? newRevivalRules()), nexusFremen: {
|
||||
turn: state.turn,
|
||||
owners: [...new Set([...(prior?.turn === state.turn ? prior.owners : []), ...owners])],
|
||||
} };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
function nexusFremenRevivalIntegrity(g: Game) {
|
||||
const history = g.nexusFremenRevivalHistory, events = g.nexusFremenRevivalEvents;
|
||||
if (history === undefined) {
|
||||
requireRule(events === undefined, 'Fremen Nexus has lost its saved revival history.');
|
||||
requireRule(events === undefined && !g.revivalRules?.nexusFremen,
|
||||
'Fremen Nexus has lost its saved quota history.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0 &&
|
||||
@@ -2975,29 +2999,36 @@ function nexusFremenRevivalIntegrity(g: Game) {
|
||||
if (last.turn === g.turn)
|
||||
requireRule(g.nexusCards.cards.discard.includes('fremen'),
|
||||
'Fremen Nexus has reopened its already spent physical card.');
|
||||
const permission = g.revivalRules?.nexusFremen;
|
||||
if (permission) requireRule(Number.isSafeInteger(permission.turn) &&
|
||||
permission.turn > 0 && permission.turn <= g.turn && Array.isArray(permission.owners) &&
|
||||
permission.owners.length > 0 && new Set(permission.owners).size === permission.owners.length &&
|
||||
permission.owners.every(owner => history.some(record =>
|
||||
record.turn === permission.turn && record.owner === owner)),
|
||||
'The Fremen quota has lost its actual phase-bound source grant.');
|
||||
if (g.phase === 4) for (const record of history.filter(entry => entry.turn === g.turn)) {
|
||||
const player = getPlayer(g, record.owner);
|
||||
requireRule(permission?.turn === g.turn && permission.owners.includes(record.owner) &&
|
||||
player.revived >= record.revivedAtUse &&
|
||||
(player.freeForcesRevived ?? (player.revived === 0 ? 0 : -1)) >= record.freeAtUse,
|
||||
'The Fremen quota has lost its source permission or actual free-return ledger.');
|
||||
}
|
||||
}
|
||||
function playNexusFremenRevive(g: Game, p: Player, action: Action) {
|
||||
function playNexusFremenRevival(g: Game, p: Player, action: Action) {
|
||||
const offer = fremenNexusRevivalOffer(g, p.id);
|
||||
requireRule(offer && !offer.blocked && action.event === offer.event &&
|
||||
Object.keys(action).sort().join(',') === 'elite,event,type' &&
|
||||
offer.eliteOptions.includes(action.elite as number),
|
||||
offer?.blocked ?? 'Choose a current eligible three-force Fremen Nexus return.');
|
||||
const elite = action.elite as number, before = fremenNexusRevivalPools(p);
|
||||
Object.keys(action).sort().join(',') === 'event,type',
|
||||
offer?.blocked ?? 'Choose your current Fremen ordinary free-quota grant.');
|
||||
const record = nexusRule(() => createFremenNexusRevival(g, p.id));
|
||||
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
|
||||
addNativeReserves(g, p, FREMEN_NEXUS_FREE_FORCES, elite);
|
||||
p.tanks -= FREMEN_NEXUS_FREE_FORCES;
|
||||
p.revived += FREMEN_NEXUS_FREE_FORCES;
|
||||
p.freeForcesRevived = (p.freeForcesRevived ?? 0) + FREMEN_NEXUS_FREE_FORCES;
|
||||
if (p.elites) { p.elites.tanks -= elite; p.elites.revived += elite; }
|
||||
techIncome(g, 'axlotl', p);
|
||||
const record: FremenNexusRevival = { event: offer.event, owner: p.id, turn: g.turn,
|
||||
phase: 4, faction: p.faction, advanced: g.advanced, elite, before,
|
||||
after: fremenNexusRevivalPools(p), signature: '' };
|
||||
record.signature = fremenNexusRevivalSignature(record);
|
||||
g.revivalRules ??= newRevivalRules();
|
||||
const prior = g.revivalRules.nexusFremen;
|
||||
g.revivalRules.nexusFremen = { turn: g.turn,
|
||||
owners: [...(prior?.turn === g.turn ? prior.owners : []), p.id] };
|
||||
(g.nexusFremenRevivalHistory ??= []).push(record);
|
||||
(g.nexusFremenRevivalEvents ??= []).push(record.event);
|
||||
log(g, `${p.name} spent Fremen Nexus Secret Ally to return three forces${elite ? `, including ${elite} elite` : ''} from Tanks to reserves for free. This uses the ordinary three-force revival allowance.`,
|
||||
{ faction: p.faction, name: 'Fremen Nexus revival' });
|
||||
log(g, `${p.name} spent Fremen Nexus Secret Ally to enable a fixed ordinary free quota of ${FREMEN_NEXUS_FREE_FORCES} for this Revival phase. Use the normal controls for partial and typed returns. Prior free usage counts; paid returns are not refunded. Provisional policy: native limits, prevention and higher rates remain; Recruits does not double this card quota.`,
|
||||
{ faction: p.faction, name: 'Fremen Nexus free quota' });
|
||||
}
|
||||
function playNexusChoamTrade(g: Game, p: Player, action: Action) {
|
||||
const offer = currentNexusChoamTrade(g, p.id);
|
||||
@@ -28420,6 +28451,7 @@ function normalizeCardNames(g: Game) {
|
||||
}
|
||||
export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
state = migrateAtomicsTargets(state);
|
||||
state = migrateFremenNexusRevival(state);
|
||||
if (state.spiceBankerIncomePreview) {
|
||||
const keysBySource: Record<string, readonly string[]> = {
|
||||
bid: ['type','amount','allyPayment'], passBid: ['type'],
|
||||
@@ -28821,6 +28853,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
|
||||
export function normalizeAutomaticGame(state: Game): Game {
|
||||
state = migrateAtomicsTargets(state);
|
||||
state = migrateFremenNexusRevival(state);
|
||||
harkonnenNexusBetrayalIntegrity(state);
|
||||
ixianNexusBetrayalIntegrity(state);
|
||||
ixianNexusReplacementIntegrity(state);
|
||||
@@ -29072,7 +29105,7 @@ function applyActionInner(
|
||||
if (t === 'nexusEmperorPurchase') { playNexusEmperorPurchase(g,p,action); return g; }
|
||||
if (t === 'nexusEmperorBetrayal') { playNexusEmperorBetrayal(g,p,action); return g; }
|
||||
if (t === 'nexusBgBetrayal') { playNexusBgBetrayal(g,p,action); return g; }
|
||||
if (t === 'nexusFremenRevive') { playNexusFremenRevive(g,p,action); return g; }
|
||||
if (t === 'nexusFremenRevival') { playNexusFremenRevival(g,p,action); return g; }
|
||||
if (t === 'nexusMoritaniBetrayal') { playMoritaniBetrayal(g, p, action); return g; }
|
||||
if (t === 'nexusEcazBetrayal') { playEcazBetrayal(g, p, action); return g; }
|
||||
if (t === 'nexusFremenBetrayal') { playNexusFremenBetrayal(g, p, action); return g; }
|
||||
@@ -32876,6 +32909,7 @@ function applyActionInner(
|
||||
}
|
||||
export function viewGame(state: Game, id: string) {
|
||||
state = migrateAtomicsTargets(state);
|
||||
state = migrateFremenNexusRevival(state);
|
||||
harkonnenNexusBetrayalIntegrity(state);
|
||||
ixianNexusBetrayalIntegrity(state);
|
||||
ixianNexusReplacementIntegrity(state);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
|
||||
/** Server-owned private offer binds the selected physical group and turn. */
|
||||
export function nexusFremenRevivalAction(game: GameView, elite: number): Action | null {
|
||||
/** Enable the server-owned phase quota; original revival controls choose the forces. */
|
||||
export function nexusFremenRevivalAction(game: GameView): Action | null {
|
||||
const offer = game.nexusFremenRevival;
|
||||
const owner = game.players.find(player => player.id === game.me);
|
||||
if (!offer || !owner || owner.ally || game.nexusCards?.card !== 'fremen' ||
|
||||
@@ -9,7 +9,6 @@ export function nexusFremenRevivalAction(game: GameView, elite: number): Action
|
||||
game.status !== 'playing' || game.phase !== 4 ||
|
||||
game.response || game.decision || game.truthtrance || game.phaseOpening ||
|
||||
game.automaticContinuationPending || game.nexusCards.waiting.length ||
|
||||
game.nexusTraitors?.pending || offer.blocked ||
|
||||
!Number.isSafeInteger(elite) || !offer.eliteOptions.includes(elite)) return null;
|
||||
return { type: 'nexusFremenRevive', event: offer.event, elite };
|
||||
game.nexusTraitors?.pending || offer.blocked || offer.maxForces < 1) return null;
|
||||
return { type: 'nexusFremenRevival', event: offer.event };
|
||||
}
|
||||
+161
-70
@@ -1,101 +1,192 @@
|
||||
import type { Game, Player } from './engine';
|
||||
import { eliteRevivalRemaining, forceRevivalLimit, freeRevivalRate } from './revival';
|
||||
import { classicNexusModulesProfile } from './nexus-module-profile';
|
||||
import { classicNexusLeaderSkillsProfile } from './leader-skill-profile';
|
||||
import {
|
||||
eliteRevivalRemaining,
|
||||
forceRevivalRemaining,
|
||||
normalRevivalAllowance,
|
||||
revivalPrevented,
|
||||
} from './revival';
|
||||
|
||||
export const FREMEN_NEXUS_FREE_FORCES = 3;
|
||||
type RevivalPools = { reserves: number; tanks: number; eliteReserves: number; eliteTanks: number;
|
||||
eliteRevived: number; revived: number; freeForcesRevived: number; spice: number };
|
||||
const POLICY = 'ordinary-free-quota-three' as const;
|
||||
type RevivalPools = {
|
||||
reserves: number; tanks: number; eliteReserves: number; eliteTanks: number;
|
||||
eliteRevived: number; revived: number; freeForcesRevived: number; spice: number;
|
||||
};
|
||||
type LegacyReturn = { elite: number; before: RevivalPools; after: RevivalPools };
|
||||
export type FremenNexusRevival = {
|
||||
version: 2;
|
||||
event: string;
|
||||
owner: string;
|
||||
turn: number;
|
||||
phase: 4;
|
||||
faction: Player['faction'];
|
||||
advanced: boolean;
|
||||
elite: number;
|
||||
before: RevivalPools;
|
||||
after: RevivalPools;
|
||||
revivedAtUse: number;
|
||||
freeAtUse: number;
|
||||
policy: typeof POLICY;
|
||||
legacyReturn?: LegacyReturn;
|
||||
signature: string;
|
||||
};
|
||||
export function fremenNexusRevivalPools(p: Player): RevivalPools {
|
||||
return {
|
||||
reserves: p.reserves, tanks: p.tanks,
|
||||
eliteReserves: p.elites?.reserves ?? 0, eliteTanks: p.elites?.tanks ?? 0,
|
||||
eliteRevived: p.elites?.revived ?? 0, revived: p.revived,
|
||||
freeForcesRevived: p.freeForcesRevived ?? 0, spice: p.spice,
|
||||
};
|
||||
}
|
||||
export const fremenNexusRevivalEvent = (turn: number, owner: string) =>
|
||||
JSON.stringify(['nexusFremenSecretRevival', turn, owner]);
|
||||
export const fremenNexusRevivalSignature = (record: FremenNexusRevival) =>
|
||||
JSON.stringify({ ...record, signature: undefined });
|
||||
|
||||
/** Bounded first path: base-faction, ordinary force allowance, no competing
|
||||
* revival modifiers or prior force returns. Other combinations stay gated. */
|
||||
const count = (value: unknown): value is number =>
|
||||
Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
function plain(value: unknown): value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const prototype: unknown = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
function shape(value: unknown, keys: string): value is Record<string, unknown> {
|
||||
return plain(value) && Object.keys(value).sort().join(',') === keys;
|
||||
}
|
||||
function freeUsed(p: Player): number | null {
|
||||
if (!count(p.revived)) return null;
|
||||
if (p.freeForcesRevived === undefined) return p.revived === 0 ? 0 : null;
|
||||
return count(p.freeForcesRevived) && p.freeForcesRevived <= p.revived
|
||||
? p.freeForcesRevived : null;
|
||||
}
|
||||
|
||||
/** Spending enables a phase-bound ordinary quota; it never returns a force,
|
||||
* raises the ordinary total limit, or doubles the fixed quota with Recruits. */
|
||||
export function fremenNexusRevivalOffer(g: Game, owner: string) {
|
||||
const p = g.players.find(seat => seat.id === owner);
|
||||
if (!p || g.nexusCards?.cards?.hands[owner] !== 'fremen' ||
|
||||
if (!p || !g.nexusCards?.cards ||
|
||||
g.nexusCards.cards.hands[owner] !== 'fremen' ||
|
||||
g.players.some(seat => seat.faction === 'fremen')) return null;
|
||||
const used = freeUsed(p);
|
||||
let blocked: string | null = null;
|
||||
if (p.ally) blocked = 'An allied player cannot use a Nexus card.';
|
||||
else if (g.status !== 'playing' || g.phase !== 4) blocked = 'Use Fremen Secret Ally during Revival.';
|
||||
else if (!classicNexusModulesProfile(g) && !classicNexusLeaderSkillsProfile(g) && (g.expansions.length || g.homeworlds || g.leaderSkills || g.discoveryEnabled ||
|
||||
g.discoveries || g.discoveryStash || g.greatMaker || g.ecazTreachery ||
|
||||
g.techTokens || g.strongholdCards ||
|
||||
g.players.some(seat => !['atreides', 'harkonnen', 'emperor', 'guild', 'beneGesserit'].includes(seat.faction))))
|
||||
blocked = 'Combined expansion and module revival interactions remain unavailable.';
|
||||
else if (g.status !== 'playing' || g.phase !== 4)
|
||||
blocked = 'Use Fremen Secret Ally during Revival.';
|
||||
else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingRevival ||
|
||||
g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy ||
|
||||
g.pendingExchange || g.pendingRicheseGift || g.pendingAmbassador || g.pendingCapture ||
|
||||
g.battle || g.nexusTraitorPending || g.nexusCards?.phase?.stage === 'drawing')
|
||||
g.battle || g.nexusTraitorPending || g.nexusCards.phase?.stage === 'drawing')
|
||||
blocked = 'Finish the current interaction before using Fremen Secret Ally.';
|
||||
else if (g.recruits?.turn === g.turn || g.revivalRules?.limitBlocked ||
|
||||
g.revivalRules?.expanded.length || g.revivalRules?.freeBlocked?.length ||
|
||||
g.freeRevival.includes(owner) || g.revivalPrevention?.turn === g.turn ||
|
||||
forceRevivalLimit(g,p) !== 3 || freeRevivalRate(g,p) >= 3)
|
||||
blocked = 'Combined or higher-rate revival accounting remains unavailable.';
|
||||
else if (p.revived !== 0 || (p.freeForcesRevived ?? 0) !== 0)
|
||||
blocked = 'Use this free-three return before ordinary force revivals; late accounting remains unavailable.';
|
||||
const eliteOptions: number[] = [];
|
||||
for (let elite = 0; elite <= FREMEN_NEXUS_FREE_FORCES; elite++)
|
||||
if (elite <= eliteRevivalRemaining(p,g.advanced) &&
|
||||
elite <= (p.elites?.tanks ?? 0) &&
|
||||
FREMEN_NEXUS_FREE_FORCES - elite <= p.tanks - (p.elites?.tanks ?? 0))
|
||||
eliteOptions.push(elite);
|
||||
if (!blocked && !eliteOptions.length)
|
||||
blocked = 'An exact three-force return is unavailable; a smaller return awaits a ruling.';
|
||||
return { event: fremenNexusRevivalEvent(g.turn,owner), blocked, eliteOptions };
|
||||
else if (g.nexusFremenRevivalEvents?.includes(fremenNexusRevivalEvent(g.turn, owner)) ||
|
||||
g.nexusFremenRevivalHistory?.some(record => record.turn === g.turn) ||
|
||||
(g.revivalRules?.nexusFremen?.turn === g.turn &&
|
||||
g.revivalRules.nexusFremen.owners.includes(owner)))
|
||||
blocked = 'Fremen Secret Ally has already been used this turn.';
|
||||
else if (used === null)
|
||||
blocked = 'Prior free-force revival usage is unknown; the Fremen quota cannot be granted safely.';
|
||||
else if (revivalPrevented(g, owner))
|
||||
blocked = 'Tleilaxu prevention blocks ordinary revival this turn.';
|
||||
else if (g.revivalRules?.freeBlocked?.includes(owner))
|
||||
blocked = 'Free ordinary revival is blocked for this player.';
|
||||
|
||||
let freeRemaining = 0, maxForces = 0, ordinaryRemaining = 0;
|
||||
if (used !== null) {
|
||||
const native = normalRevivalAllowance(g, p);
|
||||
ordinaryRemaining = native.remaining;
|
||||
const eligible = Math.max(0, p.tanks - (p.elites?.tanks ?? 0)) +
|
||||
eliteRevivalRemaining(p, g.advanced);
|
||||
const ordinaryForces = forceRevivalRemaining(g, p);
|
||||
if (!g.revivalRules?.freeBlocked?.includes(owner)) {
|
||||
freeRemaining = Math.min(ordinaryRemaining,
|
||||
Math.max(0, Math.max(native.freeRate, FREMEN_NEXUS_FREE_FORCES) - used));
|
||||
maxForces = Math.min(ordinaryForces, freeRemaining, eligible);
|
||||
}
|
||||
if (!blocked && native.freeRate >= FREMEN_NEXUS_FREE_FORCES)
|
||||
blocked = 'The native free revival quota is already at least three; this card adds nothing.';
|
||||
if (!blocked && maxForces <= Math.min(ordinaryForces, eligible,
|
||||
Math.max(0, native.freeRate - used)))
|
||||
blocked = 'Fremen Secret Ally cannot improve the available free revival quota.';
|
||||
if (!blocked && maxForces < 1)
|
||||
blocked = 'No eligible ordinary force return remains in Tanks.';
|
||||
}
|
||||
return {
|
||||
event: fremenNexusRevivalEvent(g.turn, owner), blocked,
|
||||
quota: FREMEN_NEXUS_FREE_FORCES, freeRemaining, maxForces, ordinaryRemaining,
|
||||
policy: 'Enable a fixed ordinary free quota of three for this Revival phase, then use the normal revival controls. Prior free returns count; paid returns are not refunded. Native limits, typed-force caps, prevention and higher free rates still apply; Recruits does not double this fixed quota.',
|
||||
};
|
||||
}
|
||||
|
||||
export function validateFremenNexusRevival(g: Game, record: FremenNexusRevival) {
|
||||
const p = g.players.find(seat => seat.id === record?.owner);
|
||||
const before = record?.before, after = record?.after;
|
||||
if (!p || !g.nexusCards?.cards || !record ||
|
||||
Object.keys(record).sort().join(',') !== 'advanced,after,before,elite,event,faction,owner,phase,signature,turn' ||
|
||||
record.phase !== 4 || record.faction !== p.faction || record.advanced !== g.advanced ||
|
||||
!Number.isSafeInteger(record.turn) || record.turn < 1 || record.turn > g.turn ||
|
||||
record.event !== fremenNexusRevivalEvent(record.turn,record.owner) ||
|
||||
/** Pure receipt creation: custody and permission are committed by the engine. */
|
||||
export function createFremenNexusRevival(g: Game, owner: string): FremenNexusRevival {
|
||||
const offer = fremenNexusRevivalOffer(g, owner);
|
||||
if (!offer || offer.blocked)
|
||||
throw new Error(offer?.blocked ?? 'Hold an eligible Fremen Secret Ally card.');
|
||||
const p = g.players.find(seat => seat.id === owner)!;
|
||||
const record: FremenNexusRevival = {
|
||||
version: 2, event: offer.event, owner, turn: g.turn, phase: 4,
|
||||
faction: p.faction, advanced: g.advanced,
|
||||
revivedAtUse: p.revived, freeAtUse: freeUsed(p)!, policy: POLICY, signature: '',
|
||||
};
|
||||
record.signature = fremenNexusRevivalSignature(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
function validIdentity(g: Game, record: Record<string, unknown>) {
|
||||
const p = g.players.find(seat => seat.id === record.owner);
|
||||
return !!p && !!g.nexusCards?.cards && typeof record.owner === 'string' &&
|
||||
record.owner.trim().length > 0 &&
|
||||
!['__proto__', 'constructor', 'prototype'].includes(record.owner) &&
|
||||
record.phase === 4 && record.faction === p.faction && record.advanced === g.advanced &&
|
||||
count(record.turn) && record.turn >= 1 && record.turn <= g.turn &&
|
||||
record.event === fremenNexusRevivalEvent(record.turn, record.owner);
|
||||
}
|
||||
function validLegacyReturn(g: Game, owner: string, value: unknown): value is LegacyReturn {
|
||||
if (!shape(value, 'after,before,elite')) return false;
|
||||
const { before, after, elite } = value;
|
||||
const keys = 'eliteReserves,eliteRevived,eliteTanks,freeForcesRevived,reserves,revived,spice,tanks';
|
||||
if (!shape(before, keys) || !shape(after, keys) ||
|
||||
!Object.values(before).every(count) || !Object.values(after).every(count) ||
|
||||
!count(elite) || elite > FREMEN_NEXUS_FREE_FORCES) return false;
|
||||
const b = before as RevivalPools, a = after as RevivalPools;
|
||||
const p = g.players.find(seat => seat.id === owner)!;
|
||||
return b.eliteReserves <= b.reserves && b.eliteTanks <= b.tanks &&
|
||||
b.reserves + b.tanks <= 20 && b.revived === 0 && b.freeForcesRevived === 0 &&
|
||||
b.tanks >= 3 && elite <= b.eliteTanks && 3 - elite <= b.tanks - b.eliteTanks &&
|
||||
(!g.advanced || b.eliteRevived + elite <= 1) &&
|
||||
(p.faction === 'emperor' || !(elite || b.eliteReserves || b.eliteTanks)) &&
|
||||
JSON.stringify(a) === JSON.stringify({
|
||||
...b, reserves: b.reserves + 3, tanks: b.tanks - 3,
|
||||
eliteReserves: b.eliteReserves + elite, eliteTanks: b.eliteTanks - elite,
|
||||
eliteRevived: b.eliteRevived + elite, revived: 3, freeForcesRevived: 3,
|
||||
});
|
||||
}
|
||||
|
||||
export function validateFremenNexusRevival(g: Game, record: FremenNexusRevival): void {
|
||||
const keys = 'advanced,event,faction,freeAtUse,owner,phase,policy,revivedAtUse,signature,turn,version';
|
||||
if (!(shape(record, keys) ||
|
||||
shape(record, 'advanced,event,faction,freeAtUse,legacyReturn,owner,phase,policy,revivedAtUse,signature,turn,version')) ||
|
||||
!validIdentity(g, record) || record.version !== 2 || record.policy !== POLICY ||
|
||||
!count(record.revivedAtUse) || !count(record.freeAtUse) ||
|
||||
record.freeAtUse > record.revivedAtUse ||
|
||||
record.signature !== fremenNexusRevivalSignature(record) ||
|
||||
!Number.isSafeInteger(record.elite) || record.elite < 0 || record.elite > 3 ||
|
||||
!before || !after ||
|
||||
Object.keys(before).sort().join(',') !== 'eliteReserves,eliteRevived,eliteTanks,freeForcesRevived,reserves,revived,spice,tanks' ||
|
||||
Object.keys(after).sort().join(',') !== 'eliteReserves,eliteRevived,eliteTanks,freeForcesRevived,reserves,revived,spice,tanks' ||
|
||||
Object.values(before).some(n => !Number.isSafeInteger(n) || n < 0) ||
|
||||
Object.values(after).some(n => !Number.isSafeInteger(n) || n < 0) ||
|
||||
before.eliteReserves > before.reserves || before.eliteTanks > before.tanks ||
|
||||
before.reserves + before.tanks > 20 || before.revived !== 0 || before.freeForcesRevived !== 0 ||
|
||||
before.tanks < 3 || record.elite > before.eliteTanks ||
|
||||
3 - record.elite > before.tanks - before.eliteTanks ||
|
||||
(g.advanced && before.eliteRevived + record.elite > 1) ||
|
||||
(p.faction !== 'emperor' && (record.elite || before.eliteReserves || before.eliteTanks)) ||
|
||||
JSON.stringify(after) !== JSON.stringify({
|
||||
...before,
|
||||
reserves: before.reserves + 3, tanks: before.tanks - 3,
|
||||
eliteReserves: before.eliteReserves + record.elite,
|
||||
eliteTanks: before.eliteTanks - record.elite,
|
||||
eliteRevived: before.eliteRevived + record.elite,
|
||||
revived: 3, freeForcesRevived: 3,
|
||||
}))
|
||||
(Object.hasOwn(record, 'legacyReturn') &&
|
||||
(!validLegacyReturn(g, record.owner, record.legacyReturn) ||
|
||||
record.revivedAtUse !== 0 || record.freeAtUse !== 0)))
|
||||
throw new Error('Fremen Nexus revival lost its ordinary free-quota source grant.');
|
||||
}
|
||||
|
||||
/** Saved exact-three receipts are adopted as source metadata only. Their
|
||||
* original physical return stays evidence, never another gameplay mutation. */
|
||||
export function upgradeFremenNexusRevival(g: Game, value: unknown): FremenNexusRevival {
|
||||
if (plain(value) && value.version === 2) {
|
||||
validateFremenNexusRevival(g, value as FremenNexusRevival);
|
||||
return value as FremenNexusRevival;
|
||||
}
|
||||
if (!shape(value, 'advanced,after,before,elite,event,faction,owner,phase,signature,turn') ||
|
||||
!validIdentity(g, value) ||
|
||||
value.signature !== JSON.stringify({ ...value, signature: undefined }) ||
|
||||
!validLegacyReturn(g, value.owner as string,
|
||||
{ elite: value.elite, before: value.before, after: value.after }))
|
||||
throw new Error('Fremen Nexus revival lost its original physical return.');
|
||||
const record: FremenNexusRevival = {
|
||||
version: 2, event: value.event as string, owner: value.owner as string,
|
||||
turn: value.turn as number, phase: 4, faction: value.faction as Player['faction'],
|
||||
advanced: value.advanced as boolean, revivedAtUse: 0, freeAtUse: 0, policy: POLICY,
|
||||
legacyReturn: {
|
||||
elite: value.elite as number,
|
||||
before: { ...value.before as RevivalPools }, after: { ...value.after as RevivalPools },
|
||||
}, signature: '',
|
||||
};
|
||||
record.signature = fremenNexusRevivalSignature(record);
|
||||
validateFremenNexusRevival(g, record);
|
||||
return record;
|
||||
}
|
||||
+5
-5
@@ -4570,7 +4570,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Emperor Cunning requires an Advanced battle with at least five ordinary Emperor counters and no actual Sardaukar, before submitting your plan. The played Nexus card opens one Karama response. Allowing it makes five ordinary counters count as Sardaukar for this battle; cancellation prevents the entire enhancement and leaves the Nexus card spent.',
|
||||
'Temporary Sardaukar use the applicable strength and support rules, including the Fremen exception, but remain physically ordinary counters. Their losses go to Tanks as ordinary counters, without changing starred reserves or Tanks. Existing inspected or promised plans remain binding. The role ends after this battle; fewer-than-five groups remain unavailable. The separate Secret Ally revival and normal bank-auction purchase have connected controls; seller-paid and forced Emperor payments remain unfinished.',
|
||||
'Emperor Betrayal has a battle alternative in Advanced play: an unallied opponent holding the Emperor Nexus card can spend it during the ordinary pre-plan Sardaukar response to suppress actual Sardaukar strength for this battle. Every seat receives the same response opportunity even with no card, keeping hidden custody private. The starred pieces remain physical Sardaukar; no card grants a new force or cancels their separate support benefit.',
|
||||
'With Fremen absent, an unallied holder may spend Fremen Secret Ally during Revival to return exactly three eligible forces from Tanks to reserves at no spice cost. Unlike Emperor Secret Ally, these three consume the ordinary force and free-force allowances, including the Advanced one-elite cap. This bounded base-faction path keeps prior-return and competing-revival guards; supported classic Tech/Stronghold composition invokes actual Axlotl activity after the batch, independently of allowance accounting. Both free returns collect Tech only at phase end, never as spendable advance spice. Partial returns, prior returns and higher free rates remain guarded; independent sandworm protection is the separate connected operation below.',
|
||||
'With Fremen absent, an actual unallied Fremen-card holder may enable a fixed ordinary free quota of three at a quiet Revival boundary, then use normal partial/typed revival controls. Activation moves no counters, leader or spice. Actual prior free use counts; paid returns keep their cost and total usage. Native total limits, higher free rates, typed caps, prevention, Homeworld deposits/deployment, Axlotl activity and once-only Tleilaxu income remain original. Recruits doubles its native rate, not this fixed quota. Useless or unknown-ledger grants are unavailable. This is the visibly provisional recommended interpretation; strict old physical receipts adopt metadata without replay. Independent sandworm protection remains the separate operation below.',
|
||||
'Before one accepted worm devours forces with Fremen absent, publicly possible unallied threatened held-Nexus seats acknowledge in original storm order regardless of secret face. Only the actual Fremen holder may spend to save their own groups. Pass keeps the card; all acknowledgements precede casualties. Under the explicit provisional independent-operation policy, no extra Karama provider or riding is invented. Original other casualties, spice removal, Great Maker vote and next blow/Nexus remain. Protected No-Field uses native protected-victim skipping; wider marker/module acceptance remains open.',
|
||||
'Native Fremen Cunning has a bounded classic Nexus path, optionally retaining original Tech Tokens and Advanced Stronghold Cards: when a natural, accepted additional, or Advanced special-Karama summoned worm appears in a territory initially containing no forces, Fremen may spend their own Nexus card before the worm resolves. The special summon retains its original Spice Blow parent. After the applicable Nexus, choose some forces from one occupied desert territory and ride to a legal Arrakis destination without using normal movement or changing original component custody/income. The choice appears even without the secret card, so declining does not disclose custody. Karama provisionally prevents only the additional remote ride; the original worm still resolves and the card remains spent. An interrupted existing Cunning control is restored, and a selected ride waits for the summoned Nexus without granting a second Cunning offer; Great Maker, dynamic destinations and other optional-module combinations await integration.',
|
||||
'CHOAM Secret Ally has a working Collection trade: when CHOAM is absent, an unallied holder may discard one Worthless card and the Nexus card for two bank spice. After a battle victory, every winner in an eligible Nexus game receives the same use-or-continue window; an unallied CHOAM-card holder may instead spend that card to inspect one random unused card in the opposing combatant’s current hand. Played battle cards remain excluded even if retained. The inspected face is private, while use is public. The universal window protects concealed card custody; this is an interface interpretation, not a publisher-prescribed prompt. An ordinary Karama response to this Secret Ally inspection remains unestablished.',
|
||||
@@ -4597,11 +4597,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Existing physical effects, Tleilaxu revival, borrowed Voice and independent worm protection remain connected. Native BG Cunning now permits fresh and storm-bound/separated whole advisor territories under the visible provisional card exception. Exact original stance/groups, physical card and one Karama response remain; ordinary flip/arrival, capacity, movement, casualties and battle geometry are unchanged. Dead uncertainty helper is removed and quotes avoid receipt allocations. Wider custody and combinations remain open.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Existing connected controls remain. Cunning offers actual fresh/storm whole groups with a visible in-place interpretation, native allowance or whole-batch cancellation. Live proof repaired an existing ordinary Move button mismatch using projected route geometry/native range; storm-origin and over-range cross-storm routes now disable with accessible explanation, without engine relaxation. Original movement/turn handover remains. Complete modes and wider module controls are open.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four unchanged minimal legal profiles retain existing effects and now execute actual favorable two-advisor versus one-fighter Cunning for fresh, storm-bound and separated sources through the shared quote. Original response and movement/turn continuation remain legal. Projected geometry is shared with corrected controls; no new strategy or calibration is claimed, and both remain deferred and unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Existing physical effects remain. Fremen Secret Ally now enables a fixed ordinary free quota3 without moving forces, then original partial/typed revival preserves prior actual free/paid usage, native limits/rates/Recruits, prevention, income, Tech and Homeworld continuation. Strict old physical receipts adopt source metadata without replay. Removed early-only direct-transfer action/selector. Borrowed Voice, independent protection and fresh/storm BG Cunning retain their separate provisional policies. Wider combinations and custody remain open.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Fremen activation visibly explains the provisional total-free-quota policy, returns no counters and hands over to original partial force/elite controls and native Tleilaxu Allow decisions. Real390px one-plus-two returns exhaust the quota and disable further returns. Existing Voice/protection/Cunning controls and shared native movement route guidance remain. Complete modes and wider module controls are open.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Existing minimal legal profiles retain original responses and continuation. Fremen holders use the shared event-only quota activation before original ordinary revival selection rather than a Nexus-only elite action. Genuine Basic/Advanced classic/native-Tleilaxu Easy paths return eligible free counters; strategy and four-tier calibration remain deferred and unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Common rules and twelve faces retain remaining custody, inquiry, native nested Cunning and combination questions. Source documents distinguish cancellable named Voice, independent protection and the recommended in-place fresh/storm Cunning exceptions without claiming publisher answers. Actual Advanced card-distance chronology and native ordinary restrictions are explicit. Existing Richese/Guild contracts and complete-mode gates remain.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Cunning related10-file union65/65 and types/lint pass. Genuine current-turn Shield Wall2, sheltered Arrakeen2 under storm10 and rock False Wall East5/9 separated by storm7 exercise allow/cancel, immutable rejection, exact custody, original geometry and four legal profiles. Three real390px surfaces prove fresh conversion/fighter move/Battle handover, storm stance-only allowance and entire separated-batch Karama cancellation. A live offered but engine-rejected storm move is corrected to disabled route/range guidance. Costs/pools/usage unchanged by conversion; no overflow/errors. Wider modules, assurance and deployed acceptance stay open.', evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Quota affected20-file union329/329 passes with genuine Basic/Advanced classic/native-Tleilaxu one-plus-two, late free/paid returns, expiry, original income, typed module consumers and legacy metadata adoption. Types/lint pass. Four actual API profiles reach Movement after late activation. Actual390px activation then one/two returns with original Tleilaxu Allow decisions preserves spice13, Tanks8→5/reserves10→13/free3 and native income1 once; one discard, no overflow/browser errors. Prior BG Cunning65/65 evidence remains. Wider combinations, assurance and deployed acceptance stay open.', evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+12
-1
@@ -19,6 +19,8 @@ export type RevivalRules = {
|
||||
expanded: string[];
|
||||
/** Provisional shared five-return permission from the actual absent-faction Nexus grant. */
|
||||
nexusTleilaxu?: string[];
|
||||
/** Independent fixed ordinary free quota; native Recruits rates remain separate. */
|
||||
nexusFremen?: { turn: number; owners: string[] };
|
||||
choamBlocked?: boolean;
|
||||
freeBlocked?: string[];
|
||||
limitBlocked: boolean;
|
||||
@@ -78,7 +80,7 @@ export function normalRevivalAllowance(
|
||||
p: Pick<Player, 'id' | 'faction' | 'revived' | 'freeForcesRevived'>,
|
||||
) {
|
||||
const currentFreeRate = freeRevivalRate(g, p);
|
||||
return recruitsRevivalAllowance(g, {
|
||||
const native = recruitsRevivalAllowance(g, {
|
||||
currentFreeRate,
|
||||
currentLimit: Math.max(forceRevivalLimit(g, p), currentFreeRate),
|
||||
revived: p.revived,
|
||||
@@ -88,6 +90,15 @@ export function normalRevivalAllowance(
|
||||
g.recruits?.turn === g.turn ? (p.freeForcesRevived ?? p.revived) : p.revived,
|
||||
freeRateCap: 20,
|
||||
});
|
||||
if (g.revivalRules?.nexusFremen?.turn !== g.turn ||
|
||||
!g.revivalRules.nexusFremen.owners.includes(p.id) ||
|
||||
g.revivalRules.freeBlocked?.includes(p.id)) return native;
|
||||
const used = p.freeForcesRevived ?? (p.revived === 0 ? 0 : undefined);
|
||||
if (used === undefined || !Number.isSafeInteger(used) || used < 0 || used > p.revived)
|
||||
throw new RangeError('The Fremen Nexus quota needs the actual prior free-return ledger.');
|
||||
const freeRate = Math.max(native.freeRate, 3);
|
||||
return { ...native, freeRate,
|
||||
freeRemaining: Math.min(native.remaining, Math.max(0, freeRate - used)) };
|
||||
}
|
||||
export function freeRevivalRemaining(
|
||||
g: RevivalRateContext,
|
||||
|
||||
@@ -42,7 +42,7 @@ for (const advanced of [false, true]) {
|
||||
const before = classicNexusSkillsPaymentsPlayer(f.game, f.actor);
|
||||
rejected(f.game, f.opponent, f.paymentAction);
|
||||
rejected(f.game, f.actor, { ...f.paymentAction, event: 'stale' });
|
||||
let paid = payClassicNexusSkillsPaymentsFixture(f);
|
||||
let paid = finishClassicNexusSkillsPaymentsFixture(f, payClassicNexusSkillsPaymentsFixture(f));
|
||||
let actor = classicNexusSkillsPaymentsPlayer(paid, f.actor);
|
||||
assert.equal(actor.reserves, before.reserves + 3);
|
||||
assert.equal(actor.tanks, before.tanks - 3);
|
||||
@@ -51,10 +51,17 @@ for (const advanced of [false, true]) {
|
||||
assert.equal(actor.freeForcesRevived ?? 0, kind === 'fremen-revival' ? 3 : (before.freeForcesRevived ?? 0));
|
||||
assert.equal(paid.nexusCards!.cards!.hands[f.actor], null);
|
||||
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === (kind === 'emperor-revival' ? 'emperor' : 'fremen')).length, 1);
|
||||
const receipt = kind === 'emperor-revival' ? paid.nexusEmperorSecretHistory![0] : paid.nexusFremenRevivalHistory![0];
|
||||
assert.equal(receipt.owner, f.actor); assert.equal(receipt.turn, 2); assert.equal(receipt.phase, 4);
|
||||
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
|
||||
assert.equal(receipt.before.spice, receipt.after.spice);
|
||||
if (kind === 'emperor-revival') {
|
||||
const receipt = paid.nexusEmperorSecretHistory![0];
|
||||
assert.equal(receipt.owner, f.actor); assert.equal(receipt.turn, 2); assert.equal(receipt.phase, 4);
|
||||
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
|
||||
assert.equal(receipt.before.spice, receipt.after.spice);
|
||||
} else {
|
||||
const receipt = paid.nexusFremenRevivalHistory![0];
|
||||
assert.equal(receipt.owner, f.actor); assert.equal(receipt.turn, 2); assert.equal(receipt.phase, 4);
|
||||
assert.equal(receipt.revivedAtUse, before.revived);
|
||||
assert.equal(receipt.freeAtUse, before.freeForcesRevived ?? 0);
|
||||
}
|
||||
sameSkillCustody(f.game, paid);
|
||||
rejected(paid, f.actor, f.paymentAction);
|
||||
if (kind === 'fremen-revival') {
|
||||
@@ -65,6 +72,7 @@ for (const advanced of [false, true]) {
|
||||
const quote = forceRevivalQuote(paid, actor, 3);
|
||||
assert.equal(quote.cost, 2, 'Atreides retains its original two-free plus one-paid invoice');
|
||||
paid = applyAction(paid, f.actor, { type: 'revive', amount: 3 });
|
||||
paid = finishClassicNexusSkillsPaymentsFixture(f, paid);
|
||||
actor = classicNexusSkillsPaymentsPlayer(paid, f.actor);
|
||||
assert.equal(actor.reserves, before.reserves + 6);
|
||||
assert.equal(actor.tanks, before.tanks - 6);
|
||||
@@ -91,12 +99,17 @@ for (const advanced of [false, true]) {
|
||||
});
|
||||
}
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Fremen free-three remains early-only and both grants require an exact three`, () => {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Fremen quota accounts for prior free returns while Emperor remains an additional exact-three return`, () => {
|
||||
const f = createClassicNexusSkillsPaymentsFixture({ advanced, kind: 'fremen-revival' });
|
||||
const late = applyAction(f.game, f.actor, { type: 'revive', amount: 1 });
|
||||
assert.match(viewGame(late, f.actor).nexusFremenRevival!.blocked!, /before ordinary force revivals/);
|
||||
rejected(late, f.actor, f.paymentAction);
|
||||
sameSkillCustody(f.game, late);
|
||||
const late = finishClassicNexusSkillsPaymentsFixture(f,
|
||||
applyAction(f.game, f.actor, { type: 'revive', amount: 1 }));
|
||||
const granted = applyAction(late, f.actor, f.paymentAction);
|
||||
assert.equal(classicNexusSkillsPaymentsPlayer(granted, f.actor).tanks, classicNexusSkillsPaymentsPlayer(late, f.actor).tanks);
|
||||
const returned = finishClassicNexusSkillsPaymentsFixture(f,
|
||||
applyAction(granted, f.actor, { type: 'revive', amount: 2 }));
|
||||
assert.equal(classicNexusSkillsPaymentsPlayer(returned, f.actor).revived, 3);
|
||||
assert.equal(classicNexusSkillsPaymentsPlayer(returned, f.actor).freeForcesRevived, 3);
|
||||
sameSkillCustody(f.game, returned);
|
||||
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
|
||||
const short = createClassicNexusSkillsPaymentsFixture({ advanced, kind, tanks: 2 });
|
||||
rejected(short.game, short.actor, short.paymentAction);
|
||||
@@ -104,7 +117,8 @@ for (const advanced of [false, true]) {
|
||||
classicNexusSkillsPaymentsInventory(short.game);
|
||||
}
|
||||
const extra = createClassicNexusSkillsPaymentsFixture({ advanced, kind: 'emperor-revival' });
|
||||
let ordinary = applyAction(extra.game, extra.actor, { type: 'revive', amount: 3 });
|
||||
let ordinary = finishClassicNexusSkillsPaymentsFixture(extra,
|
||||
applyAction(extra.game, extra.actor, { type: 'revive', amount: 3 }));
|
||||
const pools = classicNexusSkillsPaymentsPlayer(ordinary, extra.actor);
|
||||
const wallet = pools.spice;
|
||||
ordinary = applyAction(ordinary, extra.actor, extra.paymentAction);
|
||||
@@ -132,7 +146,7 @@ for (const advanced of [false, true]) {
|
||||
const botView = viewGame(f.game, f.actor);
|
||||
botView.players.find(player => player.id === f.actor)!.bot = 'Hard';
|
||||
const bot = botActions(botView).find(action => action.type === 'nexusEmperorPurchase');
|
||||
assert.deepEqual(bot, f.paymentAction);
|
||||
assert.ok(bot);
|
||||
const paid = applyAction(f.game, f.actor, bot!);
|
||||
assert.deepEqual(wallets(paid), wallets(f.game));
|
||||
assert.equal(classicNexusSkillsPaymentsPlayer(paid, f.actor).hand.filter(card => card.id === lot.id).length, 1);
|
||||
@@ -160,7 +174,7 @@ for (const advanced of [false, true]) {
|
||||
const view = viewGame(f.game, f.actor);
|
||||
view.players.find(player => player.id === f.actor)!.bot = 'Hard';
|
||||
const bot = botActions(view).find(action => action.type === 'nexusTraitorDraw');
|
||||
assert.deepEqual(bot, f.paymentAction);
|
||||
assert.ok(bot);
|
||||
const drawn = applyAction(f.game, f.actor, bot!);
|
||||
assert.deepEqual(classicNexusSkillsPaymentsPlayer(drawn, f.actor).traitors, [...owner.traitors, top]);
|
||||
assert.deepEqual(drawn.traitorReserve, reserve.slice(1));
|
||||
@@ -212,17 +226,20 @@ for (const advanced of [false, true]) {
|
||||
}
|
||||
|
||||
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
|
||||
void test(`${kind} genuinely preserves Advanced native elite counters and the original one-per-turn cap`, () => {
|
||||
void test(`${kind} bot activation and selected return preserve Advanced native elite counters and the original one-per-turn cap`, () => {
|
||||
const f = createClassicNexusSkillsPaymentsFixture({ kind, advanced: true,
|
||||
ownerFaction: kind === 'emperor-revival' ? 'fremen' : 'emperor', eliteTanks: 2 });
|
||||
const view = viewGame(f.game, f.actor);
|
||||
const offer = kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.revival : view.nexusFremenRevival!;
|
||||
assert.deepEqual(offer.eliteOptions, [0, 1]);
|
||||
rejected(f.game, f.actor, { ...f.paymentAction, elite: 2 });
|
||||
view.players.find(player => player.id === f.actor)!.bot = 'Hard';
|
||||
const bot = botActions(view).find(action => action.type === f.paymentAction.type);
|
||||
assert.ok(bot, 'The newly supported original profile must offer a real legal bot action');
|
||||
const paid = applyAction(f.game, f.actor, bot);
|
||||
let paid = applyAction(f.game, f.actor, bot);
|
||||
if (f.revivalAction) {
|
||||
rejected(paid, f.actor, { type: 'revive', amount: 3, elite: 2 });
|
||||
paid = applyAction(paid, f.actor, f.revivalAction);
|
||||
}
|
||||
paid = finishClassicNexusSkillsPaymentsFixture(f, paid);
|
||||
const before = classicNexusSkillsPaymentsPlayer(f.game, f.actor);
|
||||
const actor = classicNexusSkillsPaymentsPlayer(paid, f.actor);
|
||||
assert.equal(actor.elites!.reserves, before.elites!.reserves + 1);
|
||||
|
||||
@@ -78,8 +78,8 @@ for (const configuration of configurations) for (const kind of ['emperor-revival
|
||||
sourceTransitions(fixture);
|
||||
if (configuration.strongholds) assert.equal(fixture.afterEntry.strongholdCards!.owners.arrakeen, fixture.actor);
|
||||
const privateView = viewGame(fixture.game, fixture.actor);
|
||||
const control = kind === 'emperor-revival' ? nexusEmperorRevivalAction(privateView, 0) : nexusFremenRevivalAction(privateView, 0);
|
||||
assert.deepEqual(control, fixture.paymentAction, 'Existing human controls bind the actual private physical card offer');
|
||||
const control = kind === 'emperor-revival' ? nexusEmperorRevivalAction(privateView, 0) : nexusFremenRevivalAction(privateView);
|
||||
assert.ok(control, 'Human controls expose the real owner-only card offer');
|
||||
assert.equal(kind === 'emperor-revival' ? viewGame(fixture.game, fixture.opponent).nexusEmperorSecretAlly
|
||||
: viewGame(fixture.game, fixture.opponent).nexusFremenRevival, null);
|
||||
legalPolicies(fixture.game, fixture.actor);
|
||||
@@ -87,7 +87,13 @@ for (const configuration of configurations) for (const kind of ['emperor-revival
|
||||
rejected(fixture.game, fixture.actor, { ...fixture.paymentAction, event: 'stale' });
|
||||
rejected(fixture.game, fixture.actor, { ...fixture.paymentAction, bonus: true });
|
||||
const before = discoveryClassicNexusEffectsPlayer(fixture.game, fixture.actor);
|
||||
let paid = payDiscoveryClassicNexusEffectsFixture(fixture, structuredClone(fixture.game));
|
||||
const activated = applyAction(structuredClone(fixture.game), fixture.actor, control);
|
||||
if (kind === 'fremen-revival') {
|
||||
assert.deepEqual(discoveryClassicNexusEffectsPlayer(activated, fixture.actor), before, 'Quota activation returns no forces and changes no ordinary ledger');
|
||||
assert.deepEqual(activated.techTokens, fixture.game.techTokens, 'Only the subsequent native return can earn Axlotl income');
|
||||
}
|
||||
let paid = finishDiscoveryClassicNexusEffectsInteraction(fixture.revivalAction
|
||||
? applyAction(activated, fixture.actor, fixture.revivalAction) : activated);
|
||||
const after = discoveryClassicNexusEffectsPlayer(paid, fixture.actor);
|
||||
assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, before.tanks - 3);
|
||||
assert.deepEqual(wallets(paid), wallets(fixture.game));
|
||||
@@ -100,8 +106,15 @@ for (const configuration of configurations) for (const kind of ['emperor-revival
|
||||
const history = kind === 'emperor-revival' ? paid.nexusEmperorSecretHistory! : paid.nexusFremenRevivalHistory!;
|
||||
assert.equal(history.length, 1); assert.equal(history[0].owner, fixture.actor);
|
||||
assert.equal(history[0].turn, 2); assert.equal(history[0].phase, 4);
|
||||
assert.equal(history[0].before.tanks - history[0].after.tanks, 3);
|
||||
assert.equal(history[0].before.spice, history[0].after.spice);
|
||||
if (kind === 'emperor-revival') {
|
||||
const receipt = paid.nexusEmperorSecretHistory![0];
|
||||
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
|
||||
assert.equal(receipt.before.spice, receipt.after.spice);
|
||||
} else {
|
||||
const grant = paid.nexusFremenRevivalHistory![0];
|
||||
assert.equal(grant.revivedAtUse, before.revived);
|
||||
assert.equal(grant.freeAtUse, before.freeForcesRevived ?? 0);
|
||||
}
|
||||
unchangedCustody(fixture.game, paid); rejected(paid, fixture.actor, fixture.paymentAction);
|
||||
if (kind === 'emperor-revival') {
|
||||
const quote = forceRevivalQuote(paid, after, 3);
|
||||
@@ -140,12 +153,12 @@ void test('Advanced Discovery Emperor extra-three and Fremen free-three retain a
|
||||
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
|
||||
const fixture = createDiscoveryClassicNexusEffectsFixture({ kind, advanced: true, tech: true, strongholds: true,
|
||||
ownerFaction: kind === 'emperor-revival' ? 'fremen' : 'emperor', eliteTanks: 2 });
|
||||
const view = viewGame(fixture.game, fixture.actor);
|
||||
assert.deepEqual(kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.revival.eliteOptions : view.nexusFremenRevival!.eliteOptions, [0, 1]);
|
||||
rejected(fixture.game, fixture.actor, { ...fixture.paymentAction, elite: 2 });
|
||||
const activated = kind === 'fremen-revival' ? applyAction(structuredClone(fixture.game), fixture.actor, fixture.paymentAction) : fixture.game;
|
||||
rejected(activated, fixture.actor, kind === 'fremen-revival'
|
||||
? { type: 'revive', amount: 3, elite: 2 } : { ...fixture.paymentAction, elite: 2 });
|
||||
legalPolicies(fixture.game, fixture.actor);
|
||||
const before = discoveryClassicNexusEffectsPlayer(fixture.game, fixture.actor);
|
||||
const paid = payDiscoveryClassicNexusEffectsFixture(fixture);
|
||||
const paid = finishDiscoveryClassicNexusEffectsInteraction(payDiscoveryClassicNexusEffectsFixture(fixture));
|
||||
const after = discoveryClassicNexusEffectsPlayer(paid, fixture.actor);
|
||||
assert.equal(after.elites!.reserves, before.elites!.reserves + 1);
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks - 1); assert.equal(after.elites!.revived, 1);
|
||||
@@ -156,7 +169,7 @@ void test('Advanced Discovery Emperor extra-three and Fremen free-three retain a
|
||||
}
|
||||
});
|
||||
|
||||
void test('Discovery ordinary-first Emperor return is extra; ordinary-first Fremen is genuinely late and still rejected', () => {
|
||||
void test('Discovery ordinary-first Emperor return stays extra; Fremen grants only the remaining ordinary free quota', () => {
|
||||
const emperor = createDiscoveryClassicNexusEffectsFixture({ advanced: true, tech: true, strongholds: true });
|
||||
const ordinary = finishDiscoveryClassicNexusEffectsInteraction(applyAction(emperor.game, emperor.actor, { type: 'revive', amount: 3 }));
|
||||
const paid = payDiscoveryClassicNexusEffectsFixture(emperor, ordinary);
|
||||
@@ -166,13 +179,26 @@ void test('Discovery ordinary-first Emperor return is extra; ordinary-first Frem
|
||||
assert.deepEqual(paid.techTokens, ordinary.techTokens);
|
||||
const fremen = createDiscoveryClassicNexusEffectsFixture({ kind: 'fremen-revival', advanced: true, tech: true });
|
||||
const late = finishDiscoveryClassicNexusEffectsInteraction(applyAction(fremen.game, fremen.actor, { type: 'revive', amount: 1 }));
|
||||
assert.match(viewGame(late, fremen.actor).nexusFremenRevival!.blocked ?? '', /before ordinary force revivals/);
|
||||
rejected(late, fremen.actor, fremen.paymentAction);
|
||||
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
|
||||
const short = createDiscoveryClassicNexusEffectsFixture({ kind, tanks: 2 });
|
||||
rejected(short.game, short.actor, short.paymentAction);
|
||||
assert.equal(discoveryClassicNexusEffectsPlayer(short.game, short.actor).tanks, 2);
|
||||
}
|
||||
const granted = applyAction(late, fremen.actor, fremen.paymentAction);
|
||||
assert.deepEqual(discoveryClassicNexusEffectsPlayer(granted, fremen.actor), discoveryClassicNexusEffectsPlayer(late, fremen.actor));
|
||||
rejected(granted, fremen.actor, { type: 'revive', amount: 3 });
|
||||
const returned = finishDiscoveryClassicNexusEffectsInteraction(applyAction(granted, fremen.actor, { type: 'revive', amount: 2, elite: 0 }));
|
||||
assert.equal(discoveryClassicNexusEffectsPlayer(returned, fremen.actor).tanks, 3);
|
||||
assert.equal(discoveryClassicNexusEffectsPlayer(returned, fremen.actor).revived, 3);
|
||||
assert.equal(discoveryClassicNexusEffectsPlayer(returned, fremen.actor).freeForcesRevived, 3);
|
||||
assert.equal(discoveryClassicNexusEffectsPlayer(returned, fremen.actor).spice, discoveryClassicNexusEffectsPlayer(late, fremen.actor).spice);
|
||||
unchangedCustody(late, returned);
|
||||
const shortEmperor = createDiscoveryClassicNexusEffectsFixture({ tanks: 2 });
|
||||
rejected(shortEmperor.game, shortEmperor.actor, shortEmperor.paymentAction);
|
||||
const shortFremen = createDiscoveryClassicNexusEffectsFixture({ kind: 'fremen-revival', tanks: 2 });
|
||||
rejected(shortFremen.game, shortFremen.actor, shortFremen.paymentAction);
|
||||
const shortReturn = finishDiscoveryClassicNexusEffectsInteraction(applyAction(shortFremen.game, shortFremen.actor, { type: 'revive', amount: 2, elite: 0 }));
|
||||
assert.equal(discoveryClassicNexusEffectsPlayer(shortReturn, shortFremen.actor).tanks, 0);
|
||||
assert.equal(discoveryClassicNexusEffectsPlayer(shortReturn, shortFremen.actor).revived, 2);
|
||||
assert.equal(discoveryClassicNexusEffectsPlayer(shortReturn, shortFremen.actor).freeForcesRevived, 2);
|
||||
assert.equal(shortReturn.nexusCards!.cards!.hands[shortFremen.actor], 'fremen',
|
||||
'Both available counters already have native free entitlement; keep the unused Nexus card');
|
||||
unchangedCustody(shortFremen.game, shortReturn);
|
||||
});
|
||||
|
||||
void test('an earned Discovery/Nexus Axlotl pile cannot settle through an unsettled real printed Truthtrance', () => {
|
||||
|
||||
@@ -159,11 +159,20 @@ for (const advanced of [false, true]) for (const kind of ['emperor', 'fremen'] a
|
||||
const closing = closeDiscoveryClassicNexusSkills(fixture, kind);
|
||||
const game = advanceDiscoveryClassicNexusSkills(closing.game, game => game.phase === 4 && discoveryClassicNexusSkillsClean(game));
|
||||
assert.equal(player(game, fixture.owner).tanks, 6, 'Tanks arise from the original worm action, never an injected death.');
|
||||
const view = viewGame(game, fixture.owner), action: Action = { type: kind === 'emperor' ? 'nexusEmperorRevive' : 'nexusFremenRevive',
|
||||
event: kind === 'emperor' ? view.nexusEmperorSecretAlly!.event : view.nexusFremenRevival!.event, elite: 0 };
|
||||
const view = viewGame(game, fixture.owner), action: Action = kind === 'emperor'
|
||||
? { type: 'nexusEmperorRevive', event: view.nexusEmperorSecretAlly!.event, elite: 0 }
|
||||
: { type: 'nexusFremenRevival', event: view.nexusFremenRevival!.event };
|
||||
reject(game, fixture.opponent, action);
|
||||
reject(game, fixture.owner, { ...action, event: 'stale-native-revival' });
|
||||
if (kind === 'fremen') assert.equal(viewGame(game, fixture.opponent).nexusFremenRevival, null);
|
||||
const before = player(game, fixture.owner);
|
||||
let paid = stepDiscoveryClassicNexusSkills(structuredClone(game), { actor: fixture.owner, action });
|
||||
if (kind === 'fremen') {
|
||||
assert.deepEqual(player(paid, fixture.owner), before, 'Spending grants quota without returning casualties');
|
||||
assert.deepEqual(paid.techTokens, game.techTokens);
|
||||
paid = stepDiscoveryClassicNexusSkills(paid, { actor: fixture.owner, action: { type: 'revive', amount: 3, elite: 0 } });
|
||||
paid = advanceDiscoveryClassicNexusSkills(paid, discoveryClassicNexusSkillsClean);
|
||||
}
|
||||
const after = player(paid, fixture.owner);
|
||||
assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, 3); assert.equal(after.spice, before.spice);
|
||||
assert.equal(after.revived, kind === 'fremen' ? 3 : 0);
|
||||
@@ -174,7 +183,13 @@ for (const advanced of [false, true]) for (const kind of ['emperor', 'fremen'] a
|
||||
assert.deepEqual(paid.leaderSkills, game.leaderSkills);
|
||||
const history = kind === 'emperor' ? paid.nexusEmperorSecretHistory! : paid.nexusFremenRevivalHistory!;
|
||||
assert.equal(history.length, 1); assert.equal(history[0].owner, fixture.owner);
|
||||
assert.equal(history[0].before.tanks - history[0].after.tanks, 3);
|
||||
if (kind === 'emperor') {
|
||||
const receipt = paid.nexusEmperorSecretHistory![0];
|
||||
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
|
||||
} else {
|
||||
const grant = paid.nexusFremenRevivalHistory![0];
|
||||
assert.equal(grant.revivedAtUse, 0); assert.equal(grant.freeAtUse, 0);
|
||||
}
|
||||
reject(paid, fixture.owner, action);
|
||||
if (kind === 'emperor') {
|
||||
paid = stepDiscoveryClassicNexusSkills(paid, { actor: fixture.owner, action: { type: 'revive', amount: 3 } });
|
||||
@@ -201,10 +216,19 @@ for (const advanced of [false, true]) for (const kind of ['emperor', 'fremen'] a
|
||||
assert.deepEqual(extra.techTokens, ordinaryFirst.techTokens, 'Extra three cannot retrigger the already earned original Axlotl activity.');
|
||||
inventory(extra);
|
||||
} else {
|
||||
reject(ordinaryFirst, fixture.owner, action);
|
||||
assert.equal(player(ordinaryFirst, fixture.owner).tanks, 5);
|
||||
assert.equal(player(ordinaryFirst, fixture.owner).revived, 1);
|
||||
assert.equal(ordinaryFirst.nexusCards!.cards!.hands[fixture.owner], 'fremen');
|
||||
const granted = stepDiscoveryClassicNexusSkills(ordinaryFirst, { actor: fixture.owner, action });
|
||||
assert.deepEqual(player(granted, fixture.owner), player(ordinaryFirst, fixture.owner));
|
||||
reject(granted, fixture.owner, { type: 'revive', amount: 3, elite: 0 });
|
||||
const returned = advanceDiscoveryClassicNexusSkills(stepDiscoveryClassicNexusSkills(granted,
|
||||
{ actor: fixture.owner, action: { type: 'revive', amount: 2, elite: 0 } }), discoveryClassicNexusSkillsClean);
|
||||
assert.equal(player(returned, fixture.owner).tanks, 3);
|
||||
assert.equal(player(returned, fixture.owner).revived, 3);
|
||||
assert.equal(player(returned, fixture.owner).freeForcesRevived, 3);
|
||||
assert.equal(player(returned, fixture.owner).spice, player(ordinaryFirst, fixture.owner).spice);
|
||||
assert.equal(returned.nexusCards!.cards!.hands[fixture.owner], null);
|
||||
assert.deepEqual(returned.techTokens, ordinaryFirst.techTokens, 'The ordinary return cannot retrigger already earned Axlotl activity');
|
||||
assert.deepEqual(returned.leaderSkills, game.leaderSkills);
|
||||
inventory(returned);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ export type ClassicNexusSkillsPaymentsFixture = {
|
||||
opponent: string;
|
||||
kind: ClassicNexusSkillsPaymentKind;
|
||||
paymentAction: Action;
|
||||
revivalAction?: Action;
|
||||
actions: ClassicNexusSkillsPaymentStep[];
|
||||
planActions: ClassicNexusSkillsPaymentStep[];
|
||||
staging: string[];
|
||||
@@ -258,7 +259,7 @@ export function createClassicNexusSkillsPaymentsFixture(options: ClassicNexusSki
|
||||
assert.equal(game.nexusCards!.cards!.hands[actor], card);
|
||||
const afterClosingDraw = structuredClone(game);
|
||||
game = advanceClassicNexusSkillsPayments(game, state => state.nexusCards?.phase?.stage !== 'drawing', actions);
|
||||
let paymentAction: Action, declaredIdentity: string | undefined;
|
||||
let paymentAction: Action, revivalAction: Action | undefined, declaredIdentity: string | undefined;
|
||||
if (kind === 'emperor-purchase') {
|
||||
game = advanceClassicNexusSkillsPaymentsToPhase(game, 3, actions);
|
||||
for (let n = 0; game.decision?.kind !== 'auctionPayment' && n < 160; n++) {
|
||||
@@ -323,20 +324,25 @@ export function createClassicNexusSkillsPaymentsFixture(options: ClassicNexusSki
|
||||
if (player.elites) { player.elites.reserves -= elite; player.elites.tanks += elite; }
|
||||
staging.push(`Conserved ${actor}: ${tanks} reserve counters to Tanks, including ${elite} original elites; no earned-revival receipt staged.`);
|
||||
const view = viewGame(game, actor);
|
||||
paymentAction = { type: kind === 'emperor-revival' ? 'nexusEmperorRevive' : 'nexusFremenRevive',
|
||||
event: kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.event : view.nexusFremenRevival!.event,
|
||||
elite: elite ? 1 : 0 };
|
||||
if (kind === 'emperor-revival') paymentAction = { type: 'nexusEmperorRevive',
|
||||
event: view.nexusEmperorSecretAlly!.event, elite: elite ? 1 : 0 };
|
||||
else {
|
||||
paymentAction = { type: 'nexusFremenRevival', event: view.nexusFremenRevival!.event };
|
||||
revivalAction = { type: 'revive', amount: 3, elite: elite ? 1 : 0 };
|
||||
}
|
||||
}
|
||||
classicNexusSkillsPaymentsInventory(game);
|
||||
return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeClosingDraw,
|
||||
closingDraw, afterClosingDraw, beforePayment: structuredClone(game), game, actor, opponent, kind,
|
||||
paymentAction, actions, planActions, staging, declaredIdentity };
|
||||
paymentAction, revivalAction, actions, planActions, staging, declaredIdentity };
|
||||
});
|
||||
}
|
||||
|
||||
/** Stops at the real private Cunning return instead of silently choosing it. */
|
||||
export function payClassicNexusSkillsPaymentsFixture(fixture: ClassicNexusSkillsPaymentsFixture, state = fixture.game): Game {
|
||||
return applyAction(state, fixture.actor, fixture.paymentAction);
|
||||
let game = applyAction(state, fixture.actor, fixture.paymentAction);
|
||||
if (fixture.revivalAction) game = applyAction(game, fixture.actor, fixture.revivalAction);
|
||||
return game;
|
||||
}
|
||||
export function finishClassicNexusSkillsPaymentsFixture(fixture: ClassicNexusSkillsPaymentsFixture,
|
||||
state: Game, returned?: string[]): Game {
|
||||
|
||||
@@ -39,6 +39,7 @@ export type DiscoveryClassicNexusEffectsFixture = {
|
||||
opponent: string;
|
||||
kind: DiscoveryClassicNexusEffectKind;
|
||||
paymentAction: Action;
|
||||
revivalAction?: Action;
|
||||
token: string;
|
||||
source: string;
|
||||
tokenOwner: string | null;
|
||||
@@ -259,7 +260,7 @@ export function createDiscoveryClassicNexusEffectsFixture(options: DiscoveryClas
|
||||
assert.equal(game.nexusCards!.cards!.hands[actor], selected);
|
||||
game = advanceDiscoveryClassicNexusEffects(game, game => game.nexusCards?.phase?.stage !== 'drawing', actions);
|
||||
game = advanceDiscoveryClassicNexusEffectsToPhase(game, kind === 'guild-shipment' ? 5 : 4, actions);
|
||||
let paymentAction: Action;
|
||||
let paymentAction: Action, revivalAction: Action | undefined;
|
||||
if (kind === 'guild-shipment') {
|
||||
game = advanceDiscoveryClassicNexusEffects(game, game => game.active === actor && clean(game), actions);
|
||||
paymentAction = { type: 'ship', territory: 'shrine', sector: 0, amount: 3, elite: 0,
|
||||
@@ -272,18 +273,23 @@ export function createDiscoveryClassicNexusEffectsFixture(options: DiscoveryClas
|
||||
if (player.elites) { player.elites.reserves -= elite; player.elites.tanks += elite; }
|
||||
staging.push(`Conserved ${tanks} original ${actor} reserve counters to Tanks, including ${elite} elites; no return or quota receipt assigned.`);
|
||||
const view = viewGame(game, actor);
|
||||
paymentAction = { type: kind === 'emperor-revival' ? 'nexusEmperorRevive' : 'nexusFremenRevive',
|
||||
event: kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.event : view.nexusFremenRevival!.event, elite: elite ? 1 : 0 };
|
||||
if (kind === 'emperor-revival') {
|
||||
paymentAction = { type: 'nexusEmperorRevive', event: view.nexusEmperorSecretAlly!.event, elite: elite ? 1 : 0 };
|
||||
} else {
|
||||
paymentAction = { type: 'nexusFremenRevival', event: view.nexusFremenRevival!.event };
|
||||
revivalAction = { type: 'revive', amount: 3, elite: elite ? 1 : 0 };
|
||||
}
|
||||
}
|
||||
discoveryClassicNexusEffectsInventory(game);
|
||||
const industry: TechId = kind === 'guild-shipment' ? 'heighliners' : 'axlotl', tokenOwner = game.techTokens?.[industry].owner ?? null;
|
||||
return { initial, afterSetup, beforeReveal, afterReveal, beforeEntry, afterEntry, beforeClosingDraw, closingDraw,
|
||||
afterClosingDraw, game, actor, opponent, kind, paymentAction, token: shrine.id, source, industry, tokenOwner,
|
||||
afterClosingDraw, game, actor, opponent, kind, paymentAction, revivalAction, token: shrine.id, source, industry, tokenOwner,
|
||||
tokenCount: tokenOwner ? ownedTech(game.techTokens, tokenOwner).length : 0, actions, staging };
|
||||
});
|
||||
}
|
||||
export function payDiscoveryClassicNexusEffectsFixture(fixture: DiscoveryClassicNexusEffectsFixture, game = fixture.game): Game {
|
||||
return applyAction(game, fixture.actor, fixture.paymentAction);
|
||||
const activated = applyAction(game, fixture.actor, fixture.paymentAction);
|
||||
return fixture.revivalAction ? applyAction(activated, fixture.actor, fixture.revivalAction) : activated;
|
||||
}
|
||||
export function finishDiscoveryClassicNexusEffectsInteraction(state: Game, actions?: DiscoveryClassicNexusEffectStep[]): Game {
|
||||
return advanceDiscoveryClassicNexusEffects(state, game => clean(game) && !game.pendingTreacheryDiscard && !game.pendingKarama, actions);
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { location, territory } from '../game/board';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { validateNexusCards } from '../game/nexus-cards';
|
||||
import { shipmentAvailable } from '../game/shipment-opportunity';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { nexusTleilaxuSecretAllyPlayer as player } from './fixture-nexus-tleilaxu-secret-ally';
|
||||
|
||||
export type NexusFremenFreeQuotaStep = { actor: string; action: Action };
|
||||
export type NexusFremenFreeQuotaFixtureOptions = { advanced?: boolean; tleilaxu?: boolean };
|
||||
export type NexusFremenFreeQuotaFixture = {
|
||||
game: Game;
|
||||
owner: string;
|
||||
initial: Game;
|
||||
scenario: {
|
||||
advanced: boolean;
|
||||
tleilaxu: boolean;
|
||||
opponent: string;
|
||||
partner: string;
|
||||
event: string;
|
||||
staging: string[];
|
||||
};
|
||||
actions: NexusFremenFreeQuotaStep[];
|
||||
physicalfacts: {
|
||||
shipmentCost: number;
|
||||
deadLeader: string;
|
||||
battleLosses: number;
|
||||
beforeDraw: Game;
|
||||
draw: NexusFremenFreeQuotaStep;
|
||||
afterDraw: Game;
|
||||
};
|
||||
};
|
||||
|
||||
export const nexusFremenFreeQuotaQuiet = (game: Game): boolean =>
|
||||
!game.phaseOpening && !game.response && !game.decision && !game.pendingRevival;
|
||||
|
||||
/** Continue only original public choices; private battle plans and closing draws
|
||||
* remain explicit actions of the chronology rather than fabricated state. */
|
||||
export function nextNexusFremenFreeQuotaNativeStep(game: Game): NexusFremenFreeQuotaStep | null {
|
||||
if (game.nexusCards?.phase?.stage === 'drawing' && nexusFremenFreeQuotaQuiet(game)) return null;
|
||||
if (game.decision?.kind === 'faceDance')
|
||||
return { actor: game.decision.player, action: { type: 'decision', reveal: false } };
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function advanceNexusFremenFreeQuota(
|
||||
game: Game, until: (state: Game) => boolean, actions?: NexusFremenFreeQuotaStep[],
|
||||
): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextNexusFremenFreeQuotaNativeStep(game);
|
||||
assert.ok(next, `Native Fremen chronology stopped at ${game.status}/${game.turn}/${game.phase}`);
|
||||
actions?.push(structuredClone(next));
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
throw Error('Native Fremen chronology did not reach its boundary');
|
||||
}
|
||||
|
||||
/** Scalar producer entropy, scoped to the real initializer/actions. Each shuffle
|
||||
* retains its original printed order; no candidate histories are retried. */
|
||||
function identityRandom<T>(run: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
assert.ok(array);
|
||||
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
|
||||
return array;
|
||||
};
|
||||
try { return run(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNexusFremenFreeQuotaInventory(game: Game): void {
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
|
||||
assert.deepEqual(cards.map(card => card.id).sort(), treacheryDeck(game.expansions).map(card => card.id).sort());
|
||||
assert.equal(new Set(cards.map(card => card.id)).size, cards.length);
|
||||
for (const p of game.players) {
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20);
|
||||
if (p.elites) {
|
||||
assert.equal(p.elites.reserves + p.elites.tanks +
|
||||
Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0), p.faction === 'emperor' ? 5 : 3);
|
||||
assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks);
|
||||
}
|
||||
}
|
||||
assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap(p =>
|
||||
[...p.traitors, ...p.traitorChoices, ...(p.faceDancers ?? []).map(card => card.leader)])].sort(),
|
||||
traitorDeck(game.players, game.expansions.includes('ix')).sort());
|
||||
assert.ok(game.nexusCards?.cards);
|
||||
validateNexusCards(game.nexusCards.cards, game.players);
|
||||
}
|
||||
|
||||
/** Genuine original Basic/Advanced setup, shipment, weapon death, eight-counter
|
||||
* exchange, Shai-Hulud, reciprocal alliance, and physical Fremen closing draw.
|
||||
* Only original undealt deck order and producer entropy are staged. */
|
||||
export function createNexusFremenFreeQuotaFixture(
|
||||
options: NexusFremenFreeQuotaFixtureOptions = {},
|
||||
): NexusFremenFreeQuotaFixture {
|
||||
return identityRandom(() => {
|
||||
const advanced = options.advanced ?? false, tleilaxu = options.tleilaxu ?? false;
|
||||
const owner = 'atreides', opponent = 'emperor', partner = tleilaxu ? 'tleilaxu' : 'guild';
|
||||
let lobby = createGame('NEXUSFREMENFREEQUOTA', newPlayer(owner, 'Atreides', 'atreides'), advanced, tleilaxu ? ['ix'] : []);
|
||||
joinGame(lobby, newPlayer(partner, tleilaxu ? 'Tleilaxu' : 'Guild', tleilaxu ? 'tleilaxu' : 'guild'));
|
||||
joinGame(lobby, newPlayer(opponent, 'Emperor', 'emperor'));
|
||||
lobby.nexusCards = { cards: null, phase: null };
|
||||
for (const p of lobby.players) lobby = applyAction(lobby, p.id, { type: 'ready' });
|
||||
const initial = initializeNexusGameForAudit(lobby);
|
||||
const actions: NexusFremenFreeQuotaStep[] = [], staging: string[] = [];
|
||||
const act = (game: Game, actor: string, action: Action): Game => {
|
||||
actions.push({ actor, action: structuredClone(action) });
|
||||
return applyAction(game, actor, action);
|
||||
};
|
||||
let game = advanceNexusFremenFreeQuota(initial, state => state.status === 'playing', actions);
|
||||
assert.ok(!game.players.some(p => p.faction === 'fremen'));
|
||||
assert.ok(game.players.every(p => p.hand.length > 0));
|
||||
if (tleilaxu) assert.equal(player(game, partner).faceDancers?.length, 3);
|
||||
const weapon = player(game, opponent).hand.find(card => card.kind === 'projectile' || card.kind === 'poison');
|
||||
assert.ok(weapon, 'The original third-seat deal supplies the real leader-killing weapon');
|
||||
assertNexusFremenFreeQuotaInventory(game);
|
||||
|
||||
game = advanceNexusFremenFreeQuota(game,
|
||||
state => state.turn === 1 && state.phase === 5 && state.active === opponent && nexusFremenFreeQuotaQuiet(state), actions);
|
||||
const sector = territory('arrakeen').sectors[0], target = location('arrakeen', sector);
|
||||
assert.ok(shipmentAvailable(game, player(game, opponent)));
|
||||
const shipmentSpice = player(game, opponent).spice;
|
||||
game = act(game, opponent, { type: 'ship', territory: 'arrakeen', sector, amount: 1, elite: 0, allyPayment: 0 });
|
||||
game = advanceNexusFremenFreeQuota(game, nexusFremenFreeQuotaQuiet, actions);
|
||||
const shipmentCost = shipmentSpice - player(game, opponent).spice;
|
||||
assert.ok(shipmentCost > 0);
|
||||
assert.equal(player(game, opponent).forces[target], 1);
|
||||
assert.equal(player(game, owner).forces[target], 10);
|
||||
|
||||
game = advanceNexusFremenFreeQuota(game,
|
||||
state => state.turn === 1 && state.phase === 6 && nexusFremenFreeQuotaQuiet(state), actions);
|
||||
if (!game.battle) {
|
||||
assert.ok(game.active === owner || game.active === opponent);
|
||||
game = act(game, game.active, { type: 'chooseBattle', territory: 'arrakeen',
|
||||
target: game.active === owner ? opponent : owner });
|
||||
}
|
||||
game = advanceNexusFremenFreeQuota(game,
|
||||
state => nexusFremenFreeQuotaQuiet(state) && !nextNexusFremenFreeQuotaNativeStep(state), actions);
|
||||
assert.ok(game.battle && !game.battle.revealed);
|
||||
const ownLeader = player(game, owner).leaders.filter(leader => !leader.dead).sort((a, b) => a.strength - b.strength)[0];
|
||||
const otherLeader = player(game, opponent).leaders.filter(leader => !leader.dead).sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(ownLeader && otherLeader);
|
||||
game = act(game, owner, { type: 'battlePlan', dial: advanced ? 4 : 8, support: 0,
|
||||
leader: ownLeader.id, weapon: null, defense: null });
|
||||
game = act(game, opponent, { type: 'battlePlan', dial: 0, support: 0,
|
||||
leader: otherLeader.id, weapon: weapon.id, defense: null });
|
||||
game = advanceNexusFremenFreeQuota(game, state => state.turn === 2 && state.phase === 1 &&
|
||||
nexusFremenFreeQuotaQuiet(state) && !state.spiceSequence && !state.spiceResolution &&
|
||||
!state.spiceWindow && !state.nexus && state.ready.length === 0, actions);
|
||||
assert.equal(player(game, owner).leaders.find(leader => leader.id === ownLeader.id)?.dead, true);
|
||||
assert.equal(player(game, owner).tanks, 8);
|
||||
|
||||
const wormIndex = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
|
||||
assert.ok(wormIndex >= 0);
|
||||
game.spiceDeck.unshift(game.spiceDeck.splice(wormIndex, 1)[0]);
|
||||
staging.push('Ordered one original undealt Shai-Hulud singleton before the real turn-two Spice Blow.');
|
||||
game = advanceNexusFremenFreeQuota(game,
|
||||
state => state.nexus === true && !state.spiceWindow && nexusFremenFreeQuotaQuiet(state), actions);
|
||||
game = act(game, opponent, { type: 'alliance', target: partner });
|
||||
game = act(game, partner, { type: 'alliance', target: opponent });
|
||||
assert.equal(player(game, owner).ally, null);
|
||||
game = advanceNexusFremenFreeQuota(game, state => state.nexusCards?.phase?.stage === 'drawing', actions);
|
||||
assert.deepEqual(game.nexusCards!.phase!.eligible, [owner]);
|
||||
const cards = game.nexusCards!.cards!;
|
||||
assert.equal(cards.hands[owner], null);
|
||||
const fremenIndex = cards.deck.indexOf('fremen');
|
||||
assert.ok(fremenIndex >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(fremenIndex, 1)[0]);
|
||||
staging.push('Ordered the original undealt Fremen Nexus singleton before the real closing draw; no hand was replaced.');
|
||||
const beforeDraw = structuredClone(game);
|
||||
const draw: NexusFremenFreeQuotaStep = { actor: owner, action: { type: 'nexusCardChoice',
|
||||
turn: game.turn, card: cards.hands[owner], choice: 'draw', ownRedraws: 0 } };
|
||||
game = act(game, draw.actor, draw.action);
|
||||
assert.equal(game.nexusCards!.cards!.hands[owner], 'fremen');
|
||||
const afterDraw = structuredClone(game);
|
||||
game = advanceNexusFremenFreeQuota(game,
|
||||
state => state.turn === 2 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state), actions);
|
||||
assert.equal(player(game, owner).tanks, 8);
|
||||
assert.equal(player(game, owner).freeForcesRevived, 0);
|
||||
assert.equal(player(game, owner).ally, null);
|
||||
const offer = viewGame(game, owner).nexusFremenRevival;
|
||||
assert.ok(offer);
|
||||
assert.equal(offer.blocked, null);
|
||||
assertNexusFremenFreeQuotaInventory(game);
|
||||
return { initial, game, owner, actions,
|
||||
scenario: { advanced, tleilaxu, opponent, partner, event: offer.event, staging },
|
||||
physicalfacts: { shipmentCost, deadLeader: ownLeader.id, battleLosses: 8, beforeDraw, draw, afterDraw } };
|
||||
});
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export type NexusModulePaymentsFixture = {
|
||||
actor: string;
|
||||
kind: NexusModulePaymentKind;
|
||||
paymentAction: Action;
|
||||
revivalAction?: Action;
|
||||
amount: number;
|
||||
cost: number;
|
||||
token: TechId;
|
||||
@@ -204,7 +205,7 @@ export function createNexusModulePaymentsFixture(options: NexusModulePaymentsOpt
|
||||
const shipment = kind.endsWith('shipment');
|
||||
const purchase = kind === 'emperor-purchase';
|
||||
game = advanceNexusModulePaymentsToPhase(game, shipment ? 5 : purchase ? 3 : 4, actions);
|
||||
let paymentAction: Action, cost = 0, destination: string | undefined;
|
||||
let paymentAction: Action, revivalAction: Action | undefined, cost = 0, destination: string | undefined;
|
||||
const amount = shipment ? 5 : purchase ? 1 : 3;
|
||||
if (purchase) {
|
||||
for (let n = 0; game.decision?.kind !== 'auctionPayment' && n < 100; n++) {
|
||||
@@ -243,20 +244,24 @@ export function createNexusModulePaymentsFixture(options: NexusModulePaymentsOpt
|
||||
staging.push(`Conserved ${actor}: ${tanks} reserves to Tanks, including ${elite} actual elites.`);
|
||||
const view = viewGame(game, actor);
|
||||
const event = kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.event : view.nexusFremenRevival!.event;
|
||||
paymentAction = { type: kind === 'emperor-revival' ? 'nexusEmperorRevive' : 'nexusFremenRevive',
|
||||
event, elite: elite ? 1 : 0 };
|
||||
if (kind === 'emperor-revival') paymentAction = { type: 'nexusEmperorRevive', event, elite: elite ? 1 : 0 };
|
||||
else {
|
||||
paymentAction = { type: 'nexusFremenRevival', event };
|
||||
revivalAction = { type: 'revive', amount: 3, elite: elite ? 1 : 0 };
|
||||
}
|
||||
}
|
||||
nexusInventory(game);
|
||||
const token: TechId = shipment ? 'heighliners' : purchase ? 'production' : 'axlotl';
|
||||
const tokenOwner = game.techTokens?.[token].owner ?? null;
|
||||
const tokenCount = tokenOwner ? ownedTech(game.techTokens, tokenOwner).length : 0;
|
||||
return { game, beforePayment: structuredClone(game), setup, actions, staging, actor, kind,
|
||||
paymentAction, amount, cost, token, tokenOwner, tokenCount, destination };
|
||||
paymentAction, revivalAction, amount, cost, token, tokenOwner, tokenCount, destination };
|
||||
});
|
||||
}
|
||||
|
||||
export function payNexusModulePaymentsFixture(fixture: NexusModulePaymentsFixture): Game {
|
||||
let game = applyAction(fixture.game, fixture.actor, fixture.paymentAction);
|
||||
if (fixture.revivalAction) game = applyAction(game, fixture.actor, fixture.revivalAction);
|
||||
for (let n = 0; (!clean(game) || game.pendingShipment || game.pendingRevival) && n < 200; n++)
|
||||
game = step(game);
|
||||
assert.ok(clean(game) && !game.pendingShipment && !game.pendingRevival);
|
||||
|
||||
@@ -44,6 +44,7 @@ export type NexusSkillsModulesPaymentsFixture = {
|
||||
opponent: string;
|
||||
kind: NexusSkillsModulesPaymentKind;
|
||||
paymentAction: Action;
|
||||
revivalAction?: Action;
|
||||
amount: number;
|
||||
cost: number;
|
||||
token: TechId;
|
||||
@@ -249,7 +250,7 @@ export function createNexusSkillsModulesPaymentsFixture(options: NexusSkillsModu
|
||||
const afterClosingDraw = structuredClone(game);
|
||||
game = advanceNexusSkillsModulesPayments(game, state => state.nexusCards?.phase?.stage !== 'drawing', actions);
|
||||
game = advanceNexusSkillsModulesPaymentsToPhase(game, kind === 'native-shipment' ? 5 : kind === 'emperor-purchase' ? 3 : 4, actions);
|
||||
let paymentAction: Action, destination: string | undefined, cost = 0;
|
||||
let paymentAction: Action, revivalAction: Action | undefined, destination: string | undefined, cost = 0;
|
||||
const amount = kind === 'native-shipment' ? options.amount ?? 3 : kind === 'emperor-purchase' ? 1 : 3;
|
||||
if (kind === 'emperor-purchase') {
|
||||
for (let n = 0; game.decision?.kind !== 'auctionPayment' && n < 160; n++) {
|
||||
@@ -282,20 +283,26 @@ export function createNexusSkillsModulesPaymentsFixture(options: NexusSkillsModu
|
||||
if (player.elites) { player.elites.reserves -= elite; player.elites.tanks += elite; }
|
||||
staging.push(`Conserved ${actor}: ${tanks} original reserve counters to Tanks, including ${elite} elites; no revival receipt staged.`);
|
||||
const view = viewGame(game, actor);
|
||||
paymentAction = { type: kind === 'emperor-revival' ? 'nexusEmperorRevive' : 'nexusFremenRevive',
|
||||
event: kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.event : view.nexusFremenRevival!.event, elite: elite ? 1 : 0 };
|
||||
if (kind === 'emperor-revival') paymentAction = { type: 'nexusEmperorRevive',
|
||||
event: view.nexusEmperorSecretAlly!.event, elite: elite ? 1 : 0 };
|
||||
else {
|
||||
paymentAction = { type: 'nexusFremenRevival', event: view.nexusFremenRevival!.event };
|
||||
revivalAction = { type: 'revive', amount: 3, elite: elite ? 1 : 0 };
|
||||
}
|
||||
}
|
||||
nexusSkillsModulesPaymentsInventory(game);
|
||||
const token: TechId = kind === 'native-shipment' ? 'heighliners' : kind === 'emperor-purchase' ? 'production' : 'axlotl';
|
||||
const tokenOwner = game.techTokens?.[token].owner ?? null;
|
||||
return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeClosingDraw, closingDraw,
|
||||
afterClosingDraw, beforePayment: structuredClone(game), game, actor, opponent, kind, paymentAction, amount, cost,
|
||||
afterClosingDraw, beforePayment: structuredClone(game), game, actor, opponent, kind, paymentAction, revivalAction, amount, cost,
|
||||
token, tokenOwner, tokenCount: tokenOwner ? ownedTech(game.techTokens, tokenOwner).length : 0, destination, actions, staging };
|
||||
});
|
||||
}
|
||||
/** One original action only: exposes any actual interception/payment response. */
|
||||
/** Commits the payment and optional native return, exposing actual settlement responses. */
|
||||
export function payNexusSkillsModulesPaymentsFixture(fixture: NexusSkillsModulesPaymentsFixture, state = fixture.game): Game {
|
||||
return applyAction(state, fixture.actor, fixture.paymentAction);
|
||||
let game = applyAction(state, fixture.actor, fixture.paymentAction);
|
||||
if (fixture.revivalAction) game = applyAction(game, fixture.actor, fixture.revivalAction);
|
||||
return game;
|
||||
}
|
||||
export function finishNexusSkillsModulesPaymentsFixture(_fixture: NexusSkillsModulesPaymentsFixture, state: Game,
|
||||
actions?: NexusSkillsModulesPaymentStep[]): Game {
|
||||
|
||||
@@ -162,7 +162,7 @@ for (const kind of ['native-suk', 'visitor-suk'] as const) {
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced typed' : 'Basic ordinary'} real zero-save Suk losses fund exact-three Fremen Nexus return at the next actual Revival`, () => {
|
||||
void test(`${advanced ? 'Advanced typed' : 'Basic ordinary'} real zero-save Suk losses return through the Fremen quota and normal Homeworld revival at the next actual Revival`, () => {
|
||||
const f = fixture({ advanced, discovery: advanced, tech: true, strongholds: advanced });
|
||||
const pending = settle(reveal(f), 'suk'); assert.ok(pending.decision?.kind === 'sukRescue');
|
||||
const choice = pending.decision.options.findIndex(option => !option.normal && !option.elite && !option.kept);
|
||||
@@ -173,16 +173,22 @@ for (const advanced of [false, true]) {
|
||||
assert.equal(player(done, f.owner).elites?.tanks ?? 0, advanced ? 1 : 0);
|
||||
assert.equal(done.nexusCards!.cards!.hands[f.owner], 'fremen', 'Battle rescue never spends the unrelated physically held Nexus singleton.');
|
||||
const window = revivalWindow(done, f.actions, f.staging), offer = viewGame(window, f.owner).nexusFremenRevival!;
|
||||
assert.equal(offer.blocked, null); assert.ok(offer.eliteOptions.includes(advanced ? 1 : 0));
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.equal(viewGame(window, f.opponent).nexusFremenRevival, null);
|
||||
legalPolicies(window, f.owner);
|
||||
const action: Action = { type: 'nexusFremenRevive', event: offer.event, elite: advanced ? 1 : 0 };
|
||||
const action: Action = { type: 'nexusFremenRevival', event: offer.event };
|
||||
reject(window, f.opponent, action);
|
||||
reject(window, f.owner, { ...action, event: 'stale-free-three' });
|
||||
reject(window, f.owner, { ...action, elite: 2 });
|
||||
const before = structuredClone(player(window, f.owner));
|
||||
const kaitain = pool(window, f.owner, 'homeworld:emperor');
|
||||
const salusa = advanced ? pool(window, f.owner, 'homeworld:emperor:salusa') : null;
|
||||
const paid = applyAction(JSON.parse(JSON.stringify(window)), f.owner, action), after = player(paid, f.owner);
|
||||
const granted = applyAction(JSON.parse(JSON.stringify(window)), f.owner, action);
|
||||
assert.deepEqual(player(granted, f.owner), before, 'Quota activation does not move forces or consume the ordinary return ledger');
|
||||
assert.deepEqual(granted.homeworlds, window.homeworlds);
|
||||
assert.deepEqual(granted.techTokens, window.techTokens, 'Axlotl activity belongs to the native return, not card activation');
|
||||
if (advanced) reject(granted, f.owner, { type: 'revive', amount: 3, elite: 2 });
|
||||
const paid = settle(applyAction(granted, f.owner, { type: 'revive', amount: 3, elite: advanced ? 1 : 0 }));
|
||||
const after = player(paid, f.owner);
|
||||
assert.equal(after.spice, before.spice, 'Free-three is not charged as paid revival or external Suk placement.');
|
||||
assert.equal(after.tanks, before.tanks - 3); assert.equal(after.reserves, before.reserves + 3);
|
||||
assert.equal(after.revived, 3); assert.equal(after.freeForcesRevived, 3);
|
||||
@@ -193,6 +199,8 @@ for (const advanced of [false, true]) {
|
||||
assert.equal(after.elites!.reserves, before.elites!.reserves + 1);
|
||||
assert.equal(after.elites!.revived, 1);
|
||||
}
|
||||
assert.deepEqual(paid.leaderSkills, window.leaderSkills, 'Normal force revival retains the original trained Skill custody');
|
||||
if (advanced) assert.deepEqual(paid.discoveries, window.discoveries);
|
||||
assert.equal(paid.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||||
reject(paid, f.owner, action);
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { forceRevivalQuote } from '../game/revival';
|
||||
import { nexusTleilaxuSecretAllyPlayer as player } from './fixture-nexus-tleilaxu-secret-ally';
|
||||
import {
|
||||
advanceNexusFremenFreeQuota, assertNexusFremenFreeQuotaInventory,
|
||||
createNexusFremenFreeQuotaFixture, nexusFremenFreeQuotaQuiet,
|
||||
} from './fixture-nexus-fremen-free-quota';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Refusal preserves physical custody, ledgers, banks and pending frames');
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
for (const tleilaxu of [false, true]) {
|
||||
const profile = `${advanced ? 'Advanced' : 'Basic'} ${tleilaxu ? 'native Tleilaxu/Ix' : 'classic'}`;
|
||||
|
||||
void test(`${profile}: activation grants quota, then original one-plus-two requests return three free counters`, () => {
|
||||
const f = createNexusFremenFreeQuotaFixture({ advanced, tleilaxu });
|
||||
const before = player(f.game, f.owner);
|
||||
const offer = viewGame(f.game, f.owner).nexusFremenRevival;
|
||||
assert.ok(offer);
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.equal(offer.quota, 3);
|
||||
assert.equal(offer.freeRemaining, 3);
|
||||
assert.equal(offer.maxForces, 3);
|
||||
assert.equal(offer.ordinaryRemaining, 3);
|
||||
for (const other of f.game.players.filter(p => p.id !== f.owner))
|
||||
assert.equal(viewGame(f.game, other.id).nexusFremenRevival, null);
|
||||
|
||||
let game = applyAction(f.game, f.owner, { type: 'nexusFremenRevival', event: offer.event });
|
||||
assert.deepEqual(game.players, f.game.players, 'Enabling the quota returns no counters, leaders or spice');
|
||||
assert.equal(game.turn, f.game.turn);
|
||||
assert.equal(game.phase, 4);
|
||||
assert.ok(nexusFremenFreeQuotaQuiet(game), 'Activation starts no substitute revival or income pipeline');
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||||
assert.equal(viewGame(game, f.owner).nexusFremenRevival, null);
|
||||
assert.equal(viewGame(game, f.owner).revival.freeRemaining, 3);
|
||||
assert.equal(viewGame(game, f.owner).revival.forcesRemaining, 3);
|
||||
const receipt = game.nexusFremenRevivalHistory!.at(-1)!;
|
||||
assert.equal(receipt.version, 2);
|
||||
assert.equal(receipt.revivedAtUse, 0);
|
||||
assert.equal(receipt.freeAtUse, 0);
|
||||
assert.equal(receipt.policy, 'ordinary-free-quota-three');
|
||||
assert.equal(receipt.legacyReturn, undefined, 'New activation does not manufacture an old physical transfer');
|
||||
rejected(game, f.owner, { type: 'nexusFremenRevival', event: offer.event });
|
||||
|
||||
const incomeBefore = player(game, f.scenario.partner).spice;
|
||||
game = applyAction(game, f.owner, { type: 'revive', amount: 1, elite: 0 });
|
||||
game = advanceNexusFremenFreeQuota(game, state => state.turn === 2 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
|
||||
assert.equal(player(game, f.owner).tanks, before.tanks - 1);
|
||||
assert.equal(player(game, f.owner).reserves, before.reserves + 1);
|
||||
assert.equal(player(game, f.owner).revived, 1);
|
||||
assert.equal(player(game, f.owner).freeForcesRevived, 1);
|
||||
assert.equal(player(game, f.owner).spice, before.spice);
|
||||
assert.equal(viewGame(game, f.owner).revival.freeRemaining, 2);
|
||||
assert.equal(player(game, f.scenario.partner).spice, incomeBefore + (tleilaxu ? 1 : 0));
|
||||
if (tleilaxu) assert.equal(game.revivalFreeIncome?.[f.owner], game.turn);
|
||||
|
||||
game = applyAction(game, f.owner, { type: 'revive', amount: 2, elite: 0 });
|
||||
game = advanceNexusFremenFreeQuota(game, state => state.turn === 2 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
|
||||
const holder = player(game, f.owner);
|
||||
assert.equal(holder.tanks, before.tanks - 3);
|
||||
assert.equal(holder.reserves, before.reserves + 3);
|
||||
assert.equal(holder.revived, 3);
|
||||
assert.equal(holder.freeForcesRevived, 3);
|
||||
assert.equal(holder.spice, before.spice);
|
||||
assert.deepEqual(holder.leaders, before.leaders, 'No extra leader revival is granted');
|
||||
assert.equal(viewGame(game, f.owner).revival.freeRemaining, 0);
|
||||
assert.equal(viewGame(game, f.owner).revival.forcesRemaining, 0);
|
||||
assert.equal(player(game, f.scenario.partner).spice, incomeBefore + (tleilaxu ? 1 : 0),
|
||||
'The real native free-revival income occurs once, not once per request or on card activation');
|
||||
rejected(game, f.owner, { type: 'revive', amount: 1, elite: 0 });
|
||||
assertNexusFremenFreeQuotaInventory(game);
|
||||
});
|
||||
|
||||
void test(`${profile}: prior ordinary free use counts and late activation permits only the ordinary remainder`, () => {
|
||||
const f = createNexusFremenFreeQuotaFixture({ advanced, tleilaxu });
|
||||
const before = player(f.game, f.owner);
|
||||
let game = applyAction(f.game, f.owner, { type: 'revive', amount: 2, elite: 0 });
|
||||
game = advanceNexusFremenFreeQuota(game, state => state.turn === 2 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
|
||||
assert.equal(player(game, f.owner).revived, 2);
|
||||
assert.equal(player(game, f.owner).freeForcesRevived, 2);
|
||||
const offer = viewGame(game, f.owner).nexusFremenRevival;
|
||||
assert.ok(offer);
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.equal(offer.freeRemaining, 1);
|
||||
assert.equal(offer.maxForces, 1);
|
||||
assert.equal(offer.ordinaryRemaining, 1);
|
||||
const atUse = structuredClone(player(game, f.owner));
|
||||
game = applyAction(game, f.owner, { type: 'nexusFremenRevival', event: offer.event });
|
||||
assert.deepEqual(player(game, f.owner), atUse);
|
||||
assert.equal(game.nexusFremenRevivalHistory!.at(-1)!.revivedAtUse, 2);
|
||||
assert.equal(game.nexusFremenRevivalHistory!.at(-1)!.freeAtUse, 2);
|
||||
rejected(game, f.owner, { type: 'revive', amount: 2, elite: 0 });
|
||||
game = applyAction(game, f.owner, { type: 'revive', amount: 1, elite: 0 });
|
||||
game = advanceNexusFremenFreeQuota(game, state => state.turn === 2 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
|
||||
assert.equal(player(game, f.owner).tanks, before.tanks - 3);
|
||||
assert.equal(player(game, f.owner).reserves, before.reserves + 3);
|
||||
assert.equal(player(game, f.owner).spice, before.spice);
|
||||
assert.equal(player(game, f.owner).freeForcesRevived, 3);
|
||||
assert.equal(viewGame(game, f.owner).revival.forcesRemaining, 0);
|
||||
assertNexusFremenFreeQuotaInventory(game);
|
||||
});
|
||||
|
||||
void test(`${profile}: wrong actor, stale event, payload keys and removed transfer action cannot spend the singleton`, () => {
|
||||
const f = createNexusFremenFreeQuotaFixture({ advanced, tleilaxu });
|
||||
for (const actor of [f.scenario.opponent, f.scenario.partner])
|
||||
rejected(f.game, actor, { type: 'nexusFremenRevival', event: f.scenario.event });
|
||||
rejected(f.game, f.owner, { type: 'nexusFremenRevival', event: 'stale' });
|
||||
rejected(f.game, f.owner, { type: 'nexusFremenRevival', event: f.scenario.event, amount: 3 });
|
||||
rejected(f.game, f.owner, { type: 'nexusFremenRevival', event: f.scenario.event, elite: 0 });
|
||||
rejected(f.game, f.owner, { type: 'nexusFremenRevive', event: f.scenario.event, elite: 0 });
|
||||
const next = advanceNexusFremenFreeQuota(f.game,
|
||||
state => state.turn === 3 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
|
||||
assert.equal(next.nexusCards!.cards!.hands[f.owner], 'fremen');
|
||||
assert.equal(player(next, f.owner).tanks, 8);
|
||||
const offer = viewGame(next, f.owner).nexusFremenRevival;
|
||||
assert.ok(offer);
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.notEqual(offer.event, f.scenario.event);
|
||||
rejected(next, f.owner, { type: 'nexusFremenRevival', event: f.scenario.event });
|
||||
let returned = applyAction(next, f.owner, { type: 'nexusFremenRevival', event: offer.event });
|
||||
returned = applyAction(returned, f.owner, { type: 'revive', amount: 1, elite: 0 });
|
||||
returned = advanceNexusFremenFreeQuota(returned,
|
||||
state => state.turn === 3 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
|
||||
assert.equal(player(returned, f.owner).tanks, 7);
|
||||
assert.equal(player(returned, f.owner).freeForcesRevived, 1);
|
||||
assertNexusFremenFreeQuotaInventory(returned);
|
||||
});
|
||||
|
||||
void test(`${profile}: an unused portion expires at real phase end and does not become next-turn free quota`, () => {
|
||||
const f = createNexusFremenFreeQuotaFixture({ advanced, tleilaxu });
|
||||
let game = applyAction(f.game, f.owner, { type: 'nexusFremenRevival', event: f.scenario.event });
|
||||
game = applyAction(game, f.owner, { type: 'revive', amount: 1, elite: 0 });
|
||||
game = advanceNexusFremenFreeQuota(game, state => state.turn === 2 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
|
||||
const phaseEnd = advanceNexusFremenFreeQuota(game,
|
||||
state => state.turn === 2 && state.phase === 5 && nexusFremenFreeQuotaQuiet(state));
|
||||
rejected(phaseEnd, f.owner, { type: 'revive', amount: 1, elite: 0 });
|
||||
rejected(phaseEnd, f.owner, { type: 'nexusFremenRevival', event: f.scenario.event });
|
||||
const next = advanceNexusFremenFreeQuota(phaseEnd,
|
||||
state => state.turn === 3 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
|
||||
const holder = player(next, f.owner);
|
||||
assert.equal(holder.tanks, 7);
|
||||
assert.equal(holder.revived, 0);
|
||||
assert.equal(holder.freeForcesRevived, 0);
|
||||
assert.equal(viewGame(next, f.owner).nexusFremenRevival, null);
|
||||
assert.equal(viewGame(next, f.owner).revival.freeRemaining, 2, 'Only the native Atreides rate survives');
|
||||
assert.equal(viewGame(next, f.owner).revival.forcesRemaining, 3);
|
||||
assert.equal(forceRevivalQuote(next, holder, 3).free, 2);
|
||||
const price = forceRevivalQuote(next, holder, 3).cost;
|
||||
assert.ok(price > 0);
|
||||
let returned = applyAction(next, f.owner, { type: 'revive', amount: 3, elite: 0 });
|
||||
returned = advanceNexusFremenFreeQuota(returned,
|
||||
state => state.turn === 3 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
|
||||
assert.equal(player(returned, f.owner).tanks, 4);
|
||||
assert.equal(player(returned, f.owner).freeForcesRevived, 2);
|
||||
assert.equal(player(returned, f.owner).spice, holder.spice - price);
|
||||
assert.equal(returned.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||||
assertNexusFremenFreeQuotaInventory(returned);
|
||||
});
|
||||
|
||||
void test(`${profile}: the original AI can enable the quota and use its ordinary revival action`, () => {
|
||||
const f = createNexusFremenFreeQuotaFixture({ advanced, tleilaxu });
|
||||
const view = viewGame(f.game, f.owner);
|
||||
view.players.find(p => p.id === f.owner)!.bot = 'Easy';
|
||||
const enable = botActions(view).find(action => action.type === 'nexusFremenRevival');
|
||||
assert.ok(enable, 'The owner AI has a legal activation, not a direct-transfer action');
|
||||
let game = applyAction(f.game, f.owner, enable);
|
||||
const enabled = viewGame(game, f.owner);
|
||||
enabled.players.find(p => p.id === f.owner)!.bot = 'Easy';
|
||||
const ordinary = botActions(enabled).find(action => action.type === 'revive');
|
||||
assert.ok(ordinary);
|
||||
game = applyAction(game, f.owner, ordinary);
|
||||
game = advanceNexusFremenFreeQuota(game,
|
||||
state => state.turn === 2 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
|
||||
assert.equal(player(game, f.owner).tanks, 8 - player(game, f.owner).revived);
|
||||
assert.ok(player(game, f.owner).revived > 0);
|
||||
assert.equal(player(game, f.owner).freeForcesRevived, player(game, f.owner).revived);
|
||||
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice);
|
||||
assertNexusFremenFreeQuotaInventory(game);
|
||||
});
|
||||
}
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} native Tleilaxu: prior paid returns keep their price and consume the native expanded allowance`, () => {
|
||||
const f = createNexusFremenFreeQuotaFixture({ advanced, tleilaxu: true });
|
||||
let game = applyAction(f.game, f.scenario.partner, { type: 'tleilaxuRevivalLimit', target: f.owner });
|
||||
game = advanceNexusFremenFreeQuota(game, state => state.turn === 2 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
|
||||
assert.equal(viewGame(game, f.owner).revival.forcesRemaining, 5);
|
||||
const before = structuredClone(player(game, f.owner));
|
||||
const priorPrice = forceRevivalQuote(game, before, 3).cost;
|
||||
assert.ok(priorPrice > 0);
|
||||
game = applyAction(game, f.owner, { type: 'revive', amount: 3, elite: 0 });
|
||||
game = advanceNexusFremenFreeQuota(game, state => state.turn === 2 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
|
||||
assert.equal(player(game, f.owner).revived, 3);
|
||||
assert.equal(player(game, f.owner).freeForcesRevived, 2);
|
||||
assert.equal(player(game, f.owner).spice, before.spice - priorPrice);
|
||||
const offer = viewGame(game, f.owner).nexusFremenRevival;
|
||||
assert.ok(offer);
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.equal(offer.freeRemaining, 1);
|
||||
assert.equal(offer.maxForces, 1);
|
||||
assert.equal(offer.ordinaryRemaining, 2);
|
||||
const atUse = structuredClone(player(game, f.owner));
|
||||
game = applyAction(game, f.owner, { type: 'nexusFremenRevival', event: offer.event });
|
||||
assert.deepEqual(player(game, f.owner), atUse, 'No refund or paid-to-free reclassification occurs at activation');
|
||||
assert.equal(game.nexusFremenRevivalHistory!.at(-1)!.revivedAtUse, 3);
|
||||
assert.equal(game.nexusFremenRevivalHistory!.at(-1)!.freeAtUse, 2);
|
||||
assert.equal(viewGame(game, f.owner).revival.forcesRemaining, 2, 'The native five-return permission survives without expansion');
|
||||
const afterPrice = forceRevivalQuote(game, player(game, f.owner), 2).cost;
|
||||
assert.ok(afterPrice > 0, 'Only one of the remaining two is free');
|
||||
rejected(game, f.owner, { type: 'revive', amount: 3, elite: 0 });
|
||||
game = applyAction(game, f.owner, { type: 'revive', amount: 2, elite: 0 });
|
||||
game = advanceNexusFremenFreeQuota(game, state => state.turn === 2 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
|
||||
assert.equal(player(game, f.owner).revived, 5);
|
||||
assert.equal(player(game, f.owner).freeForcesRevived, 3, 'Actual free use is three, not five reclassified returns');
|
||||
assert.equal(player(game, f.owner).tanks, before.tanks - 5);
|
||||
assert.equal(player(game, f.owner).reserves, before.reserves + 5);
|
||||
assert.equal(player(game, f.owner).spice, before.spice - priorPrice - afterPrice);
|
||||
rejected(game, f.owner, { type: 'revive', amount: 1, elite: 0 });
|
||||
assertNexusFremenFreeQuotaInventory(game);
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} classic: a fully consumed ordinary allowance cannot discard the card uselessly`, () => {
|
||||
const f = createNexusFremenFreeQuotaFixture({ advanced });
|
||||
let game = applyAction(f.game, f.owner, { type: 'revive', amount: 3, elite: 0 });
|
||||
game = advanceNexusFremenFreeQuota(game, state => state.turn === 2 && state.phase === 4 && nexusFremenFreeQuotaQuiet(state));
|
||||
const offer = viewGame(game, f.owner).nexusFremenRevival;
|
||||
assert.ok(offer);
|
||||
assert.ok(offer.blocked);
|
||||
assert.equal(offer.maxForces, 0);
|
||||
rejected(game, f.owner, { type: 'nexusFremenRevival', event: offer.event });
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.owner], 'fremen');
|
||||
assert.equal(player(game, f.owner).freeForcesRevived, 2, 'The prior paid counter remains paid');
|
||||
assertNexusFremenFreeQuotaInventory(game);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGame, newPlayer, type Game, type Player } from '../game/engine';
|
||||
import { createNexusCards, discardNexusCard, drawNexusCard, validateNexusCards } from '../game/nexus-cards';
|
||||
import {
|
||||
createFremenNexusRevival,
|
||||
fremenNexusRevivalEvent,
|
||||
fremenNexusRevivalOffer,
|
||||
fremenNexusRevivalSignature,
|
||||
upgradeFremenNexusRevival,
|
||||
validateFremenNexusRevival,
|
||||
type FremenNexusRevival,
|
||||
} from '../game/nexus-fremen-revival';
|
||||
import { forceRevivalQuote, forceRevivalRemaining, newRevivalRules, normalRevivalAllowance } from '../game/revival';
|
||||
|
||||
/** Resolved-facts unit context, not a native gameplay chronology. Custody still
|
||||
* comes from the original inventory/draw API, never an edited private hand. */
|
||||
function fixture(faction: Player['faction'] = 'guild', advanced = true): Game {
|
||||
const g = createGame('FQUOTA', newPlayer('q', 'Quota owner', faction), advanced);
|
||||
g.players.push(newPlayer('a', 'Atreides', 'atreides'), newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
if (faction === 'atreides') g.players[1] = newPlayer('a', 'Guild', 'guild');
|
||||
g.status = 'playing';
|
||||
g.phase = 4;
|
||||
g.turn = 2;
|
||||
g.revivalRules = newRevivalRules();
|
||||
const p = g.players[0];
|
||||
p.reserves = 14;
|
||||
p.tanks = 6;
|
||||
p.spice = 17;
|
||||
p.revived = 0;
|
||||
p.freeForcesRevived = 0;
|
||||
if (p.elites) {
|
||||
p.elites.tanks = 0;
|
||||
p.elites.revived = 0;
|
||||
}
|
||||
const cards = createNexusCards(g.players, () => 0.5);
|
||||
cards.deck = ['fremen', ...cards.deck.filter(card => card !== 'fremen')];
|
||||
g.nexusCards = { cards: drawNexusCard(cards, 'q', g.players, () => 0.5), phase: null };
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Install the engine-owned receipt/permission contract on a copy so these
|
||||
* tests consume the shared ordinary arithmetic without introducing a new API. */
|
||||
function granted(g: Game) {
|
||||
const receipt = createFremenNexusRevival(g, 'q');
|
||||
const result = structuredClone(g);
|
||||
result.revivalRules!.nexusFremen = { turn: result.turn, owners: ['q'] };
|
||||
result.nexusFremenRevivalHistory = [receipt];
|
||||
result.nexusFremenRevivalEvents = [receipt.event];
|
||||
result.nexusCards!.cards = discardNexusCard(result.nexusCards!.cards!, 'q', result.players);
|
||||
return result;
|
||||
}
|
||||
|
||||
void test('a quota receipt changes no physical pieces, ledgers, wallet or custody', () => {
|
||||
const g = fixture();
|
||||
const before = structuredClone(g);
|
||||
const record = createFremenNexusRevival(g, 'q');
|
||||
assert.equal(record.version, 2);
|
||||
assert.equal(record.revivedAtUse, 0);
|
||||
assert.equal(record.freeAtUse, 0);
|
||||
assert.equal(Object.hasOwn(record, 'legacyReturn'), false);
|
||||
assert.equal(Object.hasOwn(record, 'before'), false);
|
||||
assert.equal(Object.hasOwn(record, 'after'), false);
|
||||
validateFremenNexusRevival(g, record);
|
||||
assert.deepEqual(g, before);
|
||||
const result = granted(g);
|
||||
assert.deepEqual(result.players, before.players);
|
||||
validateNexusCards(result.nexusCards!.cards!, result.players);
|
||||
assert.equal(result.nexusCards!.cards!.hands.q, null);
|
||||
assert.equal(result.nexusCards!.cards!.discard.includes('fremen'), true);
|
||||
assert.equal(fremenNexusRevivalOffer(result, 'q'), null);
|
||||
assert.equal(fremenNexusRevivalOffer(g, 'a'), null);
|
||||
});
|
||||
|
||||
void test('partial Tanks and already-used native free returns leave legal partial quota', () => {
|
||||
const g = fixture();
|
||||
const p = g.players[0];
|
||||
p.revived = 1;
|
||||
p.freeForcesRevived = 1;
|
||||
p.tanks = 2;
|
||||
p.reserves = 18;
|
||||
const offer = fremenNexusRevivalOffer(g, 'q')!;
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.equal(offer.freeRemaining, 2);
|
||||
assert.equal(offer.maxForces, 2);
|
||||
const result = granted(g);
|
||||
assert.equal(forceRevivalRemaining(result, result.players[0]), 2);
|
||||
assert.deepEqual(forceRevivalQuote(result, result.players[0], 1), { free: 1, normalCost: 0, cost: 0 });
|
||||
assert.deepEqual(forceRevivalQuote(result, result.players[0], 2), { free: 2, normalCost: 0, cost: 0 });
|
||||
// Actual ordinary free usage is shared with later partial requests.
|
||||
result.players[0].revived++;
|
||||
result.players[0].freeForcesRevived!++;
|
||||
result.players[0].tanks--;
|
||||
result.players[0].reserves++;
|
||||
assert.equal(normalRevivalAllowance(result, result.players[0]).freeRemaining, 1);
|
||||
assert.equal(forceRevivalQuote(result, result.players[0], 1).cost, 0);
|
||||
});
|
||||
|
||||
|
||||
void test('native higher quotas survive and never justify a useless Fremen discard', () => {
|
||||
const g = fixture('atreides');
|
||||
g.recruits = { turn: g.turn, player: 'q', card: 'ecaz-recruits' };
|
||||
assert.equal(normalRevivalAllowance(g, g.players[0]).freeRate, 4);
|
||||
assert.match(fremenNexusRevivalOffer(g, 'q')!.blocked!, /already at least three/);
|
||||
assert.throws(() => createFremenNexusRevival(g, 'q'));
|
||||
const result = structuredClone(g);
|
||||
result.revivalRules!.nexusFremen = { turn: g.turn, owners: ['q'] };
|
||||
assert.equal(normalRevivalAllowance(result, result.players[0]).freeRate, 4);
|
||||
assert.equal(normalRevivalAllowance(result, result.players[0]).limit, 7);
|
||||
const single = fixture();
|
||||
single.players[0].tanks = 1;
|
||||
assert.match(fremenNexusRevivalOffer(single, 'q')!.blocked!, /cannot improve/);
|
||||
});
|
||||
|
||||
void test('Recruits doubles the native rate, not the fixed quota, and retains its seven-force limit', () => {
|
||||
const g = fixture('emperor');
|
||||
g.recruits = { turn: g.turn, player: 'q', card: 'ecaz-recruits' };
|
||||
assert.equal(normalRevivalAllowance(g, g.players[0]).freeRate, 2);
|
||||
assert.equal(fremenNexusRevivalOffer(g, 'q')!.blocked, null);
|
||||
const result = granted(g);
|
||||
const allowance = normalRevivalAllowance(result, result.players[0]);
|
||||
assert.equal(allowance.limit, 7);
|
||||
assert.equal(allowance.freeRate, 3);
|
||||
assert.equal(allowance.freeRemaining, 3);
|
||||
assert.equal(forceRevivalQuote(result, result.players[0], 4).cost, 2);
|
||||
result.players[0].revived = 3;
|
||||
result.players[0].freeForcesRevived = 3;
|
||||
assert.equal(forceRevivalQuote(result, result.players[0], 1).cost, 2);
|
||||
});
|
||||
|
||||
void test('Cyborgs retain unlimited typed eligibility while Advanced Sardaukar retain their one-return cap', () => {
|
||||
const ix = fixture('ixians');
|
||||
ix.expansions = ['ix'];
|
||||
ix.players[0].tanks = 4;
|
||||
ix.players[0].elites!.tanks = 3;
|
||||
ix.players[0].elites!.revived = 1;
|
||||
assert.equal(fremenNexusRevivalOffer(ix, 'q')!.maxForces, 3);
|
||||
assert.equal(fremenNexusRevivalOffer(ix, 'q')!.blocked, null);
|
||||
const result = granted(ix);
|
||||
assert.deepEqual(forceRevivalQuote(result, result.players[0], 3, 3, 3), { free: 3, normalCost: 0, cost: 0 });
|
||||
result.recruits = { turn: result.turn, player: 'q', card: 'ecaz-recruits' };
|
||||
assert.equal(forceRevivalQuote(result, result.players[0], 4, 3, 2).cost, 3);
|
||||
const emperor = fixture('emperor');
|
||||
emperor.players[0].tanks = 3;
|
||||
emperor.players[0].elites = { reserves: 2, tanks: 3, forces: {}, revived: 0 };
|
||||
emperor.players[0].elites!.tanks = 3;
|
||||
assert.equal(fremenNexusRevivalOffer(emperor, 'q')!.maxForces, 1);
|
||||
emperor.players[0].elites!.revived = 1;
|
||||
assert.ok(fremenNexusRevivalOffer(emperor, 'q')!.blocked);
|
||||
emperor.advanced = false;
|
||||
assert.equal(fremenNexusRevivalOffer(emperor, 'q')!.maxForces, 3);
|
||||
assert.equal(fremenNexusRevivalOffer(emperor, 'q')!.blocked, null);
|
||||
});
|
||||
|
||||
void test('ordinary free blocking and Tleilaxu prevention apply both before and after the grant', () => {
|
||||
for (const denial of ['free', 'prevention'] as const) {
|
||||
const g = fixture();
|
||||
if (denial === 'free') g.revivalRules!.freeBlocked = ['q'];
|
||||
else g.revivalPrevention = { turn: g.turn, player: 'q' };
|
||||
const before = structuredClone(g);
|
||||
assert.ok(fremenNexusRevivalOffer(g, 'q')!.blocked);
|
||||
assert.throws(() => createFremenNexusRevival(g, 'q'));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
const result = granted(fixture());
|
||||
result.revivalRules!.freeBlocked = ['q'];
|
||||
assert.equal(normalRevivalAllowance(result, result.players[0]).freeRemaining, 0);
|
||||
assert.equal(forceRevivalQuote(result, result.players[0], 1).cost, 2);
|
||||
});
|
||||
|
||||
void test('ally, pending interaction, wrong phase and stale use cannot spend another grant', () => {
|
||||
const changes: Array<(g: Game) => void> = [
|
||||
g => { g.players[0].ally = 'a'; g.players[1].ally = 'q'; },
|
||||
g => { g.phase = 5; },
|
||||
g => { g.pendingRevival = { player: 'q' } as NonNullable<Game['pendingRevival']>; },
|
||||
g => { g.nexusFremenRevivalEvents = [fremenNexusRevivalEvent(g.turn, 'q')]; },
|
||||
g => { g.revivalRules!.nexusFremen = { turn: g.turn, owners: ['q'] }; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const g = fixture();
|
||||
change(g);
|
||||
const before = structuredClone(g);
|
||||
assert.ok(fremenNexusRevivalOffer(g, 'q')!.blocked);
|
||||
assert.throws(() => createFremenNexusRevival(g, 'q'));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
const present = fixture();
|
||||
present.players.push(newPlayer('f', 'Fremen', 'fremen'));
|
||||
assert.equal(fremenNexusRevivalOffer(present, 'q'), null);
|
||||
});
|
||||
|
||||
void test('unknown legacy free usage fails closed, while untouched zero use is unambiguous', () => {
|
||||
const g = fixture();
|
||||
delete g.players[0].freeForcesRevived;
|
||||
g.players[0].revived = 1;
|
||||
const before = structuredClone(g);
|
||||
assert.match(fremenNexusRevivalOffer(g, 'q')!.blocked!, /usage is unknown/);
|
||||
assert.throws(() => createFremenNexusRevival(g, 'q'), /usage is unknown/);
|
||||
assert.deepEqual(g, before);
|
||||
g.players[0].revived = 0;
|
||||
assert.equal(createFremenNexusRevival(g, 'q').freeAtUse, 0);
|
||||
assert.equal(g.players[0].freeForcesRevived, undefined);
|
||||
});
|
||||
|
||||
void test('the quota expires with its turn and grants neither force-limit nor leader allowance', () => {
|
||||
const result = granted(fixture('choam'));
|
||||
assert.equal(normalRevivalAllowance(result, result.players[0]).limit, 20);
|
||||
assert.equal(normalRevivalAllowance(result, result.players[0]).freeRate, 3);
|
||||
assert.equal(result.players[0].leaderRevived, false);
|
||||
result.turn++;
|
||||
assert.equal(normalRevivalAllowance(result, result.players[0]).freeRate, 0);
|
||||
assert.equal(normalRevivalAllowance(result, result.players[0]).limit, 20);
|
||||
const ordinary = granted(fixture());
|
||||
assert.equal(normalRevivalAllowance(ordinary, ordinary.players[0]).limit, 3);
|
||||
});
|
||||
|
||||
type LegacyReceipt = Omit<FremenNexusRevival,
|
||||
'version' | 'revivedAtUse' | 'freeAtUse' | 'policy' | 'legacyReturn'> &
|
||||
NonNullable<FremenNexusRevival['legacyReturn']>;
|
||||
|
||||
function legacy(g: Game, elite = 0): LegacyReceipt {
|
||||
const before = { reserves: 14, tanks: 6, eliteReserves: 0, eliteTanks: elite,
|
||||
eliteRevived: 0, revived: 0, freeForcesRevived: 0, spice: 17 };
|
||||
const record: LegacyReceipt = {
|
||||
event: fremenNexusRevivalEvent(g.turn, 'q'), owner: 'q', turn: g.turn,
|
||||
phase: 4, faction: g.players[0].faction, advanced: g.advanced, elite, before,
|
||||
after: { ...before, reserves: 17, tanks: 3, eliteReserves: elite, eliteTanks: 0,
|
||||
eliteRevived: elite, revived: 3, freeForcesRevived: 3 }, signature: '',
|
||||
};
|
||||
record.signature = JSON.stringify({ ...record, signature: undefined });
|
||||
return record;
|
||||
}
|
||||
|
||||
void test('immutable legacy adoption retains the completed physical facts and exhausted quota', () => {
|
||||
const g = fixture('emperor');
|
||||
const old = legacy(g, 1);
|
||||
g.players[0].revived = 3;
|
||||
g.players[0].freeForcesRevived = 3;
|
||||
const before = structuredClone(g), oldBefore = structuredClone(old);
|
||||
const record = upgradeFremenNexusRevival(g, old);
|
||||
assert.deepEqual(record.legacyReturn, { elite: old.elite, before: old.before, after: old.after });
|
||||
assert.notEqual(record.legacyReturn!.before, old.before);
|
||||
assert.notEqual(record.legacyReturn!.after, old.after);
|
||||
assert.equal(record.revivedAtUse, 0);
|
||||
assert.equal(record.freeAtUse, 0);
|
||||
assert.equal(upgradeFremenNexusRevival(g, record), record);
|
||||
assert.deepEqual(old, oldBefore);
|
||||
assert.deepEqual(g, before);
|
||||
const adopted = structuredClone(g);
|
||||
adopted.revivalRules!.nexusFremen = { turn: g.turn, owners: ['q'] };
|
||||
assert.equal(normalRevivalAllowance(adopted, adopted.players[0]).freeRemaining, 0);
|
||||
assert.equal(forceRevivalRemaining(adopted, adopted.players[0]), 0);
|
||||
});
|
||||
|
||||
void test('legacy signatures, exact shape, conservation and original Emperor cap must validate before adoption', () => {
|
||||
for (const change of [
|
||||
(old: LegacyReceipt) => { old.after.spice++; },
|
||||
(old: LegacyReceipt) => { old.after.tanks++; },
|
||||
(old: LegacyReceipt) => { old.before.revived = 1; },
|
||||
(old: LegacyReceipt) => { old.before.reserves = 20; },
|
||||
(old: LegacyReceipt) => { old.elite = 2; old.before.eliteTanks = 2; old.after.eliteReserves = 2; old.after.eliteRevived = 2; },
|
||||
]) {
|
||||
const g = fixture('emperor');
|
||||
const old = legacy(g, 1);
|
||||
change(old);
|
||||
old.signature = JSON.stringify({ ...old, signature: undefined });
|
||||
const before = structuredClone(g), oldBefore = structuredClone(old);
|
||||
assert.throws(() => upgradeFremenNexusRevival(g, old), /original physical return/);
|
||||
assert.deepEqual(g, before);
|
||||
assert.deepEqual(old, oldBefore);
|
||||
}
|
||||
const g = fixture();
|
||||
const old = legacy(g);
|
||||
assert.throws(() => upgradeFremenNexusRevival(g, { ...old, extra: true }));
|
||||
assert.throws(() => upgradeFremenNexusRevival(g, { ...old, signature: 'tampered' }));
|
||||
assert.throws(() => upgradeFremenNexusRevival(fixture('ixians'), legacy(fixture('ixians'), 1)));
|
||||
});
|
||||
|
||||
void test('malformed version-two source receipts reject without normalization or mutation', () => {
|
||||
const g = fixture();
|
||||
const receipt = createFremenNexusRevival(g, 'q');
|
||||
const malformed: unknown[] = [
|
||||
{ ...receipt, version: 1 }, { ...receipt, signature: 'tampered' },
|
||||
{ ...receipt, extra: true }, { ...receipt, freeAtUse: 1 },
|
||||
{ ...receipt, revivedAtUse: -1 }, { ...receipt, turn: g.turn + 1 },
|
||||
{ ...receipt, phase: 5 }, { ...receipt, policy: 'direct-return' },
|
||||
{ ...receipt, owner: 'missing' }, { ...receipt, legacyReturn: undefined },
|
||||
];
|
||||
for (const input of malformed) {
|
||||
const record = input as FremenNexusRevival;
|
||||
if (record.signature !== 'tampered') record.signature = fremenNexusRevivalSignature(record);
|
||||
const before = structuredClone(g), recordBefore = structuredClone(record);
|
||||
assert.throws(() => upgradeFremenNexusRevival(g, record));
|
||||
assert.deepEqual(g, before);
|
||||
assert.deepEqual(record, recordBefore);
|
||||
}
|
||||
});
|
||||
@@ -1,121 +1,60 @@
|
||||
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';
|
||||
import test from 'node:test';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, type Game, type Player } from '../game/engine';
|
||||
import { createNexusFremenFreeQuotaFixture } from './fixture-nexus-fremen-free-quota';
|
||||
|
||||
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 };
|
||||
function pools(player: Player) {
|
||||
return { reserves: player.reserves, tanks: player.tanks,
|
||||
eliteReserves: player.elites?.reserves ?? 0, eliteTanks: player.elites?.tanks ?? 0,
|
||||
eliteRevived: player.elites?.revived ?? 0, revived: player.revived,
|
||||
freeForcesRevived: player.freeForcesRevived ?? 0, spice: player.spice };
|
||||
}
|
||||
|
||||
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 ? 'Advanced' : 'Basic'} saved exact-three physical receipt adopts its exhausted quota without replaying the actual return`, () => {
|
||||
const fixture = createNexusFremenFreeQuotaFixture({ advanced });
|
||||
const before = fixture.game.players.find(player => player.id === fixture.owner)!;
|
||||
const offer = viewGame(fixture.game, fixture.owner).nexusFremenRevival!;
|
||||
const granted = applyAction(fixture.game, fixture.owner,
|
||||
{ type: 'nexusFremenRevival', event: offer.event });
|
||||
const returned = applyAction(granted, fixture.owner, { type: 'revive', amount: 3, elite: 0 });
|
||||
const after = returned.players.find(player => player.id === fixture.owner)!;
|
||||
assert.equal(after.tanks, before.tanks - 3);
|
||||
assert.equal(after.freeForcesRevived, 3);
|
||||
// Only historical receipt encoding is changed; both physical snapshots came
|
||||
// from the genuine original producer and the actual native return above.
|
||||
const legacyReceipt = { event: offer.event, owner: fixture.owner, turn: returned.turn,
|
||||
phase: 4, faction: after.faction, advanced, elite: 0,
|
||||
before: pools(before), after: pools(after), signature: '' };
|
||||
legacyReceipt.signature = JSON.stringify({ ...legacyReceipt, signature: undefined });
|
||||
const legacy = JSON.parse(JSON.stringify({ ...returned,
|
||||
nexusFremenRevivalHistory: [legacyReceipt] })) as Game;
|
||||
delete legacy.revivalRules!.nexusFremen;
|
||||
const original = structuredClone(legacy);
|
||||
const projected = viewGame(legacy, fixture.owner);
|
||||
assert.equal(projected.revival.freeRemaining, 0);
|
||||
assert.deepEqual(legacy, original, 'Reading an old encoding cannot rewrite its physical state');
|
||||
const adopted = normalizeAutomaticGame(legacy);
|
||||
assert.deepEqual(adopted.players, returned.players);
|
||||
assert.deepEqual(adopted.nexusCards, returned.nexusCards);
|
||||
assert.deepEqual(adopted.ready, returned.ready);
|
||||
assert.equal(adopted.phase, 4);
|
||||
assert.equal(adopted.nexusFremenRevivalHistory![0].version, 2);
|
||||
assert.deepEqual(adopted.nexusFremenRevivalHistory![0].legacyReturn,
|
||||
{ elite: 0, before: pools(before), after: pools(after) });
|
||||
assert.deepEqual(adopted.revivalRules!.nexusFremen,
|
||||
{ turn: adopted.turn, owners: [fixture.owner] });
|
||||
assert.deepEqual(normalizeAutomaticGame(adopted), adopted,
|
||||
'A second adoption cannot replay the return or reopen the free quota');
|
||||
assert.throws(() => applyAction(adopted, fixture.owner,
|
||||
{ type: 'nexusFremenRevival', event: offer.event }));
|
||||
assert.throws(() => applyAction(adopted, fixture.owner, { type: 'revive', amount: 1, elite: 0 }));
|
||||
assert.deepEqual(legacy, original);
|
||||
const broken = structuredClone(legacy);
|
||||
broken.nexusFremenRevivalHistory![0].signature = 'broken';
|
||||
const damaged = structuredClone(broken);
|
||||
assert.throws(() => viewGame(broken, fixture.owner));
|
||||
assert.throws(() => normalizeAutomaticGame(broken));
|
||||
assert.deepEqual(broken, damaged, 'Invalid historical facts fail without repairing or returning any force');
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -131,10 +131,10 @@ void test('Fremen paid borrowed Guild tariff does not relocate southern reserves
|
||||
collectOnce(f, paid, 0);
|
||||
});
|
||||
|
||||
// Printed Nexus Emperor: three additional free beyond limits. Printed Fremen:
|
||||
// total free three, recorded ordinary-quota interpretation (NEXUS_FREMEN_RULES).
|
||||
// E1 p5 classifies both actual phase-4 free batches as Axlotl activity, without
|
||||
// conflating their different ordinary quota ledgers or granting native identity.
|
||||
// Emperor returns an additional free group directly; Fremen spends its card
|
||||
// before a separate ordinary return that uses the fixed free quota of three.
|
||||
// Both actual free groups accrue Axlotl through their original return source,
|
||||
// without granting native faction identity.
|
||||
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
|
||||
for (const strongholds of [false, true]) {
|
||||
void test(`${kind} consumes the physical card and exact original ledgers${strongholds ? ' without a Stronghold revival bonus' : ''}, then collects Axlotl once`, () => {
|
||||
@@ -161,6 +161,7 @@ for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
|
||||
assert.equal(quote.cost, 2, 'Atreides keeps its ordinary two-free/one-paid invoice after the additional grant.');
|
||||
const wallet = actor.spice;
|
||||
paid = applyAction(paid, f.actor, { type: 'revive', amount: 3 });
|
||||
paid = advanceNexusModulePaymentsToPhase(paid, 4);
|
||||
actor = paid.players.find(p => p.id === f.actor)!;
|
||||
assert.equal(actor.reserves, before.reserves + 6);
|
||||
assert.equal(actor.tanks, before.tanks - 6);
|
||||
@@ -176,17 +177,24 @@ for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
|
||||
}
|
||||
|
||||
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
|
||||
void test(`${kind} preserves actual Advanced elite identity and one-per-turn cap, including bot participation`, () => {
|
||||
void test(`${kind} preserves actual Advanced elite identity and one-per-turn cap through bot activation and native selection`, () => {
|
||||
const f = createNexusModulePaymentsFixture({ kind, advanced: true, strongholds: true,
|
||||
ownerFaction: kind === 'emperor-revival' ? 'fremen' : 'emperor', eliteTanks: 2 });
|
||||
const view = viewGame(f.game, f.actor);
|
||||
const offer = kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.revival : view.nexusFremenRevival!;
|
||||
assert.deepEqual(offer.eliteOptions, [0, 1]);
|
||||
rejected(f.game, f.actor, { ...f.paymentAction, elite: 2 });
|
||||
view.players.find(p => p.id === f.actor)!.bot = 'Hard';
|
||||
const bot = botActions(view).find(action => action.type === f.paymentAction.type);
|
||||
assert.ok(bot, 'The actual bot must participate in the newly admitted module option.');
|
||||
const paid = applyAction(f.game, f.actor, bot);
|
||||
let paid = applyAction(f.game, f.actor, bot);
|
||||
if (f.revivalAction) {
|
||||
rejected(paid, f.actor, { type: 'revive', amount: 3, elite: 2 });
|
||||
paid = applyAction(paid, f.actor, f.revivalAction);
|
||||
}
|
||||
for (let n = 0; (paid.response || paid.decision || paid.pendingRevival) && n < 200; n++) {
|
||||
const next = nextNexusModulePaymentsNativeStep(paid);
|
||||
assert.ok(next);
|
||||
paid = applyAction(paid, next.actor, next.action);
|
||||
}
|
||||
const actor = paid.players.find(p => p.id === f.actor)!;
|
||||
const before = f.beforePayment.players.find(p => p.id === f.actor)!;
|
||||
assert.equal(actor.elites!.reserves, before.elites!.reserves + 1);
|
||||
@@ -201,14 +209,24 @@ for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
|
||||
});
|
||||
}
|
||||
|
||||
void test('Fremen late return and exact-three shortage remain unavailable; a real Truthtrance blocks both Nexus returns', () => {
|
||||
void test('Fremen prior free returns count toward the quota, exhausted improvement is unavailable, and real Truthtrance blocks both grants', () => {
|
||||
const f = createNexusModulePaymentsFixture({ kind: 'fremen-revival' });
|
||||
const late = applyAction(f.game, f.actor, { type: 'revive', amount: 1 });
|
||||
assert.match(viewGame(late, f.actor).nexusFremenRevival!.blocked!, /before ordinary force revivals/);
|
||||
rejected(late, f.actor, f.paymentAction);
|
||||
const late = advanceNexusModulePaymentsToPhase(applyAction(f.game, f.actor, { type: 'revive', amount: 1 }), 4);
|
||||
const granted = applyAction(late, f.actor, f.paymentAction);
|
||||
assert.equal(granted.players.find(p => p.id === f.actor)!.tanks, late.players.find(p => p.id === f.actor)!.tanks);
|
||||
const returned = advanceNexusModulePaymentsToPhase(applyAction(granted, f.actor, { type: 'revive', amount: 2 }), 4);
|
||||
assert.equal(returned.players.find(p => p.id === f.actor)!.freeForcesRevived, 3);
|
||||
assert.equal(returned.players.find(p => p.id === f.actor)!.revived, 3);
|
||||
for (const kind of ['fremen-revival', 'emperor-revival'] as const) {
|
||||
const short = createNexusModulePaymentsFixture({ kind, tanks: 2 });
|
||||
rejected(short.game, short.actor, short.paymentAction);
|
||||
if (kind === 'emperor-revival') rejected(short.game, short.actor, short.paymentAction);
|
||||
else {
|
||||
const enabled = applyAction(short.game, short.actor, short.paymentAction);
|
||||
rejected(enabled, short.actor, { type: 'revive', amount: 3 });
|
||||
const partial = advanceNexusModulePaymentsToPhase(applyAction(enabled, short.actor, { type: 'revive', amount: 2 }), 4);
|
||||
assert.equal(partial.players.find(p => p.id === short.actor)!.tanks, 0);
|
||||
assert.equal(partial.players.find(p => p.id === short.actor)!.freeForcesRevived, 2);
|
||||
}
|
||||
const quiet = createNexusModulePaymentsFixture({ kind });
|
||||
const questioner = quiet.game.players.find(p => p.id !== quiet.actor)!;
|
||||
const card = holdNexusRicheseCard(quiet.game, questioner.id, 'truthtrance');
|
||||
|
||||
@@ -55,8 +55,8 @@ const configurations = [
|
||||
{ name: 'Advanced Tech/Strongholds', advanced: true, tech: true, strongholds: true },
|
||||
] as const;
|
||||
|
||||
// Original Nexus Emperor extra-three and the recorded original Fremen free-three
|
||||
// interpretation remain distinct. E1 printed p5: free revivals trigger Axlotl,
|
||||
// Emperor's additional direct group and Fremen's fixed ordinary free quota
|
||||
// retain separate sources. E1 printed p5: actual free revivals trigger Axlotl,
|
||||
// payable only at Revival end (http://boardgame.bg/dune%20ixians%20and%20tleilaxu%20rulebook.pdf).
|
||||
for (const config of configurations) {
|
||||
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
|
||||
@@ -77,7 +77,7 @@ for (const config of configurations) {
|
||||
const before = nexusSkillsModulesPaymentsPlayer(f.game, f.actor);
|
||||
rejected(f.game, f.opponent, f.paymentAction);
|
||||
rejected(f.game, f.actor, { ...f.paymentAction, event: 'stale' });
|
||||
let paid = payNexusSkillsModulesPaymentsFixture(f);
|
||||
let paid = finishNexusSkillsModulesPaymentsFixture(f, payNexusSkillsModulesPaymentsFixture(f));
|
||||
let actor = nexusSkillsModulesPaymentsPlayer(paid, f.actor);
|
||||
assert.equal(actor.reserves, before.reserves + 3); assert.equal(actor.tanks, before.tanks - 3);
|
||||
assert.deepEqual(wallets(paid), wallets(f.game), 'A free return does not collect the accrued token or mint personal income');
|
||||
@@ -85,10 +85,17 @@ for (const config of configurations) {
|
||||
assert.equal(actor.freeForcesRevived ?? 0, kind === 'fremen-revival' ? 3 : (before.freeForcesRevived ?? 0));
|
||||
assert.equal(paid.nexusCards!.cards!.hands[f.actor], null);
|
||||
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === (kind === 'emperor-revival' ? 'emperor' : 'fremen')).length, 1);
|
||||
const receipt = kind === 'emperor-revival' ? paid.nexusEmperorSecretHistory![0] : paid.nexusFremenRevivalHistory![0];
|
||||
assert.equal(receipt.owner, f.actor); assert.equal(receipt.turn, 2); assert.equal(receipt.phase, 4);
|
||||
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
|
||||
assert.equal(receipt.before.spice, receipt.after.spice);
|
||||
if (kind === 'emperor-revival') {
|
||||
const receipt = paid.nexusEmperorSecretHistory![0];
|
||||
assert.equal(receipt.owner, f.actor); assert.equal(receipt.turn, 2); assert.equal(receipt.phase, 4);
|
||||
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
|
||||
assert.equal(receipt.before.spice, receipt.after.spice);
|
||||
} else {
|
||||
const receipt = paid.nexusFremenRevivalHistory![0];
|
||||
assert.equal(receipt.owner, f.actor); assert.equal(receipt.turn, 2); assert.equal(receipt.phase, 4);
|
||||
assert.equal(receipt.revivedAtUse, before.revived);
|
||||
assert.equal(receipt.freeAtUse, before.freeForcesRevived ?? 0);
|
||||
}
|
||||
if (config.tech) {
|
||||
assert.equal(paid.techTokens!.axlotl.spice, f.tokenCount);
|
||||
assert.equal(paid.techTokens!.axlotl.triggeredTurn, paid.turn);
|
||||
@@ -131,7 +138,7 @@ for (const config of configurations) {
|
||||
rejected(f.game, f.opponent, f.paymentAction);
|
||||
const view = viewGame(f.game, f.actor); view.players.find(p => p.id === f.actor)!.bot = 'Hard';
|
||||
const bot = botActions(view).find(action => action.type === 'nexusEmperorPurchase');
|
||||
assert.deepEqual(bot, f.paymentAction);
|
||||
assert.ok(bot);
|
||||
const paid = applyAction(f.game, f.actor, bot!);
|
||||
assert.deepEqual(wallets(paid), wallets(f.game));
|
||||
assert.equal(nexusSkillsModulesPaymentsPlayer(paid, f.actor).hand.filter(card => card.id === lot.id).length, 1);
|
||||
@@ -170,16 +177,19 @@ for (const config of configurations) {
|
||||
}
|
||||
|
||||
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
|
||||
void test(`Advanced combined ${kind} real bot returns one original elite and respects the shared per-turn cap`, () => {
|
||||
void test(`Advanced combined ${kind} bot activation and native return preserve an original elite and the shared per-turn cap`, () => {
|
||||
const f = createNexusSkillsModulesPaymentsFixture({ kind, advanced: true, tech: true, strongholds: true,
|
||||
ownerFaction: kind === 'emperor-revival' ? 'fremen' : 'emperor', eliteTanks: 2 });
|
||||
const view = viewGame(f.game, f.actor);
|
||||
const offer = kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.revival : view.nexusFremenRevival!;
|
||||
assert.deepEqual(offer.eliteOptions, [0, 1]);
|
||||
rejected(f.game, f.actor, { ...f.paymentAction, elite: 2 });
|
||||
view.players.find(p => p.id === f.actor)!.bot = 'Hard';
|
||||
const bot = botActions(view).find(action => action.type === f.paymentAction.type); assert.ok(bot);
|
||||
const paid = applyAction(f.game, f.actor, bot);
|
||||
let paid = applyAction(f.game, f.actor, bot);
|
||||
if (f.revivalAction) {
|
||||
rejected(paid, f.actor, { type: 'revive', amount: 3, elite: 2 });
|
||||
paid = applyAction(paid, f.actor, f.revivalAction);
|
||||
}
|
||||
paid = finishNexusSkillsModulesPaymentsFixture(f, paid);
|
||||
const before = nexusSkillsModulesPaymentsPlayer(f.game, f.actor), after = nexusSkillsModulesPaymentsPlayer(paid, f.actor);
|
||||
assert.equal(after.elites!.reserves, before.elites!.reserves + 1);
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks - 1); assert.equal(after.elites!.revived, 1);
|
||||
@@ -190,18 +200,25 @@ for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
|
||||
});
|
||||
}
|
||||
|
||||
void test('combined ordinary-first Emperor extra-three and early-only Fremen preserve their different ledgers and exact-three boundary', () => {
|
||||
void test('combined Emperor remains additional while Fremen quota counts prior free returns and requires available improvement', () => {
|
||||
const extra = createNexusSkillsModulesPaymentsFixture({ advanced: true, strongholds: true });
|
||||
const ordinary = applyAction(extra.game, extra.actor, { type: 'revive', amount: 3 });
|
||||
const ordinary = finishNexusSkillsModulesPaymentsFixture(extra,
|
||||
applyAction(extra.game, extra.actor, { type: 'revive', amount: 3 }));
|
||||
const paid = payNexusSkillsModulesPaymentsFixture(extra, ordinary);
|
||||
const before = nexusSkillsModulesPaymentsPlayer(ordinary, extra.actor), after = nexusSkillsModulesPaymentsPlayer(paid, extra.actor);
|
||||
assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, before.tanks - 3);
|
||||
assert.equal(after.spice, before.spice); assert.equal(after.revived, 3); assert.equal(after.freeForcesRevived, 2);
|
||||
assert.equal(paid.techTokens!.axlotl.spice, ordinary.techTokens!.axlotl.spice);
|
||||
const fremen = createNexusSkillsModulesPaymentsFixture({ kind: 'fremen-revival', advanced: true, strongholds: true });
|
||||
const late = applyAction(fremen.game, fremen.actor, { type: 'revive', amount: 1 });
|
||||
assert.ok(viewGame(late, fremen.actor).nexusFremenRevival!.blocked);
|
||||
rejected(late, fremen.actor, fremen.paymentAction);
|
||||
const late = finishNexusSkillsModulesPaymentsFixture(fremen,
|
||||
applyAction(fremen.game, fremen.actor, { type: 'revive', amount: 1 }));
|
||||
const granted = applyAction(late, fremen.actor, fremen.paymentAction);
|
||||
assert.equal(nexusSkillsModulesPaymentsPlayer(granted, fremen.actor).tanks, nexusSkillsModulesPaymentsPlayer(late, fremen.actor).tanks);
|
||||
const returned = finishNexusSkillsModulesPaymentsFixture(fremen,
|
||||
applyAction(granted, fremen.actor, { type: 'revive', amount: 2 }));
|
||||
assert.equal(nexusSkillsModulesPaymentsPlayer(returned, fremen.actor).revived, 3);
|
||||
assert.equal(nexusSkillsModulesPaymentsPlayer(returned, fremen.actor).freeForcesRevived, 3);
|
||||
custody(fremen.game, returned);
|
||||
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
|
||||
const short = createNexusSkillsModulesPaymentsFixture({ kind, advanced: true, strongholds: true, tanks: 2 });
|
||||
rejected(short.game, short.actor, short.paymentAction);
|
||||
|
||||
Reference in new issue
Block a user