Implement Emperor Nexus Cunning with physical casualty recovery

This commit is contained in:
Quintine committed 2026-09-10 11:42:01 +10:00
1 parent b05125de9a
commit ba0b5647e1
27 files changed
+2487 -64

No files matched your search

+7 -5
View File
@@ -181,11 +181,13 @@ export function BattlePromises({
{index + 1}.{' '}
{action.type === 'nexusSuboids'
? 'Use Ixian Cunning to give Suboids full strength without spice support'
: 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'
? `Play Ghola to revive ${action.leader === 'kwisatz' ? 'Kwisatz Haderach' : name(action.leader)}`
: `Play Ghola to revive ${Number(action.amount)} force${Number(action.amount) === 1 ? '' : 's'}${Number(action.elite) ? `, including ${Number(action.elite)} elite` : ''}`}
: action.type === 'nexusSardaukar'
? 'Declare Emperor Cunning to count 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'
? `Play Ghola to revive ${action.leader === 'kwisatz' ? 'Kwisatz Haderach' : name(action.leader)}`
: `Play Ghola to revive ${Number(action.amount)} force${Number(action.amount) === 1 ? '' : 's'}${Number(action.elite) ? `, including ${Number(action.elite)} elite` : ''}`}
.
</p>
))}
+12 -7
View File
@@ -14,6 +14,7 @@ import { NexusTraitors } from './nexus-traitors';
import { NexusTleilaxu } from './nexus-tleilaxu';
import { NexusSuboids } from './nexus-suboids';
import { NexusAdvisors } from './nexus-advisors';
import { NexusSardaukar } from './nexus-sardaukar';
import { TerrorBoardMarkers } from './terror-board-markers';
import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options';
import { GuildHomeworldShipment } from './guild-homeworld-shipment';
@@ -27,7 +28,7 @@ import {
} from './homeworld-table';
import { nativeShipmentSources } from '@/game/homeworld-options';
import { botBattleChoices } from '@/game/bot-battle-choices';
import { specialForceName } from '@/game/combat';
import { specialForceName, maxCombatDial, maxCombatSupport } from '@/game/combat';
import type { NativeReserveSelections } from '@/game/homeworld-native-reserves';
import {
StrongholdCardGallery,
@@ -325,8 +326,7 @@ export function GameTable({
? fighterCount(me, g.battle.territory)
: 0;
const battleDialMaximum = ownBattleForces
? ownBattleForces.normal * (ownBattleForces.normalFixedHalf ? 0.5 : 1) +
ownBattleForces.elite * ownBattleForces.eliteStrength
? maxCombatDial(ownBattleForces)
: g.advanced || me.faction === 'ixians'
? 40
: battleForces;
@@ -347,10 +347,7 @@ export function GameTable({
const battleSupportMaximum = Math.min(
(me.spice ?? 0) + g.aid.available + strongholdBankSupport,
ownBattleForces
? ownBattleForces.freeSupport
? 0
: (ownBattleForces.normalFixedHalf || ownBattleForces.normalFreeSupport ? 0 : ownBattleForces.normal) +
(ownBattleForces.eliteFreeSupport ? 0 : ownBattleForces.elite)
? maxCombatSupport(ownBattleForces)
: Number.POSITIVE_INFINITY,
);
const battleSupport = Number.isFinite(support)
@@ -1419,6 +1416,7 @@ export function GameTable({
<NexusTleilaxu game={g} act={act} busy={transportBusy || !!me.autopilot} />
<NexusSuboids game={g} act={act} busy={transportBusy || !!me.autopilot} />
<NexusAdvisors game={g} act={act} busy={busy} />
<NexusSardaukar game={g} act={act} busy={busy} />
{!g.nexusCards?.waiting.length && <TupileIntelligence game={g} act={act} busy={busy} />}
{g.biddingEnd && <BiddingEnd game={g} act={act} busy={busy} />}
{g.junctionTransport && [g.junctionTransport.owner, g.junctionTransport.recipient].includes(me.id) && (
@@ -1805,6 +1803,7 @@ export function GameTable({
worthlessKarama: 'Worthless card as Karama',
advisorFlip: 'Bene Gesserit token flip',
nexusAdvisorFlip: 'Bene Gesserit Nexus advisor conversion',
nexusSardaukar: 'Emperor Nexus temporary Sardaukar',
eliteStrength: 'Elite battle strength',
fremenSupport: 'Fremen full strength without spice',
capture: 'Harkonnen leader capture',
@@ -1978,6 +1977,7 @@ export function GameTable({
{g.response.intent &&
g.response.source !== 'ambassador' &&
g.response.kind !== 'nexusAdvisorFlip' &&
g.response.kind !== 'nexusSardaukar' &&
g.response.kind !== 'faceDancerReplacement' && (
<p className="notice">{g.response.intent}</p>
)}
@@ -4152,6 +4152,11 @@ export function GameTable({
? `Your concealed No-Field can reveal ${battleForces} physical forces from your current reserves. This estimate is private.`
: `You have ${battleForces} fighting forces here.`}
</p>
{!!ownBattleForces?.temporaryElite && (
<p className="notice">
{ownBattleForces.temporaryElite} ordinary counters count as Sardaukar for this battle. Their losses are ordinary counters, not starred Sardaukar.
</p>
)}
{ownBattleForces?.normalFreeSupport && (
<p className="notice">
Your Suboids fight at full strength without spice support this turn. Spice support here applies only to Cyborgs.
+1 -1
View File
@@ -118,7 +118,7 @@ export function NexusCards({ game, act, busy }: { game: GameView; act: (action:
<div>
<h3>Nexus cards</h3>
<p className="fine">{offer.deckCount} in deck · {offer.discardCount} discarded · {Object.values(offer.held).filter((held) => held === true).length} held</p>
<p>Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian and Bene Gesserit Cunning. Use the controls shown at each effect’s timing; other effects remain unavailable.</p>
<p>Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit and Emperor Cunning. Use the controls shown at each effect’s timing; other effects remain unavailable.</p>
</div>
{game.nexusAtreides && offer.card === 'atreides' && (
<div aria-label="Atreides Nexus effect" className="space-y-2">
+63
View File
@@ -0,0 +1,63 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { nexusSardaukarAction } from '@/game/nexus-sardaukar-options';
import { Button } from './ui/button';
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;
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.
</p>
</section>
);
if (state.active)
return (
<section aria-label="Emperor Nexus active" className="notice">
<p>
Five ordinary Emperor counters count as Sardaukar for this battle.
They remain ordinary physical counters; any losses go to the ordinary
Tanks.
</p>
</section>
);
const offer = state.offer;
if (!offer) return null;
const action = nexusSardaukarAction(game, offer.event);
return (
<section
aria-label="Emperor Nexus Cunning"
className="space-y-3 rounded-lg border border-[#a88b60]/50 p-3"
>
<h3>Emperor Nexus · Cunning</h3>
<p>
Count five 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
lost.
</p>
{offer.blocked && <p className="notice">{offer.blocked}</p>}
<Button
className="game-action min-h-11 whitespace-normal"
disabled={busy || !action}
onClick={() => {
if (!busy && action) act(action);
}}
>
Use five forces as Sardaukar
</Button>
</section>
);
}
+2
View File
@@ -1,5 +1,7 @@
# Component inventory and verification ledger
**Emperor Nexus Cunning update, 10 September 2026:** five existing ordinary counters can temporarily count as Sardaukar for one Advanced battle after the native Karama response allows the declared enhancement. No starred piece is created or transferred; casualties remain ordinary counters. Shared strength/support limits drive the editor and bots, and public notices distinguish pending versus active roles. This uses existing Nexus and force components without new artwork. Five-counter eligibility, existing commitments and all other Emperor mode gates remain explicit. [NEXUS_SARDAUKAR_RUNTIME.md](NEXUS_SARDAUKAR_RUNTIME.md) records 35 new cases, browser inspection, private recovery and the full-project checks; complete module acceptance remains pending.
**Bene Gesserit Nexus Cunning update, 10 September 2026:** existing physical advisors now have an owned whole-territory conversion checklist with counts, select-all control and explicit blocked reasons. One played Nexus card opens one Karama response for the entire public selection; no additional card, token or artwork is created. Legal selected groups become fighters in place without consuming shipment, movement or spice. Fresh accompanied advisors and storm cases remain explicitly pending rulings. Borrowed Voice, Betrayal and incomplete mode starts remain gated. [NEXUS_ADVISOR_RUNTIME.md](NEXUS_ADVISOR_RUNTIME.md) records the bounded runtime and verification status; final acceptance is pending.
**Nexus update, 10 September 2026:** all twelve physical Nexus identities and all thirty-six effect panels are verified against the original printed components. The separate inventory preserves one card per faction, independent of the selected roster; held cards remain private. The internal rules gallery and owner panel provide complete original-language effect text, existing bundled faction artwork and enlarged inspection. Common draws, replacement, preselected own-faction redraw, alliance forfeiture and saved phase progress are integrated. Atreides Cunning and Secret Ally have inspected battle controls and private history. Harkonnen Cunning and Secret Ally now draw from the physical Traitor Deck before an exact private return; Tleilaxu receives Face Dancers while previously revealed dancers stay protected. The owner selects actual leader identities with enlarged inspection, and observers receive only pending metadata. Native Tleilaxu Cunning now replaces all revealed Face Dancers atomically, drawing before returning the old identities and preserving unrevealed custody and ordinary cycling. Ixian Cunning gives Suboids strength one without support spice for the rest of the turn, while Cyborgs retain their own rules. Both use existing physical components and private offers; Ixian activation becomes public. This adds no duplicate physical deck or new artwork. Tleilaxu Secret Ally, Betrayal, remaining effects and complete module games remain unfinished and gated. See [NEXUS_HARKONNEN_RUNTIME.md](NEXUS_HARKONNEN_RUNTIME.md), [NEXUS_ATREIDES_RUNTIME.md](NEXUS_ATREIDES_RUNTIME.md), [NEXUS_CARD_RULES.md](NEXUS_CARD_RULES.md) and [NEXUS_CARD_RUNTIME.md](NEXUS_CARD_RUNTIME.md) for prior verification and limits. [NEXUS_CUNNING_RUNTIME.md](NEXUS_CUNNING_RUNTIME.md) records the new native Cunning behavior; its final checkpoint acceptance remains pending.
+2
View File
@@ -1,5 +1,7 @@
# Dune implementation status
**Emperor Nexus Cunning checkpoint, 10 September 2026:** [NEXUS_SARDAUKAR_RUNTIME.md](NEXUS_SARDAUKAR_RUNTIME.md) records five ordinary counters temporarily using Sardaukar strength and support in one Advanced battle. Physical counters, starred reserves and ordinary losses remain distinct. Shared editor and bot calculations handle the Fremen exception and Salusa support. Review fixed a canceled-enhancement Prescience deadlock and retained durable winner casualty choices across competing SQLite requests. **Types, lint, all 4,006 offline tests, production build and 40 HTTP/session tests pass.** Thirty-five new cases cover independent counter arithmetic, real Homeworld thresholds, future commitments, hidden information and recovery. Two three-seat browser rooms passed desktop/phone allowance, cancellation, reanswer, sealed planning and private refresh. All **3,421** opening room versions/hashes remain unchanged; only thirty HTTP rooms and two browser rooms were added. Fewer-than-five interpretation, Emperor Secret Ally, Betrayal and complete module acceptance remain gated. No release gate opened; no restart was due, and automation remains removed.
**Bene Gesserit Nexus advisor checkpoint, 10 September 2026:** [NEXUS_ADVISOR_RUNTIME.md](NEXUS_ADVISOR_RUNTIME.md) records whole-territory, multi-sector Cunning conversion during the native owner’s Advanced Shipment and Movement action. One Karama cancels the entire declared batch; source positions, counts and action allowances are preserved. Saved responses survive Truthtrance, Harkonnen exchanges and paid Nullentropy. Shared controls and all four bot profiles consume the public legality quote. **Types, lint, all 3,971 offline tests, production build and 40 HTTP/session tests pass.** Thirty-five new cases include pending-parent corruption, physical custody and concurrent production SQLite recovery. Two three-seat browser rooms passed desktop/phone selection, allowance/cancellation and private refresh; review also fixed checkbox spacing, accessible SVG titles and stale Nexus guidance. All **3,389** opening saved rooms retain their versions/hashes; only two isolated browser rooms and thirty HTTP-test rooms were added. Fresh advisor/storm rulings, borrowed Voice, Betrayal and complete module acceptance remain gated. The Emperor source audit prepares the next family. No release gate opened; no restart was due, and automation remains removed.
**Tleilaxu and Ixian Nexus Cunning checkpoint, 10 September 2026:** [NEXUS_CUNNING_RUNTIME.md](NEXUS_CUNNING_RUNTIME.md) records atomic revealed Face Dancer replacement and full-strength Suboids in every battle for the rest of the turn. Basic and Advanced retain Cyborg support costs and exact physical losses. Review fixed fresh Ixian Nexus setup, future-plan search through an unspent Cunning card and its private preparation hint; prior commitments remain binding. **Types, lint, all 3,936 offline tests, production build and 40 HTTP/session tests pass.** Fifty-one new cases include real Face Dance, all four bots, mixed-force support, concurrent SQLite recovery and future Truthtrance/Prescience commitments. Desktop/phone card play, private refresh and a seven-strength/two-support sealed plan passed. The hourly restart preserved all **3,359** then-existing games; final comparison preserves all **3,327** opening room versions/hashes. Tleilaxu Secret Ally, Betrayal and remaining Nexus effects stay gated pending their recorded requirements. No expansion release gate opened; automation remains removed.
+2
View File
@@ -38,6 +38,8 @@ Supported composition:
Karama can suppress Emperor's Sardaukar advantage before plan revelation. Cunning enhances that native advantage, so suppression applies to its temporary Sardaukar too; it does not destroy or move counters. The card supplies no general Karama immunity. [November FAQ, p.7](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=7)
The selected implementation composition treats a Cunning declaration as the new advantage use: allowing it grants the temporary role; canceling it prevents that role entirely. Those ordinary counters consequently gain neither doubled strength nor Salusa's support exemption. This differs from canceling the strength of actual starred Sardaukar, whose physical identity survives that cancellation. Keep the new use and its response separate from the earlier native starred-force response; one Karama must not consume two independently declared uses.
Salusa high names Sardaukar, without limiting support relief to starred tokens. Applying that relief to counters currently counted as Sardaukar is supported composition. Salusa population still uses its physical native starred pool; Cunning cannot raise its threshold. Preserve the existing [mid-battle Salusa threshold guard](HOMEWORLD_BENEFITS_RULES.md). Occupation effects remain subject to their unresolved lifecycle. [Homeworld components](HOMEWORLD_COMPONENT_AUDIT.md)
## Secret Ally: retain the actual purchase price
+48
View File
@@ -0,0 +1,48 @@
# Emperor Nexus temporary Sardaukar
10 September 2026. This checkpoint implements the bounded native **Cunning** effect in [NEXUS_EMPEROR_RULES.md](NEXUS_EMPEROR_RULES.md). It extends the [Nexus lifecycle](NEXUS_CARD_RUNTIME.md) without enabling Emperor Secret Ally, Betrayal, undersized groups or incomplete mode starts.
## Five existing counters, one battle
An unallied Emperor holding its own Nexus card can declare Cunning during its current Advanced battle before submitting its plan. The eligible army must contain at least five ordinary counters and no actual Sardaukar. A canceled starred-force advantage does not turn a physical Sardaukar into an ordinary counter or satisfy that absence condition.
The declaration spends the Nexus card and opens one ordinary Karama response for the native enhancement. While pending, no temporary roles are active. Allowing the response makes five ordinary counters count as Sardaukar for this battle. Cancellation prevents the whole enhancement and leaves the Nexus card spent. This response is distinct from suppressing actual starred Sardaukar.
The five counters retain their physical identity. No starred counter is created, shipped, revived or removed from reserves. Effective strength and support use a temporary role over the ordinary pool; every loss still removes an ordinary counter. The role ends with its original battle and does not carry to a later battle that turn. A Homeworld army retains its existing physical eligibility and size limits.
| Opponent | Each temporary Sardaukar, supported | Unsupported |
| --- | --- | --- |
| Non-Fremen | 2 strength | 1 strength |
| Fremen | 1 strength | 0.5 strength |
Current applicable Salusa support rules are evaluated through the same combat profile. The effect does not confer free support on the whole Emperor army, and cancellation does not leave a support exemption on counters that never acquired the temporary role.
## Plans, controls and privacy
The shared combat helpers distinguish maximum strength from the number of counters that can receive support. The editor and bots use those helpers for their projected own/opposing armies. For example, seven ordinary counters with five temporary Sardaukar can supply strength twelve against a non-Fremen opponent, but at most seven paid support. Casualty options still contain zero physical starred losses.
Existing Prescience and Truthtrance commitments remain binding when activation is allowed. The authoritative offer checks that activation can preserve them; it cannot reopen an already submitted plan. The controls submit the exact current event and display the engine's blocked reason. A future-strength Prescience answer can include the held Cunning in its reachable preparation without spending the actual card during the search. Its private hint explicitly names Emperor Cunning, followed by the genuine Karama response. Preparation steps can remain open where the battle workflow permits them; live responses, Truthtrance and other action locks retain priority.
If Karama cancels the enhancement and makes an existing inspection impossible, the owner answers that same field again. Other feasible disclosures remain binding, including a disclosed leader. The shared reconciliation also preserves the existing Residual Poison restrictions; it does not decide the unresolved case of two individually feasible but jointly incompatible Cunning answers. Earlier observations remain private historical evidence. Native Prescience, both orders of Cunning fields and Secret Ally inspection have regressions.
Winner casualties retain a saved commitment to the original effective forces, dial, support and every legal physical loss allocation. Independent battle markers detect removed history. Current and suspended choices must match that commitment, and competing requests can settle only one allocation. Completion records survive subsequent battles without applying the temporary role again.
Independent review also caught a pending display inconsistency: the response was still open while force profiles already showed temporary strength. Only an allowed declaration now affects public force profiles. Private future-plan search explicitly models allowance on its own copy; pending and active projections have a regression.
The holder gets one **Use five forces as Sardaukar** action. Declaration and allowance become public, with a pending response notice followed by an active-battle notice. The board and loss explanations identify the counters as physically ordinary. The standard response panel supplies Karama/pass controls and a readable effect name without printing its internal event.
All four bot profiles consume the owner's current offer and wait for the response before boosted planning. The legal-plan search uses the projected temporary-role profile; it does not infer eligibility from another player's hand. Existing Hard/Brutal response policy can cancel a hostile enhancement in the bot's battle. This is legal-policy integration, not a new strategy calibration.
## Verification status and boundaries
The **35 new cases** cover six pure combat/Stone Burner cases, twelve engine cases, eight controls/bot cases, three Homeworld interactions and six production SQLite recovery cases. The independent counter enumeration verifies mixed support and physical loss mapping. SQL cases exercise competing plays and casualty choices, lost acknowledgments, private projections, replay and corrupted source/role/choice records. The genuine Salusa fixtures test high, low and the one-to-two threshold without counting temporary counters as physical stars.
The full-project check passes **4,006 offline cases**, types and lint. The production build and **40 HTTP/session cases** pass. Two isolated three-seat browser rooms exercise declaration/allowance, a twelve-strength/seven-support sealed plan, cancellation after a future-strength inspection and a revised dial of five. Desktop and phone controls, private refresh across all six seats and absence of page errors pass. Screenshots were visually inspected; the 390-pixel phone layout has no horizontal overflow. The non-inspecting third seat receives neither private answer nor sealed plan.
All **3,421** opening saved rooms retain their original versions and state hashes. Thirty HTTP-test rooms and two isolated browser rooms account for all additions. The existing development server was reused; no hourly restart was due at this checkpoint. No recurring automation was created.
An independent review found that a concealed Emperor-owned No-Field cannot arise from the implemented real allied Richese shipment: that path immediately reveals the token and materializes ordinary recipient counters. No fabricated hidden-Emperor fixture is counted as combination coverage. Mandatory discard and Homeworld suspended casualty corruption are validated by the shared receipt checks, but do not yet have separate corruption fixtures for this effect.
Implementation: [shared combat helpers](../game/combat.ts), [owned options](../game/nexus-sardaukar-options.ts), [controls](../components/nexus-sardaukar.tsx), [bots](../game/bots.ts), [table](../components/game-table.tsx), [engine](../game/engine.ts). Evidence: [controls](../tests/nexus-sardaukar-controls.test.ts), [bots](../tests/nexus-sardaukar-bots.test.ts), [physical combat solver](../tests/nexus-sardaukar-combat.test.ts), [engine and inspection](../tests/nexus-sardaukar-engine.test.ts), [Homeworld](../tests/nexus-sardaukar-homeworld.test.ts), [declaration recovery](../tests/nexus-sardaukar-recovery.test.ts), [casualty recovery](../tests/nexus-sardaukar-casualty-recovery.test.ts).
Fewer-than-five interpretation, Emperor Secret Ally purchases/revival and Betrayal remain outside this package. The existing source questions and reaction-policy gate remain explicit. Passing this battle enhancement cannot certify complete Emperor Nexus effects, complete module games or the full expansion goal.
+7 -12
View File
@@ -6,7 +6,12 @@ import {
} from './cards';
import type { FactionId } from './catalog';
import { battleWeaponsExplode, isStoneBurner } from './battle-cards';
import { casualtyOptions, type Casualties, type CombatForces } from './combat';
import {
casualtyOptions,
validCombatForces,
type Casualties,
type CombatForces,
} from './combat';
import { strongholdBattleEffects } from './stronghold-battle';
import {
strongholdBattleIncome,
@@ -160,17 +165,7 @@ function validateCombatant(
p &&
f &&
whole(side.spice) &&
whole(f.normal) &&
whole(f.elite) &&
f.normal + f.elite <= 20 &&
(f.eliteStrength === 1 || f.eliteStrength === 2) &&
typeof f.freeSupport === 'boolean' &&
(f.eliteFreeSupport === undefined ||
typeof f.eliteFreeSupport === 'boolean') &&
(f.normalFreeSupport === undefined ||
typeof f.normalFreeSupport === 'boolean') &&
(f.normalFixedHalf === undefined ||
typeof f.normalFixedHalf === 'boolean') &&
validCombatForces(f) &&
Number.isFinite(p.dial) &&
p.dial >= 0 &&
Number.isSafeInteger(p.dial * 2) &&
+8 -6
View File
@@ -3,6 +3,7 @@ import { nexusTraitorBotActions } from './nexus-traitor-options';
import { nexusTleilaxuBotActions } from './nexus-tleilaxu-options';
import { nexusSuboidBotActions } from './nexus-suboid-options';
import { nexusAdvisorBotActions } from './nexus-advisor-options';
import { nexusSardaukarBotActions } from './nexus-sardaukar-options';
import { tupileIntelligenceActions } from './tupile-intelligence-options';
import { biddingEndActions, choamMarketPolicy } from './bidding-end-options';
import { homeworldRevivalActionBlock, homeworldRevivalDeploymentActions } from './homeworld-revival-deployment-options';
@@ -60,7 +61,7 @@ import { richeseCardDefinition } from './richese-cards';
import { presenceAt } from './force-presence';
import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
import { fighterCount, isAdvisor } from './advisors';
import { casualtyOptions, type CombatForces } from './combat';
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
import { stoneBurnerPlanBlock, stoneBurnerComparison } from './stone-burner';
import {
applyAction,
@@ -566,8 +567,7 @@ function plans(g: GameView): Action[] {
const own = forces.normal + forces.elite;
const typedForces = g.advanced || me.faction === 'ixians';
const ownStrength = typedForces
? forces.normal * (forces.normalFixedHalf ? 0.5 : 1) +
forces.elite * forces.eliteStrength
? maxCombatDial(forces)
: own;
const supportCache = new Map<
number,
@@ -580,7 +580,7 @@ function plans(g: GameView): Action[] {
length:
(g.advanced
? Math.min(
own,
maxCombatSupport(forces),
(me.spice ?? 0) +
g.aid.available +
(b.strongholdEffects[me.id] === 'arrakeen' ? 2 : 0),
@@ -627,8 +627,7 @@ function plans(g: GameView): Action[] {
const ownNativeBonus = b.native === me.id ? b.nativeBattleStrength : 0;
const enemyNativeBonus = b.native === other.id ? b.nativeBattleStrength : 0;
const enemyCapacity = b.opponentForces
? b.opponentForces.normal * (b.opponentForces.normalFixedHalf ? 0.5 : 1) +
b.opponentForces.elite * b.opponentForces.eliteStrength
? maxCombatDial(b.opponentForces)
: enemies;
const insights = [
...(b.insight && b.prescience?.player === me.id ? [b.insight] : []),
@@ -1320,6 +1319,7 @@ function policyActions(g: GameView): Action[] {
g.response.kind === 'harkonnenTraitor' ||
g.response.kind === 'eliteStrength' ||
g.response.kind === 'fremenSupport' ||
g.response.kind === 'nexusSardaukar' ||
g.response.kind === 'choamBattleAid');
const auditThreat =
g.response.kind === 'choamAudit' &&
@@ -3326,6 +3326,8 @@ export function botActions(g: GameView): Action[] {
: nexusTraitorBotActions(g);
if (g.automaticContinuationPending) return [];
if (g.nexusCards?.waiting.length) return nexusCardBotActions(g);
const sardaukar = nexusSardaukarBotActions(g);
if (sardaukar.length) return sardaukar;
const advisorConversion = nexusAdvisorBotActions(g);
if (advisorConversion.length) return advisorConversion;
const suboids = nexusSuboidBotActions(g);
+56 -3
View File
@@ -5,6 +5,9 @@ export type CombatForces = {
/** Normal counters alone retain full strength without support, overriding
* fixed half strength. Special counters keep their separate support rules. */
normalFreeSupport?: boolean;
/** A battle-only effective role assigned to this many physical normal
* counters. It never changes their board or casualty custody. */
temporaryElite?: number;
elite: number;
eliteStrength: 1 | 2;
freeSupport: boolean;
@@ -30,6 +33,11 @@ export function validCombatForces(forces: CombatForces): boolean {
Number.isSafeInteger(forces.elite) &&
forces.elite >= 0 &&
forces.normal + forces.elite <= 20 &&
(forces.temporaryElite === undefined ||
(Number.isSafeInteger(forces.temporaryElite) &&
forces.temporaryElite >= 0 &&
forces.temporaryElite <= forces.normal &&
forces.elite === 0)) &&
(forces.eliteStrength === 1 || forces.eliteStrength === 2) &&
typeof forces.freeSupport === 'boolean' &&
(forces.eliteFreeSupport === undefined ||
@@ -41,6 +49,30 @@ export function validCombatForces(forces: CombatForces): boolean {
);
}
/** Maximum supported strength, without making any assertion about funding. */
export function maxCombatDial(forces: CombatForces): number {
if (!validCombatForces(forces)) return 0;
const temporary = forces.temporaryElite ?? 0;
return (
(forces.normal - temporary) *
(forces.normalFixedHalf && !forces.normalFreeSupport ? 0.5 : 1) +
(forces.elite + temporary) * forces.eliteStrength
);
}
/** Maximum usable spice support; physical normal counters with temporary
* elite roles follow elite support rules without becoming starred counters. */
export function maxCombatSupport(forces: CombatForces): number {
if (!validCombatForces(forces) || forces.freeSupport) return 0;
const temporary = forces.temporaryElite ?? 0;
return (
(forces.normalFixedHalf || forces.normalFreeSupport
? 0
: forces.normal - temporary) +
(forces.eliteFreeSupport ? 0 : forces.elite + temporary)
);
}
/** Every loss combination consistent with the revealed dial and spice payment. */
export function casualtyOptions(
forces: CombatForces,
@@ -54,12 +86,33 @@ export function casualtyOptions(
!Number.isInteger(dial * 2) ||
!Number.isSafeInteger(support) ||
support < 0 ||
support >
(forces.normalFixedHalf || forces.normalFreeSupport ? 0 : forces.normal) +
(forces.eliteFreeSupport ? 0 : forces.elite) ||
support > maxCombatSupport(forces) ||
(forces.freeSupport && support !== 0)
)
return [];
if (forces.temporaryElite) {
const effective = casualtyOptions(
{
...forces,
normal: forces.normal - forces.temporaryElite,
elite: forces.temporaryElite,
temporaryElite: undefined,
},
dial,
support,
);
const physical = new Map<number, Casualties>();
for (const loss of effective) {
const normal = loss.normal + loss.elite;
physical.set(normal, {
normal,
elite: 0,
paidNormal: loss.paidNormal + loss.paidElite,
paidElite: 0,
});
}
return [...physical.values()].sort((a, b) => a.normal - b.normal);
}
const options: Casualties[] = [];
for (let elite = 0; elite <= forces.elite; elite++) {
for (let normal = 0; normal <= forces.normal; normal++) {
+238 -13
View File
@@ -3,6 +3,7 @@ import { beginNexusTraitorExchange, finishNexusTraitorExchange, validateNexusTra
import { replaceNexusFaceDancers, validateNexusFaceDancerHistory, type NexusFaceDancerReceipt } from './nexus-face-dancers';
import { createNexusSuboids, validateNexusSuboids, nexusSuboidsActive, type NexusSuboidReceipt } from './nexus-suboids';
import { quoteNexusAdvisors, createNexusAdvisors, validateNexusAdvisors, type NexusAdvisorReceipt } from './nexus-advisors';
import { createNexusSardaukar, validateNexusSardaukar, type NexusSardaukarReceipt } from './nexus-sardaukar';
import { createTraitorDeclaration, validateTraitorDeclarations, type TraitorDeclaration, type TraitorDeclarationContext } from './traitor-declarations';
import { createNexusCards, validateNexusCards, drawNexusCard, replaceNexusCard, discardNexusCard, projectNexusCards, nexusCardMode, type NexusState } from './nexus-cards';
import { createNexusCardPhase, markNexusCardOccurred, validateNexusCardPhase, closeNexusCardPhase, nexusCardChoices, finishNexusCardChoice, type NexusCardPhase, type NexusCardChoice } from './nexus-card-phase';
@@ -451,7 +452,7 @@ import {
settleAdvisors,
arrivalAsAdvisor,
} from './advisors';
import { casualtyOptions, type Casualties, type CombatForces } from './combat';
import { casualtyOptions, maxCombatDial, maxCombatSupport, validCombatForces, type Casualties, type CombatForces } from './combat';
import { FACTIONS, faction, type FactionId } from './catalog';
import {
treacheryDeck,
@@ -541,6 +542,7 @@ export type Battle = {
nexusInspection?: NexusInspection;
/** Independent presence marker: deleting a spent-card record is not a legacy save. */
nexusInspectionUsed?: string;
nexusSardaukarUsed?: string;
traitorDeclarationVersion?: 1;
traitorDeclarations?: Record<string, TraitorDeclaration>;
homeworldDefensePassed?: string[];
@@ -793,6 +795,7 @@ export type ResponseWindow = {
| 'fremenSupport'
| 'advisorFlip'
| 'nexusAdvisorFlip'
| 'nexusSardaukar'
| 'bgCharity'
| 'choamCharity'
| 'choamInflation'
@@ -1267,6 +1270,9 @@ export type Game = {
nexusSuboidLast?: { event: string; owner: string; turn: number };
nexusAdvisorHistory?: { receipt: NexusAdvisorReceipt; stage: 'pending' | 'completed' | 'canceled'; frame: string; signature: string }[];
nexusAdvisorLast?: { event: string; stage: 'pending' | 'completed' | 'canceled' };
nexusSardaukarHistory?: {receipt: NexusSardaukarReceipt; stage: 'pending' | 'active' | 'canceled'; parent: string; signature: string;
casualties?: {forces: CombatForces; dial: number; support: number; options: Casualties[]; outcome: 'pending' | 'complete'}}[];
nexusSardaukarLast?: {event: string; stage: 'pending' | 'active' | 'canceled'};
nexusTraitorPending?: string | null;
nexusTraitorParent?: { event: string; signature: string } | null;
/** Null/absent disables the module; custody is installed at force placement. */
@@ -1385,6 +1391,7 @@ export type Game = {
cardRolesSignature?: string;
winnerDiscards?: { cards: string[]; completed: boolean; signature: string };
caladanReinforcement?: HomeworldVictoryObligation;
nexusSardaukarCasualties?: string;
} | null;
auction: Auction | null;
battle: Battle | null;
@@ -1862,6 +1869,182 @@ function finishNexusAdvisors(g: Game, canceled: boolean) {
{faction: p.faction, name: canceled ? 'Nexus advisor conversion prevented' : 'Nexus advisors become fighters'});
}
function currentNexusSardaukar(g: Game) {
return g.battle?.event ? g.nexusSardaukarHistory?.find(record => record.receipt.battle === g.battle!.event) ?? null : null;
}
function nexusSardaukarEffective(g: Game, id: string, to: string) {
const record = currentNexusSardaukar(g);
return record && record.receipt.owner === id && record.receipt.territory === to && record.stage === 'active' ? record.receipt.count : 0;
}
function nexusSardaukarParent(g: Game) {
const b = g.battle!;
return JSON.stringify([b.attacker, b.defender, b.territory, b.preparation ?? null,
b.powerChecks ?? null, b.preLeader ?? null, b.plans]);
}
function nexusSardaukarSignature(record: NonNullable<Game['nexusSardaukarHistory']>[number]) {
return JSON.stringify([record.receipt.signature, record.stage, record.parent, record.casualties ?? null]);
}
function validateNexusSardaukarResponse(g: Game, response: ResponseWindow) {
const record = currentNexusSardaukar(g), b = g.battle;
requireRule(record?.stage === 'pending' && b && !b.revealed &&
record.receipt.turn === g.turn && record.receipt.territory === b.territory &&
response.kind === 'nexusSardaukar' && response.owner === record.receipt.owner && response.intent === record.receipt.event &&
Object.keys(response).every(key => ['kind', 'owner', 'intent', 'passed'].includes(key)) &&
Array.isArray(response.passed) && new Set(response.passed).size === response.passed.length &&
response.passed.every(id => g.players.some(p => p.id === id)) && record.parent === nexusSardaukarParent(g),
'The Nexus Sardaukar response has lost its original battle and preparation.');
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 === record.receipt.normal && forces.elite === 0,
'The pending Nexus Sardaukar declaration has lost its original eligible forces.');
}
function nexusSardaukarIntegrity(g: Game) {
const continuation = g.pendingTreacheryDiscard?.continuation;
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume,
g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume,
continuation && 'resume' in continuation ? continuation.resume : null];
const responses = contexts.flatMap(context => {
const pending = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
return [context?.response, pending?.use.kind === 'cancel' ? pending.use.response : null];
}).filter(response => response?.kind === 'nexusSardaukar');
const history = g.nexusSardaukarHistory;
if (history === undefined) {
requireRule(!g.nexusSardaukarLast && !g.battle?.nexusSardaukarUsed && !g.lastBattleContext?.nexusSardaukarCasualties && !responses.length,
'The Nexus Sardaukar use has lost its saved history.');
return;
}
requireRule(g.advanced && g.nexusCards?.cards && Array.isArray(history) && history.length > 0,
'Nexus Sardaukar history requires its original Advanced module.');
const battles = new Set<string>(), turns = new Set<number>();
for (const [index, record] of history.entries()) {
requireRule(record && record.receipt && typeof record.receipt === 'object' &&
Object.keys(record).sort().join(',') === (record.casualties ? 'casualties,parent,receipt,signature,stage' : 'parent,receipt,signature,stage') &&
['pending', 'active', 'canceled'].includes(record.stage) && typeof record.parent === 'string' &&
record.signature === nexusSardaukarSignature(record) && !battles.has(record.receipt.battle) &&
!turns.has(record.receipt.turn) && (record.stage !== 'pending' || index === history.length - 1),
'The Nexus Sardaukar battle history is malformed.');
nexusRule(() => validateNexusSardaukar(g, record.receipt));
const losses = record.casualties;
if (losses) {
requireRule(record.stage === 'active' && ['pending','complete'].includes(losses.outcome) &&
Object.keys(losses).sort().join(',') === 'dial,forces,options,outcome,support' &&
validCombatForces(losses.forces) && losses.forces.temporaryElite === 5 &&
Array.isArray(losses.options) && losses.options.length > 0 &&
JSON.stringify(losses.options) === JSON.stringify(casualtyOptions(losses.forces,losses.dial,losses.support)),
'The Nexus Sardaukar casualties have lost their committed force allocation.');
if (losses.outcome === 'pending') {
const context = g.lastBattleContext;
requireRule(!g.battle && context?.event === record.receipt.battle && context.result === 'normal' &&
context.winner === record.receipt.owner && context.nexusSardaukarCasualties === record.receipt.event &&
context.territory === record.receipt.territory && context.turn === record.receipt.turn,
'The pending Sardaukar casualties have lost their completed battle.');
const pool = combatArmy(g,record.receipt.owner,record.receipt.territory);
requireRule(pool.normal === losses.forces.normal && pool.elite === losses.forces.elite,
'The pending Sardaukar casualties have lost their exact physical army.');
const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'battleLosses' && d.player === record.receipt.owner);
const committed = continuation?.kind === 'battleResolved' && continuation.event === record.receipt.battle ? continuation.casualties : null;
requireRule(decisions.length > 0 || committed, 'The Nexus Sardaukar battle has lost its casualty choice.');
for (const decision of decisions) requireRule(decision.kind === 'battleLosses' &&
decision.territory === record.receipt.territory && JSON.stringify(decision.options) === JSON.stringify(losses.options),
'The casualty choices differ from the committed Nexus Sardaukar plan.');
if (committed) requireRule(JSON.stringify(committed) === JSON.stringify({forces:losses.forces,dial:losses.dial,support:losses.support,options:losses.options}),
'The suspended Sardaukar casualty allocation was changed.');
if (g.homeworldBattleLoss) requireRule(JSON.stringify(g.homeworldBattleLoss.commitment) ===
JSON.stringify({forces:losses.forces,dial:losses.dial,support:losses.support}),
'The Homeworld Sardaukar casualties have lost their effective combat roles.');
}
}
battles.add(record.receipt.battle); turns.add(record.receipt.turn);
}
const last = history.at(-1)!;
requireRule(JSON.stringify(g.nexusSardaukarLast) === JSON.stringify({event: last.receipt.event, stage: last.stage}),
'The Nexus Sardaukar use has lost its latest saved outcome.');
if (last.receipt.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('emperor'),
'The Nexus Sardaukar use has lost its spent card.');
const record = currentNexusSardaukar(g), b = g.battle;
requireRule((record?.stage === 'pending') === (responses.length > 0) &&
(last.stage !== 'pending' || record === last), 'The Nexus Sardaukar use has lost or reopened its response.');
if (b) requireRule(b.nexusSardaukarUsed === record?.receipt.event,
'The battle has lost its original Nexus Sardaukar marker.');
if (record) {
requireRule(g.phase === 6 && record.receipt.turn === g.turn && b!.territory === record.receipt.territory &&
[b!.attacker, b!.defender].includes(record.receipt.owner), 'The temporary Sardaukar belong to another battle.');
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.');
}
}
for (const response of responses) validateNexusSardaukarResponse(g, response!);
const lastLoss = history.find(record => record.receipt.battle === g.lastBattleContext?.event)?.casualties;
const lossEvent = history.find(record => record.receipt.battle === g.lastBattleContext?.event)?.receipt.event;
requireRule(g.lastBattleContext?.nexusSardaukarCasualties === (lastLoss ? lossEvent : undefined),
'The completed battle has lost its Nexus Sardaukar casualty receipt.');
}
function nexusSardaukarEligibility(g: Game, p: Player): string | null {
const b = g.battle;
if (!g.advanced) return 'Sardaukar are an Advanced advantage.';
if (g.status !== 'playing' || g.phase !== 6 || !b?.event || ![b.attacker,b.defender].includes(p.id))
return 'Use Cunning in your own battle before submitting your Battle Plan.';
if (p.faction !== 'emperor' || p.ally || g.nexusCards?.cards?.hands[p.id] !== 'emperor')
return 'Use your own Emperor Nexus card while unallied.';
if (b.revealed || b.plans[p.id] || b.nexusSardaukarUsed) return 'This battle has already committed your plan or used Emperor Cunning.';
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.';
return null;
}
function projectedNexusSardaukar(g: Game, id: string) {
if (!g.nexusCards?.cards) return null;
const record = currentNexusSardaukar(g);
const active = record?.stage === 'active';
const pending = record?.stage === 'pending' ? {event: record.receipt.event, owner: record.receipt.owner} : null;
const p = getPlayer(g,id);
if (p.faction !== 'emperor' || g.nexusCards.cards.hands[id] !== 'emperor') return {offer:null,active,pending};
let blocked = nexusSardaukarEligibility(g,p);
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.';
const event = g.battle?.event ? JSON.stringify(['nexusSardaukar',g.turn,g.battle.event,id]) : '';
if (!blocked) {
const trial = structuredClone(g);
commitNexusSardaukar(trial,getPlayer(trial,id),'active');
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};
}
function commitNexusSardaukar(g: Game, p: Player, stage: 'pending' | 'active') {
const blocked = nexusSardaukarEligibility(g,p);
requireRule(!blocked, blocked ?? 'The Nexus Sardaukar opportunity is unavailable.');
const b = g.battle!, other = getPlayer(g,b.attacker === p.id ? b.defender : b.attacker);
const receipt = nexusRule(() => createNexusSardaukar(g,p.id,b.event!,b.territory,combatForces(g,p,b.territory,other).normal));
const record = {receipt,stage,parent:nexusSardaukarParent(g),signature:''};
record.signature = nexusSardaukarSignature(record);
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players));
(g.nexusSardaukarHistory ??= []).push(record);
g.nexusSardaukarLast = {event:receipt.event,stage}; b.nexusSardaukarUsed = receipt.event;
if (stage === 'pending') g.response = {kind:'nexusSardaukar',owner:p.id,intent:receipt.event,passed:[]};
}
function playNexusSardaukar(g: Game,p: Player,action: Action) {
const offer = projectedNexusSardaukar(g,p.id)?.offer;
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.`,
{faction:p.faction,name:'Nexus Sardaukar declared'});
}
function finishNexusSardaukar(g: Game,canceled: boolean) {
const record = currentNexusSardaukar(g)!;
record.stage = canceled ? 'canceled' : 'active'; record.signature = nexusSardaukarSignature(record);
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.',
{faction:'emperor',name:canceled ? 'Nexus Sardaukar prevented' : 'Nexus Sardaukar active'});
}
function battleInspectionContext(g: Game): BattleInspectionContext {
const b = g.battle!;
return { event: b.event!, attacker: b.attacker, defender: b.defender,
@@ -4014,6 +4197,7 @@ function treacheryDiscardIntegrity(g: Game) {
requireRule(
winner &&
forces &&
validCombatForces(forces) &&
Number.isSafeInteger(forces.normal) &&
forces.normal >= 0 &&
Number.isSafeInteger(forces.elite) &&
@@ -4467,6 +4651,7 @@ function combatForces(
const elite = army.elite;
return {
normal: physical + prospective - elite,
...(nexusSardaukarEffective(g,p.id,t) ? {temporaryElite:nexusSardaukarEffective(g,p.id,t)} : {}),
...(homeworldSardaukarFreeSupport(g, p.id)
? { eliteFreeSupport: true }
: {}),
@@ -4708,7 +4893,7 @@ export function initializeHomeworldGameForAudit(state: Game): Game {
export function initializeNexusGameForAudit(state: Game): Game {
requireRule(!!state.nexusCards && state.nexusCards.cards === null && state.nexusCards.phase === null,
'Enable Nexus cards in a fresh audit lobby first.');
return initializeSetupGameForAudit(state, false, true);
return initializeSetupGameForAudit(state, !!state.homeworlds, true);
}
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false): Game {
nexusCardsIntegrity(state);
@@ -6488,6 +6673,7 @@ function validateKaramaUse(
moritaniAllianceCancellationQuote(g, use.response);
if (use.response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, use.response);
if (use.response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, use.response);
if (use.response.kind === 'nexusSardaukar') validateNexusSardaukarResponse(g, use.response);
if (isCombatResponseKind(use.response.kind))
currentCombatResponseQuote(g, {
kind: 'response',
@@ -6627,6 +6813,10 @@ function assertKaramaPromiseFeasibility(
b.nexusInspection = cancelNexusInspection(battleInspectionContext(projected), b.nexusInspection);
delete b.preparation;
break;
case 'nexusSardaukar':
if (!b || !currentNexusSardaukar(projected)) return false;
finishNexusSardaukar(projected,true);
break;
case 'eliteStrength':
if (!b) return false;
(b.eliteBlocked ??= []).push(use.response.owner);
@@ -11347,13 +11537,24 @@ function playResidualPoison(
`${p.name} played Residual Poison against ${target.name}. ${victim.name} was randomly selected from the available leaders and sent to the Tanks. No spice is awarded, no forces are lost, and battle preparation continues.`,
{ faction: p.faction, name: 'Residual Poison' },
);
// First retire an inspection made impossible by the death itself. Testing on
reconcileChangedBattleInspections(g, 'the leader death');
if (b.preLeader && !b.preLeader.closed)
b.preLeader.ready = b.preLeader.ready.filter((id) => id !== p.id);
}
function reconcileChangedBattleInspections(g: Game, cause: string) {
const b = g.battle!;
// Retire an inspection made impossible by the opposing effect. Testing on
// a private copy without Truthtrance avoids releasing a still-feasible truth
// answer solely because an obsolete inspection was left in the trial.
const insight = b.prescience;
if (insight && 'value' in insight && insight.field !== 'leader') {
const trial = structuredClone(g);
trial.battle!.truthPromises = [];
// A different impossible disclosure must not falsely retire this one.
// The real extra answer is tested afterward against any retained native
// commitment; the trial keeps its signed first-answer relationship.
if (trial.battle!.nexusInspection?.mode === 'cunning' && trial.battle!.nexusInspection.stage === 'answered' && trial.battle!.nexusInspection.field !== 'leader')
trial.battle!.nexusInspection = nexusRule(() => reopenNexusInspection(battleInspectionContext(trial),trial.battle!.nexusInspection!));
const answering = getPlayer(
trial,
insight.player === b.attacker ? b.defender : b.attacker,
@@ -11369,7 +11570,7 @@ function playResidualPoison(
};
log(
g,
'The prior inspected element no longer permits a legal battle plan after the leader death. Its owner must answer that same element again; the earlier private value is not revealed.',
`The prior inspected element no longer permits a legal battle plan after ${cause}. Its owner must answer that same element again; the earlier private value is not revealed.`,
);
}
}
@@ -11380,12 +11581,10 @@ function playResidualPoison(
if (!findReachableBattlePlan(trial, getPlayer(trial, nexus.target))) {
b.nexusInspection = nexusRule(() => reopenNexusInspection(battleInspectionContext(g), nexus));
finishInspectionAnswers(g);
log(g, 'The leader death made the inspected element impossible. Its owner must answer the same element again; the earlier private observation is retained.');
log(g, `After ${cause}, the inspected element became impossible. Its owner must answer the same element again; the earlier private observation is retained.`);
}
}
reconcileBattlePromises(g);
if (b.preLeader && !b.preLeader.closed)
b.preLeader.ready = b.preLeader.ready.filter((id) => id !== p.id);
}
function residualPoisonView(g: Game, p: Player) {
const card = p.hand.find(
@@ -11525,7 +11724,7 @@ function stoneCompulsionBlock(g: Game, target: Player): string | null {
const opponents = stonePublicPools(g, other, target);
for (const own of stonePublicPools(g, target, other)) {
const supported = Array.from(
{ length: (own.normal + own.elite * own.eliteStrength) * 2 + 1 },
{ length: maxCombatDial(own) * 2 + 1 },
(_, n) => n / 2,
).some(
(dial) =>
@@ -11878,6 +12077,7 @@ function marketGholaIntegrity(g: Game) {
nexusFaceDancerIntegrity(g);
nexusSuboidIntegrity(g);
nexusAdvisorIntegrity(g);
nexusSardaukarIntegrity(g);
traitorDeclarationIntegrity(g);
nexusInspectionIntegrity(g);
homeworldHistoryIntegrity(g);
@@ -12019,17 +12219,14 @@ function findLegalBattlePlan(
const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
const forces = combatForces(g, p, b.territory, opponent);
const typed = g.advanced || p.faction === 'ixians';
const maxDial = typed
? forces.normal * (forces.normalFixedHalf ? 0.5 : 1) +
forces.elite * forces.eliteStrength
: forces.normal + forces.elite;
const maxDial = typed ? maxCombatDial(forces) : forces.normal + forces.elite;
const numerical: { dial: number; support: number }[] = [];
for (let dial = 0; dial <= maxDial; dial += typed ? 0.5 : 1)
for (
let support = 0;
support <=
(g.advanced && !forces.freeSupport
? Math.min(battleSupportBudget(g, p), forces.normal + forces.elite)
? Math.min(battleSupportBudget(g, p), maxCombatSupport(forces))
: 0);
support++
) {
@@ -12150,6 +12347,7 @@ function findReachableBattlePlan(
// The real table must finish its current window before it can execute a preparation action.
if (initial.battle?.nexusInspection?.stage === 'response')
initial.battle.nexusInspection = allowNexusInspection(battleInspectionContext(initial), initial.battle.nexusInspection);
if (currentNexusSardaukar(initial)?.stage === 'pending') finishNexusSardaukar(initial,false);
initial.response = null;
initial.pendingKarama = null;
// Search after Truthtrance resolves, retaining every existing or proposed
@@ -12174,6 +12372,11 @@ function findReachableBattlePlan(
!nexusSuboidsActive(node.state, node.state.nexusSuboidHistory, actor.id)
? [{ type: 'nexusSuboids', event: JSON.stringify(['nexusSuboids', node.state.turn, node.state.battle!.event, actor.id]) } as Action]
: []),
...(actor.faction === 'emperor' && !nexusSardaukarEligibility(node.state,actor) &&
node.state.nexusCards?.phase?.stage !== 'drawing' && !node.state.pendingNullentropy &&
!node.state.pendingTreacheryDiscard && !pendingNexusTraitors(node.state)
? [{type:'nexusSardaukar',event:JSON.stringify(['nexusSardaukar',node.state.turn,node.state.battle!.event,actor.id])} as Action]
: []),
...gholaPreparationActions(node.state, actor),
...cashInPreparationActions(node.state, actor),
]) {
@@ -12181,6 +12384,7 @@ function findReachableBattlePlan(
target = getPlayer(trial, p.id);
try {
if (action.type === 'nexusSuboids') commitNexusSuboids(trial, target);
else if (action.type === 'nexusSardaukar') commitNexusSardaukar(trial,target,'active');
else if (action.mode === 'special') specialKarama(trial, target, action);
else {
applyGholaEffect(trial, target, action);
@@ -12945,6 +13149,11 @@ function resolveBattle(g: Game) {
};
const winner = quote.winner ? getPlayer(g, quote.winner) : undefined;
const casualtyCommitment = quote.casualties ?? undefined;
const sardaukar = currentNexusSardaukar(g);
if (sardaukar?.stage === 'active' && casualtyCommitment && winner?.id === sardaukar.receipt.owner) {
sardaukar.casualties = {...structuredClone(casualtyCommitment),outcome:'pending'};
sardaukar.signature = nexusSardaukarSignature(sardaukar);
}
for (const payment of quote.payments) {
const player = getPlayer(g, payment.player);
player.spice -= payment.ownPayment;
@@ -13112,6 +13321,7 @@ function resolveBattle(g: Game) {
winner: winner?.id ?? null,
result: quote.result,
...(playedCardRoles ? { cardRoles: playedCardRoles } : {}),
...(sardaukar?.casualties ? {nexusSardaukarCasualties:sardaukar.receipt.event} : {}),
};
if (g.homeworlds?.custody && winner?.faction === 'atreides' &&
(quote.result === 'normal' || quote.result === 'traitor')) {
@@ -13209,7 +13419,15 @@ function settleWinnerCasualties(
automatic = false,
) {
if (to.startsWith('homeworld:')) homeworldBattleLossIntegrity(g);
const sardaukar = g.nexusSardaukarHistory?.find(record => record.receipt.battle === g.lastBattleContext?.event && record.receipt.owner === p.id);
if (sardaukar?.casualties) requireRule(sardaukar.casualties.outcome === 'pending' &&
sardaukar.casualties.options.some(option => JSON.stringify(option) === JSON.stringify(choice)),
'Choose an original Nexus Sardaukar casualty allocation.');
const losses = takeBattleLosses(g, p, to, choice);
if (sardaukar?.casualties) {
sardaukar.casualties.outcome = 'complete';
sardaukar.signature = nexusSardaukarSignature(sardaukar);
}
observeOccupation(g);
if (to.startsWith('homeworld:')) g.homeworldBattleLoss = null;
log(
@@ -14265,6 +14483,7 @@ function finishResponse(g: Game, canceled: boolean) {
const response = g.response!;
if (response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, response);
if (response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, response);
if (response.kind === 'nexusSardaukar') validateNexusSardaukarResponse(g, response);
const ecazCollectionQuote =
response.kind === 'ecazCollection'
? currentEcazCollectionQuote(g, response, canceled)
@@ -14297,6 +14516,10 @@ function finishResponse(g: Game, canceled: boolean) {
finishNexusAdvisors(g, canceled);
return;
}
if (response.kind === 'nexusSardaukar') {
finishNexusSardaukar(g,canceled);
return;
}
if (ecazCollectionQuote) {
if (canceled)
log(
@@ -17094,6 +17317,7 @@ function applyActionInner(
if (t === 'nexusFaceDancers') { playNexusFaceDancers(g, p, action); return g; }
if (t === 'nexusSuboids') { playNexusSuboids(g, p, action); return g; }
if (t === 'nexusAdvisors') { playNexusAdvisors(g, p, action); return g; }
if (t === 'nexusSardaukar') { playNexusSardaukar(g,p,action); return g; }
if (t === 'nexusTraitorReturn') {
requireRule(!g.truthtrance, 'Finish the active Truthtrance before returning Nexus cards.');
finishNexusTraitorReturn(g, p, action); return g;
@@ -20564,6 +20788,7 @@ export function viewGame(state: Game, id: string) {
nexusTleilaxu: projectedNexusTleilaxu(g, id),
nexusSuboids: projectedNexusSuboids(g, id),
nexusAdvisors: projectedNexusAdvisors(g, id),
nexusSardaukar: projectedNexusSardaukar(g,id),
nexusAtreides: nexusAtreidesOffer(g, id),
homeworldRevivalDeployment: projectedHomeworldRevivalReturn(g, id),
caladanReinforcement: projectedHomeworldVictoryReturn(g, id),
+4
View File
@@ -84,6 +84,10 @@ const cancellationSources = {
...movementSource(g),
conversion: g.nexusAdvisorHistory?.find(record => record.stage === 'pending') ?? null,
}),
nexusSardaukar: (g) => ({
battle: battleSource(g),
declaration: g.nexusSardaukarHistory?.find(record => record.stage === 'pending') ?? null,
}),
advisor: (g, response) =>
response.advisorResume === 'ambassador'
? {
+44
View File
@@ -0,0 +1,44 @@
import type { Action, GameView } from './engine';
/** The owner-only server offer verifies the five-counter army and the
* feasibility of existing commitments. Preserve its exact battle event. */
export function nexusSardaukarAction(
game: GameView,
event: string,
): Action | null {
const state = game.nexusSardaukar;
const owner = game.players.find((player) => player.id === game.me);
const battle = game.battle;
if (
game.status !== 'playing' ||
!game.advanced ||
game.phase !== 6 ||
!owner ||
owner.faction !== 'emperor' ||
owner.ally ||
game.nexusCards?.card !== 'emperor' ||
!battle ||
![battle.attacker, battle.defender].includes(owner.id) ||
battle.submitted.includes(owner.id) ||
game.automaticContinuationPending ||
game.truthtrance ||
game.response ||
game.decision ||
game.phaseOpening ||
game.nexusCards.waiting.length ||
game.nexusTraitors?.pending ||
state?.pending ||
state?.active ||
!state?.offer ||
!event ||
state.offer.event !== event ||
state.offer.blocked
)
return null;
return { type: 'nexusSardaukar', event };
}
export function nexusSardaukarBotActions(game: GameView): Action[] {
const offer = game.nexusSardaukar?.offer;
const action = offer ? nexusSardaukarAction(game, offer.event) : null;
return action ? [action] : [];
}
+123
View File
@@ -0,0 +1,123 @@
import type { FactionId } from './catalog';
export type NexusSardaukarContext = {
turn: number;
players: readonly { id: string; faction: FactionId }[];
};
export type NexusSardaukarReceipt = {
version: 1;
event: string;
owner: string;
turn: number;
battle: string;
territory: string;
normal: number;
elite: 0;
count: 5;
roster: { id: string; faction: FactionId }[];
signature: string;
};
function requireSardaukar(value: unknown, message: string): asserts value {
if (!value) throw new Error(message);
}
function identifier(value: unknown): value is string {
return (
typeof value === 'string' &&
value.trim().length > 0 &&
!['__proto__', 'constructor', 'prototype'].includes(value)
);
}
function roster(context: NexusSardaukarContext) {
requireSardaukar(
Number.isSafeInteger(context.turn) &&
context.turn > 0 &&
Array.isArray(context.players) &&
context.players.length > 0 &&
context.players.every(
(p) => p && identifier(p.id) && identifier(p.faction),
) &&
new Set(context.players.map((p) => p.id)).size ===
context.players.length &&
new Set(context.players.map((p) => p.faction)).size ===
context.players.length,
'The Sardaukar Nexus needs its original faction roster and turn.',
);
return context.players
.map(({ id, faction }) => ({ id, faction }))
.sort((a, b) => a.id.localeCompare(b.id));
}
function signature(receipt: NexusSardaukarReceipt): string {
return JSON.stringify([
receipt.version,
receipt.event,
receipt.owner,
receipt.turn,
receipt.battle,
receipt.territory,
receipt.normal,
receipt.elite,
receipt.count,
receipt.roster.map((p) => [p.id, p.faction]),
]);
}
/** Historical evidence does not depend on forces surviving this battle. */
export function validateNexusSardaukar(
context: NexusSardaukarContext,
receipt: NexusSardaukarReceipt,
): void {
const seats = roster(context);
requireSardaukar(
receipt &&
typeof receipt === 'object' &&
!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' &&
receipt.version === 1 &&
identifier(receipt.battle) &&
identifier(receipt.territory) &&
Number.isSafeInteger(receipt.turn) &&
receipt.turn > 0 &&
receipt.turn <= context.turn &&
Number.isSafeInteger(receipt.normal) &&
receipt.normal >= 5 &&
receipt.normal <= 20 &&
receipt.elite === 0 &&
receipt.count === 5 &&
seats.some((p) => p.id === receipt.owner && p.faction === 'emperor') &&
receipt.event ===
JSON.stringify([
'nexusSardaukar',
receipt.turn,
receipt.battle,
receipt.owner,
]) &&
JSON.stringify(receipt.roster) === JSON.stringify(seats) &&
receipt.signature === signature(receipt),
'The Sardaukar Nexus has lost its original battle, five-counter group or owner.',
);
}
export function createNexusSardaukar(
context: NexusSardaukarContext,
owner: string,
battle: string,
territory: string,
normal: number,
): NexusSardaukarReceipt {
const receipt: NexusSardaukarReceipt = {
version: 1,
event: JSON.stringify(['nexusSardaukar', context.turn, battle, owner]),
owner,
turn: context.turn,
battle,
territory,
normal,
elite: 0,
count: 5,
roster: roster(context),
signature: '',
};
receipt.signature = signature(receipt);
validateNexusSardaukar(context, receipt);
return receipt;
}
+8 -6
View File
@@ -3340,7 +3340,7 @@ export const RULE_TOPICS: RuleTopic[] = [
searchText: NEXUS_CARD_REFERENCE.map(card => `${card.faction} ${card.betrayal} ${card.cunning} ${card.secretAlly}`).join(' '),
category: 'Advanced & expansions',
coverage: 'Partial',
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws, Atreides inspections, Harkonnen exchanges and native Tleilaxu, Ixian and Bene Gesserit Cunning are integrated. Other effects and complete module play remain in development.',
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws, Atreides inspections, Harkonnen exchanges and native Tleilaxu, Ixian, Bene Gesserit and Emperor Cunning are integrated. Other effects and complete module play remain in development.',
steps: [
'This optional module can be combined with Basic or Advanced rules independently of the factions and other expansion modules selected. Include one Nexus Card for each of the twelve factions.',
'At the end of the entire Spice Blow and Nexus phase, if a Nexus occurred and at least one alliance exists, each unallied player may draw one card or discard their held card and draw a replacement. You may decline. The two Advanced spice piles do not grant two closing draws.',
@@ -3362,15 +3362,17 @@ export const RULE_TOPICS: RuleTopic[] = [
'In Advanced play, Bene Gesserit Cunning converts selected whole advisor territories during your own Shipment and Movement action. All your counters in each selected territory become fighters together across sectors. It spends the Nexus card once, with no spice, shipment or movement cost.',
'One Karama response covers the entire declared territory set. Cancellation keeps every selected group as advisors and leaves the Nexus card spent. Existing territory restrictions still apply; fresh accompanied advisors and storm-related conversion remain unavailable pending their rulings.',
'Bene Gesserit Secret Ally borrowed Voice remains unavailable pending its cancellation and timing interpretation. Its printed permission does not make the other unfinished effects available.',
'Emperor Cunning requires an Advanced battle with at least five ordinary Emperor counters and no actual Sardaukar, before submitting your plan. The played Nexus card opens one Karama response. Allowing it makes five ordinary counters count as Sardaukar for this battle; cancellation prevents the entire enhancement and leaves the Nexus card spent.',
'Temporary Sardaukar use the applicable strength and support rules, including the Fremen exception, but remain physically ordinary counters. Their losses go to Tanks as ordinary counters, without changing starred reserves or Tanks. Existing inspected or promised plans remain binding. The role ends after this battle; fewer-than-five groups and other Emperor Nexus modes remain unavailable.',
'Betrayal reactions await a private response timing decision. Other factions’ Nexus effects and complete module games are unfinished, so public module starts remain disabled.',
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength and whole-territory advisor conversion. Borrowed Voice, Betrayal, Tleilaxu revival and remaining effects are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation and counted advisor-territory selection with one Karama response. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All profiles handle the integrated effects through private offers, authorized choices and projected forces. Advisor choices use public opposing fighter counts. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and the integrated effect families retain explicit unresolved boundaries, including fresh advisors, storm and borrowed Voice.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; final advisor acceptance and complete module games remain separate requirements.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.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'] },
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion and temporary Emperor Sardaukar. Borrowed Voice, Betrayal, Tleilaxu revival and remaining effects are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation and counted advisor-territory selection and temporary Sardaukar with native Karama responses. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All profiles handle the integrated effects through private offers, authorized choices and projected forces. Advisor choices use public opposing fighter counts. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and the integrated effect families retain explicit unresolved boundaries, including fresh advisors, storm and borrowed Voice.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; Emperor Cunning browser checks pass while complete module games remain a separate requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.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'] },
],
},
...[
+1
View File
@@ -75,6 +75,7 @@ const RESPONSES = {
prescience: true,
nexusPrescience: true,
nexusAdvisorFlip: true,
nexusSardaukar: true,
advisor: true,
emperorIncome: true,
richesePurchaseIncome: true,
+7 -10
View File
@@ -1,6 +1,8 @@
import {
casualtyOptions,
validCombatForces,
maxCombatDial,
maxCombatSupport,
type CombatForces,
} from './combat';
@@ -28,7 +30,7 @@ function totals(forces: CombatForces, dial: number, support: number): number[] {
!Number.isInteger(dial * 2) ||
!Number.isSafeInteger(support) ||
support < 0 ||
support > forces.normal + forces.elite
support > maxCombatSupport(forces)
)
return [];
return sorted(
@@ -88,6 +90,7 @@ function allOpposingTotals(
forces.normal,
forces.elite,
forces.eliteStrength,
forces.temporaryElite ?? 0,
!!forces.normalFixedHalf,
!!forces.normalFreeSupport,
forces.freeSupport,
@@ -96,14 +99,8 @@ function allOpposingTotals(
const cached = opposingChoices.get(key);
if (cached) return cached;
const result: number[][] = [];
const maximumHalfDial =
forces.normal *
(forces.normalFixedHalf && !forces.normalFreeSupport ? 1 : 2) +
forces.elite * forces.eliteStrength * 2;
const maximumSupport = forces.freeSupport
? 0
: (forces.normalFixedHalf || forces.normalFreeSupport ? 0 : forces.normal) +
(forces.eliteFreeSupport ? 0 : forces.elite);
const maximumHalfDial = maxCombatDial(forces) * 2;
const maximumSupport = maxCombatSupport(forces);
// Use the same legality and typed-support arithmetic as sealed plans. Cache
// public pool results so repeated preflights do not repeat this enumeration.
for (let halfDial = 0; halfDial <= maximumHalfDial; halfDial++)
@@ -158,7 +155,7 @@ export function stoneBurnerCompulsionBlock(
return 'Choose valid Stone Burner combatant roles.';
if (!validCombatForces(own) || !validCombatForces(opponent))
return 'Stone Burner needs valid supported physical force pools of at most 20 tokens.';
const maximumStrength = own.normal + own.elite * own.eliteStrength;
const maximumStrength = maxCombatDial(own);
for (let halfDial = 0; halfDial <= maximumStrength * 2; halfDial++) {
if (!totals(own, halfDial / 2, 0).length) continue;
if (
+112
View File
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import {
nexusTraitorFixture,
nexusTraitorInventory,
} from './fixture-nexus-traitors';
import { nexusAllow } from './fixture-nexus-cards';
export function nexusSardaukarFixture(
options: {
seatIds?: [string, string, string];
advanced?: boolean;
opponentFaction?: 'guild' | 'atreides' | 'fremen';
normal?: number;
starred?: number;
} = {},
): Game {
let g = nexusTraitorFixture({
ownerFaction: 'emperor',
opponentFaction:
options.opponentFaction === 'fremen'
? undefined
: options.opponentFaction,
advanced: options.advanced ?? true,
seatIds: options.seatIds,
phase: 6,
});
const owner = g.players[0],
opponent = g.players[options.opponentFaction === 'fremen' ? 2 : 1];
const normal = options.normal ?? 7,
starred = options.starred ?? 0;
assert.ok(normal >= 0 && normal <= 12 && starred >= 0 && starred <= 5);
const cards = g.nexusCards!.cards!;
const index = cards.deck.indexOf('emperor');
assert.ok(index >= 0);
cards.deck[index] = cards.hands[owner.id]!;
cards.hands[owner.id] = 'emperor';
for (const p of g.players) {
p.forces = {};
p.reserves = 20;
p.tanks = 0;
if (p.elites) {
p.elites.forces = {};
p.elites.tanks = 0;
p.elites.reserves = p.faction === 'emperor' ? 5 : 3;
}
}
owner.forces = { 'pasty_mesa:5': normal + starred, 'hagga_basin:12': 3 };
owner.reserves = 17 - normal - starred;
if (owner.elites) {
owner.elites.reserves = 5 - starred;
if (starred) owner.elites.forces = { 'pasty_mesa:5': starred };
}
opponent.forces = { 'pasty_mesa:5': 2, 'hagga_basin:12': 2 };
opponent.reserves = 16;
Object.assign(g, {
active: owner.id,
order: g.players.map((p) => p.id),
ready: [],
response: null,
decision: null,
phaseOpening: null,
});
g = applyAction(g, owner.id, {
type: 'chooseBattle',
territory: 'pasty_mesa',
target: opponent.id,
});
for (const id of [owner.id, opponent.id])
if (g.battle!.preLeader && !g.battle!.preLeader.closed)
g = applyAction(g, id, {
type: 'battlePreparationReady',
event: g.battle!.event,
});
nexusSardaukarInventory(g);
return g;
}
export const nexusSardaukarPlayer = (g: Game) =>
g.players.find((p) => p.faction === 'emperor')!;
export function nexusSardaukarInventory(g: Game): void {
nexusTraitorInventory(g);
const emperor = nexusSardaukarPlayer(g);
if (!emperor.elites) {
assert.equal(g.advanced, false);
return;
}
assert.equal(
emperor.elites!.reserves +
emperor.elites!.tanks +
Object.values(emperor.elites!.forces).reduce((sum, n) => sum + n, 0),
5,
);
}
export function prepareSardaukarBattle(g: Game): Game {
while (g.battle!.preparation)
g = applyAction(g, g.battle!.preparation.owner, {
type: 'declineBattlePower',
});
if (g.decision?.kind === 'fullPlanOffer')
g = applyAction(g, g.decision.player, { type: 'decision', decline: true });
return g;
}
export function beginNexusSardaukar(g: Game): Game {
const owner = nexusSardaukarPlayer(g);
const offer = viewGame(g, owner.id).nexusSardaukar!.offer!;
assert.equal(offer.blocked, null);
return applyAction(g, owner.id, {
type: 'nexusSardaukar',
event: offer.event,
});
}
export const allowNexusSardaukar = nexusAllow;
+1 -1
View File
@@ -123,7 +123,7 @@ void test('the owned face highlights its mode from the public roster and all thr
void test('controls expose public counts and waiting players, disable while busy, and offer no effect play action', () => {
const view = fixture();
const html = markup(view, true);
assert.match(html, /Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian and Bene Gesserit Cunning/);
assert.match(html, /Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit and Emperor Cunning/);
assert.match(html, /other effects remain unavailable/);
assert.doesNotMatch(html, /other card effects are not playable yet/);
assert.match(html, /10 in deck · 0 discarded · 2 held/);
+84
View File
@@ -0,0 +1,84 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame } from '../game/engine';
import { DIFFICULTIES } from '../game/bot-profiles';
import { botActions } from '../game/bots';
import { casualtyOptions } from '../game/combat';
import {
nexusSardaukarFixture,
nexusSardaukarInventory,
beginNexusSardaukar,
allowNexusSardaukar,
prepareSardaukarBattle,
} from './fixture-nexus-sardaukar';
void test('all profiles declare actual Cunning then seal a physically legal boosted plan after allowance', () => {
const initial = nexusSardaukarFixture();
for (const difficulty of DIFFICULTIES) {
const v = viewGame(initial, 'p');
v.players[0].bot = difficulty;
const before = structuredClone(v);
const declaration = botActions(v)[0];
assert.equal(declaration.type, 'nexusSardaukar');
assert.deepEqual(v, before);
const g = prepareSardaukarBattle(
allowNexusSardaukar(applyAction(initial, 'p', declaration)),
);
const boosted = viewGame(g, 'p');
boosted.players[0].bot = difficulty;
const plans = botActions(boosted);
assert.ok(plans.length);
for (const plan of plans) {
assert.equal(plan.type, 'battlePlan');
const losses = casualtyOptions(
boosted.battle!.ownForces!,
Number(plan.dial),
Number(plan.support),
);
assert.ok(losses.length);
assert.ok(losses.every((loss) => loss.elite === 0 && loss.normal <= 7));
}
const sealed = applyAction(g, 'p', plans[0]);
nexusSardaukarInventory(sealed);
assert.deepEqual(sealed.players[0].elites, initial.players[0].elites);
assert.equal(sealed.battle!.plans.p.dial, plans[0].dial);
}
});
void test('Emperor offer policy never reads private cards or an opposing sealed plan', () => {
const v = viewGame(nexusSardaukarFixture(), 'p');
v.players[0].bot = 'Brutal';
for (const p of v.players)
for (const field of ['hand', 'traitors', 'faceDancers', 'spice'])
Object.defineProperty(p, field, {
get() {
throw new Error(`Private ${field} read`);
},
});
Object.defineProperty(v.battle!, 'plans', {
get() {
throw new Error('Read sealed plan');
},
});
assert.equal(botActions(v)[0].type, 'nexusSardaukar');
});
void test('native enhancement uses ordinary Karama response policy before any boosted planning', () => {
const initial = nexusSardaukarFixture();
const index = initial.deck.findIndex((c) => c.effect === 'karama');
const card = initial.deck.splice(index, 1)[0];
initial.players[1].hand.push(card);
const g = beginNexusSardaukar(initial);
for (const difficulty of DIFFICULTIES) {
const v = viewGame(g, 'q');
v.players.find((p) => p.id === 'q')!.bot = difficulty;
const action = botActions(v)[0];
assert.deepEqual(
action,
difficulty === 'Easy' || difficulty === 'Medium'
? { type: 'passResponse' }
: { type: 'card', card: card.id, mode: 'cancel' },
);
assert.equal(v.nexusSardaukar!.active, false);
}
});
@@ -0,0 +1,281 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Game,
} from '../game/engine';
import { unitStore } from './fixture-nexus-room-store';
import {
nexusSardaukarFixture,
nexusSardaukarInventory,
beginNexusSardaukar,
allowNexusSardaukar,
prepareSardaukarBattle,
} from './fixture-nexus-sardaukar';
const clock = { now: () => 10000, sleep: async () => {} };
const choice = (index: number) => ({ type: 'decision', choice: index });
function resolvedVictory(): Game {
let g = prepareSardaukarBattle(
allowNexusSardaukar(beginNexusSardaukar(nexusSardaukarFixture())),
);
const [owner, opponent] = g.players;
const leader = [...owner.leaders].sort((a, b) => b.strength - a.strength)[0];
const opposing = [...opponent.leaders].sort(
(a, b) => a.strength - b.strength,
)[0];
g = applyAction(g, owner.id, {
type: 'battlePlan',
dial: 6,
support: 3,
leader: leader.id,
});
g = applyAction(g, opponent.id, {
type: 'battlePlan',
dial: 0,
leader: opposing.id,
});
for (const id of [owner.id, opponent.id])
g = applyAction(g, id, { type: 'traitorCall', call: false });
assert.equal(g.battle, null);
assert.equal(g.decision?.kind, 'battleLosses');
assert.deepEqual(
g.decision.options.map((loss) => [loss.normal, loss.elite]),
[
[3, 0],
[4, 0],
[5, 0],
],
);
nexusSardaukarInventory(g);
return g;
}
async function fixture() {
const store = unitStore();
const made = await store.rooms.createRoom(
'Sardaukar casualties',
'emperor',
true,
[],
);
const code = made.view.code;
const tokens = [made.token];
for (const faction of ['guild', 'fremen'] as const)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
// Bind disposable authentication seats to already-final fixture identities;
// no game identity or signed source history is rewritten after setup.
for (const [index, token] of tokens.entries()) {
const auth = await store.rooms.authenticate(code, token);
store.sqlite
.prepare(
'UPDATE seats SET player_id = ? WHERE room_code = ? AND player_id = ?',
)
.run(['p', 'q', 'r'][index], code, auth.playerId);
}
const auths = await Promise.all(
tokens.map((token) => store.rooms.authenticate(code, token)),
);
const initial = resolvedVictory();
initial.code = code;
initial.version = (await store.rooms.readRoom(code)).version;
store.sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(initial), initial.version, code);
store.writes.length = 0;
return { ...store, code, auths, initial };
}
type Fixture = Awaited<ReturnType<typeof fixture>>;
const row = (f: Fixture) =>
f.sqlite
.prepare('SELECT state,version FROM rooms WHERE code = ?')
.get(f.code);
async function restored(f: Fixture, g: Game) {
const before = row(f);
assert.deepEqual(normalizeAutomaticGame(structuredClone(g)), g);
for (const auth of f.auths) {
const projected = await f.restart().readSeatView(f.code, auth);
assert.deepEqual(projected, viewGame(g, auth.playerId));
assert.doesNotMatch(
JSON.stringify(projected),
/nexusSardaukarHistory|nexusSardaukarCasualties/,
);
}
await f.restart().continueRoomAutomatic(f.code, clock);
assert.deepEqual(row(f), before);
nexusSardaukarInventory(g);
}
void test('SQLite restores each committed Emperor casualty choice before and after ordinary-counter settlement', async () => {
for (const [index, losses] of [3, 4, 5].entries()) {
const f = await fixture();
try {
await restored(f, f.initial);
await f
.restart()
.act(f.code, f.auths[0], f.initial.version, choice(index), clock);
const done = await f.restart().readRoom(f.code);
assert.equal(done.players[0].tanks, losses);
assert.equal(done.players[0].forces['pasty_mesa:5'], 7 - losses);
assert.equal(done.players[0].elites!.tanks, 0);
assert.equal(done.players[0].elites!.reserves, 5);
assert.equal(
done.nexusSardaukarHistory![0].casualties!.outcome,
'complete',
);
await restored(f, done);
const before = row(f);
await assert.rejects(
f.restart().act(f.code, f.auths[0], done.version, choice(index), clock),
);
assert.deepEqual(row(f), before);
} finally {
f.sqlite.close();
}
}
});
void test('SQLite competing different legal casualty allocations settle one physical group exactly once', async () => {
const f = await fixture();
try {
let arrived = 0;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
f.hooks.beforeWrite = async () => {
if (++arrived === 2) release();
await gate;
};
const attempts = await Promise.allSettled(
[0, 2].map((index) =>
f
.restart()
.act(f.code, f.auths[0], f.initial.version, choice(index), clock),
),
);
delete f.hooks.beforeWrite;
assert.equal(
attempts.filter((result) => result.status === 'fulfilled').length,
1,
);
assert.deepEqual(
f.writes.map((write) => write.changes).sort((a, b) => a - b),
[0, 1],
);
const done = await f.restart().readRoom(f.code);
assert.equal(done.version, f.initial.version + 1);
assert.ok([3, 5].includes(done.players[0].tanks));
await restored(f, done);
const before = row(f);
await assert.rejects(
f.restart().act(f.code, f.auths[0], f.initial.version, choice(0), clock),
);
assert.deepEqual(row(f), before);
} finally {
f.sqlite.close();
}
});
void test('SQLite edited winner costs, effective roles or missing casualty proof reject every read and action without writes', async () => {
const f = await fixture();
try {
const edits: [string, (g: Game) => void][] = [
[
'available but illegal normal loss',
(g) => {
if (g.decision?.kind === 'battleLosses')
g.decision.options[0].normal = 6;
},
],
[
'support allocation',
(g) => {
if (g.decision?.kind === 'battleLosses')
g.decision.options[0].paidNormal = 0;
},
],
[
'effective temporary role',
(g) => {
delete g.nexusSardaukarHistory![0].casualties!.forces.temporaryElite;
},
],
[
'forged completion',
(g) => {
g.nexusSardaukarHistory![0].casualties!.outcome = 'complete';
},
],
[
'missing choice',
(g) => {
g.decision = null;
},
],
[
'missing casualty receipt',
(g) => {
delete g.nexusSardaukarHistory![0].casualties;
},
],
[
'missing independent battle marker',
(g) => {
delete g.lastBattleContext!.nexusSardaukarCasualties;
},
],
[
'missing history',
(g) => {
delete g.nexusSardaukarHistory;
},
],
[
'missing history and latest outcome',
(g) => {
delete g.nexusSardaukarHistory;
delete g.nexusSardaukarLast;
},
],
];
for (const [label, edit] of edits) {
const bad = structuredClone(f.initial);
edit(bad);
const snapshot = JSON.stringify(bad);
for (const auth of f.auths)
assert.throws(() => viewGame(bad, auth.playerId), /./, label);
assert.throws(() => normalizeAutomaticGame(bad), /./, label);
assert.throws(() => applyAction(bad, 'p', choice(0)), /./, label);
assert.equal(JSON.stringify(bad), snapshot, label);
f.sqlite
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
.run(snapshot, f.code);
f.writes.length = 0;
const before = row(f);
for (const auth of f.auths)
await assert.rejects(
f.restart().readSeatView(f.code, auth),
/./,
label,
);
await assert.rejects(
f.restart().act(f.code, f.auths[0], bad.version, choice(0), clock),
/./,
label,
);
await f
.restart()
.continueRoomAutomatic(f.code, clock)
.catch(() => {});
assert.deepEqual(row(f), before, label);
assert.equal(f.writes.length, 0, label);
}
} finally {
f.sqlite.close();
}
});
+260
View File
@@ -0,0 +1,260 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
casualtyOptions,
maxCombatDial,
maxCombatSupport,
validCombatForces,
type CombatForces,
} from '../game/combat';
import {
stoneBurnerComparison,
stoneBurnerPlanBlock,
} from '../game/stone-burner';
import {
quoteBattleResolution,
type BattleResolutionInput,
} from '../game/battle-resolution-quote';
import { leaders } from '../game/cards';
const pool = (patch: Partial<CombatForces> = {}): CombatForces => ({
normal: 7,
elite: 0,
temporaryElite: 5,
eliteStrength: 2,
freeSupport: false,
...patch,
});
void test('temporary Sardaukar produce alternative ordinary casualties and pay only for physical counters', () => {
const forces = pool(),
before = structuredClone(forces);
assert.deepEqual(casualtyOptions(forces, 2, 0), [
{ normal: 2, elite: 0, paidNormal: 0, paidElite: 0 },
{ normal: 3, elite: 0, paidNormal: 0, paidElite: 0 },
]);
assert.deepEqual(casualtyOptions(forces, 2, 1), [
{ normal: 1, elite: 0, paidNormal: 1, paidElite: 0 },
{ normal: 2, elite: 0, paidNormal: 1, paidElite: 0 },
]);
assert.deepEqual(casualtyOptions(forces, 12, 7), [
{ normal: 7, elite: 0, paidNormal: 7, paidElite: 0 },
]);
assert.equal(maxCombatDial(forces), 12);
assert.equal(maxCombatSupport(forces), 7);
assert.deepEqual(forces, before);
});
void test('Salusa support, ordinary free support and canceled Sardaukar strength retain distinct role arithmetic', () => {
const salusa = pool({ eliteFreeSupport: true });
assert.equal(maxCombatSupport(salusa), 2);
assert.equal(maxCombatDial(salusa), 12);
assert.deepEqual(casualtyOptions(salusa, 12, 2), [
{ normal: 7, elite: 0, paidNormal: 2, paidElite: 0 },
]);
assert.deepEqual(casualtyOptions(salusa, 12, 7), []);
const normalFree = pool({ normalFreeSupport: true });
assert.equal(maxCombatSupport(normalFree), 5);
assert.deepEqual(casualtyOptions(normalFree, 12, 5), [
{ normal: 7, elite: 0, paidNormal: 5, paidElite: 0 },
]);
const suppressed = pool({ eliteStrength: 1 });
assert.equal(maxCombatDial(suppressed), 7);
for (let half = 0; half <= 14; half++)
for (let support = 0; support <= 7; support++) {
assert.deepEqual(
casualtyOptions(suppressed, half / 2, support),
casualtyOptions(
{ ...suppressed, temporaryElite: undefined },
half / 2,
support,
),
);
}
assert.equal(maxCombatSupport(pool({ freeSupport: true })), 0);
assert.deepEqual(casualtyOptions(pool({ freeSupport: true }), 12, 0), [
{ normal: 7, elite: 0, paidNormal: 0, paidElite: 0 },
]);
});
/** Independent labeled-counter choices: survive, die unsupported, die supported. */
function assignments(forces: CombatForces) {
let states = new Set(['0:0:0']);
for (let counter = 0; counter < forces.normal; counter++) {
const temporary = counter < (forces.temporaryElite ?? 0);
const strength = temporary ? forces.eliteStrength : 1;
const free =
forces.freeSupport ||
(temporary ? forces.eliteFreeSupport : forces.normalFreeSupport);
const fixedHalf =
!temporary && forces.normalFixedHalf && !forces.normalFreeSupport;
const choices = [
[0, 0, 0],
[1, strength * (free && !fixedHalf ? 2 : 1), 0],
];
if (!free && !fixedHalf) choices.push([1, strength * 2, 1]);
const next = new Set<string>();
for (const previous of states) {
const [loss, half, paid] = previous.split(':').map(Number);
for (const [n, h, p] of choices)
next.add(`${loss + n}:${half + h}:${paid + p}`);
}
states = next;
}
return [...states].map((state) => state.split(':').map(Number));
}
void test('mapped losses match independent counter enumeration across mixed roles and every support exemption', () => {
for (const normal of [0, 1, 2, 4, 6])
for (let temporaryElite = 0; temporaryElite <= normal; temporaryElite++)
for (const eliteStrength of [1, 2] as const)
for (const flags of [
{},
{ eliteFreeSupport: true },
{ normalFreeSupport: true },
{ normalFixedHalf: true },
{ normalFixedHalf: true, normalFreeSupport: true },
{ freeSupport: true },
{ freeSupport: true, normalFixedHalf: true },
{ eliteFreeSupport: true, normalFreeSupport: true },
]) {
const forces = pool({
normal,
temporaryElite,
eliteStrength,
...flags,
});
const expected = assignments(forces);
assert.equal(
maxCombatDial(forces),
Math.max(...expected.map(([, half]) => half)) / 2,
);
assert.equal(
maxCombatSupport(forces),
Math.max(...expected.map(([, , paid]) => paid)),
);
for (let half = 0; half <= normal * 4; half++)
for (let support = 0; support <= normal; support++) {
const losses = expected
.filter(([, h, p]) => h === half && p === support)
.map(([n]) => n)
.sort((a, b) => a - b);
assert.deepEqual(
casualtyOptions(forces, half / 2, support),
losses.map((normal) => ({
normal,
elite: 0,
paidNormal: support,
paidElite: 0,
})),
JSON.stringify([forces, half, support]),
);
}
}
});
void test('Stone Burner compares physical survivors and separates temporary-role cache entries', () => {
const own: CombatForces = {
normal: 4,
elite: 0,
eliteStrength: 1,
freeSupport: true,
};
const temporary = pool({ normal: 3, temporaryElite: 2 });
const ordinary = { ...temporary, temporaryElite: 0 };
assert.deepEqual(stoneBurnerComparison(temporary, 2, 0, own, 2, 0), {
winner: 'defender',
attacker: [1],
defender: [2],
});
assert.deepEqual(stoneBurnerComparison(temporary, 2, 1, own, 2, 0), {
winner: null,
attacker: [1, 2],
defender: [2],
});
for (let repeat = 0; repeat < 3; repeat++) {
assert.equal(stoneBurnerPlanBlock(own, 3, 0, ordinary, 'attacker'), null);
assert.match(
stoneBurnerPlanBlock(own, 3, 0, temporary, 'attacker')!,
/different legal force allocations/,
);
}
});
function resolution(): BattleResolutionInput {
const a = leaders('emperor')[0],
d = leaders('guild')[0];
return {
advanced: true,
turn: 2,
territory: 'pasty_mesa',
attacker: {
id: 'e',
faction: 'emperor',
spice: 7,
hand: [],
leader: a,
forces: pool(),
plan: { dial: 12, support: 7, leader: a.id, weapon: null, defense: null },
},
defender: {
id: 'g',
faction: 'guild',
spice: 0,
hand: [],
leader: d,
forces: { normal: 1, elite: 0, eliteStrength: 1, freeSupport: false },
plan: { dial: 0, support: 0, leader: d.id, weapon: null, defense: null },
},
voters: [
{ id: 'e', beneficiary: 'e', called: false, traitors: [] },
{ id: 'g', beneficiary: 'g', called: false, traitors: [] },
],
participants: [
{ id: 'e', faction: 'emperor' },
{ id: 'g', faction: 'guild' },
],
physicalCards: [],
pendingAuditorPresent: false,
pendingRetentionPresent: false,
};
}
void test('battle quote retains the physical pool, ordinary casualties and exact support payment', () => {
const input = resolution(),
before = structuredClone(input),
quote = quoteBattleResolution(input);
assert.equal(quote.winner, 'e');
assert.equal(quote.payments.find((p) => p.player === 'e')!.cost, 7);
assert.deepEqual(quote.casualties!.forces, input.attacker.forces);
assert.deepEqual(quote.casualties!.options, [
{ normal: 7, elite: 0, paidNormal: 7, paidElite: 0 },
]);
assert.deepEqual(input, before);
});
void test('invalid temporary populations reject before calculation without changing physical custody', () => {
for (const temporaryElite of [-1, 0.5, 8, 21, NaN, Infinity, '5', null]) {
const forces = { ...pool(), temporaryElite } as unknown as CombatForces;
assert.equal(validCombatForces(forces), false);
assert.deepEqual(casualtyOptions(forces, 2, 0), []);
assert.equal(maxCombatDial(forces), 0);
assert.equal(maxCombatSupport(forces), 0);
assert.match(
stoneBurnerPlanBlock(forces, 2, 0, pool(), 'attacker')!,
/valid supported physical/,
);
const input = resolution();
input.attacker.forces = forces;
const before = structuredClone(input);
assert.throws(() => quoteBattleResolution(input), /valid force/);
assert.deepEqual(input, before);
}
assert.equal(validCombatForces(pool({ elite: 1 })), false);
assert.equal(validCombatForces(pool({ normal: 21 })), false);
assert.equal(
validCombatForces(pool({ normal: 20, temporaryElite: 20 })),
true,
);
assert.equal(maxCombatDial(pool({ normal: 20, temporaryElite: 20 })), 40);
});
+205
View File
@@ -0,0 +1,205 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame, type GameView } from '../game/engine';
import { BattlePromises } from '../components/battle-promises';
import { nexusAllow, nexusReload } from './fixture-nexus-cards';
import { NexusSardaukar } from '../components/nexus-sardaukar';
import { nexusSardaukarAction } from '../game/nexus-sardaukar-options';
import {
nexusSardaukarFixture,
beginNexusSardaukar,
allowNexusSardaukar,
prepareSardaukarBattle,
} from './fixture-nexus-sardaukar';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(
specifier === 'next/image'
? 'vinext/shims/image'
: specifier === 'next/link'
? 'vinext/shims/link'
: specifier,
context,
);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries(
[...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map((m) => [m[1], m[1]]),
);
return {
format: 'module',
source: `export default ${JSON.stringify(classes)}`,
shortCircuit: true,
};
},
});
const { GameTable } = await import('../components/game-table');
aliases.deregister();
const html = (game: GameView, busy = false) =>
renderToStaticMarkup(createElement(NexusSardaukar, { game, busy, act() {} }));
void test('Emperor Cunning offers one event-bound five-counter play with explicit physical identity', () => {
const v = viewGame(nexusSardaukarFixture(), 'p');
const offer = v.nexusSardaukar!.offer!;
assert.deepEqual(nexusSardaukarAction(v, offer.event), {
type: 'nexusSardaukar',
event: offer.event,
});
assert.match(html(v), /Use five forces as Sardaukar/);
assert.match(html(v), /ordinary physical identity, including when lost/);
assert.doesNotMatch(html(v), /disabled=""|checkbox/);
assert.match(html(v, true), /disabled=""/);
assert.equal(nexusSardaukarAction(v, 'stale'), null);
offer.blocked = 'An earlier Battle Plan commitment would become impossible.';
assert.equal(nexusSardaukarAction(v, offer.event), null);
assert.match(html(v), /earlier Battle Plan commitment/);
});
void test('Emperor control retains actor, Advanced, unsealed plan and interruption fences', () => {
const initial = viewGame(nexusSardaukarFixture(), 'p');
for (const mutate of [
(v: GameView) => {
v.me = 'q';
},
(v: GameView) => {
v.advanced = false;
},
(v: GameView) => {
v.phase = 5;
},
(v: GameView) => {
v.nexusCards!.card = null;
},
(v: GameView) => {
v.battle!.submitted = ['p'];
},
(v: GameView) => {
v.decision = { kind: 'nullentropy', player: 'p' };
},
(v: GameView) => {
v.truthtrance = {
stage: 'priority',
queue: [],
passed: [],
question: null,
};
},
(v: GameView) => {
v.automaticContinuationPending = true;
},
(v: GameView) => {
v.nexusCards!.waiting = ['q'];
},
]) {
const v = structuredClone(initial);
mutate(v);
assert.equal(nexusSardaukarAction(v, v.nexusSardaukar!.offer!.event), null);
}
for (const options of [{ normal: 4 }, { starred: 1 }]) {
const v = viewGame(nexusSardaukarFixture(options), 'p');
const offer = v.nexusSardaukar!.offer!;
assert.ok(offer.blocked);
assert.equal(nexusSardaukarAction(v, offer.event), null);
assert.ok(html(v).includes(offer.blocked));
}
});
void test('public pending and active notices avoid private hands and identify ordinary-counter losses', () => {
const initial = nexusSardaukarFixture();
const index = initial.deck.findIndex((c) => c.effect === 'karama');
initial.players[1].hand.push(initial.deck.splice(index, 1)[0]);
const g = beginNexusSardaukar(initial);
assert.equal(g.response?.kind, 'nexusSardaukar');
for (const id of ['p', 'q', 'r']) {
const v = viewGame(g, id);
assert.equal(v.nexusSardaukar!.active, false);
assert.equal(v.battle!.ownForces?.temporaryElite, undefined);
assert.equal(v.battle!.opponentForces?.temporaryElite, undefined);
Object.defineProperty(v.nexusSardaukar!, 'offer', {
get() {
throw new Error('Private offer read');
},
});
for (const p of v.players)
Object.defineProperty(p, 'hand', {
get() {
throw new Error('Private hand read');
},
});
assert.match(html(v), /Karama may cancel the entire enhancement/);
}
const allowed = allowNexusSardaukar(g);
const observer = viewGame(allowed, 'q');
assert.equal(observer.battle!.opponentForces?.temporaryElite, 5);
assert.match(html(observer), /Five ordinary Emperor counters/);
assert.match(html(observer), /ordinary Tanks/);
assert.doesNotMatch(html(observer), /<button/);
});
void test('actual battle editor uses temporary strength twelve and physical support seven without starred losses', () => {
const g = prepareSardaukarBattle(
allowNexusSardaukar(beginNexusSardaukar(nexusSardaukarFixture())),
);
const v = viewGame(g, 'p');
assert.equal(v.battle!.ownForces!.normal, 7);
assert.equal(v.battle!.ownForces!.elite, 0);
assert.equal(v.battle!.ownForces!.temporaryElite, 5);
const text = renderToStaticMarkup(
createElement(GameTable, {
game: v,
send: async () => {},
onExit() {},
busy: false,
}),
);
assert.match(text, /max="12"/);
assert.match(text.match(/<input[^>]*id="battle-spice"[^>]*>/)![0], /max="7"/);
assert.match(
text,
/Their losses are ordinary counters, not starred Sardaukar/,
);
});
void test('real future Prescience preparation names Emperor Cunning without spending it during the search', () => {
let g = nexusSardaukarFixture({ opponentFaction: 'atreides' });
const cardIndex = g.deck.findIndex((card) => card.effect === 'karama');
assert.ok(cardIndex >= 0);
g.players[1].hand.push(g.deck.splice(cardIndex, 1)[0]);
g = applyAction(g, 'q', { type: 'prescience', field: 'dial' });
g = nexusAllow(g);
g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 10 });
g = prepareSardaukarBattle(nexusReload(g));
const before = JSON.stringify(g);
const v = viewGame(g, 'p');
const action = v.battle!.compliantPreparation!.actions[0];
assert.equal(action.type, 'nexusSardaukar');
assert.equal(g.nexusCards!.cards!.hands.p, 'emperor');
const text = renderToStaticMarkup(
createElement(BattlePromises, {
game: v,
fill() {},
act() {},
busy: false,
}),
);
assert.match(
text,
/Declare Emperor Cunning to count five ordinary forces as Sardaukar for this battle/,
);
assert.doesNotMatch(text, /Ghola|NaN/);
assert.equal(JSON.stringify(g), before);
for (const id of ['q', 'r'])
assert.equal(viewGame(g, id).battle!.compliantPreparation, null);
g = applyAction(g, 'p', action);
assert.equal(g.response?.kind, 'nexusSardaukar');
assert.equal(viewGame(g, 'p').nexusSardaukar!.active, false);
g = allowNexusSardaukar(g);
assert.equal(viewGame(g, 'p').nexusSardaukar!.active, true);
assert.equal(g.battle!.prescience!.value, 10);
});
+415
View File
@@ -0,0 +1,415 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
viewGame,
normalizeAutomaticGame,
type Game,
} from '../game/engine';
import { casualtyOptions, maxCombatDial } from '../game/combat';
import {
createNexusSardaukar,
validateNexusSardaukar,
} from '../game/nexus-sardaukar';
import {
nexusSardaukarFixture,
nexusSardaukarPlayer,
nexusSardaukarInventory,
prepareSardaukarBattle,
beginNexusSardaukar,
allowNexusSardaukar,
} from './fixture-nexus-sardaukar';
import { nexusReload, nexusReject, nexusAllow } from './fixture-nexus-cards';
import { drawNexusCard } from '../game/nexus-cards';
function hold(g: Game, id: string, effect: string): string {
const index = g.deck.findIndex(
(card) => card.effect === effect || card.kind === effect,
);
assert.ok(index >= 0);
const card = g.deck.splice(index, 1)[0];
g.players.find((p) => p.id === id)!.hand.push(card);
return card.id;
}
function activate(g: Game): Game {
return allowNexusSardaukar(beginNexusSardaukar(g));
}
function request(g: Game) {
const p = nexusSardaukarPlayer(g);
return {
type: 'nexusSardaukar',
event: viewGame(g, p.id).nexusSardaukar!.offer!.event,
};
}
function resolveOrdinary(state: Game, dial = 6, support = 3): Game {
let g = prepareSardaukarBattle(state);
const owner = nexusSardaukarPlayer(g),
target = g.players.find((p) => p.id === g.battle!.defender)!;
const leader = [...owner.leaders].sort((a, b) => b.strength - a.strength)[0];
const opposing = [...target.leaders].sort(
(a, b) => a.strength - b.strength,
)[0];
g = applyAction(g, owner.id, {
type: 'battlePlan',
dial,
support,
leader: leader.id,
});
g = applyAction(g, target.id, {
type: 'battlePlan',
dial: 0,
leader: opposing.id,
});
for (const id of [owner.id, target.id])
g = applyAction(g, id, { type: 'traitorCall', call: false });
return g;
}
void test('Sardaukar receipt binds the original five-counter battle role without depending on later force custody', () => {
const g = nexusSardaukarFixture();
const receipt = createNexusSardaukar(
g,
'p',
g.battle!.event!,
'pasty_mesa',
7,
);
assert.equal(receipt.elite, 0);
assert.equal(receipt.count, 5);
validateNexusSardaukar({ turn: g.turn + 1, players: g.players }, receipt);
for (const patch of [
{ normal: 4 },
{ normal: 8 },
{ count: 4 },
{ elite: 1 },
{ owner: 'q' },
{ territory: 'hagga_basin' },
{ battle: 'other' },
{ version: 0 },
]) {
const bad = { ...receipt, ...patch };
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));
const changed = g.players.map((p) => ({ id: p.id, faction: p.faction }));
changed[1].id = 'another-seat';
assert.throws(() =>
validateNexusSardaukar({ turn: g.turn, players: changed }, receipt),
);
});
void test('five temporary Sardaukar enhance an owned battle while every actual force and starred counter stays in place', () => {
let g = nexusSardaukarFixture();
hold(g, 'q', 'karama');
const before = structuredClone(g.players);
g = beginNexusSardaukar(g);
assert.equal(g.response?.kind, 'nexusSardaukar');
g = allowNexusSardaukar(g);
assert.deepEqual(g.players, before);
const forces = viewGame(g, 'p').battle!.ownForces!;
assert.equal(forces.normal, 7);
assert.equal(forces.elite, 0);
assert.equal(forces.temporaryElite, 5);
assert.equal(maxCombatDial(forces), 12);
assert.deepEqual(
casualtyOptions(forces, 6, 3).map((x) => [x.normal, x.elite]),
[
[3, 0],
[4, 0],
[5, 0],
],
);
for (const p of g.players)
assert.equal(viewGame(g, p.id).nexusSardaukar!.active, true);
nexusSardaukarInventory(g);
});
void test('temporary Sardaukar retain the ordinary Fremen exception and need support for full strength', () => {
const g = activate(nexusSardaukarFixture({ opponentFaction: 'fremen' }));
const forces = viewGame(g, 'p').battle!.ownForces!;
assert.equal(forces.temporaryElite, 5);
assert.equal(forces.eliteStrength, 1);
assert.equal(forces.freeSupport, false);
assert.equal(maxCombatDial(forces), 7);
assert.deepEqual(
casualtyOptions(forces, 5, 5).map((x) => [x.normal, x.elite]),
[[5, 0]],
);
assert.deepEqual(casualtyOptions(forces, 5, 0), []);
});
void test('Karama stops the new role grant entirely and leaves all physical forces normal', () => {
let g = nexusSardaukarFixture();
const karama = hold(g, 'q', 'karama');
const original = structuredClone(nexusSardaukarPlayer(g).elites);
g = beginNexusSardaukar(g);
g = applyAction(g, 'q', { type: 'card', card: karama, mode: 'cancel' });
g = allowNexusSardaukar(g);
const forces = viewGame(g, 'p').battle!.ownForces!;
assert.equal(forces.temporaryElite ?? 0, 0);
assert.equal(maxCombatDial(forces), 7);
assert.deepEqual(nexusSardaukarPlayer(g).elites, original);
assert.equal(g.discard.filter((c) => c.id === karama).length, 1);
assert.equal(
g.nexusCards!.cards!.discard.filter((c) => c === 'emperor').length,
1,
);
nexusSardaukarInventory(g);
});
void test('every winning casualty choice removes ordinary counters and never invents Sardaukar in the Tanks', () => {
const initial = activate(nexusSardaukarFixture());
for (const [index, losses] of [3, 4, 5].entries()) {
let g = resolveOrdinary(nexusReload(initial));
assert.equal(g.decision?.kind, 'battleLosses');
g = applyAction(g, 'p', { type: 'decision', choice: index });
const p = nexusSardaukarPlayer(g);
assert.equal(p.tanks, losses);
assert.equal(p.forces['pasty_mesa:5'], 7 - losses);
assert.equal(p.elites!.tanks, 0);
assert.equal(p.elites!.reserves, 5);
assert.deepEqual(p.elites!.forces, {});
nexusSardaukarInventory(g);
}
});
void test('the completed grant expires before a second battle in the same turn', () => {
let g = resolveOrdinary(activate(nexusSardaukarFixture()), 0, 0);
assert.equal(g.battle, null);
g = applyAction(g, 'p', {
type: 'chooseBattle',
territory: 'hagga_basin',
target: 'q',
});
assert.equal(viewGame(g, 'p').battle!.ownForces!.temporaryElite ?? 0, 0);
assert.equal(viewGame(g, 'p').nexusSardaukar!.active, false);
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 }]) {
const g = nexusSardaukarFixture(options);
assert.ok(viewGame(g, 'p').nexusSardaukar!.offer!.blocked);
nexusReject(g, 'p', request(g), /Basic|Advanced|five|5|Sardaukar|ruling/i);
}
const g = nexusSardaukarFixture(),
action = request(g);
nexusReject(g, 'q', action, /Sardaukar|Cunning/);
nexusReject(g, 'p', { ...action, event: 'old' }, /Sardaukar|Cunning/);
nexusReject(g, 'p', { ...action, count: 4 }, /Sardaukar|Cunning/);
nexusReject(activate(g), 'p', action, /Sardaukar|Cunning/);
const ready = prepareSardaukarBattle(g);
const sealed = applyAction(ready, 'p', {
type: 'battlePlan',
dial: 0,
leader: ready.players[0].leaders[0].id,
});
nexusReject(sealed, 'p', action, /submitted|Sardaukar|Cunning/);
});
void test('saved declaration restores its public pending effect and only settles once', () => {
let g = nexusSardaukarFixture({
seatIds: ['emperor-seat', 'guild-seat', 'fremen-seat'],
});
hold(g, 'guild-seat', 'karama');
const action = request(g);
g = beginNexusSardaukar(g);
const saved = nexusReload(g);
for (const p of saved.players) {
const view = viewGame(saved, p.id);
assert.equal(view.nexusSardaukar!.pending!.owner, 'emperor-seat');
for (const other of view.players)
if (other.id !== p.id) assert.equal(other.hand, undefined);
}
assert.deepEqual(normalizeAutomaticGame(saved), saved);
g = allowNexusSardaukar(nexusReload(saved));
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
nexusReject(g, 'emperor-seat', action, /Sardaukar|Cunning/);
nexusSardaukarInventory(g);
});
void test('native Prescience accepts a future Cunning dial and keeps its answer binding after activation', () => {
let g = nexusSardaukarFixture({ opponentFaction: 'atreides' });
g = applyAction(g, 'q', { type: 'prescience', field: 'dial' });
g = nexusAllow(g);
g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 10 });
assert.equal(g.nexusCards!.cards!.hands.p, 'emperor');
g = activate(prepareSardaukarBattle(g));
g = applyAction(g, 'p', {
type: 'battlePlan',
dial: 10,
support: 5,
leader: g.players[0].leaders[0].id,
});
assert.equal(g.battle!.plans.p.dial, 10);
assert.equal(g.battle!.prescience!.value, 10);
});
void test('canceling Cunning reopens an impossible native dial answer without granting a new inspection', () => {
let g = nexusSardaukarFixture({ opponentFaction: 'atreides' });
const karama = hold(g, 'q', 'karama');
g = applyAction(g, 'q', { type: 'prescience', field: 'dial' });
g = nexusAllow(g);
g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 10 });
const battle = g.battle!.event;
g = beginNexusSardaukar(prepareSardaukarBattle(g));
g = applyAction(g, 'q', { type: 'card', card: karama, mode: 'cancel' });
assert.equal(g.battle!.event, battle);
assert.equal(g.battle!.prescience!.player, 'q');
assert.equal(g.battle!.prescience!.field, 'dial');
assert.equal(g.battle!.preparation?.kind, 'prescienceAnswer');
g = applyAction(nexusReload(g), 'p', { type: 'prescienceAnswer', value: 5 });
assert.equal(g.battle!.prescience!.value, 5);
g = prepareSardaukarBattle(g);
g = applyAction(g, 'p', {
type: 'battlePlan',
dial: 5,
support: 5,
leader: g.players[0].leaders[0].id,
});
assert.equal(g.battle!.plans.p.dial, 5);
assert.equal(
g.nexusCards!.cards!.discard.filter((card) => card === 'emperor').length,
1,
);
assert.equal(g.discard.filter((card) => card.id === karama).length, 1);
nexusSardaukarInventory(g);
});
void test('canceled Sardaukar reopen the affected Cunning inspection while preserving the other answered element', () => {
for (const [nativeDial, extraLeader] of [
[true, false],
[false, false],
[true, true],
]) {
let g = nexusSardaukarFixture({ opponentFaction: 'atreides' });
const leader = g.players[0].leaders[0].id;
const unchangedField = extraLeader
? 'leader'
: nativeDial
? 'defense'
: 'weapon';
const unchangedValue = extraLeader ? leader : null;
const karama = hold(g, 'q', 'karama');
const cards = g.nexusCards!.cards!;
cards.deck = [
'atreides',
...cards.deck.filter((card) => card !== 'atreides'),
];
g.nexusCards!.cards = drawNexusCard(cards, 'q', g.players, () => 0);
g = applyAction(g, 'q', {
type: 'prescience',
field: nativeDial ? 'dial' : 'weapon',
});
g = nexusAllow(g);
g = applyAction(g, 'p', {
type: 'prescienceAnswer',
value: nativeDial ? 10 : null,
});
g = prepareSardaukarBattle(g);
g = applyAction(g, 'q', {
type: 'nexusAtreides',
event: g.battle!.event,
mode: 'cunning',
field: extraLeader ? 'leader' : nativeDial ? 'defense' : 'dial',
});
g = nexusAllow(g);
g = applyAction(g, 'p', {
type: 'nexusPrescienceAnswer',
event: g.battle!.event,
value: nativeDial ? unchangedValue : 10,
});
g = beginNexusSardaukar(prepareSardaukarBattle(g));
g = applyAction(g, 'q', { type: 'card', card: karama, mode: 'cancel' });
assert.equal(
g.battle!.preparation?.kind,
nativeDial ? 'prescienceAnswer' : 'nexusPrescienceAnswer',
);
assert.deepEqual(viewGame(g, 'r').battle!.nexusInsights, []);
g = applyAction(
nexusReload(g),
'p',
nativeDial
? { type: 'prescienceAnswer', value: 5 }
: { type: 'nexusPrescienceAnswer', event: g.battle!.event, value: 5 },
);
const commitments = viewGame(g, 'p').battle!.ownCommitments;
assert.ok(commitments.some((c) => c.field === 'dial' && c.value === 5));
assert.ok(
commitments.some(
(c) => c.field === unchangedField && c.value === unchangedValue,
),
);
g = prepareSardaukarBattle(g);
if (extraLeader)
nexusReject(
g,
'p',
{
type: 'battlePlan',
dial: 5,
support: 5,
leader: g.players[0].leaders.find(
(candidate) => candidate.id !== leader,
)!.id,
},
/Every element revealed by a battle inspection must remain unchanged/,
);
g = applyAction(g, 'p', {
type: 'battlePlan',
dial: 5,
support: 5,
leader,
});
assert.equal(g.battle!.plans.p.dial, 5);
nexusSardaukarInventory(g);
}
});
void test('Secret Ally inspection retains private history when canceled Sardaukar require a revised dial', () => {
let g = nexusSardaukarFixture();
const karama = hold(g, 'q', 'karama');
const cards = g.nexusCards!.cards!;
cards.deck = [
'atreides',
...cards.deck.filter((card) => card !== 'atreides'),
];
g.nexusCards!.cards = drawNexusCard(cards, 'q', g.players, () => 0);
g = applyAction(g, 'q', {
type: 'nexusAtreides',
event: g.battle!.event,
mode: 'secretAlly',
field: 'dial',
});
g = applyAction(g, 'p', {
type: 'nexusPrescienceAnswer',
event: g.battle!.event,
value: 10,
});
g = beginNexusSardaukar(prepareSardaukarBattle(g));
g = applyAction(g, 'q', { type: 'card', card: karama, mode: 'cancel' });
assert.equal(g.battle!.preparation?.kind, 'nexusPrescienceAnswer');
assert.deepEqual(viewGame(g, 'r').battle!.nexusInsights, []);
assert.ok(
viewGame(g, 'q').battle!.nexusInsights.some(
(insight) => insight.value === 10 && !insight.active,
),
);
g = applyAction(nexusReload(g), 'p', {
type: 'nexusPrescienceAnswer',
event: g.battle!.event,
value: 5,
});
const answers = viewGame(g, 'q').battle!.nexusInsights;
assert.ok(answers.some((insight) => insight.value === 10 && !insight.active));
assert.ok(answers.some((insight) => insight.value === 5 && insight.active));
g = prepareSardaukarBattle(g);
g = applyAction(g, 'p', {
type: 'battlePlan',
dial: 5,
support: 5,
leader: g.players[0].leaders[0].id,
});
nexusSardaukarInventory(g);
});
+194
View File
@@ -0,0 +1,194 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
initializeNexusGameForAudit,
joinGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { drawNexusCard } from '../game/nexus-cards';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { maxCombatDial, maxCombatSupport } from '../game/combat';
import {
enterNexusSpice,
orderNexusSpice,
finishNexusSpice,
nexusAllow,
nexusReload,
} from './fixture-nexus-cards';
import {
beginNexusSardaukar,
prepareSardaukarBattle,
nexusSardaukarInventory,
} from './fixture-nexus-sardaukar';
/** Both modules and the final roster precede the genuine setup initializer.
* Later board positions conserve all counters; Salusa is an explicit subset
* of existing reserves, including the low case's four stars at Kaitain. */
function fixture(salusa: 1 | 2 | 5 = 5) {
let g = createGame('SARDHOME', newPlayer('p', 'Emperor', 'emperor'), true);
joinGame(g, newPlayer('q', 'Guild', 'guild'));
joinGame(g, newPlayer('r', 'Fremen', 'fremen'));
g = applyAction(g, 'p', { type: 'homeworlds', enabled: true });
g.nexusCards = { cards: null, phase: null };
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g = initializeNexusGameForAudit(g);
for (let step = 0; g.status === 'setup' && step < 80; step++) {
let next: Game | undefined;
for (const p of g.players) {
const view = viewGame(g, p.id);
view.players.find((player) => player.id === p.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) {
next = applyAction(g, p.id, action);
break;
}
}
assert.ok(next, 'Combined setup must progress through real choices.');
g = next;
}
assert.equal(g.status, 'playing');
for (const p of g.players) g.deck.push(...p.hand.splice(0));
g = enterNexusSpice(g);
orderNexusSpice(g, ['land', 'land']);
g = finishNexusSpice(g);
for (const p of g.players) {
p.forces = {};
p.reserves = 20;
p.tanks = 0;
p.spice = 20;
if (p.elites) {
p.elites.forces = {};
p.elites.reserves = p.faction === 'emperor' ? 5 : 3;
p.elites.tanks = 0;
}
}
g.players[0].forces = { 'pasty_mesa:5': 7 };
g.players[0].reserves = 13;
g.players[1].forces = { 'pasty_mesa:5': 2 };
g.players[1].reserves = 18;
g.homeworlds!.custody!.salusa = { normal: 0, elite: salusa };
const cards = g.nexusCards!.cards!;
cards.deck = ['emperor', ...cards.deck.filter((card) => card !== 'emperor')];
g.nexusCards!.cards = drawNexusCard(cards, 'p', g.players, () => 0);
Object.assign(g, {
phase: 6,
phaseOpening: null,
response: null,
decision: null,
active: 'p',
order: ['p', 'q', 'r'],
ready: [],
storm: 18,
});
g = applyAction(g, 'p', {
type: 'chooseBattle',
territory: 'pasty_mesa',
target: 'q',
});
for (const id of ['p', 'q'])
if (g.battle!.preLeader && !g.battle!.preLeader.closed)
g = applyAction(g, id, {
type: 'battlePreparationReady',
event: g.battle!.event,
});
g = prepareSardaukarBattle(g);
inventory(g);
return g;
}
function inventory(g: Game) {
homeworldGameIntegrity(g);
nexusSardaukarInventory(g);
}
function activate(g: Game) {
return nexusAllow(beginNexusSardaukar(g));
}
function plan(g: Game, support: number) {
const leader = [...g.players[0].leaders].sort(
(a, b) => b.strength - a.strength,
)[0];
return { type: 'battlePlan', dial: 12, support, leader: leader.id };
}
function resolve(g: Game, support: number) {
g = applyAction(g, 'p', plan(g, support));
g = applyAction(g, 'q', {
type: 'battlePlan',
dial: 0,
support: 0,
leader: g.players[1].leaders[0].id,
});
for (const id of ['p', 'q'])
g = applyAction(g, id, { type: 'traitorCall', call: false });
if (g.decision?.kind === 'battleLosses')
g = applyAction(g, 'p', { type: 'decision', choice: 0 });
return g;
}
void test('real high-Salusa Cunning pays only two normal support and all seven losses remain ordinary counters', () => {
const original = fixture();
let g = activate(nexusReload(original));
const forces = viewGame(g, 'p').battle!.ownForces!;
assert.deepEqual(
[forces.normal, forces.elite, forces.temporaryElite],
[7, 0, 5],
);
assert.equal(forces.eliteFreeSupport, true);
assert.equal(maxCombatDial(forces), 12);
assert.equal(maxCombatSupport(forces), 2);
assert.deepEqual(g.homeworlds!.custody!.salusa, { normal: 0, elite: 5 });
const before = nexusReload(g);
assert.throws(() => applyAction(g, 'p', plan(g, 3)));
assert.deepEqual(g, before);
for (const p of g.players)
assert.deepEqual(viewGame(nexusReload(g), p.id), viewGame(g, p.id));
g = resolve(g, 2);
assert.equal(g.players[0].tanks, 7);
assert.equal(g.players[0].spice, original.players[0].spice - 2);
assert.equal(g.players[0].elites!.tanks, 0);
assert.equal(g.players[0].elites!.reserves, 5);
assert.deepEqual(g.homeworlds!.custody!.salusa, { normal: 0, elite: 5 });
assert.equal(g.players[0].reserves, original.players[0].reserves);
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
inventory(g);
});
void test('one Salusa star stays low despite five temporary Sardaukar and requires all seven support payments', () => {
const original = fixture(1);
let g = activate(nexusReload(original));
const forces = viewGame(g, 'p').battle!.ownForces!;
assert.equal(forces.temporaryElite, 5);
assert.equal(!!forces.eliteFreeSupport, false);
assert.equal(maxCombatDial(forces), 12);
assert.equal(maxCombatSupport(forces), 7);
const before = nexusReload(g);
assert.throws(() => applyAction(g, 'p', plan(g, 2)));
assert.deepEqual(g, before);
g = resolve(g, 7);
assert.equal(g.players[0].tanks, 7);
assert.equal(g.players[0].spice, original.players[0].spice - 7);
assert.equal(g.players[0].elites!.tanks, 0);
assert.equal(g.players[0].elites!.reserves, 5);
assert.deepEqual(g.homeworlds!.custody!.salusa, { normal: 0, elite: 1 });
inventory(g);
});
void test('the actual Salusa one-to-two threshold controls the shared profile without counting stars at Kaitain', () => {
for (const [salusa, support] of [
[1, 7],
[2, 2],
] as const) {
const g = activate(fixture(salusa));
assert.equal(
maxCombatSupport(viewGame(g, 'p').battle!.ownForces!),
support,
);
assert.equal(g.players[0].elites!.reserves, 5);
assert.equal(g.homeworlds!.custody!.salusa!.elite, salusa);
inventory(g);
}
});
+302
View File
@@ -0,0 +1,302 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { DatabaseSync } from 'node:sqlite';
import { unitStore } from './fixture-nexus-room-store';
import {
nexusSardaukarFixture,
prepareSardaukarBattle,
nexusSardaukarInventory,
} from './fixture-nexus-sardaukar';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import type { RoomsClock } from '../db/rooms';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
async function fixture(initial: Game) {
const store = unitStore();
const made = await store.rooms.createRoom(
'Cunning SQL',
'harkonnen',
false,
[],
);
const code = made.view.code,
tokens = [made.token];
for (const faction of ['guild', 'fremen'] as const)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const ids = ['p', 'q', 'r'];
// Only disposable SQL seat bindings move. Existing game identities and their
// signed battle/component histories remain untouched.
for (const [i, token] of tokens.entries()) {
const auth = await store.rooms.authenticate(code, token);
store.sqlite
.prepare(
'UPDATE seats SET player_id = ? WHERE room_code = ? AND player_id = ?',
)
.run(ids[i], code, auth.playerId);
}
const auths = await Promise.all(
tokens.map((token) => store.rooms.authenticate(code, token)),
);
initial.code = code;
initial.version = (await store.rooms.readRoom(code)).version;
store.sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(initial), initial.version, code);
store.writes.length = 0;
return { ...store, code, tokens, auths, ids, initial };
}
type Fixture = Awaited<ReturnType<typeof fixture>> & { sqlite: DatabaseSync };
const row = (f: Fixture) =>
f.sqlite
.prepare('SELECT state,version FROM rooms WHERE code = ?')
.get(f.code);
const sardaukarAction = (g: Game): Action => ({
type: 'nexusSardaukar',
event: viewGame(g, 'p').nexusSardaukar!.offer!.event,
});
async function restored(f: Fixture, g: Game) {
const before = row(f),
rooms = f.restart();
for (const [i, token] of f.tokens.entries()) {
const auth = await rooms.authenticate(f.code, token);
const actual = await rooms.readSeatView(f.code, auth);
assert.deepEqual(actual, viewGame(g, f.ids[i]));
assert.equal(Object.hasOwn(actual, 'nexusSardaukarHistory'), false);
}
await rooms.continueRoomAutomatic(f.code, clock);
assert.deepEqual(row(f), before);
nexusSardaukarInventory(g);
}
async function compete(f: Fixture, action: Action) {
let arrivals = 0;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
f.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await gate;
};
const results = await Promise.allSettled(
[f.restart(), f.restart()].map((rooms) =>
rooms.act(f.code, f.auths[0], f.initial.version, action, clock),
),
);
delete f.hooks.beforeWrite;
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
assert.deepEqual(
f.writes.map((w) => w.changes).sort((a, b) => a - b),
[0, 1],
);
const g = await f.restart().readRoom(f.code);
assert.equal(g.version, f.initial.version + 1);
return g;
}
function initialBattle(sealed = false) {
let g = prepareSardaukarBattle(nexusSardaukarFixture());
const index = g.deck.findIndex((card) => card.effect === 'karama');
assert.ok(index >= 0);
const karama = g.deck.splice(index, 1)[0];
g.players[1].hand.push(karama);
if (sealed)
g = applyAction(g, 'q', {
type: 'battlePlan',
dial: 0,
support: 0,
leader: g.players[1].leaders[0].id,
weapon: null,
defense: null,
});
nexusSardaukarInventory(g);
return { g, karama: karama.id };
}
function physical(g: Game) {
return g.players.map((p) => ({
id: p.id,
forces: p.forces,
reserves: p.reserves,
tanks: p.tanks,
elites: p.elites,
spice: p.spice,
}));
}
void test('SQLite competing Emperor Cunning plays spend one card and restore a sealed opponent plus the pending response privately', async () => {
const { g: initial } = initialBattle(true);
const f = await fixture(initial);
try {
const action = sardaukarAction(initial);
const g = await compete(f, action);
assert.equal(g.response?.kind, 'nexusSardaukar');
assert.equal(g.nexusSardaukarHistory!.length, 1);
assert.equal(g.nexusSardaukarLast!.stage, 'pending');
assert.deepEqual(g.battle!.plans.q, initial.battle!.plans.q);
assert.deepEqual(physical(g), physical(initial));
assert.equal(g.nexusCards!.cards!.hands.p, null);
assert.equal(
g.nexusCards!.cards!.discard.filter((card) => card === 'emperor').length,
1,
);
await restored(f, g);
for (const id of ['p', 'r'])
assert.equal(viewGame(g, id).battle!.plans.q, undefined);
const before = row(f);
await assert.rejects(
f.rooms.act(f.code, f.auths[0], initial.version, action, clock),
);
await assert.rejects(
f.rooms.act(f.code, f.auths[0], g.version, action, clock),
);
assert.deepEqual(row(f), before);
await f
.restart()
.act(f.code, f.auths[1], g.version, { type: 'passResponse' }, clock);
const active = await f.restart().readRoom(f.code);
assert.equal(active.nexusSardaukarLast!.stage, 'active');
assert.deepEqual(physical(active), physical(initial));
assert.deepEqual(active.battle!.plans.q, initial.battle!.plans.q);
await restored(f, active);
} finally {
f.sqlite.close();
}
});
void test('SQLite lost acknowledgment followed by Karama cancels only temporary strength and retains all real Sardaukar in reserves', async () => {
const { g: initial, karama } = initialBattle();
const f = await fixture(initial);
try {
await f.rooms.act(
f.code,
f.auths[0],
initial.version,
sardaukarAction(initial),
clock,
);
const pending = await f.restart().readRoom(f.code);
await restored(f, pending);
await f
.restart()
.act(
f.code,
f.auths[1],
pending.version,
{ type: 'card', card: karama, mode: 'cancel' },
clock,
);
const canceled = await f.restart().readRoom(f.code);
assert.equal(canceled.nexusSardaukarLast!.stage, 'canceled');
assert.deepEqual(physical(canceled), physical(initial));
assert.equal(canceled.players[0].elites!.reserves, 5);
assert.equal(canceled.players[0].elites!.tanks, 0);
assert.equal(
canceled.discard.filter((card) => card.id === karama).length,
1,
);
assert.equal(
canceled.nexusCards!.cards!.discard.filter((card) => card === 'emperor')
.length,
1,
);
await restored(f, canceled);
const before = row(f);
await assert.rejects(
f.rooms.act(
f.code,
f.auths[1],
canceled.version,
{ type: 'card', card: karama, mode: 'cancel' },
clock,
),
);
assert.deepEqual(row(f), before);
} finally {
f.sqlite.close();
}
});
void test('SQLite damaged Emperor battle source, history, stage or response refuses every seat and writes nothing', async () => {
const { g: initial } = initialBattle();
const f = await fixture(initial);
try {
await f.rooms.act(
f.code,
f.auths[0],
initial.version,
sardaukarAction(initial),
clock,
);
const good = await f.rooms.readRoom(f.code);
const changes: ((g: Game) => void)[] = [
(g) => {
delete g.nexusSardaukarHistory;
},
(g) => {
delete g.nexusSardaukarLast;
},
(g) => {
delete g.battle!.nexusSardaukarUsed;
},
(g) => {
g.battle!.nexusSardaukarUsed = 'stale';
},
(g) => {
g.nexusSardaukarHistory![0].stage = 'active';
},
(g) => {
g.nexusSardaukarHistory![0].receipt.normal++;
},
(g) => {
g.nexusSardaukarHistory![0].receipt.battle = 'stale';
},
(g) => {
g.nexusSardaukarHistory![0].parent += '-stale';
},
(g) => {
g.response = null;
},
(g) => {
g.response!.owner = 'q';
},
(g) => {
g.response!.intent += '-stale';
},
(g) => {
g.players[0].forces['pasty_mesa:5']++;
g.players[0].reserves--;
},
];
for (const change of changes) {
const bad = structuredClone(good);
change(bad);
f.sqlite
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
.run(JSON.stringify(bad), f.code);
f.writes.length = 0;
const before = row(f);
for (const auth of f.auths)
await assert.rejects(f.restart().readSeatView(f.code, auth));
await assert.rejects(
f
.restart()
.act(
f.code,
f.auths[1],
bad.version,
{ type: 'passResponse' },
clock,
),
);
// Automatic recovery can decline an invalid continuation or surface its
// integrity error; neither path may rewrite the damaged saved row.
await f
.restart()
.continueRoomAutomatic(f.code, clock)
.catch(() => {});
assert.deepEqual(row(f), before);
assert.equal(f.writes.length, 0);
}
} finally {
f.sqlite.close();
}
});