Connect BG Nexus fresh and storm advisor conversion

This commit is contained in:
Quintine committed 2026-10-15 04:40:46 +11:00
1 parent 82f1aae51b
commit 985cf85e12
21 files changed
+936 -316

No files matched your search

+26 -4
View File
@@ -95,6 +95,7 @@ import { NexusHarkonnenBetrayal } from './nexus-harkonnen-betrayal';
import { OrnithopterMovement } from './ornithopter-movement';
import { DiscoveryOrnithopterMovement } from './discovery-flight-movement';
import { PlanetologistMovement } from './planetologist-movement';
import { selectedOriginElites } from '@/game/planetologist-movement';
import { SandmasterMovement } from './sandmaster-movement';
import { SandmasterWormChoice } from './sandmaster-worm';
import { sandmasterWormCollection } from '@/game/sandmaster-worm';
@@ -177,7 +178,7 @@ import { SmugglerShipmentChoice } from './smuggler-shipment';
import { nexusGuildSecretAllyAction, nexusGuildSecretAllyQuote } from '@/game/nexus-guild-secret-ally-options';
import { nexusRicheseAction, nexusRicheseQuote } from '@/game/nexus-richese-options';
import { guildTransportQuote } from '@/game/transport-quote';
import { fremenReserveEntry, botGroundMoveAllowed } from '@/game/bot-mobility';
import { fremenReserveEntry, botGroundMoveAllowed, botMovementRange, groundMoveRouteDistance } from '@/game/bot-mobility';
import { isAlliedShipmentVisit } from '@/game/allied-separation';
import { location as boardLocation } from '@/game/board';
import { fighterCount, isAdvisor } from '@/game/advisors';
@@ -814,6 +815,15 @@ export function GameTable({
count > 0 &&
!botGroundMoveAllowed(g, me, key, boardLocation(selected, sector), 0),
);
const movementTarget = boardLocation(selected, sector);
const selectedMovementElite = combineSectors
? selectedOriginElites(physicalDraftEntries, eliteForces, splitLocation(source).territory)
: eliteAmount;
const ordinaryMovementRange = botMovementRange(g, me, selectedMovementElite);
const ordinaryMovementRouteBlocked = physicalDraftEntries.some(([key, count]) =>
count > 0 && groundMoveRouteDistance(g, me, key, movementTarget) > ordinaryMovementRange) ||
(!!includesNoField && !!ownNoField && groundMoveRouteDistance(g, me,
boardLocation(ownNoField.location.territory, ownNoField.location.sector), movementTarget) > ordinaryMovementRange);
const act = (a: Action) => {
// Control of one's own seat is independent of every gameplay response.
if (a.type === 'setAutopilot') { void send(a); return; }
@@ -4674,6 +4684,7 @@ export function GameTable({
ordinaryMovementAction,
!source ||
canceledFremenRouteBlocked ||
ordinaryMovementRouteBlocked ||
selectedDestinationInStorm ||
(me.moved ?? 0) >= (me.movesAllowed ?? 1) ||
(includesNoField
@@ -4681,9 +4692,11 @@ export function GameTable({
: (me.forces[source] ?? 0) === 0),
selectedDestinationInStorm
? 'movement-storm-unavailable'
: canceledFremenRouteBlocked
? 'movement-range-unavailable'
: undefined,
: ordinaryMovementRouteBlocked
? 'movement-route-unavailable'
: canceledFremenRouteBlocked
? 'movement-range-unavailable'
: undefined,
)}
{canceledFremenRouteBlocked &&
!selectedDestinationInStorm && (
@@ -4697,6 +4710,13 @@ export function GameTable({
territory, or use available ornithopters.
</p>
)}
{ordinaryMovementRouteBlocked && !selectedDestinationInStorm && (
<p id="movement-route-unavailable" className="fine" role="status">
Movement unavailable: this force group has no unblocked
ground route within its ordinary range. Choose a reachable
source or destination outside the storm and blocked strongholds.
</p>
)}
{selectedDestinationInStorm && (
<p
id="movement-storm-unavailable"
@@ -4718,6 +4738,8 @@ export function GameTable({
? 'No ordinary movement is currently available.'
: (me.moved ?? 0) >= (me.movesAllowed ?? 1)
? 'No ordinary movement remains this turn.'
: ordinaryMovementRouteBlocked
? 'Choose an unblocked source and route outside the storm.'
: canceledFremenRouteBlocked
? 'Choose an ordinary route within your available range.'
: selectedDestinationInStorm
+5
View File
@@ -79,6 +79,11 @@ function AdvisorSelection({ game, act, busy }: Props) {
This spends your Nexus card once. Karama may cancel the entire
selection. It costs no spice and uses no shipment or movement allowance.
</p>
<p className="notice">
Provisional card exception: Cunning may convert freshly accompanied or
storm-bound advisors in place. Ordinary flipping, storm movement and
battle restrictions remain unchanged.
</p>
{offer.blocked && <p className="notice">{offer.blocked}</p>}
<div className="flex flex-wrap gap-2">
<Button
+19
View File
@@ -29,6 +29,25 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
**BG Cunning fresh/storm card exceptions — 14 October.** The recommended
in-place any/all prototype now permits current-turn accompanied and storm-
bound/separated advisor territories during BG's own Advanced action. Native
whole-group custody, one Karama response and ordinary flip/arrival/movement/
battle rules remain. Real Shield Wall2/lockedTurn2, Arrakeen2 under storm10
and False Wall East5/9 separated by storm7 feed allow/cancel and four legal
profiles. Related10-file union65/65 passes; types/lint pass.
Three actual390px tables prove fresh allowance plus one fighter movement
and Battle handover, storm-bound stance-only allowance and separated batch
Karama cancellation. A live existing Move-button mismatch was reproduced
and fixed using shared projected geometry/range; blocked origin and
cross-storm detour now disable with accessible explanation. No engine rule
was weakened. No overflow/browser errors; wider module and release gates stay open.
Final compiler/lint and app build pass, with existing build warnings. Native
API receipts show fresh distance1/range1, bound route unreachable and separated
distance2/range1, with original battle geometry unchanged. Owned QA service/
scaffold removed and tabs released or auto-closed; proofs remain outside Git.
See [the source-bound prototype](NEXUS_ADVISOR_RUNTIME.md#fresh-and-storm-card-exception-prototype--14-october-2026).
**Fremen Secret Ally worm protection — 14 October.** The recommended
independent-operation prototype now saves only the actual unallied holder's
groups before original devouring. Publicly possible threatened held-Nexus
+46
View File
@@ -1,5 +1,51 @@
# Dune implementation status
## 14 October 2026 — Native BG Cunning fresh and storm card exceptions
Recommended provisional interpretation: printed any/all permission is an
in-place card exception for freshly accompanied and storm-bound/separated
advisor territories during native BG's own Advanced action. Only the
Cunning uncertainty guards are removed; dead storm-only helper/imports
are deleted. Quotes count public presence without allocating receipt groups.
Original declarations still bind exact locations/counts/locked stance,
one physical Nexus and one native Karama window. Ordinary flip/arrival,
movement, casualties, allied capacity and battle geometry remain unchanged.
Two disjoint genuine native slices retain real setup, card draws, reserve
payments/free accompaniment and original Advanced storm cards. An initial
dial assumption was corrected without changing the producer: actual identity
card draws reach sheltered Arrakeen storm10 and rock separation storm7.
The Storm fixture's setup-only action routing was repaired by requiring
playing status before dials; no phase/board/force/hand state was fabricated.
Fresh Shield Wall2 locks at turn2; real Karama cancels old+fresh groups as
one batch, and the previously forbidden ordinary fighter arrival works
after Cunning. Both storm geometries retain ordinary move/flip/battle blocks.
Unresolved-guard, wording, action-copy, fixture-provenance and incidental
phase/null assertions were deleted, not re-pinned. The related10-file union
passes65/65; types/lint pass. Actual390px GameTable declares/allows fresh
Shield Wall2, moves one to Imperial Basin9 and hands over to Battle; allows
Arrakeen2 in place under storm10; and cancels False Wall East5/9 as one batch.
One Nexus remains spent; conversion costs/pools/usage stay unchanged.
Live proof found an existing movement-control bug: Arrakeen10→Polar Sink
was offered during storm10 although authoritative movement rejected unchanged.
Controls now share projected geometric distance and native range, including
selected-origin elites and owned marker routes. Blocked origin/cross-storm
detour is disabled with an accessible reason; no special Cunning bypass or
engine-rule relaxation. Selected-elite input accepts immutable tuples without
copies. Before/after live proof and three native API receipts stay outside Git.
An earlier Fremen-fixture Storm description was corrected to distinguish
Basic dials from the unchanged Advanced card producer; behavior is unchanged.
Final foreground compiler/lint and app build pass, retaining existing
warnings. Native API receipts observe fresh distance1/range1 and battle
Shield Wall, unreachable bound origin/range3 with no battle, and separated
distance2/range1 with no battle. One physical Nexus spends in every actual
declaration. Owned service/scaffold removed, tabs released/auto-closed; final
source-bound receipts remain outside Git.
Three surfaces have no overflow/browser errors. Broader modules/markers,
saved assurance, calibrated AI, complete modes and deployed acceptance remain open.
## 14 October 2026 — Absent-Fremen Nexus pre-devour protection
Explicit recommended interpretation: independent printed protection, without
+51 -4
View File
@@ -1,12 +1,12 @@
# Bene Gesserit Nexus advisor conversion
10 September 2026. This checkpoint implements the native **Cunning** stance conversion described in [NEXUS_BENE_GESSERIT_RULES.md](NEXUS_BENE_GESSERIT_RULES.md). It extends the [Nexus lifecycle](NEXUS_CARD_RUNTIME.md); it does not implement borrowed Voice or Betrayal, settle the outstanding source questions, or open incomplete mode and expansion starts.
Original10 September2026 checkpoint with dated follow-ups below. Native **Cunning** stance conversion uses [the source contract](NEXUS_BENE_GESSERIT_RULES.md) and [Nexus lifecycle](NEXUS_CARD_RUNTIME.md). Borrowed Voice and native Betrayal have separate later bounded paths; none opens incomplete modes or expansion starts.
## Selection and physical result
During its own active Shipment and Movement action in Advanced play, an unallied Bene Gesserit holder can select a nonempty set of eligible advisor territories. Every advisor in each selected territory becomes a fighter together, across all sectors. Positions, physical counts, spice, shipment usage and movement usage stay unchanged. The choice cannot target individual counters or leave mixed stances within a territory.
The private server quote supplies each territory's total count and blocked reason. The controls begin with no selection, offer individual checkboxes and **Select all available**, and require one explicit submission. A blocked group cannot be included through the shared options helper. Fresh accompanied advisors and storm-related conversions retain explicit pending-ruling explanations; these guards are not presented as independently verified printed prohibitions. Stronghold capacity, allied presence, public No-Field presence and existing territorial restrictions remain in force. Homeworlds are not advisor territories.
The private server quote supplies each territory's count and authoritative capacity/alliance reason. Controls begin empty, offer individual checkboxes and **Select all available**, and require one submission. Under the explicit recommended card-exception prototype below, fresh accompanied and storm-related groups may convert in place. Ordinary flip, movement, casualties and battle rules remain; Homeworlds are not advisor territories.
## One cancellation window
@@ -16,7 +16,7 @@ The event-bound record retains original groups, turn, owner, the active Shipment
Other responses, owned decisions, Truthtrance, phase opening, closing Nexus draws and unfinished card transactions must finish before declaration. The table names the response and shows the declared territory names, with standard **Allow this power** and **Cancel with Karama** controls. The internal event is not printed as a player instruction.
## Bots, privacy and evidence
## Bots, privacy and original checkpoint evidence
All four profiles use the same authorized choices. The policy selects legal groups whose advisor count exceeds the total public opposing fighter presence, preserving peaceful or apparently unfavorable groups. It does not read opposing hands, spice, traitors or concealed No-Field values. Hard and Brutal opponents use their existing Karama policy against a declared conversion threatening their fighters; Easy and Medium retain the ordinary pass policy. This is bounded legal-policy integration, not new strategy calibration.
@@ -24,7 +24,7 @@ The focused consumer suites contain **eight** cases, including actual all-profil
Implementation: [source quote](../game/nexus-advisors.ts), [shared options](../game/nexus-advisor-options.ts), [controls](../components/nexus-advisors.tsx), [engine](../game/engine.ts), [bots](../game/bots.ts). Consumer evidence: [controls](../tests/nexus-advisor-controls.test.ts), [bot actions](../tests/nexus-advisor-bots.test.ts). Structural server rendering does not substitute for browser acceptance.
Fresh-advisor and storm permissions remain pending the existing user question. Borrowed Voice remains gated on its separate cancellation/timing interpretation, and Betrayal retains the existing private reaction-policy gate. Complete Bene Gesserit Nexus effects, complete module games and full expansion compliance remain unfinished.
The dated source/interpretation boundaries remain explicit. Fresh/storm card conversion and borrowed Voice now have recommended prototypes, not publisher answers; native Betrayal has its own uniform reaction. Complete BG Nexus effects, module games, full expansion and release acceptance remain unfinished.
## Integrated acceptance
@@ -35,3 +35,50 @@ Two isolated three-seat browser rooms exercised the controls with actual server
The final database comparison preserved all **3,389** opening room versions and state hashes. The database now contains **3,421** rooms, adding only two named browser rooms and thirty isolated HTTP-test rooms. The running development server was reused; the previous controlled restart was about 00:45 UTC, so no hourly restart was due for this checkpoint. Saved games and the removed-automation preference remain preserved.
The native conversion has focused acceptance only. Full module games, placed Terror/Ambassador token combinations and the unresolved printed-rule interactions retain their separate completion requirements. The [Emperor source audit](NEXUS_EMPEROR_RULES.md) prepares the next effect family without claiming runtime support.
## Fresh and storm card-exception prototype — 14 October 2026
The [recommended source interpretation](NEXUS_BENE_GESSERIT_RULES.md#recommended-cunning-card-exceptions--14-october-2026)
removes only Cunning's earlier uncertainty gates. The now-dead storm-only
helper/imports are removed; counts use public presence without allocating
a receipt for each quote. Actual declarations retain exact original locations,
counts and locked stance. One original response still cancels the whole set.
No ordinary flip, arrival, movement, battle or casualty handler is changed.
Two disjoint native proof slices retain real setups, alliances and closing
card draws. Fresh Guild shipment plus free BG accompaniment/paid reinforcement
creates Shield Wall2 with lockedTurn2 on the actual BG turn. A real starting
Karama cancels selected old+new groups together. The same ordinary fighter
arrival rejected before Cunning works after allowed conversion.
Older native advisors survive the real Advanced storm-card producer, not
fabricated dials/phase/board state. Arrakeen2 is sheltered under storm10;
False Wall East5/9 rock groups remain separated by storm7. Native ordinary
flip and cross-storm movement stay blocked; original battle geometry remains
disconnected after the advisors become fighters. The fixture's setup-only
wrong Storm action was repaired by respecting playing status; native rules
and the Advanced card producer are unchanged.
Unresolved-guard, wording, action-copy and incidental phase/null tests were
deleted, not re-pinned. Existing actual rejection/custody tests remain; genuine
cases cover both interpretations, original batch cancellation, retry/actor/
event rejection, all four legal policies and native turn continuation.
The related10-file union65/65 passes.
Actual390px GameTable proofs: fresh selection→native allowance→one fighter
Shield Wall9→Imperial Basin9→Battle handover; storm-bound allowance keeps
Arrakeen2 in place; separated whole-batch Karama leaves both sectors advisors
and the Nexus spent. A real preexisting move-control defect was found: the
button offered Arrakeen10→Polar Sink during storm10, while authoritative
movement correctly rejected unchanged. Controls now share geometric route
distance/native range, including selected-origin elites and owned marker
routes; the blocked origin and cross-storm detour are disabled with an
accessible reason. No special Cunning or faction bypass is added.
Final compiler/lint/build pass. The native API observes fresh distance1/
range1, bound origin unreachable/range3 and separated distance2/range1;
ordinary geometry does not gain a storm battle. Owned QA service/scaffold
is removed and tabs are released/auto-closed. Receipts remain outside Git.
Three surfaces retain width/document390 and no browser errors. Wider
module/marker paths, saved assurance, calibrated AI and deployed acceptance
remain due.
+35 -3
View File
@@ -93,12 +93,44 @@ not certify all named-special/module combinations, historical recovery or
complete rules/deployment.
## Material boundaries requiring a ruling
## Recommended Cunning card exceptions — 14 October 2026
Choose-recommended-option priority now adopts the printed any/all permission
as an **in-place card exception** for freshly accompanied and storm-bound or
storm-separated advisor territories, during BG's own Advanced action.
No retrieved specific publisher clarification settled those combinations;
this interpretation is visible and provisional, not a new publisher ruling.
Reuse the existing whole-territory quote, physical Nexus spend, exact original
stance/force receipt and single Karama response. Allow changes only stance;
cancel keeps every original advisor/lock and leaves the card spent. Ordinary
flip timing, ally/capacity, storm casualties, movement and battle geometry
stay unchanged. Basic supplies no advisors. Pending quotes still bind exact
original groups and cannot become partial conversions.
Native proofs use real setup, closing BG card draws, paid reserve shipments,
free accompaniment and the existing Advanced card-distance producer. Fresh
Shield Wall2 is current-turn locked; sheltered Arrakeen2 is under storm10;
rock False Wall East groups at5/9 are separated by storm7. Only original
undealt order/producer entropy is staged. Original Karama, all four minimal
legal policies and original movement continuation are exercised.
The live390px tables declare/allow the fresh group, move one fighter from
Shield Wall9 to Imperial Basin9, allow the storm-bound group in place, and
cancel the two-sector rock batch. Costs, pools and native counters stay
unchanged by conversion. A live existing Move button mismatch was reproduced:
it offered an impossible storm-origin route that the engine rejected.
Controls now reuse projected route geometry/range and disable that source
and the cross-storm route, with an accessible reason. Engine legality was
not weakened. Related10-file union65/65; wider module and final acceptance
remain open.
## Material source boundaries and provisional interpretations
No retrieved primary clarification resolves these specific combinations. Do not silently broaden permission or describe a conservative block as the printed rule.
1. **Newly accompanied groups:** does Cunning's any/all permission override the same-turn advisor lock, or only the ordinary pre-shipment timing? The face does not name that prohibition.
2. **Storm:** does Cunning allow an in-place flip in a storm-locked stronghold? The FAQ's prohibition explicitly addresses the earlier ordinary Battle flip; its rationale concerns inability to battle. Applying it to this later card is plausible, not explicit.
1. **Newly accompanied groups:** no specific publisher answer was retrieved. The recommended card-exception prototype above allows the in-place Cunning conversion but retains ordinary same-turn flipping/arrival restrictions.
2. **Storm:** the FAQ's ordinary-flip prohibition is not silently broadened to this later card. The recommended prototype permits in-place Cunning only; native storm movement, casualties and disconnected battle geometry remain unchanged.
3. **Borrowed Voice and Karama:** no retrieved Nexus FAQ selects an interpretation. The recommended prototype above explicitly chooses cancellation for this named advantage; Atreides' differently worded effect is not authority for it.
The remaining private-reaction policy question applies to other hidden effects; the bounded Voice Betrayal path uses a uniform native response. This audit likewise does not settle historical Homeworld occupation ownership. Ordinary unoccupied, older, storm-free advisor groups are a clear selection subset, but broader claims retain the boundaries above.
+11 -1
View File
@@ -2,6 +2,16 @@
Original source audit, 10 September 2026, with dated bounded runtime follow-ups below. This document establishes the printed component inventory, source protocol and implementation boundaries; none lifts expansion release gates. The ordinary worm-triggered alliance Nexus already in the engine is distinct from this optional card module.
## Recommended BG Cunning card exceptions — 14 October 2026
The printed any/all conversion now permits fresh accompanied and storm-bound
or storm-separated advisor territories in native BG's own Advanced action,
under an explicitly provisional in-place card interpretation. Only Cunning's
old uncertainty gates are removed: ordinary flip/arrival, capacity/alliance,
movement, battle geometry and actual casualties stay unchanged. One original
Karama response cancels the entire physical batch and keeps the Nexus spent.
[Source, original native proofs and controls](NEXUS_BENE_GESSERIT_RULES.md#recommended-cunning-card-exceptions--14-october-2026).
## Recommended absent-Fremen protection — 14 October 2026
The printed Secret Ally protection now connects before original worm
@@ -1376,7 +1386,7 @@ The [bounded Ixian Secret Ally replacement integration](NEXUS_IXIAN_REPLACEMENT_
- No retrieved card or E3 FAQ gives a general rule that every Nexus effect is Karama-immune, or that playing one suspends all ordinary responses. Homeworld immunity on the preceding page is limited to Homeworld advantages/penalties. Audit cancellation of an underlying native advantage separately from cancellation of the Nexus card before enabling each family.
- Duration is explicit for several panels but not all. Ixian Betrayal's scope over multiple auction lots, the optional continuation after canceling native Prescience, and exact multi-party cancellation priority require the corresponding existing faction timing contract. Do not make an entire-phase cancellation merely from the word “advantage.”
- Emperor forced ally payment and purchase refunds retain their precise source-invoice requirements. Tleilaxu's combined revival now uses the [explicit recommended policy](NEXUS_TLEILAXU_RULES.md#recommended-combined-revival-prototype--14-october-2026): a mandatory free own leader/ordinary slot and fixed paid physical group sharing an expanded ordinary ceiling, with preserved cycles and typed caps. This is a provisional implementation, not a new publisher answer; the five counters are not free.
- Bene Gesserit borrowed Voice now follows the [recommended cancellable named-advantage policy](NEXUS_BENE_GESSERIT_RULES.md#recommended-borrowed-voice-prototype--14-october-2026), with neutral pre-Prescience timing and original native command checks. The prototype does not answer generic hidden-reaction priority, fresh advisor/storm permission or broader named-special/module combinations.
- Bene Gesserit borrowed Voice follows the [recommended cancellable named-advantage policy](NEXUS_BENE_GESSERIT_RULES.md#recommended-borrowed-voice-prototype--14-october-2026), with neutral pre-Prescience timing. Native Cunning separately follows the [recommended in-place fresh/storm exceptions](NEXUS_BENE_GESSERIT_RULES.md#recommended-cunning-card-exceptions--14-october-2026). Neither answers generic hidden-reaction priority or broader named-special/module combinations.
- Fremen Secret Ally protection now follows the [recommended independent-operation policy](NEXUS_FREMEN_RULES.md#recommended-independent-protection--14-october-2026), using original threatened public membership and pre-devour timing. This does not resolve general reactive Betrayal priority or widen native Cunning, and broader revival/module accounting remains separate.
- Richese's two-No-Field effect has a bounded native Cunning shipment follow-up, but wider marker/module compositions remain separate. Its [bounded Betrayal auction contract](NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026) connects public self-cache veto and other-buyer sale diversion, not normal-deck hidden-family self-purchases or private special-Karama acquisition. Ecaz's Duke override needs explicit restoration/end-turn handling across capture, death and Ghola records. These are material implementation tasks, not reasons to invent substitute effects.
- A complete twelve-card reference/inventory and common lifecycle are substantial first work, but do not alone make all thirty-six panels playable. Keep the public module release gated until every face's supported timing, secrecy, custody, bots, UI, rejection, multiplayer CAS and saved continuation paths are verified. If development supports only Atreides' family first, other faces must remain accurately represented and explicitly unavailable in the audit mode; they must not silently act as blanks in a released module.
+1
View File
@@ -550,6 +550,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Tleilaxu Secret Ally revival | Under choose-recommended-option priority, a quiet unallied actual holder with Tleilaxu absent returns one mandatory own dead leader free, using the ordinary leader slot, and zero-to-five exact counters at one bank spice each. The card shares an ordinary ceiling expanded to at least five; prior normal returns count, larger native/Recruits allowances remain, and the fixed card group is never doubled. Only the unavailable-roster trigger is waived; repeated-death/face-down and special custody rules remain, with original ordinary cycles preserved. No force-only use, native Tleilaxu income or nonexistent cancellation owner is invented. This is provisional implementation policy, not publisher clarification or a separately answered numerical user ruling. | [Recommended combined policy](NEXUS_TLEILAXU_RULES.md#recommended-combined-revival-prototype--14-october-2026) |
| Bene Gesserit borrowed Voice | Choose-recommended-option priority selects one Karama-cancellable named Voice advantage when BG is absent. Every fresh Nexus battle has the same attacker-first two-combatant continuation before Prescience/inspection/plans, independent of hidden face. Only the actual unallied holder can Use. Declaration spends the Nexus; cancellation removes only Voice and retains the spent card, receipt and native preparation. No retrofit/reset of old preparation. This is explicit provisional implementation policy, not retrieved publisher clarification. | [Recommended borrowed Voice](NEXUS_BENE_GESSERIT_RULES.md#recommended-borrowed-voice-prototype--14-october-2026) |
| Fremen Secret Ally protection | Choose-recommended-option priority adopts independent printed protection before one original worm's destruction, not a borrowed named native advantage or riding. Publicly possible unallied threatened held-Nexus seats acknowledge in frozen storm order regardless of face; only the actual Fremen holder can spend. Protect that owner's original groups only, retain other casualties/spice removal and original Great Maker vote/Nexus continuation. No invented additional Karama response or native provider. Protected No-Field uses native protected-victim skipping; broader combinations remain unverified. This is visible provisional implementation policy, not publisher clarification or universal reaction priority. | [Recommended independent protection](NEXUS_FREMEN_RULES.md#recommended-independent-protection--14-october-2026) |
| Bene Gesserit Cunning fresh/storm groups | Choose-recommended-option priority treats the printed any/all instruction as an in-place card exception during native BG's own Advanced Shipment and Movement action, permitting freshly accompanied and storm-bound/separated advisor territories. Exact whole groups and one original Karama response remain; cancellation preserves the original locked stance and spends the card. Ordinary flipping/arrival, allied capacity, storm movement/casualties and battle geometry are unchanged. This is a visible provisional implementation policy, not publisher clarification or a separately answered source question. | [Recommended card exceptions](NEXUS_BENE_GESSERIT_RULES.md#recommended-cunning-card-exceptions--14-october-2026) |
| Moritani Atomics after alliance change | The publisher’s permanent −1 text does not identify later-ally treatment, and prior requested searches found no clarification. Under choose-recommended-option priority, retain the original affected pair permanently: Moritani and its ally at detonation. Later Nexus and Enemy of My Enemy alliances are allowed without moving the penalty to a new ally; an unallied detonation gains no later ally target. Existing stored activation targets migrate immutably, without replaying effects. This is a conservative provisional implementation interpretation, not publisher clarification or a separately answered numerical user ruling. | [Recommended persistent targets](MORITANI_TERROR_RULES.md#recommended-persistent-activation-targets--14-october-2026) |
| Moritani Sneak Attack entry classification | The printed verb is “Send”; no retrieved ruling classifies the off-planet reserve transfer as a shipment for other arrival powers. The engine now **explicitly adopts** the supported shipment composition: a positive Sneak Attack in Shipment and Movement accrues the Heighliners token once for its owner, then offers the Bene Gesserit Intrusion (Advanced, with fighters present) and the free spiritual-advisor shipment, with the committed Terror entry resolving first; a zero-force resolution accrues nothing and opens no reaction. This order is an implementation interpretation, not a publisher ruling. | [Source boundary and adopted classification](MORITANI_ASSASSINATION_SNEAK.md#adopted-shipment-classification-7-october-2026) |
| Orgiz collection | Basic retains the publisher-p13 per-blow ambiguity and provisional one-transfer-per-positive-deposit mapping. Authorized supplied Advanced rulebook physical24 says each time Collection occurs in a territory containing spice: one transfer per actual positive territory Collection per fighter occupant, never extra transfers for multiple source keys. Canonical ordinary deposit receipts identify actual payers, including co-present collectors; force presence and old wallets alone do not qualify. Under choose-recommended-option priority, owners and eligible foreign ordinary payers resolve in original storm order. Shared Ecaz lots in both modes wait for actual bilateral allocation, then consider positive foreign recipients Ecaz first, ally second; Advanced charges once per territory, Basic counts actually reduced positive source deposits in that territory. Self, zero shares and empty purses are skipped; balances track every transfer without debt. Ordering and the Basic mapping are provisional implementations, not separate numerical user answers or publisher rulings. | [Advanced source cutover and Basic boundary](DISCOVERY_PROTOTYPE.md#authorized-advanced-collection-wording) |
+10 -10
View File
@@ -87,6 +87,15 @@ export function botMovementRange(
choamBonus: p.faction === 'choam' ? g.choamMovementBonus : 0,
}, planetologist && planetologistLeader(g, p.id) ? planetologist : undefined);
}
/** Geometry shared by projected controls and policy; range and entry remain separate. */
export function groundMoveRouteDistance(g: GameView, p: Seat, from: string, to: string): number {
const advisors = isAdvisor(p, splitLocation(from).territory);
return gameDistance(g, from, to, key => {
const loc = splitLocation(key);
return (loc.sector !== 0 && loc.sector === g.storm) ||
strongholdPathBlocked(g.players, p.id, loc.territory, advisors);
});
}
export function botGroundMoveAllowed(
g: GameView,
p: Seat,
@@ -121,16 +130,7 @@ export function botGroundMoveAllowed(
)
)
return false;
const advisors = isAdvisor(p, source.territory);
return (
gameDistance(g, from, to, (key) => {
const loc = splitLocation(key);
return (
(loc.sector !== 0 && loc.sector === g.storm) ||
strongholdPathBlocked(g.players, p.id, loc.territory, advisors)
);
}) <= botMovementRange(g, p, elite, planetologist)
);
return groundMoveRouteDistance(g, p, from, to) <= botMovementRange(g, p, elite, planetologist);
}
/** Fremen reinforcement radius is static territory geometry, not a ground route. */
export function fremenReserveEntry(to: string) {
+1 -1
View File
@@ -3585,7 +3585,7 @@ function playNexusAdvisors(g: Game, p: Player, action: Action) {
(g.nexusAdvisorHistory ??= []).push(record);
g.nexusAdvisorLast = {event: receipt.event, stage: record.stage};
g.response = {kind: 'nexusAdvisorFlip', owner: p.id, intent: receipt.event, passed: []};
log(g, `${p.name} spent Bene Gesserit Nexus Cunning to convert all advisors in ${receipt.selections.map(s => territory(s.territory).name).join(', ')}. Karama may prevent this entire conversion.`,
log(g, `${p.name} spent Bene Gesserit Nexus Cunning to convert all advisors in ${receipt.selections.map(s => territory(s.territory).name).join(', ')}. Karama may prevent this entire conversion. Provisional card exception: fresh and storm-bound advisors may change stance in place; ordinary movement and battle restrictions remain.`,
{faction: p.faction, name: 'Nexus advisor conversion declared'});
}
function finishNexusAdvisors(g: Game, canceled: boolean) {
+6 -63
View File
@@ -1,8 +1,6 @@
import {
gameDistance,
gameTerritories,
location,
MOBILE_LOCATION,
splitLocation,
territory,
validLocation,
@@ -12,8 +10,7 @@ import {
type BoardContext,
type BoardSeat,
} from './board-resolution-quote';
import { presenceAt, presenceByLocation } from './force-presence';
import { fighterCount } from './advisors';
import { presenceAt } from './force-presence';
import { territoryEntryBlock } from './occupancy';
import { ecazOccupancyIdentity } from './ecaz-occupy';
@@ -126,54 +123,6 @@ function selection(player: BoardSeat, to: string): NexusAdvisorSelection {
: { lockedTurn: player.advisors![to].lockedTurn },
};
}
function stormBlocked(
context: NexusAdvisorContext,
players: readonly BoardSeat[],
owner: BoardSeat,
to: string,
): boolean {
if (context.storm === 0 || territory(to).type === 'polar') return false;
const inStorm = (key: string) =>
splitLocation(
key === MOBILE_LOCATION
? (context.mobileStronghold?.location ?? key)
: key,
).sector === context.storm;
const keys = (p: BoardSeat) =>
Object.entries(presenceByLocation(p))
.filter(
([key, count]) => count > 0 && splitLocation(key).territory === to,
)
.map(([key]) => key);
const own = keys(owner);
if (own.some(inStorm)) return true;
// Only storm-caused separation is an unresolved interaction. No general
// requirement to be in a battle-capable territory is imposed by this card.
for (const other of players) {
if (
other.id === owner.id ||
other.id === owner.ally ||
!fighterCount(other, to)
)
continue;
const theirs = keys(other);
if (
own.some((x) =>
theirs.some((y) => x === y || gameDistance(context, x, y) === 0),
) &&
!own.some((x) =>
theirs.some(
(y) =>
!inStorm(x) &&
!inStorm(y) &&
(x === y || gameDistance(context, x, y, inStorm) === 0),
),
)
)
return true;
}
return false;
}
export function quoteNexusAdvisors(
context: NexusAdvisorContext,
owner: string,
@@ -192,17 +141,11 @@ export function quoteNexusAdvisors(
release.player === owner && release.territory === site.id,
),
)
.map((site) => {
const original = selection(player, site.id);
let blocked = territoryEntryBlock(settled.players, owner, site.id);
if (!blocked && player.advisors![site.id].lockedTurn === context.turn)
blocked =
'Whether Nexus Cunning overrides the same-turn advisor lock is unresolved.';
if (!blocked && stormBlocked(context, settled.players, player, site.id))
blocked =
'Nexus advisor conversion involving storm-blocked forces awaits a ruling.';
return { territory: site.id, count: original.count, blocked };
});
.map((site) => ({
territory: site.id,
count: presenceAt(player, site.id),
blocked: territoryEntryBlock(settled.players, owner, site.id),
}));
return { territories };
}
function signature(receipt: Omit<NexusAdvisorReceipt, 'signature'>): string {
+1 -1
View File
@@ -41,7 +41,7 @@ export function planetologistRange(
/** Cyborgs accompany selected sectors of their own origin, never another origin. */
export function selectedOriginElites(
group: readonly [string, number][],
group: readonly (readonly [string, number])[],
elites: Readonly<Record<string, number>>,
origin: string,
): number {
+6 -6
View File
@@ -4564,7 +4564,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Ixian Betrayal has a separate fresh native-Ixian ixian-betrayal profile for both original Bidding inspection/draw and Advanced Technology attempts. Native counters resolve first; then every publicly possible unallied rival held-Nexus seat acknowledges uniformly before the effect. A legal Use spends one physical Ixian Nexus to deny only that attempt; all-pass preserves the native effect once. No completed-card undo or whole-phase ban is added. This bounded ordering/privacy is an inference, not universal publisher priority; see its bounded verified native/private/recovery/phone evidence and unchanged Richese/module/public/deployment gates.',
'With Tleilaxu absent, its actual unallied Nexus holder may use a quiet Revival control to return one mandatory own dead leader free and up to five exact physical counters for one bank spice each. The provisional policy uses the ordinary leader slot and expands the shared ordinary ceiling to at least five, counting prior returns and retaining larger native/Recruits allowance. Only the unopened roster trigger is waived: repeated deaths, actual stars/Cyborgs and special custody retain canonical rules, and ordinary cycle history is not fabricated. Zero forces is legal; force-only use is not. Spend the physical card once; native Tleilaxu income and cancellation are not imported.',
'In Advanced play, Bene Gesserit Cunning converts selected whole advisor territories during your own Shipment and Movement action. All your counters in each selected territory become fighters together across sectors. It spends the Nexus card once, with no spice, shipment or movement cost.',
'One Karama response covers the entire declared territory set. Cancellation keeps every selected group as advisors and leaves the Nexus card spent. Existing territory restrictions still apply; fresh accompanied advisors and storm-related conversion remain unavailable pending their rulings.',
'One Karama response covers the entire declared territory set. Cancellation preserves every original advisor group and lock while leaving the Nexus spent. Under the explicit recommended in-place card exception, Cunning may include freshly accompanied or storm-bound/separated territories. This is provisional, not publisher clarification. Ordinary flipping/arrival, allied capacity, storm movement/casualties and battle geometry remain unchanged; broader module acceptance remains open.',
'With BG absent, every fresh Nexus battle asks both combatants in attacker order before Prescience, Nexus inspection or sealed plans. Continue preserves the card. The actual unallied BG-card holder may spend it to require or forbid one canonical type against the opponent. Under the explicit provisional recommendation, original Karama can cancel this named Voice advantage; the Nexus stays spent, only Voice is removed and native preparation continues. Existing in-flight preparation is not retrofitted. Native compliance and special/mode restrictions remain; this does not complete other unfinished effects.',
'Bene Gesserit Betrayal can prevent the native Voice while the ordinary pre-plan response is open. In Nexus games every seat responds even without a canceling card, so the window does not reveal the hidden holder. An unallied opponent spends one physical Bene Gesserit Nexus card to remove only that declared Voice; Prescience and plan commitments continue. Truthtrance has priority. This path does not grant borrowed Voice.',
'Emperor Cunning requires an Advanced battle with at least five ordinary Emperor counters and no actual Sardaukar, before submitting your plan. The played Nexus card opens one Karama response. Allowing it makes five ordinary counters count as Sardaukar for this battle; cancellation prevents the entire enhancement and leaves the Nexus card spent.',
@@ -4597,11 +4597,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Existing physical effects, Tleilaxu shared revival and borrowed Voice remain connected. Absent-Fremen protection now binds one original pre-devour occurrence and public threatened held-Nexus storm order; only the actual unallied holder spends. It preserves that owner only and resumes native other casualties, spice removal, Great Maker vote and replacement once. No extra Karama provider, ride or native identity is invented under the visible provisional policy. Wider custody and combinations remain open.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Existing lifecycle, Tleilaxu and borrowed Voice controls remain. Worm protection adds owner-only neutral Continue or actual-holder Use before devouring, with a visible independent-operation policy. The original Accept spice blow and Great Maker vote remain available after settlement. Nexus table guidance now lists connected effects and supply-only Moritani Cunning honestly. Remaining combinations and complete modes are open.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four minimal legal profiles retain existing effects, Tleilaxu combined returns and borrowed Voice, and now answer only their own worm protection offer: actual holder Use or irrelevant-face Pass. Original later worm/Nexus and vote policies continue. This is legal participation, not strategy or difficulty calibration; both remain deferred and unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules and twelve faces retain remaining custody, inquiry, native nested Cunning, fresh advisor/storm and combination questions. Source documents explicitly distinguish cancellable named borrowed Voice from independent absent-Fremen protection with neutral pre-destruction timing. Neither is publisher clarification or universal reaction priority. Existing Richese/Guild contracts and complete-mode gates remain.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Protection related9-file union86/86 and actual390px GameTable pass. Genuine setup, worm/alliance/closing draw, paid safe Hagga shipment and free Advanced advisor produce the next real worm; Use/Pass, source rejection, four policies and original Great Maker vote/replacement are exercised. Neutral Continue leaves groups intact; Use preserves Emperor1/reserves19/Tanks0/spice10, devours BG advisor1, clears four spice, spends one Nexus and accepts the resumed Sihaya blow. No overflow/errors. Earlier Voice/Tleilaxu evidence remains bounded; wider modules, assurance and deployed acceptance stay open.', evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Existing physical effects, Tleilaxu revival, borrowed Voice and independent worm protection remain connected. Native BG Cunning now permits fresh and storm-bound/separated whole advisor territories under the visible provisional card exception. Exact original stance/groups, physical card and one Karama response remain; ordinary flip/arrival, capacity, movement, casualties and battle geometry are unchanged. Dead uncertainty helper is removed and quotes avoid receipt allocations. Wider custody and combinations remain open.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Existing connected controls remain. Cunning offers actual fresh/storm whole groups with a visible in-place interpretation, native allowance or whole-batch cancellation. Live proof repaired an existing ordinary Move button mismatch using projected route geometry/native range; storm-origin and over-range cross-storm routes now disable with accessible explanation, without engine relaxation. Original movement/turn handover remains. Complete modes and wider module controls are open.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four unchanged minimal legal profiles retain existing effects and now execute actual favorable two-advisor versus one-fighter Cunning for fresh, storm-bound and separated sources through the shared quote. Original response and movement/turn continuation remain legal. Projected geometry is shared with corrected controls; no new strategy or calibration is claimed, and both remain deferred and unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules and twelve faces retain remaining custody, inquiry, native nested Cunning and combination questions. Source documents distinguish cancellable named Voice, independent protection and the recommended in-place fresh/storm Cunning exceptions without claiming publisher answers. Actual Advanced card-distance chronology and native ordinary restrictions are explicit. Existing Richese/Guild contracts and complete-mode gates remain.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Cunning related10-file union65/65 and types/lint pass. Genuine current-turn Shield Wall2, sheltered Arrakeen2 under storm10 and rock False Wall East5/9 separated by storm7 exercise allow/cancel, immutable rejection, exact custody, original geometry and four legal profiles. Three real390px surfaces prove fresh conversion/fighter move/Battle handover, storm stance-only allowance and entire separated-batch Karama cancellation. A live offered but engine-rejected storm move is corrected to disabled route/range guidance. Costs/pools/usage unchanged by conversion; no overflow/errors. Wider modules, assurance and deployed acceptance stay open.', evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md'] },
],
},
{
+183
View File
@@ -0,0 +1,183 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { type SpiceCard } from '../game/cards';
import { validateNexusCards } from '../game/nexus-cards';
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type NexusAdvisorFreshFixture = {
initial: Game;
afterSetup: Game;
game: Game;
owner: string;
foreign: string;
canceler: string;
card: string;
territory: string;
location: string;
event: string;
declaration: Action;
batchDeclaration: Action;
ordinaryAction: Action;
actions: StrongholdFactionsNativeStep[];
beforeForeignShipment: Game;
afterForeignShipment: Game;
beforeAccompaniment: Game;
afterAccompaniment: Game;
beforeReinforcement: Game;
scenario: {
turn: number;
storm: number;
lockedTurn: number;
advisors: number;
opposingFighters: number;
alliance: [string, string];
ordinaryTarget: string;
undealtOrder: string[];
};
};
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
export function nexusAdvisorFreshPlayer(game: Game, actor: string) {
const player = game.players.find(p => p.id === actor);
assert.ok(player, `Missing original advisor seat ${actor}`);
return player;
}
function entropy<T>(producer: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return producer(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function nativeStep(game: Game): StrongholdFactionsNativeStep {
if (!game.phaseOpening && !game.response) {
const decision = game.decision;
if (decision?.kind === 'guildTiming')
return { actor: decision.player, action: { type: 'decision', take: game.turn === 2 } };
// Preserve original advisors through the earlier mandatory declaration.
if (decision?.kind === 'advisorBattle' || decision?.kind === 'advisor' || decision?.kind === 'intrusion')
return { actor: decision.player, action: { type: 'decision', accept: false } };
if (game.status === 'setup' && game.setupStage === 'prediction')
return { actor: 'bg', action: { type: 'predict', faction: 'emperor', turn: 10 } };
if (game.status === 'setup' && game.setupStage === 'forces' && !nexusAdvisorFreshPlayer(game, 'bg').advisorSetup)
return { actor: 'bg', action: { type: 'advisorSetup', territory: 'arrakeen', sector: 10 } };
}
const step = nextStrongholdFactionsNativeStep(game);
assert.ok(step, 'The genuine advisor fixture reached an unexpected private battle plan');
return step;
}
function run(game: Game, step: StrongholdFactionsNativeStep, actions: StrongholdFactionsNativeStep[]): Game {
actions.push(structuredClone(step));
return entropy(() => applyAction(game, step.actor, step.action));
}
function advance(game: Game, until: (state: Game) => boolean, actions: StrongholdFactionsNativeStep[]): Game {
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
game = run(game, nativeStep(game), actions);
}
throw Error('Native advisor progression did not reach its requested boundary');
}
/** Finish only original acknowledgement/decision windows, never another movement turn. */
export function settleNexusAdvisorFresh(game: Game): Game {
return advance(game, clean, []);
}
/** Reorder undealt Spice only. Previously blown cards retain their discard custody. */
function orderSpice(game: Game, kinds: ('land' | 'worm')[], order: string[]): void {
const front: SpiceCard[] = [];
for (const kind of kinds) {
const index = game.spiceDeck.findIndex(card => kind === 'worm' ? 'worm' in card :
'territory' in card && !['imperial_basin', 'arrakeen', 'tueks_sietch'].includes(card.territory));
assert.ok(index >= 0, `Missing original undealt ${kind}`);
const card = game.spiceDeck.splice(index, 1)[0];
front.push(card);
order.push('territory' in card ? card.territory : 'worm');
}
game.spiceDeck.unshift(...front);
}
/** Every force, payment, lock, alliance, card deal and continuation is native.
* The only staged values are physical undealt deck order and producer entropy. */
export function createNexusAdvisorFreshFixture(): NexusAdvisorFreshFixture {
const owner = 'bg', foreign = 'guild', canceler = foreign;
const territory = 'shield_wall', location = 'shield_wall:9';
const actions: StrongholdFactionsNativeStep[] = [], undealtOrder: string[] = [];
let game = createGame('NEXUSADVISORFRESH', newPlayer(owner, 'Bene Gesserit', 'beneGesserit'), true, []);
joinGame(game, newPlayer(foreign, 'Guild', 'guild'));
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
game.nexusCards = { cards: null, phase: null };
for (const player of game.players) game = run(game, { actor: player.id, action: { type: 'ready' } }, actions);
game = entropy(() => initializeNexusGameForAudit(game));
assert.equal(game.status, 'setup');
assert.ok(game.players.every(p => p.hand.length === 0));
// The original starting deal consumes one card per seat, in roster order.
const karamaIndex = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(karamaIndex >= 0);
const card = game.deck[karamaIndex].id;
[game.deck[1], game.deck[karamaIndex]] = [game.deck[karamaIndex], game.deck[1]];
undealtOrder.push(`Starting deal ${card} to ${canceler}`);
const initial = structuredClone(game);
game = advance(game, state => state.status === 'playing', actions);
const afterSetup = structuredClone(game);
assert.ok(nexusAdvisorFreshPlayer(game, canceler).hand.some(held => held.id === card));
game = advance(game, state => state.phase === 1 && clean(state), actions);
orderSpice(game, ['land', 'land'], undealtOrder);
game = advance(game, state => state.phase === 5 && state.active === 'atreides' && clean(state), actions);
// A genuine nearby occupied destination makes the later ordinary flip action
// geometrically legal; only its fresh advisor stance disallows fighter entry.
game = run(game, { actor: 'atreides', action: { type: 'ship', territory: 'imperial_basin', sector: 10, amount: 1 } }, actions);
game = advance(game, clean, actions);
game = advance(game, state => state.turn === 2 && state.phase === 1 && clean(state), actions);
orderSpice(game, ['worm', 'land', 'land'], undealtOrder);
game = advance(game, state => state.nexus === true && !state.spiceWindow, actions);
game = run(game, { actor: 'emperor', action: { type: 'alliance', target: 'atreides' } }, actions);
game = run(game, { actor: 'atreides', action: { type: 'alliance', target: 'emperor' } }, actions);
game = advance(game, state => state.nexusCards?.phase?.stage === 'drawing', actions);
for (const [actor, face] of [[owner, 'beneGesserit'], [foreign, 'guild']] as const) {
const cards = game.nexusCards!.cards!;
const index = cards.deck.indexOf(face);
assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
undealtOrder.push(`Closing Nexus ${face} to ${actor}`);
game = run(game, { actor, action: { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } }, actions);
}
game = advance(game, state => state.phase === 5 && state.active === foreign && clean(state), actions);
const beforeForeignShipment = structuredClone(game);
game = run(game, { actor: foreign, action: { type: 'ship', territory, sector: 9, amount: 1 } }, actions);
game = advance(game, state => state.decision?.kind === 'advisor' && !state.response && !state.phaseOpening, actions);
const afterForeignShipment = structuredClone(game), beforeAccompaniment = structuredClone(game);
game = run(game, { actor: owner, action: { type: 'decision', accept: true, accompany: true, amount: 1 } }, actions);
game = advance(game, clean, actions);
const afterAccompaniment = structuredClone(game);
game = run(game, { actor: foreign, action: { type: 'endMovement' } }, actions);
game = advance(game, state => state.phase === 5 && state.active === owner && clean(state), actions);
const beforeReinforcement = structuredClone(game);
game = run(game, { actor: owner, action: { type: 'ship', territory, sector: 9, amount: 1 } }, actions);
game = advance(game, clean, actions);
const player = nexusAdvisorFreshPlayer(game, owner);
assert.equal(player.advisors?.[territory]?.lockedTurn, game.turn);
assert.equal(player.forces[location], 2);
assert.equal(game.nexusCards!.cards!.hands[owner], 'beneGesserit');
assert.equal(player.ally, null);
validateNexusCards(game.nexusCards!.cards!, game.players);
const event = viewGame(game, owner).nexusAdvisors!.offer!.event;
return {
initial, afterSetup, game, owner, foreign, canceler, card, territory, location, event,
declaration: { type: 'nexusAdvisors', event, territories: [territory] },
batchDeclaration: { type: 'nexusAdvisors', event, territories: ['arrakeen', territory] },
ordinaryAction: { type: 'move', from: location, territory: 'imperial_basin', sector: 10, amount: 1, fighters: true },
actions, beforeForeignShipment, afterForeignShipment, beforeAccompaniment, afterAccompaniment, beforeReinforcement,
scenario: { turn: game.turn, storm: game.storm, lockedTurn: player.advisors![territory].lockedTurn!,
advisors: player.forces[location], opposingFighters: nexusAdvisorFreshPlayer(game, foreign).forces[location],
alliance: ['emperor', 'atreides'], ordinaryTarget: 'imperial_basin:10', undealtOrder },
};
}
+230
View File
@@ -0,0 +1,230 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
type Action, type Game, type Player,
} from '../game/engine';
import { gameDistance, splitLocation, territory } from '../game/board';
import type { FactionId } from '../game/catalog';
import { validateNexusCards } from '../game/nexus-cards';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { nexusBorrowedVoiceInventory } from './fixture-nexus-borrowed-voice';
export type NexusAdvisorStormStep = { actor: string; action: Action };
export type NexusAdvisorStormFixture = {
initial: Game;
afterSetup: Game;
afterShipment: Game;
beforeStorm: Game;
afterStorm: Game;
game: Game;
owner: string;
opponent: string;
canceler: string;
cancelerCard: string;
territory: string;
storm: number;
separated: boolean;
physicalFacts: {
sectors: number[];
groups: Record<string, number>;
opponentGroups: Record<string, number>;
originalStance: { lockedTurn?: number };
unblockedDistance: number;
blockedDistance: number;
stormSource: Game['stormMovementSource'];
};
actions: NexusAdvisorStormStep[];
staging: string[];
scenario: Record<string, unknown>;
};
export function nexusAdvisorStormPlayer(game: Game, id: string): Player {
const player = game.players.find(candidate => candidate.id === id);
assert.ok(player, `Missing original storm seat ${id}`);
return player;
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
/** Entropy affects original producers only. No persisted hand, board, wallet,
* phase, casualty, alliance, response or source receipt is staged. */
function identityRandom<T>(run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
type Driver = {
owner: string;
opponent: string;
canceler: string;
territory: string;
accompanySector: number;
reinforcementSector: number;
declinedAdvisorBattle: boolean;
};
function nextStep(game: Game, driver: Driver): NexusAdvisorStormStep | null {
if (game.status === 'setup' && game.setupStage === 'prediction')
return { actor: driver.owner, action: { type: 'predict', faction: 'emperor', turn: 10 } };
if (game.status === 'setup' && game.setupStage === 'forces' &&
!nexusAdvisorStormPlayer(game, driver.owner).advisorSetup)
return { actor: driver.owner, action: { type: 'advisorSetup', territory: 'polar_sink', sector: 0 } };
const decision = game.decision;
if (decision?.kind === 'advisorBattle') {
driver.declinedAdvisorBattle = true;
return { actor: decision.player, action: { type: 'decision', accept: false } };
}
if (decision?.kind === 'advisor') {
const accompany = game.turn === 1 && decision.shipment === driver.opponent;
return { actor: decision.player, action: accompany
? { type: 'decision', accept: true, accompany: true, sector: driver.accompanySector, amount: 1 }
: { type: 'decision', accept: false } };
}
if (decision?.kind === 'guildTiming')
return { actor: decision.player, action: { type: 'decision', take: false } };
if (!clean(game)) return nextStrongholdFactionsNativeStep(game);
if (game.nexusCards?.phase?.stage === 'drawing') {
const phase = game.nexusCards.phase;
const actor = phase.eligible.find(id => !phase.done.includes(id));
if (actor) return { actor, action: { type: 'nexusCardChoice', turn: phase.turn,
card: game.nexusCards.cards!.hands[actor],
choice: game.nexusCards.cards!.hands[actor] === null ? 'draw' : 'keep', ownRedraws: 0 } };
}
if (game.status === 'playing' && game.phase === 0 && game.stormPending === null) {
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
if (actor) return { actor, action: { type: 'stormDial', amount: 0 } };
}
if (game.turn === 1 && game.phase === 3 && game.auction &&
!nexusAdvisorStormPlayer(game, driver.canceler).hand.some(card => card.effect === 'karama') &&
game.auction.index === 0 && game.auction.bid === 0 && game.auction.active === driver.canceler)
return { actor: driver.canceler, action: { type: 'bid', amount: 1 } };
if (game.turn === 1 && game.phase === 5 && game.active &&
[driver.owner, driver.opponent].includes(game.active) &&
!nexusAdvisorStormPlayer(game, game.active).shipped)
return { actor: game.active, action: { type: 'ship', territory: driver.territory,
sector: game.active === driver.owner ? driver.reinforcementSector : driver.accompanySector,
amount: 1, elite: 0, allyPayment: 0,
...(game.active === driver.owner ? { advisors: true } : {}) } };
return nextStrongholdFactionsNativeStep(game);
}
function advance(game: Game, until: (state: Game) => boolean,
driver: Driver, actions: NexusAdvisorStormStep[]): Game {
for (let count = 0; count < 2600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original chronology must remain playable');
const next = nextStep(game, driver);
assert.ok(next, 'The native storm chronology must not require an invented battle plan');
actions.push(structuredClone(next));
game = applyAction(game, next.actor, next.action);
}
throw Error('Original advisor storm chronology did not reach its requested boundary');
}
function frontUndealt<T>(deck: T[], matches: (card: T) => boolean): void {
const index = deck.findIndex(matches);
assert.ok(index >= 0, 'The requested physical card must remain undealt');
deck.unshift(deck.splice(index, 1)[0]);
}
function build(separated: boolean): NexusAdvisorStormFixture {
const owner = 'storm-bene-gesserit', opponent = 'storm-emperor', canceler = 'storm-guild';
const harkonnen = 'storm-harkonnen';
const site = separated ? 'false_wall_east' : 'arrakeen';
const accompanySector = separated ? 5 : 10, reinforcementSector = separated ? 9 : 10;
const actions: NexusAdvisorStormStep[] = [], staging: string[] = [];
let lobby = createGame('NEXUSADVISORSTORM', newPlayer(opponent, 'Emperor', 'emperor'), true);
joinGame(lobby, newPlayer(owner, 'Bene Gesserit', 'beneGesserit'));
joinGame(lobby, newPlayer(canceler, 'Guild', 'guild'));
joinGame(lobby, newPlayer(harkonnen, 'Harkonnen', 'harkonnen'));
lobby.nexusCards ??= { cards: null, phase: null };
for (const player of lobby.players) if (!player.ready)
lobby = applyAction(lobby, player.id, { type: 'ready' });
const initial = structuredClone(lobby);
let game = initializeNexusGameForAudit(lobby);
const driver: Driver = { owner, opponent, canceler, territory: site,
accompanySector, reinforcementSector, declinedAdvisorBattle: false };
game = advance(game, state => state.status === 'playing', driver, actions);
const afterSetup = structuredClone(game);
game = advance(game, state => state.turn === 1 && state.phase === 2 && clean(state), driver, actions);
frontUndealt(game.deck, card => card.effect === 'karama');
frontUndealt(game.spiceDeck, card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
staging.push('Ordered one original undealt Karama for the real first auction and one original undealt Shai-Hulud for the real turn-two Nexus.');
game = advance(game, state => state.turn === 1 && state.phase === 5 && clean(state) &&
nexusAdvisorStormPlayer(state, owner).shipped, driver, actions);
const afterShipment = structuredClone(game);
assert.equal(nexusAdvisorStormPlayer(game, owner).forces[`${site}:${accompanySector}`], separated ? 1 : 2);
assert.equal(nexusAdvisorStormPlayer(game, owner).forces[`${site}:${reinforcementSector}`], separated ? 1 : 2);
assert.deepEqual(nexusAdvisorStormPlayer(game, owner).advisors?.[site], { lockedTurn: 1 });
const cancelerCard = nexusAdvisorStormPlayer(game, canceler).hand.find(card => card.effect === 'karama');
assert.ok(cancelerCard, 'The Guild must have bought the original Karama');
game = advance(game, state => state.turn === 2 && state.nexus && !state.spiceWindow && clean(state), driver, actions);
assert.equal(game.storm, 2, 'The real turn-two one-sector storm card must move the original storm from one to two');
for (const entry of [
{ actor: opponent, action: { type: 'alliance', target: canceler } },
{ actor: canceler, action: { type: 'alliance', target: opponent } },
] satisfies NexusAdvisorStormStep[]) {
actions.push(structuredClone(entry));
game = applyAction(game, entry.actor, entry.action);
}
game = advance(game, state => state.nexusCards?.phase?.stage === 'drawing' && clean(state), driver, actions);
const phase = game.nexusCards!.phase!, cards = game.nexusCards!.cards!;
assert.deepEqual([...phase.eligible].sort(), [owner, harkonnen].sort());
const wanted: Record<string, FactionId> = { [owner]: 'beneGesserit', [harkonnen]: 'harkonnen' };
const front = phase.eligible.map(id => wanted[id]);
cards.deck = [...front, ...cards.deck.filter(card => !front.includes(card))];
staging.push('Ordered the conserved undealt Nexus deck for the actual unallied Bene Gesserit and Harkonnen draws; neither hand is assigned.');
const storm = separated ? 7 : 10;
game = advance(game, state => state.turn === storm - 1 && state.phase === 8 && clean(state), driver, actions);
const beforeStorm = structuredClone(game);
assert.equal(game.nexusCards!.cards!.hands[owner], 'beneGesserit');
assert.ok(driver.declinedAdvisorBattle, 'The native ordinary advisor battle opportunity must be declined before the storm');
game = advance(game, state => state.turn === storm && state.phase === 0 && state.stormPending !== null && clean(state), driver, actions);
const stormSource = structuredClone(game.stormMovementSource);
assert.ok(stormSource);
assert.equal(stormSource.kind, 'card');
assert.equal(stormSource.turn, storm);
assert.equal(stormSource.distance, 1);
assert.equal(game.stormPending, 1, 'Identity producer entropy draws the real printed one-sector storm card');
game = advance(game, state => state.turn === storm && state.phase === 1 && clean(state), driver, actions);
const afterStorm = structuredClone(game);
game = advance(game, state => state.turn === storm && state.phase === 5 && state.active === owner && clean(state), driver, actions);
assert.equal(game.storm, storm, 'Only the original storm producer establishes the blocked geometry');
assert.deepEqual(nexusAdvisorStormPlayer(game, owner).forces, nexusAdvisorStormPlayer(beforeStorm, owner).forces);
assert.deepEqual(nexusAdvisorStormPlayer(game, opponent).forces, nexusAdvisorStormPlayer(beforeStorm, opponent).forces);
assert.equal(nexusAdvisorStormPlayer(game, owner).ally, null);
assert.equal(game.nexusCards!.cards!.hands[owner], 'beneGesserit');
assert.equal(nexusAdvisorStormPlayer(game, owner).shipped, false);
assert.equal(nexusAdvisorStormPlayer(game, owner).moved, 0);
if (separated) assert.equal(territory(site).type, 'rock');
else assert.ok(!game.shieldWallDestroyed, 'The intact Shield Wall must shelter Arrakeen from real storm casualties');
const from = `${site}:${reinforcementSector}`, to = `${site}:${accompanySector}`;
const blocked = (key: string) => splitLocation(key).sector === storm;
const physicalFacts = {
sectors: [...territory(site).sectors],
groups: structuredClone(nexusAdvisorStormPlayer(game, owner).forces),
opponentGroups: structuredClone(nexusAdvisorStormPlayer(game, opponent).forces),
originalStance: structuredClone(nexusAdvisorStormPlayer(game, owner).advisors![site]),
unblockedDistance: gameDistance(game, from, to),
blockedDistance: gameDistance(game, from, to, blocked),
stormSource,
};
assert.equal(physicalFacts.unblockedDistance, 0);
assert.ok(physicalFacts.blockedDistance > 0);
nexusBorrowedVoiceInventory(game);
validateNexusCards(game.nexusCards!.cards!, game.players);
const scenario = { code: game.code, advanced: game.advanced, turn: game.turn, phase: game.phase,
owner, opponent, canceler, cancelerCard: cancelerCard.id, territory: site, storm, separated,
physicalFacts, heldNexusCard: game.nexusCards!.cards!.hands[owner], staging: [...staging] };
return { initial, afterSetup, afterShipment, beforeStorm, afterStorm, game, owner, opponent,
canceler, cancelerCard: cancelerCard.id, territory: site, storm, separated, physicalFacts,
actions, staging, scenario };
}
export function createNexusAdvisorStormFixture(options: { separated?: boolean } = {}): NexusAdvisorStormFixture {
return identityRandom(() => build(options.separated ?? false));
}
+3 -1
View File
@@ -185,7 +185,9 @@ export function createNexusFremenProtectionFixture(
'Original scalar producer lotteries keep every printed shuffle order; native UUID entropy is untouched.',
'Only the undealt physical Spice Deck is ordered: turn-one land, the real empty turn-two worm, the turn-two sand blow the group ships into, the next accepted real worm, then the replacement draw.',
'Only the undealt physical Nexus Deck is ordered so the real closing draws give the unallied holder the physical Fremen card and the other unallied seat the physical Bene Gesserit card; no hand is assigned.',
'Every storm dialer chooses the printed minimum (0 on turn one, otherwise 1), an original legal dial that keeps the real storm on Meridian at turn one and well clear of the shipped sand source afterwards',
advanced
? 'Advanced retains the original storm-card producer after the first Storm; no card distance, storm sector or phase is assigned.'
: 'Basic storm dialers choose the native minimum: zero on turn one and one on later turns. No storm sector or phase is assigned.',
advanced
? 'The physically dealt Bene Gesserit seat documents the absent-Fremen case: it holds an irrelevant Nexus face and accepts the native free accompanied advisor arrival at the same sector.'
: 'No Bene Gesserit is seated; the fourth seat is Harkonnen and holds an irrelevant Nexus face at no other location.',
+1 -159
View File
@@ -1,113 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { viewGame, type GameView } from '../game/engine';
import { NexusAdvisors } from '../components/nexus-advisors';
import {
nexusAdvisorAction,
nexusAdvisorCanAct,
} from '../game/nexus-advisor-options';
import {
nexusAdvisorFixture,
beginNexusAdvisorFlip,
holdAdvisorKarama,
} from './fixture-nexus-advisors';
import { nexusAdvisorFixture } from './fixture-nexus-advisors';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(
specifier === 'next/image'
? 'vinext/shims/image'
: specifier === 'next/link'
? 'vinext/shims/link'
: specifier,
context,
);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries(
[...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map((match) => [
match[1],
match[1],
]),
);
return {
format: 'module',
source: `export default ${JSON.stringify(classes)}`,
shortCircuit: true,
};
},
});
const { GameTable } = await import('../components/game-table');
aliases.deregister();
const html = (game: GameView, busy = false) =>
renderToStaticMarkup(createElement(NexusAdvisors, { game, busy, act() {} }));
void test('advisor controls name whole territories and counts with an explicit empty initial selection', () => {
const v = viewGame(nexusAdvisorFixture(), 'p');
const offer = v.nexusAdvisors!.offer!;
const before = structuredClone(v);
const text = html(v);
assert.match(text, /Pasty Mesa: 3 advisors/);
assert.match(text, /Arrakeen: 2 advisors/);
assert.equal((text.match(/class="decision-checkbox min-h-11"/g) ?? []).length, offer.territories.length);
assert.match(text, /Select all available/);
assert.match(text, /0 territories selected/);
assert.match(text, /across all sectors/);
assert.match(
text,
/costs no spice and uses no shipment or movement allowance/,
);
assert.doesNotMatch(text, /checked=""/);
assert.match(text, /disabled=""[^>]*>Convert selected advisors/);
const selected = offer.territories
.filter((choice) => !choice.blocked)
.map((choice) => choice.territory);
assert.ok(selected.length >= 2);
assert.deepEqual(nexusAdvisorAction(v, offer.event, selected), {
type: 'nexusAdvisors',
event: offer.event,
territories: selected,
});
assert.deepEqual(v, before);
assert.match(html(v, true), /disabled=""/);
});
void test('shared advisor selection binds event, unique whole groups and projected source boundary reasons', () => {
const v = viewGame(nexusAdvisorFixture(), 'p');
const offer = v.nexusAdvisors!.offer!;
const place = offer.territories.find(
(choice) => choice.territory === 'arrakeen',
)!;
assert.ok(nexusAdvisorAction(v, offer.event, ['arrakeen']));
for (const choices of [
[],
['arrakeen', 'arrakeen'],
['not-a-territory'],
['arrakeen:10'],
])
assert.equal(nexusAdvisorAction(v, offer.event, choices), null);
assert.equal(nexusAdvisorAction(v, 'stale', ['arrakeen']), null);
// Consumer-only projections prove reasons are displayed rather than guessed
// from a source rule or the current territory's board graphics.
place.blocked =
'Newly accompanied advisors await the same-turn conversion ruling.';
offer.territories.find(
(choice) => choice.territory === 'pasty_mesa',
)!.blocked = 'Conversion in storm awaits its timing ruling.';
assert.equal(nexusAdvisorAction(v, offer.event, ['arrakeen']), null);
assert.match(html(v), /Newly accompanied advisors await/);
assert.match(html(v), /Conversion in storm awaits/);
offer.blocked = 'Finish the current movement response first.';
assert.equal(nexusAdvisorCanAct(v), false);
assert.match(html(v), /Finish the current movement response first/);
});
void test('advisor selection rejects other actors, Basic, wrong phase and all interrupted windows', () => {
const original = viewGame(nexusAdvisorFixture(), 'p');
@@ -169,59 +67,3 @@ void test('advisor selection rejects other actors, Basic, wrong phase and all in
}
});
void test('public pending territory set explains one Karama cancellation without exposing the private offer', () => {
const initial = nexusAdvisorFixture();
holdAdvisorKarama(initial);
const g = beginNexusAdvisorFlip(initial, ['arrakeen', 'pasty_mesa']);
assert.equal(g.response?.kind, 'nexusAdvisorFlip');
for (const id of ['p', 'q', 'r']) {
const v = viewGame(g, id);
assert.ok(v.nexusAdvisors!.pending);
Object.defineProperty(v.nexusAdvisors!, 'offer', {
get() {
throw new Error('Read offer during public pending response');
},
});
for (const player of v.players)
Object.defineProperty(player, 'hand', {
get() {
throw new Error('Read private hand');
},
});
const text = html(v);
assert.match(text, /Arrakeen, Pasty Mesa/);
assert.match(text, /Karama can cancel the entire selection/);
assert.doesNotMatch(text, /checkbox|Convert selected advisors/);
}
});
void test('actual response panel names Nexus conversion and offers Karama without printing its internal event', () => {
const initial = nexusAdvisorFixture();
holdAdvisorKarama(initial);
const g = beginNexusAdvisorFlip(initial, ['arrakeen', 'pasty_mesa']);
const v = viewGame(g, 'q');
// Real component rendering with CSS selector names preserved above; this is
// structural response verification, not a browser or visual acceptance claim.
const text = renderToStaticMarkup(
createElement(GameTable, {
game: v,
send: async () => {},
onExit() {},
busy: false,
}),
);
assert.match(text, /Bene Gesserit Nexus advisor conversion/);
assert.match(text, /Cancel with Karama/);
assert.match(text, /Allow this power/);
assert.doesNotMatch(text, /nexusAdvisors/);
const circleTitles = [
...text.matchAll(/<title>(Player circle [^<]+)<\/title>/g),
].map((match) => match[1]);
assert.equal(circleTitles.length, 6);
assert.ok(
circleTitles.every((title) =>
/^Player circle [1-6], sector \d+: .+/.test(title),
),
);
assert.ok(circleTitles.some((title) => title.includes('Bene Gesserit')));
});
+132
View File
@@ -0,0 +1,132 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { distance, splitLocation } from '../game/board';
import { fighterCount } from '../game/advisors';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { nexusAdvisorAction } from '../game/nexus-advisor-options';
import {
createNexusAdvisorFreshFixture, nexusAdvisorFreshPlayer, settleNexusAdvisorFresh,
} from './fixture-nexus-advisor-fresh';
/** Stance/card changes must not alter physical pools, resources or the native turn. */
function unchangedFrame(game: Game) {
return {
status: game.status, turn: game.turn, phase: game.phase, active: game.active,
order: game.order, remaining: game.movementRemaining, spice: game.spice,
storm: game.storm, hajr: game.hajr, guildTimingGranted: game.guildTimingGranted,
guildTimingLocked: game.guildTimingLocked,
players: game.players.map(player => ({
id: player.id, forces: player.forces, reserves: player.reserves, tanks: player.tanks,
elites: player.elites, spice: player.spice, shipped: player.shipped, moved: player.moved,
})),
};
}
void test('fresh Cunning spends its physical card once and resumes the original shipment/movement turn in place', () => {
const fixture = createNexusAdvisorFreshFixture();
const { owner, territory, location } = fixture;
const frame = structuredClone(unchangedFrame(fixture.game));
const offer = viewGame(fixture.game, owner).nexusAdvisors!.offer!;
assert.equal(offer.blocked, null);
assert.deepEqual(offer.territories.find(group => group.territory === territory), { territory, count: 2, blocked: null });
const declaration = nexusAdvisorAction(viewGame(fixture.game, owner), offer.event, [territory]);
const pending = applyAction(fixture.game, owner, declaration!);
assert.equal(pending.response!.kind, 'nexusAdvisorFlip');
assert.equal(nexusAdvisorFreshPlayer(pending, owner).advisors![territory].lockedTurn, pending.turn);
assert.equal(pending.nexusCards!.cards!.hands[owner], null);
assert.equal(pending.nexusCards!.cards!.discard.filter(face => face === 'beneGesserit').length, 1);
assert.deepEqual(unchangedFrame(pending), frame);
assert.throws(() => applyAction(pending, owner, fixture.declaration));
const allowed = settleNexusAdvisorFresh(pending);
assert.equal(nexusAdvisorFreshPlayer(allowed, owner).advisors?.[territory], undefined);
assert.deepEqual(nexusAdvisorFreshPlayer(allowed, owner).advisors?.arrakeen, {});
assert.equal(nexusAdvisorFreshPlayer(allowed, owner).forces[location], 2);
assert.equal(fighterCount(nexusAdvisorFreshPlayer(allowed, owner), territory), 2);
assert.equal(allowed.nexusAdvisorHistory!.at(-1)!.stage, 'completed');
assert.deepEqual(unchangedFrame(allowed), frame);
assert.equal(allowed.nexusCards!.cards!.discard.filter(face => face === 'beneGesserit').length, 1);
assert.throws(() => applyAction(allowed, owner, fixture.declaration));
assert.equal(viewGame(allowed, owner).nexusAdvisors!.offer, null);
const continued = settleNexusAdvisorFresh(applyAction(allowed, owner, { type: 'endMovement' }));
assert.equal(nexusAdvisorFreshPlayer(continued, owner).forces[location], 2);
});
void test('original Karama cancels the entire selected batch and preserves the current-turn lock and native continuation', () => {
const fixture = createNexusAdvisorFreshFixture();
const frame = structuredClone(unchangedFrame(fixture.game));
const stances = structuredClone(nexusAdvisorFreshPlayer(fixture.game, fixture.owner).advisors);
const pending = applyAction(fixture.game, fixture.owner, fixture.batchDeclaration);
assert.deepEqual(pending.nexusAdvisorHistory!.at(-1)!.receipt.selections.map(group => group.territory), ['arrakeen', fixture.territory]);
const canceled = settleNexusAdvisorFresh(applyAction(pending, fixture.canceler, { type: 'card', card: fixture.card, mode: 'cancel' }));
assert.equal(canceled.nexusAdvisorHistory!.at(-1)!.stage, 'canceled');
assert.deepEqual(nexusAdvisorFreshPlayer(canceled, fixture.owner).advisors, stances);
assert.equal(nexusAdvisorFreshPlayer(canceled, fixture.owner).advisors![fixture.territory].lockedTurn, canceled.turn);
assert.equal(fighterCount(nexusAdvisorFreshPlayer(canceled, fixture.owner), fixture.territory), 0);
assert.deepEqual(unchangedFrame(canceled), frame);
assert.equal(canceled.nexusCards!.cards!.hands[fixture.owner], null);
assert.equal(canceled.nexusCards!.cards!.discard.filter(face => face === 'beneGesserit').length, 1);
assert.ok(!nexusAdvisorFreshPlayer(canceled, fixture.canceler).hand.some(card => card.id === fixture.card));
assert.equal(canceled.discard.filter(card => card.id === fixture.card).length, 1);
assert.throws(() => applyAction(canceled, fixture.owner, fixture.declaration));
assert.throws(() => applyAction(canceled, fixture.owner, fixture.ordinaryAction), /advisors cannot flip|New advisors cannot become fighters/i);
const continued = settleNexusAdvisorFresh(applyAction(canceled, fixture.owner, { type: 'endMovement' }));
assert.notEqual(continued.active, fixture.owner);
assert.ok(!continued.movementRemaining!.includes(fixture.owner));
assert.deepEqual(nexusAdvisorFreshPlayer(continued, fixture.owner).advisors, stances);
});
void test('ordinary fighter arrival remains illegal for the fresh group but its same native action works after Cunning', () => {
const fixture = createNexusAdvisorFreshFixture();
const { game, owner, location, ordinaryAction } = fixture;
assert.equal(distance(location, fixture.scenario.ordinaryTarget, key => splitLocation(key).sector === game.storm), 1);
assert.equal(nexusAdvisorFreshPlayer(game, 'atreides').forces[fixture.scenario.ordinaryTarget], 1);
assert.equal(nexusAdvisorFreshPlayer(game, owner).forces[fixture.scenario.ordinaryTarget], undefined);
assert.throws(() => applyAction(game, owner, ordinaryAction), /advisors cannot flip|New advisors cannot become fighters/i);
const converted = settleNexusAdvisorFresh(applyAction(game, owner, fixture.declaration));
const moved = settleNexusAdvisorFresh(applyAction(converted, owner, ordinaryAction));
const player = nexusAdvisorFreshPlayer(moved, owner);
assert.equal(player.forces[location], 1);
assert.equal(player.forces[fixture.scenario.ordinaryTarget], 1);
assert.equal(player.advisors?.imperial_basin, undefined);
assert.equal(player.moved, nexusAdvisorFreshPlayer(game, owner).moved + 1);
assert.equal(player.spice, nexusAdvisorFreshPlayer(game, owner).spice);
});
void test('fresh native Cunning rejects other actors, stale events, extra keys and duplicate whole territories atomically', () => {
const fixture = createNexusAdvisorFreshFixture();
const before = structuredClone(fixture.game);
const rejected = [
{ actor: fixture.foreign, action: fixture.declaration },
{ actor: fixture.owner, action: { ...fixture.declaration, event: `${fixture.event}:stale` } },
{ actor: fixture.owner, action: { ...fixture.declaration, amount: 1 } },
{ actor: fixture.owner, action: { ...fixture.declaration, territories: [fixture.territory, fixture.territory] } },
];
for (const step of rejected) {
assert.throws(() => applyAction(fixture.game, step.actor, step.action));
assert.deepEqual(fixture.game, before);
}
const view = viewGame(fixture.game, fixture.owner);
assert.equal(nexusAdvisorAction(view, `${fixture.event}:stale`, [fixture.territory]), null);
assert.equal(nexusAdvisorAction(view, fixture.event, [fixture.territory, fixture.territory]), null);
});
void test('all four unchanged policies Use the favorable actual fresh group and complete the same native response and turn', () => {
const fixture = createNexusAdvisorFreshFixture();
for (const difficulty of DIFFICULTIES) {
const view = viewGame(fixture.game, fixture.owner);
view.players.find(player => player.id === fixture.owner)!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action, `${difficulty} must produce a legal active-turn action`);
const used = applyAction(fixture.game, fixture.owner, action);
assert.equal(used.response!.kind, 'nexusAdvisorFlip', difficulty);
const allowed = settleNexusAdvisorFresh(used);
assert.equal(fighterCount(nexusAdvisorFreshPlayer(allowed, fixture.owner), fixture.territory), 2, difficulty);
assert.equal(nexusAdvisorFreshPlayer(allowed, fixture.owner).advisors?.[fixture.territory], undefined, difficulty);
assert.deepEqual(unchangedFrame(allowed), unchangedFrame(fixture.game), difficulty);
const continued = settleNexusAdvisorFresh(applyAction(allowed, fixture.owner, { type: 'endMovement' }));
assert.notEqual(continued.active, fixture.owner, difficulty);
}
});
+169
View File
@@ -0,0 +1,169 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { fighterCount } from '../game/advisors';
import { gameDistance, splitLocation, territory } from '../game/board';
import { quoteBattleBoard } from '../game/board-resolution-quote';
import { quoteAdvisorBattleOffer } from '../game/movement-phase-quote';
import { nexusAdvisorAction } from '../game/nexus-advisor-options';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import {
createNexusAdvisorStormFixture, nexusAdvisorStormPlayer,
type NexusAdvisorStormFixture,
} from './fixture-nexus-advisor-storm';
function declaration(f: NexusAdvisorStormFixture, game = f.game): Action {
const view = viewGame(game, f.owner);
const offer = view.nexusAdvisors!.offer!;
const action = nexusAdvisorAction(view, offer.event, [f.territory]);
assert.ok(action, 'The real storm-held advisor territory is a legal whole-territory Cunning selection');
return action;
}
function resources(game: Game) {
return game.players.map(player => ({ id: player.id, spice: player.spice,
reserves: player.reserves, tanks: player.tanks, forces: player.forces,
elites: player.elites, shipped: player.shipped, moved: player.moved,
revived: player.revived, ally: player.ally }));
}
function settle(game: Game): Game {
for (let count = 0; count < 80; count++) {
if (!game.response && !game.decision && !game.phaseOpening) return game;
const step = nextStrongholdFactionsNativeStep(game);
assert.ok(step, 'Only original responses and decisions may settle the declaration');
game = applyAction(game, step.actor, step.action);
}
throw Error('Original storm advisor response did not settle');
}
function rejectsImmutably(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function assertStormGeometry(f: NexusAdvisorStormFixture, game: Game): void {
const from = `${f.territory}:${f.separated ? 9 : 10}`;
const to = `${f.territory}:${f.separated ? 5 : 10}`;
assert.equal(gameDistance(game, from, to), 0);
assert.ok(gameDistance(game, from, to, key => splitLocation(key).sector === game.storm) > 0);
assert.equal(quoteBattleBoard(game).battles.some(battle => battle.territory === f.territory &&
[battle.attacker, battle.defender].includes(f.owner)), false);
}
function ordinaryStormMove(f: NexusAdvisorStormFixture): Action {
return { type: 'move', forces: { [`${f.territory}:${f.separated ? 9 : 10}`]: 1 },
eliteForces: { [`${f.territory}:${f.separated ? 9 : 10}`]: 0 },
territory: f.separated ? f.territory : 'polar_sink', sector: f.separated ? 5 : 0 };
}
for (const separated of [false, true]) {
const label = separated ? 'real rock groups separated by the storm' : 'real shielded Arrakeen groups under the storm';
void test(`Cunning changes only stance for ${label}, without granting movement or battle`, () => {
const f = createNexusAdvisorStormFixture({ separated });
const original = structuredClone(f.game);
assert.equal(original.advanced, true);
assert.deepEqual(nexusAdvisorStormPlayer(f.afterStorm, f.owner).forces,
nexusAdvisorStormPlayer(f.beforeStorm, f.owner).forces);
assert.deepEqual(nexusAdvisorStormPlayer(f.afterStorm, f.opponent).forces,
nexusAdvisorStormPlayer(f.beforeStorm, f.opponent).forces);
assert.equal(territory(f.territory).type, separated ? 'rock' : 'stronghold');
assert.deepEqual(nexusAdvisorStormPlayer(original, f.owner).advisors![f.territory], { lockedTurn: 1 });
assert.equal(nexusAdvisorStormPlayer(original, f.owner).forces[`${f.territory}:${separated ? 9 : 10}`], separated ? 1 : 2);
assert.equal(fighterCount(nexusAdvisorStormPlayer(original, f.opponent), f.territory), 1);
assert.equal(quoteAdvisorBattleOffer(original).territories.includes(f.territory), false);
assertStormGeometry(f, original);
rejectsImmutably(original, f.owner, ordinaryStormMove(f));
rejectsImmutably(original, f.owner, { ...ordinaryStormMove(f), territory: f.territory,
sector: separated ? 5 : 10, fighters: true });
rejectsImmutably(original, f.owner, { type: 'ship', territory: f.territory,
sector: original.storm, amount: 1, elite: 0, allyPayment: 0 });
const action = declaration(f);
rejectsImmutably(original, f.opponent, action);
rejectsImmutably(original, f.owner, { ...action, event: `${String(action.event)}-stale` });
let game = applyAction(original, f.owner, action);
assert.equal(game.response?.kind, 'nexusAdvisorFlip');
const receipt = structuredClone(game.nexusAdvisorHistory!.at(-1)!.receipt);
assert.equal(receipt.owner, f.owner);
assert.equal(receipt.turn, original.turn);
assert.deepEqual(receipt.selections, [{ territory: f.territory, count: 2,
locations: separated
? [{ location: `${f.territory}:5`, count: 1 }, { location: `${f.territory}:9`, count: 1 }]
: [{ location: `${f.territory}:10`, count: 2 }],
noField: null, stance: { lockedTurn: 1 } }]);
assert.deepEqual(resources(game), resources(original));
assert.deepEqual(nexusAdvisorStormPlayer(game, f.owner).advisors,
nexusAdvisorStormPlayer(original, f.owner).advisors);
assert.equal(game.nexusCards!.cards!.hands[f.owner], null);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'beneGesserit').length, 1);
rejectsImmutably(game, f.owner, action);
game = settle(game);
assert.equal(game.nexusAdvisorHistory!.at(-1)!.stage, 'completed');
assert.equal(game.nexusAdvisorHistory!.length, 1);
assert.deepEqual(game.nexusAdvisorHistory!.at(-1)!.receipt, receipt);
const expected = structuredClone(nexusAdvisorStormPlayer(original, f.owner).advisors!);
delete expected[f.territory];
assert.deepEqual(nexusAdvisorStormPlayer(game, f.owner).advisors, expected);
assert.deepEqual(resources(game), resources(original));
assert.deepEqual(game.players.map(player => player.hand), original.players.map(player => player.hand));
assert.equal(fighterCount(nexusAdvisorStormPlayer(game, f.owner), f.territory), 2);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'beneGesserit').length, 1);
assert.equal(game.turn, original.turn);
assert.equal(game.phase, 5);
assert.equal(game.active, f.owner);
assert.equal(game.response, null);
assertStormGeometry(f, game);
rejectsImmutably(game, f.owner, ordinaryStormMove(f));
rejectsImmutably(game, f.owner, action);
const sinkBefore = nexusAdvisorStormPlayer(game, f.owner).forces['polar_sink:0'] ?? 0;
game = applyAction(game, f.owner, { type: 'ship', territory: 'polar_sink', sector: 0,
amount: 1, elite: 0, allyPayment: 0 });
game = settle(game);
assert.equal(nexusAdvisorStormPlayer(game, f.owner).shipped, true);
assert.equal(nexusAdvisorStormPlayer(game, f.owner).moved, 0);
assert.equal(nexusAdvisorStormPlayer(game, f.owner).forces['polar_sink:0'], sinkBefore + 1);
game = applyAction(game, f.owner, { type: 'endMovement' });
assert.notEqual(game.active, f.owner);
});
void test(`the real auction Karama cancels the original batch for ${label}`, () => {
const f = createNexusAdvisorStormFixture({ separated });
const original = structuredClone(f.game), action = declaration(f);
let game = applyAction(original, f.owner, action);
game = applyAction(game, f.canceler, { type: 'card', card: f.cancelerCard, mode: 'cancel' });
game = settle(game);
assert.equal(game.nexusAdvisorHistory!.at(-1)!.stage, 'canceled');
assert.equal(game.nexusAdvisorHistory!.length, 1);
assert.deepEqual(resources(game), resources(original));
assert.deepEqual(game.players.map(player => player.advisors), original.players.map(player => player.advisors));
assert.equal(game.discard.filter(card => card.id === f.cancelerCard).length, 1);
assert.equal(nexusAdvisorStormPlayer(game, f.canceler).hand.some(card => card.id === f.cancelerCard), false);
assert.equal(game.nexusCards!.cards!.hands[f.owner], null);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'beneGesserit').length, 1);
assert.equal(game.active, f.owner);
assert.equal(game.phase, 5);
assert.equal(fighterCount(nexusAdvisorStormPlayer(game, f.owner), f.territory), 0);
assert.equal(quoteAdvisorBattleOffer(game).territories.includes(f.territory), false);
assertStormGeometry(f, game);
rejectsImmutably(game, f.owner, ordinaryStormMove(f));
rejectsImmutably(game, f.owner, action);
game = applyAction(game, f.owner, { type: 'endMovement' });
assert.notEqual(game.active, f.owner);
});
void test(`all four legal bot policies can use Cunning for ${label}`, () => {
const f = createNexusAdvisorStormFixture({ separated });
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(f.game, f.owner);
view.players.find(player => player.id === f.owner)!.bot = difficulty;
const actions = botActions(view);
const action = actions.find(candidate => candidate.type === 'nexusAdvisors');
assert.ok(action, `${difficulty} selects the genuine two-advisor versus one-fighter offer`);
let game = applyAction(f.game, f.owner, action);
assert.equal(game.response?.kind, 'nexusAdvisorFlip');
game = settle(game);
assert.equal(fighterCount(nexusAdvisorStormPlayer(game, f.owner), f.territory), 2);
assert.deepEqual(resources(game), resources(f.game));
assertStormGeometry(f, game);
}
});
}
-45
View File
@@ -78,48 +78,6 @@ void test('Basic and automatically released lone advisors yield no choice; Homew
);
assert.throws(() => quoteNexusAdvisors(c, 'g'));
});
void test('fresh advisor lock and storm interference expose explicit unresolved reasons while clear sectors remain usable', () => {
const fresh = context();
fresh.players[0].advisors!.hagga_basin.lockedTurn = 3;
assert.match(
quoteNexusAdvisors(fresh, 'b').territories.find(
(t) => t.territory === 'hagga_basin',
)!.blocked!,
/same-turn.*unresolved/,
);
assert.throws(
() => createNexusAdvisors(fresh, 'b', 'x', ['arrakeen', 'hagga_basin']),
/unresolved/,
);
const storm = context();
storm.storm = 10;
assert.match(
quoteNexusAdvisors(storm, 'b').territories.find(
(t) => t.territory === 'arrakeen',
)!.blocked!,
/storm.*ruling/,
);
assert.equal(
quoteNexusAdvisors(storm, 'b').territories.find(
(t) => t.territory === 'hagga_basin',
)!.blocked,
null,
);
});
void test('a storm separating hostile sectors blocks only the affected conversion rather than requiring battle contact everywhere', () => {
const c = context();
c.players[0].forces = { 'pasty_mesa:5': 2 };
c.players[0].advisors = { pasty_mesa: {} };
c.players[1].forces = { 'pasty_mesa:8': 1 };
c.storm = 7;
assert.match(quoteNexusAdvisors(c, 'b').territories[0].blocked!, /storm/);
c.storm = 18;
assert.equal(quoteNexusAdvisors(c, 'b').territories[0].blocked, null);
c.players[0].forces = { 'polar_sink:0': 2 };
c.players[0].advisors = { polar_sink: {} };
c.players[1].forces = { 'polar_sink:0': 1 };
assert.equal(quoteNexusAdvisors(c, 'b').territories[0].blocked, null);
});
void test('ordinary allied occupation and a third stronghold faction block conversion using public No-Field presence', () => {
const allied = context();
allied.players[0].ally = 'g';
@@ -231,9 +189,6 @@ void test('empty, duplicate, foreign and malformed selections reject immutably a
(g: NexusAdvisorContext) => {
g.players[2].forces = { 'arrakeen:10': 1 };
},
(g: NexusAdvisorContext) => {
g.storm = 10;
},
]) {
const altered = reload(c);
change(altered);
-18
View File
@@ -172,24 +172,6 @@ void test('Cunning belongs to the current native Advanced shipment and movement
);
});
void test('fresh advisor locks and storm-locked strongholds remain guarded without partially converting an allowed group', () => {
for (const scenario of ['lock', 'storm']) {
const g = nexusAdvisorFixture();
if (scenario === 'lock')
nexusAdvisorPlayer(g).advisors!.arrakeen.lockedTurn = g.turn;
else g.storm = 10;
const offer = viewGame(g, 'p').nexusAdvisors!.offer!;
assert.ok(
offer.territories.find((t) => t.territory === 'arrakeen')!.blocked,
);
assert.equal(
offer.territories.find((t) => t.territory === 'pasty_mesa')!.blocked,
null,
);
nexusReject(g, 'p', action(g));
assert.ok(nexusAdvisorPlayer(g).advisors!.pasty_mesa);
}
});
void test('a third occupying faction blocks stronghold conversion while leaving other advisor groups available', () => {
const g = nexusAdvisorFixture();