Implement Moritani Nexus placement and preserve later Terror custody
This commit is contained in:
1 parent
290259bac2
commit
ae66668e2e
27 files changed
+2208
-73
No files matched your search
@@ -15,12 +15,11 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { territory } from '@/game/board';
|
||||
import { moritaniPlacementChoices, nexusMoritaniAction } from '@/game/nexus-moritani-options';
|
||||
import {
|
||||
TERROR_COMMON_GAMEPLAY,
|
||||
TERROR_DEFINITIONS,
|
||||
TERROR_STRONGHOLDS,
|
||||
type TerrorKind,
|
||||
type TerrorToken,
|
||||
} from '@/game/moritani-terror';
|
||||
|
||||
type ProjectedToken = NonNullable<GameView['moritaniTerror']>['tokens'][number];
|
||||
@@ -266,6 +265,7 @@ export function MoritaniPlacement({
|
||||
busy: boolean;
|
||||
}) {
|
||||
const formId = useId();
|
||||
const [useNexus, setUseNexus] = useState(false);
|
||||
const [selectedToken, setSelectedToken] = useState('');
|
||||
const [selectedTerritory, setSelectedTerritory] = useState('');
|
||||
const decision = game.decision;
|
||||
@@ -276,34 +276,32 @@ export function MoritaniPlacement({
|
||||
!game.moritaniTerror
|
||||
)
|
||||
return null;
|
||||
const tokens = game.moritaniTerror.tokens.filter(
|
||||
(token): token is TerrorToken =>
|
||||
'kind' in token &&
|
||||
(token.status === 'available' || token.status === 'placed'),
|
||||
);
|
||||
const token =
|
||||
tokens.find((candidate) => candidate.id === selectedToken) ?? tokens[0];
|
||||
const destinations = TERROR_STRONGHOLDS.filter(
|
||||
(to) =>
|
||||
to !== token?.location &&
|
||||
!game.moritaniTerror!.tokens.some(
|
||||
(candidate) =>
|
||||
candidate.status === 'placed' && candidate.location === to,
|
||||
),
|
||||
);
|
||||
const nexus = useNexus && !!game.nexusMoritani;
|
||||
const { tokens, token, destinations } = moritaniPlacementChoices(game, nexus, selectedToken);
|
||||
const destination = destinations.includes(selectedTerritory)
|
||||
? selectedTerritory
|
||||
: destinations[0];
|
||||
const waiting = Boolean(game.response);
|
||||
const disabled = busy || waiting;
|
||||
const nexusAction = nexus && token && destination ? nexusMoritaniAction(game, game.nexusMoritani!.event, token.id, destination) : null;
|
||||
return (
|
||||
<section className="notice min-w-0" aria-labelledby={`${formId}-title`}>
|
||||
<h3 id={`${formId}-title`}>Place or relocate Terror</h3>
|
||||
<p className="text-sm leading-6">
|
||||
Choose one token and a stronghold without a Terror token for this Mentat
|
||||
Pause. Storm does not prevent placement. Choose privately; the token’s
|
||||
face stays hidden from opponents.
|
||||
{nexus
|
||||
? 'Choose one available supply token and a projected Arrakis territory for this Mentat Pause. Existing Terror tokens may remain there.'
|
||||
: 'Choose one token and a stronghold without a Terror token for this Mentat Pause.'}{' '}
|
||||
Storm does not prevent placement. Choose privately; the token’s face stays hidden from opponents.
|
||||
</p>
|
||||
{game.nexusMoritani && <>
|
||||
<label className="decision-checkbox min-h-11">
|
||||
<input type="checkbox" checked={nexus} disabled={disabled || !!game.nexusMoritani.blocked}
|
||||
onChange={(event) => setUseNexus(event.target.checked)} />
|
||||
Use Moritani Nexus Cunning
|
||||
</label>
|
||||
<p className="fine">Cunning places one available supply token in an Arrakis territory, including one with Terror already present. It spends the Nexus card and this Mentat placement opportunity. Relocation, Grumman use, Homeworlds and the Hidden Mobile Stronghold are unavailable.</p>
|
||||
{game.nexusMoritani.blocked && <p className="fine">{game.nexusMoritani.blocked}</p>}
|
||||
</>}
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<label htmlFor={`${formId}-token`} className="text-sm font-medium">
|
||||
Your Terror token
|
||||
@@ -326,7 +324,7 @@ export function MoritaniPlacement({
|
||||
))}
|
||||
</select>
|
||||
<label htmlFor={`${formId}-territory`} className="text-sm font-medium">
|
||||
Destination stronghold
|
||||
{nexus ? 'Destination territory' : 'Destination stronghold'}
|
||||
</label>
|
||||
<select
|
||||
id={`${formId}-territory`}
|
||||
@@ -336,7 +334,7 @@ export function MoritaniPlacement({
|
||||
onChange={(event) => setSelectedTerritory(event.target.value)}
|
||||
>
|
||||
{!destinations.length && (
|
||||
<option value="">No empty stronghold</option>
|
||||
<option value="">{nexus ? 'No available territory' : 'No empty stronghold'}</option>
|
||||
)}
|
||||
{destinations.map((to) => (
|
||||
<option key={to} value={to}>
|
||||
@@ -351,9 +349,10 @@ export function MoritaniPlacement({
|
||||
</output>
|
||||
<Button
|
||||
className="h-auto min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={disabled || !token || !destination}
|
||||
disabled={disabled || !token || !destination || (nexus && !nexusAction)}
|
||||
onClick={() => {
|
||||
if (token && destination)
|
||||
if (nexusAction) act(nexusAction);
|
||||
else if (!nexus && token && destination)
|
||||
act({
|
||||
type: 'decision',
|
||||
token: token.id,
|
||||
@@ -361,7 +360,7 @@ export function MoritaniPlacement({
|
||||
});
|
||||
}}
|
||||
>
|
||||
{token?.status === 'placed'
|
||||
{nexus ? 'Declare Cunning placement' : token?.status === 'placed'
|
||||
? 'Declare relocation'
|
||||
: 'Declare placement'}
|
||||
</Button>
|
||||
|
||||
@@ -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, Bene Gesserit, Emperor and CHOAM Cunning. Use the controls shown at each effect’s timing. CHOAM’s Kull Wahad effect and other unfinished effects remain unavailable.</p>
|
||||
<p>Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit, Emperor, CHOAM and Moritani Cunning. Use the controls shown at each effect’s timing. CHOAM’s Kull Wahad effect, Moritani Cunning relocation and Grumman combinations, and other unfinished effects remain unavailable.</p>
|
||||
</div>
|
||||
{game.nexusAtreides && offer.card === 'atreides' && (
|
||||
<div aria-label="Atreides Nexus effect" className="space-y-2">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Component inventory and verification ledger
|
||||
|
||||
**Moritani Nexus Cunning update, 10 September 2026:** existing supply tokens now have an explicit Cunning choice for placement on the ordinary Arrakis map, including existing stacks. The physical token inspector and hidden public map marker remain shared; no extra token or artwork is created. Separate current and historical placement evidence supports later entry, revelation and discard recovery without exposing faces. [NEXUS_MORITANI_RUNTIME.md](NEXUS_MORITANI_RUNTIME.md) records 27 new regressions and desktop/phone verification. Enhanced relocation, mobile-stronghold placement, Grumman combinations and complete faction/module acceptance remain unresolved.
|
||||
|
||||
**CHOAM Nexus Cunning update, 10 September 2026:** the existing physical Nexus can fund one of five supported Worthless effects with an actual held Treachery Card of any category. Shared controls show the cost card separately from the selected effect and retain its full inspector; the engine preserves its canonical identity through allowance, cancellation and legitimate payment loss. No duplicate Worthless piece or artwork is created. The printed family has six effects: Kull Wahad remains unavailable. [NEXUS_CHOAM_RUNTIME.md](NEXUS_CHOAM_RUNTIME.md) records thirty new regressions, browser inspection and private recovery; complete CHOAM and module acceptance remain gated.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Dune implementation status
|
||||
|
||||
**Moritani Nexus Cunning checkpoint, 10 September 2026:** [NEXUS_MORITANI_RUNTIME.md](NEXUS_MORITANI_RUNTIME.md) records Basic/Advanced supply placement throughout the printed Arrakis map, including existing Terror stacks. Shared controls and all four bot profiles use the private offer; ordinary relocation stays unchanged. Native Karama cancellation preserves the token and spends the opportunity/Nexus. Independent review fixed arrival-signature downgrade, stale former-location reuse and physical-face rebinding; current placement permission and historical entry evidence remain separate. **Types, lint, all 4,069 offline tests, production build and 40 HTTP/session tests pass.** Twenty-seven new cases include real later shipments, Robbery/Sabotage/Sneak Attack, Truthtrance, prior Grumman outcomes and concurrent SQLite recovery. Two three-seat browser rooms passed phone placement, allowance/cancellation, map markers and all six private refreshes. All **3,516** opening room versions/hashes remain unchanged; two browser and thirty HTTP rooms were added. Relocation/HMS/Grumman interpretations, other unfinished Terror/Nexus effects and full module acceptance remain gated. The new [Ecaz source audit](NEXUS_ECAZ_RULES.md) records its leader-scope and disclosure boundaries. Existing server reused, no restart due; automation remains removed.
|
||||
|
||||
**CHOAM Nexus Cunning checkpoint, 10 September 2026:** [NEXUS_CHOAM_RUNTIME.md](NEXUS_CHOAM_RUNTIME.md) records five native effects using one actual Treachery Card in Basic and Advanced. Shared controls inspect the real cost independently of the selected effect; all four bots use projected legality. Karama retains the cost and spent Nexus; actual intervening cash-in fizzles and resumes the original operation. Independent review fixed saved movement, revival and storm orders accepting valid-looking replacements. **Types, lint, all 4,042 offline tests, production build and 40 HTTP/session tests pass.** Thirty new cases include real paid Nullentropy, private views, physical disposal and concurrent SQLite outcomes. Two three-seat browser rooms passed phone declarations, allowance/cancellation and all six private refreshes. All **3,484** opening room versions/hashes remain unchanged; only two browser and thirty HTTP rooms were added. The printed family has six effects: Kull Wahad remains unavailable, as do unfinished Secret Ally/Betrayal and full module starts. Existing server reused, no restart due, automation remains removed.
|
||||
|
||||
**Grumman stack cancellation checkpoint, 10 September 2026:** [GRUMMAN_PLACEMENT_CANCELLATION.md](GRUMMAN_PLACEMENT_CANCELLATION.md) records the reproduced failure of printed Karama against a native Mentat placement after a legal Collection stack. Cancellation now preserves the existing stack without requiring distinct occupied locations. Independent SQL review also fixed allowance accepting unrelated duplicate physical token IDs; both outcomes validate the same inventory. Existing denial semantics remain unchanged. **Types, lint, all 4,012 offline cases, production build and 40 HTTP/session cases pass.** Six new cases cover Basic/Advanced, low population, unchanged income, competing cancellation/allowance, private restoration and malformed records. Browser phone declaration, desktop/phone cancellation and three-seat refresh pass. The hourly restart preserved all **3,453** opening games and restored twelve earlier private seats; final comparison leaves those same versions/hashes unchanged, with one new browser room and thirty HTTP rooms added. New [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md) and [Fremen Nexus](NEXUS_FREMEN_RULES.md) audits record remaining source and privacy boundaries. No new Nexus effect or release gate was enabled; automation stays removed.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Ecaz Nexus source contract
|
||||
|
||||
Audited 10 September 2026. This is a source and integration audit, not an implementation or release claim. The immediate scope is Secret Ally; the other two printed effects remain separate. See [common Nexus rules](NEXUS_CARD_RULES.md).
|
||||
|
||||
## Primary evidence
|
||||
|
||||
The Ecaz panel was freshly inspected in the [photograph of the original GF9 components](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani), cached at `/tmp/dune-nexus-cards.jpg`. Secret Ally selects one player and requires a yes/no disclosure of whether that player holds any of the user's leaders as Traitors. It expressly withholds which leaders. It has no printed phase, battle, location, payment, live-leader or opponent-only condition. The face does not specify that this answer is secret or addressed only to the user. Its Cunning takes Duke Vidal for a turn despite capture, Tanks or Ghola custody; Betrayal concerns Ecaz's ally returning co-located forces before Shipment and Movement. Those are not Secret Ally prerequisites.
|
||||
|
||||
The [GF9 E3 rulebook, pp.11 and 16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=11) provides common mode eligibility, consumption and the Nexus FAQ. Secret Ally requires Ecaz absent from the seated faction roster and does not create a real alliance. The holder cannot retain the card through joining an alliance. Use consumes the physical Nexus card. There is no Ecaz-specific FAQ answer. The cached publisher-authored text at `/tmp/dune-e3-nexus-rules.txt` was inspected; fresh official-domain indexed excerpts confirm the two Nexus FAQ entries. A [publisher-authored mirror](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf) supplied the previously cached PDF.
|
||||
|
||||
## Leader identity and custody
|
||||
|
||||
**Certain:** Duke Vidal cannot make the answer positive. E3 expressly gives him no Traitor Card, despite changes of controller. Its Moritani FAQ permits an ordinary traitor call against a native Moritani leader captured by Harkonnen, so capture does not erase that leader's printed identity. [E3, pp.3, 8 and 14](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=14)
|
||||
|
||||
**Certain:** a leader's death does not remove an already held Traitor Card or make that identity cease to be a traitor. Revival preserves it. The effect supplies no alive-only filter. The later FAQ also distinguishes Tleilaxu's native leaders from foreign Ghola leaders and permits foreign Gholas to return through the Tanks. [GF9 FAQ, April 2020 p.1](https://www.gf9games.com/dune/wp-content/uploads/2020/04/Dune-FAQ.pdf#page=1), [GF9 FAQ, November 2020 p.6](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=6)
|
||||
|
||||
**Not authoritatively settled by the inspected sources:** whether this card's possessive means native faction identity, the current controlled leader pool, or native leaders plus acquired foreign leaders. The [E1 Ghola advantage, p.7](https://www.gf9.com/Portals/0/Documents/GF9/IxianAndTleilaxuRulebook.pdf#page=7) explicitly adds foreign leaders to the Tleilaxu leader pool. It does not redefine the Ecaz card, which was published later. It is therefore unsafe to label either a native-only or current-control interpretation an explicit GF9 ruling.
|
||||
|
||||
The ordinary uncontested case is an owner's native faction leader identities, including dead leaders, against a target's actual held traitors. Canonical faction matching avoids accidental dependence on whether a captured disc remains in its original owner's array. For a full implementation, keep the ownership scope explicit and resolve the exceptional scope before claiming captive/Ghola coverage. Do not silently use only currently battle-available leaders: that adds an unsupported live/used-at constraint. A native-only choice would include native leaders currently captured elsewhere; a current-control choice would instead include held foreign captives/Gholas. Those can produce opposite answers.
|
||||
|
||||
The ordinary Cheap Hero/Heroine Treachery Cards are not faction leader discs and share a separate physical traitor identity. They must not silently become part of every faction's native leader set. Advanced CHOAM's Auditor is a real native leader with its own component identity; use the initialized canonical inventory rather than a hard-coded count of five. These are component/model observations, not a retrieved Ecaz-specific ruling about a controlled-pool interpretation.
|
||||
|
||||
## Face Dancers and targets
|
||||
|
||||
E1 makes Tleilaxu's drawn identities Face Dancers instead of ordinary selected traitors. The E3 Harkonnen Secret Ally FAQ expressly preserves that classification when Tleilaxu obtains another such card. [GF9 E1, pp.6–7](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=6), [GF9 E3, p.16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=16)
|
||||
|
||||
A literal **traitors-only composition** therefore reads the target's ordinary traitors and answers false for Tleilaxu; it does not disclose either revealed or unrevealed Face Dancers. This is a defensible composition of the distinct card roles, not an explicit Ecaz-versus-Tleilaxu FAQ. Do not merge `faceDancers` into `traitors` merely because they share the physical Traitor Deck. Extending the question to Face Dancers would reveal additional information and needs an explicit selected interpretation.
|
||||
|
||||
The card does not restrict the target to a combat opponent, an unallied player, a faction with a matching traitor, or a faction whose cards are still secret. Target availability must not depend on the answer. An allied target remains a player; the user's own alliance status is constrained by common Nexus custody. The wording does not explicitly exclude selecting oneself, although it reveals no new information to the user. Do not claim a self-target exclusion comes from the face.
|
||||
|
||||
## Disclosure and transaction timing
|
||||
|
||||
The phrase on the face is a disclosure requirement without an explicit audience qualifier. **The inspected GF9 sources do not settle private-only versus table-wide disclosure.** Atreides' nearby Cunning explicitly describes showing its user a plan element; this Ecaz effect does not. Existing private inspection code is an implementation precedent, not evidence that this different effect has the same audience. Preserve only the yes/no fact under whichever audience is selected, never a count, identity, faction breakdown, concealed captive status, or whole target hand. Do not use a tournament's added “publicly announce” wording as if printed on the GF9 component.
|
||||
|
||||
There is no prescribed separate answer delay. An atomic accepted play can spend Nexus and record the current answer without asking the target to confirm a server-known fact. That avoids an avoidable interval in which a hand exchange changes what is being answered. This is an implementation sequencing recommendation, not an extra printed phase restriction.
|
||||
|
||||
Read the target's completed current hand. Finish an already pending Harkonnen draw/return or Face Dancer replacement before a new inquiry: those operations expose an augmented intermediate pool and already have game-wide action locks. A later legal exchange can change the target's hand but must not rewrite the historical answer. Neither declared traitor-call history nor discarded/replaced identities substitute for what the player currently has. The answer cannot retroactively change an earlier battle call.
|
||||
|
||||
## Current integration map and proposed boundary
|
||||
|
||||
- `game/leader-control.ts` separates original faction identity from `capturedBy`, `gholaBy` and temporary `controller`; `engine.ts` keeps native leader records in their original seat arrays. Its `controlledLeaders` helper is a different query from native faction identity.
|
||||
- `projectLeader` hides appropriate capture/Tanks details. A new offer must not enumerate actual hidden foreign captives or vary availability with their presence. Any exceptional-custody gate must follow public configuration, not a secret `capturedBy` test.
|
||||
- `game/nexus-traitor-exchange.ts` validates the exact global physical census and requires Tleilaxu's ordinary `traitors` array to be empty. Pending exchanges are locked through `pendingNexusTraitors`; completed history deliberately survives later custody changes.
|
||||
- `game/traitors.ts` maps real leader identities and the separate Cheap Hero identity for battle use. It does not answer who owns a leader for this effect.
|
||||
- `game/tupile-intelligence-state.ts` is the closest stored-observation precedent: retain a signed, dated answer and validate its intrinsic receipt without recomputing against future hidden hands. `game/battle-inspections.ts` concerns commitments to a plan and its answer/reopen history; Ecaz's past fact should not become an ongoing commitment.
|
||||
|
||||
Implement the common, source-clear contract as a pure boolean intersection of **explicitly supplied queried leader IDs** with an ordinary held-traitor snapshot. Let the engine select the agreed leader scope and audience. Bind the accepted event, original seats/factions, turn/phase, chosen target, scope and boolean in a completed receipt; keep private source evidence server-side if retained. Discard the one real Ecaz Nexus card in the same accepted transaction. Validate current global custody separately from historical answers. Public offers can be built entirely from roster, timing/continuation state and the owner's card, without reading target secrets.
|
||||
|
||||
Meaningful regressions should cover positive and negative answers with equal public hand sizes; dead/revived native leaders; native captive versus foreign captive/Ghola cases for the explicitly selected scope; Duke and Hero identities; Tleilaxu role separation; Harkonnen exchange before/after inquiry; stale/replayed/concurrent play; JSON/SQL historical persistence; and exact audience projection without counts or matching identities. Until scope and audience are selected, those unresolved branches must not be presented as fully source-verified behavior.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Moritani Nexus: placement source audit
|
||||
|
||||
Source audit, 10 September 2026. This establishes the supported Cunning contract and remaining combinations; it does not certify runtime support, all Terror effects, or the complete Nexus module. See [common Nexus rules](NEXUS_CARD_RULES.md) and [Grumman stacking rules](GRUMMAN_STACKED_TERROR_RULES.md). The coordinator’s combined interpretation question is recorded below.
|
||||
|
||||
## Authority
|
||||
|
||||
The Moritani face was inspected again in the [photograph of the twelve publisher-authored Nexus cards](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani), using the original local inspection image `/tmp/dune-nexus-cards.jpg`. Cunning is native Moritani only. It modifies a Terror placement to permit any Arrakis territory, expressly including a territory already containing Terror. It does not print a phase restriction, grant another placement, or say that the modification lasts for the turn.
|
||||
|
||||
The [GF9 Ecaz & Moritani rulebook](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf), printed pp.5–6, 11, 14 and 16, supplies the native placement, entry, Nexus and cancellation rules. The inspected local PDF/text came from a [mirror of the publisher-authored rulebook](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf), not a successful official-domain download. Page 5 distinguishes placing a supply token from moving a board token during Mentat. Ordinary destinations are empty strongholds, with an express Hidden Mobile Stronghold exception; storm is permitted. Opposing shipment/movement, including advisors, can trigger Terror. Page 11 describes Cunning as a native advantage enhancement and requires discarding a used Nexus card. Page 14 preserves Terror through storms/explosions and distinguishes new concealed No-Field entry from later revelation. Page 16 prohibits Homeworld placement and permits Karama to prevent Terror placement or movement.
|
||||
|
||||
The [Grumman component photograph](https://boardgamegeek.com/image/7767034/dune-ecaz-and-moritani) supplies its separate high-population Collection addition/removal and four-spice award. Its wording and unresolved removal custody are recorded in the linked Grumman audit. Bounded fresh publisher/designer searches found no additional authenticated clarification of Cunning relocation, Hidden Mobile Stronghold placement, or Cunning combined with Grumman. Search results from community discussions were not adopted as rulings.
|
||||
|
||||
## Resolved effect and ordinary composition
|
||||
|
||||
1. **One earned placement.** A native unallied Moritani holder can spend this card when making an otherwise earned placement. Ordinary Mentat supply placement is a concrete supported source in Basic and Advanced. The selected physical token must actually be available; no removed or front-of-shield Extortion token becomes supply. Spending the card neither creates a seventh token nor creates another Mentat action.
|
||||
2. **Static Arrakis destination override.** Every printed Arrakis territory is within the positive destination grant, including deserts, rock, Polar Sink, occupied territories and an existing Terror stack. Do not add a force-occupancy requirement or a storm exclusion. A Terror token is not a force and placing it neither enters forces nor triggers existing Terror, Ambassadors or advisors. Homeworlds remain excluded. The separate mobile-territory question is below.
|
||||
3. **One committed native attempt.** Bind the chosen token and destination to the original placement opportunity, spend the Nexus card once, and use the ordinary Terrorize cancellation response. If canceled, no physical token moves and the attempted Mentat opportunity is consumed. This is composition of the native cancellation and used-Nexus rules, not an additional printed Nexus cancellation rule. It does not imply that every borrowed Secret Ally effect can be canceled as a native advantage.
|
||||
4. **Placement does not expire.** Nothing instructs removal of the placed token at phase or turn end. Subsequent loss of Grumman's high population, an alliance change, or another Nexus draw cannot undo the physical placement. Native current-population and ally restrictions still apply when a later entry is evaluated.
|
||||
5. **Future entries must work at the broadened location.** Reading native entry language literally as strongholds only would make the added non-stronghold destinations unable to Terrorize. Apply the ordinary entry opportunity to a Cunning-authorized token in its actual Arrakis territory. This is necessary effect composition, not a separate FAQ sentence. Preserve original arrival counts and causes, current low-Grumman checks, own/ally exclusions, No-Field privacy, and the existing effect-specific guards.
|
||||
6. **Stacks use the existing single-entry choice.** The owner chooses one physical candidate for a given entry; resolving it does not invent another entry for its neighbor. Refusal, Enemy of My Enemy cancellation, accepted alliance supply return, and card-discard children must retain that chosen identity. Other tokens remain where they were. See the Grumman audit for the distinction between singular-trigger composition and an explicit stacking FAQ.
|
||||
|
||||
Ordinary later relocation can move a Cunning-placed token out to a native-legal destination. Its unusual original location does not change its placed custody status or grant an enlarged destination for the later move. Returning a token to hidden supply ends that placement's custody interval; redeployment requires its own authorization and the existing supply-ID rotation.
|
||||
|
||||
## Material boundaries
|
||||
|
||||
**Relocation is not expressly placement.** The native rule deliberately offers placing a token or moving one, whereas Cunning names placement. Treating the card as a blanket modifier on the current combined place/move UI would silently include a second source category. No inspected primary clarification establishes that extension. Preserve ordinary relocation; do not present enhanced relocation as a settled printed right.
|
||||
|
||||
**Hidden Mobile Stronghold has a named native exception.** Cunning's broad Arrakis language could be read to replace that destination restriction, while the native exception could be read as surviving the generic grant. Unlike Homeworlds, it is not settled by the explicit Homeworld FAQ. The physical mobile stronghold is also a distinct territory from its pointed-to Arrakis sector. Static Arrakis support must not claim to resolve placement on the mobile stronghold itself or move a token with the mobile counter by accident.
|
||||
|
||||
**Grumman is a separate earned placement source.** Cunning has no Mentat-only wording, so a reusable source contract should represent Collection addition as well. A high-Grumman addition from supply is naturally a placement. The unresolved interaction is its destination condition: does Cunning replace Grumman's requirement for a stronghold already containing Terror, allowing an empty territory, or does that requirement establish an eligible addition before Cunning modifies the destination? The former follows a broad destination override; the latter follows an underlying-opportunity prerequisite. No inspected source explicitly chooses between them. Do not silently gate all Grumman use by inventing a Mentat restriction, or describe either destination composition as a publisher ruling. An enhanced Collection integration also needs to preserve its own once-only outcome and four-spice transaction, separate from Mentat usage and its cancellation source.
|
||||
|
||||
The coordinator has now asked one combined question about enhanced relocation, the mobile stronghold and Grumman; its answer is pending. The already pending question about unrevealed Grumman removal custody remains unchanged. Cunning places a token; it does not answer removal custody. Other incomplete Terror effects and combined reaction boundaries remain independent release limitations.
|
||||
|
||||
## Smallest complete integration for the resolved source
|
||||
|
||||
Implement a full ordinary Mentat supply-placement path over static Arrakis territories, with saved cancellation and all supported later entry effects. Keep the unresolved source/destination combinations explicit; a destination-only UI patch is insufficient.
|
||||
|
||||
Use a separate Cunning placement quote and receipt instead of widening ordinary [placeTerror](../game/moritani-terror.ts). Suggested original evidence: version, event, owner, turn, phase, source kind and source event, physical token, original custody, destination, roster, and original Terror-state commitment. Keep pending/completed/canceled stage evidence outside mutable current token custody. A completed placement authorizes the corresponding non-stronghold custody interval; a historical receipt alone must not authorize arbitrary later relocation, a returned token's new ID, or another token.
|
||||
|
||||
Current adapters requiring coordinated review:
|
||||
|
||||
- [grumman-collection.ts](../game/grumman-collection.ts) validates every placed token against the five ordinary strongholds even when merely quoting unrelated Collection choices. That registry validation must accept independently authorized Cunning custody without widening Grumman's ordinary addition quote.
|
||||
- [terror-entry-receipt.ts](../game/terror-entry-receipt.ts) and [moritani-alliance-cancellation.ts](../game/moritani-alliance-cancellation.ts) restrict entry territories to those same five locations. Broaden intrinsic territory validity and separately prove the live token's authorized location. Preserve candidate and selected-token signatures through all children.
|
||||
- [placement-cancellation.ts](../game/placement-cancellation.ts) likewise restricts both registry locations and the pending destination. Its current denial quote intentionally does not require that a placement can still succeed: cancellation moves no token. Preserve original declared-source proof and canceled-custody semantics when adding the modifier; do not weaken them into a successful-placement replay.
|
||||
- [engine.ts](../game/engine.ts) already finds all placed tokens matching an entry destination in `openTerrorEntry`; the downstream validators and effect continuations must accept the same authorized territory. Its Mentat declaration and allowed/canceled settlement currently use the ordinary source only.
|
||||
- [Moritani controls](../components/moritani-terror.tsx), bots and placement previews need a shared modifier-aware option quote. Board markers already group by territory. Public views may show location and stack size, but only Moritani receives token faces, selectable physical supply identities or a held-card-dependent action offer.
|
||||
|
||||
Required integration evidence includes genuine Basic/Advanced placement, cancel/allow with unchanged token custody on denial, non-stronghold stacked entry, current low-Grumman entry thresholds, accepted-alliance return, cross-turn ordinary relocation, JSON/SQLite recovery and every-seat privacy. Deterministic saved-state signatures detect inconsistent records; they are not cryptographic protection against coordinated rewriting of all state and proofs. This source audit makes no runtime or verification claim.
|
||||
|
||||
## Pure implementation checkpoint
|
||||
|
||||
[nexus-moritani.ts](../game/nexus-moritani.ts) now provides the initial Mentat-only historical receipt and immutable supply-placement quote. Its receipt fixes source `mentat` and phase 8; it does not represent a Grumman grant. The quote admits static Arrakis destinations and stacks, validates the six physical faces and custody zones, and leaves ordinary `placeTerror` unchanged. Current source ownership, phase, held Nexus card, authorization of prior non-stronghold custody and allowed/canceled progress remain engine responsibilities.
|
||||
|
||||
The five focused cases in [nexus-moritani.test.ts](../tests/nexus-moritani.test.ts) cover every static destination, stacks and later ordinary relocation, physical-inventory rejection, historical privacy and altered proof rejection. `npm test -- nexus-moritani` and focused type-aware lint passed. Local links and whitespace were checked. Full checks and integrated runtime verification remain the coordinator's pending work; this pure-module evidence does not lift release gates.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Moritani Nexus Cunning runtime
|
||||
|
||||
This checkpoint integrates Cunning with native Moritani's Mentat placement, in Basic and Advanced. Its bounded scope follows the [source audit](NEXUS_MORITANI_RULES.md): one **available supply token** may be placed in a territory on the printed Arrakis board, including one already containing Terror. Homeworlds and the Hidden Mobile Stronghold are excluded. Cunning relocation and its combination with Grumman Collection remain unavailable pending their separate interpretation.
|
||||
|
||||
The existing Mentat opportunity remains the action window. Cunning does not create another native placement, trigger an existing occupant, move forces, or remove a sibling token from a stack. Playing the Nexus spends it and opens the placement's ordinary Karama response. Allowance places the selected physical token; cancellation keeps the token in its original custody and consumes the placement opportunity while leaving the Nexus spent. Ordinary placement and relocation retain their original stronghold rules and action shape.
|
||||
|
||||
## Private choices and controls
|
||||
|
||||
The engine projects `nexusMoritani` only to the native holder. It contains the current event, a blocking reason, eligible physical supply tokens and authorized territory IDs. The [shared options helper](../game/nexus-moritani-options.ts) checks the current owner, held unallied Nexus, native Mentat decision, event, interruptions and projected membership. Cunning actions add `nexus: event` to the existing placement decision; ordinary actions remain unchanged.
|
||||
|
||||
The [Moritani placement panel](../components/moritani-terror.tsx) starts with ordinary placement selected. Its explicit “Use Moritani Nexus Cunning” checkbox changes the token list to available supply and the destination list to the projected Arrakis choices. The checkbox uses the existing accessible label spacing; controls respect transport-busy state and display server blocking reasons. The physical token names and existing token inspectors remain intact. No client code invents a token face from its opaque ID.
|
||||
|
||||
Opponents do not receive the private offer or supply faces. Public placed tokens retain only their visible location and stack count. Existing [Terror board markers](../components/terror-board-markers.tsx) group tokens by territory, and existing stack-entry controls choose one original token before revelation or an alliance offer. Broad placement does not implement a previously unavailable Terror effect.
|
||||
|
||||
## Bot policy and preserved boundaries
|
||||
|
||||
All four profiles consume the same private offer. The shared policy uses public opposing force positions to choose a territory or stack unavailable to ordinary placement; otherwise the existing native placement policy remains in charge. It prefers a supported Terror face from the owner's authorized supply when available. This is a legal policy extension, not evidence of calibrated Moritani strategy.
|
||||
|
||||
The engine binds the original placement, physical face and native opportunity in a saved receipt. A separate current-placement map ends when the token relocates, reveals or returns to supply; historical evidence remains available to the already committed entry and discard children. This preserves legitimate continuation across later turns and population changes without letting an old receipt authorize a new relocation. Consumers neither reconstruct that history nor broaden ordinary Grumman addition rules. The [Grumman stacking source](GRUMMAN_STACKED_TERROR_RULES.md) and its separate removal boundary remain unchanged.
|
||||
|
||||
Atomics and Extortion reactions, restricted Assassination leader pools, and existing Sneak Attack arrival combinations retain their explicit gates. No complete Moritani, Nexus or expansion-release claim follows from this checkpoint.
|
||||
|
||||
## Independent recovery review
|
||||
|
||||
The review found and fixed two source-consistency flaws: deleting a new arrival signature could downgrade it to the legacy path, and an old placement record could justify editing a later relocated token back to its previous desert. New Cunning entries require their arrival signature, and current custody now requires its live placement record. Rebinding the saved token face also rejects against the original inventory. The ordinary cancellation and card-discard paths use the same territory proof.
|
||||
|
||||
The fixture completes real Nexus-enabled setup and real turn boundaries before placement and later arrivals. An older internal-sector movement test formerly injected a desert token without authorization; it now earns that position through actual Cunning and next-turn progression, retaining the same no-new-entry assertion.
|
||||
|
||||
## Verification
|
||||
|
||||
Five new consumer cases are provided in [controls](../tests/nexus-moritani-controls.test.ts) and [bots](../tests/nexus-moritani-bots.test.ts), using the [genuine setup and Mentat fixture](../tests/fixture-nexus-moritani.ts). They cover Basic/Advanced source actions, expanded versus native choices, supply-only membership, explicit unchecked controls, existing inspectors, busy/blocked state, stale events, private-view fences, all four profiles and native fallback. The expanded selection is exercised through the shared options and actual engine declaration; interactive checkbox browser verification is separate.
|
||||
|
||||
Focused verification passed **42/42 tests** across the new consumer suites, existing Moritani placement, Grumman controls, Nexus controls and reference checks. Type-aware lint passed for the changed consumer files.
|
||||
|
||||
Final integrated checks pass: **types, lint, all 4,069 offline tests, production build and 40 HTTP/session tests**. Twenty-seven new cases cover the pure quote/receipt, consumers, engine, production SQLite and independent review. The SQL cases include actual non-stronghold shipment, reveal and Robbery choice, competing pass/cancellation, repeated requests and malformed records. Logs are `/tmp/dune-moritani-{check,build,http}.log`.
|
||||
|
||||
Two isolated three-seat browser rooms passed phone checkbox/selection/declaration, desktop response, allowance/cancellation and all six private refreshes. Advanced/Homeworld room `S7ANWNXF` version 4 placed Robbery in Red Chasm and preserved the ordinary Arrakeen token. Basic room `2K8RJCG2` version 4 canceled the same attempted destination, retaining its supply token and spent Nexus. Both retained Moritani's original 20 spice; placement earned no extra Grumman income. Opponents received no supply face or private offer. No page errors or phone overflow were observed. The public Red Chasm marker was inspected on desktop and phone. `/tmp/dune-moritani-nexus-restore.cjs` performs read-only private restoration checks.
|
||||
|
||||
The opening backup and final comparison preserve all **3,516** older room versions/state hashes. Only two browser and thirty HTTP-test rooms were added, for 3,548 total. The existing server was reused; no hourly restart was due. Automation remains removed. These checks do not certify the unresolved Cunning combinations or full Moritani/expansion acceptance.
|
||||
@@ -1,3 +1,4 @@
|
||||
import { nexusMoritaniBotActions } from './nexus-moritani-options';
|
||||
import { choamPowerAction, choamPowerBotPlay } from './choam-power-options';
|
||||
import { nexusCardBotActions } from './nexus-card-options';
|
||||
import { nexusTraitorBotActions } from './nexus-traitor-options';
|
||||
@@ -1724,6 +1725,8 @@ function policyActions(g: GameView): Action[] {
|
||||
: [];
|
||||
}
|
||||
if (d.kind === 'moritaniPlacement') {
|
||||
const cunning = nexusMoritaniBotActions(g);
|
||||
if (cunning.length) return cunning;
|
||||
const tokens = g.moritaniTerror?.tokens ?? [];
|
||||
const destinations = TERRITORIES.filter(
|
||||
(t) =>
|
||||
|
||||
+143
-14
@@ -4,6 +4,8 @@ import { replaceNexusFaceDancers, validateNexusFaceDancerHistory, type NexusFace
|
||||
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 { createNexusMoritani, validateNexusMoritani, quoteNexusMoritaniPlacement, type NexusMoritaniReceipt } from './nexus-moritani';
|
||||
import { nexusMoritaniRecordSignature, terrorLocationAllowed, terrorEntryLocationAllowed } from './terror-location';
|
||||
import { CHOAM_NEXUS_EFFECTS, createNexusChoam, validateNexusChoam, type NexusChoamEffect, type NexusChoamReceipt } from './nexus-choam';
|
||||
import { createTraitorDeclaration, validateTraitorDeclarations, type TraitorDeclaration, type TraitorDeclarationContext } from './traitor-declarations';
|
||||
import { createNexusCards, validateNexusCards, drawNexusCard, replaceNexusCard, discardNexusCard, projectNexusCards, nexusCardMode, type NexusState } from './nexus-cards';
|
||||
@@ -375,6 +377,7 @@ import {
|
||||
TERROR_DEFINITIONS,
|
||||
createTerrorState,
|
||||
placeTerror,
|
||||
TERROR_STRONGHOLDS,
|
||||
projectTerror,
|
||||
revealTerror,
|
||||
returnTerror,
|
||||
@@ -1114,6 +1117,7 @@ export type Game = {
|
||||
entrySignature?: string;
|
||||
candidates?: string[];
|
||||
selectionSignature?: string;
|
||||
nexusPlacements?: Record<string,string>;
|
||||
token: string;
|
||||
entrant: string;
|
||||
territory: string;
|
||||
@@ -1150,6 +1154,7 @@ export type Game = {
|
||||
token: string;
|
||||
territory: string;
|
||||
turn: number;
|
||||
nexusEvent?: string;
|
||||
} | null;
|
||||
pendingChoamMove?: MovementOrder | null;
|
||||
choamBaliset?: { turn: number; player: string; territory: string }[];
|
||||
@@ -1276,6 +1281,9 @@ export type Game = {
|
||||
casualties?: {forces: CombatForces; dial: number; support: number; options: Casualties[]; outcome: 'pending' | 'complete'}}[];
|
||||
nexusSardaukarLast?: {event: string; stage: 'pending' | 'active' | 'canceled'};
|
||||
nexusChoamHistory?: {receipt: NexusChoamReceipt; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'; frame: string; parent: string; signature: string}[];
|
||||
nexusMoritaniHistory?: {receipt:NexusMoritaniReceipt;stage:'pending'|'complete'|'canceled';before:string;frame:string;signature:string}[];
|
||||
nexusMoritaniLocations?: Record<string,{event:string;territory:string}>;
|
||||
nexusMoritaniLast?: {event:string;stage:'pending'|'complete'|'canceled'};
|
||||
nexusChoamLast?: {event: string; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'};
|
||||
nexusTraitorPending?: string | null;
|
||||
nexusTraitorParent?: { event: string; signature: string } | null;
|
||||
@@ -3966,12 +3974,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
owner?.faction === 'moritani' &&
|
||||
entrant &&
|
||||
entrant.id !== owner.id &&
|
||||
TERRITORIES.some(
|
||||
(t) =>
|
||||
t.id === entry.territory &&
|
||||
t.type === 'stronghold' &&
|
||||
t.sectors.includes(entry.sector),
|
||||
) &&
|
||||
terrorEntryLocationAllowed(g,entry) && validLocation(entry.territory,entry.sector) &&
|
||||
[
|
||||
'shipment',
|
||||
'movement',
|
||||
@@ -9627,6 +9630,14 @@ function openTerrorEntry(
|
||||
resume,
|
||||
...(ambassadorEvent ? { ambassadorEvent } : {}),
|
||||
};
|
||||
if (!TERROR_STRONGHOLDS.includes(to)) {
|
||||
const proofs = tokens.map(token => {
|
||||
const record = g.nexusMoritaniHistory?.find(r => r.stage === 'complete' && r.receipt.token === token.id && r.receipt.territory === to);
|
||||
requireRule(record && terrorLocationAllowed(g,token.id,to,record.receipt.event), 'The Terror token has lost its original Cunning placement.');
|
||||
return [token.id,record.receipt.event];
|
||||
});
|
||||
g.pendingTerrorEntry.nexusPlacements = Object.fromEntries(proofs);
|
||||
}
|
||||
g.pendingTerrorEntry.entrySignature = terrorEntrySignature(g.pendingTerrorEntry);
|
||||
g.decision = {
|
||||
kind: 'moritaniTerror',
|
||||
@@ -9838,6 +9849,7 @@ function decideTerror(g: Game, p: Player, action: Action) {
|
||||
requireRule(!blocked, blocked ?? 'This alliance is unavailable.');
|
||||
formTerrorAlliance(g, owner, entrant);
|
||||
g.moritaniTerror = returnTerror(g.moritaniTerror!, token.id, random);
|
||||
if (g.nexusMoritaniLocations) delete g.nexusMoritaniLocations[token.id];
|
||||
log(
|
||||
g,
|
||||
`${owner.name} and ${entrant.name} formed an alliance through Enemy of My Enemy; their former alliances ended and the Terror token returned hidden to supply.`,
|
||||
@@ -9881,6 +9893,7 @@ function decideTerror(g: Game, p: Player, action: Action) {
|
||||
const blocked = terrorRevealBlocked(g, entry, token.kind);
|
||||
requireRule(!blocked, blocked ?? 'This Terror effect is unavailable.');
|
||||
g.moritaniTerror = revealTerror(g.moritaniTerror!, token.id);
|
||||
if (g.nexusMoritaniLocations) delete g.nexusMoritaniLocations[token.id];
|
||||
log(
|
||||
g,
|
||||
`${p.name} revealed ${TERROR_DEFINITIONS[token.kind].name} in ${territory(entry.territory).name}.`,
|
||||
@@ -12083,6 +12096,7 @@ function marketGholaIntegrity(g: Game) {
|
||||
nexusAdvisorIntegrity(g);
|
||||
nexusSardaukarIntegrity(g);
|
||||
nexusChoamIntegrity(g);
|
||||
nexusMoritaniIntegrity(g);
|
||||
traitorDeclarationIntegrity(g);
|
||||
nexusInspectionIntegrity(g);
|
||||
homeworldHistoryIntegrity(g);
|
||||
@@ -14761,6 +14775,7 @@ function finishResponse(g: Game, canceled: boolean) {
|
||||
placementCancellation?.receipt.kind === 'moritaniPlacement',
|
||||
'Missing Terror denial receipt.',
|
||||
);
|
||||
finishNexusMoritani(g,true);
|
||||
g.moritaniTerror = placementCancellation.receipt.terror;
|
||||
log(
|
||||
g,
|
||||
@@ -14768,12 +14783,13 @@ function finishResponse(g: Game, canceled: boolean) {
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
g.moritaniTerror = placeTerror(
|
||||
g.moritaniTerror,
|
||||
pending.token,
|
||||
pending.territory,
|
||||
g.turn,
|
||||
);
|
||||
const placed = pending.nexusEvent
|
||||
? quoteNexusMoritaniPlacement(g.moritaniTerror,pending.token,pending.territory,g.turn)
|
||||
: placeTerror(g.moritaniTerror,pending.token,pending.territory,g.turn);
|
||||
finishNexusMoritani(g,false);
|
||||
g.moritaniTerror = placed;
|
||||
if (pending.nexusEvent) (g.nexusMoritaniLocations ??= {})[pending.token] = {event:pending.nexusEvent,territory:pending.territory};
|
||||
else if (g.nexusMoritaniLocations) delete g.nexusMoritaniLocations[pending.token];
|
||||
} catch (error) {
|
||||
throw new RuleError(
|
||||
error instanceof Error ? error.message : 'Invalid Terror placement.',
|
||||
@@ -16115,6 +16131,107 @@ function choamWorthlessBlocked(g: Game, card: string) {
|
||||
g.choamWorthlessBlocked.cards.includes(card)
|
||||
);
|
||||
}
|
||||
function nexusMoritaniOffer(g: Game, owner: string, decision: Game['decision'] = g.decision) {
|
||||
const p = g.players.find(p => p.id === owner);
|
||||
if (!p || p.faction !== 'moritani' || g.nexusCards?.cards?.hands[p.id] !== 'moritani') return null;
|
||||
const tokens = g.moritaniTerror?.tokens.filter(t => t.status === 'available') ?? [];
|
||||
let blocked: string | null = null;
|
||||
if (p.ally) blocked = 'Nexus Cunning requires an unallied Moritani player.';
|
||||
else if (g.status !== 'playing' || g.phase !== 8 || decision?.kind !== 'moritaniPlacement' || decision.player !== p.id)
|
||||
blocked = 'Use Cunning when placing a supply token during your Mentat opportunity.';
|
||||
else if (g.response || g.truthtrance || g.phaseOpening || g.pendingKarama || g.pendingNullentropy || g.pendingTreacheryDiscard)
|
||||
blocked = 'Finish the current interaction before declaring this placement.';
|
||||
else if (g.moritaniTerror?.placementTurn === g.turn) blocked = 'This turn’s placement opportunity is already used.';
|
||||
else if (!tokens.length) blocked = 'No supply token is available. Cunning relocation awaits a ruling.';
|
||||
return {event:JSON.stringify(['nexusMoritani',g.turn,p.id]),blocked,tokens,
|
||||
destinations:TERRITORIES.map(t => t.id)};
|
||||
}
|
||||
function nexusMoritaniIntegrity(g: Game) {
|
||||
const history = g.nexusMoritaniHistory, pending = g.pendingMoritaniPlacement;
|
||||
if (history === undefined) {
|
||||
requireRule(!g.nexusMoritaniLast && !pending?.nexusEvent,
|
||||
'Moritani Cunning has lost its saved placement history.');
|
||||
} else {
|
||||
requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0,
|
||||
'Moritani Cunning requires its original Nexus module and history.');
|
||||
const events = new Set<string>();
|
||||
for (const [index,record] of history.entries()) {
|
||||
requireRule(record && typeof record === 'object' &&
|
||||
Object.keys(record).sort().join(',') === 'before,frame,receipt,signature,stage' &&
|
||||
['pending','complete','canceled'].includes(record.stage) &&
|
||||
typeof record.before === 'string' && typeof record.frame === 'string' &&
|
||||
record.signature === nexusMoritaniRecordSignature(record) && !events.has(record.receipt?.event) &&
|
||||
(record.stage !== 'pending' || index === history.length - 1),
|
||||
'Moritani Cunning has a changed or duplicated placement record.');
|
||||
nexusRule(() => validateNexusMoritani(g,record.receipt));
|
||||
const before = nexusRule(() => JSON.parse(record.before)) as TerrorState;
|
||||
nexusRule(() => quoteNexusMoritaniPlacement(before,record.receipt.token,record.receipt.territory,record.receipt.turn));
|
||||
requireRule(record.frame === JSON.stringify({token:record.receipt.token,territory:record.receipt.territory,turn:record.receipt.turn,nexusEvent:record.receipt.event}),
|
||||
'Moritani Cunning has a changed original placement frame.');
|
||||
const original = before.tokens.find(t => t.id === record.receipt.token)!;
|
||||
const current = g.moritaniTerror?.tokens.find(t => t.id === record.receipt.token);
|
||||
requireRule(!current || current.kind === original.kind, 'Moritani Cunning has changed the original physical token face.');
|
||||
events.add(record.receipt.event);
|
||||
}
|
||||
const last = history.at(-1)!;
|
||||
requireRule(JSON.stringify(g.nexusMoritaniLast) === JSON.stringify({event:last.receipt.event,stage:last.stage}) &&
|
||||
(last.stage === 'pending') === !!pending?.nexusEvent,
|
||||
'Moritani Cunning has lost or reopened its latest placement.');
|
||||
if (last.stage === 'pending') {
|
||||
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 karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
|
||||
return [context?.response,karama?.use.kind === 'cancel' ? karama.use.response : null];
|
||||
}).filter(r => r?.kind === 'moritaniPlacement');
|
||||
requireRule(pending && pending.nexusEvent === last.receipt.event && pending.token === last.receipt.token &&
|
||||
pending.territory === last.receipt.territory && pending.turn === last.receipt.turn &&
|
||||
g.turn === last.receipt.turn && g.phase === 8 && JSON.stringify(pending) === last.frame &&
|
||||
JSON.stringify(g.moritaniTerror) === last.before && responses.length > 0 &&
|
||||
responses.every(r => r!.owner === last.receipt.owner) && g.nexusCards.cards.discard.includes('moritani'),
|
||||
'Moritani Cunning no longer matches its original token, target or response.');
|
||||
}
|
||||
}
|
||||
if (g.nexusMoritaniLocations !== undefined) {
|
||||
requireRule(g.nexusMoritaniLocations && typeof g.nexusMoritaniLocations === 'object' && !Array.isArray(g.nexusMoritaniLocations),
|
||||
'Moritani Cunning has an invalid live placement map.');
|
||||
for (const [id,placement] of Object.entries(g.nexusMoritaniLocations)) {
|
||||
const token = g.moritaniTerror?.tokens.find(t => t.id === id);
|
||||
requireRule(placement && Object.keys(placement).sort().join(',') === 'event,territory' && token?.status === 'placed' &&
|
||||
token.location === placement.territory && terrorLocationAllowed(g,id,placement.territory,placement.event),
|
||||
'Moritani Cunning has a stale or changed live placement.');
|
||||
}
|
||||
}
|
||||
for (const token of g.moritaniTerror?.tokens ?? []) {
|
||||
if (token.status === 'placed') requireRule(token.location && terrorLocationAllowed(g,token.id,token.location),
|
||||
'The Terror token has no valid original placement for this territory.');
|
||||
}
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
for (const entry of [g.pendingTerrorEntry,continuation?.kind === 'terrorDiscard' ? continuation.entry : null])
|
||||
if (entry) requireRule(terrorEntryLocationAllowed(g,entry),
|
||||
'The Terror arrival has lost its original Cunning placement permission.');
|
||||
}
|
||||
function recordNexusMoritani(g: Game, owner: string, token: string, to: string) {
|
||||
const receipt = nexusRule(() => createNexusMoritani(g,owner,token,to));
|
||||
g.pendingMoritaniPlacement = {token,territory:to,turn:g.turn,nexusEvent:receipt.event};
|
||||
const record = {receipt,stage:'pending' as const,before:JSON.stringify(g.moritaniTerror),frame:JSON.stringify(g.pendingMoritaniPlacement),signature:''};
|
||||
record.signature = nexusMoritaniRecordSignature(record);
|
||||
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,owner,g.players));
|
||||
(g.nexusMoritaniHistory ??= []).push(record);
|
||||
g.nexusMoritaniLast = {event:receipt.event,stage:record.stage};
|
||||
}
|
||||
function finishNexusMoritani(g: Game, canceled: boolean) {
|
||||
const pending = g.pendingMoritaniPlacement;
|
||||
if (!pending?.nexusEvent) return;
|
||||
const record = g.nexusMoritaniHistory?.find(r => r.receipt.event === pending.nexusEvent);
|
||||
requireRule(record?.stage === 'pending' && record.frame === JSON.stringify(pending) && record.before === JSON.stringify(g.moritaniTerror),
|
||||
'Moritani Cunning has lost its original placement outcome.');
|
||||
record.stage = canceled ? 'canceled' : 'complete';
|
||||
record.signature = nexusMoritaniRecordSignature(record);
|
||||
g.nexusMoritaniLast = {event:record.receipt.event,stage:record.stage};
|
||||
}
|
||||
function nexusChoamSignature(record: NonNullable<Game['nexusChoamHistory']>[number]) {
|
||||
return JSON.stringify([record.receipt.signature, record.stage, record.frame, record.parent]);
|
||||
}
|
||||
@@ -17926,8 +18043,17 @@ function applyActionInner(
|
||||
} else {
|
||||
const token = stringField(action.token);
|
||||
const to = stringField(action.territory);
|
||||
const nexus = action.nexus !== undefined;
|
||||
if (nexus) {
|
||||
const offer = nexusMoritaniOffer(g,p.id,decision);
|
||||
requireRule(offer && !offer.blocked && offer.event === action.nexus &&
|
||||
Object.keys(action).every(key => ['type','token','territory','nexus'].includes(key)) &&
|
||||
offer.tokens.some(t => t.id === token) && offer.destinations.includes(to),
|
||||
offer?.blocked ?? 'Choose your current Moritani Cunning supply placement.');
|
||||
}
|
||||
try {
|
||||
placeTerror(g.moritaniTerror, token, to, g.turn);
|
||||
if (nexus) quoteNexusMoritaniPlacement(g.moritaniTerror,token,to,g.turn);
|
||||
else placeTerror(g.moritaniTerror, token, to, g.turn);
|
||||
} catch (error) {
|
||||
throw new RuleError(
|
||||
error instanceof Error
|
||||
@@ -17935,7 +18061,9 @@ function applyActionInner(
|
||||
: 'Invalid Terror placement.',
|
||||
);
|
||||
}
|
||||
g.pendingMoritaniPlacement = { token, territory: to, turn: g.turn };
|
||||
if (nexus) recordNexusMoritani(g,p.id,token,to);
|
||||
else g.pendingMoritaniPlacement = { token, territory: to, turn: g.turn };
|
||||
if (nexus) log(g, `${p.name} played Moritani Nexus Cunning for this hidden Terror placement. Karama may prevent the placement; the Nexus remains spent.`);
|
||||
g.response = { kind: 'moritaniPlacement', owner: id, passed: [] };
|
||||
log(g, `${p.name} declared a hidden Terror placement or relocation.`);
|
||||
}
|
||||
@@ -20915,6 +21043,7 @@ export function viewGame(state: Game, id: string) {
|
||||
techTokens: g.techTokens ?? null,
|
||||
strongholdCards: g.strongholdCards ?? null,
|
||||
nexusCards: projectedNexusCards(g, id),
|
||||
nexusMoritani: nexusMoritaniOffer(g,id),
|
||||
nexusTraitors: projectedNexusTraitors(g, id),
|
||||
nexusTleilaxu: projectedNexusTleilaxu(g, id),
|
||||
nexusSuboids: projectedNexusSuboids(g, id),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { terrorLocationAllowed, type TerrorLocationContext } from './terror-location';
|
||||
import type { Game } from './engine';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import { homeworldContext } from './homeworld-game';
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
export type GrummanCollectionContext = Pick<
|
||||
Game,
|
||||
'advanced' | 'players' | 'homeworlds' | 'moritaniTerror'
|
||||
>;
|
||||
> & TerrorLocationContext;
|
||||
export type GrummanCollectionQuote = {
|
||||
player: string;
|
||||
population: number | null;
|
||||
@@ -76,7 +77,7 @@ function stateFor(
|
||||
!['available', 'placed', 'removed', 'extortion'].includes(token.status) ||
|
||||
(token.status === 'placed'
|
||||
? typeof token.location !== 'string' ||
|
||||
!TERROR_STRONGHOLDS.includes(token.location)
|
||||
!terrorLocationAllowed(context,token.id,token.location)
|
||||
: token.location !== null) ||
|
||||
(token.status === 'extortion' && token.kind !== 'extortion')
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { terrorEntryLocationAllowed, type TerrorLocationContext } from './terror-location';
|
||||
import {
|
||||
validateGuildAmbassadorArrivalContext,
|
||||
GuildAmbassadorContinuationError,
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
validateAmbassadorRelocationContext,
|
||||
MovementCancellationError,
|
||||
} from './karama-movement-cancellation';
|
||||
import { TERROR_KINDS, TERROR_STRONGHOLDS } from './moritani-terror';
|
||||
import { TERROR_KINDS } from './moritani-terror';
|
||||
import { validateTerrorEntrySignature } from './terror-entry-receipt';
|
||||
|
||||
export class MoritaniAllianceCancellationError extends Error {
|
||||
@@ -27,7 +28,7 @@ export type MoritaniAllianceCancellationContext = Pick<
|
||||
| 'moritaniTerror'
|
||||
| 'pendingAmbassador'
|
||||
| 'ecazAmbassadors'
|
||||
>;
|
||||
> & TerrorLocationContext;
|
||||
export type MoritaniAllianceCancellationQuote = {
|
||||
kind: 'moritaniAlliance';
|
||||
owner: string;
|
||||
@@ -94,7 +95,7 @@ export function quoteMoritaniAllianceCancellation(
|
||||
);
|
||||
requireSource(
|
||||
typeof entry.territory === 'string' &&
|
||||
TERROR_STRONGHOLDS.includes(entry.territory) &&
|
||||
terrorEntryLocationAllowed(g,entry) &&
|
||||
whole(entry.sector) &&
|
||||
validLocation(entry.territory, entry.sector) &&
|
||||
whole(entry.amount) &&
|
||||
|
||||
@@ -272,9 +272,9 @@ export type TerrorDefinition = Readonly<{
|
||||
|
||||
/** Shared original-language guidance; these paragraphs do not expose token custody. */
|
||||
export const TERROR_COMMON_GAMEPLAY: readonly string[] = Object.freeze([
|
||||
'During Mentat Pause, Moritani may place one hidden Terror token from supply or move one placed token. Choose an ordinary stronghold without another Terror token. Storm does not prevent placement; Homeworlds and the Hidden Mobile Stronghold are excluded.',
|
||||
'A token may be revealed when another faction enters its stronghold by shipment or movement. An ally does not qualify, but Bene Gesserit advisors do. Existing occupation alone does not trigger it. Declining the opportunity keeps the token hidden and unspent.',
|
||||
'When a stronghold contains several Terror tokens, Moritani chooses one for that entry before deciding whether to reveal it or offer an alliance. The remaining tokens stay hidden and do not activate from that same entry.',
|
||||
'During Mentat Pause, Moritani may place one hidden Terror token from supply or move one placed token. Choose an ordinary stronghold without another Terror token. Storm does not prevent placement. Moritani Nexus Cunning can expand a supply placement to an ordinary Arrakis territory, including an existing Terror location; unresolved relocation and Homeworld combinations remain unavailable. Homeworlds and the Hidden Mobile Stronghold are excluded.',
|
||||
'A token may be revealed when another faction enters its territory by shipment or movement. An ally does not qualify, but Bene Gesserit advisors do. Existing occupation alone does not trigger it. Declining the opportunity keeps the token hidden and unspent.',
|
||||
'When a territory contains several Terror tokens, Moritani chooses one for that entry before deciding whether to reveal it or offer an alliance. The remaining tokens stay hidden and do not activate from that same entry.',
|
||||
'Before revealing a token, Moritani may instead offer the entrant an alliance, except when the entrant is Ecaz. Acceptance ends both previous alliances and returns the token to supply without revealing it. Refusal requires the token to be revealed.',
|
||||
'Karama may prevent the placement or relocation opportunity, or prevent Enemy of My Enemy from forming an alliance while leaving revelation available. There is no generic Karama cancellation of a Terror token’s reveal or effect.',
|
||||
'Terror tokens survive storm and Lasgun/Shield explosions. A Richese No-Field placement can trigger one even with a zero token; revealing a No-Field or replacing forces with Face Dancers does not.',
|
||||
@@ -382,7 +382,7 @@ export const TERROR_DEFINITIONS: Readonly<
|
||||
sneakAttack: define(
|
||||
'sneakAttack',
|
||||
'Sneak Attack',
|
||||
'Bring up to five Moritani reserve forces into the stronghold for free.',
|
||||
'Bring up to five Moritani reserve forces into the territory for free.',
|
||||
[
|
||||
'Bring up to five of Moritani’s reserve forces into this territory for free. Respect the storm and occupancy restrictions.',
|
||||
'This entry is permitted even when Atomics Aftermath is present. The effect grants this reserve entry only; it does not grant another ordinary movement or a general shipment exemption.',
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
import { TERRITORIES, splitLocation } from './board';
|
||||
import { TERROR_STRONGHOLDS, type TerrorToken } from './moritani-terror';
|
||||
|
||||
export function nexusMoritaniCanAct(g: GameView): boolean {
|
||||
const owner = g.players.find((player) => player.id === g.me);
|
||||
if (owner?.faction !== 'moritani') return false;
|
||||
const offer = g.nexusMoritani;
|
||||
return !!(
|
||||
g.status === 'playing' &&
|
||||
g.phase === 8 &&
|
||||
!owner.ally &&
|
||||
g.nexusCards?.card === 'moritani' &&
|
||||
offer &&
|
||||
!offer.blocked &&
|
||||
offer.event === JSON.stringify(['nexusMoritani', g.turn, g.me]) &&
|
||||
g.decision?.kind === 'moritaniPlacement' &&
|
||||
g.decision.player === g.me &&
|
||||
!g.response &&
|
||||
!g.truthtrance &&
|
||||
!g.phaseOpening &&
|
||||
!g.automaticContinuationPending &&
|
||||
!g.nexusCards.waiting.length &&
|
||||
!g.nexusTraitors?.pending
|
||||
);
|
||||
}
|
||||
|
||||
/** Native options keep their existing relocation rules; Cunning uses the
|
||||
* server's available supply and static Arrakis destination projection. */
|
||||
export function moritaniPlacementChoices(
|
||||
g: GameView,
|
||||
useNexus: boolean,
|
||||
selectedToken = '',
|
||||
) {
|
||||
if (g.players.find((player) => player.id === g.me)?.faction !== 'moritani')
|
||||
return {
|
||||
tokens: [] as TerrorToken[],
|
||||
destinations: [] as string[],
|
||||
token: undefined,
|
||||
};
|
||||
const tokens = useNexus
|
||||
? (g.nexusMoritani?.tokens ?? []).filter(
|
||||
(token) => token.status === 'available' && token.location === null,
|
||||
)
|
||||
: (g.moritaniTerror?.tokens ?? []).filter(
|
||||
(token): token is TerrorToken =>
|
||||
'kind' in token &&
|
||||
(token.status === 'available' || token.status === 'placed'),
|
||||
);
|
||||
const token =
|
||||
tokens.find((candidate) => candidate.id === selectedToken) ?? tokens[0];
|
||||
const destinations = useNexus
|
||||
? [...(g.nexusMoritani?.destinations ?? [])]
|
||||
: TERROR_STRONGHOLDS.filter(
|
||||
(to) =>
|
||||
to !== token?.location &&
|
||||
!g.moritaniTerror?.tokens.some(
|
||||
(candidate) =>
|
||||
candidate.status === 'placed' && candidate.location === to,
|
||||
),
|
||||
);
|
||||
return { tokens, token, destinations };
|
||||
}
|
||||
|
||||
export function nexusMoritaniAction(
|
||||
g: GameView,
|
||||
event: string,
|
||||
token: string,
|
||||
territory: string,
|
||||
): Action | null {
|
||||
if (!nexusMoritaniCanAct(g) || event !== g.nexusMoritani!.event) return null;
|
||||
const choices = moritaniPlacementChoices(g, true, token);
|
||||
if (
|
||||
!choices.tokens.some((candidate) => candidate.id === token) ||
|
||||
!choices.destinations.includes(territory)
|
||||
)
|
||||
return null;
|
||||
return { type: 'decision', token, territory, nexus: event };
|
||||
}
|
||||
|
||||
/** Spend Cunning for a public enemy destination unavailable to ordinary
|
||||
* placement. Hidden faces are read only from Moritani's authorized supply. */
|
||||
export function nexusMoritaniBotActions(g: GameView): Action[] {
|
||||
if (!nexusMoritaniCanAct(g)) return [];
|
||||
const choices = moritaniPlacementChoices(g, true);
|
||||
const token =
|
||||
choices.tokens.find((candidate) =>
|
||||
['robbery', 'sabotage', 'sneakAttack', 'assassination'].includes(
|
||||
candidate.kind,
|
||||
),
|
||||
) ?? choices.tokens[0];
|
||||
const owner = g.players.find((player) => player.id === g.me)!;
|
||||
const destination = choices.destinations.find(
|
||||
(to) =>
|
||||
(!TERROR_STRONGHOLDS.includes(to) ||
|
||||
g.moritaniTerror?.tokens.some(
|
||||
(candidate) =>
|
||||
candidate.status === 'placed' && candidate.location === to,
|
||||
)) &&
|
||||
TERRITORIES.some((place) => place.id === to) &&
|
||||
g.players.some(
|
||||
(player) =>
|
||||
player.id !== owner.id &&
|
||||
player.id !== owner.ally &&
|
||||
Object.entries(player.forces).some(
|
||||
([key, amount]) =>
|
||||
amount > 0 && splitLocation(key).territory === to,
|
||||
),
|
||||
),
|
||||
);
|
||||
const action =
|
||||
token && destination
|
||||
? nexusMoritaniAction(g, g.nexusMoritani!.event, token.id, destination)
|
||||
: null;
|
||||
return action ? [action] : [];
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { TERRITORIES } from './board';
|
||||
import type { FactionId } from './catalog';
|
||||
import { TERROR_KINDS, type TerrorState } from './moritani-terror';
|
||||
|
||||
export type NexusMoritaniContext = {
|
||||
turn: number;
|
||||
players: readonly { id: string; faction: FactionId }[];
|
||||
};
|
||||
export type NexusMoritaniReceipt = {
|
||||
version: 1;
|
||||
event: string;
|
||||
source: 'mentat';
|
||||
phase: 8;
|
||||
turn: number;
|
||||
owner: string;
|
||||
token: string;
|
||||
territory: string;
|
||||
roster: { id: string; faction: FactionId }[];
|
||||
signature: string;
|
||||
};
|
||||
function requireMoritani(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 whole(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
||||
}
|
||||
function staticTerritory(value: unknown): value is string {
|
||||
return typeof value === 'string' && TERRITORIES.some((t) => t.id === value);
|
||||
}
|
||||
function knownKeys(value: unknown, allowed: string[]): boolean {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
[Object.prototype, null].includes(Object.getPrototypeOf(value)) &&
|
||||
Object.keys(value).every((key) => allowed.includes(key))
|
||||
);
|
||||
}
|
||||
function roster(context: NexusMoritaniContext) {
|
||||
requireMoritani(
|
||||
whole(context.turn) &&
|
||||
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,
|
||||
'Moritani 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: NexusMoritaniReceipt): string {
|
||||
return JSON.stringify([
|
||||
receipt.version,
|
||||
receipt.event,
|
||||
receipt.source,
|
||||
receipt.phase,
|
||||
receipt.turn,
|
||||
receipt.owner,
|
||||
receipt.token,
|
||||
receipt.territory,
|
||||
receipt.roster.map((p) => [p.id, p.faction]),
|
||||
]);
|
||||
}
|
||||
/** Historical evidence does not infer authorization from current token custody.
|
||||
* The engine owns live placement eligibility, cancellation and progress binding. */
|
||||
export function validateNexusMoritani(
|
||||
context: NexusMoritaniContext,
|
||||
receipt: NexusMoritaniReceipt,
|
||||
): void {
|
||||
const seats = roster(context);
|
||||
requireMoritani(
|
||||
knownKeys(receipt, [
|
||||
'version',
|
||||
'event',
|
||||
'source',
|
||||
'phase',
|
||||
'turn',
|
||||
'owner',
|
||||
'token',
|
||||
'territory',
|
||||
'roster',
|
||||
'signature',
|
||||
]) &&
|
||||
Object.keys(receipt).length === 10 &&
|
||||
receipt.version === 1 &&
|
||||
receipt.source === 'mentat' &&
|
||||
receipt.phase === 8 &&
|
||||
whole(receipt.turn) &&
|
||||
receipt.turn <= context.turn &&
|
||||
identifier(receipt.token) &&
|
||||
staticTerritory(receipt.territory) &&
|
||||
seats.some((p) => p.id === receipt.owner && p.faction === 'moritani') &&
|
||||
receipt.event ===
|
||||
JSON.stringify([
|
||||
'nexusMoritani',
|
||||
receipt.turn,
|
||||
receipt.owner,
|
||||
receipt.token,
|
||||
receipt.territory,
|
||||
]) &&
|
||||
JSON.stringify(receipt.roster) === JSON.stringify(seats) &&
|
||||
receipt.signature === signature(receipt),
|
||||
'Moritani Nexus has lost its original placement, timing or owner.',
|
||||
);
|
||||
}
|
||||
export function createNexusMoritani(
|
||||
context: NexusMoritaniContext,
|
||||
owner: string,
|
||||
token: string,
|
||||
territory: string,
|
||||
): NexusMoritaniReceipt {
|
||||
const receipt: NexusMoritaniReceipt = {
|
||||
version: 1,
|
||||
event: JSON.stringify([
|
||||
'nexusMoritani',
|
||||
context.turn,
|
||||
owner,
|
||||
token,
|
||||
territory,
|
||||
]),
|
||||
source: 'mentat',
|
||||
phase: 8,
|
||||
turn: context.turn,
|
||||
owner,
|
||||
token,
|
||||
territory,
|
||||
roster: roster(context),
|
||||
signature: '',
|
||||
};
|
||||
receipt.signature = signature(receipt);
|
||||
validateNexusMoritani(context, receipt);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
/** One earned Mentat supply placement. Static territory validity is intrinsic;
|
||||
* the engine separately proves prior Cunning placements in unusual locations. */
|
||||
export function quoteNexusMoritaniPlacement(
|
||||
state: TerrorState,
|
||||
tokenId: string,
|
||||
territory: string,
|
||||
turn: number,
|
||||
): TerrorState {
|
||||
requireMoritani(
|
||||
whole(turn) &&
|
||||
staticTerritory(territory) &&
|
||||
knownKeys(state, ['tokens', 'placementTurn', 'supplyEpoch']) &&
|
||||
Array.isArray(state.tokens) &&
|
||||
state.tokens.length === TERROR_KINDS.length &&
|
||||
(state.placementTurn === undefined ||
|
||||
(whole(state.placementTurn) && state.placementTurn < turn)) &&
|
||||
(state.supplyEpoch === undefined ||
|
||||
(Number.isSafeInteger(state.supplyEpoch) && state.supplyEpoch >= 0)) &&
|
||||
new Set(state.tokens.map((t) => t?.id)).size === TERROR_KINDS.length &&
|
||||
new Set(state.tokens.map((t) => t?.kind)).size === TERROR_KINDS.length &&
|
||||
state.tokens.every(
|
||||
(t) =>
|
||||
knownKeys(t, ['id', 'kind', 'location', 'status']) &&
|
||||
identifier(t.id) &&
|
||||
TERROR_KINDS.includes(t.kind) &&
|
||||
['available', 'placed', 'removed', 'extortion'].includes(t.status) &&
|
||||
(t.status === 'placed'
|
||||
? staticTerritory(t.location)
|
||||
: t.location === null) &&
|
||||
(t.status !== 'extortion' || t.kind === 'extortion'),
|
||||
),
|
||||
'Moritani Nexus needs six physical Terror tokens, a static Arrakis destination and an unused Mentat placement.',
|
||||
);
|
||||
requireMoritani(
|
||||
state.tokens.some((t) => t.id === tokenId && t.status === 'available'),
|
||||
'Moritani Nexus must place a Terror token from available supply; enhanced relocation is unresolved.',
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
tokens: state.tokens.map((t) =>
|
||||
t.id === tokenId
|
||||
? { ...t, status: 'placed', location: territory }
|
||||
: { ...t },
|
||||
),
|
||||
placementTurn: turn,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { terrorLocationAllowed, type TerrorLocationContext } from './terror-location';
|
||||
import type { Game, ResponseWindow } from './engine';
|
||||
import { TERRITORIES } from './board';
|
||||
import {
|
||||
@@ -39,7 +40,7 @@ export type PlacementCancellationContext = Pick<
|
||||
| 'dukeVidal'
|
||||
| 'dukeAcquisitionTurn'
|
||||
| 'movementRemaining'
|
||||
>;
|
||||
> & TerrorLocationContext;
|
||||
export type PlacementCancellationQuote =
|
||||
| {
|
||||
kind: 'ecazPlacement';
|
||||
@@ -146,12 +147,12 @@ export function quotePlacementCancellation(
|
||||
) &&
|
||||
(t.status === 'placed'
|
||||
? typeof t.location === 'string' &&
|
||||
TERROR_STRONGHOLDS.includes(t.location)
|
||||
terrorLocationAllowed(g,t.id,t.location)
|
||||
: t.location === null) &&
|
||||
(t.status !== 'extortion' || t.kind === 'extortion'),
|
||||
) &&
|
||||
state.tokens.filter((t) => t.id === pending.token).length === 1 &&
|
||||
TERROR_STRONGHOLDS.includes(pending.territory),
|
||||
(pending.nexusEvent ? terrorLocationAllowed(g,pending.token,pending.territory,pending.nexusEvent,true) : TERROR_STRONGHOLDS.includes(pending.territory)),
|
||||
'No current physical Terror placement is awaiting cancellation.',
|
||||
);
|
||||
// Grumman may have created legal stacks earlier. Denial validates physical
|
||||
|
||||
+8
-6
@@ -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, Bene Gesserit, Emperor and five CHOAM Cunning effects 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, Emperor, five CHOAM Cunning effects and Moritani Cunning supply placement 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.',
|
||||
@@ -3366,15 +3366,17 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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.',
|
||||
'CHOAM Cunning spends its Nexus card to use one held Treachery Card for one chosen Worthless effect at that effect’s normal timing, in Basic and Advanced. Choose the physical card separately from the effect. Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont are supported; Kull Wahad’s Karama-prevention reaction remains unavailable.',
|
||||
'One Karama response covers the CHOAM effect. Cancellation leaves the chosen Treachery Card in hand and the Nexus spent. An allowed use discards the actual card once, retaining its printed identity and applicable discard consequences; it does not create a named Worthless card or a turn-long conversion permission.',
|
||||
'Moritani Cunning modifies its normal Mentat placement: choose one available supply token and a territory on the printed Arrakis board, including a territory already containing Terror. The explicit Cunning toggle keeps ordinary placement and relocation unchanged. Homeworlds, the Hidden Mobile Stronghold, Cunning relocation and combined Grumman use are unavailable.',
|
||||
'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules; this does not implement Atomics, Extortion or complete Moritani games.',
|
||||
'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, whole-territory advisor conversion, temporary Emperor Sardaukar and five source-aware CHOAM Cunning effects. 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/choam-power-options.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation and counted advisor-territory selection, temporary Sardaukar and CHOAM physical-card/effect selection 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', 'components/choam-power-cost.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', 'game/choam-power-options.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and the integrated effect families retain explicit unresolved boundaries, including fresh advisors, storm and borrowed Voice.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md'] },
|
||||
{ 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', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-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, temporary Emperor Sardaukar and five source-aware CHOAM Cunning effects and Moritani supply placement beyond ordinary strongholds. 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/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation and counted advisor-territory selection, temporary Sardaukar and CHOAM physical-card/effect selection 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', 'components/choam-power-cost.tsx', 'components/moritani-terror.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', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and the integrated effect families retain explicit unresolved boundaries, including fresh advisors, storm and borrowed Voice.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md'] },
|
||||
{ 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', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts'] },
|
||||
],
|
||||
},
|
||||
...[
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Game } from './engine';
|
||||
import { validLocation } from './board';
|
||||
import { TERRITORIES, validLocation } from './board';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import { TERROR_STRONGHOLDS } from './moritani-terror';
|
||||
|
||||
@@ -20,19 +20,21 @@ export function terrorEntrySignature(entry: TerrorEntry): string {
|
||||
entry.phase,
|
||||
entry.resume,
|
||||
entry.ambassadorEvent ?? null,
|
||||
...(entry.nexusPlacements ? [entry.nexusPlacements] : []),
|
||||
]);
|
||||
}
|
||||
|
||||
export function terrorSelectionSignature(entry: TerrorEntry): string {
|
||||
return JSON.stringify(['terrorSelection', entry.candidates, entry.token, entry.entrant,
|
||||
entry.territory, entry.sector, entry.amount, entry.elite, entry.cause,
|
||||
entry.turn, entry.phase, entry.resume, entry.ambassadorEvent ?? null]);
|
||||
entry.turn, entry.phase, entry.resume, entry.ambassadorEvent ?? null,
|
||||
...(entry.nexusPlacements ? [entry.nexusPlacements] : [])]);
|
||||
}
|
||||
|
||||
/** Older saves without a receipt remain explicit legacy state. Never invent
|
||||
* their original entry count from the destination's present occupants. */
|
||||
export function validateTerrorEntrySignature(entry: TerrorEntry): void {
|
||||
if (entry.entrySignature === undefined && entry.candidates === undefined && entry.selectionSignature === undefined && entry.stage !== 'select') return;
|
||||
if (entry.entrySignature === undefined && entry.candidates === undefined && entry.selectionSignature === undefined && entry.nexusPlacements === undefined && entry.stage !== 'select') return;
|
||||
if (
|
||||
typeof entry.entrySignature !== 'string' ||
|
||||
typeof entry.token !== 'string' ||
|
||||
@@ -48,7 +50,12 @@ export function validateTerrorEntrySignature(entry: TerrorEntry): void {
|
||||
: entry.selectionSignature !== terrorSelectionSignature(entry))) ||
|
||||
typeof entry.entrant !== 'string' ||
|
||||
!entry.entrant ||
|
||||
!TERROR_STRONGHOLDS.includes(entry.territory) ||
|
||||
(!TERROR_STRONGHOLDS.includes(entry.territory) &&
|
||||
(!TERRITORIES.some(t => t.id === entry.territory) || !entry.nexusPlacements)) ||
|
||||
(entry.nexusPlacements !== undefined &&
|
||||
(!entry.nexusPlacements || typeof entry.nexusPlacements !== 'object' || Array.isArray(entry.nexusPlacements) ||
|
||||
Object.keys(entry.nexusPlacements).length !== (entry.candidates ?? [entry.token]).length ||
|
||||
(entry.candidates ?? [entry.token]).some(id => typeof entry.nexusPlacements?.[id] !== 'string' || !entry.nexusPlacements[id]))) ||
|
||||
!Number.isSafeInteger(entry.sector) ||
|
||||
!validLocation(entry.territory, entry.sector) ||
|
||||
!Number.isSafeInteger(entry.amount) ||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Game } from './engine';
|
||||
import { TERRITORIES } from './board';
|
||||
import { TERROR_STRONGHOLDS } from './moritani-terror';
|
||||
import { validateNexusMoritani } from './nexus-moritani';
|
||||
|
||||
export type TerrorLocationContext = Pick<Game,'players'> & Partial<Pick<Game,'turn'|'nexusMoritaniHistory'|'nexusMoritaniLocations'>>;
|
||||
type Record = NonNullable<Game['nexusMoritaniHistory']>[number];
|
||||
export function nexusMoritaniRecordSignature(record: Record): string {
|
||||
return JSON.stringify([record.receipt.signature,record.stage,record.before,record.frame]);
|
||||
}
|
||||
/** A historical grant survives card disposal, population changes and revelation.
|
||||
* It never grants a new placement action or admits Homeworld/HMS destinations. */
|
||||
export function terrorLocationAllowed(context: TerrorLocationContext, token: string, territory: string, event?: string, pending = false, historical = false): boolean {
|
||||
if (!event && TERROR_STRONGHOLDS.includes(territory)) return true;
|
||||
if (!TERRITORIES.some(t => t.id === territory) || !Number.isSafeInteger(context.turn)) return false;
|
||||
const record = context.nexusMoritaniHistory?.find(candidate =>
|
||||
candidate.receipt?.token === token && candidate.receipt.territory === territory &&
|
||||
(!event || candidate.receipt.event === event) && candidate.stage === (pending ? 'pending' : 'complete'));
|
||||
if (!record || record.signature !== nexusMoritaniRecordSignature(record)) return false;
|
||||
try { validateNexusMoritani({turn:context.turn!,players:context.players},record.receipt); }
|
||||
catch { return false; }
|
||||
if (pending) return record.receipt.turn === context.turn;
|
||||
return historical || (context.nexusMoritaniLocations?.[token]?.event === record.receipt.event &&
|
||||
context.nexusMoritaniLocations[token].territory === territory);
|
||||
}
|
||||
export function terrorEntryLocationAllowed(context: TerrorLocationContext, entry: NonNullable<Game['pendingTerrorEntry']>): boolean {
|
||||
if (TERROR_STRONGHOLDS.includes(entry.territory) && !entry.nexusPlacements) return true;
|
||||
const ids = entry.candidates ?? [entry.token];
|
||||
return !!entry.nexusPlacements && Object.keys(entry.nexusPlacements).length === ids.length && ids.every(id =>
|
||||
typeof entry.nexusPlacements?.[id] === 'string' &&
|
||||
terrorLocationAllowed(context,id,entry.territory,entry.nexusPlacements[id],false,true));
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeNexusGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Game,
|
||||
type Action,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { drawNexusCard } from '../game/nexus-cards';
|
||||
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { TERROR_KINDS, type TerrorKind } from '../game/moritani-terror';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { nexusTraitorInventory } from './fixture-nexus-traitors';
|
||||
import {
|
||||
enterNexusSpice,
|
||||
finishNexusSpice,
|
||||
orderNexusSpice,
|
||||
nexusAllow,
|
||||
nexusReload,
|
||||
} from './fixture-nexus-cards';
|
||||
|
||||
export type NexusMoritaniFixture = {
|
||||
g: Game;
|
||||
owner: string;
|
||||
target: string;
|
||||
observer: string;
|
||||
karama?: string;
|
||||
};
|
||||
/** Final faction/seat/module choices precede genuine initialization. The later
|
||||
* conserved position is staged before actual Collection -> Mentat actions. */
|
||||
export function nexusMoritaniFixture(
|
||||
options: {
|
||||
seatIds?: [string, string, string];
|
||||
advanced?: boolean;
|
||||
homeworlds?: boolean;
|
||||
native?: number;
|
||||
karama?: boolean;
|
||||
stack?: boolean;
|
||||
} = {},
|
||||
): NexusMoritaniFixture {
|
||||
const [owner, target, observer] = options.seatIds ?? ['p', 'q', 'r'];
|
||||
let g = createGame(
|
||||
'MORITANINEXUS',
|
||||
newPlayer(owner, 'Moritani', 'moritani'),
|
||||
options.advanced ?? false,
|
||||
[],
|
||||
);
|
||||
joinGame(g, newPlayer(target, 'Guild', 'guild'));
|
||||
joinGame(g, newPlayer(observer, 'Fremen', 'fremen'));
|
||||
if (options.homeworlds)
|
||||
g = applyAction(g, owner, { 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 i = 0; g.status === 'setup' && i < 80; i++) {
|
||||
let next: Game | undefined;
|
||||
for (const p of g.players) {
|
||||
const v = viewGame(g, p.id);
|
||||
v.players.find((s) => s.id === p.id)!.bot = 'Easy';
|
||||
const a = botActions(v)[0];
|
||||
if (a) {
|
||||
next = applyAction(g, p.id, a);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(next, `Setup stalled at ${g.setupStage}/${g.decision?.kind}`);
|
||||
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) {
|
||||
Object.assign(p, {
|
||||
forces: {},
|
||||
reserves: 20,
|
||||
tanks: 0,
|
||||
spice: 20,
|
||||
shipped: true,
|
||||
moved: 0,
|
||||
});
|
||||
if (p.elites)
|
||||
Object.assign(p.elites, { forces: {}, reserves: 3, tanks: 0 });
|
||||
}
|
||||
// Complete the first genuine Mentat opportunity before staging turn two.
|
||||
// Stack fixtures actually place Sabotage at that first opportunity.
|
||||
Object.assign(g, {
|
||||
phase: 5,
|
||||
active: target,
|
||||
movementRemaining: [target],
|
||||
order: [owner, target, observer],
|
||||
ready: [],
|
||||
response: null,
|
||||
decision: null,
|
||||
phaseOpening: null,
|
||||
spice: {},
|
||||
storm: 18,
|
||||
});
|
||||
g = applyAction(g, target, { type: 'endMovement' });
|
||||
if (g.decision?.kind === 'grummanCollection')
|
||||
g = applyAction(g, owner, {
|
||||
type: 'decision',
|
||||
event: g.grummanCollection!.event,
|
||||
decline: true,
|
||||
});
|
||||
for (const id of g.order) g = applyAction(g, id, { type: 'ready' });
|
||||
assert.equal(g.decision?.kind, 'moritaniPlacement');
|
||||
g = options.stack
|
||||
? nexusAllow(
|
||||
applyAction(g, owner, {
|
||||
type: 'decision',
|
||||
token: nexusMoritaniToken(g, 'sabotage').id,
|
||||
territory: 'arrakeen',
|
||||
}),
|
||||
)
|
||||
: applyAction(g, owner, { type: 'decision', decline: true });
|
||||
let nextSpiceOrdered = false;
|
||||
for (let step = 0; g.phase !== 2 && step < 80; step++) {
|
||||
if (g.phase === 1 && !nextSpiceOrdered) {
|
||||
orderNexusSpice(g, ['land', 'land']);
|
||||
nextSpiceOrdered = true;
|
||||
}
|
||||
g = automaticStep(g);
|
||||
}
|
||||
assert.equal(g.turn, 2);
|
||||
assert.equal(g.phase, 2);
|
||||
for (const p of g.players) {
|
||||
g.deck.push(...p.hand.splice(0));
|
||||
Object.assign(p, {
|
||||
forces: {},
|
||||
reserves: 20,
|
||||
tanks: 0,
|
||||
spice: 20,
|
||||
shipped: true,
|
||||
moved: 0,
|
||||
});
|
||||
if (p.elites)
|
||||
Object.assign(p.elites, { forces: {}, reserves: 3, tanks: 0 });
|
||||
}
|
||||
const native = options.native ?? 8;
|
||||
g.players[0].reserves = native;
|
||||
g.players[0].forces = { 'polar_sink:0': 20 - native };
|
||||
const cards = g.nexusCards!.cards!;
|
||||
cards.deck = [
|
||||
'moritani',
|
||||
...cards.deck.filter((card) => card !== 'moritani'),
|
||||
];
|
||||
g.nexusCards!.cards = drawNexusCard(cards, owner, g.players, () => 0);
|
||||
const karama =
|
||||
options.karama === false
|
||||
? undefined
|
||||
: holdNexusMoritaniCard(g, target, 'karama').id;
|
||||
Object.assign(g, {
|
||||
turn: 2,
|
||||
phase: 5,
|
||||
active: target,
|
||||
movementRemaining: [target],
|
||||
order: [owner, target, observer],
|
||||
ready: [],
|
||||
response: null,
|
||||
decision: null,
|
||||
phaseOpening: null,
|
||||
spice: {},
|
||||
storm: 18,
|
||||
});
|
||||
g = applyAction(g, target, { type: 'endMovement' });
|
||||
assert.equal(g.phase, 7);
|
||||
if (g.decision?.kind === 'grummanCollection')
|
||||
g = applyAction(g, owner, {
|
||||
type: 'decision',
|
||||
event: g.grummanCollection!.event,
|
||||
decline: true,
|
||||
});
|
||||
for (const id of g.order) g = applyAction(g, id, { type: 'ready' });
|
||||
assert.equal(g.phase, 8);
|
||||
assert.equal(g.decision?.kind, 'moritaniPlacement');
|
||||
nexusMoritaniInventory(g);
|
||||
return { g, owner, target, observer, karama };
|
||||
}
|
||||
export function holdNexusMoritaniCard(g: Game, owner: string, kind: string) {
|
||||
const i = g.deck.findIndex((c) => c.kind === kind || c.effect === kind);
|
||||
assert.ok(i >= 0);
|
||||
const card = g.deck.splice(i, 1)[0];
|
||||
g.players.find((p) => p.id === owner)!.hand.push(card);
|
||||
return card;
|
||||
}
|
||||
function automaticStep(g: Game): Game {
|
||||
for (const p of g.players) {
|
||||
const v = viewGame(g, p.id);
|
||||
v.players.find((s) => s.id === p.id)!.bot = 'Easy';
|
||||
const action = botActions(v)[0];
|
||||
if (action) return applyAction(g, p.id, action);
|
||||
}
|
||||
throw new Error(
|
||||
`Phase progression stalled at ${g.phase}/${g.decision?.kind}/${g.response?.kind}`,
|
||||
);
|
||||
}
|
||||
export const nexusMoritaniToken = (g: Game, kind: TerrorKind) =>
|
||||
g.moritaniTerror!.tokens.find((token) => token.kind === kind)!;
|
||||
export function nexusMoritaniRequest(
|
||||
f: NexusMoritaniFixture,
|
||||
kind: TerrorKind = 'robbery',
|
||||
territory = 'red_chasm',
|
||||
g = f.g,
|
||||
): Action {
|
||||
return {
|
||||
type: 'decision',
|
||||
token: nexusMoritaniToken(g, kind).id,
|
||||
territory,
|
||||
nexus: viewGame(g, f.owner).nexusMoritani!.event,
|
||||
};
|
||||
}
|
||||
export function nexusMoritaniInventory(g: Game) {
|
||||
nexusTraitorInventory(g);
|
||||
if (g.homeworlds) homeworldGameIntegrity(g);
|
||||
assert.deepEqual(
|
||||
g.moritaniTerror!.tokens.map((t) => t.kind).sort(),
|
||||
[...TERROR_KINDS].sort(),
|
||||
);
|
||||
assert.equal(new Set(g.moritaniTerror!.tokens.map((t) => t.id)).size, 6);
|
||||
}
|
||||
/** Advance actual phase boundaries with legal bot choices before shipping;
|
||||
* signed placement and Nexus phase history are retained unchanged. */
|
||||
export function nexusMoritaniMovement(state: Game): Game {
|
||||
let g = nexusReload(state);
|
||||
const target = g.players.find((p) => p.faction === 'guild')!;
|
||||
// Finish actual Mentat, Storm, Spice Blow, Charity, Bidding and Revival.
|
||||
// In particular the Nexus phase receipt is created by the real next Storm.
|
||||
for (let step = 0; g.phase !== 5 && step < 160; step++) {
|
||||
let next: Game | undefined;
|
||||
for (const p of g.players) {
|
||||
const view = viewGame(g, p.id);
|
||||
view.players.find((seat) => seat.id === p.id)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
if (action) {
|
||||
next = applyAction(g, p.id, action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
next,
|
||||
`Next-turn progression stalled at ${g.phase}/${g.decision?.kind}/${g.response?.kind}`,
|
||||
);
|
||||
g = next;
|
||||
}
|
||||
assert.equal(g.phase, 5);
|
||||
for (let step = 0; g.active !== target.id && step < g.players.length; step++)
|
||||
g = applyAction(g, g.active!, { type: 'endMovement' });
|
||||
assert.equal(g.active, target.id);
|
||||
assert.equal(g.players.find((p) => p.id === target.id)!.shipped, false);
|
||||
nexusMoritaniInventory(g);
|
||||
return g;
|
||||
}
|
||||
export function nexusMoritaniArrival(
|
||||
state: Game,
|
||||
territory: string,
|
||||
amount = 3,
|
||||
): Game {
|
||||
let g = nexusMoritaniMovement(state);
|
||||
const target = g.players.find((p) => p.faction === 'guild')!;
|
||||
const sector = TERRITORIES.find((t) => t.id === territory)!.sectors.find(
|
||||
(s) => s !== g.storm,
|
||||
)!;
|
||||
g = applyAction(g, target.id, { type: 'ship', territory, sector, amount });
|
||||
nexusMoritaniInventory(g);
|
||||
return g;
|
||||
}
|
||||
export const nexusMoritaniAllow = nexusAllow;
|
||||
export const nexusMoritaniReload = nexusReload;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { nexusMoritaniFixture, nexusMoritaniRequest, nexusMoritaniAllow, nexusMoritaniMovement } from './fixture-nexus-moritani';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
@@ -258,22 +259,22 @@ void test('normal movement triggers after moving forces once, while an internal
|
||||
const done = decide(reload(entered), { decline: true });
|
||||
assert.deepEqual(done.players, entered.players);
|
||||
rejected(done, 'e', action);
|
||||
// Ordinary strongholds each have one sector. This injected historical location exercises
|
||||
// the general same-territory guard independently of present placement restrictions.
|
||||
const internal = fixture();
|
||||
internal.moritaniTerror!.tokens.find(
|
||||
(token) => token.status === 'placed',
|
||||
)!.location = 'imperial_basin';
|
||||
player(internal, 'e').forces = { 'imperial_basin:10': 3 };
|
||||
player(internal, 'e').reserves = 17;
|
||||
const shifted = applyAction(internal, 'e', {
|
||||
// A real Cunning supply placement earns the otherwise unavailable desert
|
||||
// location. Moving between its sectors is still not a new territory entry.
|
||||
const f = nexusMoritaniFixture();
|
||||
const placed = nexusMoritaniAllow(applyAction(f.g,f.owner,
|
||||
nexusMoritaniRequest(f,'robbery','imperial_basin')));
|
||||
const internal = nexusMoritaniMovement(placed);
|
||||
internal.storm = 18;
|
||||
Object.assign(player(internal,f.target), {forces:{'imperial_basin:10':3},reserves:17,shipped:true,moved:0});
|
||||
const shifted = applyAction(internal, f.target, {
|
||||
...action,
|
||||
territory: 'imperial_basin',
|
||||
sector: 11,
|
||||
});
|
||||
assert.equal(shifted.pendingTerrorEntry ?? null, null);
|
||||
assert.equal(shifted.decision, null);
|
||||
assert.equal(player(shifted, 'e').forces['imperial_basin:11'], 2);
|
||||
assert.equal(player(shifted, f.target).forces['imperial_basin:11'], 2);
|
||||
});
|
||||
|
||||
void test('overlapping Guild income and Bene Gesserit arrival reactions reject the entire shipment atomically', () => {
|
||||
|
||||
@@ -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, Bene Gesserit, Emperor and CHOAM Cunning/);
|
||||
assert.match(html, /Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit, Emperor, CHOAM and Moritani Cunning/);
|
||||
assert.match(html, /other unfinished effects remain unavailable/);
|
||||
assert.doesNotMatch(html, /other card effects are not playable yet/);
|
||||
assert.match(html, /10 in deck · 0 discarded · 2 held/);
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import {
|
||||
applyAction,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Game,
|
||||
type Action,
|
||||
} from '../game/engine';
|
||||
import {
|
||||
nexusMoritaniFixture,
|
||||
nexusMoritaniToken,
|
||||
nexusMoritaniRequest,
|
||||
nexusMoritaniAllow,
|
||||
nexusMoritaniReload,
|
||||
nexusMoritaniArrival,
|
||||
holdNexusMoritaniCard,
|
||||
} from './fixture-nexus-moritani';
|
||||
|
||||
function rejectCorruption(g: Game) {
|
||||
const before = nexusMoritaniReload(g);
|
||||
for (const p of g.players) assert.throws(() => viewGame(g, p.id));
|
||||
assert.throws(() => normalizeAutomaticGame(g));
|
||||
assert.throws(() => applyAction(g, g.players[0].id, { type: 'advanceBots' }));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
function placed(kind: 'robbery' | 'sabotage' = 'robbery') {
|
||||
const f = nexusMoritaniFixture();
|
||||
holdNexusMoritaniCard(f.g, f.target, 'shield');
|
||||
const g = nexusMoritaniAllow(
|
||||
applyAction(f.g, f.owner, nexusMoritaniRequest(f, kind)),
|
||||
);
|
||||
return { f, g };
|
||||
}
|
||||
|
||||
void test('completed placement binds its physical face, and ordinary later relocation retires its live Cunning location', () => {
|
||||
const { f, g: initial } = placed();
|
||||
const swapped = nexusMoritaniReload(initial);
|
||||
const first = nexusMoritaniToken(swapped, 'robbery'),
|
||||
other = nexusMoritaniToken(swapped, 'sabotage');
|
||||
[first.kind, other.kind] = [other.kind, first.kind];
|
||||
rejectCorruption(swapped);
|
||||
let g = nexusMoritaniArrival(initial, 'red_chasm');
|
||||
g = applyAction(g, f.owner, { type: 'decision', decline: true });
|
||||
while (g.phase === 5) g = applyAction(g, g.active!, { type: 'endMovement' });
|
||||
assert.equal(g.phase, 7);
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
assert.equal(g.decision?.kind, 'moritaniPlacement');
|
||||
const id = nexusMoritaniToken(g, 'robbery').id;
|
||||
g = nexusMoritaniAllow(
|
||||
applyAction(g, f.owner, {
|
||||
type: 'decision',
|
||||
token: id,
|
||||
territory: 'carthag',
|
||||
}),
|
||||
);
|
||||
assert.equal(nexusMoritaniToken(g, 'robbery').location, 'carthag');
|
||||
assert.equal(g.nexusMoritaniLocations?.[id], undefined);
|
||||
for (const p of g.players) assert.doesNotThrow(() => viewGame(g, p.id));
|
||||
const stale = nexusMoritaniReload(g);
|
||||
nexusMoritaniToken(stale, 'robbery').location = 'red_chasm';
|
||||
rejectCorruption(stale);
|
||||
});
|
||||
|
||||
void test('a genuine Cunning arrival cannot downgrade to legacy by deleting only its original arrival signature', () => {
|
||||
const { g } = placed();
|
||||
const arrived = nexusMoritaniArrival(g, 'red_chasm');
|
||||
assert.ok(arrived.pendingTerrorEntry?.nexusPlacements);
|
||||
assert.equal(arrived.pendingTerrorEntry.candidates, undefined);
|
||||
for (const mode of ['signature', 'count', 'both'] as const) {
|
||||
const bad = nexusMoritaniReload(arrived);
|
||||
delete bad.pendingTerrorEntry!.entrySignature;
|
||||
if (mode === 'count') bad.pendingTerrorEntry!.amount = 100;
|
||||
if (mode === 'both') delete bad.pendingTerrorEntry!.nexusPlacements;
|
||||
rejectCorruption(bad);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Truthtrance preserves the original pending Cunning placement and its cancellation response', () => {
|
||||
const f = nexusMoritaniFixture();
|
||||
const truth = holdNexusMoritaniCard(f.g, f.observer, 'truthtrance');
|
||||
let g = applyAction(f.g, f.owner, nexusMoritaniRequest(f));
|
||||
const response = structuredClone(g.response),
|
||||
history = structuredClone(g.nexusMoritaniHistory);
|
||||
g = applyAction(g, f.observer, { type: 'card', card: truth.id });
|
||||
while (g.truthtrance?.stage === 'priority')
|
||||
g = applyAction(
|
||||
g,
|
||||
g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id,
|
||||
{ type: 'truthPass' },
|
||||
);
|
||||
g = applyAction(g, f.observer, {
|
||||
type: 'truthAsk',
|
||||
question: {
|
||||
kind: 'fact',
|
||||
target: f.owner,
|
||||
fact: { kind: 'hand', name: 'Shield' },
|
||||
},
|
||||
});
|
||||
g = applyAction(nexusMoritaniReload(g), f.owner, {
|
||||
type: 'truthAnswer',
|
||||
answer: 'no',
|
||||
});
|
||||
assert.deepEqual(g.response, response);
|
||||
assert.deepEqual(g.nexusMoritaniHistory, history);
|
||||
g = nexusMoritaniAllow(g);
|
||||
assert.equal(nexusMoritaniToken(g, 'robbery').location, 'red_chasm');
|
||||
assert.equal(g.discard.filter((c) => c.id === truth.id).length, 1);
|
||||
});
|
||||
|
||||
void test('a live Cunning placement rejects missing parent response, pending marker or changed original supply', () => {
|
||||
const f = nexusMoritaniFixture();
|
||||
const declared = applyAction(f.g, f.owner, nexusMoritaniRequest(f));
|
||||
for (const edit of [
|
||||
(g: Game) => {
|
||||
g.response = null;
|
||||
},
|
||||
(g: Game) => {
|
||||
delete g.pendingMoritaniPlacement!.nexusEvent;
|
||||
},
|
||||
(g: Game) => {
|
||||
delete g.nexusMoritaniLast;
|
||||
},
|
||||
(g: Game) => {
|
||||
delete g.nexusMoritaniHistory;
|
||||
},
|
||||
(g: Game) => {
|
||||
nexusMoritaniToken(g, 'sabotage').status = 'removed';
|
||||
},
|
||||
]) {
|
||||
const bad = nexusMoritaniReload(declared);
|
||||
edit(bad);
|
||||
rejectCorruption(bad);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a genuine non-stronghold Sabotage discard retains its original placement proof after revelation and restores exactly once', () => {
|
||||
const { f, g } = placed('sabotage');
|
||||
const arrived = nexusMoritaniArrival(g, 'red_chasm');
|
||||
const originalHandCount = arrived.players.find((p) => p.id === f.target)!.hand
|
||||
.length;
|
||||
const observed: {
|
||||
applyActionInner?: (g: Game, id: string, action: Action) => Game;
|
||||
} = {};
|
||||
runInNewContext(
|
||||
ts.transpileModule(
|
||||
readFileSync(new URL('../game/engine.ts', import.meta.url), 'utf8') +
|
||||
'\nexport { applyActionInner };\n',
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
},
|
||||
).outputText,
|
||||
{
|
||||
exports: observed,
|
||||
require: createRequire(new URL('../game/engine.ts', import.meta.url)),
|
||||
crypto,
|
||||
structuredClone,
|
||||
TextEncoder,
|
||||
JSON,
|
||||
},
|
||||
);
|
||||
const pending = nexusMoritaniReload(
|
||||
observed.applyActionInner!(arrived, f.owner, {
|
||||
type: 'decision',
|
||||
reveal: true,
|
||||
}),
|
||||
);
|
||||
const continuation = pending.pendingTreacheryDiscard!.continuation;
|
||||
assert.equal(continuation.kind, 'terrorDiscard');
|
||||
if (continuation.kind !== 'terrorDiscard')
|
||||
throw new Error('Missing genuine Sabotage discard');
|
||||
assert.ok(continuation.entry.nexusPlacements);
|
||||
assert.equal(nexusMoritaniToken(pending, 'sabotage').status, 'removed');
|
||||
for (const p of pending.players)
|
||||
assert.doesNotThrow(() => viewGame(pending, p.id));
|
||||
const finished = normalizeAutomaticGame(nexusMoritaniReload(pending));
|
||||
assert.equal(finished.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual(finished.discard, pending.discard);
|
||||
assert.equal(finished.discard.length, 1);
|
||||
assert.equal(
|
||||
finished.players.find((p) => p.id === f.target)!.hand.length,
|
||||
originalHandCount - 1,
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeAutomaticGame(nexusMoritaniReload(finished)),
|
||||
nexusMoritaniReload(finished),
|
||||
);
|
||||
for (const edit of [
|
||||
(bad: Game) => {
|
||||
delete bad.nexusMoritaniHistory;
|
||||
},
|
||||
(bad: Game) => {
|
||||
const child = bad.pendingTreacheryDiscard!.continuation;
|
||||
if (child.kind === 'terrorDiscard') delete child.entry.nexusPlacements;
|
||||
},
|
||||
(bad: Game) => {
|
||||
const child = bad.pendingTreacheryDiscard!.continuation;
|
||||
if (child.kind === 'terrorDiscard')
|
||||
child.entry.nexusPlacements![child.entry.token] = 'different-placement';
|
||||
},
|
||||
]) {
|
||||
const bad = nexusMoritaniReload(pending);
|
||||
edit(bad);
|
||||
rejectCorruption(bad);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { nexusMoritaniBotActions } from '../game/nexus-moritani-options';
|
||||
import {
|
||||
nexusMoritaniFixture,
|
||||
nexusMoritaniInventory,
|
||||
} from './fixture-nexus-moritani';
|
||||
|
||||
void test('all four Moritani profiles use Cunning for a public opposing Arrakis group and keep token custody until allowance', () => {
|
||||
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const f = nexusMoritaniFixture();
|
||||
// Conserved public target position, staged before the real declaration.
|
||||
const target = f.g.players.find((player) => player.id === f.target)!;
|
||||
target.reserves -= 3;
|
||||
target.forces['red_chasm:7'] = 3;
|
||||
const v = viewGame(f.g, f.owner);
|
||||
v.players.find((player) => player.id === f.owner)!.bot = profile;
|
||||
const before = structuredClone(v);
|
||||
const action = botActions(v)[0];
|
||||
assert.deepEqual([action], nexusMoritaniBotActions(v));
|
||||
assert.equal(action.territory, 'red_chasm');
|
||||
assert.equal(action.nexus, v.nexusMoritani!.event);
|
||||
const pending = applyAction(f.g, f.owner, action);
|
||||
assert.equal(pending.response?.kind, 'moritaniPlacement');
|
||||
assert.deepEqual(pending.moritaniTerror, f.g.moritaniTerror);
|
||||
nexusMoritaniInventory(pending);
|
||||
assert.deepEqual(v, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Cunning policy reads only public opposing armies, supports an existing stack and retains ordinary fallback', () => {
|
||||
const f = nexusMoritaniFixture({ stack: true });
|
||||
const target = f.g.players.find((player) => player.id === f.target)!;
|
||||
target.reserves -= 2;
|
||||
target.forces['arrakeen:10'] = 2;
|
||||
const v = viewGame(f.g, f.owner);
|
||||
for (const p of v.players.filter((player) => player.id !== f.owner))
|
||||
for (const key of ['hand', 'traitors', 'spice'])
|
||||
Object.defineProperty(p, key, {
|
||||
get() {
|
||||
throw new Error('Private opponent data read');
|
||||
},
|
||||
});
|
||||
assert.equal(nexusMoritaniBotActions(v)[0].territory, 'arrakeen');
|
||||
v.nexusMoritani!.blocked = 'Not available now';
|
||||
assert.deepEqual(nexusMoritaniBotActions(v), []);
|
||||
const ordinary = viewGame(f.g, f.owner);
|
||||
ordinary.nexusMoritani = null;
|
||||
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
ordinary.players.find((player) => player.id === f.owner)!.bot = profile;
|
||||
const action = botActions(ordinary)[0];
|
||||
assert.equal(action.type, 'decision');
|
||||
assert.equal(action.nexus, undefined);
|
||||
nexusMoritaniInventory(applyAction(f.g, f.owner, action));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { applyAction, viewGame, type GameView } from '../game/engine';
|
||||
import {
|
||||
MoritaniPlacement,
|
||||
MoritaniTerrorSupply,
|
||||
} from '../components/moritani-terror';
|
||||
import {
|
||||
moritaniPlacementChoices,
|
||||
nexusMoritaniAction,
|
||||
nexusMoritaniCanAct,
|
||||
} from '../game/nexus-moritani-options';
|
||||
import {
|
||||
nexusMoritaniFixture,
|
||||
nexusMoritaniToken,
|
||||
nexusMoritaniInventory,
|
||||
} from './fixture-nexus-moritani';
|
||||
|
||||
const html = (game: GameView, busy = false) =>
|
||||
renderToStaticMarkup(
|
||||
createElement(MoritaniPlacement, { game, busy, act() {} }),
|
||||
);
|
||||
|
||||
void test('genuine Basic and Advanced Mentat keeps native relocation choices and offers explicit Cunning supply placement into Arrakis or a stack', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = nexusMoritaniFixture({ advanced, stack: true });
|
||||
const v = viewGame(f.g, f.owner),
|
||||
before = structuredClone(v);
|
||||
const token = nexusMoritaniToken(f.g, 'robbery').id;
|
||||
const native = moritaniPlacementChoices(v, false, token);
|
||||
const cunning = moritaniPlacementChoices(v, true, token);
|
||||
assert.ok(native.tokens.some((candidate) => candidate.status === 'placed'));
|
||||
assert.ok(
|
||||
cunning.tokens.every((candidate) => candidate.status === 'available'),
|
||||
);
|
||||
for (const destination of ['red_chasm', 'polar_sink', 'arrakeen']) {
|
||||
assert.equal(native.destinations.includes(destination), false);
|
||||
assert.ok(cunning.destinations.includes(destination));
|
||||
const action = nexusMoritaniAction(
|
||||
v,
|
||||
v.nexusMoritani!.event,
|
||||
token,
|
||||
destination,
|
||||
);
|
||||
assert.deepEqual(action, {
|
||||
type: 'decision',
|
||||
token,
|
||||
territory: destination,
|
||||
nexus: v.nexusMoritani!.event,
|
||||
});
|
||||
const pending = applyAction(f.g, f.owner, action!);
|
||||
assert.equal(pending.response?.kind, 'moritaniPlacement');
|
||||
assert.equal(nexusMoritaniToken(pending, 'robbery').status, 'available');
|
||||
nexusMoritaniInventory(pending);
|
||||
}
|
||||
assert.ok(!cunning.destinations.includes('hidden_mobile_stronghold'));
|
||||
assert.ok(
|
||||
!cunning.destinations.some((destination) =>
|
||||
destination.startsWith('homeworld:'),
|
||||
),
|
||||
);
|
||||
const markup = html(v);
|
||||
assert.match(markup, /type="checkbox"/);
|
||||
assert.doesNotMatch(markup, /checked=""/);
|
||||
assert.match(markup, /decision-checkbox min-h-11/);
|
||||
assert.match(markup, /Use Moritani Nexus Cunning/);
|
||||
assert.match(
|
||||
markup,
|
||||
/Relocation, Grumman use, Homeworlds and the Hidden Mobile Stronghold are unavailable/,
|
||||
);
|
||||
assert.match(markup, /Destination stronghold/);
|
||||
assert.match(html(v, true), /disabled=""/);
|
||||
assert.match(
|
||||
renderToStaticMarkup(createElement(MoritaniTerrorSupply, { game: v })),
|
||||
/Inspect Robbery/,
|
||||
);
|
||||
assert.deepEqual(v, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Moritani shared Cunning action binds current private offer, available token and destination without accepting relocation or stale events', () => {
|
||||
const f = nexusMoritaniFixture({ stack: true });
|
||||
const v = viewGame(f.g, f.owner),
|
||||
offer = v.nexusMoritani!;
|
||||
const token = nexusMoritaniToken(f.g, 'robbery').id;
|
||||
assert.ok(nexusMoritaniCanAct(v));
|
||||
assert.equal(nexusMoritaniAction(v, 'stale', token, 'red_chasm'), null);
|
||||
assert.equal(
|
||||
nexusMoritaniAction(
|
||||
v,
|
||||
offer.event,
|
||||
nexusMoritaniToken(f.g, 'sabotage').id,
|
||||
'red_chasm',
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
nexusMoritaniAction(v, offer.event, 'missing', 'red_chasm'),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
nexusMoritaniAction(v, offer.event, token, 'hidden_mobile_stronghold'),
|
||||
null,
|
||||
);
|
||||
for (const edit of [
|
||||
(g: GameView) => {
|
||||
g.phase = 7;
|
||||
},
|
||||
(g: GameView) => {
|
||||
g.turn++;
|
||||
},
|
||||
(g: GameView) => {
|
||||
g.decision = null;
|
||||
},
|
||||
(g: GameView) => {
|
||||
g.decision = { kind: 'moritaniPlacement', player: f.target };
|
||||
},
|
||||
(g: GameView) => {
|
||||
g.nexusCards!.card = null;
|
||||
},
|
||||
(g: GameView) => {
|
||||
g.players.find((p) => p.id === f.owner)!.ally = f.target;
|
||||
},
|
||||
(g: GameView) => {
|
||||
g.automaticContinuationPending = true;
|
||||
},
|
||||
(g: GameView) => {
|
||||
g.nexusMoritani!.blocked = 'Finish the current interaction.';
|
||||
},
|
||||
]) {
|
||||
const blocked = structuredClone(v);
|
||||
edit(blocked);
|
||||
assert.equal(
|
||||
nexusMoritaniAction(blocked, offer.event, token, 'red_chasm'),
|
||||
null,
|
||||
);
|
||||
}
|
||||
const blocked = structuredClone(v);
|
||||
blocked.nexusMoritani!.blocked =
|
||||
'This combination awaits its placement ruling.';
|
||||
assert.match(html(blocked), /combination awaits its placement ruling/);
|
||||
});
|
||||
|
||||
void test('nonowners cannot render or read private Cunning supply even from an accidentally broad view', () => {
|
||||
const f = nexusMoritaniFixture({ stack: true });
|
||||
for (const id of [f.target, f.observer]) {
|
||||
const v = viewGame(f.g, id);
|
||||
assert.equal(v.nexusMoritani, null);
|
||||
assert.equal(html(v), '');
|
||||
Object.defineProperty(v, 'nexusMoritani', {
|
||||
get() {
|
||||
throw new Error('Opponent Nexus offer read');
|
||||
},
|
||||
});
|
||||
assert.equal(nexusMoritaniCanAct(v), false);
|
||||
assert.deepEqual(moritaniPlacementChoices(v, true).tokens, []);
|
||||
assert.equal(nexusMoritaniAction(v, 'event', 'token', 'red_chasm'), null);
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(MoritaniTerrorSupply, { game: v }),
|
||||
);
|
||||
assert.match(markup, /Hidden Terror/);
|
||||
assert.doesNotMatch(markup, /Inspect Sabotage/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Game,
|
||||
type Action,
|
||||
} from '../game/engine';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import {
|
||||
nexusMoritaniFixture,
|
||||
nexusMoritaniToken,
|
||||
nexusMoritaniRequest,
|
||||
nexusMoritaniAllow,
|
||||
nexusMoritaniReload,
|
||||
nexusMoritaniInventory,
|
||||
nexusMoritaniArrival,
|
||||
holdNexusMoritaniCard,
|
||||
} from './fixture-nexus-moritani';
|
||||
function reject(g: Game, id: string, a: Action) {
|
||||
const before = nexusMoritaniReload(g);
|
||||
assert.throws(() => applyAction(g, id, a));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
function stable(g: Game) {
|
||||
nexusMoritaniInventory(g);
|
||||
const before = nexusMoritaniReload(g);
|
||||
assert.deepEqual(normalizeAutomaticGame(before), before);
|
||||
for (const p of g.players)
|
||||
assert.deepEqual(viewGame(nexusMoritaniReload(g), p.id), viewGame(g, p.id));
|
||||
}
|
||||
void test('Moritani Cunning places a real available token in sand, rock, Polar Sink or an existing stack in Basic and Advanced', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const territory of [
|
||||
'red_chasm',
|
||||
'sihaya_ridge',
|
||||
'polar_sink',
|
||||
'arrakeen',
|
||||
]) {
|
||||
const f = nexusMoritaniFixture({
|
||||
advanced,
|
||||
stack: territory === 'arrakeen',
|
||||
}),
|
||||
before = nexusMoritaniReload(f.g),
|
||||
token = nexusMoritaniToken(f.g, 'robbery').id;
|
||||
const action = nexusMoritaniRequest(f, 'robbery', territory);
|
||||
let g = applyAction(f.g, f.owner, action);
|
||||
assert.equal(g.response?.kind, 'moritaniPlacement');
|
||||
assert.equal(nexusMoritaniToken(g, 'robbery').status, 'available');
|
||||
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(
|
||||
g.nexusCards!.cards!.discard.filter((c) => c === 'moritani').length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(g.moritaniTerror, before.moritaniTerror);
|
||||
stable(g);
|
||||
g = nexusMoritaniAllow(nexusMoritaniReload(g));
|
||||
assert.equal(nexusMoritaniToken(g, 'robbery').location, territory);
|
||||
assert.equal(nexusMoritaniToken(g, 'robbery').status, 'placed');
|
||||
assert.equal(g.moritaniTerror!.placementTurn, g.turn);
|
||||
assert.deepEqual(
|
||||
g.moritaniTerror!.tokens.filter((t) => t.id !== token),
|
||||
before.moritaniTerror!.tokens.filter((t) => t.id !== token),
|
||||
);
|
||||
assert.deepEqual(
|
||||
g.players.map((p) => [p.spice, p.forces, p.reserves, p.tanks]),
|
||||
before.players.map((p) => [p.spice, p.forces, p.reserves, p.tanks]),
|
||||
);
|
||||
if (territory === 'arrakeen')
|
||||
assert.equal(
|
||||
g.moritaniTerror!.tokens.filter((t) => t.location === 'arrakeen')
|
||||
.length,
|
||||
2,
|
||||
);
|
||||
reject(g, f.owner, action);
|
||||
stable(g);
|
||||
}
|
||||
});
|
||||
void test('printed Karama consumes the Mentat opportunity while preserving supply, previous placement and physical counters', () => {
|
||||
const f = nexusMoritaniFixture({ stack: true }),
|
||||
before = nexusMoritaniReload(f.g),
|
||||
action = nexusMoritaniRequest(f, 'robbery', 'arrakeen');
|
||||
let g = applyAction(f.g, f.owner, action);
|
||||
g = applyAction(nexusMoritaniReload(g), f.target, {
|
||||
type: 'card',
|
||||
mode: 'cancel',
|
||||
card: f.karama,
|
||||
});
|
||||
assert.equal(g.pendingMoritaniPlacement, null);
|
||||
assert.equal(g.response, null);
|
||||
assert.deepEqual(g.moritaniTerror!.tokens, before.moritaniTerror!.tokens);
|
||||
assert.equal(g.moritaniTerror!.placementTurn, g.turn);
|
||||
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(g.discard.filter((c) => c.id === f.karama).length, 1);
|
||||
assert.deepEqual(
|
||||
g.players.map((p) => p.spice),
|
||||
before.players.map((p) => p.spice),
|
||||
);
|
||||
reject(g, f.owner, action);
|
||||
stable(g);
|
||||
});
|
||||
void test('Moritani Cunning offers every static territory without reading token kinds for another seat; hidden declarations stay equivalent', () => {
|
||||
const f = nexusMoritaniFixture(),
|
||||
a = f.g,
|
||||
b = nexusMoritaniReload(a);
|
||||
const tokenA = nexusMoritaniToken(a, 'robbery'),
|
||||
tokenB = b.moritaniTerror!.tokens.find((t) => t.id === tokenA.id)!,
|
||||
other = nexusMoritaniToken(b, 'sabotage');
|
||||
[tokenB.kind, other.kind] = [other.kind, tokenB.kind];
|
||||
for (const id of [f.target, f.observer])
|
||||
assert.deepEqual(viewGame(a, id), viewGame(b, id));
|
||||
const offer = viewGame(a, f.owner).nexusMoritani!;
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.equal(offer.tokens.length, 6);
|
||||
assert.equal(offer.destinations.length, TERRITORIES.length);
|
||||
const action = nexusMoritaniRequest(f, 'robbery', 'red_chasm');
|
||||
const pendingA = applyAction(a, f.owner, action),
|
||||
pendingB = applyAction(b, f.owner, action);
|
||||
for (const id of [f.target, f.observer]) {
|
||||
assert.deepEqual(viewGame(pendingA, id), viewGame(pendingB, id));
|
||||
assert.equal(viewGame(pendingA, id).nexusMoritani, null);
|
||||
assert.equal(
|
||||
Object.hasOwn(viewGame(pendingA, id), 'nexusMoritaniHistory'),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
void test('Moritani Cunning rejects stale event, foreign ownership, relocation, unknown supply, Homeworlds and mobile stronghold without spending Nexus', () => {
|
||||
const f = nexusMoritaniFixture({ stack: true }),
|
||||
action = nexusMoritaniRequest(f);
|
||||
for (const bad of [
|
||||
{ ...action, nexus: 'old' },
|
||||
{ ...action, token: 'missing' },
|
||||
{ ...action, token: nexusMoritaniToken(f.g, 'sabotage').id },
|
||||
{ ...action, territory: 'grumman' },
|
||||
{ ...action, territory: 'hidden_mobile_stronghold' },
|
||||
{ ...action, territory: 'unknown' },
|
||||
])
|
||||
reject(f.g, f.owner, bad);
|
||||
reject(f.g, f.target, action);
|
||||
assert.equal(f.g.nexusCards!.cards!.hands[f.owner], 'moritani');
|
||||
const noNexus = nexusMoritaniReload(f.g);
|
||||
noNexus.nexusCards!.cards!.deck.push('moritani');
|
||||
noNexus.nexusCards!.cards!.hands[f.owner] = null;
|
||||
reject(noNexus, f.owner, action);
|
||||
});
|
||||
void test('Nexus Mentat placement preserves genuine previous Grumman decline or expiry, pays no Collection income and applies the low entry threshold later', () => {
|
||||
for (const native of [7, 8]) {
|
||||
const f = nexusMoritaniFixture({ homeworlds: true, native, stack: true }),
|
||||
receipt = structuredClone(f.g.grummanCollection);
|
||||
assert.equal(receipt?.outcome, native === 7 ? 'expired' : 'decline');
|
||||
let g = nexusMoritaniAllow(
|
||||
applyAction(f.g, f.owner, nexusMoritaniRequest(f)),
|
||||
);
|
||||
assert.deepEqual(g.grummanCollection, receipt);
|
||||
assert.equal(g.players[0].spice, 20);
|
||||
g = nexusMoritaniArrival(g, 'red_chasm', 2);
|
||||
if (native === 7) assert.equal(g.pendingTerrorEntry ?? null, null);
|
||||
else assert.equal(g.pendingTerrorEntry?.stage, 'offer');
|
||||
stable(g);
|
||||
}
|
||||
});
|
||||
for (const kind of ['robbery', 'sabotage', 'sneakAttack'] as const)
|
||||
void test(`Nexus ${kind} placed outside a stronghold triggers its existing effect on a later real shipment`, () => {
|
||||
const f = nexusMoritaniFixture();
|
||||
const victimCard = holdNexusMoritaniCard(f.g, f.target, 'shield');
|
||||
let g = nexusMoritaniAllow(
|
||||
applyAction(f.g, f.owner, nexusMoritaniRequest(f, kind, 'sihaya_ridge')),
|
||||
);
|
||||
g = nexusMoritaniArrival(g, 'sihaya_ridge');
|
||||
assert.equal(g.pendingTerrorEntry?.stage, 'offer');
|
||||
assert.equal(viewGame(g, f.owner).terrorEntry?.kind, kind);
|
||||
for (const id of [f.target, f.observer])
|
||||
assert.equal(viewGame(g, id).terrorEntry?.kind, undefined);
|
||||
const before = nexusMoritaniReload(g);
|
||||
g = applyAction(nexusMoritaniReload(g), f.owner, {
|
||||
type: 'decision',
|
||||
reveal: true,
|
||||
});
|
||||
if (kind === 'robbery') {
|
||||
g = applyAction(g, f.owner, { type: 'decision', choice: 'spice' });
|
||||
const taken = Math.ceil(before.players[1].spice / 2);
|
||||
assert.equal(g.players[0].spice, before.players[0].spice + taken);
|
||||
assert.equal(g.players[1].spice, before.players[1].spice - taken);
|
||||
} else if (kind === 'sabotage') {
|
||||
assert.equal(g.pendingTerrorEntry?.stage, 'gift');
|
||||
g = applyAction(g, f.owner, { type: 'decision', decline: true });
|
||||
assert.ok(before.players[1].hand.some((c) => c.id === victimCard.id));
|
||||
assert.equal(g.players[1].hand.length, before.players[1].hand.length - 1);
|
||||
assert.equal(g.discard.length, before.discard.length + 1);
|
||||
assert.ok(
|
||||
before.players[1].hand.some((c) => c.id === g.discard.at(-1)!.id),
|
||||
);
|
||||
} else {
|
||||
assert.equal(g.pendingTerrorEntry?.stage, 'sneakAttack');
|
||||
g = applyAction(g, f.owner, { type: 'decision', amount: 3 });
|
||||
assert.equal(g.players[0].forces['sihaya_ridge:9'], 3);
|
||||
assert.equal(g.players[0].reserves, before.players[0].reserves - 3);
|
||||
assert.equal(g.players[0].spice, before.players[0].spice);
|
||||
}
|
||||
assert.equal(nexusMoritaniToken(g, kind).status, 'removed');
|
||||
assert.equal(g.pendingTerrorEntry, null);
|
||||
assert.equal(g.players[1].forces['sihaya_ridge:9'], 3);
|
||||
reject(g, f.owner, { type: 'decision', reveal: true });
|
||||
stable(g);
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { viewGame, type Game, type Action } from '../game/engine';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import {
|
||||
nexusMoritaniFixture,
|
||||
nexusMoritaniRequest,
|
||||
nexusMoritaniToken,
|
||||
nexusMoritaniInventory,
|
||||
nexusMoritaniMovement,
|
||||
} from './fixture-nexus-moritani';
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
async function fixture() {
|
||||
const store = unitStore(),
|
||||
made = await store.rooms.createRoom('Moritani SQL', 'moritani', false, []),
|
||||
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 auths = await Promise.all(
|
||||
tokens.map((token) => store.rooms.authenticate(code, token)),
|
||||
),
|
||||
ids = auths.map((auth) => auth.playerId) as [string, string, string];
|
||||
const f = nexusMoritaniFixture({
|
||||
seatIds: ids,
|
||||
stack: true,
|
||||
homeworlds: true,
|
||||
});
|
||||
f.g.code = code;
|
||||
f.g.version = (await store.rooms.readRoom(code)).version;
|
||||
const save = (g: Game) =>
|
||||
store.sqlite
|
||||
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(g), g.version, code);
|
||||
save(f.g);
|
||||
store.writes.length = 0;
|
||||
return { ...store, ...f, code, tokens, auths, ids, save };
|
||||
}
|
||||
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 act(f: Fixture, seat: number, action: Action) {
|
||||
const g = await f.restart().readRoom(f.code);
|
||||
await f.restart().act(f.code, f.auths[seat], g.version, action, clock);
|
||||
return f.restart().readRoom(f.code);
|
||||
}
|
||||
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),
|
||||
v = await rooms.readSeatView(f.code, auth);
|
||||
assert.deepEqual(v, viewGame(g, f.ids[i]));
|
||||
assert.equal(Object.hasOwn(v, 'nexusMoritaniHistory'), false);
|
||||
if (i !== 0) {
|
||||
assert.equal(v.nexusMoritani, null);
|
||||
for (const token of v.moritaniTerror!.tokens.filter(
|
||||
(t) => t.status === 'placed',
|
||||
))
|
||||
assert.equal('kind' in token, false);
|
||||
}
|
||||
}
|
||||
await rooms.continueRoomAutomatic(f.code, clock);
|
||||
assert.deepEqual(row(f), before);
|
||||
nexusMoritaniInventory(g);
|
||||
}
|
||||
void test('SQLite lost acknowledgment restores hidden supply placement and preserves genuine Grumman history after allowance or cancellation', async () => {
|
||||
for (const canceled of [false, true])
|
||||
for (const territory of [
|
||||
'red_chasm',
|
||||
'sihaya_ridge',
|
||||
'polar_sink',
|
||||
'arrakeen',
|
||||
]) {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const request = nexusMoritaniRequest(f, 'robbery', territory),
|
||||
source = structuredClone(f.g.moritaniTerror),
|
||||
grumman = structuredClone(f.g.grummanCollection);
|
||||
await f.rooms.act(f.code, f.auths[0], f.g.version, request, clock);
|
||||
const pending = await f.restart().readRoom(f.code);
|
||||
assert.equal(pending.nexusMoritaniLast?.stage, 'pending');
|
||||
assert.deepEqual(pending.moritaniTerror, source);
|
||||
await restored(f, pending);
|
||||
const done = await act(
|
||||
f,
|
||||
1,
|
||||
canceled
|
||||
? { type: 'card', mode: 'cancel', card: f.karama }
|
||||
: { type: 'passResponse' },
|
||||
);
|
||||
assert.equal(
|
||||
done.nexusMoritaniLast?.stage,
|
||||
canceled ? 'canceled' : 'complete',
|
||||
);
|
||||
assert.equal(done.nexusMoritaniHistory!.length, 1);
|
||||
assert.equal(
|
||||
nexusMoritaniToken(done, 'robbery').status,
|
||||
canceled ? 'available' : 'placed',
|
||||
);
|
||||
assert.equal(
|
||||
nexusMoritaniToken(done, 'robbery').location,
|
||||
canceled ? null : territory,
|
||||
);
|
||||
assert.equal(done.moritaniTerror!.placementTurn, done.turn);
|
||||
assert.deepEqual(done.grummanCollection, grumman);
|
||||
assert.equal(done.players[0].spice, 20);
|
||||
assert.equal(
|
||||
done.nexusCards!.cards!.discard.filter((card) => card === 'moritani')
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
await restored(f, done);
|
||||
const before = row(f);
|
||||
await assert.rejects(
|
||||
f.restart().act(f.code, f.auths[0], done.version, request, clock),
|
||||
);
|
||||
assert.deepEqual(row(f), before);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
async function compete(
|
||||
f: Fixture,
|
||||
seat: number,
|
||||
version: number,
|
||||
actions: Action[],
|
||||
) {
|
||||
let arrivals = 0,
|
||||
release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
f.hooks.beforeWrite = async () => {
|
||||
if (++arrivals === 2) release();
|
||||
await gate;
|
||||
};
|
||||
const timer = setTimeout(release, 2000);
|
||||
const results = await Promise.allSettled(
|
||||
actions.map((action) =>
|
||||
f.restart().act(f.code, f.auths[seat], version, action, clock),
|
||||
),
|
||||
);
|
||||
clearTimeout(timer);
|
||||
delete f.hooks.beforeWrite;
|
||||
assert.equal(arrivals, 2);
|
||||
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],
|
||||
);
|
||||
return f.restart().readRoom(f.code);
|
||||
}
|
||||
void test('SQLite concurrent Moritani declarations and competing final Karama/pass each commit exactly once', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const request = nexusMoritaniRequest(f),
|
||||
pending = await compete(f, 0, f.g.version, [request, request]);
|
||||
assert.equal(pending.version, f.g.version + 1);
|
||||
assert.equal(pending.nexusMoritaniHistory!.length, 1);
|
||||
assert.equal(nexusMoritaniToken(pending, 'robbery').status, 'available');
|
||||
await restored(f, pending);
|
||||
f.writes.length = 0;
|
||||
const done = await compete(f, 1, pending.version, [
|
||||
{ type: 'passResponse' },
|
||||
{ type: 'card', mode: 'cancel', card: f.karama },
|
||||
]);
|
||||
assert.equal(done.version, pending.version + 1);
|
||||
assert.equal(done.nexusMoritaniHistory!.length, 1);
|
||||
const canceled = done.nexusMoritaniLast!.stage === 'canceled';
|
||||
assert.equal(
|
||||
nexusMoritaniToken(done, 'robbery').status,
|
||||
canceled ? 'available' : 'placed',
|
||||
);
|
||||
assert.equal(
|
||||
done.discard.filter((c) => c.id === f.karama).length,
|
||||
canceled ? 1 : 0,
|
||||
);
|
||||
assert.equal(
|
||||
done.nexusCards!.cards!.discard.filter((c) => c === 'moritani').length,
|
||||
1,
|
||||
);
|
||||
await restored(f, done);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
void test('SQLite damaged Moritani original supply, target, response or completion proof rejects both settlement choices and every private read without writes', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const pending = await act(f, 0, nexusMoritaniRequest(f));
|
||||
const changes: ((g: Game) => void)[] = [
|
||||
(g) => {
|
||||
g.pendingMoritaniPlacement!.token = 'unknown';
|
||||
},
|
||||
(g) => {
|
||||
g.pendingMoritaniPlacement!.territory = 'sihaya_ridge';
|
||||
},
|
||||
(g) => {
|
||||
g.pendingMoritaniPlacement!.turn++;
|
||||
},
|
||||
(g) => {
|
||||
g.pendingMoritaniPlacement!.nexusEvent = 'changed';
|
||||
},
|
||||
(g) => {
|
||||
g.response!.owner = f.target;
|
||||
},
|
||||
(g) => {
|
||||
g.response = null;
|
||||
},
|
||||
(g) => {
|
||||
g.moritaniTerror!.tokens[0].id = g.moritaniTerror!.tokens[1].id;
|
||||
},
|
||||
(g) => {
|
||||
nexusMoritaniToken(g, 'robbery').status = 'removed';
|
||||
},
|
||||
(g) => {
|
||||
g.nexusMoritaniHistory![0].stage = 'complete';
|
||||
},
|
||||
(g) => {
|
||||
delete g.nexusMoritaniLast;
|
||||
},
|
||||
(g) => {
|
||||
delete g.nexusMoritaniHistory;
|
||||
},
|
||||
(g) => {
|
||||
g.nexusMoritaniHistory![0].before = '{}';
|
||||
},
|
||||
];
|
||||
for (const [index, change] of changes.entries()) {
|
||||
const bad = structuredClone(pending);
|
||||
change(bad);
|
||||
f.save(bad);
|
||||
f.writes.length = 0;
|
||||
const before = row(f);
|
||||
for (const action of [
|
||||
{ type: 'passResponse' },
|
||||
{ type: 'card', mode: 'cancel', card: f.karama },
|
||||
] as Action[])
|
||||
await assert.rejects(
|
||||
f.restart().act(f.code, f.auths[1], bad.version, action, clock),
|
||||
`mutation ${index}`,
|
||||
);
|
||||
for (const auth of f.auths)
|
||||
await assert.rejects(
|
||||
f.restart().readSeatView(f.code, auth),
|
||||
`read mutation ${index}`,
|
||||
);
|
||||
assert.deepEqual(row(f), before);
|
||||
assert.equal(f.writes.length, 0);
|
||||
}
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
void test('SQLite later nonstronghold entry preserves the original Nexus placement proof through reveal, private choice and replay rejection', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await act(f, 0, nexusMoritaniRequest(f, 'robbery', 'sihaya_ridge'));
|
||||
const placed = await act(f, 1, { type: 'passResponse' });
|
||||
// Complete genuine phase boundaries, then persist before the real SQL shipment.
|
||||
// No original receipt or seat identity is rewritten.
|
||||
const movement = nexusMoritaniMovement(placed);
|
||||
f.save(movement);
|
||||
f.writes.length = 0;
|
||||
const arrival = await act(f, 1, {
|
||||
type: 'ship',
|
||||
territory: 'sihaya_ridge',
|
||||
sector: 9,
|
||||
amount: 3,
|
||||
});
|
||||
assert.equal(arrival.pendingTerrorEntry?.stage, 'offer');
|
||||
await restored(f, arrival);
|
||||
const original = JSON.stringify(arrival.pendingTerrorEntry);
|
||||
const revealed = await act(f, 0, { type: 'decision', reveal: true });
|
||||
assert.equal(revealed.pendingTerrorEntry?.stage, 'robbery');
|
||||
await restored(f, revealed);
|
||||
const beforeSpice = revealed.players[0].spice,
|
||||
taken = Math.ceil(revealed.players[1].spice / 2);
|
||||
const done = await act(f, 0, { type: 'decision', choice: 'spice' });
|
||||
assert.equal(done.players[0].spice, beforeSpice + taken);
|
||||
assert.equal(done.pendingTerrorEntry, null);
|
||||
assert.equal(done.nexusMoritaniLast?.stage, 'complete');
|
||||
await restored(f, done);
|
||||
const before = row(f);
|
||||
await assert.rejects(
|
||||
f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.auths[0],
|
||||
done.version,
|
||||
{ type: 'decision', choice: 'spice' },
|
||||
clock,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(row(f), before);
|
||||
const bad = structuredClone(arrival);
|
||||
bad.pendingTerrorEntry!.territory = 'red_chasm';
|
||||
assert.notEqual(JSON.stringify(bad.pendingTerrorEntry), original);
|
||||
f.save(bad);
|
||||
f.writes.length = 0;
|
||||
await assert.rejects(
|
||||
f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.auths[0],
|
||||
bad.version,
|
||||
{ type: 'decision', reveal: true },
|
||||
clock,
|
||||
),
|
||||
);
|
||||
assert.equal(f.writes.length, 0);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { TERRITORIES, MOBILE_STRONGHOLD } from '../game/board';
|
||||
import {
|
||||
createTerrorState,
|
||||
placeTerror,
|
||||
type TerrorState,
|
||||
} from '../game/moritani-terror';
|
||||
import {
|
||||
createNexusMoritani,
|
||||
validateNexusMoritani,
|
||||
quoteNexusMoritaniPlacement,
|
||||
type NexusMoritaniContext,
|
||||
type NexusMoritaniReceipt,
|
||||
} from '../game/nexus-moritani';
|
||||
|
||||
const context: NexusMoritaniContext = {
|
||||
turn: 3,
|
||||
players: [
|
||||
{ id: 'm', faction: 'moritani' },
|
||||
{ id: 'a', faction: 'atreides' },
|
||||
],
|
||||
};
|
||||
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
const fresh = () => createTerrorState(() => 0.3);
|
||||
|
||||
void test('Moritani Cunning places one supply token in every static Arrakis territory without mutating the source', () => {
|
||||
const state = fresh(),
|
||||
before = clone(state),
|
||||
id = state.tokens[0].id;
|
||||
state.supplyEpoch = 4;
|
||||
before.supplyEpoch = 4;
|
||||
for (const territory of TERRITORIES) {
|
||||
const receipt = createNexusMoritani(context, 'm', id, territory.id);
|
||||
validateNexusMoritani(context, clone(receipt));
|
||||
const placed = quoteNexusMoritaniPlacement(state, id, territory.id, 3);
|
||||
assert.equal(placed.tokens[0].location, territory.id);
|
||||
assert.equal(placed.tokens[0].status, 'placed');
|
||||
assert.equal(placed.placementTurn, 3);
|
||||
assert.equal(placed.supplyEpoch, 4);
|
||||
assert.deepEqual(placed.tokens.slice(1), state.tokens.slice(1));
|
||||
placed.tokens[1].status = 'removed';
|
||||
assert.deepEqual(state, before);
|
||||
}
|
||||
assert.throws(
|
||||
() => placeTerror(state, id, 'polar_sink', 3),
|
||||
/ordinary strongholds/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('Moritani Cunning stacks physical tokens but cannot reuse the Mentat allowance or enhance a relocation', () => {
|
||||
let state = fresh();
|
||||
state = quoteNexusMoritaniPlacement(
|
||||
state,
|
||||
state.tokens[0].id,
|
||||
'hagga_basin',
|
||||
1,
|
||||
);
|
||||
state = quoteNexusMoritaniPlacement(
|
||||
state,
|
||||
state.tokens[1].id,
|
||||
'hagga_basin',
|
||||
2,
|
||||
);
|
||||
assert.equal(
|
||||
state.tokens.filter((t) => t.location === 'hagga_basin').length,
|
||||
2,
|
||||
);
|
||||
const before = clone(state);
|
||||
for (const turn of [1, 2, 0, -1, 2.5, NaN])
|
||||
assert.throws(
|
||||
() =>
|
||||
quoteNexusMoritaniPlacement(
|
||||
state,
|
||||
state.tokens[2].id,
|
||||
'polar_sink',
|
||||
turn,
|
||||
),
|
||||
/Moritani Nexus/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
quoteNexusMoritaniPlacement(state, state.tokens[0].id, 'polar_sink', 3),
|
||||
/relocation/,
|
||||
);
|
||||
assert.deepEqual(state, before);
|
||||
// Its custody is ordinary placed custody; a later unenhanced move may leave it.
|
||||
const moved = placeTerror(state, state.tokens[0].id, 'carthag', 3);
|
||||
assert.equal(moved.tokens[1].location, 'hagga_basin');
|
||||
assert.equal(moved.tokens[0].location, 'carthag');
|
||||
});
|
||||
|
||||
void test('Moritani placement rejects invalid physical inventory and excluded destinations without mutations', () => {
|
||||
const state = fresh();
|
||||
const edits: ((s: TerrorState) => void)[] = [
|
||||
(s) => {
|
||||
s.tokens.pop();
|
||||
},
|
||||
(s) => {
|
||||
s.tokens[1].id = s.tokens[0].id;
|
||||
},
|
||||
(s) => {
|
||||
s.tokens[1].kind = s.tokens[0].kind;
|
||||
},
|
||||
(s) => {
|
||||
s.tokens[0].status = 'removed';
|
||||
},
|
||||
(s) => {
|
||||
s.tokens[0].location = 'arrakeen';
|
||||
},
|
||||
(s) => {
|
||||
s.tokens[1].status = 'placed';
|
||||
s.tokens[1].location = MOBILE_STRONGHOLD;
|
||||
},
|
||||
(s) => {
|
||||
s.tokens[1].status = 'extortion';
|
||||
s.tokens[1].kind = 'robbery';
|
||||
},
|
||||
(s) => {
|
||||
s.supplyEpoch = -1;
|
||||
},
|
||||
(s) => {
|
||||
Object.assign(s, { extraToken: true });
|
||||
},
|
||||
];
|
||||
for (const edit of edits) {
|
||||
const bad = clone(state);
|
||||
edit(bad);
|
||||
const before = clone(bad);
|
||||
assert.throws(
|
||||
() =>
|
||||
quoteNexusMoritaniPlacement(bad, state.tokens[0].id, 'polar_sink', 3),
|
||||
/Moritani Nexus/,
|
||||
);
|
||||
assert.deepEqual(bad, before);
|
||||
}
|
||||
for (const destination of [
|
||||
MOBILE_STRONGHOLD,
|
||||
'homeworld:moritani',
|
||||
'arrakeen:10',
|
||||
'',
|
||||
'__proto__',
|
||||
]) {
|
||||
assert.throws(
|
||||
() =>
|
||||
quoteNexusMoritaniPlacement(state, state.tokens[0].id, destination, 3),
|
||||
/Moritani Nexus/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createNexusMoritani(context, 'm', state.tokens[0].id, destination),
|
||||
/Moritani Nexus/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Moritani historical receipts preserve original identity after later turns without reading hidden custody or alliances', () => {
|
||||
const receipt = createNexusMoritani(
|
||||
context,
|
||||
'm',
|
||||
'physical-token',
|
||||
'hagga_basin',
|
||||
);
|
||||
const later = {
|
||||
turn: 9,
|
||||
players: [...context.players].reverse().map((p) =>
|
||||
Object.defineProperties(
|
||||
{ ...p },
|
||||
{
|
||||
hand: {
|
||||
get() {
|
||||
throw new Error('hidden hand read');
|
||||
},
|
||||
},
|
||||
ally: {
|
||||
get() {
|
||||
throw new Error('later alliance read');
|
||||
},
|
||||
},
|
||||
moritaniTerror: {
|
||||
get() {
|
||||
throw new Error('later custody read');
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
};
|
||||
validateNexusMoritani(later, clone(receipt));
|
||||
assert.equal(
|
||||
receipt.event,
|
||||
JSON.stringify(['nexusMoritani', 3, 'm', 'physical-token', 'hagga_basin']),
|
||||
);
|
||||
assert.notEqual(
|
||||
receipt.event,
|
||||
createNexusMoritani(context, 'm', 'other-token', 'hagga_basin').event,
|
||||
);
|
||||
});
|
||||
|
||||
void test('Moritani receipt rejects deleted or changed source evidence and foreign native owners', () => {
|
||||
const receipt = createNexusMoritani(
|
||||
context,
|
||||
'm',
|
||||
'physical-token',
|
||||
'hagga_basin',
|
||||
);
|
||||
const edits: ((r: NexusMoritaniReceipt) => void)[] = [
|
||||
(r) => {
|
||||
r.token = 'other';
|
||||
},
|
||||
(r) => {
|
||||
r.territory = 'polar_sink';
|
||||
},
|
||||
(r) => {
|
||||
r.turn = 4;
|
||||
},
|
||||
(r) => {
|
||||
r.owner = 'a';
|
||||
},
|
||||
(r) => {
|
||||
r.roster.pop();
|
||||
},
|
||||
(r) => {
|
||||
r.signature = '';
|
||||
},
|
||||
(r) => {
|
||||
r.event = 'other';
|
||||
},
|
||||
(r) => {
|
||||
Object.assign(r, { source: 'grumman', phase: 7 });
|
||||
},
|
||||
(r) => {
|
||||
delete (r as Partial<NexusMoritaniReceipt>).source;
|
||||
},
|
||||
(r) => {
|
||||
Object.assign(r, { stage: 'complete' });
|
||||
},
|
||||
];
|
||||
for (const edit of edits) {
|
||||
const bad = clone(receipt);
|
||||
edit(bad);
|
||||
const before = clone(bad);
|
||||
assert.throws(() => validateNexusMoritani(context, bad), /Moritani Nexus/);
|
||||
assert.deepEqual(bad, before);
|
||||
}
|
||||
assert.throws(
|
||||
() => validateNexusMoritani({ ...context, turn: 2 }, receipt),
|
||||
/Moritani Nexus/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createNexusMoritani(context, 'a', 'token', 'polar_sink'),
|
||||
/Moritani Nexus/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createNexusMoritani(
|
||||
{ ...context, players: [...context.players, context.players[0]] },
|
||||
'm',
|
||||
'token',
|
||||
'polar_sink',
|
||||
),
|
||||
/roster/,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user