Prototype Emperor Nexus shortage handling
This commit is contained in:
1 parent
b620cea873
commit
e5c7e338e8
31 files changed
+1057
-95
No files matched your search
@@ -215,7 +215,7 @@ export function BattlePromises(props: {
|
||||
{action.type === 'nexusSuboids'
|
||||
? 'Use Ixian Cunning to give Suboids full strength without spice support'
|
||||
: action.type === 'nexusSardaukar'
|
||||
? 'Declare Emperor Cunning to count five ordinary forces as Sardaukar for this battle'
|
||||
? `Declare Emperor Cunning to count ${g.nexusSardaukar?.shortCount ?? 'five'} ordinary forces as Sardaukar for this battle`
|
||||
: action.mode === 'special'
|
||||
? `Spend Karama and discard ${(action.cards as string[]).map(name).join(', ')} to gain ${(action.cards as string[]).length * 3} spice`
|
||||
: typeof action.leader === 'string'
|
||||
|
||||
@@ -77,6 +77,7 @@ export function NexusEmperorSecretAlly({
|
||||
if (!eligible) return null;
|
||||
|
||||
const options = offer.revival.eliteOptions;
|
||||
const amount = offer.revival.amount ?? 3;
|
||||
const elite = options.length === 1
|
||||
? options[0]
|
||||
: selectedElite !== null && options.includes(selectedElite)
|
||||
@@ -88,10 +89,15 @@ export function NexusEmperorSecretAlly({
|
||||
<section className="min-w-0 space-y-3" aria-label="Emperor Secret Ally revival">
|
||||
<h3 className="font-serif text-xl">Emperor Secret Ally</h3>
|
||||
<p className="text-base leading-7">
|
||||
Spend the Emperor Nexus card to request exactly 3 additional forces for free
|
||||
Spend the Emperor Nexus card to request {amount} additional {amount === 1 ? 'force' : 'forces'} for free
|
||||
through the normal revival continuation, beyond ordinary force/free allowances.
|
||||
Native typed caps and prevention still apply; Recruits does not double this grant.
|
||||
</p>
|
||||
<p className="notice">
|
||||
Provisional short-group policy: return exactly 3 when eligible; otherwise return
|
||||
all 1 or 2 eligible physical forces. This is not publisher clarification.
|
||||
No eligible forces means the card cannot be spent. This grant revives no leader.
|
||||
</p>
|
||||
{offer.revival.blocked && <p className="notice">{offer.revival.blocked}</p>}
|
||||
{offer.revival.policy && <p className="notice">{offer.revival.policy}</p>}
|
||||
{options.length > 1 && (
|
||||
@@ -118,7 +124,7 @@ export function NexusEmperorSecretAlly({
|
||||
if (!blocked && action) act(action);
|
||||
}}
|
||||
>
|
||||
Revive 3 additional forces{elite === null ? '' : ` · ${elite} elite`}
|
||||
{amount > 0 ? `Revive ${amount} additional ${amount === 1 ? 'force' : 'forces'}` : 'No eligible forces to revive'}{elite === null ? '' : ` · ${elite} elite`}
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -8,27 +8,35 @@ type Props = { game: GameView; act: (action: Action) => void; busy: boolean };
|
||||
export function NexusSardaukar({ game, act, busy }: Props) {
|
||||
const state = game.nexusSardaukar;
|
||||
if (!state) return null;
|
||||
const count = state.shortCount ?? 5;
|
||||
const countText = count === 5 ? 'five' : String(count);
|
||||
const shortage = count < 5 && <p className="notice">
|
||||
Provisional shortage policy: use all {count} available ordinary force{count === 1 ? '' : 's'}.
|
||||
This interpretation is not publisher clarification.
|
||||
</p>;
|
||||
if (state.pending)
|
||||
return (
|
||||
<section aria-label="Emperor Nexus response" className="notice">
|
||||
<p>
|
||||
{game.players.find((p) => p.id === state.pending!.owner)?.name ??
|
||||
'Emperor'}{' '}
|
||||
declared Cunning. Karama may cancel the entire enhancement before the
|
||||
five counters count as Sardaukar.
|
||||
declared Cunning. Karama may cancel the entire enhancement before the{' '}
|
||||
{countText} counter{count === 1 ? '' : 's'} count{count === 1 ? 's' : ''} as Sardaukar.
|
||||
</p>
|
||||
{shortage}
|
||||
</section>
|
||||
);
|
||||
if (state.active)
|
||||
return (
|
||||
<section aria-label="Emperor Nexus active" className="notice">
|
||||
<p>
|
||||
Cunning declared five ordinary Emperor counters as Sardaukar for this
|
||||
Cunning declared {countText} ordinary Emperor counter{count === 1 ? '' : 's'} as Sardaukar for this
|
||||
battle. An own-Homeworld reserve fee can reduce that group;
|
||||
the selected plan shows the remaining combat pool. These counters
|
||||
retain their ordinary physical identity and go to the ordinary Tanks
|
||||
when lost.
|
||||
</p>
|
||||
{shortage}
|
||||
</section>
|
||||
);
|
||||
const offer = state.offer;
|
||||
@@ -41,15 +49,16 @@ export function NexusSardaukar({ game, act, busy }: Props) {
|
||||
>
|
||||
<h3>Emperor Nexus · Cunning</h3>
|
||||
<p>
|
||||
Count five of your ordinary forces as Sardaukar for this battle. Use
|
||||
Count {countText} of your ordinary forces as Sardaukar for this battle. Use
|
||||
this before submitting your plan, with no actual Sardaukar in the
|
||||
battle.
|
||||
</p>
|
||||
<p className="fine">
|
||||
This spends your Nexus card once. Karama may cancel the enhancement. The
|
||||
five counters keep their ordinary physical identity, including when
|
||||
This spends your Nexus card once. Karama may cancel the enhancement. The{' '}
|
||||
{countText} counter{count === 1 ? '' : 's'} keep{count === 1 ? 's its' : ' their'} ordinary physical identity, including when
|
||||
lost.
|
||||
</p>
|
||||
{shortage}
|
||||
{offer.blocked && <p className="notice">{offer.blocked}</p>}
|
||||
<Button
|
||||
className="game-action min-h-11 whitespace-normal"
|
||||
@@ -58,7 +67,7 @@ export function NexusSardaukar({ game, act, busy }: Props) {
|
||||
if (!busy && action) act(action);
|
||||
}}
|
||||
>
|
||||
Use five forces as Sardaukar
|
||||
Use {countText} force{count === 1 ? '' : 's'} as Sardaukar
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,28 @@ open mode gates.
|
||||
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
|
||||
Prototype labels and final acceptance gates remain.
|
||||
|
||||
**Emperor Nexus positive shortages — 27 October. Prototyped / Partial.**
|
||||
Cunning uses five eligible ordinary counters when possible, otherwise the whole
|
||||
positive short battle group. Secret Ally revival takes three when eligible,
|
||||
otherwise all one or two typed-eligible Tanks counters. Zero and chosen underfill
|
||||
remain unavailable. Controls name the actual count and persistent provisional
|
||||
policy; no publisher shortage clarification was found. Full-grant receipt
|
||||
shapes/signatures remain unchanged, while new short sources are explicit.
|
||||
[Contract](NEXUS_EMPEROR_RULES.md#positive-shortages--27-october-2026).
|
||||
|
||||
**18 files, 130/130**, types/lint/build and five native runtime branches pass.
|
||||
Four original public Advanced base33/Nexus sources total 322 actions; 20 accepted
|
||||
continuations cover Cunning2/4 allowance, the original Cunning2 Karama-cancel
|
||||
branch and Secret Ally1/2 returns. Real first-turn losses, turn-two worms,
|
||||
alliances and closing draws earn the sources. Only unopened original card/Spice/
|
||||
Nexus positions and native scalar lotteries are selected; no played hand, purse,
|
||||
force, Tank, phase, alliance, holding or receipt is installed. Ten GameTable and
|
||||
ten controller SSR renders operate five actual controller callbacks. Stateful
|
||||
plans/selectors, browser hydration, new short own-World fees, other module pairs,
|
||||
full modes, mobile/deployed and final assurance are not certified. Older fixture
|
||||
phase/order and occupied-bank Collection expectations are repaired explicitly;
|
||||
their controlled positions are not new native chronology. No mode gate opens.
|
||||
|
||||
**Salusa Ghola / live Truthtrance — 27 October. Prototyped / Partial.**
|
||||
Before either full seal, feasible Yes, whole-No and grouped promises survive the
|
||||
actual return. A return that makes the actor's own promise impossible rejects
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 27 October 2026 — Emperor Nexus positive shortages
|
||||
|
||||
Prototype the selected shortage-only composition: Cunning uses the full five
|
||||
ordinary counters when available, otherwise the whole positive eligible army;
|
||||
Secret Ally revival takes three when eligible, otherwise every one/two eligible
|
||||
typed Tanks counters. No chosen underfill, zero use, leader replacement or
|
||||
physical starred conversion occurs. Original caps, responses and continuations
|
||||
remain. New short receipts are explicit; original full-grant signature/shape
|
||||
bytes remain. Controls name the count and provisional source interpretation.
|
||||
No dedicated publisher shortage clarification was found.
|
||||
[Contract](NEXUS_EMPEROR_RULES.md#positive-shortages--27-october-2026).
|
||||
|
||||
Eighteen files pass 130/130, types/lint and production build. Four original
|
||||
public Advanced base33/Nexus sources total 322 actions; five branches use 20
|
||||
accepted continuations: E2/E4 Cunning allowance, original E2 Karama cancellation
|
||||
and absent-E A Secret Ally1/2 returns. Real shipments/battle losses, next-turn
|
||||
worms, alliances and closing draws earn every source. Only unopened original
|
||||
card/Spice/Nexus positions and scalar lotteries select conditions. Ten GameTable
|
||||
and ten controller SSR renders operate five actual controller callbacks. Four
|
||||
minimal profiles retain legal participation; stateful plan/quantity inputs and
|
||||
browser hydration are not operated.
|
||||
|
||||
Older controlled fixtures now align their staged phase frames and natural
|
||||
ordered ready/revival actors; their positions are not new native chronology.
|
||||
The visitor Reinforcements assertion accounts for the actual occupied-bank
|
||||
Collection award. No short own-World fee pairing, broader module/full-mode,
|
||||
mobile/deployed or final assurance is certified. No deployment, server restart
|
||||
or saved-room/database operation occurred. Private proof
|
||||
`20261027-emperor-nexus-short-proof-v5/` and runtime
|
||||
`20261027-emperor-nexus-short-runtime/run-v2/` use the existing private checkpoint
|
||||
directory. Frozen tree
|
||||
`b606e6af989abcfed0255444dbaf60da00e581a51c4a81502856eed41ded109c`
|
||||
contains 3360 files; final prose overlays are limited to this log,
|
||||
`CURRENT_STATUS.md` and `NEXUS_EMPEROR_RULES.md`.
|
||||
|
||||
## 27 October 2026 — Salusa Ghola with live Truthtrance
|
||||
|
||||
Before either full seal, preserve feasible Yes/whole-No/grouped promises and
|
||||
|
||||
@@ -2,6 +2,73 @@
|
||||
|
||||
Primary-source audit, 10 September 2026, with bounded purchase/revival runtime amendments through 14 October. The source contract does not certify the full mode. The [common Nexus rules](NEXUS_CARD_RULES.md) and release gates remain; recommended compositions are not publisher clarification or separately answered numerical rulings.
|
||||
|
||||
## Positive shortages — 27 October 2026
|
||||
|
||||
Under standing 14 October choose-recommended authority, prototype shortages
|
||||
explicitly. Cunning counts `min(5, actual eligible own ordinary battle counters)`
|
||||
as temporary Sardaukar; Secret Ally returns `min(3, physically and typed-eligible
|
||||
own Tanks counters)`. Use the full printed group when it is available, otherwise
|
||||
use every eligible counter. There is no optional smaller selection, zero use,
|
||||
leader substitution, invented starred counter or additional ordinary allowance.
|
||||
Native elite revival caps and all existing timing/prevention/income still apply.
|
||||
|
||||
The original component photograph was freshly retrieved and visually inspected
|
||||
again. Its Emperor panels name five and three forces without an up-to qualifier.
|
||||
Targeted GF9/designer searches found no shortage clarification; E3 printed11 and
|
||||
the supplied PDF physical23 provide general Nexus rules, not a numerical
|
||||
exception. This is a provisional product composition, not publisher clarification
|
||||
or a newly answered user ruling. Keep that explanation in the actual controls.
|
||||
|
||||
New short Cunning receipts carry `shortGroup:true`, with count exactly equal to
|
||||
the original one-through-four ordinary pool. Full five-role receipt shape and
|
||||
signature stay unchanged. All losses remain ordinary physical counters; own-World
|
||||
reserve fees may reduce the retained temporary pool to zero. Cancellation spends
|
||||
the Nexus but creates no roles or physical return.
|
||||
|
||||
New short revival V2 receipts alone carry `amount:1|2`; absent amount still means
|
||||
three for existing V2 and legacy receipts. The count is derived by the server,
|
||||
not supplied in the action. The original queued revival validates that exact
|
||||
count/free group, returns only those actual pieces and retains ordinary/free
|
||||
usage and purse accounting. Existing return/denial and native Homeworld/Tech/
|
||||
Tleilaxu continuation rules remain; no new module admission is granted.
|
||||
|
||||
### Shortage evidence
|
||||
|
||||
The frozen 18-file union passes 130/130 with types/lint and production build.
|
||||
New positive Cunning receipts cover one through four and retain original five
|
||||
shape/signature bytes. Revival covers physical/typed shortages, no chosen
|
||||
underfill, original three/legacy records, denial and current controls. All four
|
||||
minimal profiles provide legal short declaration/plan/return actions; no strategy
|
||||
or difficulty calibration is claimed.
|
||||
|
||||
Four original public Advanced base33/Nexus sources contain 322 source actions.
|
||||
E2/E4 real ordinary shipments follow actual turn-two worms, A/G alliances and
|
||||
closing draws. One E2 prefix branches into allowance or original opposing
|
||||
Karama cancellation; E4 allows. Separate absent-E A/G/H sources earn one/two
|
||||
real paid battle casualties, then actual next-turn worms, G/H alliances and
|
||||
Emperor draws before Revival. Five branches use 20 accepted continuation actions.
|
||||
No played hand, purse, force, Tank, phase, alliance, Nexus holding or receipt is
|
||||
installed; only unopened original positions and native scalar lotteries select
|
||||
sources. Physical losses and returns remain ordinary counters; revival usage
|
||||
ledgers, purse and current queue remain intact.
|
||||
|
||||
Ten GameTable/ten controller SSR renders operate five actual Cunning/Secret Ally
|
||||
controller callbacks with original action packets. Stateful plan/quantity inputs
|
||||
and browser hydration are not operated. No new short own-World fee pairing,
|
||||
other module combination, full-mode, mobile/deployed or broad recovery/privacy/
|
||||
custody acceptance is certified. Legacy controlled fixture phase jumps now
|
||||
discard stale Charity frames only at their declared staging boundaries; natural
|
||||
ready/ordinary-revival helpers follow actual ordered actors. The older visitor
|
||||
Reinforcements price assertion includes its real occupied-World Collection
|
||||
award rather than treating that income as a changed combat cost.
|
||||
|
||||
Private proof: `/home/quintine/.local/share/dune/checkpoints/20261027-emperor-nexus-short-proof-v5/`;
|
||||
runtime: `20261027-emperor-nexus-short-runtime/run-v2/` beside it. Frozen source
|
||||
tree `b606e6af989abcfed0255444dbaf60da00e581a51c4a81502856eed41ded109c`
|
||||
contains 3360 files. Final evidence prose overlays are limited to this contract,
|
||||
the dashboard and implementation log. Build retains its existing warning scope;
|
||||
no deployment, server restart or saved-room/database operation occurred.
|
||||
|
||||
## Authority
|
||||
|
||||
- [Photograph of the original twelve GF9 Nexus cards](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani), freshly reinspected at `/tmp/dune-nexus-cards.jpg`.
|
||||
@@ -356,7 +423,7 @@ The final build retains existing route-classification/chunk-size warnings.
|
||||
|
||||
The following material cases remain unresolved; no new question was sent:
|
||||
|
||||
- **Short groups:** may Cunning operate with fewer than five eligible ordinary counters, or Secret Ally revive fewer than three when Tanks/typed limits cannot supply three? Neither panel says up to. Do not disguise the question as a silently truncated count.
|
||||
- **Short groups:** the numerical publisher question remains unanswered. The [27 October shortage prototype](#positive-shortages--27-october-2026) supplies an explicit positive-only available-count policy under standing authority; it is not a silent up-to reading or publisher clarification.
|
||||
- **Wider forced payment:** the normal-auction prototype chooses the full original obligation and refuses missing own funding or an already fully prepaid price. Arbitrary larger transfers and special seller-lot composition remain unimplemented; the printed text is not claimed to answer them.
|
||||
- **Special purchases and revival suppression/income:** resolve the concrete boundaries above before connecting broad existing payment/free-revival adapters.
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Emperor Nexus Secret Ally revival and original purchases
|
||||
|
||||
Current [positive-shortage follow-up](NEXUS_EMPEROR_RULES.md#positive-shortages--27-october-2026) adds one/two-counter eligible returns while retaining the original full-three paths. The earlier evidence below remains dated.
|
||||
|
||||
Updated 14 October 2026. **Prototyped**, with **Partial** rules coverage.
|
||||
Revival now uses the independent extra source through original native pending
|
||||
continuations and supported Nexus module envelopes. Ordinary bank-auction
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Rule decision index
|
||||
|
||||
## Emperor Nexus positive short groups — 27 October 2026
|
||||
|
||||
Select the provisional positive-only shortage composition under standing
|
||||
14 October authority. Cunning uses min(5, eligible own ordinary battle pool);
|
||||
Secret Ally revival uses min(3, typed-eligible actual Tanks). Use the full printed
|
||||
grant whenever possible and all eligible pieces otherwise, with no chosen
|
||||
underfill, zero use, leader substitute or physical starred creation. Existing
|
||||
typed caps and original continuation/cost/prevention rules remain. New short
|
||||
sources are explicit; old full-grant receipt shapes/signatures stay unchanged.
|
||||
Fresh inspection confirms neither printed panel says up to, and no dedicated
|
||||
primary clarification was found. This is a product interpretation, not a
|
||||
publisher ruling. [Source and contract](NEXUS_EMPEROR_RULES.md#positive-shortages--27-october-2026).
|
||||
|
||||
## Salusa Ghola with binding Truthtrance — 27 October 2026
|
||||
|
||||
Remove the blanket live-promise restriction before either full seal. Preserve
|
||||
|
||||
+24
-13
@@ -118,7 +118,7 @@ import { quoteEcazInquiry, ecazInquiryAnswer, ecazInquiryHistory, ecazInquirySig
|
||||
import { nexusCleanPlayBlocked } from './nexus-play-boundary';
|
||||
import { quoteEcazBattleLead } from './ecaz-homeworld-lead';
|
||||
import { sourceForRicheseCunning, richeseCunningRateMatches, validRicheseCunningRateSource, RicheseCunningRateError, type RicheseCunningRateSource } from './richese-cunning-rate';
|
||||
import { EMPEROR_NEXUS_REVIVALS, emperorNexusLegacyRevivalModeSupported, quoteEmperorNexusRevival, createEmperorNexusRevival, settleEmperorNexusRevival, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally';
|
||||
import { emperorNexusRevivalCount, emperorNexusLegacyRevivalModeSupported, quoteEmperorNexusRevival, createEmperorNexusRevival, settleEmperorNexusRevival, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally';
|
||||
import { emperorNexusPurchaseEvent, quoteEmperorNexusPurchase, signEmperorNexusPurchase, validateEmperorNexusPurchase, emperorNexusBankPurchaseModeSupported, emperorNexusSellerPurchaseModeSupported, quoteEmperorNexusSellerPurchase, createEmperorNexusSellerPurchase, emperorNexusAmbassadorPurchaseModeSupported, quoteEmperorNexusAmbassadorPurchase, createEmperorNexusAmbassadorPurchase, quoteEmperorNexusSpecialPurchase, createEmperorNexusSpecialPurchase, type EmperorNexusPurchase } from './nexus-emperor-purchase';
|
||||
import { createEmperorPaymentReceipt, validateEmperorPaymentReceipt, emperorPaymentUseBlock, emperorPaymentSourceQuote, requiredEmperorPaymentResponders, emperorPaymentReceiptSignature, type EmperorPaymentReceipt } from './nexus-emperor-payment';
|
||||
import { FREMEN_NEXUS_FREE_FORCES, fremenNexusRevivalOffer, createFremenNexusRevival, upgradeFremenNexusRevival, validateFremenNexusRevival, type FremenNexusRevival } from './nexus-fremen-revival';
|
||||
@@ -3333,15 +3333,16 @@ function playNexusEmperorRevive(g:Game,p:Player,action:Action) {
|
||||
const offer = nexusEmperorSecretAllyOffer(g,p.id);
|
||||
requireRule(offer && !offer.revival.blocked && action.event === offer.event &&
|
||||
Object.keys(action).sort().join(',') === 'elite,event,type' && offer.revival.eliteOptions.includes(action.elite as number),
|
||||
offer?.revival.blocked ?? 'Choose the current Emperor Nexus revival and an eligible exact three-force group.');
|
||||
offer?.revival.blocked ?? 'Choose the current Emperor Nexus revival and its eligible physical force group.');
|
||||
const record = nexusRule(() => createEmperorNexusRevival(g, p.id, action.elite as number));
|
||||
const amount = emperorNexusRevivalCount(record);
|
||||
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players));
|
||||
(g.nexusEmperorSecretHistory ??= []).push(record);
|
||||
(g.nexusEmperorSecretEvents ??= []).push(record.event);
|
||||
log(g, `${p.name} spent Emperor Nexus Secret Ally to request exactly three additional forces for free through the original revival continuation. Ordinary force/free allowances are unchanged; native Tleilaxu prevention applies under the provisional independent-extra-return policy.`,
|
||||
log(g, `${p.name} spent Emperor Nexus Secret Ally to request ${amount === 3 ? 'exactly three' : amount} additional force${amount === 1 ? '' : 's'} for free through the original revival continuation.${amount < 3 ? ' Provisional shortage policy uses every currently eligible counter.' : ''} Ordinary force/free allowances are unchanged; native Tleilaxu prevention applies under the provisional independent-extra-return policy.`,
|
||||
{faction:p.faction,name:'Emperor Nexus revival'});
|
||||
beginRevival(g, { player: p.id, kind: 'forces', amount: EMPEROR_NEXUS_REVIVALS,
|
||||
elite: record.elite, free: EMPEROR_NEXUS_REVIVALS, normalCost: 0, cost: 0,
|
||||
beginRevival(g, { player: p.id, kind: 'forces', amount,
|
||||
elite: record.elite, free: amount, normalCost: 0, cost: 0,
|
||||
checks: [], nexusEmperorEvent: record.event });
|
||||
}
|
||||
function finishNexusEmperorRevival(g: Game, revival: PendingRevival, outcome: 'returned' | 'denied') {
|
||||
@@ -3351,7 +3352,7 @@ function finishNexusEmperorRevival(g: Game, revival: PendingRevival, outcome: 'r
|
||||
const record = history?.[index];
|
||||
requireRule(record && 'version' in record && record.version === 2 &&
|
||||
record.stage === 'requested' && record.owner === revival.player,
|
||||
'The extra-three revival lost its original consumed Nexus source.');
|
||||
'The additional revival lost its original consumed Nexus source.');
|
||||
history![index] = nexusRule(() => settleEmperorNexusRevival(g, record, outcome));
|
||||
if (outcome === 'denied')
|
||||
log(g, `${getPlayer(g, revival.player).name}'s Emperor Nexus extra return was prevented. The Nexus remains spent; no counters, payment, revival income or Tech activity was granted.`);
|
||||
@@ -4265,7 +4266,9 @@ function nexusSardaukarIntegrity(g: Game) {
|
||||
requireRule(record.stage === 'active' && ['pending','complete'].includes(losses.outcome) &&
|
||||
Object.keys(losses).sort().join(',') ===
|
||||
['dial','forces','options','outcome','support',...(fee ? ['reserveFee'] : []),...Object.keys(constraints)].sort().join(',') &&
|
||||
validCombatForces(losses.forces) && (fee ? Number(losses.forces.temporaryElite) >= 2 : losses.forces.temporaryElite === 5) &&
|
||||
validCombatForces(losses.forces) && (fee
|
||||
? Number(losses.forces.temporaryElite) >= 0 && Number(losses.forces.temporaryElite) < record.receipt.count
|
||||
: losses.forces.temporaryElite === record.receipt.count) &&
|
||||
Array.isArray(losses.options) && losses.options.length > 0 &&
|
||||
JSON.stringify(losses.options) === JSON.stringify(committedCasualtyOptions(losses.forces,losses.dial,losses.support,losses)),
|
||||
'The Nexus Sardaukar casualties have lost their committed force allocation.');
|
||||
@@ -4315,7 +4318,8 @@ function nexusSardaukarIntegrity(g: Game) {
|
||||
if (record.stage !== 'canceled') {
|
||||
const p = getPlayer(g, record.receipt.owner), other = getPlayer(g, b!.attacker === p.id ? b!.defender : b!.attacker);
|
||||
const forces = combatForces(g, p, b!.territory, other);
|
||||
requireRule(forces.normal >= 5 && forces.elite === 0, 'The Nexus battle no longer has five eligible ordinary forces.');
|
||||
requireRule(forces.normal >= record.receipt.count && forces.elite === 0,
|
||||
'The Nexus battle no longer has its original eligible ordinary role group.');
|
||||
}
|
||||
}
|
||||
for (const response of responses) validateNexusSardaukarResponse(g, response!);
|
||||
@@ -4335,7 +4339,7 @@ function nexusSardaukarEligibility(g: Game, p: Player): string | null {
|
||||
const other = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
|
||||
const forces = combatForces(g, p, b.territory, other);
|
||||
if (forces.elite) return 'Cunning requires a battle containing none of your actual Sardaukar.';
|
||||
if (forces.normal < 5) return 'Using Emperor Cunning with fewer than five eligible forces awaits a ruling.';
|
||||
if (forces.normal < 1) return 'Cunning needs at least one eligible ordinary force in this battle.';
|
||||
return null;
|
||||
}
|
||||
function projectedNexusSardaukar(g: Game, id: string) {
|
||||
@@ -4343,9 +4347,15 @@ function projectedNexusSardaukar(g: Game, id: string) {
|
||||
const record = currentNexusSardaukar(g);
|
||||
const active = record?.stage === 'active';
|
||||
const pending = record?.stage === 'pending' ? {event: record.receipt.event, owner: record.receipt.owner} : null;
|
||||
const short: { shortCount?: number } = record?.receipt.shortGroup ? { shortCount: record.receipt.count } : {};
|
||||
const p = getPlayer(g,id);
|
||||
if (p.faction !== 'emperor' || g.nexusCards.cards.hands[id] !== 'emperor') return {offer:null,active,pending};
|
||||
if (p.faction !== 'emperor' || g.nexusCards.cards.hands[id] !== 'emperor') return {offer:null,active,pending,...short};
|
||||
let blocked = nexusSardaukarEligibility(g,p);
|
||||
if (!blocked) {
|
||||
const b = g.battle!, other = getPlayer(g,b.attacker === p.id ? b.defender : b.attacker);
|
||||
const normal = combatForces(g,p,b.territory,other).normal;
|
||||
if (normal < 5) short.shortCount = normal;
|
||||
}
|
||||
if (!blocked && (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingKarama ||
|
||||
g.pendingTreacheryDiscard || g.pendingNullentropy || pendingNexusTraitors(g) || g.nexusCards.phase?.stage === 'drawing'))
|
||||
blocked = 'Finish the current interaction before declaring Nexus Sardaukar.';
|
||||
@@ -4356,7 +4366,7 @@ function projectedNexusSardaukar(g: Game, id: string) {
|
||||
if (!findReachableBattlePlan(trial,getPlayer(trial,id)))
|
||||
blocked = 'Sardaukar strength would prevent you from honoring an existing Battle Plan commitment.';
|
||||
}
|
||||
return {offer:{event,blocked},active,pending};
|
||||
return {offer:{event,blocked},active,pending,...short};
|
||||
}
|
||||
function commitNexusSardaukar(g: Game, p: Player, stage: 'pending' | 'active') {
|
||||
const blocked = nexusSardaukarEligibility(g,p);
|
||||
@@ -4375,7 +4385,8 @@ function playNexusSardaukar(g: Game,p: Player,action: Action) {
|
||||
requireRule(Object.keys(action).every(key => ['type','event'].includes(key)) && offer && !offer.blocked && action.event === offer.event,
|
||||
offer?.blocked ?? 'Choose the current Emperor Cunning opportunity.');
|
||||
commitNexusSardaukar(g,p,'pending');
|
||||
log(g,`${p.name} spent Emperor Nexus Cunning to count five ordinary forces as Sardaukar for this battle. Karama may prevent this use. The physical counters remain ordinary forces.`,
|
||||
const count = currentNexusSardaukar(g)!.receipt.count;
|
||||
log(g,`${p.name} spent Emperor Nexus Cunning to count ${count === 5 ? 'five' : count} ordinary force${count === 1 ? '' : 's'} as Sardaukar for this battle.${count < 5 ? ' Provisional shortage policy uses the available group.' : ''} Karama may prevent this use. The physical counters remain ordinary forces.`,
|
||||
{faction:p.faction,name:'Nexus Sardaukar declared'});
|
||||
}
|
||||
function finishNexusSardaukar(g: Game,canceled: boolean) {
|
||||
@@ -4384,7 +4395,7 @@ function finishNexusSardaukar(g: Game,canceled: boolean) {
|
||||
g.nexusSardaukarLast = {event:record.receipt.event,stage:record.stage};
|
||||
if (canceled) reconcileChangedBattleInspections(g, 'the canceled Sardaukar enhancement');
|
||||
log(g,canceled ? 'Karama prevented Emperor Cunning. These forces remain ordinary in both strength and physical custody; the Nexus card stays spent.'
|
||||
: 'Five ordinary Emperor forces count as Sardaukar in this battle only. They use Sardaukar strength and support, including the Fremen exception, but every casualty remains an ordinary physical counter.',
|
||||
: `${record.receipt.count === 5 ? 'Five' : record.receipt.count} ordinary Emperor force${record.receipt.count === 1 ? '' : 's'} count${record.receipt.count === 1 ? 's' : ''} as Sardaukar in this battle only. They use Sardaukar strength and support, including the Fremen exception, but every casualty remains an ordinary physical counter.`,
|
||||
{faction:'emperor',name:canceled ? 'Nexus Sardaukar prevented' : 'Nexus Sardaukar active'});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
import { EMPEROR_NEXUS_REVIVALS } from './nexus-emperor-secret-ally';
|
||||
|
||||
/** Bind the current private offer without inspecting another player's cards. */
|
||||
export function nexusEmperorRevivalAction(
|
||||
@@ -6,6 +7,7 @@ export function nexusEmperorRevivalAction(
|
||||
elite: number,
|
||||
): Action | null {
|
||||
const offer = game.nexusEmperorSecretAlly;
|
||||
const amount = offer?.revival.amount ?? EMPEROR_NEXUS_REVIVALS;
|
||||
const owner = game.players.find((player) => player.id === game.me);
|
||||
if (
|
||||
!offer ||
|
||||
@@ -23,7 +25,10 @@ export function nexusEmperorRevivalAction(
|
||||
game.nexusCards.waiting.length ||
|
||||
game.nexusTraitors?.pending ||
|
||||
offer.revival.blocked ||
|
||||
offer.event !== offer.revival.event ||
|
||||
!Number.isSafeInteger(amount) || amount < 1 || amount > EMPEROR_NEXUS_REVIVALS ||
|
||||
!Number.isSafeInteger(elite) ||
|
||||
elite < 0 || elite > amount ||
|
||||
!offer.revival.eliteOptions.includes(elite)
|
||||
)
|
||||
return null;
|
||||
|
||||
@@ -52,7 +52,7 @@ export type NexusEmperorRevival =
|
||||
stage?: never;
|
||||
after: EmperorNexusPools;
|
||||
})
|
||||
| (EmperorNexusRevivalIdentity & { version: 2 } & (
|
||||
| (EmperorNexusRevivalIdentity & { version: 2; amount?: 1 | 2 } & (
|
||||
| { stage: 'requested'; after: null }
|
||||
| { stage: 'returned' | 'denied'; after: EmperorNexusPools }
|
||||
));
|
||||
@@ -60,6 +60,11 @@ export type NexusEmperorRevivalRequest = Extract<
|
||||
NexusEmperorRevival,
|
||||
{ version: 2; stage: 'requested' }
|
||||
>;
|
||||
/** Existing receipts omit the amount and retain their original three-force
|
||||
* signature. Only a new short requested return records one or two. */
|
||||
export function emperorNexusRevivalCount(receipt: NexusEmperorRevival): number {
|
||||
return receipt.version === 2 ? receipt.amount ?? EMPEROR_NEXUS_REVIVALS : EMPEROR_NEXUS_REVIVALS;
|
||||
}
|
||||
export function emperorNexusPools(p: Player): EmperorNexusPools {
|
||||
return {
|
||||
reserves: p.reserves,
|
||||
@@ -108,17 +113,29 @@ export function emperorNexusLegacyRevivalModeSupported(g: Game): boolean {
|
||||
(!!g.leaderSkills && classicNexusLeaderSkillsProfile(g)))))
|
||||
);
|
||||
}
|
||||
/** A fixed three-counter grant preserves the separate Advanced elite cap. */
|
||||
/** Provisional short-group completion: take three whenever eligible; otherwise
|
||||
* take every eligible physical counter, respecting the existing typed cap. */
|
||||
export function emperorNexusRevivalAmount(
|
||||
p: Pick<Player, 'tanks' | 'faction' | 'elites'>,
|
||||
advanced: boolean,
|
||||
): number {
|
||||
return Math.min(EMPEROR_NEXUS_REVIVALS,
|
||||
p.tanks - (p.elites?.tanks ?? 0) + eliteRevivalRemaining(p, advanced));
|
||||
}
|
||||
|
||||
/** Choose only the typed composition of the current maximal return. */
|
||||
export function emperorNexusRevivalElites(
|
||||
p: Player,
|
||||
advanced: boolean,
|
||||
): number[] {
|
||||
const amount = emperorNexusRevivalAmount(p, advanced);
|
||||
if (amount <= 0) return [];
|
||||
const choices: number[] = [];
|
||||
for (let elite = 0; elite <= 3; elite++)
|
||||
for (let elite = 0; elite <= amount; elite++)
|
||||
if (
|
||||
elite <= eliteRevivalRemaining(p, advanced) &&
|
||||
elite <= (p.elites?.tanks ?? 0) &&
|
||||
3 - elite <= p.tanks - (p.elites?.tanks ?? 0)
|
||||
amount - elite <= p.tanks - (p.elites?.tanks ?? 0)
|
||||
)
|
||||
choices.push(elite);
|
||||
return choices;
|
||||
@@ -129,6 +146,8 @@ const receiptKeys =
|
||||
'advanced,after,before,elite,event,faction,kind,owner,phase,sequence,signature,turn';
|
||||
const receiptV2Keys =
|
||||
'advanced,after,before,elite,event,faction,kind,owner,phase,sequence,signature,stage,turn,version';
|
||||
const receiptV2ShortKeys =
|
||||
'advanced,after,amount,before,elite,event,faction,kind,owner,phase,sequence,signature,stage,turn,version';
|
||||
const whole = (n: unknown): n is number =>
|
||||
Number.isSafeInteger(n) && (n as number) >= 0;
|
||||
function shape(value: unknown, keys: string): boolean {
|
||||
@@ -149,10 +168,11 @@ function samePools(a: EmperorNexusPools, b: EmperorNexusPools): boolean {
|
||||
a[key as keyof EmperorNexusPools] === b[key as keyof EmperorNexusPools]);
|
||||
}
|
||||
function returnedPools(r: NexusEmperorRevival): EmperorNexusPools {
|
||||
const amount = emperorNexusRevivalCount(r);
|
||||
return {
|
||||
...r.before,
|
||||
reserves: r.before.reserves + EMPEROR_NEXUS_REVIVALS,
|
||||
tanks: r.before.tanks - EMPEROR_NEXUS_REVIVALS,
|
||||
reserves: r.before.reserves + amount,
|
||||
tanks: r.before.tanks - amount,
|
||||
eliteReserves: r.before.eliteReserves + r.elite,
|
||||
eliteTanks: r.before.eliteTanks - r.elite,
|
||||
eliteRevived: r.before.eliteRevived + r.elite,
|
||||
@@ -167,17 +187,19 @@ export function validateEmperorNexusRevival(
|
||||
): void {
|
||||
const p = r && g.players.find(p => p.id === r.owner);
|
||||
const legacy = shape(r, receiptKeys);
|
||||
if (!p || !(legacy || shape(r, receiptV2Keys)) ||
|
||||
const short = shape(r, receiptV2ShortKeys);
|
||||
const amount = r ? emperorNexusRevivalCount(r) : EMPEROR_NEXUS_REVIVALS;
|
||||
if (!p || !(legacy || shape(r, receiptV2Keys) || short) ||
|
||||
r.kind !== 'revival' || r.phase !== 4 ||
|
||||
typeof r.owner !== 'string' || !r.owner.trim() ||
|
||||
!whole(r.turn) || r.turn < 1 || r.turn > g.turn ||
|
||||
r.faction !== p.faction || r.advanced !== g.advanced ||
|
||||
!whole(r.sequence) || !whole(r.elite) || r.elite > EMPEROR_NEXUS_REVIVALS ||
|
||||
!whole(r.sequence) || !whole(r.elite) || r.elite > amount ||
|
||||
r.event !== emperorNexusEvent(r.turn, 4, r.owner, r.sequence) ||
|
||||
r.signature !== emperorNexusSignature(r) || !validPools(r.before) ||
|
||||
r.before.tanks < EMPEROR_NEXUS_REVIVALS ||
|
||||
r.before.tanks < amount ||
|
||||
r.elite > r.before.eliteTanks ||
|
||||
EMPEROR_NEXUS_REVIVALS - r.elite > r.before.tanks - r.before.eliteTanks)
|
||||
amount - r.elite > r.before.tanks - r.before.eliteTanks)
|
||||
throw new Error('Emperor Nexus revival has lost its exact original physical return or allowance.');
|
||||
if (legacy) {
|
||||
if (g.expansions.length ||
|
||||
@@ -192,6 +214,10 @@ export function validateEmperorNexusRevival(
|
||||
}
|
||||
const typed = p.faction === 'ixians' || p.faction === 'fremen';
|
||||
if (r.version !== 2 ||
|
||||
(short && r.amount !== 1 && r.amount !== 2) ||
|
||||
amount !== emperorNexusRevivalAmount({ faction: r.faction, tanks: r.before.tanks,
|
||||
elites: { reserves: r.before.eliteReserves, tanks: r.before.eliteTanks,
|
||||
revived: r.before.eliteRevived, forces: {} } }, r.advanced) ||
|
||||
g.players.some(seat => seat.faction === 'emperor') ||
|
||||
(!typed && (r.elite || r.before.eliteTanks || r.before.eliteReserves || r.before.eliteRevived)) ||
|
||||
(typed && r.before.eliteReserves + r.before.eliteTanks > (p.faction === 'ixians' ? 7 : 3)) ||
|
||||
@@ -236,16 +262,18 @@ function revivalModeSupported(g: Game): boolean {
|
||||
|
||||
/** Provisional composition, not a publisher clarification or numerical user ruling. */
|
||||
const revivalPolicy =
|
||||
'Provisional composed policy: exactly three additional free physical forces from Tanks, beyond ordinary force and free-revival allowances. Native typed caps and Tleilaxu prevention still apply; this is not ordinary free-rate revival, so ordinary free blocking (including LaLaLa) and higher or expanded rates do not reprice it. Recruits does not double this fixed grant. Spend the card on declaration; denial returns no forces and grants no revival income or Tech benefit. No leader, missing-native-Emperor payment, extra Karama response or refund is granted. Actual returns continue through native Tleilaxu, Tech and Homeworld handling.';
|
||||
'Provisional composed policy: return exactly three additional free physical forces from Tanks whenever three are eligible; otherwise return all one or two eligible forces. Zero cannot spend the card. This short-group policy is not publisher clarification. The return is beyond ordinary force and free-revival allowances. Native typed caps and Tleilaxu prevention still apply; this is not ordinary free-rate revival, so ordinary free blocking (including LaLaLa) and higher or expanded rates do not reprice it. Recruits does not double the grant. Spend the card on declaration; denial returns no forces and grants no revival income or Tech benefit. No leader, missing-native-Emperor payment, extra Karama response or refund is granted. Actual returns continue through native Tleilaxu, Tech and Homeworld handling.';
|
||||
|
||||
export function quoteEmperorNexusRevival(g: Game, owner: string): {
|
||||
event: string;
|
||||
amount: number;
|
||||
blocked: string | null;
|
||||
eliteOptions: number[];
|
||||
policy: string;
|
||||
} {
|
||||
const p = g.players.find(p => p.id === owner);
|
||||
const event = emperorNexusEvent(g.turn, g.phase, owner, g.nexusEmperorSecretHistory?.length ?? 0);
|
||||
const amount = p ? emperorNexusRevivalAmount(p, g.advanced) : 0;
|
||||
const eliteOptions = p ? emperorNexusRevivalElites(p, g.advanced) : [];
|
||||
let blocked: string | null = null;
|
||||
if (!p || g.nexusCards?.cards?.hands[owner] !== 'emperor')
|
||||
@@ -256,7 +284,7 @@ export function quoteEmperorNexusRevival(g: Game, owner: string): {
|
||||
else if (!revivalModeSupported(g))
|
||||
blocked = 'Emperor Nexus revival requires an original supported Nexus roster and module profile.';
|
||||
else if (g.status !== 'playing' || g.phase !== 4)
|
||||
blocked = 'Use the three-force Emperor Nexus return during Revival.';
|
||||
blocked = 'Use the additional Emperor Nexus return during Revival.';
|
||||
else if (g.response || g.decision || g.truthtrance || g.phaseOpening ||
|
||||
g.pendingRevival || g.pendingKarama || g.pendingTreacheryDiscard ||
|
||||
g.pendingNullentropy || g.pendingExchange || g.pendingRicheseGift ||
|
||||
@@ -268,8 +296,8 @@ export function quoteEmperorNexusRevival(g: Game, owner: string): {
|
||||
else if (revivalPrevented(g, owner))
|
||||
blocked = 'Native Tleilaxu prevention blocks this revival.';
|
||||
else if (!eliteOptions.length)
|
||||
blocked = 'Emperor Nexus needs exactly three eligible forces in Tanks.';
|
||||
return { event, blocked, eliteOptions, policy: revivalPolicy };
|
||||
blocked = 'Emperor Nexus needs at least one eligible force in Tanks.';
|
||||
return { event, amount, blocked, eliteOptions, policy: revivalPolicy };
|
||||
}
|
||||
|
||||
/** Pure declaration receipt: the engine commits card custody and the original pending revival. */
|
||||
@@ -280,12 +308,13 @@ export function createEmperorNexusRevival(
|
||||
): NexusEmperorRevivalRequest {
|
||||
const quote = quoteEmperorNexusRevival(g, owner);
|
||||
if (quote.blocked || !quote.eliteOptions.includes(elite))
|
||||
throw new Error(quote.blocked ?? 'Choose an eligible exact three-force typed group.');
|
||||
throw new Error(quote.blocked ?? 'Choose an eligible typed group for the current additional return.');
|
||||
const p = g.players.find(p => p.id === owner)!;
|
||||
const r: NexusEmperorRevivalRequest = {
|
||||
kind: 'revival', version: 2, event: quote.event, owner, turn: g.turn, phase: 4,
|
||||
sequence: g.nexusEmperorSecretHistory?.length ?? 0,
|
||||
faction: p.faction, advanced: g.advanced, elite,
|
||||
...(quote.amount === 1 || quote.amount === 2 ? { amount: quote.amount } : {}),
|
||||
before: emperorNexusPools(p), after: null, stage: 'requested', signature: '',
|
||||
};
|
||||
r.signature = emperorNexusSignature(r);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
|
||||
/** The owner-only server offer verifies the five-counter army and the
|
||||
/** The owner-only server offer verifies the available ordinary army and the
|
||||
* feasibility of existing commitments. Preserve its exact battle event. */
|
||||
export function nexusSardaukarAction(
|
||||
game: GameView,
|
||||
|
||||
+14
-6
@@ -13,7 +13,9 @@ export type NexusSardaukarReceipt = {
|
||||
territory: string;
|
||||
normal: number;
|
||||
elite: 0;
|
||||
count: 5;
|
||||
count: number;
|
||||
/** Explicit provisional shortage composition; absent on original five-role receipts. */
|
||||
shortGroup?: true;
|
||||
roster: { id: string; faction: FactionId }[];
|
||||
signature: string;
|
||||
};
|
||||
@@ -58,6 +60,7 @@ function signature(receipt: NexusSardaukarReceipt): string {
|
||||
receipt.elite,
|
||||
receipt.count,
|
||||
receipt.roster.map((p) => [p.id, p.faction]),
|
||||
...(receipt.shortGroup ? ['available-short-group'] : []),
|
||||
]);
|
||||
}
|
||||
/** Historical evidence does not depend on forces surviving this battle. */
|
||||
@@ -72,7 +75,9 @@ export function validateNexusSardaukar(
|
||||
!Array.isArray(receipt) &&
|
||||
[Object.prototype, null].includes(Object.getPrototypeOf(receipt)) &&
|
||||
Object.keys(receipt).sort().join(',') ===
|
||||
'battle,count,elite,event,normal,owner,roster,signature,territory,turn,version' &&
|
||||
['battle','count','elite','event','normal','owner','roster',
|
||||
...(receipt.shortGroup === true ? ['shortGroup'] : []),
|
||||
'signature','territory','turn','version'].sort().join(',') &&
|
||||
receipt.version === 1 &&
|
||||
identifier(receipt.battle) &&
|
||||
identifier(receipt.territory) &&
|
||||
@@ -80,10 +85,12 @@ export function validateNexusSardaukar(
|
||||
receipt.turn > 0 &&
|
||||
receipt.turn <= context.turn &&
|
||||
Number.isSafeInteger(receipt.normal) &&
|
||||
receipt.normal >= 5 &&
|
||||
receipt.normal >= 1 &&
|
||||
receipt.normal <= 20 &&
|
||||
receipt.elite === 0 &&
|
||||
receipt.count === 5 &&
|
||||
(receipt.normal < 5
|
||||
? receipt.shortGroup === true && receipt.count === receipt.normal
|
||||
: receipt.shortGroup === undefined && receipt.count === 5) &&
|
||||
seats.some((p) => p.id === receipt.owner && p.faction === 'emperor') &&
|
||||
receipt.event ===
|
||||
JSON.stringify([
|
||||
@@ -94,7 +101,7 @@ export function validateNexusSardaukar(
|
||||
]) &&
|
||||
JSON.stringify(receipt.roster) === JSON.stringify(seats) &&
|
||||
receipt.signature === signature(receipt),
|
||||
'The Sardaukar Nexus has lost its original battle, five-counter group or owner.',
|
||||
'The Sardaukar Nexus has lost its original battle, physical role group or owner.',
|
||||
);
|
||||
}
|
||||
export function createNexusSardaukar(
|
||||
@@ -113,7 +120,8 @@ export function createNexusSardaukar(
|
||||
territory,
|
||||
normal,
|
||||
elite: 0,
|
||||
count: 5,
|
||||
count: Math.min(5, normal),
|
||||
...(normal < 5 ? { shortGroup: true as const } : {}),
|
||||
roster: roster(context),
|
||||
signature: '',
|
||||
};
|
||||
|
||||
+9
-9
@@ -5877,11 +5877,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'With Emperor absent, an unallied holder can choose exactly three extra free returns or retain a personally affordable original purchase price. Original classic/native bank, Richese cache/Advanced Black Market, Ambassador and joint special Karama have distinct connected paths. Full combinations and assurance retain separate gates.',
|
||||
summary: 'With Emperor absent, an unallied holder can take the current eligible extra free group or retain a personally affordable original purchase price. The provisional shortage policy takes three whenever possible, otherwise all one or two eligible counters. Original classic/native bank, Richese cache/Advanced Black Market, Ambassador and joint special Karama have distinct connected paths. Full combinations and assurance retain separate gates.',
|
||||
steps: [
|
||||
'Use the private Emperor Secret Ally controls during Revival. Select the physical elite count when more than one legal group is available. The card returns exactly three eligible forces from Tanks to reserves and is discarded once.',
|
||||
'Use the private Emperor Secret Ally controls during Revival. Select the physical elite count when more than one legal group is available. The card returns three eligible forces whenever possible, otherwise all one or two eligible counters under the visible provisional shortage policy. It is discarded once; zero forces cannot spend it.',
|
||||
'These returns cost no spice and do not consume ordinary force-revival or free-revival allowances. You may use the card before or after ordinary revivals, while Revival remains open.',
|
||||
'The Advanced one-per-turn Fedaykin limit still applies across ordinary revivals and this card. The extra total allowance does not permit another elite revival. Fewer than three eligible counters is an unresolved case and remains unavailable.',
|
||||
'The Advanced one-per-turn Fedaykin limit still applies across ordinary revivals and this card. The extra total allowance does not permit another elite revival. The visible provisional shortage policy uses the remaining positive eligible group without exceeding that cap.',
|
||||
'The independent revival grant enters original pending revival. Under the visible provisional policy, native Tleilaxu prevention/income, typed reserves, Tech and eligible Homeworld handling remain; ordinary free-rate blocking does not reprice it. Keeping the card creates no extra public confirmation.',
|
||||
'At an eligible original classic/native Nexus normal bank-auction winner payment, prove the positive final bid from your own spice and spend Emperor Nexus to receive the lot without losing spice. Every winner sees the same payment confirmation; the provisional privacy recommendation remains. Canonical native module envelopes preserve original card/capacity, bonus and next-lot inspection; ordinary spice or legal Karama remains available. No bank subsidy or Richese cache/seller route is invented. Strict old physical revival admission is separate and never broadens.',
|
||||
'Original Richese cache and other-buyer Advanced Black Market offer Pay or eligible Emperor Nexus Use after original Richese Betrayal. The provisional seller-unpaid policy retains your full personally affordable price without bank reimbursement. One original lot, native Harkonnen bonus and source successor continue; legal self-cache is included. Already-held Black Market paid retention supplies no acquisition/free-seller source, so it does not invent this confirmation or spend a Nexus. Original self-purchase forced discard cannot consume this Nexus or acquire a card.',
|
||||
@@ -5890,11 +5890,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['nexus-cards', 'revival'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Independent extra-three and seller/Ambassador/joint sources remain. Normal bank purchase now uses canonical native Nexus admission plus exact old classic parity, preserving its original price, card/capacity/bonus/successor and receipt format. Strict legacy physical revival stays classic. No subsidy or extra seller source; full combinations and assurance are open.', evidence: ['game/nexus-emperor-secret-ally.ts', 'game/nexus-emperor-purchase.ts', 'game/auction-continuation-quote.ts', 'game/engine.ts', 'game/ambassador-phase.ts', 'game/ambassador-resume.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Independent extra returns retain the full three-counter source and explicitly record positive short groups under the provisional policy. Seller/Ambassador/joint sources remain. Normal bank purchase now uses canonical native Nexus admission plus exact old classic parity, preserving its original price, card/capacity/bonus/successor and receipt format. Strict legacy physical revival stays classic. No subsidy or extra seller source; full combinations and assurance are open.', evidence: ['game/nexus-emperor-secret-ally.ts', 'game/nexus-emperor-purchase.ts', 'game/auction-continuation-quote.ts', 'game/engine.ts', 'game/ambassador-phase.ts', 'game/ambassador-resume.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Existing uniform bank payment, private eligible Use and original next-lot controls now work for native buyers. Actual390px Richese/Ecaz Homeworld-Tech and Basic Tleilaxu retain price; wrong-face CHOAM Pay and Atreides inspection remain. Existing typed revival and seller/Ambassador/joint controls stay connected; complete-mode acceptance remains open.', evidence: ['components/nexus-emperor-secret-ally.tsx', 'components/nexus-emperor-seller-purchase.tsx', 'components/nexus-emperor-ambassador-purchase.tsx', 'components/nexus-cards.tsx', 'components/game-table.tsx', 'components/richese-special-karama.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Minimal profiles use projected legal revival and original personally funded purchase choices, or Pay, without inspecting rival private custody. Complete strategy/calibration remain unfinished.', evidence: ['game/nexus-emperor-secret-ally-options.ts', 'game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Printed purchase panel composes with original bank/Richese seller/Ambassador/special purchase classifications under visible provisional recommendations. No fake money, auction, beneficiary veto or broader Kull admission. Full combination/assurance/publication gates remain; CHOAM closing sales/trades are not direct purchase producers.', evidence: ['docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', 'docs/RULE_DECISIONS.md', 'docs/RICHESE_AMBASSADOR_RULES.md', 'docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md', 'docs/RICHESE_ACQUISITION_RULES.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Native bank16-file union217/217, types/lint/build and thirty-six real API profiles pass: all six natives Basic/Advanced Pay/Use and Advanced Homeworld/Tech Pay/Use. Four actual390px surfaces prove Richese5 to5/Ecaz12 to12 with modules unchanged, Basic Tleilaxu5 to5 and Guild-card CHOAM Pay14 to12, one card/index0 to1 and actual next Atreides inspection, no overflow/errors. Prior joint112/112, Ambassador111/111, seller108/108 and other evidence remains; broad assurance and deployed acceptance are open.', evidence: ['tests/nexus-emperor-secret-ally.test.ts', 'tests/nexus-emperor-secret-ally-recovery.test.ts', 'tests/nexus-emperor-purchase-engine.test.ts', 'tests/nexus-emperor-purchase-recovery.test.ts', 'tests/nexus-emperor-secret-ally-purchase-controls.test.ts', 'tests/fixture-nexus-emperor-seller.ts', 'tests/nexus-emperor-seller-purchase.test.ts', 'tests/nexus-emperor-seller-runtime.test.ts', 'tests/fixture-nexus-emperor-ambassador.ts', 'tests/nexus-emperor-ambassador-purchase.test.ts', 'tests/nexus-emperor-ambassador-runtime.test.ts', 'tests/fixture-nexus-emperor-special.ts', 'tests/nexus-emperor-special-purchase.test.ts', 'tests/nexus-emperor-special-runtime.test.ts', 'tests/fixture-nexus-emperor-bank.ts', 'tests/nexus-emperor-bank-profile.test.ts', 'tests/nexus-emperor-bank-runtime.test.ts'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Native bank16-file union217/217, types/lint/build and thirty-six real API profiles pass: all six natives Basic/Advanced Pay/Use and Advanced Homeworld/Tech Pay/Use. Four actual390px surfaces prove Richese5 to5/Ecaz12 to12 with modules unchanged, Basic Tleilaxu5 to5 and Guild-card CHOAM Pay14 to12, one card/index0 to1 and actual next Atreides inspection, no overflow/errors. Prior joint112/112, Ambassador111/111, seller108/108 and other evidence remains; broad assurance and deployed acceptance are open.', evidence: ['tests/nexus-emperor-short-revival.test.ts', 'tests/fixture-nexus-short-revival.ts', 'tests/nexus-short-revival-runtime.test.ts', 'tests/nexus-emperor-secret-ally.test.ts', 'tests/nexus-emperor-secret-ally-recovery.test.ts', 'tests/nexus-emperor-purchase-engine.test.ts', 'tests/nexus-emperor-purchase-recovery.test.ts', 'tests/nexus-emperor-secret-ally-purchase-controls.test.ts', 'tests/fixture-nexus-emperor-seller.ts', 'tests/nexus-emperor-seller-purchase.test.ts', 'tests/nexus-emperor-seller-runtime.test.ts', 'tests/fixture-nexus-emperor-ambassador.ts', 'tests/nexus-emperor-ambassador-purchase.test.ts', 'tests/nexus-emperor-ambassador-runtime.test.ts', 'tests/fixture-nexus-emperor-special.ts', 'tests/nexus-emperor-special-purchase.test.ts', 'tests/nexus-emperor-special-runtime.test.ts', 'tests/fixture-nexus-emperor-bank.ts', 'tests/nexus-emperor-bank-profile.test.ts', 'tests/nexus-emperor-bank-runtime.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -6133,10 +6133,10 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'One Karama response covers the entire declared territory set. Cancellation preserves every original advisor group and lock while leaving the Nexus spent. Under the explicit recommended in-place card exception, Cunning may include freshly accompanied or storm-bound/separated territories. This is provisional, not publisher clarification. Ordinary flipping/arrival, allied capacity, storm movement/casualties and battle geometry remain unchanged; broader module acceptance remains open.',
|
||||
'With BG absent, every fresh Nexus battle asks both combatants in attacker order before Prescience, Nexus inspection or sealed plans. Continue preserves the card. The actual unallied BG-card holder may spend it to require or forbid one canonical type against the opponent. Under the explicit provisional recommendation, original Karama can cancel this named Voice advantage; the Nexus stays spent, only Voice is removed and native preparation continues. Existing in-flight preparation is not retrofitted. Native compliance and special/mode restrictions remain; this does not complete other unfinished effects.',
|
||||
'Bene Gesserit Betrayal can prevent the native Voice while the ordinary pre-plan response is open. In Nexus games every seat responds even without a canceling card, so the window does not reveal the hidden holder. An unallied opponent spends one physical Bene Gesserit Nexus card to remove only that declared Voice; Prescience and plan commitments continue. Truthtrance has priority. This path does not grant borrowed Voice.',
|
||||
'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.',
|
||||
'Emperor Cunning requires a positive own ordinary battle pool and no actual Sardaukar in Advanced, before submitting your plan. The provisional shortage policy uses five when available, otherwise every eligible counter; zero and chosen underfill are unavailable. The played Nexus card opens one Karama response. Allowing it makes the quoted ordinary group count as Sardaukar for this battle; cancellation prevents the entire enhancement and leaves the Nexus card spent.',
|
||||
'Temporary Sardaukar use applicable strength/support rules, including the Fremen exception, but remain physical ordinary counters. Losses go to Tanks as ordinary, without changing starred pools. Existing inspected/promised plans bind and the role ends after this battle; fewer-than-five remains unavailable. Secret Ally independent revival and original bank/seller/Ambassador/joint special purchases have connected controls; compulsory funding stays separate. Full combinations remain open.',
|
||||
'Original normal bank-auction purchase now uses canonical native Nexus roster/module admission and exact prior classic parity. Uniform winner payment is private-face independent. Actual unallied holder proves a positive full own price, spends one Nexus and acquires one original lot without bank payment. Native card capacity, bonus and next Atreides inspection remain; Richese ordinary pool is not a seller/cache route. Bank receipt format is unchanged, and strict legacy physical revival remains separately classic without replay. Unsupported native aliases and full-mode gates stay closed.',
|
||||
'Emperor Secret Ally revival selects exactly three real normal/starred counters for free, outside ordinary total/free and native allied-extra usage. Declaration spends one Nexus and enters original pending revival; no leader or absent native payer is granted. Under the visible provisional independent-source policy, native Tleilaxu may prevent it and actual free returns award income once; ordinary free-rate blocking and Recruits do not reprice or double this fixed grant. Denial keeps both spent cards without forces/income/Tech or refund. Native typed caps, reserve deposits, Axlotl and eligible newly returned Fedaykin placement continue. Tleilax ordinary Free Revival deployment is not extended to this extra source. Source receipts distinguish requested, returned and denied; old physical receipts never replay.',
|
||||
'Emperor Secret Ally revival selects three real counters when eligible, otherwise the full positive typed-eligible group under the visible provisional shortage policy. It is free and outside ordinary total/free and native allied-extra usage. Declaration spends one Nexus and enters original pending revival; no leader or absent native payer is granted. Under the visible provisional independent-source policy, native Tleilaxu may prevent it and actual free returns award income once; ordinary free-rate blocking and Recruits do not reprice or double this fixed grant. Denial keeps both spent cards without forces/income/Tech or refund. Native typed caps, reserve deposits, Axlotl and eligible newly returned Fedaykin placement continue. Tleilax ordinary Free Revival deployment is not extended to this extra source. Source receipts distinguish requested, returned and denied; old physical receipts never replay.',
|
||||
'Original Richese cache and Advanced Black Market sold lots offer every buyer Pay after original Richese Betrayal. An eligible unallied Emperor Nexus holder, with Emperor absent and full price personally affordable, may Use to retain it and leave seller/bank unpaid under the visible provisional recommendation. Actual stock, hand limits, native Harkonnen bonus and successor run once. Legal self-cache is included; self-Black remains blocked. Original self-discard gives no acquisition or Emperor Nexus spend.',
|
||||
'Original Richese Ambassador trigger offers the beneficiary uniform Pay or eligible Emperor Nexus Use, independently of hidden face or private availability. Under provisional purchase composition, an actual unallied holder proves/retains three own spice and acquires one original deck card, with no bank payment, reimbursement or fake auction. Native source phase/resume authority, private inability, draw/recycling, bonus and movement/worm suffix remain. An ally cannot Use; forming alliance disposes held Nexus originally. CHOAM closing sales/trades are not direct purchases.',
|
||||
'Original Advanced Richese special Karama may jointly spend its held activation plus actual Emperor Nexus, after proving three own uncommitted spice. The provisional purchase composition retains that price and acquires one exact cache card at native commit, preserving original once-use, capacity, reserved-card and enclosing controls. Full hand exchanges within its actual limit; final depletion continues to ordinary Bidding. No bank payment, reimbursement, ally bonus, fake auction or public automatic pause. Ordinary paid use remains; broader Kull admission is not invented.',
|
||||
@@ -6176,7 +6176,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{area:"Player controls",status:'Partial',detail:"Richese pair controls retain the purchased combined tariff and persistent bank/rate/cancellation explanation. Fremen table SSR retains original Cunning spend/ride, Betrayal and Great Maker controls; an actual pending Sandmaster quote also renders its shared checkbox. Native Betrayal/Atomics table SSR retains typed/concealed return, revelation, ordered arrival, Aftermath and desert controls; CSS stubs mean no visual/browser acceptance. Earlier selected E3 lead/plan, assassination/revival and Duke controls retain their dated proof. Four earlier original-action390px journeys prove native Junction5/full2, poor1/full2 release plus ordinary one-force replacement, still-held source sand2 disabled then five-force stronghold1, and independent purchased1. The response explains the spent-card consequence. No overflow, browser error or application exception; two tool-only stale-node errors were resolved by fresh evidence without replay. Earlier Junction/loan/refund surfaces remain scoped evidence.",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','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx", "components/junction-transport.tsx"]},
|
||||
{area:"AI",status:'Partial',detail:"The Richese minimal pair candidate and shared payment guard use the original beneficiary purchased rate; strategy remains deferred. All four policies retain original Fremen Cunning and both Betrayal declarations with shared destination legality. All four policies retain minimal legal Ecaz Betrayal and original Aftermath/Sneak participation. All four policies execute original selected E3 Duke Cunning and unused expiry, and legal Occupy plans with real World sources. All four original policies also execute a legal still-held Richese full-rate one-counter alternative after native cancellation, with actual physical delivery and card cost. Shared human/AI quotes no longer promise a canceled discount. Native permission alternatives, recipient-specific lender prices and immune Junction source choice remain. Optional non-reoffer is not forced. No strategic first-candidate pin, tuning or calibration.",evidence:['game/richese-cunning-rate.ts', 'game/homeworld-payment-options.ts', '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','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']},
|
||||
{area:"Documentation",status:'Partial',detail:"The native Richese pair/purchased rate composition and retained provisional exact-card refund are explicit in its canonical source contract. Selected E3 scope, printed two-seat no-alliance draw limit and remaining individual-effect envelopes are explicit in the canonical Nexus contract. Richese printed one-counter pricing and native Guild eligibility stay distinct from absent-faction cancellation, physical force count and independent purchased price. Unfunded spent-card/no-refund release is selected existing no-debt composition, not a new publisher worked example. Immune Junction and earlier permission/refund/loan interpretations remain explicit. Full rules and public gates stay open.",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/GUILD_RATE_KARAMA_RULES.md', 'docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md', 'docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md", "docs/RICHESE_ACQUISITION_RULES.md", "docs/DUKE_VIDAL_RULES.md", "docs/ECAZ_DUKE_ACQUISITION.md", "docs/ECAZ_REVIVAL_RULES.md", "docs/JUNCTION_TRANSPORT_RULES.md", "docs/JUNCTION_TRANSPORT_RUNTIME.md"]},
|
||||
{area:"Verification",status:'Partial',detail:"Independent Discovery adds frozen15-file118/118, types/lint/build and six original-action/rendered-control programmes; broader modes, visual/deployed acceptance and final assurance remain open. Native Fremen modules pass frozen14-file86/86, types/lint/build and nine original-action/rendered-control programmes. HMS has genuine runtime proof; Discovery destination arrival remains quote-only evidence. Earlier native Betrayal/Atomics composition passes frozen16-file115/115, types/lint/build and eight finite original-action/table-SSR programmes, including original Cunning desert Aftermath; visual/browser and deployed acceptance remain open. Earlier selected E3 entry, Occupy, Assassinate/Recruits, Duke and arrival proof remains dated in the canonical contract. Earlier selected E1/E2 native card/cancellation proof remains dated in the canonical Nexus contract. Classic core retains original World/Tech/Stronghold modules with base33/selected36 and optional14. Independent World cases use native setup, population-sensitive Recruits, typed foreign Harass and actual own-Kaitain Reinforcements/Cunning. The local fee reduces only its paid temporary roles; original count5 and real stars remain. Earlier phase/card proofs are dated in the canonical contract. Native-family/Discovery/Semuta envelopes, full-mode and comprehensive assurance remain open.",evidence:['tests/independent-discovery-nexus-entry.test.ts', 'tests/independent-discovery-cards.test.ts', 'tests/independent-discovery-battles.test.ts', 'tests/fremen-cunning-modules.test.ts','tests/fixture-fremen-cunning-modules.ts','tests/fremen-betrayal-modules.test.ts','tests/fixture-fremen-betrayal-modules.ts','tests/nexus-fremen-cunning-modules-quotes.test.ts','tests/ecaz-betrayal-modules.test.ts','tests/fixture-ecaz-betrayal-modules.ts','tests/richese-no-field-hidden-return.test.ts','tests/nexus-ecaz-betrayal-quotes.test.ts','tests/moritani-atomics-modules.test.ts','tests/nexus-moritani-atomics-runtime.test.ts','tests/nexus-world-independent-cards.test.ts','tests/nexus-world-independent-battles.test.ts','tests/reinforcements-temporary-role.test.ts','tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', '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-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/native-guild-rate-karama.test.ts', 'tests/native-guild-rate-modules.test.ts', 'tests/allied-marker-guild-rate.test.ts', 'tests/purchased-homeworld-rate.test.ts', 'tests/native-guild-transport-permission.test.ts', 'tests/native-guild-homeworld-rate.test.ts', 'tests/native-guild-rate-betrayal.test.ts', 'tests/nexus-guild-cunning-engine.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-neLine truncated
|
||||
{area:"Verification",status:'Partial',detail:"Independent Discovery adds frozen15-file118/118, types/lint/build and six original-action/rendered-control programmes; broader modes, visual/deployed acceptance and final assurance remain open. Native Fremen modules pass frozen14-file86/86, types/lint/build and nine original-action/rendered-control programmes. HMS has genuine runtime proof; Discovery destination arrival remains quote-only evidence. Earlier native Betrayal/Atomics composition passes frozen16-file115/115, types/lint/build and eight finite original-action/table-SSR programmes, including original Cunning desert Aftermath; visual/browser and deployed acceptance remain open. Earlier selected E3 entry, Occupy, Assassinate/Recruits, Duke and arrival proof remains dated in the canonical contract. Earlier selected E1/E2 native card/cancellation proof remains dated in the canonical Nexus contract. Classic core retains original World/Tech/Stronghold modules with base33/selected36 and optional14. Independent World cases use native setup, population-sensitive Recruits, typed foreign Harass and actual own-Kaitain Reinforcements/Cunning. The local fee reduces only its paid temporary roles; original count5 and real stars remain. Earlier phase/card proofs are dated in the canonical contract. Native-family/Discovery/Semuta envelopes, full-mode and comprehensive assurance remain open.",evidence:['tests/nexus-short-sardaukar.test.ts', 'tests/fixture-nexus-short-cunning.ts', 'tests/nexus-short-cunning.test.ts', 'tests/independent-discovery-nexus-entry.test.ts', 'tests/independent-discovery-cards.test.ts', 'tests/independent-discovery-battles.test.ts', 'tests/fremen-cunning-modules.test.ts','tests/fixture-fremen-cunning-modules.ts','tests/fremen-betrayal-modules.test.ts','tests/fixture-fremen-betrayal-modules.ts','tests/nexus-fremen-cunning-modules-quotes.test.ts','tests/ecaz-betrayal-modules.test.ts','tests/fixture-ecaz-betrayal-modules.ts','tests/richese-no-field-hidden-return.test.ts','tests/nexus-ecaz-betrayal-quotes.test.ts','tests/moritani-atomics-modules.test.ts','tests/nexus-moritani-atomics-runtime.test.ts','tests/nexus-world-independent-cards.test.ts','tests/nexus-world-independent-battles.test.ts','tests/reinforcements-temporary-role.test.ts','tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', '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-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/native-guild-rate-karama.test.ts', 'tests/native-guild-rate-modules.test.ts', 'tests/allied-marker-guild-rate.test.ts', 'tests/purchased-homeworld-rate.test.ts', 'tests/native-guild-transport-permission.test.ts', 'tests/native-guild-homeworld-rate.test.ts', 'tests/native-guild-rate-betrayal.test.ts', 'tests/nexus-guild-cunning-engine.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/pLine truncated
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -6385,7 +6385,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'When reviving an own leader while you have no Skill Card, decide whether to draw before seeing two private cards. Once drawn, keep one and assign it to that revived leader. Under the 13 October 2026 ruling the original faction keeps its assignment while its trained leader is captured, so reviving a different own leader is legal but draws no replacement while that card exists.',
|
||||
'Planetologist chooses either one extra movement range (maximum three) or forces from two territories to one destination at normal range. These alternatives do not stack. Provisional mixed BG advisor/fighter gather into an enemy-only territory declares one common fighter arrival before either group moves. Native Karama cancellation keeps both sources and the movement allowance unchanged, and blocks another advisor flip for that movement opportunity; fresh-advisor locks remain. Empty or already BG-occupied destinations keep their original common stance. The actual skilled leader can use an eligible green Special in the weapon slot for +2 if it survives; discard that Special without its ordinary effect. Other combined modules remain unfinished.',
|
||||
'Suk Graduate returns one selected casualty to reserves while its leader stays face up, or lets its surviving battle leader save up to three: one stays in its original sector and the rest return to reserves. The effects do not stack. Rescued counters still count toward Advanced Atreides’ seven battle losses for Kwisatz Haderach; combined modules remain unfinished.',
|
||||
'In the supported classic Nexus/Skills composition, Emperor Cunning counts five actual ordinary counters as temporary Sardaukar. Normal or trained Suk rescue still saves physical ordinary casualties. The original casualty allocation remains pending while the rescue choice is open; physical counters move only when rescue settles. Original support is already paid and is not refunded or charged twice. A normal save returns one to reserves; a trained save keeps one and returns the rest. Actual starred reserves and Tanks remain unchanged.',
|
||||
'In the supported classic Nexus/Skills composition, Emperor Cunning counts five actual ordinary counters as temporary Sardaukar, or the full positive eligible short group under the provisional shortage policy. Normal or trained Suk rescue still saves physical ordinary casualties. The original casualty allocation remains pending while the rescue choice is open; physical counters move only when rescue settles. Original support is already paid and is not refunded or charged twice. A normal save returns one to reserves; a trained save keeps one and returns the rest. Actual starred reserves and Tanks remain unchanged.',
|
||||
'A surviving selected Mentat adds two battle strength. A surviving selected Bureaucrat subtracts one from the opponent’s total per distinct stronghold occupied by that opponent; advisors do not count. Printed disc values and bounties stay unchanged.',
|
||||
'A living native Sandmaster may collect one spice per entered territory along a declared legal ground route. With multiple positive piles, provisionally choose one debit sector per territory or decline; two piles never grant two spice from the same territory. Canceled moves earn nothing. The initial source and a sector-only reposition earn nothing. Separate movements have separate opportunities. Native worm rides and physical Fremen Ambassador relocation offer one selected destination pile before arrival reactions, without an invented route or normal movement use. Native Ixian HMS passengers choose one pile in each entered territory before faction collection, including supplied Advanced special Karama; its route preview reflects both transfers. These allocations are provisional. Other special relocation remains unfinished.',
|
||||
'A surviving skilled Sandmaster winner adds three board spice to an existing pile in the battle territory, even with no surviving forces. No pile adds nothing; a single pile resolves automatically. With several remaining positive piles, the winner selects one sector after any Smuggler allocation. Owner selection is a visibly provisional placement policy, not a publisher ruling.',
|
||||
|
||||
@@ -13,6 +13,7 @@ import { DUKE_VIDAL_ID } from './duke-vidal';
|
||||
import { resolveEcazDukeRevival } from './ecaz-duke-revival';
|
||||
import {
|
||||
emperorNexusPools,
|
||||
emperorNexusRevivalCount,
|
||||
validateEmperorNexusRevival,
|
||||
} from './nexus-emperor-secret-ally';
|
||||
import { validateNexusCards } from './nexus-cards';
|
||||
@@ -87,14 +88,15 @@ function validateNexusEmperorSource(
|
||||
requireResume(
|
||||
typeof event === 'string' && event.length > 0 &&
|
||||
Object.keys(pending).every((key) => nexusEmperorPendingKeys[key]) &&
|
||||
pending.kind === 'forces' && pending.amount === 3 &&
|
||||
pending.kind === 'forces' && Number.isSafeInteger(pending.amount) &&
|
||||
pending.amount! >= 1 && pending.amount! <= 3 &&
|
||||
Number.isSafeInteger(pending.elite) && pending.elite! >= 0 &&
|
||||
pending.elite! <= 3 && pending.free === 3 &&
|
||||
pending.elite! <= pending.amount! && pending.free === pending.amount &&
|
||||
pending.cost === 0 && pending.normalCost === 0 &&
|
||||
pending.payer === undefined && pending.emperorExtra === undefined &&
|
||||
pending.leader === undefined && Array.isArray(pending.checks) &&
|
||||
pending.checks.length === 0,
|
||||
'The Emperor Nexus source needs its original exact-three free request.',
|
||||
'The Emperor Nexus source needs its original physical free-force request.',
|
||||
);
|
||||
const owner = g.players.find((p) => p.id === pending.player);
|
||||
const history = g.nexusEmperorSecretHistory;
|
||||
@@ -117,6 +119,7 @@ function validateNexusEmperorSource(
|
||||
receipt.version === 2 && receipt.stage === 'requested' &&
|
||||
receipt.owner === owner.id && receipt.turn === g.turn &&
|
||||
receipt.phase === g.phase && receipt.elite === pending.elite &&
|
||||
emperorNexusRevivalCount(receipt) === pending.amount &&
|
||||
receipt.after === null &&
|
||||
receipt.before && Object.entries(currentPools).every(([key, value]) =>
|
||||
receipt.before[key as keyof typeof currentPools] === value) &&
|
||||
|
||||
@@ -68,6 +68,18 @@ export function nexusFixture(
|
||||
|
||||
export function nexusReady(state: Game): Game {
|
||||
let g = state;
|
||||
// Native ordered phases finish their actual current actor, rather than the
|
||||
// roster order used by legacy simultaneous readiness. Never skip a response.
|
||||
const phase = state.phase;
|
||||
const ordered = () => phase === 2 ? g.charityOrder : phase === 4 ? g.revivalOrder : undefined;
|
||||
if (ordered() && !g.phaseOpening && !g.response && !g.decision && !g.truthtrance) {
|
||||
for (let pass = 0; pass < state.players.length && g.phase === phase && ordered(); pass++) {
|
||||
const owner = ordered()!.remaining[0]; assert.ok(owner);
|
||||
g = applyAction(g, owner, {type:'ready'});
|
||||
if (g.phaseOpening || g.response || g.decision || g.truthtrance) break;
|
||||
}
|
||||
return g;
|
||||
}
|
||||
for (const p of state.players)
|
||||
if (!g.ready.includes(p.id)) g = applyAction(g, p.id, { type: 'ready' });
|
||||
return g;
|
||||
@@ -95,6 +107,9 @@ export function enterNexusSpice(state: Game, nextTurn = false): Game {
|
||||
stormDialers: g.players.slice(0, 2).map((p) => p.id),
|
||||
stormDials: {},
|
||||
});
|
||||
// This controlled next-turn jump skips the previous Charity phase; its
|
||||
// ordered frame cannot describe the staged new Storm boundary.
|
||||
delete g.charityOrder;
|
||||
}
|
||||
assert.equal(g.phase, 0);
|
||||
for (const id of g.stormDialers)
|
||||
|
||||
@@ -69,6 +69,9 @@ export function nexusSardaukarFixture(
|
||||
decision: null,
|
||||
phaseOpening: null,
|
||||
});
|
||||
// The parent fixture explicitly stages Battle rather than playing through
|
||||
// Charity; discard only that earlier phase's ordered fixture frame.
|
||||
delete g.charityOrder;
|
||||
g = applyAction(g, owner.id, {
|
||||
type: 'chooseBattle',
|
||||
territory: 'pasty_mesa',
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { NEXUS_FACTIONS } from '../game/nexus-cards';
|
||||
import { combatArmy } from '../game/combat-location';
|
||||
import {
|
||||
drawClassicNexusReaction, reactionAction, reactionPlayer,
|
||||
} from './fixture-classic-independent-nexus-reactions';
|
||||
import { advanceClassicIndependentNexus, type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus';
|
||||
import { nextClassicDiscoveryNexusStep, classicDiscoveryNexusClean } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type NexusShortCunningStep = ClassicIndependentNexusStep;
|
||||
export const actNexusShortCunning = reactionAction;
|
||||
export const nexusShortCunningPlayer = reactionPlayer;
|
||||
export const nexusShortCunningClean = (game: Game) => classicDiscoveryNexusClean(game) &&
|
||||
!game.pendingShipment && !game.pendingGuildBetrayal;
|
||||
export function nexusShortCunningInventory(game: Game) {
|
||||
const cards = [...game.deck,...game.discard,...game.players.flatMap(p => p.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index+Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
|
||||
.map(c => c.id).sort();
|
||||
assert.deepEqual(cards,baseDeck().map(c => c.id).sort());
|
||||
const native = game.nexusCards!.cards!;
|
||||
const nexus = [...native.deck,...native.discard,...Object.values(native.hands).filter(c => c !== null)].sort();
|
||||
assert.deepEqual(nexus,[...NEXUS_FACTIONS].sort());
|
||||
assert.equal(new Set(nexus).size,nexus.length);
|
||||
const traitors = [...(game.traitorReserve ?? []),...game.players.flatMap(p => [...p.traitors,...p.traitorChoices])].sort();
|
||||
const spice = [...game.spiceDeck,...game.spiceDiscard.flat()].map(c => JSON.stringify(c)).sort();
|
||||
for (const p of game.players)
|
||||
assert.equal(p.reserves+p.tanks+Object.values(p.forces).reduce((sum,n) => sum+n,0),20);
|
||||
return {cards,nexus,traitors,spice};
|
||||
}
|
||||
export function advanceNexusShortCunning(game: Game, until: (game: Game) => boolean,
|
||||
actions: NexusShortCunningStep[] = []): Game {
|
||||
return advanceClassicIndependentNexus(game,until,actions);
|
||||
}
|
||||
const planReady = (game: Game) => nexusShortCunningClean(game) && !!game.battle &&
|
||||
!game.battle.revealed && !game.battle.preparation && game.battle.preLeader?.closed !== false;
|
||||
|
||||
/** Original public Advanced A/G/E Nexus-only source. The real turn-two worm,
|
||||
* reciprocal A/G alliance and closing Nexus draw give unallied E its own card;
|
||||
* the only force producer is E's actual smaller normal shipment to Arrakeen.
|
||||
* Initial Treachery/Nexus/Spice positions and scalar lotteries are selected
|
||||
* while unused. No played hand, army, wallet, phase or receipt is installed. */
|
||||
export function createNexusShortCunningFixture(options: {amount?:2|4;karama?:boolean} = {}) {
|
||||
const amount = options.amount ?? 2, actor = 'emperor', opponent = 'atreides';
|
||||
const cards = baseDeck(), counter = cards.find(c => c.effect === 'karama')!;
|
||||
const shield = cards.find(c => c.kind === 'shield')!, projectile = cards.find(c => c.kind === 'projectile')!;
|
||||
const ordinary = cards.find(c => c.kind === 'worthless')!;
|
||||
const base = drawClassicNexusReaction('emperor',actor,{advanced:true,independent:false,
|
||||
roster:['atreides','guild','emperor'],startingCards:{
|
||||
atreides:[options.karama === false ? ordinary.id : counter.id],guild:[projectile.id],emperor:[shield.id],
|
||||
}});
|
||||
let game = base.game; const actions = [...base.actions];
|
||||
const original = nexusShortCunningInventory(game);
|
||||
assert.equal(game.nexusCards!.cards!.hands[actor],'emperor');
|
||||
assert.equal(reactionPlayer(game,actor).ally,null);
|
||||
assert.equal(reactionPlayer(game,opponent).ally,'guild');
|
||||
game = advanceNexusShortCunning(game,g => g.phase === 5 && g.active === actor &&
|
||||
nexusShortCunningClean(g),actions);
|
||||
const beforeShipment = structuredClone(game), shipmentAt = actions.length;
|
||||
game = actNexusShortCunning(game,actor,{type:'ship',territory:'arrakeen',sector:10,
|
||||
amount,elite:0,allyPayment:0},actions);
|
||||
game = advanceNexusShortCunning(game,nexusShortCunningClean,actions);
|
||||
const afterShipment = structuredClone(game), shipment = {
|
||||
before:beforeShipment,after:afterShipment,steps:structuredClone(actions.slice(shipmentAt)),
|
||||
cost:reactionPlayer(beforeShipment,actor).spice-reactionPlayer(afterShipment,actor).spice,
|
||||
};
|
||||
assert.equal(shipment.cost,amount);
|
||||
assert.deepEqual(combatArmy(game,actor,'arrakeen'),{normal:amount,elite:0});
|
||||
game = advanceNexusShortCunning(game,g => g.phase === 6 && !!g.active &&
|
||||
nexusShortCunningClean(g),actions);
|
||||
const beforeBattle = structuredClone(game), chooser = game.active!;
|
||||
assert.ok(viewGame(game,chooser).battleChoices.some(c => c.territory === 'arrakeen'));
|
||||
game = actNexusShortCunning(game,chooser,{type:'chooseBattle',territory:'arrakeen',
|
||||
target:chooser === actor ? opponent : actor},actions);
|
||||
const preparation = structuredClone(game);
|
||||
game = advanceNexusShortCunning(game,planReady,actions);
|
||||
const leader = reactionPlayer(game,actor).leaders.filter(l => !l.dead && !l.usedAt)
|
||||
.sort((a,b) => b.strength-a.strength)[0];
|
||||
const enemy = reactionPlayer(game,opponent).leaders.filter(l => !l.dead && !l.usedAt)
|
||||
.sort((a,b) => a.strength-b.strength)[0];
|
||||
assert.ok(leader && enemy);
|
||||
assert.deepEqual(game.battle!.plans,{});
|
||||
assert.deepEqual(combatArmy(game,actor,'arrakeen'),{normal:amount,elite:0});
|
||||
assert.ok(!game.homeworlds && !game.leaderSkills && !game.discoveryEnabled &&
|
||||
!game.techTokens && !game.strongholdCards && !game.ecazTreachery);
|
||||
assert.deepEqual(nexusShortCunningInventory(game),original);
|
||||
const event = viewGame(game,actor).nexusSardaukar!.offer!.event;
|
||||
return {game,source:structuredClone(game),actor,owner:actor,opponent,amount,event,
|
||||
card:'emperor' as const,karama:options.karama === false ? null : counter.id,
|
||||
action:{type:'nexusSardaukar',event} as Action,leader:leader.id,opponentLeader:enemy.id,
|
||||
plan:{type:'battlePlan',leader:leader.id,dial:2,support:1} as Action,
|
||||
canceledPlan:{type:'battlePlan',leader:leader.id,dial:1,support:1} as Action,
|
||||
opponentPlan:{type:'battlePlan',leader:enemy.id,dial:0,support:0} as Action,
|
||||
key:'arrakeen:10',territory:'arrakeen',base,shipment,beforeBattle,preparation,original,
|
||||
actions,sourceActions:actions,sourceCount:actions.length,
|
||||
disclosure:'Original public Advanced A/G/E with Nexus opt-in before Ready/start and base33 only. Actual first turn, turn-two worm, reciprocal A/G alliance and original closing draw earn the unique Emperor card. Only unused original Treachery/Nexus/Spice positions and scalar lotteries are selected. E really ships the requested short ordinary group to original A10 Arrakeen, at its actual city price. No played hand, wallet, force, phase, alliance, Nexus holding, battle, promise or receipt is installed.'};
|
||||
}
|
||||
export type NexusShortCunningFixture = ReturnType<typeof createNexusShortCunningFixture>;
|
||||
|
||||
export function resolveNexusShortCunning(f: NexusShortCunningFixture, canceled = false, source = f.game) {
|
||||
const actions: NexusShortCunningStep[] = [];
|
||||
let game = actNexusShortCunning(structuredClone(source),f.actor,f.action,actions);
|
||||
const declared = structuredClone(game);
|
||||
if (canceled) {
|
||||
assert.ok(f.karama);
|
||||
assert.equal(game.response?.kind,'nexusSardaukar');
|
||||
game = actNexusShortCunning(game,f.opponent,{type:'card',card:f.karama,mode:'cancel'},actions);
|
||||
} else game = advanceNexusShortCunning(game,nexusShortCunningClean,actions);
|
||||
assert.equal(game.nexusSardaukarLast?.stage,canceled ? 'canceled' : 'active');
|
||||
return {game,declared,actions};
|
||||
}
|
||||
|
||||
export function finishNexusShortCunning(f: NexusShortCunningFixture, source: Game,
|
||||
plan: Action = f.plan, actions: NexusShortCunningStep[] = []) {
|
||||
let game = actNexusShortCunning(source,f.actor,plan,actions);
|
||||
const afterPlan = structuredClone(game);
|
||||
game = actNexusShortCunning(game,f.opponent,f.opponentPlan,actions);
|
||||
const revealed = structuredClone(game);
|
||||
for (let n = 0; game.battle && n < 100; n++) {
|
||||
const choice = game.decision?.kind === 'battleLosses' && game.decision.player === f.actor
|
||||
? game.decision.options.findIndex(loss => loss.normal === 1 && loss.elite === 0) : -1;
|
||||
const next = choice >= 0 ? {actor:f.actor,action:{type:'decision',choice} as Action}
|
||||
: nextClassicDiscoveryNexusStep(game); assert.ok(next);
|
||||
game = actNexusShortCunning(game,next.actor,next.action,actions);
|
||||
}
|
||||
assert.equal(game.battle,null);
|
||||
return {game,afterPlan,revealed,actions};
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { NEXUS_FACTIONS } from '../game/nexus-cards';
|
||||
import { emperorNexusRevivalCount } from '../game/nexus-emperor-secret-ally';
|
||||
import {
|
||||
createClassicIndependentNexusFixture, advanceClassicIndependentNexus, type ClassicIndependentNexusStep,
|
||||
} from './fixture-classic-independent-nexus';
|
||||
import {
|
||||
applyClassicDiscoveryNexusStep, classicDiscoveryNexusClean, nextClassicDiscoveryNexusStep,
|
||||
} from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type NexusShortRevivalStep = ClassicIndependentNexusStep;
|
||||
export const actNexusShortRevival = (game: Game, actor: string, action: Action,
|
||||
actions: NexusShortRevivalStep[] = []) => applyClassicDiscoveryNexusStep(game,{actor,action},actions);
|
||||
export const nexusShortRevivalPlayer = (game: Game, actor = 'atreides') => {
|
||||
const p = game.players.find(p => p.id === actor); assert.ok(p); return p;
|
||||
};
|
||||
export const nexusShortRevivalClean = (game: Game) => classicDiscoveryNexusClean(game) &&
|
||||
!game.pendingRevival && !game.pendingShipment && !game.pendingGuildBetrayal;
|
||||
export function nexusShortRevivalInventory(game: Game) {
|
||||
const cards = [...game.deck,...game.discard,...game.players.flatMap(p => p.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index+Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
|
||||
.map(c => c.id).sort();
|
||||
assert.deepEqual(cards,baseDeck().map(c => c.id).sort());
|
||||
const state = game.nexusCards!.cards!;
|
||||
const nexus = [...state.deck,...state.discard,...Object.values(state.hands).filter(card => card !== null)].sort();
|
||||
assert.deepEqual(nexus,[...NEXUS_FACTIONS].sort());
|
||||
const traitors = [...(game.traitorReserve ?? []),...game.players.flatMap(p => [...p.traitors,...p.traitorChoices])].sort();
|
||||
const spice = [...game.spiceDeck,...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort();
|
||||
for (const p of game.players)
|
||||
assert.equal(p.reserves+p.tanks+Object.values(p.forces).reduce((sum,n) => sum+n,0),20);
|
||||
return {cards,nexus,traitors,spice};
|
||||
}
|
||||
|
||||
export function nextNexusShortRevival(game: Game, losses?:1|2): NexusShortRevivalStep|null {
|
||||
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
|
||||
if (game.decision?.kind === 'guildTiming')
|
||||
return {actor:game.decision.player,action:{type:'decision',take:false}};
|
||||
if (game.decision?.kind === 'battleLosses' && game.decision.player === 'atreides' && losses) {
|
||||
const choice = game.decision.options.findIndex(loss => loss.normal === losses && loss.elite === 0);
|
||||
assert.ok(choice >= 0,'Select the actual paid ordinary loss promised by this source.');
|
||||
return {actor:'atreides',action:{type:'decision',choice}};
|
||||
}
|
||||
if (game.battle?.outcomeWindow && !game.battle.outcomeWindow.closed) {
|
||||
const actor = [game.battle.attacker,game.battle.defender]
|
||||
.find(id => !game.battle!.outcomeWindow!.ready.includes(id)); assert.ok(actor);
|
||||
return {actor,action:{type:'battleOutcomeReady',event:game.battle.outcomeWindow.event}};
|
||||
}
|
||||
}
|
||||
return nextClassicDiscoveryNexusStep(game);
|
||||
}
|
||||
export function advanceNexusShortRevival(game: Game, until: (game: Game) => boolean,
|
||||
actions: NexusShortRevivalStep[] = [], losses?:1|2): Game {
|
||||
for (let n = 0; n < 1800; n++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status,'finished');
|
||||
if (game.pendingGuildBetrayal) {
|
||||
game = advanceClassicIndependentNexus(game,g => !g.pendingGuildBetrayal,actions);
|
||||
continue;
|
||||
}
|
||||
const step = nextNexusShortRevival(game,losses);
|
||||
assert.ok(step,`Choose the native original plan/control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actNexusShortRevival(game,step.actor,step.action,actions);
|
||||
}
|
||||
assert.fail('The genuine short-revival chronology did not reach its finite boundary.');
|
||||
}
|
||||
|
||||
/** Actual public A/G/H Nexus-only setup and battle-to-revival producer. Initial
|
||||
* unopened deals/Spice order/Nexus draw and scalar entropy are the only choices
|
||||
* outside actions. Dead counters, card holding, alliance, phase and allowances
|
||||
* are all native results; no played resource or receipt is installed. */
|
||||
export function createNexusShortRevivalFixture(options: {amount?:1|2} = {}) {
|
||||
const amount = options.amount ?? 1, actor = 'atreides', guild = 'guild', harkonnen = 'harkonnen';
|
||||
const cards = baseDeck(), shield = cards.find(c => c.kind === 'shield')!, projectile = cards.find(c => c.kind === 'projectile')!;
|
||||
const worthless = cards.find(c => c.kind === 'worthless')!, snooper = cards.find(c => c.kind === 'snooper')!;
|
||||
const base = createClassicIndependentNexusFixture({advanced:true,independent:false,
|
||||
roster:['atreides','guild','harkonnen'],startingCards:{atreides:[shield.id],guild:[projectile.id],
|
||||
harkonnen:[worthless.id,snooper.id]}});
|
||||
let game = base.game; const actions = [...base.actions];
|
||||
const original = nexusShortRevivalInventory(game), afterSetup = structuredClone(game);
|
||||
const act = (owner: string, action: Action) => {game = actNexusShortRevival(game,owner,action,actions);};
|
||||
const reach = (until: (game: Game) => boolean) => {game = advanceNexusShortRevival(game,until,actions,amount);};
|
||||
const spiceOrder: {position:number;from:number;card:Game['spiceDeck'][number]}[] = [];
|
||||
for (const [position,worm] of [false,false,true,false,false].entries()) {
|
||||
const from = game.spiceDeck.findIndex((card,index) => index >= position &&
|
||||
(worm ? 'worm' in card : 'territory' in card)); assert.ok(from >= position);
|
||||
const [card] = game.spiceDeck.splice(from,1); game.spiceDeck.splice(position,0,card);
|
||||
spiceOrder.push({position,from,card:structuredClone(card)});
|
||||
}
|
||||
reach(g => g.turn === 1 && g.phase === 5 && g.active === guild && nexusShortRevivalClean(g));
|
||||
const beforeShipment = structuredClone(game), shipmentAt = actions.length;
|
||||
act(guild,{type:'ship',territory:'arrakeen',sector:10,amount:1,elite:0,allyPayment:0});
|
||||
reach(nexusShortRevivalClean);
|
||||
const shipment = {before:beforeShipment,after:structuredClone(game),steps:structuredClone(actions.slice(shipmentAt))};
|
||||
assert.equal(nexusShortRevivalPlayer(game,guild).forces['arrakeen:10'],1);
|
||||
reach(g => g.turn === 1 && g.phase === 6 && !!g.active && nexusShortRevivalClean(g));
|
||||
const beforeBattle = structuredClone(game), battleAt = actions.length, chooser = game.active!;
|
||||
assert.ok(viewGame(game,chooser).battleChoices.some(choice => choice.territory === 'arrakeen'));
|
||||
act(chooser,{type:'chooseBattle',territory:'arrakeen',target:chooser === actor ? guild : actor});
|
||||
reach(g => nexusShortRevivalClean(g) && !!g.battle && !g.battle.revealed &&
|
||||
!g.battle.preparation && g.battle.preLeader?.closed !== false);
|
||||
const own = nexusShortRevivalPlayer(game,actor).leaders.filter(l => !l.dead && !l.usedAt)
|
||||
.sort((a,b) => b.strength-a.strength)[0];
|
||||
const other = nexusShortRevivalPlayer(game,guild).leaders.filter(l => !l.dead && !l.usedAt)
|
||||
.sort((a,b) => a.strength-b.strength)[0]; assert.ok(own && other);
|
||||
const plans: Record<string, {leader:string;dial:number;support:number}> = {
|
||||
[actor]:{leader:own.id,dial:amount,support:amount},[guild]:{leader:other.id,dial:0,support:0},
|
||||
};
|
||||
for (const owner of [game.battle!.attacker,game.battle!.defender]) act(owner,{type:'battlePlan',...plans[owner]});
|
||||
reach(g => !g.battle && nexusShortRevivalClean(g));
|
||||
const battleResolution = structuredClone(game), battleActions = structuredClone(actions.slice(battleAt));
|
||||
assert.equal(game.lastBattleContext!.winner,actor);
|
||||
assert.equal(nexusShortRevivalPlayer(game,actor).tanks,amount);
|
||||
assert.equal(nexusShortRevivalPlayer(game,actor).forces['arrakeen:10'],10-amount);
|
||||
reach(g => g.turn === 2 && g.nexus && !g.spiceWindow && !g.spiceResolution && nexusShortRevivalClean(g));
|
||||
const nexus = structuredClone(game), allianceAt = actions.length;
|
||||
act(guild,{type:'alliance',target:harkonnen}); act(harkonnen,{type:'alliance',target:guild});
|
||||
const alliance = {steps:structuredClone(actions.slice(allianceAt)),after:structuredClone(game)};
|
||||
reach(g => g.nexusCards?.phase?.stage === 'drawing');
|
||||
const state = game.nexusCards!.cards!, from = state.deck.indexOf('emperor'); assert.ok(from >= 0);
|
||||
state.deck.unshift(state.deck.splice(from,1)[0]);
|
||||
const beforeDraw = structuredClone(game), drawAt = actions.length;
|
||||
act(actor,{type:'nexusCardChoice',turn:game.turn,card:state.hands[actor],choice:'draw',ownRedraws:0});
|
||||
const afterDraw = structuredClone(game), drawActions = structuredClone(actions.slice(drawAt));
|
||||
assert.equal(game.nexusCards!.cards!.hands[actor],'emperor');
|
||||
reach(g => g.turn === 2 && g.phase === 4 && nexusShortRevivalClean(g) &&
|
||||
(!g.revivalOrder || g.revivalOrder.remaining[0] === actor));
|
||||
assert.equal(nexusShortRevivalPlayer(game,actor).tanks,amount);
|
||||
assert.equal(nexusShortRevivalPlayer(game,actor).revived,0);
|
||||
const offer = viewGame(game,actor).nexusEmperorSecretAlly; assert.ok(offer);
|
||||
assert.equal(offer.revival.amount,amount); assert.equal(offer.revival.blocked,null);
|
||||
assert.deepEqual(nexusShortRevivalInventory(game),original);
|
||||
return {game,source:structuredClone(game),actor,amount,event:offer.event,
|
||||
action:{type:'nexusEmperorRevive',event:offer.event,elite:0} as Action,
|
||||
base,afterSetup,shipment,beforeBattle,plans,battleResolution,battleActions,nexus,alliance,
|
||||
beforeDraw,afterDraw,drawActions,nexusOrder:{from,card:'emperor' as const},
|
||||
beforeRevival:structuredClone(game),spiceOrder,original,actions,sourceActions:actions,sourceCount:actions.length,
|
||||
disclosure:'Original public Advanced A/G/H Nexus-only base33 setup, selected unopened four-card deal, five original Spice positions and scalar lotteries. Guild really ships1 to original A10 Arrakeen; real A leader5/dial1or2/paid support1or2 wins and creates exactly the requested one/two ordinary Tanks. Actual next-turn worm and G–H reciprocal alliance leave A unallied; original unused Emperor Nexus position is selected before A’s real closing draw. Native phase controls and current Revival queue stop before A ordinary return. No played hand, purse, army, Tank, phase, alliance, Nexus holding, quota or receipt is installed.'};
|
||||
}
|
||||
export type NexusShortRevivalFixture = ReturnType<typeof createNexusShortRevivalFixture>;
|
||||
|
||||
export function applyNexusShortRevival(f: NexusShortRevivalFixture, source = f.game) {
|
||||
const actions: NexusShortRevivalStep[] = [], before = structuredClone(source);
|
||||
let game = actNexusShortRevival(structuredClone(source),f.actor,f.action,actions);
|
||||
const declared = structuredClone(game);
|
||||
game = normalizeAutomaticGame(game);
|
||||
assert.equal(game.nexusEmperorSecretHistory!.at(-1)!.stage,'returned');
|
||||
assert.equal(emperorNexusRevivalCount(game.nexusEmperorSecretHistory!.at(-1)!),f.amount);
|
||||
assert.equal(nexusShortRevivalPlayer(game,f.actor).tanks,0);
|
||||
assert.deepEqual(nexusShortRevivalInventory(game),f.original);
|
||||
return {game,before,declared,actions};
|
||||
}
|
||||
@@ -42,6 +42,19 @@ function allow(game: Game, native: string): Game {
|
||||
assert.equal(game.decision!.player, native);
|
||||
return settleNexusEmperorExtra(applyAction(game, native, { type: 'decision', decline: true }));
|
||||
}
|
||||
function ownRevival(game: Game, owner: string): Game {
|
||||
for (let n = 0; game.revivalOrder && game.revivalOrder.remaining[0] !== owner && n < game.players.length; n++) {
|
||||
assert.equal(game.phase,4);
|
||||
assert.ok(game.revivalOrder.remaining.includes(owner),'The original owner must still have an unfinished Revival turn.');
|
||||
game = settleNexusEmperorExtra(applyAction(game,game.revivalOrder.remaining[0],{type:'ready'}));
|
||||
}
|
||||
assert.ok(!game.revivalOrder || game.revivalOrder.remaining[0] === owner);
|
||||
return game;
|
||||
}
|
||||
function ordinaryRevive(game: Game, owner: string, action: Action): Game {
|
||||
assert.equal(action.type,'revive');
|
||||
return applyAction(ownRevival(game,owner),owner,action);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
const band = advanced ? 'Advanced' : 'Basic';
|
||||
@@ -73,7 +86,7 @@ for (const advanced of [false, true]) {
|
||||
const extra = settleNexusEmperorExtra(applyAction(f.game, f.owner, grant(f.game, f.owner)));
|
||||
assert.deepEqual(forceRevivalQuote(extra, player(extra, f.owner), 3), quote);
|
||||
assert.equal(forceRevivalRemaining(extra, player(extra, f.owner)), 3);
|
||||
const ordinary = settleNexusEmperorExtra(applyAction(extra, f.owner, { type: 'revive', amount: 3 }));
|
||||
const ordinary = settleNexusEmperorExtra(ordinaryRevive(extra, f.owner, { type: 'revive', amount: 3 }));
|
||||
const holder = player(ordinary, f.owner);
|
||||
assert.equal(holder.reserves, player(f.game, f.owner).reserves + 6);
|
||||
assert.equal(holder.tanks, player(f.game, f.owner).tanks - 6);
|
||||
@@ -88,7 +101,7 @@ for (const advanced of [false, true]) {
|
||||
assert.equal(player(next, f.owner).freeForcesRevived, 0);
|
||||
receipt(next, 'returned');
|
||||
const nextQuote = forceRevivalQuote(next, player(next, f.owner), 2);
|
||||
const later = settleNexusEmperorExtra(applyAction(next, f.owner, { type: 'revive', amount: 2 }));
|
||||
const later = settleNexusEmperorExtra(ordinaryRevive(next, f.owner, { type: 'revive', amount: 2 }));
|
||||
assert.equal(player(later, f.owner).tanks, 0);
|
||||
assert.equal(player(later, f.owner).spice, player(next, f.owner).spice - nextQuote.cost);
|
||||
spent(later, f.owner);
|
||||
@@ -96,7 +109,7 @@ for (const advanced of [false, true]) {
|
||||
|
||||
void test(`${band} three prior ordinary counters do not reduce the independent printed three grant`, () => {
|
||||
const f = createNexusEmperorExtraFixture({ advanced });
|
||||
const ordinary = settleNexusEmperorExtra(applyAction(f.game, f.owner, { type: 'revive', amount: 3 }));
|
||||
const ordinary = settleNexusEmperorExtra(ordinaryRevive(f.game, f.owner, { type: 'revive', amount: 3 }));
|
||||
assert.equal(forceRevivalRemaining(ordinary, player(ordinary, f.owner)), 0);
|
||||
const before = emperorNexusPools(player(ordinary, f.owner));
|
||||
const extra = settleNexusEmperorExtra(applyAction(ordinary, f.owner, grant(ordinary, f.owner)));
|
||||
@@ -110,7 +123,7 @@ for (const advanced of [false, true]) {
|
||||
|
||||
void test(`${band} genuine single-E1 Ix counters retain native typed custody and available-count caps`, () => {
|
||||
const f = createNexusEmperorExtraFixture({ advanced, profile: 'ixians' });
|
||||
const ordinary = settleNexusEmperorExtra(applyAction(f.game, f.owner, { type: 'revive', amount: 1, elite: 1 }));
|
||||
const ordinary = settleNexusEmperorExtra(ordinaryRevive(f.game, f.owner, { type: 'revive', amount: 1, elite: 1 }));
|
||||
const before = player(ordinary, f.owner), beforePools = emperorNexusPools(before);
|
||||
assert.equal(before.elites!.tanks, 2);
|
||||
rejected(ordinary, f.owner, grant(ordinary, f.owner, 3));
|
||||
@@ -122,7 +135,7 @@ for (const advanced of [false, true]) {
|
||||
assert.equal(player(extra, f.owner).revived, 1);
|
||||
assert.equal(player(extra, f.owner).elites!.revived, 3, 'Ix follows its native count cap, not the Advanced Fedaykin cap');
|
||||
assert.equal(forceRevivalRemaining(extra, player(extra, f.owner)), 2);
|
||||
const continued = settleNexusEmperorExtra(applyAction(extra, f.owner, { type: 'revive', amount: 1, elite: 0 }));
|
||||
const continued = settleNexusEmperorExtra(ordinaryRevive(extra, f.owner, { type: 'revive', amount: 1, elite: 0 }));
|
||||
assert.equal(player(continued, f.owner).revived, 2);
|
||||
assert.equal(player(continued, f.owner).tanks, 3);
|
||||
receipt(continued, 'returned');
|
||||
@@ -151,7 +164,7 @@ void test('native Tleilaxu Allow waits for original physical return, awards free
|
||||
assert.equal(returned.techTokens!.axlotl.triggeredTurn, returned.turn);
|
||||
assert.equal(returned.techTokens!.axlotl.spice, ownedTech(returned.techTokens!, returned.techTokens!.axlotl.owner!).length);
|
||||
const tokens = structuredClone(returned.techTokens);
|
||||
const ordinary = allow(applyAction(returned, f.owner, { type: 'revive', amount: 2 }), native);
|
||||
const ordinary = allow(ordinaryRevive(returned, f.owner, { type: 'revive', amount: 2 }), native);
|
||||
assert.equal(player(ordinary, native).spice, nativeSpice + 1, 'Later ordinary free returns cannot award a second bank income');
|
||||
assert.deepEqual(ordinary.techTokens, tokens, 'The same physical Axlotl token does not accrue twice this turn');
|
||||
assert.equal(player(ordinary, f.owner).revived, 2);
|
||||
@@ -163,7 +176,7 @@ void test('native Tleilaxu Allow waits for original physical return, awards free
|
||||
|
||||
void test('native Tleilaxu prior free return earns once; the later extra source does not earn or consume ordinary quota again', () => {
|
||||
const f = createNexusEmperorExtraFixture({ advanced: true, profile: 'tleilaxu' });
|
||||
const ordinary = allow(applyAction(f.game, f.owner, { type: 'revive', amount: 2 }), f.partner);
|
||||
const ordinary = allow(ordinaryRevive(f.game, f.owner, { type: 'revive', amount: 2 }), f.partner);
|
||||
const nativeSpice = player(ordinary, f.partner).spice, before = emperorNexusPools(player(ordinary, f.owner));
|
||||
const declared = applyAction(ordinary, f.owner, grant(ordinary, f.owner));
|
||||
receipt(declared, 'requested');
|
||||
@@ -173,7 +186,7 @@ void test('native Tleilaxu prior free return earns once; the later extra source
|
||||
assert.equal(forceRevivalRemaining(extra, player(extra, f.owner)), 1);
|
||||
const price = forceRevivalQuote(extra, player(extra, f.owner), 1);
|
||||
assert.equal(price.free, 0);
|
||||
const paid = allow(applyAction(extra, f.owner, { type: 'revive', amount: 1 }), f.partner);
|
||||
const paid = allow(ordinaryRevive(extra, f.owner, { type: 'revive', amount: 1 }), f.partner);
|
||||
assert.equal(player(paid, f.owner).spice, player(extra, f.owner).spice - price.cost);
|
||||
assert.equal(player(paid, f.partner).spice, nativeSpice + price.cost);
|
||||
assert.equal(player(paid, f.owner).revived, 3);
|
||||
@@ -224,7 +237,7 @@ void test('Basic native Tleilaxu returns the extra source without an Advanced st
|
||||
assert.equal(player(extra, f.owner).revived, 0);
|
||||
assert.equal(player(extra, f.owner).freeForcesRevived, 0);
|
||||
assert.equal(player(extra, f.partner).spice, nativeSpice + 1);
|
||||
const ordinary = settleNexusEmperorExtra(applyAction(extra, f.owner, { type: 'revive', amount: 2 }));
|
||||
const ordinary = settleNexusEmperorExtra(ordinaryRevive(extra, f.owner, { type: 'revive', amount: 2 }));
|
||||
assert.equal(player(ordinary, f.partner).spice, nativeSpice + 1);
|
||||
assert.equal(player(ordinary, f.owner).revived, 2);
|
||||
assert.equal(player(ordinary, f.owner).freeForcesRevived, 2);
|
||||
|
||||
@@ -110,12 +110,14 @@ void test('native typed Tanks determine exact-three choices and Ix Cyborgs are n
|
||||
assert.deepEqual(quoteEmperorNexusRevival(fremen, 'owner').eliteOptions, [0]);
|
||||
assert.throws(() => createEmperorNexusRevival(fremen, 'owner', 1));
|
||||
fremen.players[0].tanks = 3;
|
||||
assert.deepEqual(quoteEmperorNexusRevival(fremen, 'owner').eliteOptions, []);
|
||||
assert.notEqual(quoteEmperorNexusRevival(fremen, 'owner').blocked, null);
|
||||
assert.equal(quoteEmperorNexusRevival(fremen, 'owner').amount, 1);
|
||||
assert.deepEqual(quoteEmperorNexusRevival(fremen, 'owner').eliteOptions, [0]);
|
||||
assert.equal(createEmperorNexusRevival(fremen, 'owner', 0).amount, 1);
|
||||
const short = sample();
|
||||
short.players[0].tanks = 2;
|
||||
assert.deepEqual(quoteEmperorNexusRevival(short, 'owner').eliteOptions, []);
|
||||
assert.throws(() => createEmperorNexusRevival(short, 'owner', 0));
|
||||
assert.equal(quoteEmperorNexusRevival(short, 'owner').amount, 2);
|
||||
assert.deepEqual(quoteEmperorNexusRevival(short, 'owner').eliteOptions, [0]);
|
||||
assert.equal(createEmperorNexusRevival(short, 'owner', 0).amount, 2);
|
||||
});
|
||||
|
||||
void test('original native modules allow revival independently of classic purchase admission', () => {
|
||||
|
||||
@@ -65,7 +65,7 @@ void test('Fremen bot choices preserve real elite limits and unavailable grants
|
||||
assert.equal(nexusEmperorRevivalAction(viewGame(g, owner), 2), null);
|
||||
assert.equal(nexusEmperorRevivalAction(viewGame(g, observer), 0), null);
|
||||
}
|
||||
const { g, owner } = nexusEmperorSecretAllyFixture({ tanks: 2 });
|
||||
const { g, owner } = nexusEmperorSecretAllyFixture({ tanks: 0 });
|
||||
assert.ok(
|
||||
!botActions(viewGame(g, owner)).some(
|
||||
(action) => action.type === 'nexusEmperorRevive',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '../game/engine';
|
||||
import {
|
||||
emperorNexusPools,
|
||||
emperorNexusRevivalCount,
|
||||
emperorNexusSignature,
|
||||
} from '../game/nexus-emperor-secret-ally';
|
||||
import { forceRevivalRemaining, freeRevivalRemaining } from '../game/revival';
|
||||
@@ -17,6 +18,16 @@ import {
|
||||
} from './nexus-emperor-secret-ally-fixture';
|
||||
import { nexusReject, nexusReload, nexusReady } from './fixture-nexus-cards';
|
||||
|
||||
function ownRevival(game: Game, owner: string): Game {
|
||||
for (let n = 0; game.revivalOrder && game.revivalOrder.remaining[0] !== owner && n < game.players.length; n++) {
|
||||
assert.equal(game.phase,4);
|
||||
assert.ok(game.revivalOrder.remaining.includes(owner),'Finish only native turns preceding the still-unfinished owner.');
|
||||
game = normalizeAutomaticGame(applyAction(game,game.revivalOrder.remaining[0],{type:'ready'}));
|
||||
}
|
||||
assert.ok(!game.revivalOrder || game.revivalOrder.remaining[0] === owner);
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('a later real Ghola return and phase advance preserve historical Nexus proof without replaying its physical grant', () => {
|
||||
const f = nexusEmperorSecretAllyFixture();
|
||||
const card = f.g.deck.find((c) => c.effect === 'ghola')!;
|
||||
@@ -77,7 +88,7 @@ for (const advanced of [false, true])
|
||||
assert.equal(g.phase, 4);
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(g.decision, null);
|
||||
const ordinary = applyAction(g, f.owner, {
|
||||
const ordinary = applyAction(ownRevival(g,f.owner), f.owner, {
|
||||
type: 'revive',
|
||||
amount: 3,
|
||||
elite: 0,
|
||||
@@ -93,7 +104,7 @@ for (const advanced of [false, true])
|
||||
|
||||
void test('Emperor additional return still works after the ordinary three-force and free allowances are exhausted', () => {
|
||||
const f = nexusEmperorSecretAllyFixture();
|
||||
const paid = applyAction(f.g, f.owner, {
|
||||
const paid = applyAction(ownRevival(f.g,f.owner), f.owner, {
|
||||
type: 'revive',
|
||||
amount: 3,
|
||||
elite: 0,
|
||||
@@ -123,6 +134,7 @@ void test('Advanced Fedaykin use typed Tanks and share the actual one-per-turn c
|
||||
let g: Game;
|
||||
if (nexusFirst) {
|
||||
g = applyAction(f.g, f.owner, f.action);
|
||||
g = ownRevival(g,f.owner);
|
||||
nexusReject(
|
||||
g,
|
||||
f.owner,
|
||||
@@ -131,7 +143,7 @@ void test('Advanced Fedaykin use typed Tanks and share the actual one-per-turn c
|
||||
);
|
||||
g = applyAction(g, f.owner, { type: 'revive', amount: 3, elite: 0 });
|
||||
} else {
|
||||
g = applyAction(f.g, f.owner, { type: 'revive', amount: 3, elite: 1 });
|
||||
g = applyAction(ownRevival(f.g,f.owner), f.owner, { type: 'revive', amount: 3, elite: 1 });
|
||||
assert.deepEqual(
|
||||
viewGame(g, f.owner).nexusEmperorSecretAlly!.revival.eliteOptions,
|
||||
[0],
|
||||
@@ -145,23 +157,44 @@ void test('Advanced Fedaykin use typed Tanks and share the actual one-per-turn c
|
||||
}
|
||||
});
|
||||
|
||||
void test('short total or typed groups are explicitly unavailable without spending a card or fabricating partial forces', () => {
|
||||
for (const options of [
|
||||
{ tanks: 2 },
|
||||
{ advanced: true, owner: 'p' as const, tanks: 3, eliteTanks: 2 },
|
||||
void test('short eligible totals return every available counter while zero and exhausted typed caps remain blocked', () => {
|
||||
for (const entry of [
|
||||
{options:{tanks:1},amount:1,elite:0,choices:[0]},
|
||||
{options:{tanks:2},amount:2,elite:0,choices:[0]},
|
||||
{options:{advanced:true,owner:'p' as const,tanks:3,eliteTanks:2},amount:2,elite:1,choices:[1]},
|
||||
]) {
|
||||
const f = nexusEmperorSecretAllyFixture(options);
|
||||
assert.deepEqual(
|
||||
viewGame(f.g, f.owner).nexusEmperorSecretAlly!.revival.eliteOptions,
|
||||
[],
|
||||
);
|
||||
assert.match(
|
||||
viewGame(f.g, f.owner).nexusEmperorSecretAlly!.revival.blocked!,
|
||||
/exactly three/,
|
||||
);
|
||||
nexusReject(f.g, f.owner, f.action, /exactly three/);
|
||||
assert.equal(f.g.nexusCards!.cards!.hands[f.owner], 'emperor');
|
||||
const f = nexusEmperorSecretAllyFixture(entry.options), before = emperorNexusPools(f.g.players.find(p => p.id === f.owner)!);
|
||||
const physical = emperorNexusPhysical(f.g), quote = viewGame(f.g,f.owner).nexusEmperorSecretAlly!.revival;
|
||||
assert.equal(quote.amount,entry.amount);
|
||||
assert.equal(quote.blocked,null);
|
||||
assert.deepEqual(quote.eliteOptions,entry.choices);
|
||||
const done = applyAction(f.g,f.owner,{...f.action,elite:entry.elite});
|
||||
assert.deepEqual(emperorNexusPools(done.players.find(p => p.id === f.owner)!),{
|
||||
...before,reserves:before.reserves+entry.amount,tanks:before.tanks-entry.amount,
|
||||
eliteReserves:before.eliteReserves+entry.elite,eliteTanks:before.eliteTanks-entry.elite,
|
||||
eliteRevived:before.eliteRevived+entry.elite,
|
||||
});
|
||||
assert.equal(emperorNexusRevivalCount(done.nexusEmperorSecretHistory!.at(-1)!),entry.amount);
|
||||
assert.equal(done.nexusCards!.cards!.hands[f.owner],null);
|
||||
assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'emperor').length,1);
|
||||
assert.deepEqual(emperorNexusPhysical(done),physical);
|
||||
}
|
||||
const empty = nexusEmperorSecretAllyFixture({tanks:0});
|
||||
assert.equal(viewGame(empty.g,empty.owner).nexusEmperorSecretAlly!.revival.amount,0);
|
||||
assert.deepEqual(viewGame(empty.g,empty.owner).nexusEmperorSecretAlly!.revival.eliteOptions,[]);
|
||||
nexusReject(empty.g,empty.owner,empty.action,/eligible/i);
|
||||
assert.equal(empty.g.nexusCards!.cards!.hands[empty.owner],'emperor');
|
||||
|
||||
const typed = nexusEmperorSecretAllyFixture({advanced:true,owner:'p',tanks:2,eliteTanks:2});
|
||||
const used = applyAction(ownRevival(typed.g,typed.owner),typed.owner,{type:'revive',amount:1,elite:1});
|
||||
const remaining = used.players.find(p => p.id === typed.owner)!;
|
||||
assert.equal(remaining.elites!.tanks,1);
|
||||
assert.equal(remaining.elites!.revived,1);
|
||||
const exhausted = viewGame(used,typed.owner).nexusEmperorSecretAlly!.revival;
|
||||
assert.equal(exhausted.amount,0);
|
||||
assert.deepEqual(exhausted.eliteOptions,[]);
|
||||
nexusReject(used,typed.owner,typed.action,/eligible/i);
|
||||
assert.equal(used.nexusCards!.cards!.hands[typed.owner],'emperor');
|
||||
});
|
||||
|
||||
void test('only the private absent-Emperor holder gets revival choices; nonholders retain ordinary phase progress', () => {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Game, type GameView } from '../game/engine';
|
||||
import { createNexusShortRevivalFixture } from './fixture-nexus-short-revival';
|
||||
import { FACTIONS, type FactionId } from '../game/catalog';
|
||||
import { createNexusCards, discardNexusCard } from '../game/nexus-cards';
|
||||
import {
|
||||
createEmperorNexusRevival, emperorNexusPools, emperorNexusRevivalAmount,
|
||||
emperorNexusRevivalCount, emperorNexusSignature, quoteEmperorNexusRevival,
|
||||
settleEmperorNexusRevival, validateEmperorNexusRevival,
|
||||
type NexusEmperorRevival, type NexusEmperorRevivalRequest,
|
||||
} from '../game/nexus-emperor-secret-ally';
|
||||
import { nexusEmperorRevivalAction } from '../game/nexus-emperor-secret-ally-options';
|
||||
import { NexusEmperorSecretAlly } from '../components/nexus-emperor-secret-ally';
|
||||
|
||||
/** Controlled pure-rule pools and projected controls, not native gameplay
|
||||
* chronology. Physical settlement below models the native producer before
|
||||
* income or Homeworld deployment; root's native source covers that path. */
|
||||
function sample(tanks = 2, faction: FactionId = 'atreides', advanced = true,
|
||||
eliteTanks = 0, eliteRevived = 0): Game {
|
||||
const g = createGame('SHORTEMPEROR', newPlayer('owner', 'Holder', faction), advanced);
|
||||
g.players.push(newPlayer('rival', 'Rival', faction === 'atreides' ? 'harkonnen' : 'atreides'));
|
||||
g.players.push(newPlayer('third', 'Third', 'guild'));
|
||||
g.expansions = [...new Set(g.players.map(p => FACTIONS.find(f => f.id === p.faction)!.expansion)
|
||||
.filter(expansion => expansion !== 'base'))];
|
||||
g.status = 'playing'; g.phase = 4; g.turn = 2;
|
||||
const p = g.players[0];
|
||||
p.reserves -= tanks; p.tanks = tanks;
|
||||
p.revived = 4; p.freeForcesRevived = 2;
|
||||
if (faction === 'fremen' || faction === 'ixians')
|
||||
p.elites = { reserves: (faction === 'ixians' ? 7 : 3) - eliteTanks,
|
||||
tanks: eliteTanks, forces: {}, revived: eliteRevived };
|
||||
const cards = createNexusCards(g.players, () => 0.5);
|
||||
cards.deck = cards.deck.filter(card => card !== 'emperor'); cards.hands.owner = 'emperor';
|
||||
g.nexusCards = { cards, phase: null };
|
||||
return g;
|
||||
}
|
||||
function commit(g: Game, receipt: NexusEmperorRevivalRequest) {
|
||||
g.nexusCards!.cards = discardNexusCard(g.nexusCards!.cards!, receipt.owner, g.players);
|
||||
g.nexusEmperorSecretHistory = [receipt]; g.nexusEmperorSecretEvents = [receipt.event];
|
||||
}
|
||||
function physicalReturn(g: Game, receipt: NexusEmperorRevivalRequest) {
|
||||
const p = g.players[0], amount = emperorNexusRevivalCount(receipt);
|
||||
p.reserves += amount; p.tanks -= amount;
|
||||
if (p.elites) {
|
||||
p.elites.reserves += receipt.elite; p.elites.tanks -= receipt.elite;
|
||||
p.elites.revived += receipt.elite;
|
||||
}
|
||||
}
|
||||
const html = (game: GameView, busy = false) =>
|
||||
renderToStaticMarkup(createElement(NexusEmperorSecretAlly, { game, busy, act() {} }));
|
||||
|
||||
void test('the current maximal return stays three when eligible and shortens only for actual Tanks or native typed limits', () => {
|
||||
for (const amount of [1, 2, 3, 5]) {
|
||||
const g = sample(amount), offered = quoteEmperorNexusRevival(g, 'owner');
|
||||
assert.equal(offered.amount, Math.min(3, amount));
|
||||
assert.deepEqual(offered.eliteOptions, [0]); assert.equal(offered.blocked, null);
|
||||
}
|
||||
const capped = sample(3, 'fremen', true, 2);
|
||||
assert.equal(emperorNexusRevivalAmount(capped.players[0], true), 2);
|
||||
assert.deepEqual(quoteEmperorNexusRevival(capped, 'owner').eliteOptions, [1]);
|
||||
capped.players[0].elites!.revived = 1;
|
||||
assert.equal(quoteEmperorNexusRevival(capped, 'owner').amount, 1);
|
||||
assert.deepEqual(quoteEmperorNexusRevival(capped, 'owner').eliteOptions, [0]);
|
||||
const exhausted = sample(2, 'fremen', true, 2, 1);
|
||||
assert.equal(quoteEmperorNexusRevival(exhausted, 'owner').amount, 0);
|
||||
assert.deepEqual(quoteEmperorNexusRevival(exhausted, 'owner').eliteOptions, []);
|
||||
assert.throws(() => createEmperorNexusRevival(exhausted, 'owner', 0), /at least one eligible force/);
|
||||
const basic = sample(3, 'fremen', false, 2, 1);
|
||||
assert.equal(quoteEmperorNexusRevival(basic, 'owner').amount, 3);
|
||||
assert.deepEqual(quoteEmperorNexusRevival(basic, 'owner').eliteOptions, [2]);
|
||||
const ix = sample(3, 'ixians', true, 3, 4);
|
||||
assert.equal(quoteEmperorNexusRevival(ix, 'owner').amount, 3);
|
||||
assert.deepEqual(quoteEmperorNexusRevival(ix, 'owner').eliteOptions, [3]);
|
||||
});
|
||||
|
||||
void test('short requested receipts settle only the actual typed group and preserve ordinary ledgers and purse', () => {
|
||||
for (const amount of [1, 2] as const) for (const outcome of ['returned', 'denied'] as const) {
|
||||
const g = sample(amount), before = emperorNexusPools(g.players[0]);
|
||||
const receipt = createEmperorNexusRevival(g, 'owner', 0);
|
||||
assert.equal(receipt.amount, amount); assert.equal(receipt.stage, 'requested');
|
||||
assert.equal(emperorNexusRevivalCount(receipt), amount);
|
||||
assert.deepEqual(emperorNexusPools(g.players[0]), before, 'declaration is a pure source quote');
|
||||
commit(g, receipt);
|
||||
if (outcome === 'returned') physicalReturn(g, receipt);
|
||||
const result = settleEmperorNexusRevival(g, receipt, outcome);
|
||||
assert.equal(result.stage, outcome); assert.equal(result.amount, amount);
|
||||
assert.deepEqual(result.after, outcome === 'denied' ? before : {
|
||||
...before, reserves: before.reserves + amount, tanks: before.tanks - amount,
|
||||
});
|
||||
assert.equal(result.after!.revived, before.revived);
|
||||
assert.equal(result.after!.freeForcesRevived, before.freeForcesRevived);
|
||||
assert.equal(result.after!.spice, before.spice);
|
||||
}
|
||||
const typed = sample(3, 'fremen', true, 2);
|
||||
const receipt = createEmperorNexusRevival(typed, 'owner', 1);
|
||||
assert.equal(receipt.amount, 2); commit(typed, receipt); physicalReturn(typed, receipt);
|
||||
const returned = settleEmperorNexusRevival(typed, receipt, 'returned');
|
||||
assert.equal(returned.after!.tanks, 1); assert.equal(returned.after!.eliteTanks, 1);
|
||||
assert.equal(returned.after!.eliteRevived, 1);
|
||||
});
|
||||
|
||||
void test('a short receipt cannot choose less than the available group or disguise a three-force record', () => {
|
||||
const g = sample(2), receipt = createEmperorNexusRevival(g, 'owner', 0);
|
||||
for (const amount of [0, 1, 3, undefined]) {
|
||||
const malformed = { ...receipt, amount, signature: '' } as unknown as NexusEmperorRevival;
|
||||
malformed.signature = emperorNexusSignature(malformed);
|
||||
assert.throws(() => validateEmperorNexusRevival(g, malformed));
|
||||
}
|
||||
const unrecorded = { ...receipt };
|
||||
delete unrecorded.amount; unrecorded.signature = emperorNexusSignature(unrecorded);
|
||||
assert.throws(() => validateEmperorNexusRevival(g, unrecorded));
|
||||
});
|
||||
|
||||
void test('full-three requests retain the previous shape and old completed receipts still mean three', () => {
|
||||
const g = sample(5), receipt = createEmperorNexusRevival(g, 'owner', 0);
|
||||
assert.equal(Object.hasOwn(receipt, 'amount'), false);
|
||||
assert.equal(emperorNexusRevivalCount(receipt), 3);
|
||||
const previous = {
|
||||
kind: 'revival' as const, version: 2 as const, event: receipt.event, owner: 'owner', turn: 2,
|
||||
phase: 4 as const, sequence: 0, faction: 'atreides' as const, advanced: true, elite: 0,
|
||||
before: emperorNexusPools(g.players[0]), after: null, stage: 'requested' as const, signature: '',
|
||||
};
|
||||
previous.signature = emperorNexusSignature(previous);
|
||||
assert.deepEqual(receipt, previous);
|
||||
const legacy: NexusEmperorRevival = {
|
||||
kind: 'revival', event: receipt.event, owner: receipt.owner, turn: receipt.turn,
|
||||
phase: 4, sequence: 0, faction: receipt.faction, advanced: receipt.advanced, elite: 0,
|
||||
before: receipt.before,
|
||||
after: { ...receipt.before, reserves: receipt.before.reserves + 3, tanks: receipt.before.tanks - 3 },
|
||||
signature: '',
|
||||
};
|
||||
legacy.signature = emperorNexusSignature(legacy);
|
||||
validateEmperorNexusRevival(g, legacy); assert.equal(emperorNexusRevivalCount(legacy), 3);
|
||||
});
|
||||
|
||||
void test('short controls display the quoted count and keep the exact native packet without a client-selected quantity', () => {
|
||||
for (const amount of [1, 2] as const) {
|
||||
const f = createNexusShortRevivalFixture({amount}), g = f.game;
|
||||
const view = viewGame(g, f.actor), offered = view.nexusEmperorSecretAlly!;
|
||||
assert.equal(offered.revival.amount, amount);
|
||||
assert.deepEqual(nexusEmperorRevivalAction(view, 0), {
|
||||
type: 'nexusEmperorRevive', event: offered.event, elite: 0,
|
||||
});
|
||||
const text = html(view);
|
||||
assert.match(text, new RegExp(`request ${amount} additional force`));
|
||||
assert.match(text, /Provisional short-group policy/); assert.match(text, /not publisher clarification/);
|
||||
assert.match(text, /beyond ordinary force\/free allowances/);
|
||||
assert.match(html(view, true), /disabled=""/);
|
||||
assert.equal(nexusEmperorRevivalAction(view, amount + 1), null);
|
||||
// Explicit stale projection supplement; no stale network snapshot is claimed.
|
||||
const stale = structuredClone(view); stale.nexusEmperorSecretAlly!.revival.event += '-stale';
|
||||
assert.equal(nexusEmperorRevivalAction(stale, 0), null);
|
||||
assert.equal(nexusEmperorRevivalAction(viewGame(g, 'guild'), 0), null);
|
||||
}
|
||||
const f = createNexusShortRevivalFixture({amount:1});
|
||||
const empty = applyAction(f.game, f.actor, {type:'revive',amount:1,elite:0});
|
||||
const view = viewGame(empty, f.actor);
|
||||
assert.equal(nexusEmperorRevivalAction(view, 0), null);
|
||||
assert.match(html(view), /No eligible forces to revive/); assert.match(html(view), /disabled=""/);
|
||||
// Controlled projected denial supplement; no Tleilaxu event is manufactured.
|
||||
const blocked = viewGame(f.game, f.actor);
|
||||
blocked.nexusEmperorSecretAlly!.revival.blocked = 'Native Tleilaxu prevention blocks this revival.';
|
||||
assert.equal(nexusEmperorRevivalAction(blocked, 0), null);
|
||||
assert.match(html(blocked), /Native Tleilaxu prevention/);
|
||||
});
|
||||
@@ -101,7 +101,7 @@ void test('Emperor control retains actor, Advanced, unsealed plan and interrupti
|
||||
mutate(v);
|
||||
assert.equal(nexusSardaukarAction(v, v.nexusSardaukar!.offer!.event), null);
|
||||
}
|
||||
for (const options of [{ normal: 4 }, { starred: 1 }]) {
|
||||
for (const options of [{ starred: 1 }]) {
|
||||
const v = viewGame(nexusSardaukarFixture(options), 'p');
|
||||
const offer = v.nexusSardaukar!.offer!;
|
||||
assert.ok(offer.blocked);
|
||||
|
||||
@@ -91,7 +91,7 @@ void test('Sardaukar receipt binds the original five-counter battle role without
|
||||
assert.throws(() => validateNexusSardaukar(g, bad as typeof receipt));
|
||||
}
|
||||
assert.throws(() => createNexusSardaukar(g, 'q', 'battle', 'pasty_mesa', 7));
|
||||
assert.throws(() => createNexusSardaukar(g, 'p', 'battle', 'pasty_mesa', 4));
|
||||
assert.throws(() => createNexusSardaukar(g, 'p', 'battle', 'pasty_mesa', 0));
|
||||
const changed = g.players.map((p) => ({ id: p.id, faction: p.faction }));
|
||||
changed[1].id = 'another-seat';
|
||||
assert.throws(() =>
|
||||
@@ -191,8 +191,8 @@ void test('the completed grant expires before a second battle in the same turn',
|
||||
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
|
||||
});
|
||||
|
||||
void test('Basic, short groups, actual Sardaukar, foreign actors and stale or repeated declarations reject unchanged', () => {
|
||||
for (const options of [{ advanced: false }, { normal: 4 }, { starred: 1 }]) {
|
||||
void test('Basic, actual Sardaukar, foreign actors and stale or repeated declarations reject unchanged', () => {
|
||||
for (const options of [{ advanced: false }, { starred: 1 }]) {
|
||||
const g = nexusSardaukarFixture(options);
|
||||
assert.ok(viewGame(g, 'p').nexusSardaukar!.offer!.blocked);
|
||||
nexusReject(g, 'p', request(g), /Basic|Advanced|five|5|Sardaukar|ruling/i);
|
||||
|
||||
@@ -86,6 +86,9 @@ function fixture(salusa: 1 | 2 | 5 = 5) {
|
||||
ready: [],
|
||||
storm: 18,
|
||||
});
|
||||
// This conserved controlled Battle setup skips the actual Charity turns;
|
||||
// their ordered frame does not belong to the newly staged phase.
|
||||
delete g.charityOrder;
|
||||
g = applyAction(g, 'p', {
|
||||
type: 'chooseBattle',
|
||||
territory: 'pasty_mesa',
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { nexusSardaukarAction } from '../game/nexus-sardaukar-options';
|
||||
import {
|
||||
createNexusShortCunningFixture, resolveNexusShortCunning as resolve,
|
||||
finishNexusShortCunning as finish, actNexusShortCunning as act,
|
||||
nexusShortCunningPlayer as p, nexusShortCunningInventory as inventory,
|
||||
type NexusShortCunningFixture,
|
||||
} from './fixture-nexus-short-cunning';
|
||||
|
||||
const sources = new Map<number,NexusShortCunningFixture>();
|
||||
function fixture(amount:2|4 = 2) {
|
||||
let f = sources.get(amount);
|
||||
if (!f) {f = createNexusShortCunningFixture({amount}); sources.set(amount,f);}
|
||||
return f;
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game,actor,action)); assert.deepEqual(game,before);
|
||||
}
|
||||
|
||||
void test('native short Cunning source earns its original Emperor Nexus and pays only for two or four ordinary counters', () => {
|
||||
for (const amount of [2,4] as const) {
|
||||
const f = fixture(amount);
|
||||
assert.equal(f.base.lobby.status,'lobby');
|
||||
assert.ok(f.base.lobby.nexusCards);
|
||||
assert.equal(f.base.setup.status,'setup');
|
||||
assert.equal(f.base.beforeDraw.turn,2);
|
||||
assert.equal(f.base.afterDraw.nexusCards!.cards!.hands[f.actor],'emperor');
|
||||
assert.equal(p(f.game,f.actor).ally,null);
|
||||
assert.equal(p(f.game,f.opponent).ally,'guild');
|
||||
assert.equal(f.shipment.steps[0].action.amount,amount);
|
||||
assert.equal(f.shipment.steps[0].action.elite,0);
|
||||
assert.equal(f.shipment.cost,amount);
|
||||
assert.equal(p(f.game,f.actor).forces[f.key],amount);
|
||||
assert.equal(p(f.game,f.actor).elites!.forces[f.key] ?? 0,0);
|
||||
assert.deepEqual(f.game.battle!.plans,{});
|
||||
assert.equal(f.sourceCount,f.sourceActions.length);
|
||||
assert.equal(f.original.cards.length,33);
|
||||
assert.deepEqual(inventory(f.game),f.original);
|
||||
assert.match(f.disclosure,/Original public.*No played hand, wallet, force, phase/);
|
||||
}
|
||||
});
|
||||
|
||||
for (const amount of [2,4] as const)
|
||||
void test(`native Emperor Cunning counts all ${amount} short ordinary fighters and loses one physical normal exactly once`, () => {
|
||||
const f = fixture(amount), before = structuredClone(p(f.game,f.actor)), use = resolve(f);
|
||||
const quote = viewGame(use.game,f.actor).battle!.ownForces!;
|
||||
assert.deepEqual([quote.normal,quote.elite,quote.temporaryElite],[amount,0,amount]);
|
||||
assert.equal(viewGame(f.game,f.actor).nexusSardaukar!.shortCount,amount);
|
||||
assert.equal(viewGame(use.game,f.actor).nexusSardaukar!.shortCount,amount);
|
||||
const receipt = use.game.nexusSardaukarHistory![0].receipt;
|
||||
assert.equal(receipt.normal,amount); assert.equal(receipt.count,amount);
|
||||
assert.equal(receipt.shortGroup,true); assert.equal(receipt.elite,0);
|
||||
assert.deepEqual(p(use.game,f.actor).elites,before.elites);
|
||||
reject(use.game,f.actor,f.action);
|
||||
const result = finish(f,use.game), done = p(result.game,f.actor);
|
||||
assert.equal(result.game.lastBattleContext!.winner,f.actor);
|
||||
assert.equal(done.forces[f.key],amount-1);
|
||||
assert.equal(done.tanks,before.tanks+1);
|
||||
assert.equal(done.reserves,before.reserves);
|
||||
assert.deepEqual(done.elites,before.elites);
|
||||
assert.equal(result.game.nexusSardaukarHistory!.length,1);
|
||||
assert.equal(result.game.nexusSardaukarHistory![0].casualties!.outcome,'complete');
|
||||
assert.ok(result.game.nexusSardaukarHistory![0].casualties!.options.every(loss => loss.elite === 0));
|
||||
assert.equal(result.game.nexusCards!.cards!.discard.filter(c => c === 'emperor').length,1);
|
||||
assert.deepEqual(inventory(result.game),f.original);
|
||||
});
|
||||
|
||||
void test('original opposing Karama cancels a short Cunning declaration without refund or conversion', () => {
|
||||
const f = fixture(), before = structuredClone(p(f.game,f.actor)), use = resolve(f,true);
|
||||
assert.equal(use.declared.response?.kind,'nexusSardaukar');
|
||||
assert.equal(use.declared.nexusCards!.cards!.hands[f.actor],null);
|
||||
assert.equal(viewGame(use.game,f.actor).battle!.ownForces!.temporaryElite ?? 0,0);
|
||||
assert.equal(use.game.nexusSardaukarHistory![0].stage,'canceled');
|
||||
assert.equal(use.game.nexusSardaukarHistory![0].receipt.count,2);
|
||||
assert.deepEqual(p(use.game,f.actor).forces,before.forces);
|
||||
assert.deepEqual(p(use.game,f.actor).elites,before.elites);
|
||||
assert.equal(p(use.game,f.actor).spice,before.spice);
|
||||
assert.equal(use.game.discard.filter(c => c.id === f.karama).length,1);
|
||||
const result = finish(f,use.game,f.canceledPlan);
|
||||
assert.equal(result.game.lastBattleContext!.winner,f.actor);
|
||||
assert.equal(p(result.game,f.actor).forces[f.key],1);
|
||||
assert.equal(p(result.game,f.actor).tanks,before.tanks+1);
|
||||
assert.deepEqual(p(result.game,f.actor).elites,before.elites);
|
||||
assert.equal(result.game.nexusCards!.cards!.discard.filter(c => c === 'emperor').length,1);
|
||||
assert.deepEqual(inventory(result.game),f.original);
|
||||
});
|
||||
|
||||
void test('short Cunning exact adapter rejects stale owners and all four minimal policies can declare and plan legally', () => {
|
||||
const f = fixture();
|
||||
assert.deepEqual(nexusSardaukarAction(viewGame(f.game,f.actor),f.event),f.action);
|
||||
reject(f.game,f.opponent,f.action);
|
||||
reject(f.game,f.actor,{...f.action,event:`${f.event}:stale`});
|
||||
for (const profile of ['Easy','Medium','Hard','Brutal'] as const) {
|
||||
const view = viewGame(f.game,f.actor);
|
||||
view.players.find(p => p.id === f.actor)!.bot = profile;
|
||||
const declaration = botActions(view).find(a => a.type === 'nexusSardaukar');
|
||||
assert.ok(declaration,`${profile} needs the exact short Cunning action.`);
|
||||
assert.deepEqual(declaration,f.action);
|
||||
const pending = act(structuredClone(f.game),f.actor,declaration);
|
||||
assert.equal(pending.nexusSardaukarHistory![0].receipt.count,2);
|
||||
const active = resolve(f).game, planning = viewGame(active,f.actor);
|
||||
planning.players.find(p => p.id === f.actor)!.bot = profile;
|
||||
const plan = botActions(planning).find(a => a.type === 'battlePlan');
|
||||
assert.ok(plan,`${profile} needs a legal converted short-group plan.`);
|
||||
const committed = act(active,f.actor,plan);
|
||||
assert.ok(committed.battle!.plans[f.actor]);
|
||||
assert.deepEqual(inventory(committed),f.original);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { emperorNexusPools, emperorNexusRevivalCount } from '../game/nexus-emperor-secret-ally';
|
||||
import { nexusEmperorRevivalAction } from '../game/nexus-emperor-secret-ally-options';
|
||||
import { forceRevivalRemaining, freeRevivalRemaining } from '../game/revival';
|
||||
import {
|
||||
createNexusShortRevivalFixture, applyNexusShortRevival as apply,
|
||||
nexusShortRevivalPlayer as p, nexusShortRevivalInventory as inventory,
|
||||
type NexusShortRevivalFixture,
|
||||
} from './fixture-nexus-short-revival';
|
||||
|
||||
const sources = new Map<number,NexusShortRevivalFixture>();
|
||||
function fixture(amount:1|2 = 1) {
|
||||
let f = sources.get(amount);
|
||||
if (!f) {f = createNexusShortRevivalFixture({amount}); sources.set(amount,f);}
|
||||
return f;
|
||||
}
|
||||
|
||||
for (const amount of [1,2] as const)
|
||||
void test(`earned Emperor Secret Ally returns the actual ${amount}-counter shortage through native revival without ordinary quota or price`, () => {
|
||||
const f = fixture(amount), before = p(f.game,f.actor);
|
||||
assert.equal(f.base.lobby.status,'lobby');
|
||||
assert.ok(f.base.lobby.nexusCards); assert.equal(f.base.setup.status,'setup');
|
||||
assert.ok(f.game.players.every(p => p.faction !== 'emperor'));
|
||||
assert.equal(f.shipment.steps[0].actor,'guild');
|
||||
assert.equal(f.shipment.steps[0].action.type,'ship');
|
||||
assert.equal(f.shipment.steps[0].action.amount,1);
|
||||
assert.equal(f.battleResolution.lastBattleContext!.winner,f.actor);
|
||||
assert.equal(p(f.battleResolution,f.actor).tanks,amount);
|
||||
assert.equal(f.battleActions.filter(step => step.action.type === 'battlePlan').length,2);
|
||||
assert.equal(f.alliance.steps.length,2);
|
||||
assert.equal(p(f.game,'guild').ally,'harkonnen');
|
||||
assert.equal(p(f.game,f.actor).ally,null);
|
||||
assert.equal(f.afterDraw.nexusCards!.cards!.hands[f.actor],'emperor');
|
||||
assert.equal(f.game.phase,4);
|
||||
if (f.game.revivalOrder) assert.equal(f.game.revivalOrder.remaining[0],f.actor);
|
||||
assert.equal(before.revived,0);
|
||||
assert.equal(f.sourceCount,f.sourceActions.length);
|
||||
const ordinary = forceRevivalRemaining(f.game,before), free = freeRevivalRemaining(f.game,before);
|
||||
assert.equal(ordinary, amount);
|
||||
const result = apply(f), after = p(result.game,f.actor);
|
||||
assert.deepEqual(emperorNexusPools(after),{...emperorNexusPools(before),reserves:before.reserves+amount,tanks:0});
|
||||
assert.equal(after.spice,before.spice);
|
||||
assert.equal(forceRevivalRemaining(result.game,after),0, 'The Tanks are now empty; the ordinary usage ledger above remains unchanged.');
|
||||
assert.equal(freeRevivalRemaining(result.game,after),free);
|
||||
assert.deepEqual(after.forces,before.forces);
|
||||
assert.equal(result.game.phase,4);
|
||||
assert.deepEqual(result.game.revivalOrder,f.game.revivalOrder);
|
||||
const receipt = result.game.nexusEmperorSecretHistory!.at(-1)!;
|
||||
assert.ok(receipt.version === 2);
|
||||
assert.equal(receipt.stage,'returned');
|
||||
assert.equal(receipt.amount,amount);
|
||||
assert.equal(emperorNexusRevivalCount(receipt),amount);
|
||||
assert.equal(receipt.before.tanks,amount);
|
||||
assert.equal(receipt.after!.tanks,0);
|
||||
assert.equal(result.game.nexusCards!.cards!.hands[f.actor],null);
|
||||
assert.equal(result.game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length,1);
|
||||
assert.equal(result.game.nexusEmperorSecretHistory!.length,1);
|
||||
assert.deepEqual(inventory(result.game),f.original);
|
||||
const immutable = structuredClone(result.game);
|
||||
assert.throws(() => applyAction(result.game,f.actor,f.action));
|
||||
assert.deepEqual(result.game,immutable);
|
||||
});
|
||||
|
||||
void test('all four minimal policies use the exact earned short-revival adapter and actual native return', () => {
|
||||
const f = fixture();
|
||||
assert.deepEqual(nexusEmperorRevivalAction(viewGame(f.game,f.actor),0),f.action);
|
||||
for (const profile of ['Easy','Medium','Hard','Brutal'] as const) {
|
||||
const game = structuredClone(f.game), view = viewGame(game,f.actor);
|
||||
view.players.find(p => p.id === f.actor)!.bot = profile;
|
||||
const action = botActions(view).find(action => action.type === 'nexusEmperorRevive');
|
||||
assert.ok(action,`${profile} needs the real one-counter Secret Ally return.`);
|
||||
assert.deepEqual(action,f.action);
|
||||
const returned = apply(f,game).game;
|
||||
assert.equal(p(returned,f.actor).tanks,0);
|
||||
const receipt = returned.nexusEmperorSecretHistory!.at(-1)!;
|
||||
assert.ok(receipt.version === 2);
|
||||
assert.equal(receipt.amount,1);
|
||||
assert.equal(returned.nexusCards!.cards!.discard.filter(card => card === 'emperor').length,1);
|
||||
assert.deepEqual(inventory(returned),f.original);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createNexusSardaukar, validateNexusSardaukar } from '../game/nexus-sardaukar';
|
||||
|
||||
const context = { turn: 2, players: [
|
||||
{ id: 'e', faction: 'emperor' as const }, { id: 'a', faction: 'atreides' as const },
|
||||
] };
|
||||
|
||||
void test('positive short Cunning groups use every available counter and explicitly record their provisional source', () => {
|
||||
for (const normal of [1, 2, 3, 4]) {
|
||||
const receipt = createNexusSardaukar(context, 'e', 'battle', 'arrakeen', normal);
|
||||
assert.equal(receipt.count, normal);
|
||||
assert.equal(receipt.shortGroup, true);
|
||||
validateNexusSardaukar(context, receipt);
|
||||
assert.throws(() => validateNexusSardaukar(context, { ...receipt, shortGroup: undefined }));
|
||||
assert.throws(() => validateNexusSardaukar(context, { ...receipt, count: normal - 1 }));
|
||||
}
|
||||
for (const normal of [0, -1, 21, 1.5, NaN])
|
||||
assert.throws(() => createNexusSardaukar(context, 'e', 'battle', 'arrakeen', normal));
|
||||
});
|
||||
|
||||
void test('Cunning retains the original five-role shape and signature when the full group exists', () => {
|
||||
for (const normal of [5, 7, 20]) {
|
||||
const receipt = createNexusSardaukar(context, 'e', 'battle', 'arrakeen', normal);
|
||||
assert.equal(receipt.count, 5);
|
||||
assert.equal(Object.hasOwn(receipt, 'shortGroup'), false);
|
||||
assert.equal(receipt.signature, JSON.stringify([
|
||||
1, receipt.event, 'e', 2, 'battle', 'arrakeen', normal, 0, 5,
|
||||
[['a', 'atreides'], ['e', 'emperor']],
|
||||
]));
|
||||
assert.throws(() => validateNexusSardaukar(context, { ...receipt, shortGroup: true }));
|
||||
}
|
||||
});
|
||||
@@ -124,7 +124,10 @@ for (const advanced of [false, true]) for (const role of ['visitor', 'native'] a
|
||||
assert.equal(after.tanks, before.tanks + 5, 'Three native fee counters and two real dial casualties, no virtual bonus casualties');
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks);
|
||||
assert.equal(after.battleLosses, before.battleLosses + 5);
|
||||
assert.equal(after.spice, before.spice - (advanced ? 2 : 0));
|
||||
if (role === 'visitor') assert.ok(done.log.slice(f.game.log.length).some(entry =>
|
||||
entry.text === `${before.name} received 2 spice from ${f.location}'s occupied bank award.`),
|
||||
'Completing the last visitor battle includes its actual occupied-World Collection award.');
|
||||
assert.equal(after.spice, before.spice - (advanced ? 2 : 0) + (role === 'visitor' ? 2 : 0));
|
||||
if (role === 'visitor') {
|
||||
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 1, elite: 4 });
|
||||
assert.equal(after.reserves, before.reserves - 3);
|
||||
|
||||
Reference in new issue
Block a user