Compose native Ecaz Betrayal and Moritani Atomics

Retain original selected modules, typed native returns, advisors and hidden No-Field recall. Preserve a full unspent Guild/Sapho opening, paid ordered arrivals, capped provisional No-Field destruction and native World Sneak withdrawal. Bind desert Aftermath to its original Moritani Cunning placement.

Validation: frozen 16-file115/115, types/lint/build, eight finite original-action and GameTable SSR programmes, four minimal legal policies. Document provisional overlaps and corrected genuine recipes. No stored-game change or deployment; full combinations, comprehensive assurance and deployed acceptance remain required.
This commit is contained in:
Quintine committed 2026-10-18 11:21:28 +11:00
1 parent 8793e23d6b
commit bb9d292c4c
23 files changed
+1824 -88

No files matched your search

+3 -1
View File
@@ -601,7 +601,9 @@ export function MoritaniEntry({
does too, if you have one. Each hand over its new limit
discards one random card per excess card. Under the provisional
persistent-target interpretation, later alliance changes do not
move this penalty to a new ally.
move this penalty to a new ally. A caught concealed No-Field
provisionally reveals and places only its available reserve
forces before those forces go to the Tanks.
</p>
)}
{entry.kind === 'sneakAttack' && sneak && (
+3 -2
View File
@@ -21,7 +21,7 @@ export function NexusEcazBetrayal({ game, act, busy }: Props) {
<section aria-label="Ecaz Nexus Betrayal" className="space-y-3 rounded-lg border border-[#a88b60]/50 p-3">
<h3>Ecaz Nexus · Betrayal</h3>
<p>{nexusCardReference('ecaz').betrayal}</p>
{ally && <p className="fine">Ecaz’s ally: {ally.name}. Their entire physical group in the selected territory returns to reserves after the Karama response.</p>}
{ally && <p className="fine">Ecaz’s ally: {ally.name}. Their entire group in the selected territory returns to reserves if the Karama response allows Betrayal.</p>}
{offer.territories.length > 1 && <label className="block space-y-1">
<span>Shared territory</span>
<select className="min-h-11 w-full" value={choice?.territory ?? ''} disabled={busy || !!offer.blocked}
@@ -29,7 +29,8 @@ export function NexusEcazBetrayal({ game, act, busy }: Props) {
{offer.territories.map(candidate => <option key={candidate.territory} value={candidate.territory}>{boardTerritory(candidate.territory).name}</option>)}
</select>
</label>}
{choice && <p>Selected: {boardTerritory(choice.territory).name} · {choice.total.normal} normal · {choice.total.elite} elite forces</p>}
{choice && <p>Selected: {boardTerritory(choice.territory).name} · {choice.total.normal} visible normal · {choice.total.elite} visible elite forces{choice.concealed ? ' · one concealed No-Field' : ''}</p>}
{choice?.concealed && <p className="fine">The No-Field returns face-down without revealing its value. Cancellation leaves the marker and all forces in place.</p>}
{offer.blocked && <p className="notice">{offer.blocked}</p>}
<Button className="game-action min-h-11 whitespace-normal" disabled={busy || !action}
onClick={() => { if (!busy && action) act(action); }}>
+28 -2
View File
@@ -16,6 +16,32 @@ open mode gates.
## Current checkpoint and work
**Native Ecaz Betrayal / Moritani Atomics — 18 October. Prototyped / Partial.**
Selected modules now retain typed native returns, advisors, actual shared HMS,
face-down concealed recall and original paid Karama cancellation. Guild first/
wait and Sapho ordering preserve a full unspent Shipment/Movement opening.
Native Atomics retains capped No-Field materialization, original paid arrival,
ordered Ambassador/Terror continuation and fixed hand targets. Original Nexus
Cunning grants now support printed-desert Atomics and historical Aftermath;
ordinary placement, shipment bans and the Sneak Attack exception stay distinct.
Both No-Field overlaps remain explicitly provisional.
Three disjoint lanes and owner integration are frozen. **16 files115/115**,
types/lint/build and eight finite original-action/table-SSR programmes pass:
typed/marker/advisor/HMS Betrayal201/222/200/213 source actions plus two choices
per allowance/cancel; World Atomics/Sneak375, paired arrival176, Basic/Advanced
desert134/261. Four policies retain legal typed/concealed Betrayal and native
World Sneak choices; no calibration. SSR framework aliases/CSS stubs mean no
visual/browser or deployed acceptance. Corrected fixtures preserve native
placement chronology, optional allied No-Field revelation and Basic range1.
The external smoke initially selected an already completed Sneak result; its
corrected pending-choice run passed against unchanged application source.
No saved room/database, reset, restart or deployment. Remaining Nexus/Discovery/
Semuta envelopes and other rule interactions still require implementation;
comprehensive assurance and complete modes stay open.
[Source, evidence and limits](NEXUS_CARD_RULES.md#native-betrayal-and-atomics-composition--18-october-2026).
**Selected E3 independent Nexus — 18 October. Prototyped / Partial.**
Fresh selected E3, optionally Ix/CHOAM/both, now composes independent cards,
Nexus and original World/phase/all14 modules. Native Occupy, Advanced
@@ -36,8 +62,8 @@ Fixture corrections retain the real at-least-one-alliance draw gate and decline
unwanted paid Ambassador placement before the Duke invasion. Initial lint caught
a reserved loop binding. No printed numeric rule changed. Bounded shared-profile
review found no material defect. No saved room/database, reset, restart or
deployment. **Next required rules:** Ecaz Betrayal and Moritani Atomics envelopes,
then remaining optional combinations; comprehensive assurance stays deferred.
deployment. The later native Betrayal/Atomics checkpoint above supersedes these
original effect exclusions; remaining combinations and assurance stay open.
[Contract, evidence and boundaries](NEXUS_CARD_RULES.md#selected-e3-independent-nexus--18-october-2026).
**Selected-deck independent Nexus — 18 October. Prototyped / Partial.**
+20
View File
@@ -1,5 +1,25 @@
# Dune implementation status
## 18 October 2026 — Native Betrayal and Atomics composition
Ecaz Betrayal now retains selected native modules, typed World returns,
advisors, actual shared HMS and provisional face-down No-Field recall. A genuine
Guild-first/wait or Sapho reorder preserves a full unspent opening. Native
Atomics retains original invoice/arrival order, provisional capped No-Field
destruction, fixed activation hand targets and actual World Sneak withdrawal.
Original Cunning grants support printed-desert Atomics/Aftermath without
changing ordinary placement or shipment/movement rules.
Three exclusive lanes plus owner integration pass frozen16-file115/115,
types/lint/build, eight finite original-action/table-SSR programmes and four
minimal policies. Recipe corrections preserve actual timing, optional allied
revelation and Basic range1; no printed numeric rule changed for a fixture.
One external smoke selected an already completed Sneak result, then passed
against unchanged application source after selecting its pending choice.
[Canonical source-bound proof and limitations](NEXUS_CARD_RULES.md#native-betrayal-and-atomics-composition--18-october-2026).
No stored room/reset/restart/deployment; wider effects, full combinations,
comprehensive assurance, AI calibration and deployed acceptance remain open.
## 18 October 2026 — Selected E3 independent Nexus
Selected E3, optionally Ix/CHOAM/both, now composes independent cards/Nexus12
+48
View File
@@ -2,6 +2,54 @@
Audit: 2026-09-06. Scope: the six Terror tokens, setup, placement, entry reactions and continuing effects. Initial audit made no runtime edits; see the implementation checkpoint below. This is an implementation contract with explicit evidence limits, not certification that the faction is playable.
## Native module Atomics — 18 October 2026
**Prototyped / Partial.** Original registered native
tables can reveal Atomics with their selected World/Nexus/Skills/phase/card
modules. Their setup and original arrival legality remain authoritative; no
Terror placement on a Homeworld, HMS or other unprinted target is added.
Existing Grumman occupation and low-population reveal restrictions still apply
to the entrant, including a directly submitted Atomics action. Another deferred
Extortion award does not remove the separately held Atomics token.
The separate original Moritani Nexus Cunning grant permits placement in any
printed Arrakis territory, including a desert. Atomics follows that actual
physical grant; a non-stronghold Aftermath retains its original placement event
and validates it against the original Cunning history after token removal.
This does not give ordinary Mentat placement a desert option or make Homeworlds,
HMS or Discovery territories printed Arrakis targets. The source and original
placement contract remain in [Moritani Nexus](NEXUS_MORITANI_RUNTIME.md).
The all-forces effect includes original normal/starred counters and advisors.
For a caught concealed No-Field, the
[explicit provisional reveal-first policy](RULE_DECISIONS.md#provisional-no-field-return-and-atomics--18-october-2026)
uses a detached forecast of the original native capped reveal, then performs
that reveal and actual physical loss once on acceptance. An unaffordable
denomination creates no extra counters. Forecasts disclose no private value.
This follows the supplied revision physical35/36 as a selected destructive-
effect composition; it is not an explicit printed Atomics/No-Field FAQ.
Original paid Guild invoices and Ambassador/Terror arrival order retain their
own continuation. A random hand-overflow discard can suspend that same ordered
arrival, rather than losing or replaying it. Aftermath keeps the original
activation targets, lasting shipment/cross-shipment ban, movement distinction
and printed Sneak Attack exception. No new battle, victory reward, revival or
shipment income is inferred from these casualties.
Original-action evidence is in `tests/fixture-moritani-atomics-modules.ts` and
`tests/moritani-atomics-modules.test.ts`, alongside the original pure/native
Atomics cases. Genuine desert placement and later movement/shipment evidence is
in `tests/fixture-nexus-moritani-atomics.ts` and
`tests/nexus-moritani-atomics-runtime.test.ts`. Full rules, combinations, comprehensive custody/privacy/recovery
assurance and deployed acceptance remain open.
The owner-frozen related16-file115/115 batch, types/lint/build and finite original-
action/table-SSR smoke pass. Original paid marker entry, ordered paired arrival,
random overflow, World Sneak withdrawal and Basic/Advanced Cunning desert
Aftermath use actual controls. Four policies have legal World Sneak choices.
The [shared checkpoint](NEXUS_CARD_RULES.md#native-betrayal-and-atomics-composition--18-october-2026)
records counts, the external-smoke correction and visual/deployment limits.
## Evidence and revision boundary
- **E3:** [GF9 Ecaz & Moritani rulebook](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf), printed pp. 3–6, 14, 16. Publisher-indexed text verified setup/placement/Assassination/Atomics on p. 5 and the official built-in FAQ/Karama answers on pp. 14 and 16. Direct download currently returns 403.
+59 -2
View File
@@ -2,6 +2,63 @@
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.
## Native Betrayal and Atomics composition — 18 October 2026
**Prototyped / Partial.** Ecaz Betrayal now uses
the original native and selected-independent Nexus profiles with their selected
World/training/phase modules. Actual typed groups return to native reserves;
advisors lose their returned stance; original parent-to-HMS movement supplies a
real shared target. A full unspent Shipment/Movement opening survives actual
Guild first/wait and Sapho ordering. The native counter still needs its own
original paid Karama; no second shipment or movement is created.
For a concealed No-Field, the explicit provisional return policy recalls the
marker face down without materializing counters or revealing its denomination.
The distinct provisional destructive policy materializes capped native reserves
before Moritani Atomics sends all territory forces to Tanks. Original invoice,
Ambassador/Terror order, random overflow and fixed activation hand targets
remain. Original World Sneak Attack withdraws actual native reserves and retains
the printed Aftermath exception. See [Ecaz](NEXUS_ECAZ_RULES.md#native-modules-and-face-down-recall--18-october-2026),
[Atomics](MORITANI_TERROR_RULES.md#native-module-atomics--18-october-2026) and
[provisional authority](RULE_DECISIONS.md#provisional-no-field-return-and-atomics--18-october-2026).
A real Moritani Cunning placement can put Atomics into a printed desert. Its
actual historical grant remains the source for later Aftermath; ordinary
placement stays restricted to strongholds. Homeworld, HMS and Discovery targets
are not added by this exception. These are connected effects, not certification
of all Nexus effects, complete factions or all rules-permitted combinations.
Comprehensive assurance, full AI and deployed acceptance remain open.
Original-action evidence: `tests/ecaz-betrayal-modules.test.ts`,
`tests/moritani-atomics-modules.test.ts`,
`tests/nexus-moritani-atomics-runtime.test.ts` and their genuine setup fixtures.
Focused quote/physical cases include `tests/nexus-ecaz-betrayal-quotes.test.ts`,
`tests/richese-no-field-hidden-return.test.ts` and `tests/moritani-atomics.test.ts`.
Owner frozen **16 files115/115**, types/lint/build pass. Eight finite original-
action programmes pass: typed/marker/advisor/HMS Betrayal201/222/200/213 original
actions plus two response actions per allowance/cancel; World Atomics/
Sneak375; paired arrival176; Basic/Advanced desert134/261. Actual table SSR
retains return, concealed-marker, revelation, Aftermath and original arrival
controls. Framework aliases and CSS stubs mean no visual/browser acceptance.
Four minimal policies execute actual typed/concealed Betrayal and World Sneak
choices; this is legal participation, not difficulty calibration.
Iteration corrected real Guild-first opening and World reserve withdrawal
defects. Fixtures now decline an unwanted allied No-Field offer, place Ecaz at
its actual native phase-four opportunity, accompany BG explicitly, read the
completed Cunning history, and fund a genuine adjacent Basic movement source.
No printed numeric rule changed for a fixture. A bounded original-grant review
found no remaining concrete desert guard/continuation defect.
Private proof `20261018-native-betrayal-atomics/combined-proof.json` binds the
passed rule/type/build steps and the corrected runtime step to the same source
tree. The first external smoke incorrectly selected a completed Sneak result;
only that external programme changed before the passing repeat. Diagnostics
remain outside Git. Later checkpoint prose is a documented overlay; no saved
room/database, reset, server restart or deployment was involved. Full rules,
combinations, comprehensive assurance and deployed acceptance remain required.
## Selected E3 independent Nexus — 18 October 2026
**Prototyped / Partial.** Fresh selected Ecaz/Moritani
@@ -41,8 +98,8 @@ Evidence paths: `tests/selected-independent-nexus-entry.test.ts`,
`tests/e3-independent-nexus-cards.test.ts`,
`tests/e3-independent-nexus-duke.test.ts` and
`tests/e3-independent-nexus-arrival.test.ts`, with their original-action fixtures.
Ecaz Betrayal and Moritani Atomics remain distinct required effects with
substantive unimplemented envelopes; this delivery does not certify all Nexus
The later native composition above supersedes the original Betrayal/Atomics
exclusions. This earlier delivery does not certify all Nexus
effects, complete factions, combinations or final release. Comprehensive
assurance, full AI and deployed acceptance remain open.
+48 -4
View File
@@ -48,6 +48,51 @@ Implement the common, source-clear contract as a pure boolean intersection of **
Meaningful regressions should cover positive and negative answers with equal public hand sizes; dead/revived native leaders; native captive versus foreign captive/Ghola cases for the explicitly selected scope; Duke and Hero identities; Tleilaxu role separation; Harkonnen exchange before/after inquiry; stale/replayed/concurrent play; JSON/SQL historical persistence; and exact audience projection without counts or matching identities. Until scope and audience are selected, those unresolved branches must not be presented as fully source-verified behavior.
## Native modules and face-down recall — 18 October 2026
**Prototyped / Partial.** The original E3 Nexus
and Skills profiles and selected-independent Nexus core share the same Betrayal
effect. Original World, phase and training selections no longer block unrelated
Arrakis force returns. Their own setup/capture gates remain. Printed locations,
the actual placed HMS and revealed Discovery territories use room-local
legality. Homeworlds remain separate off-planet custody, not territory targets;
see original E3p10 and [Homeworld transport rules](HOMEWORLD_RULES.md).
The ally returns every visible normal/starred counter in every sector of the
chosen territory through the original native-reserve deposit. This preserves
typed Emperor Worlds; returned BG advisors lose their old territorial stance.
The coalition's other territories, Ecaz forces, original movement queue and
shipment/movement allowances remain. No shipment, revival or token income is
invented by the return.
Under the [explicit provisional No-Field policy](RULE_DECISIONS.md#provisional-no-field-return-and-atomics--18-october-2026),
an allied concealed marker counts as a possible shared group even if no ordinary
counter is visible. The public offer contains a concealed-marker flag and only
visible force counts. It never reads its denomination or reserve cap for target
availability. The private pre-return snapshot additionally binds the supplier,
controller-derived ally, original token/event and location. On allowance, the
marker returns face down, without materializing physical forces or changing
reserves/Tanks. The original last-shipment ban stays, and public last-used
projection does not reveal the recalled marker. Cancellation keeps all custody
unchanged. Old marker-free snapshot grammar remains.
The original quiet first-Shipment window and provisional Karama response remain.
An actual Guild first/wait choice or Sapho queue change does not close this
window while every player's shipment/movement is still unused and the full
roster remains in the queue. A pending timing choice must finish first;
this is not a newly supplied timing FAQ. Unsupported profiles, including the
existing trained E3/Harkonnen capture combination, remain separate. Original-
action fixtures and focused cases are `tests/fixture-ecaz-betrayal-modules.ts`,
`tests/ecaz-betrayal-modules.test.ts`, `tests/nexus-ecaz-betrayal-quotes.test.ts`
and `tests/richese-no-field-hidden-return.test.ts`. Comprehensive assurance and
complete-mode/deployed acceptance remain open.
The owner-frozen related16-file115/115 batch, types/lint/build and actual table
SSR pass. Original typed, marker, advisor and HMS programmes retain all selected
modules, original paid Karama and the unspent queue. Four minimal policies have
legal typed/concealed return choices. See the shared [source-bound checkpoint](NEXUS_CARD_RULES.md#native-betrayal-and-atomics-composition--18-october-2026)
for counts, fixture corrections and the explicit visual/deployment limits.
## Bounded Betrayal runtime
The printed Betrayal is a **separate panel** from the Secret Ally inquiry above.
@@ -63,7 +108,7 @@ board forces unchanged and the Nexus card spent. This response classification
is an explicit **provisional composition**, not a GF9 ruling granting Karama
blanket jurisdiction over Nexus effects.
The first implementation window is deliberately narrow: phase 5 while its
The initial implementation window was deliberately narrow: phase 5 while its
first active player is still current, the original movement queue is intact,
no pending decision/response and no shipment or movement has been used. It does
not assert that this is the only possible reading of “before Shipment and
@@ -71,9 +116,8 @@ Movement.” The physical Ecaz alliance and co-occupation must be current.
An automated eligible holder is scheduled before an earlier-seated movement
bot closes this opening; a human holder must declare before that first turn
advances. The game does not pause all seats to reveal a hidden card.
Only the classic factions plus Ecaz/Moritani and the Nexus module are
admitted; advisors, hidden No-Fields, Homeworlds, Discoveries, Leader Skills,
Tech, Stronghold Cards and other combined modules remain gated. A saved
That initial classic-plus-E3 envelope is superseded by the native-module
extension above; it is retained here as historical scope. A saved
pending snapshot binds the card's discard position, ally and exact typed board
group through the Karama window, even if another Nexus card is discarded
during that response. The ally's reserve counters are validated before any
+5 -1
View File
@@ -18,7 +18,11 @@ All four profiles consume the same private offer. The shared policy uses public
The engine binds the original placement, physical face and native opportunity in a saved receipt. A separate current-placement map ends when the token relocates, reveals or returns to supply; historical evidence remains available to the already committed entry and discard children. This preserves legitimate continuation across later turns and population changes without letting an old receipt authorize a new relocation. Consumers neither reconstruct that history nor broaden ordinary Grumman addition rules. The [Grumman stacking source](GRUMMAN_STACKED_TERROR_RULES.md) and its separate removal boundary remain unchanged.
Atomics and Extortion reactions, restricted Assassination leader pools, and existing Sneak Attack arrival combinations retain their explicit gates. No complete Moritani, Nexus or expansion-release claim follows from this checkpoint.
The [native-module Atomics extension](MORITANI_TERROR_RULES.md#native-module-atomics--18-october-2026)
now follows the actual Cunning grant into a printed desert, preserving its
historical placement source in Aftermath after removal. This supersedes this
checkpoint's original Atomics gate. Other reactions retain their own current
contracts. No complete Moritani, Nexus or expansion-release claim follows.
## Independent recovery review
+27
View File
@@ -2,6 +2,33 @@
Updated 18 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority.
## Provisional No-Field return and Atomics — 18 October 2026
The supplied Advanced revision physical35/printed33 sends all territory forces
to Tanks for Moritani Atomics. Physical36/printed34 treats a concealed No-Field
as one force until revelation and lists Storm, worm, Lasgun–Shield explosion
and Battle Plan revelation; it does not explicitly name either Terror Atomics
or Ecaz Betrayal. The original E3 Homeworld rule p10 says Homeworlds are not
territories; enabling that module adds no Homeworld target to these effects.
Under the 14 October choose-recommended instruction, provisionally compose:
- **Ecaz Betrayal:** return the marker face down without materializing forces.
Return actual visible normal/starred counters to their native reserves.
Preserve the last-shipment repeat restriction; recalling the marker does not
reveal its denomination through the public last-used display. Cancellation
leaves the marker and visible group unchanged.
- **Moritani Atomics:** treat this destructive explosion as a reveal source;
materialize only the original available capped reserves, then send those
physical forces and all other territory forces to Tanks. Before acceptance,
the forecast changes no live marker and exposes no denomination. Original
paid entry, random overflow disposal and both arrival owners continue once.
These are explicit provisional compositions, not retrieved publisher FAQ
answers or new user numerical rulings. Original Karama classification and
persistent activation-target interpretations remain. [Ecaz contract](NEXUS_ECAZ_RULES.md#native-modules-and-face-down-recall--18-october-2026),
[Atomics contract](MORITANI_TERROR_RULES.md#native-module-atomics--18-october-2026).
## Provisional own-World temporary-role reserve fee — 18 October 2026
Under the 14 October choose-recommended direction, an own-World Reinforcements
+58 -19
View File
@@ -419,6 +419,7 @@ import {
validateRicheseNoFieldPair,
moveRicheseNoField,
revealRicheseNoField,
recallRicheseNoField,
shipAlliedRicheseNoField,
projectRicheseNoField,
type RicheseNoField,
@@ -3248,13 +3249,20 @@ function finishEcazBetrayalResponse(g: Game, response: ResponseWindow, canceled:
delete ally.forces[key];
if (ally.elites) delete ally.elites.forces[key];
}
ally.reserves += record.snapshot.total.normal + record.snapshot.total.elite;
if (ally.elites) ally.elites.reserves += record.snapshot.total.elite;
if (ally.advisors) delete ally.advisors[record.snapshot.territory];
if (record.snapshot.noField) {
const source = record.snapshot.noField;
const supplier = getPlayer(g, source.supplier);
supplier.noField = noFieldRule(() => recallRicheseNoField(supplier.noField!, source.tokenId));
supplier.noFieldEvent = crypto.randomUUID();
}
const amount = record.snapshot.total.normal + record.snapshot.total.elite;
if (amount) addNativeReserves(g, ally, amount, record.snapshot.total.elite);
observeOccupation(g);
}
log(g, canceled
? `Karama prevented the Ecaz Nexus return from ${territory(record.snapshot.territory).name}; the Nexus card remains spent.`
: `${ally.name} returned ${record.snapshot.total.normal + record.snapshot.total.elite} forces from ${territory(record.snapshot.territory).name} to reserves under Ecaz Nexus Betrayal.`,
: `${ally.name} returned ${record.snapshot.total.normal + record.snapshot.total.elite} physical forces${record.snapshot.noField ? ' and recalled the concealed No-Field face down' : ''} from ${territory(record.snapshot.territory).name} under Ecaz Nexus Betrayal.`,
{ faction: ally.faction, name: 'Ecaz Nexus Betrayal' });
}
function playEcazBetrayal(g: Game, p: Player, action: Action) {
@@ -4941,28 +4949,50 @@ function atomicsRule<T>(run: () => T): T {
throw error;
}
}
/** Only the clean printed-territory entry is connected; other arrival parents remain gated. */
/** Optional modules do not remove the native all-forces effect. Their setup and
* original arrival rules retain their own admission and continuation checks. */
function atomicsModeSupported(g: Game): boolean {
return !!byFaction(g, 'moritani') && !byFaction(g, 'ecaz') &&
if (!byFaction(g, 'moritani')) return false;
const original = !byFaction(g, 'ecaz') &&
!byFaction(g, 'guild') &&
g.players.every(p => CLASSIC_FACTIONS[p.faction] === true || p.faction === 'moritani') &&
g.expansions.every(expansion => expansion === 'ecaz') &&
!g.homeworlds && !g.nexusCards && !g.leaderSkills &&
!g.discoveryEnabled && !g.techTokens && !g.strongholdCards &&
!g.ecazTreachery && !g.pendingArrivalOverlap;
!g.ecazTreachery;
return original || (typeof g.advanced === 'boolean' &&
g.players.length >= (g.techTokens ? 3 : 2) && g.players.length <= 6 &&
new Set(g.players.map(player => player.faction)).size === g.players.length &&
g.expansions.includes('ecaz') && g.expansions.length <= 3 &&
g.expansions.every((family,index) => ['ecaz','ix','choam'].includes(family) && g.expansions.indexOf(family) === index) &&
(!g.strongholdCards || g.advanced) &&
g.players.every(player => FACTIONS.some(faction => faction.id === player.faction &&
(faction.expansion === 'base' || g.expansions.includes(faction.expansion)))));
}
function currentAtomicsQuote(
g: Game,
entry: NonNullable<Game['pendingTerrorEntry']>,
): AtomicsQuote {
requireRule(atomicsModeSupported(g) && !g.moritaniAtomics && !g.moritaniExtortion,
'Atomics with these combined modules or another unresolved Terror effect is not yet supported.');
requireRule(atomicsModeSupported(g) && !g.moritaniAtomics,
'Atomics requires its original native roster and unused Aftermath token.');
const blocked = homeworldTerrorEntryBlock(g, entry.entrant, entry.amount, 'reveal');
requireRule(!blocked, blocked ?? 'This Terror reveal is unavailable.');
const token = g.moritaniTerror?.tokens.find(row => row.id === entry.token);
requireRule(token, 'The Atomics token has lost its original stronghold.');
requireRule(terrorEntryLocationAllowed(g,entry), 'Atomics lost its original physical placement source.');
// Provisional explosion composition: quote the same capped physical reveal
// that allowance will perform. Forecasts never change or expose a live marker.
const forecast = g.players.some(player => player.noField?.deployed?.location.territory === entry.territory)
? structuredClone(g) : g;
if (forecast !== g) for (const player of forecast.players)
if (player.noField?.deployed?.location.territory === entry.territory)
revealPlayerNoField(forecast, player, 'atomics');
return atomicsRule(() => quoteMoritaniAtomics({
token, territory: entry.territory, moritaniId: byFaction(g, 'moritani')!.id,
turn: g.turn, aftermath: g.moritaniAtomics ?? null,
players: g.players.map(player => ({
...(!TERROR_STRONGHOLDS.includes(entry.territory)
? {nexusPlacement:entry.nexusPlacements?.[token.id]} : {}),
players: forecast.players.map(player => ({
...player, handSize: player.hand.length, baseHandLimit: handLimit(player),
})),
}));
@@ -4972,6 +5002,9 @@ function resolveAtomics(
entry: NonNullable<Game['pendingTerrorEntry']>,
quote: AtomicsQuote,
) {
for (const player of g.players)
if (player.noField?.deployed?.location.territory === entry.territory)
revealPlayerNoField(g, player, 'atomics');
g.moritaniAtomics = quote.aftermath;
for (const casualty of quote.casualties) {
const player = getPlayer(g, casualty.playerId);
@@ -6153,7 +6186,7 @@ function treacheryDiscardIntegrity(g: Game) {
const box = continuation?.kind === 'nullentropyDiscard';
const truth = continuation?.kind === 'truthtranceDiscard';
const ambassadorTerror =
continuation?.kind === 'terrorDiscard' &&
(continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics') &&
continuation.entry.cause === 'ambassador';
requireRule(
(g.status === 'playing' || (truth && g.status === 'setup')) &&
@@ -7035,16 +7068,20 @@ function treacheryDiscardIntegrity(g: Game) {
} else if (continuation?.kind === 'terrorAtomics') {
const { entry, aftermath, handBefore } = continuation;
atomicsIntegrity(g);
if (entry?.cause === 'ambassador') ambassadorRelocationIntegrity(g);
const owner = byFaction(g, 'moritani');
const token = g.moritaniTerror?.tokens.find(row => row.id === entry?.token);
requireRule(!g.pendingTerrorEntry && !g.pendingExchange && !g.summonedWorm &&
entry && entry.turn === g.turn && entry.phase === g.phase &&
entry.stage === 'offer' && entry.resume === (entry.cause === 'wormRide' ? 'wormRide' : 'none') &&
(entry.cause !== 'wormRide' || (g.phase === 1 &&
g.players.some(player => player.id === entry.entrant && player.faction === 'fremen'))) &&
['shipment', 'movement', 'advisor', 'wormRide'].includes(entry.cause) &&
entry.stage === 'offer' && ['none','wormRide','ambassador'].includes(entry.resume) &&
(entry.cause === 'ambassador' ? ambassadorTerrorEntryMatches(g,entry)
: entry.cause === 'wormRide' ?
(entry.resume === 'wormRide' || overlapOwnsWormTerror(g,entry)) && g.phase === 1 &&
g.players.some(player => player.id === entry.entrant && player.faction === 'fremen')
: entry.resume === 'none') &&
['shipment', 'movement', 'guildTransport', 'advisor', 'wormRide', 'ambassador'].includes(entry.cause) &&
entry.entrant !== owner?.id && entry.territory === aftermath.territory &&
validGameLocation(g, entry.territory, entry.sector) &&
terrorEntryLocationAllowed(g,entry) && validGameLocation(g, entry.territory, entry.sector) &&
token?.kind === 'atomics' && token.status === 'removed' &&
token.location === null &&
batch.cause === 'terror:atomics' && batch.entries.length > 0 &&
@@ -16445,7 +16482,7 @@ function arrivalOverlapIntegrity(g: Game) {
const ambassador = g.pendingAmbassador ??
(continuation?.kind === 'ambassador' ? continuation.entry : null);
const terror = g.pendingTerrorEntry ??
(continuation?.kind === 'terrorDiscard' ? continuation.entry : null);
(continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics' ? continuation.entry : null);
const active = record.stage === 'first' ? record.first :
record.first === 'ambassador' ? 'terror' : 'ambassador';
requireRule(g.status === 'playing' && arrivalOverlapModeSupported(g) &&
@@ -16713,7 +16750,7 @@ function ambassadorRelocationIntegrity(g: Game) {
);
const terror =
g.pendingTerrorEntry ??
(continuation?.kind === 'terrorDiscard' ? continuation.entry : null);
(continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics' ? continuation.entry : null);
const relatedResponses = responses.filter(
(response) =>
response.advisorResume === 'ambassador' ||
@@ -18980,7 +19017,7 @@ function decideTerror(g: Game, p: Player, action: Action) {
!options.blocked,
options.blocked ?? 'This arrival is unavailable.',
);
p.reserves -= amount;
withdrawNativeReserves(g, p, amount, 0);
place(p, entry.territory, entry.sector, amount);
observeOccupation(g);
// A positive Sneak Attack transfers Moritani's expressly off-planet
@@ -21864,7 +21901,9 @@ function atomicsIntegrity(g: Game) {
const token = g.moritaniTerror?.tokens.find(row => row.kind === 'atomics');
requireRule(atomicsModeSupported(g) && owner?.id === aftermath.moritaniId &&
Number.isSafeInteger(aftermath.turn) && aftermath.turn >= 1 &&
aftermath.turn <= g.turn && TERROR_STRONGHOLDS.includes(aftermath.territory) &&
aftermath.turn <= g.turn && (TERROR_STRONGHOLDS.includes(aftermath.territory) ||
(token && typeof aftermath.nexusPlacement === 'string' &&
terrorLocationAllowed(g,token.id,aftermath.territory,aftermath.nexusPlacement,false,true))) &&
token?.status === 'removed' && token.location === null &&
aftermath.alliancePolicy === 'activation-targets' &&
(aftermath.allyAtActivation === null || g.players.some(player =>
+14 -7
View File
@@ -1,4 +1,4 @@
import { splitLocation, validLocation } from './board';
import { TERRITORIES, splitLocation, validLocation } from './board';
import type { Player } from './engine';
import type { TerrorToken } from './moritani-terror';
import { TERROR_STRONGHOLDS } from './moritani-terror';
@@ -11,6 +11,8 @@ export type AtomicsAftermath = Readonly<{
allyAtActivation: string | null;
/** Provisional permanent targets: Moritani and its ally at activation. */
alliancePolicy: 'activation-targets';
/** Original Moritani Cunning grant for an otherwise non-stronghold target. */
nexusPlacement?: string;
}>;
export type AtomicsPlayer = Pick<
@@ -49,11 +51,15 @@ export function quoteMoritaniAtomics(input: {
moritaniId: string;
turn: number;
aftermath: AtomicsAftermath | null;
/** The engine authenticates the original physical Cunning placement. */
nexusPlacement?: string;
}): AtomicsQuote {
const { token, players, moritaniId, turn, territory } = input;
requireAtomics(token.kind === 'atomics' && token.status === 'placed' &&
token.location === territory && TERROR_STRONGHOLDS.includes(territory),
'Atomics requires its still-placed token in the entered ordinary stronghold.');
token.location === territory && (TERROR_STRONGHOLDS.includes(territory) ||
(typeof input.nexusPlacement === 'string' && input.nexusPlacement.length > 0 &&
TERRITORIES.some(entry => entry.id === territory))),
'Atomics requires its still-placed token and an ordinary stronghold or original Nexus placement.');
requireAtomics(input.aftermath === null, 'Atomics Aftermath is already on the board.');
requireAtomics(Number.isSafeInteger(turn) && turn >= 1, 'Atomics needs a valid turn.');
requireAtomics(players.length > 0 && new Set(players.map(p => p.id)).size === players.length &&
@@ -70,8 +76,8 @@ export function quoteMoritaniAtomics(input: {
(whole(p.elites.tanks) && p.elites.tanks <= p.tanks)),
'Atomics needs valid Tanks custody.');
let total = 0, eliteTotal = 0;
// A marker with zero counters is not a casualty. A concealed No-Field with
// physical counters needs its own reveal/custody ruling before this effect.
// The engine materializes any caught No-Field in a detached forecast before
// quoting this physical effect. The pure calculator never invents counters.
requireAtomics(p.noField?.deployed?.location.territory !== token.location,
'Atomics with a concealed No-Field awaits its reveal and casualty rules.');
for (const key of new Set([...Object.keys(p.forces),
@@ -82,7 +88,7 @@ export function quoteMoritaniAtomics(input: {
const elite = p.elites?.forces[key] ?? 0;
requireAtomics(key === `${territory}:${sector}` && validLocation(territory, sector) &&
whole(amount) && whole(elite) && elite <= amount,
'Atomics needs valid typed forces in the stronghold.');
'Atomics needs valid typed forces in the territory.');
total += amount;
eliteTotal += elite;
if (amount) casualties.push({ playerId: p.id, location: key,
@@ -103,7 +109,8 @@ export function quoteMoritaniAtomics(input: {
return {
casualties,
aftermath: { territory: token.location, turn, moritaniId,
allyAtActivation: ally?.id ?? null, alliancePolicy: 'activation-targets' },
allyAtActivation: ally?.id ?? null, alliancePolicy: 'activation-targets',
...(!TERROR_STRONGHOLDS.includes(territory) ? { nexusPlacement: input.nexusPlacement! } : {}) },
handReductions,
};
}
+114 -31
View File
@@ -1,13 +1,19 @@
import { TERRITORIES, splitLocation, validLocation } from './board';
import { gameTerritories, splitLocation, validGameLocation } from './board';
import type { Game, Player } from './engine';
import { nexusCoreProfile } from './faction-module-profile';
import { mixedE3NexusLeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile } from './leader-skill-profile';
import { nexusCardMode, validateNexusCards } from './nexus-cards';
import { mixedE3NexusProfile, pairedE3NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
import { validateRicheseNoField } from './richese-no-field';
export type EcazBetrayalCounts = Readonly<{ normal: number; elite: number }>;
export type EcazBetrayalTerritory = Readonly<{
territory: string;
/** Complete physical ally group, keyed by printed board location. */
/** Visible physical ally group, keyed by current board location. */
sectors: Readonly<Record<string, EcazBetrayalCounts>>;
total: EcazBetrayalCounts;
/** One public concealed marker; its denomination is never a force count here. */
concealed?: true;
}>;
export type EcazBetrayalOffer = Readonly<{
event: string;
@@ -24,6 +30,14 @@ export type EcazBetrayalSnapshot = Readonly<{
territory: string;
sectors: Readonly<Record<string, EcazBetrayalCounts>>;
total: EcazBetrayalCounts;
/** Private source for a face-down recall, not a reveal/materialization quote. */
noField?: EcazBetrayalNoFieldSource;
}>;
export type EcazBetrayalNoFieldSource = Readonly<{
supplier: string;
event: string;
tokenId: string;
location: Readonly<{ territory: string; sector: number }>;
}>;
const allowedFactions: Record<string, true> = {
@@ -38,16 +52,44 @@ const count = (value: unknown): value is number =>
const exactKeys = (value: unknown, keys: readonly string[]): value is Record<string, unknown> =>
plain(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
function openingReady(g: Game): boolean {
const remaining = g.movementRemaining;
const guildFirst = g.advanced && g.guildTimingGranted === true && !g.guildTimingLocked &&
g.players.some(player => player.faction === 'guild' && player.id === g.active);
return g.status === 'playing' && g.phase === 5 &&
Array.isArray(g.order) && g.order.length === g.players.length &&
new Set(g.order).size === g.players.length &&
g.players.every(p => g.order.includes(p.id) && p.shipped === false && p.moved === 0) &&
g.active === g.order[0] && Array.isArray(g.movementRemaining) &&
g.movementRemaining.length === g.order.length &&
g.movementRemaining.every((id, index) => id === g.order[index]);
Array.isArray(remaining) && remaining.length === g.order.length &&
new Set(remaining).size === remaining.length && remaining.every(id => g.order.includes(id)) &&
remaining.includes(g.active!) && (g.active === remaining[0] || guildFirst);
}
function locations(player: Player): Map<string, Record<string, EcazBetrayalCounts>> {
/** Original native E3 envelopes and the selected-independent Nexus core share
* the same effect. Quiet timing and actual alliance/card custody are separate. */
export function ecazBetrayalModeSupported(g: Game): boolean {
if (!g.nexusCards?.cards || !g.players.some(p => p.faction === 'ecaz') || g.sandtrout) return false;
if (nexusCoreProfile(g) || standaloneE3NexusProfile(g) || pairedE3NexusProfile(g) || mixedE3NexusProfile(g) ||
(g.leaderSkills && (standaloneE3NexusLeaderSkillsProfile(g) || pairedE3NexusLeaderSkillsProfile(g) ||
mixedE3NexusLeaderSkillsProfile(g)))) return true;
// Preserve the original unselected-deck audit/runtime shape. Fresh combined
// configurations use the canonical predicates above, not this legacy seam.
return g.expansions.length === 0 && g.players.length >= 2 && g.players.length <= 6 &&
new Set(g.players.map(p => p.faction)).size === g.players.length &&
g.players.every(p => Object.hasOwn(allowedFactions, p.faction)) &&
!g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries &&
!g.discoveryStash && !g.greatMaker && !g.ecazTreachery && !g.techTokens && !g.strongholdCards &&
!g.semutaPreview && !g.advancedPreview && !g.kullPreview && !g.nexusKullPreview &&
!g.guildBetrayalPreview && !g.richeseBetrayalPreview && !g.nexusIxianReplacementPreview &&
!g.nexusIxianBetrayalPreview && !g.nexusHarkonnenBetrayalPreview;
}
function boardLocation(g: Game, key: string): boolean {
const { territory, sector } = splitLocation(key);
return Number.isSafeInteger(sector) && key === `${territory}:${sector}` &&
gameTerritories(g).some(t => t.id === territory) && validGameLocation(g, territory, sector);
}
function locations(g: Game, player: Player): Map<string, Record<string, EcazBetrayalCounts>> {
if (!plain(player.forces) || (player.elites && !plain(player.elites.forces)))
throw new Error('Ecaz Betrayal needs plain physical force custody.');
const elites = player.elites?.forces;
@@ -57,10 +99,8 @@ function locations(player: Player): Map<string, Record<string, EcazBetrayalCount
}
const result = new Map<string, Record<string, EcazBetrayalCounts>>();
for (const [key, total] of Object.entries(player.forces)) {
const { territory, sector } = splitLocation(key);
if (!TERRITORIES.some(t => t.id === territory) ||
!Number.isSafeInteger(sector) || !validLocation(territory, sector) ||
key !== `${territory}:${sector}` || !count(total))
const { territory } = splitLocation(key);
if (!boardLocation(g, key) || !count(total))
throw new Error('Ecaz Betrayal has invalid physical board custody.');
const elite = elites && Object.hasOwn(elites, key) ? elites[key] : 0;
if (!count(elite) || elite > total)
@@ -73,6 +113,39 @@ function locations(player: Player): Map<string, Record<string, EcazBetrayalCount
return result;
}
/** Public presence only. Do not inspect the selected token or its value when
* constructing offers, even for a zero marker or a controller with no reserves. */
function markerSupplier(g: Game, ally: string, territory: string): Player | undefined {
const suppliers = g.players.filter(p => p.noField?.deployed?.controller === ally &&
p.noField.deployed.location.territory === territory);
if (suppliers.length > 1) throw new Error('Ecaz Betrayal has multiple concealed marker sources.');
return suppliers[0];
}
function markerSource(g: Game, ally: string, territory: string): EcazBetrayalNoFieldSource | undefined {
const supplier = markerSupplier(g, ally, territory);
if (!supplier) return undefined;
const marker = supplier.noField!.deployed!;
validateRicheseNoField(supplier.noField!);
if (supplier.faction !== 'richese' || typeof supplier.noFieldEvent !== 'string' || !supplier.noFieldEvent ||
!boardLocation(g, `${marker.location.territory}:${marker.location.sector}`))
throw new Error('Ecaz Betrayal has invalid concealed marker custody.');
return { supplier: supplier.id, event: supplier.noFieldEvent, tokenId: marker.tokenId,
location: { ...marker.location } };
}
function allyLocations(g: Game, ally: Player): Map<string, Record<string, EcazBetrayalCounts>> {
const result = locations(g, ally);
for (const supplier of g.players) {
const marker = supplier.noField?.deployed;
if (!marker || marker.controller !== ally.id) continue;
if (!boardLocation(g, `${marker.location.territory}:${marker.location.sector}`))
throw new Error('Ecaz Betrayal has invalid concealed board custody.');
if (!result.has(marker.location.territory)) result.set(marker.location.territory, {});
}
return result;
}
function territoryQuote(territory: string, sectors: Record<string, EcazBetrayalCounts>): EcazBetrayalTerritory {
let normal = 0, elite = 0;
for (const group of Object.values(sectors)) {
@@ -100,13 +173,8 @@ export function ecazBetrayalOffer(g: Game, owner: string, automaticPending = fal
else if (!openingReady(g))
blocked = 'Use Ecaz Betrayal before the first Shipment and Movement action.';
else if (!ally) blocked = 'Ecaz needs a real reciprocal alliance.';
else if (g.expansions.some(expansion => expansion !== 'ecaz') ||
g.players.some(p => !Object.hasOwn(allowedFactions, p.faction)) ||
g.homeworlds || g.leaderSkills || g.discoveryEnabled || g.discoveries ||
g.discoveryStash || g.greatMaker || g.ecazTreachery || g.techTokens ||
g.strongholdCards || g.sandtrout || g.players.some(p => p.noField ||
(p.advisors && Object.keys(p.advisors).length > 0)))
blocked = 'Combined modules, No-Fields and advisor stances remain unavailable.';
else if (!ecazBetrayalModeSupported(g))
blocked = 'This Ecaz Betrayal roster and module combination is unavailable.';
else if (automaticPending || g.response || g.decision || g.truthtrance ||
g.phaseOpening || g.battle || g.pendingTreacheryDiscard || g.pendingExchange ||
g.pendingKarama || g.pendingRevival || g.pendingAmbassador || g.pendingCapture ||
@@ -117,9 +185,10 @@ export function ecazBetrayalOffer(g: Game, owner: string, automaticPending = fal
if (!blocked) validateNexusCards(cards, g.players);
const territories: EcazBetrayalTerritory[] = [];
if (!blocked && ally) {
const occupied = locations(ecaz);
for (const [territory, sectors] of locations(ally))
if (occupied.has(territory)) territories.push(territoryQuote(territory, sectors));
const occupied = locations(g, ecaz);
for (const [territory, sectors] of allyLocations(g, ally))
if (occupied.has(territory)) territories.push({ ...territoryQuote(territory, sectors),
...(markerSupplier(g, ally.id, territory) ? { concealed: true as const } : {}) });
if (!territories.length) blocked = 'Ecaz and its ally share no territory containing physical forces.';
}
return { event, blocked, ally: ally?.id ?? null, territories };
@@ -134,9 +203,10 @@ export function quoteEcazBetrayal(g: Game, owner: string, event: string, territo
throw new Error(offer?.blocked ?? 'This Ecaz Betrayal opportunity is stale.');
const selected = offer.territories.find(row => row.territory === territory);
if (!selected) throw new Error('Choose one shared territory with Ecaz and its ally.');
const noField = markerSource(g, offer.ally!, territory);
return { event, owner, ally: offer.ally!, turn: g.turn, territory,
discardIndex: g.nexusCards!.cards!.discard.length,
sectors: selected.sectors, total: selected.total };
sectors: selected.sectors, total: selected.total, ...(noField ? { noField } : {}) };
}
/** Validate a saved pre-return quote against frozen authoritative physical
@@ -145,20 +215,28 @@ export function quoteEcazBetrayal(g: Game, owner: string, event: string, territo
export function validateEcazBetrayalSnapshot(
g: Game, snapshot: EcazBetrayalSnapshot, pending = false,
): void {
if (!exactKeys(snapshot, ['event', 'owner', 'ally', 'turn', 'territory', 'discardIndex', 'sectors', 'total']) ||
if (!exactKeys(snapshot, ['event', 'owner', 'ally', 'turn', 'territory', 'discardIndex', 'sectors', 'total',
...(snapshot?.noField !== undefined ? ['noField'] : [])]) ||
typeof snapshot.event !== 'string' || typeof snapshot.owner !== 'string' ||
typeof snapshot.ally !== 'string' || typeof snapshot.territory !== 'string' ||
!Number.isSafeInteger(snapshot.turn) || snapshot.turn !== g.turn ||
!Number.isSafeInteger(snapshot.discardIndex) || snapshot.discardIndex < 0 ||
!exactKeys(snapshot.total, ['normal', 'elite']) ||
!count(snapshot.total.normal) || !count(snapshot.total.elite) ||
!plain(snapshot.sectors) || !Object.keys(snapshot.sectors).length)
!plain(snapshot.sectors) || (!Object.keys(snapshot.sectors).length && !snapshot.noField) ||
!gameTerritories(g).some(t => t.id === snapshot.territory))
throw new Error('Ecaz Betrayal has an invalid saved force snapshot.');
if (snapshot.noField !== undefined && (!exactKeys(snapshot.noField, ['supplier', 'event', 'tokenId', 'location']) ||
typeof snapshot.noField.supplier !== 'string' || !snapshot.noField.supplier ||
typeof snapshot.noField.event !== 'string' || !snapshot.noField.event ||
typeof snapshot.noField.tokenId !== 'string' || !snapshot.noField.tokenId ||
!exactKeys(snapshot.noField.location, ['territory', 'sector']) ||
snapshot.noField.location.territory !== snapshot.territory ||
!boardLocation(g, `${snapshot.noField.location.territory}:${snapshot.noField.location.sector}`)))
throw new Error('Ecaz Betrayal has an invalid saved concealed marker.');
for (const [key, group] of Object.entries(snapshot.sectors)) {
const { territory, sector } = splitLocation(key);
if (territory !== snapshot.territory || !TERRITORIES.some(t => t.id === territory) ||
!Number.isSafeInteger(sector) || !validLocation(territory, sector) ||
key !== `${territory}:${sector}` ||
const { territory } = splitLocation(key);
if (territory !== snapshot.territory || !boardLocation(g, key) ||
!exactKeys(group, ['normal', 'elite']) || !count(group.normal) || !count(group.elite) ||
group.normal + group.elite <= 0 || !Number.isSafeInteger(group.normal + group.elite))
throw new Error('Ecaz Betrayal has an invalid saved sector group.');
@@ -169,7 +247,7 @@ export function validateEcazBetrayalSnapshot(
const ecaz = g.players.find(p => p.faction === 'ecaz');
const ally = g.players.find(p => p.id === snapshot.ally);
const holder = g.players.find(p => p.id === snapshot.owner);
if (!openingReady(g) || !cards || cards.hands[snapshot.owner] !== null ||
if (!openingReady(g) || !ecazBetrayalModeSupported(g) || !cards || cards.hands[snapshot.owner] !== null ||
cards.discard[snapshot.discardIndex] !== 'ecaz' || !ecaz || !ally ||
ecaz.ally !== ally.id || ally.ally !== ecaz.id || !holder ||
holder.ally || holder.faction === 'ecaz' ||
@@ -177,16 +255,21 @@ export function validateEcazBetrayalSnapshot(
snapshot.owner, ally.id, snapshot.discardIndex]))
throw new Error('Ecaz Betrayal has lost its pending card or alliance.');
validateNexusCards(cards, g.players);
const occupied = locations(ecaz);
const selected = locations(ally).get(snapshot.territory);
const occupied = locations(g, ecaz);
const selected = allyLocations(g, ally).get(snapshot.territory);
if (!occupied.has(snapshot.territory) || !selected)
throw new Error('Ecaz Betrayal has lost the shared physical territory.');
const noField = markerSource(g, ally.id, snapshot.territory);
fresh = { event: snapshot.event, turn: g.turn, owner: snapshot.owner,
ally: ally.id, discardIndex: snapshot.discardIndex,
...territoryQuote(snapshot.territory, selected) };
...territoryQuote(snapshot.territory, selected), ...(noField ? { noField } : {}) };
} else fresh = quoteEcazBetrayal(g, snapshot.owner, snapshot.event, snapshot.territory);
if (snapshot.ally !== fresh.ally || snapshot.discardIndex !== fresh.discardIndex ||
snapshot.total.normal !== fresh.total.normal || snapshot.total.elite !== fresh.total.elite ||
snapshot.noField?.supplier !== fresh.noField?.supplier || snapshot.noField?.event !== fresh.noField?.event ||
snapshot.noField?.tokenId !== fresh.noField?.tokenId ||
snapshot.noField?.location.territory !== fresh.noField?.location.territory ||
snapshot.noField?.location.sector !== fresh.noField?.location.sector ||
Object.keys(snapshot.sectors).length !== Object.keys(fresh.sectors).length ||
Object.entries(fresh.sectors).some(([key, group]) =>
!Object.hasOwn(snapshot.sectors, key) ||
+19 -10
View File
@@ -1470,7 +1470,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The mobile stronghold can receive a non-Ixian group only from the territory it points to. Ecaz and its ally may co-occupy under Occupy. Advisors keep their applicable stance and accompaniment locks; an eligible arrival can request fighters. CHOAM may use Baliset during Shipment and Movement; if allowed, choose another legal destination. Intrusion or Terror resolves before the original entrant continues. A destination that causes both settles the Intrusion first and then offers the committed Terror entry; no payment or force transfer replays. There is no extra decline after triggering; if no legal move remains, the effect finishes without moving pieces and its token stays consumed.',
'The Guild Ambassador grants an immediate free shipment of zero through four physical reserve forces for Ecaz or its ally, including typed elites. Choose a clear territory and sector; this does not spend ordinary shipment or movement, ally aid or a retained rate card. Fremen may use destinations outside their ordinary reinforcement radius, but their southern reserves stay on-planet and this effect never permits storm entry. Only Ixians may ship directly into their placed mobile stronghold.',
'During Shipment and Movement, Guild can use its special Karama to stop another eligible off-planet Ambassador shipment before forces arrive; it does not stop this grant during a phase-one worm sequence. A successful off-planet shipment may invite Bene Gesserit accompaniment and accrues applicable phase-five Heighliner income once. BG can accompany Ixians into the mobile stronghold. Projected accompaniment choices show blocked sectors and keep Polar Sink and decline available when legal. Intrusion, separate Terror and accompanying fighter-triggered Ambassadors finish before the original entrant or worm rider resumes.',
'No-Field substitution into the free four-force grant remains unavailable. In bounded classic plus Ecaz/Moritani games, a shared Ambassador/Terror entry gives both owners saved optional prompts in current storm order without repeating the committed entry; this is a provisional table order, not a printed publisher priority. A Terror entry now waits for the arrival’s own interaction — Guild income, a Bene Gesserit fighter Intrusion or advisor choice, or Fremen storm protection — and then opens without replaying payment or force transfer. Ambassador-created Fremen/Guild child arrivals remain gated. Atomics Aftermath, Homeworlds, Nexus and Discovery combinations do not gain coverage from this overlap path.',
'No-Field substitution into the free four-force grant remains unavailable. In bounded classic plus Ecaz/Moritani games, a shared Ambassador/Terror entry gives both owners saved optional prompts in current storm order without repeating the committed entry; this is a provisional table order, not a printed publisher priority. A Terror entry now waits for the arrival’s own interaction — Guild income, a Bene Gesserit fighter Intrusion or advisor choice, or Fremen storm protection — and then opens without replaying payment or force transfer. Ambassador-created child arrivals retain their own contracts. The native-module extension now composes ordinary paid arrival order with Atomics, Homeworlds and Nexus; other combinations and complete rules remain unfinished.',
'The Tleilaxu Ambassador offers one first-death own-leader revival or up to four physical forces for free. Atreides may choose its separate first-killed Kwisatz Haderach under the base leader-revival rule; this does not add a disc to the ordinary roster, reset battle losses or spend the ordinary leader allowance. Native and Bene Gesserit copied grants share the same private eligibility. Every existing KH return advances its original cycle marker; a prior return remains outside this first-death preview. The force/leader choices are exclusive and ordinary allowance independence stays provisional. Repeat-return, shared-leader and revival-income interpretations remain separate.',
'All eleven effect guides are readable. Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are integrated, together with Bene Gesserit copies of those eight effects and direct Duke acquisition or consensual alliance through the reusable Ecaz token. Advanced combined Occupy has a separate bounded Development Verified guide, and Basic even-force Occupy now composes the same shared controls. Remaining effects, exceptional Duke custody and complete Ecaz rules remain unfinished. Mobile stronghold placement is gated. Full Ecaz starts remain disabled.',
],
@@ -3040,7 +3040,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Advanced & expansions',
coverage: 'Partial',
summary:
'Six hidden tokens threaten stronghold entrants. Bounded Atomics revelation is available in classic-plus-Moritani tables; complete Moritani starts remain unavailable.',
'Six hidden tokens threaten stronghold entrants. Native Atomics now composes the selected modules; complete Moritani rules remain unfinished.',
steps: [
'Placement and isolated entry decisions have controls for Robbery, Sabotage, Sneak Attack, Extortion, ordinary native-leader Assassination and bounded Atomics. You may instead leave a token hidden. Supported native Moritani starts use labelled previews; unsupported competing entry reactions remain guarded.',
'Begin with all six Terror tokens in your hidden supply. Once during Mentat Pause, you may either place one from supply or move one already placed to another eligible stronghold. There is no spice cost.',
@@ -3071,7 +3071,7 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [
'Robbery, Sabotage, Sneak Attack, effective-control Assassination, Extortion and bounded Atomics have controls for supported entry reactions. After a Robbery draw, finish any required discard; after Sabotage, give one of your own cards or decline. Enemy of My Enemy has partial alliance controls. Complete Moritani games remain unfinished.',
'Assassination randomly kills one actual living effectively controlled disc: native, captive, ordinary foreign Ghola, shared Duke or optional Advanced Auditor, once each. Native discs controlled elsewhere are excluded; living used discs remain eligible. The provisional public-victim policy reveals the actual killed disc and pays native printed value, Zoal3 or Duke6. Empty revelation spends the token without victim, bounty or RNG and resumes arrival. No pool/size is projected or used to gate availability; Moritani cannot choose a victim. These exceptional/empty policies are product compositions, not a located publisher FAQ.',
'Atomics sends every faction’s forces in the territory to the Tanks and places public Aftermath. Supported revelation remains bounded to classic-plus-Moritani without Guild, Ecaz or optional modules. Later alliances now retain the original affected hand-limit targets under the explicit provisional interpretation in the Aftermath topic.',
'Atomics sends every faction’s forces in the territory to the Tanks and places public Aftermath. Native revelation retains original Guild invoices, Ambassador/Terror arrival order and selected modules. A caught No-Field provisionally materializes capped reserves before all physical losses. Later alliances now retain the original affected hand-limit targets under the explicit provisional interpretation in the Aftermath topic.',
'Extortion: set aside five spice from the bank for collection during Mentat Pause. After collection, recover this token unless one player pays you three spice in the storm-order opportunity. The Extortion topic explains the two separate payments.',
'Robbery: choose between taking half the entrant’s spice, rounded up, and drawing the top Treachery Card. If the draw exceeds your hand limit, choose a card to discard afterward; a full hand does not remove the draw option.',
'Sabotage: randomly draw and discard a Treachery Card from the entrant if possible. Then you may give that player a card of your choice from your own hand. Giving a card is optional.',
@@ -3101,7 +3101,9 @@ export const RULE_TOPICS: RuleTopic[] = [
summary:
'Bounded Atomics reveals destroy the territory’s forces, prohibit future shipment there, and reduce hand limits.',
steps: [
'On a supported classic-plus-Moritani table without Guild, Ecaz or optional modules, revealing Atomics sends every faction’s forces in the triggered territory to the Tanks and places a public Atomics Aftermath marker there. Hidden tokens remain face down until revealed.',
'On a supported native Moritani table with original selected modules, revealing Atomics sends every faction’s forces in the triggered territory to the Tanks and places a public Atomics Aftermath marker there. Hidden tokens remain face down until revealed.',
'An original Moritani Nexus Cunning placement may put Atomics in a printed Arrakis desert. The later explosion and Aftermath retain that actual placement grant after the token is removed. This does not permit ordinary desert placement, Homeworld, HMS or Discovery targets.',
'A caught concealed No-Field provisionally reveals its reserve-capped native forces before the explosion sends them to Tanks. This composition is labelled provisional; it is not a located publisher clarification.',
'Aftermath permanently prohibits shipping forces into that territory, including Fremen reinforcements. The restriction is on shipment: it does not prohibit otherwise legal ordinary movement into the territory.',
'Sneak Attack expressly permits its reserve forces to enter despite Aftermath. Its storm and occupancy restrictions still apply.',
'Beginning on the activation turn, Moritani’s hand limit and its ally’s at activation are each reduced by one. If either hand exceeds its reduced limit on resolution, discard one random card per excess card. The public Aftermath notice identifies the affected players; their individual projected hand limits reflect the penalty.',
@@ -3109,6 +3111,13 @@ export const RULE_TOPICS: RuleTopic[] = [
'The Atomics Terror token and Family Atomics Treachery Card are separate components with different effects. Aftermath is not the destroyed Shield Wall. Complete Moritani games and public expansion starts remain unavailable.',
],
related: ['moritani-terror-effects', 'movement', 'card-atomics', 'bidding'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Native optional-module Atomics uses original typed casualties, capped No-Field reveal, fixed activation hand targets and suspended arrival continuation. Desert Aftermath retains the actual Cunning placement history. Complete combinations remain open.', evidence: ['game/moritani-atomics.ts', 'game/engine.ts', 'game/terror-location.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Original Reveal Atomics and public Aftermath controls explain the destructive effect, shipment ban, random overflow and provisional policies.', evidence: ['components/moritani-terror.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Minimal legal original Terror decisions and Sneak Attack use the original projected controls; strategy and difficulty calibration remain deferred.', evidence: ['game/bots.ts', 'tests/moritani-atomics-modules.test.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Printed Cunning placement and provisional concealed destruction/activation targets are explicit; preview and complete-mode gates stay separate.', evidence: ['docs/MORITANI_TERROR_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused original-action module and Cunning desert scenarios supplement the earlier native proof; source-bound owner verification is recorded in the linked contract. Comprehensive assurance and deployed acceptance remain required.', evidence: ['tests/moritani-atomics-modules.test.ts', 'tests/fixture-moritani-atomics-modules.ts', 'tests/nexus-moritani-atomics-runtime.test.ts', 'tests/fixture-nexus-moritani-atomics.ts', 'tests/moritani-atomics.test.ts'] },
],
},
{
id: 'moritani-extortion',
@@ -4682,7 +4691,7 @@ export const RULE_TOPICS: RuleTopic[] = [
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws and selected Atreides, Harkonnen, Tleilaxu, Ixian, Bene Gesserit, Emperor, Fremen, CHOAM, Moritani, Richese and Guild effects are connected. Other effects and complete module play remain in development.',
steps: [
'This optional module can be combined with Basic or Advanced rules independently of the factions and other expansion modules selected. Include one Nexus Card for each of the twelve factions.',
'Fresh classic2–6 Nexus core admits base33/independent36. Any selected E1/E2/E3 decks compose the independent three-card variant with Nexus12, optionally all14, original Homeworlds, Tech3+ and Advanced Strongholds2+. A selected deck need not seat its native faction. Ordinary stock is36/38/50 plus any native Richese cache10. Native Ecaz Occupy, Advanced Moritani Assassinate, Ambassador/Terror arrival and shared-Duke Cunning retain their original physical owners and continuation. Advanced actual E3 with Harkonnen and Skills remains guarded. Native Ixians get Betrayal; only their absence permits purchased-card Secret Ally replacement. Two seats can select Nexus but cannot earn a hand without the printed alliance condition. Ecaz Betrayal, Moritani Atomics, Discovery/Semuta and other reaction envelopes remain separate required work; this is not complete Nexus acceptance.',
'Fresh classic2–6 Nexus core admits base33/independent36. Any selected E1/E2/E3 decks compose the independent three-card variant with Nexus12, optionally all14, original Homeworlds, Tech3+ and Advanced Strongholds2+. A selected deck need not seat its native faction. Ordinary stock is36/38/50 plus any native Richese cache10. Native Ecaz Occupy, Advanced Moritani Assassinate, Ambassador/Terror arrival and shared-Duke Cunning retain their original physical owners and continuation. Advanced actual E3 with Harkonnen and Skills remains guarded. Native Ixians get Betrayal; only their absence permits purchased-card Secret Ally replacement. Two seats can select Nexus but cannot earn a hand without the printed alliance condition. Ecaz Betrayal now returns typed native groups, advisors and concealed markers; Moritani Atomics follows actual original placements, including a Cunning-granted printed desert. Discovery/Semuta and other reaction envelopes remain required; this is not complete Nexus acceptance.',
'Recruits changes native Revival rates and the ordinary seven ceiling, not the fixed Tleilaxu Secret Ally group or its one-spice price. Five paid card counters may leave two ordinary free returns, using the one leader slot once. Emperor Cunning still makes five ordinary board counters temporarily Sardaukar: independent reserve fees use separate physical reserves, Harass returns actual undialed counters, and native Karama changes only the original Nexus effect. Native winner/inspection and Collection continue without extra cleanup prompts.',
'At the end of the entire Spice Blow and Nexus phase, if a Nexus occurred and at least one alliance exists, each unallied player may draw one card or discard their held card and draw a replacement. You may decline. The two Advanced spice piles do not grant two closing draws.',
'Keep your card secret until it is used. You may hold at most one Nexus Card. It is separate from your Treachery hand and does not occupy a Treachery hand slot.',
@@ -4735,7 +4744,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'With Moritani seated, an unallied holder of its Nexus card may spend Betrayal at a clean play boundary to return one placed Terror token to hidden Moritani supply without revealing its face. Choose the public location and token, not its secret effect; this does not use Moritani’s Mentat placement or trigger that token.',
'When Moritani is absent, a defeated combatant with an eligible publicly played card receives the same postbattle choice whether or not they hold Moritani Nexus. An unallied holder may spend it to retain one played card that a winner could keep; other played cards follow ordinary discard. Without a publicly eligible card there is no prompt. The choice is after the battle result and before later income/capture; the public prompt protects hidden custody and is an interface inference. This Secret Ally effect has no invented ordinary Karama response. Native Moritani allied retention remains a different, cancelable ability.',
'With actual Advanced Stronghold Cards, Arrakeen bank support changes the real payer debit, not personal income. Emperor Cunning temporary Sardaukar remain physically ordinary counters and retain ordinary Tanks custody. Original losing Moritani retention precedes mandatory original-winner Tech, then CHOAM inspection excludes used opposing cards even if retained. Ordinary city Collection remains separate; none of these windows repeats the original support, card cost or casualties.',
'Ecaz Betrayal is available to an unallied holder with Ecaz seated and reciprocally allied in a shared Arrakis territory. At the opening of Shipment and Movement, choose one such territory; the ally returns its entire normal and starred force group from every sector there to reserves, leaving Ecaz forces in place. The Nexus card is spent before a Karama response. Cancellation preserves the board but not the card. Only the base/Ecaz Nexus roster without combined modules is supported.',
'Ecaz Betrayal is available to an unallied holder with Ecaz seated and reciprocally allied in a shared Arrakis territory. At the opening of Shipment and Movement, choose one such territory; the ally returns its entire normal and starred force group from every sector there to reserves, leaving Ecaz forces in place. The Nexus card is spent before a Karama response. Cancellation preserves the board but not the card. Original native and selected-independent Nexus profiles retain World, training and phase modules. Advisors return as native counters; a concealed No-Field provisionally returns face down without materializing counters. The full unspent opening remains available after an actual Guild first/wait or Sapho reorder.',
'Ecaz Cunning lets an unallied native holder spend its physical Nexus at an original quiet boundary in any phase to take the existing Duke for this turn, including living control, original captured custody or the set-aside disc in Tanks. Finish committed auctions, Battle, responses, Ambassador and other physical continuations first; no chooser or phase is stolen. New version3 records bind actual phase and custody; legacy ordinary/version2 Battle history remains strict. Tanks preserve deaths and independently clear old use without spice or ordinary allowance; living already-used Duke gains no fresh use. Standalone no-Skills Ecaz/Advanced Harkonnen connects original capture and provisional permanent liberation; one battle or unused current-turn expiry sets Duke aside, never back to the captor. Actual Ecaz Ghola returns set aside before separate Cunning; foreign-Ghola revival remains forbidden. Roster/module and shared-training guards remain.',
'Fremen Betrayal has two bounded advance-declaration alternatives for an unallied holder in a classic Nexus table. Before the first Spice Blow or worm appearance, spend the physical card to prevent Fremen ordinary and Cunning worm rides for the turn; worm survival, destruction and Nexus still resolve. This clean timing is a product boundary, not an official post-appearance priority ruling. Instead, before Fremen move during Shipment and Movement, spend it to suppress their native two-territory advantage for that turn while ordinary movement and independent ornithopters remain. Only one alternative can be used even if the card is recycled later that turn. Owner controls show the currently available choice; combined modules and reactive post-worm timing remain gated.',
'When Ecaz is absent, an unallied holder may spend its Nexus card at a quiet play boundary to ask whether any one player, including themself, holds one of the holder’s native faction leaders as an ordinary Traitor Card. The owner receives a private yes/no answer saved at the moment of use; no name or count is disclosed. Dead or captured native leaders count, but foreign Gholas, Duke Vidal, Cheap Hero and Face Dancers do not. Native-leader scope and private-only audience are explicit product interpretations, not publisher rulings; exceptional controlled-leader and public-disclosure combinations remain open.',
@@ -4752,11 +4761,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:"Selected E1/E2/E3 independent/Nexus now shares fresh setup and original native consumers: typed Ix fees/Harass/substitution, CHOAM fuel, Richese paired invoices, native Occupy/Assassinate, shared arrival order and Duke Cunning. Existing trained-capture and unfinished individual-effect envelopes remain. Native cross/return permission and rate remain separate; Cunning preserves its opportunity. Independent typed purchases and explicit immune Junction source choice retain their original tariffs/payees. Richese Secret Ally now keeps one billed counter and all five physical forces through native Guild rate allowance/cancellation; signed pending/settled sources stay bound. Unfunded release retains spent Nexus without debt/refund/reusable coupon and preserves ordinary shipment. Marker approvals still cannot silently increase; full acceptance remains open.",evidence:['tests/e3-independent-nexus-occupy.test.ts','tests/fixture-e3-independent-nexus-occupy.ts','tests/e3-independent-nexus-cards.test.ts','tests/fixture-e3-independent-nexus-cards.ts','tests/e3-independent-nexus-duke.test.ts','tests/fixture-e3-independent-nexus-duke.ts','tests/e3-independent-nexus-arrival.test.ts','tests/fixture-e3-independent-nexus-arrival.ts','game/faction-module-profile.ts','tests/selected-independent-nexus-entry.test.ts','tests/fixture-selected-independent-nexus-entry.ts','tests/selected-independent-nexus-battles.test.ts','tests/fixture-selected-independent-nexus-battles.ts','tests/selected-independent-nexus-cards.test.ts','tests/fixture-selected-independent-nexus-cards.ts','tests/selected-independent-ixian-betrayal.test.ts','game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts", "game/battle-aftermath-quote.ts", "game/ecaz-duke-acquisition.ts", "game/ecaz-duke-revival.ts", "game/nexus-ecaz-duke-options.ts", "game/nexus-play-boundary.ts", "game/junction-transport-options.ts"]},
{area:"Player controls",status:'Partial',detail:"Selected E3 table SSR retains original lead/plan, assassination/revival, Duke and arrival controls; CSS stubs mean no visual/browser acceptance. Four earlier original-action390px journeys prove native Junction5/full2, poor1/full2 release plus ordinary one-force replacement, still-held source sand2 disabled then five-force stronghold1, and independent purchased1. The response explains the spent-card consequence. No overflow, browser error or application exception; two tool-only stale-node errors were resolved by fresh evidence without replay. Earlier Junction/loan/refund surfaces remain scoped evidence.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx", "components/junction-transport.tsx"]},
{area:"AI",status:'Partial',detail:"All four policies execute original selected E3 Duke Cunning and unused expiry, and legal Occupy plans with real World sources. All four original policies also execute a legal still-held Richese full-rate one-counter alternative after native cancellation, with actual physical delivery and card cost. Shared human/AI quotes no longer promise a canceled discount. Native permission alternatives, recipient-specific lender prices and immune Junction source choice remain. Optional non-reoffer is not forced. No strategic first-candidate pin, tuning or calibration.",evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']},
{area:"Implementation",status:'Partial',detail:"Ecaz Betrayal now composes typed World returns, advisors, actual HMS and provisional face-down marker recall; Guild/Sapho timing preserves a full unspent opening. Native module Atomics retains actual paid arrival and original Cunning desert grants. Selected E1/E2/E3 independent/Nexus now shares fresh setup and original native consumers: typed Ix fees/Harass/substitution, CHOAM fuel, Richese paired invoices, native Occupy/Assassinate, shared arrival order and Duke Cunning. Existing trained-capture and unfinished individual-effect envelopes remain. Native cross/return permission and rate remain separate; Cunning preserves its opportunity. Independent typed purchases and explicit immune Junction source choice retain their original tariffs/payees. Richese Secret Ally now keeps one billed counter and all five physical forces through native Guild rate allowance/cancellation; signed pending/settled sources stay bound. Unfunded release retains spent Nexus without debt/refund/reusable coupon and preserves ordinary shipment. Marker approvals still cannot silently increase; full acceptance remains open.",evidence:['tests/e3-independent-nexus-occupy.test.ts','tests/fixture-e3-independent-nexus-occupy.ts','tests/e3-independent-nexus-cards.test.ts','tests/fixture-e3-independent-nexus-cards.ts','tests/e3-independent-nexus-duke.test.ts','tests/fixture-e3-independent-nexus-duke.ts','tests/e3-independent-nexus-arrival.test.ts','tests/fixture-e3-independent-nexus-arrival.ts','game/faction-module-profile.ts','tests/selected-independent-nexus-entry.test.ts','tests/fixture-selected-independent-nexus-entry.ts','tests/selected-independent-nexus-battles.test.ts','tests/fixture-selected-independent-nexus-battles.ts','tests/selected-independent-nexus-cards.test.ts','tests/fixture-selected-independent-nexus-cards.ts','tests/selected-independent-ixian-betrayal.test.ts','game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts", "game/battle-aftermath-quote.ts", "game/ecaz-duke-acquisition.ts", "game/ecaz-duke-revival.ts", "game/nexus-ecaz-duke-options.ts", "game/nexus-play-boundary.ts", "game/junction-transport-options.ts"]},
{area:"Player controls",status:'Partial',detail:"Native Betrayal/Atomics table SSR retains typed/concealed return, revelation, ordered arrival, Aftermath and desert controls; CSS stubs mean no visual/browser acceptance. Earlier selected E3 lead/plan, assassination/revival and Duke controls retain their dated proof. Four earlier original-action390px journeys prove native Junction5/full2, poor1/full2 release plus ordinary one-force replacement, still-held source sand2 disabled then five-force stronghold1, and independent purchased1. The response explains the spent-card consequence. No overflow, browser error or application exception; two tool-only stale-node errors were resolved by fresh evidence without replay. Earlier Junction/loan/refund surfaces remain scoped evidence.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx", "components/junction-transport.tsx"]},
{area:"AI",status:'Partial',detail:"All four policies retain minimal legal Ecaz Betrayal and original Aftermath/Sneak participation. All four policies execute original selected E3 Duke Cunning and unused expiry, and legal Occupy plans with real World sources. All four original policies also execute a legal still-held Richese full-rate one-counter alternative after native cancellation, with actual physical delivery and card cost. Shared human/AI quotes no longer promise a canceled discount. Native permission alternatives, recipient-specific lender prices and immune Junction source choice remain. Optional non-reoffer is not forced. No strategic first-candidate pin, tuning or calibration.",evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']},
{area:"Documentation",status:'Partial',detail:"Selected E3 scope, printed two-seat no-alliance draw limit and remaining individual-effect envelopes are explicit in the canonical Nexus contract. Richese printed one-counter pricing and native Guild eligibility stay distinct from absent-faction cancellation, physical force count and independent purchased price. Unfunded spent-card/no-refund release is selected existing no-debt composition, not a new publisher worked example. Immune Junction and earlier permission/refund/loan interpretations remain explicit. Full rules and public gates stay open.",evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/GUILD_RATE_KARAMA_RULES.md', 'docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md', 'docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md", "docs/RICHESE_ACQUISITION_RULES.md", "docs/DUKE_VIDAL_RULES.md", "docs/ECAZ_DUKE_ACQUISITION.md", "docs/ECAZ_REVIVAL_RULES.md", "docs/JUNCTION_TRANSPORT_RULES.md", "docs/JUNCTION_TRANSPORT_RUNTIME.md"]},
{area:"Verification",status:'Partial',detail:"Selected E3 independent entry, Occupy, Assassinate/Recruits, Duke and arrival paths pass frozen17-file168/168, types/lint/build and seven finite original-action programmes. Earlier selected E1/E2 native card/cancellation proof remains dated in the canonical Nexus contract. Classic core retains original World/Tech/Stronghold modules with base33/selected36 and optional14. Independent World cases use native setup, population-sensitive Recruits, typed foreign Harass and actual own-Kaitain Reinforcements/Cunning. The local fee reduces only its paid temporary roles; original count5 and real stars remain. Earlier phase/card proofs are dated in the canonical contract. Native-family/Discovery/Semuta envelopes, full-mode and comprehensive assurance remain open.",evidence:['tests/nexus-world-independent-cards.test.ts','tests/nexus-world-independent-battles.test.ts','tests/reinforcements-temporary-role.test.ts','tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/native-guild-rate-karama.test.ts', 'tests/native-guild-rate-modules.test.ts', 'tests/allied-marker-guild-rate.test.ts', 'tests/purchased-homeworld-rate.test.ts', 'tests/native-guild-transport-permission.test.ts', 'tests/native-guild-homeworld-rate.test.ts', 'tests/native-guild-rate-betrayal.test.ts', 'tests/nexus-guild-cunning-engine.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','Line truncated
{area:"Verification",status:'Partial',detail:"Native Betrayal/Atomics composition passes frozen16-file115/115, types/lint/build and eight finite original-action/table-SSR programmes, including original Cunning desert Aftermath; visual/browser and deployed acceptance remain open. Earlier selected E3 entry, Occupy, Assassinate/Recruits, Duke and arrival proof remains dated in the canonical contract. Earlier selected E1/E2 native card/cancellation proof remains dated in the canonical Nexus contract. Classic core retains original World/Tech/Stronghold modules with base33/selected36 and optional14. Independent World cases use native setup, population-sensitive Recruits, typed foreign Harass and actual own-Kaitain Reinforcements/Cunning. The local fee reduces only its paid temporary roles; original count5 and real stars remain. Earlier phase/card proofs are dated in the canonical contract. Native-family/Discovery/Semuta envelopes, full-mode and comprehensive assurance remain open.",evidence:['tests/ecaz-betrayal-modules.test.ts','tests/fixture-ecaz-betrayal-modules.ts','tests/richese-no-field-hidden-return.test.ts','tests/nexus-ecaz-betrayal-quotes.test.ts','tests/moritani-atomics-modules.test.ts','tests/nexus-moritani-atomics-runtime.test.ts','tests/nexus-world-independent-cards.test.ts','tests/nexus-world-independent-battles.test.ts','tests/reinforcements-temporary-role.test.ts','tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/native-guild-rate-karama.test.ts', 'tests/native-guild-rate-modules.test.ts', 'tests/allied-marker-guild-rate.test.ts', 'tests/purchased-homeworld-rate.test.ts', 'tests/native-guild-transport-permission.test.ts', 'tests/native-guild-homeworld-rate.test.ts', 'tests/native-guild-rate-betrayal.test.ts', 'tests/nexus-guild-cunning-engine.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-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-proteLine truncated
],
},
{
+20 -2
View File
@@ -14,6 +14,8 @@ export type RicheseNoField = {
/** Shared own/allied shipment history; revelation never resets it. */
lastShipped: string | null;
deployed: NoFieldDeployment | null;
/** A forced face-down recall retains the last shipment without revealing it. */
hiddenReturn?: true;
};
export type NoFieldRevealCause =
| 'voluntary'
@@ -22,7 +24,8 @@ export type NoFieldRevealCause =
| 'worm'
| 'gamont'
| 'beforeAlly'
| 'allyShipment';
| 'allyShipment'
| 'atomics';
export type NoFieldReveal = {
state: RicheseNoField;
tokenId: string;
@@ -93,6 +96,9 @@ export function validateRicheseNoField(state: RicheseNoField): void {
(typeof state.deployed === 'object' && !!state.deployed),
'Invalid deployed No-Field custody.',
);
requireNoField(state.hiddenReturn === undefined ||
(state.hiddenReturn === true && state.deployed === null && state.lastShipped !== null),
'A hidden No-Field return needs its previous shipment and no deployed token.');
if (state.deployed) {
requireNoField(
state.tokens.some((token) => token.id === state.deployed!.tokenId) &&
@@ -151,6 +157,7 @@ export function deployRicheseNoField(
): RicheseNoField {
validateDeployment(previous, deployment);
const state = structuredClone(previous);
delete state.hiddenReturn;
state.lastShipped = deployment.tokenId;
state.deployed = structuredClone(deployment);
return state;
@@ -192,6 +199,7 @@ export function deployRicheseNoFieldPair(
): { state: RicheseNoField; revealed: NoFieldReveal } {
const token = validateRicheseNoFieldPair(previous, concealed, revealedTokenId, reserves);
const state = structuredClone(previous);
delete state.hiddenReturn;
state.lastShipped = concealed.tokenId;
state.deployed = structuredClone(concealed);
return {
@@ -252,6 +260,7 @@ export function revealRicheseNoField(
'gamont',
'beforeAlly',
'allyShipment',
'atomics',
].includes(input.cause),
'Choose a valid No-Field reveal cause.',
);
@@ -260,6 +269,7 @@ export function revealRicheseNoField(
(candidate) => candidate.id === input.tokenId,
)!;
const state = structuredClone(previous);
delete state.hiddenReturn;
state.deployed = null;
return {
state,
@@ -272,6 +282,14 @@ export function revealRicheseNoField(
};
}
/** Provisional Ecaz Betrayal composition: return the marker face down, without
* revealing or materializing its denomination. The last-shipment ban remains. */
export function recallRicheseNoField(previous: RicheseNoField, tokenId: string): RicheseNoField {
validateRicheseNoField(previous);
requireNoField(previous.deployed?.tokenId === tokenId, 'That No-Field is not deployed.');
return {...structuredClone(previous), deployed:null, hiddenReturn:true};
}
/** Any existing marker must already have been revealed by the authorized caller. */
export function shipAlliedRicheseNoField(
previous: RicheseNoField,
@@ -312,7 +330,7 @@ export function projectRicheseNoField(
}
: null,
lastUsed:
!state.deployed && state.lastShipped !== null
!state.deployed && !state.hiddenReturn && state.lastShipped !== null
? state.tokens.find((token) => token.id === state.lastShipped)!.value
: null,
};
+167
View File
@@ -0,0 +1,167 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { Game } from '../game/engine';
import { MOBILE_LOCATION } from '../game/board';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import { deployRicheseNoField, projectRicheseNoField } from '../game/richese-no-field';
import {
createEcazBetrayalModulesFixture, ecazBetrayalModulesPlayer as player,
resolveEcazBetrayalModules, smokeEcazBetrayalModules,
type EcazBetrayalModulesFixture,
} from './fixture-ecaz-betrayal-modules';
function queue(game: Game) {
return { turn: game.turn, phase: game.phase, active: game.active, order: game.order,
remaining: game.movementRemaining, choices: game.players.map(player => [player.id, player.shipped, player.moved]) };
}
function pool(game: Game, owner: string, location: string) {
return homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
.find(group => group.id === location)!.forces[owner];
}
function completion(f: EcazBetrayalModulesFixture, game: Game, cancel: boolean) {
assert.deepEqual(queue(game), queue(f.game), 'Betrayal neither consumes nor reorders the original Shipment/Movement queue');
assert.equal(game.nexusEcazBetrayalHistory!.at(-1)!.stage, 'complete');
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
assert.equal(game.discard.filter(card => card.id === f.karama).length, cancel ? 1 : 0);
assert.equal(player(game, f.ally).forces['arrakeen:10'], player(f.game, f.ally).forces['arrakeen:10'],
'The actual paid outside group remains in its own territory');
assert.deepEqual(player(game, 'ecaz').forces, player(f.game, 'ecaz').forces);
assert.deepEqual(player(game, 'guild').forces, player(f.game, 'guild').forces);
assert.deepEqual(game.strongholdCards, f.game.strongholdCards,
'Returning troops does not reassign previously earned Stronghold Cards before Mentat');
}
for (const modules of [false, true]) {
void test(`original ${modules ? 'selected-family Worlds/all14/phase' : 'Ecaz'} typed Emperor Betrayal allows a native return or actual earned Karama cancellation`, () => {
const f = createEcazBetrayalModulesFixture({ program: 'typed', advanced: true,
expansions: modules ? ['ix', 'choam', 'ecaz'] : ['ecaz'], homeworlds: modules,
skills: modules, tech: modules, strongholds: modules });
assert.equal(f.game.turn, 3);
assert.equal(f.game.phase, 5);
assert.ok(f.game.players.every(player => !player.shipped && player.moved === 0));
assert.equal(f.acquired.bid.actor, 'ecaz');
assert.equal(f.acquired.bid.action.amount, 1);
assert.ok(player(f.game, 'ecaz').hand.some(card => card.id === f.karama));
assert.equal(f.afterDraw.nexusCards!.cards!.hands.guild, 'ecaz');
assert.equal(player(f.afterDraw, 'ecaz').ally, 'emperor');
assert.equal(player(f.afterDraw, 'emperor').ally, 'ecaz');
assert.equal(player(f.afterDraw, 'guild').ally, null);
assert.deepEqual(f.row.total, { normal: 1, elite: 2 });
assert.equal(player(f.game, 'ecaz').forces[f.location], 6, 'The original six-counter Ecaz setup remains in Imperial Basin');
assert.equal(player(f.game, 'emperor').forces[f.location], 3);
assert.equal(player(f.game, 'emperor').elites!.forces[f.location], 2);
const before = player(f.game, 'emperor');
for (const cancel of [false, true]) {
const { game, declared } = resolveEcazBetrayalModules(f, cancel);
assert.equal(declared.response?.kind, 'nexusEcazBetrayal');
assert.deepEqual(player(declared, 'emperor').forces, before.forces, 'The declared return waits for real counters');
const after = player(game, 'emperor');
completion(f, game, cancel);
assert.equal(after.forces[f.location] ?? 0, cancel ? 3 : 0);
assert.equal(after.elites!.forces[f.location] ?? 0, cancel ? 2 : 0);
assert.equal(after.reserves, before.reserves + (cancel ? 0 : 3));
assert.equal(after.elites!.reserves, before.elites!.reserves + (cancel ? 0 : 2));
assert.equal(after.tanks, before.tanks);
assert.equal(after.elites!.tanks, before.elites!.tanks);
if (modules) {
const k = 'homeworld:emperor', s = 'homeworld:emperor:salusa';
assert.deepEqual(pool(game, 'emperor', k), { normal: pool(f.game, 'emperor', k).normal + (cancel ? 0 : 1),
elite: pool(f.game, 'emperor', k).elite });
assert.deepEqual(pool(game, 'emperor', s), { normal: pool(f.game, 'emperor', s).normal,
elite: pool(f.game, 'emperor', s).elite + (cancel ? 0 : 2) });
}
}
});
}
void test('original Richese zero/three/five markers recall face down under Ecaz Betrayal; actual Karama preserves each concealed deployment', () => {
const rows: unknown[] = [];
for (const value of [0, 3, 5] as const) {
const f = createEcazBetrayalModulesFixture({ program: 'richese', marker: value, advanced: true,
expansions: ['choam', 'ecaz'], homeworlds: true, skills: true, tech: true, strongholds: true });
const before = player(f.game, 'richese'), marker = structuredClone(before.noField!);
const shipment = f.shipments.at(-1)!;
assert.equal(shipment.step.action.noField, f.marker, 'The selected original token arrives through its paid own shipment');
assert.equal(player(shipment.after, 'richese').noField!.deployed!.tokenId, f.marker);
assert.equal(player(f.cooccupied, 'richese').noField!.deployed!.tokenId, f.marker);
assert.ok(f.actions.every(step => step.action.type !== 'offerRicheseNoField'),
'The continuation declines a separate optional allied shipment, whose acceptance would reveal the existing marker');
assert.equal(marker.deployed!.tokenId, f.marker);
assert.equal(marker.tokens.find(token => token.id === f.marker)!.value, value);
assert.equal(before.forces[f.location] ?? 0, 0, 'The genuine concealed marker has not materialized counters');
assert.equal(player(f.game, 'ecaz').forces[f.location], 6);
assert.deepEqual(f.row.total, { normal: 0, elite: 0 });
assert.equal('concealed' in f.row && f.row.concealed, true);
assert.ok(!JSON.stringify(f.row).includes(f.marker!), 'Only public concealed presence enters the target row');
rows.push(structuredClone(f.row));
for (const cancel of [false, true]) {
const { game } = resolveEcazBetrayalModules(f, cancel), after = player(game, 'richese');
completion(f, game, cancel);
assert.equal(after.reserves, before.reserves, 'A face-down recall neither materializes nor returns hidden counters');
assert.equal(after.tanks, before.tanks);
assert.deepEqual(after.forces, before.forces);
assert.deepEqual(game.homeworlds, f.game.homeworlds, 'A marker-only recall changes no native physical pool');
if (cancel) assert.deepEqual(after.noField, marker);
else {
assert.equal(after.noField!.deployed, null);
assert.equal(after.noField!.lastShipped, f.marker, 'The original repeat ban survives a hidden return');
assert.equal(projectRicheseNoField(after.noField!, false).lastUsed, null, 'Recalling face down reveals no denomination');
assert.throws(() => deployRicheseNoField(after.noField!, marker.deployed!), /same No-Field|twice|row/i);
}
}
}
assert.deepEqual(rows[1], rows[0]);
assert.deepEqual(rows[2], rows[0], 'All three original denominations have the same public target projection');
});
void test('original Bene Gesserit advisors return completely from Ecaz cooccupation while an actual outside group and stance remain unchanged', () => {
const f = createEcazBetrayalModulesFixture({ program: 'advisors', advanced: true,
expansions: ['ix', 'choam', 'ecaz'], homeworlds: true, skills: true, tech: true, strongholds: true });
const before = player(f.game, f.ally);
assert.equal(before.forces[f.location], 2, 'One original setup advisor and one paid advisor shipment share Imperial Basin');
assert.ok(before.advisors?.[f.target]);
assert.deepEqual(f.row.total, { normal: 2, elite: 0 });
for (const cancel of [false, true]) {
const { game } = resolveEcazBetrayalModules(f, cancel), after = player(game, f.ally);
completion(f, game, cancel);
assert.equal(after.reserves, before.reserves + (cancel ? 0 : 2));
assert.equal(after.forces[f.location] ?? 0, cancel ? 2 : 0);
assert.equal(after.tanks, before.tanks);
assert.equal(!!after.advisors?.[f.target], cancel);
assert.deepEqual(after.advisors?.arrakeen, before.advisors?.arrakeen);
}
});
void test('actual parent-to-HMS Ecaz movement admits original mixed Ix garrison return without moving the structure or consuming its queue', () => {
const f = createEcazBetrayalModulesFixture({ program: 'hms', advanced: true,
expansions: ['ix', 'ecaz'], homeworlds: true, skills: true, tech: true, strongholds: true });
assert.equal(f.entryMove!.action.type, 'move');
assert.equal(f.entryMove!.action.from, 'polar_sink:0');
assert.equal(player(f.game, 'ecaz').forces[MOBILE_LOCATION], 1);
const before = player(f.game, 'ixians');
assert.equal(before.forces[MOBILE_LOCATION], 6);
assert.equal(before.elites!.forces[MOBILE_LOCATION], 3);
assert.deepEqual(f.row.total, { normal: 3, elite: 3 });
for (const cancel of [false, true]) {
const { game } = resolveEcazBetrayalModules(f, cancel), after = player(game, 'ixians');
completion(f, game, cancel);
assert.equal(after.forces[MOBILE_LOCATION] ?? 0, cancel ? 6 : 0);
assert.equal(after.elites!.forces[MOBILE_LOCATION] ?? 0, cancel ? 3 : 0);
assert.equal(after.reserves, before.reserves + (cancel ? 0 : 6));
assert.equal(after.elites!.reserves, before.elites!.reserves + (cancel ? 0 : 3));
assert.equal(after.tanks, before.tanks);
assert.equal(after.elites!.tanks, before.elites!.tanks);
assert.deepEqual(game.mobileStronghold, f.game.mobileStronghold);
assert.equal(player(game, 'ecaz').forces[MOBILE_LOCATION], 1);
}
});
void test('all four minimal policies propose original Ecaz Betrayal for typed and concealed-only cooccupation', () => {
for (const program of ['typed', 'richese'] as const) {
const report = smokeEcazBetrayalModules({ program, marker: 0, advanced: true,
homeworlds: true, expansions: program === 'richese' ? ['choam', 'ecaz'] : ['ecaz'] });
assert.equal(report.length, 4);
assert.ok(report.every(result => result.completed && result.phase === 5 && result.actions > 0));
}
});
+226
View File
@@ -0,0 +1,226 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, splitLocation, territory } from '../game/board';
import { nativeShipmentSources } from '../game/homeworld-options';
import type { NoFieldValue } from '../game/richese-no-field';
import type { SelectedIndependentEntryOptions } from './fixture-selected-independent-nexus-entry';
import {
actE3IndependentDuke, e3IndependentDukeClean, nextE3IndependentDukeStep,
} from './fixture-e3-independent-nexus-duke';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
export type EcazBetrayalModulesStep = { actor: string; action: Action };
export type EcazBetrayalModulesOptions = Omit<SelectedIndependentEntryOptions, 'factions' | 'expansions'> & {
expansions?: Game['expansions'];
program?: 'typed' | 'richese' | 'advisors' | 'hms';
marker?: NoFieldValue;
};
export const ecazBetrayalModulesClean = e3IndependentDukeClean;
export const ecazBetrayalModulesPlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export function actEcazBetrayalModules(game: Game, actor: string, action: Action,
actions?: EcazBetrayalModulesStep[]): Game {
return actE3IndependentDuke(game, actor, action, actions);
}
export function advanceEcazBetrayalModules(game: Game, until: (state: Game) => boolean,
actions: EcazBetrayalModulesStep[] = []): Game {
for (let count = 0; count < 2600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'Original Betrayal programme ended before its next opening');
// An accepted extra allied shipment must reveal the existing marker first.
// This programme keeps its genuine own shipment concealed by declining the
// separate optional offer, rather than changing already-deployed custody.
const step: EcazBetrayalModulesStep | null = !game.response && !game.phaseOpening &&
!game.pendingTreacheryDiscard && game.decision?.kind === 'richeseAllyOpportunity'
? { actor: game.decision.player, action: { type: 'decision', decline: true } }
: nextE3IndependentDukeStep(game);
assert.ok(step, `Choose the actual ${game.decision?.kind ?? 'Battle Plan'} at ${game.turn}/${game.phase}`);
game = actEcazBetrayalModules(game, step.actor, step.action, actions);
}
throw Error('Original Ecaz Betrayal module programme did not reach its finite boundary');
}
/** Same normal host entry as the shared selected-entry helper. The two local
* setup choices are original controls: a sheltered first HMS placement and a
* BG advisor beside the original six Ecaz counters. Neither relocates a piece. */
function originalSetup(options: EcazBetrayalModulesOptions, ally: 'emperor' | 'richese' | 'beneGesserit' | 'ixians') {
const defaultExpansions: Game['expansions'] = ['ecaz',
...(ally === 'richese' ? ['choam'] as const : ally === 'ixians' ? ['ix'] as const : [])];
let game = createGame('ECAZBETRAYALMODULES', newPlayer('ecaz', 'Ecaz', 'ecaz'), options.advanced ?? true,
options.expansions ?? defaultExpansions);
for (const faction of [ally, 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
const actions: EcazBetrayalModulesStep[] = [];
const act = (actor: string, action: Action) => { game = actEcazBetrayalModules(game, actor, action, actions); };
for (const selection of ['ecazTreachery', 'nexusCards', ...(options.skills ? ['leaderSkills'] : [])])
act(game.host, { type: 'moduleOption', module: selection, enabled: true });
for (const [type, enabled] of [['homeworlds', options.homeworlds], ['techTokens', options.tech],
['strongholdCards', options.strongholds]] as const) if (enabled) act(game.host, { type, enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, game.advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
for (let count = 0; game.status === 'setup' && count < 180; count++) {
let step: EcazBetrayalModulesStep | undefined;
if (game.decision?.kind === 'mobileStronghold' && game.decision.placement)
step = { actor: game.decision.player, action: { type: 'decision', location: 'polar_sink:0' } };
else if (game.advanced && ally === 'beneGesserit' && game.setupStage === 'forces' &&
ecazBetrayalModulesPlayer(game, 'ecaz').reserves === 14 &&
!ecazBetrayalModulesPlayer(game, ally).advisorSetup && viewGame(game, ally).setupPending.includes(ally))
step = { actor: ally, action: { type: 'advisorSetup', territory: 'imperial_basin',
sector: territory('imperial_basin').sectors[0] } };
else for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) { step = { actor: player.id, action }; break; }
}
assert.ok(step, 'Original setup must expose its next owned private choice');
act(step.actor, step.action);
}
assert.equal(game.status, 'playing');
return { lobby, setup, game, actions };
}
function orderBlow(game: Game, worm = false) {
const front: Game['spiceDeck'] = [];
if (worm) {
const at = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
assert.ok(at >= 0); front.push(game.spiceDeck.splice(at, 1)[0]);
}
for (let count = 0; count < (game.advanced ? 2 : 1); count++) {
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery && card.territory !== 'imperial_basin');
assert.ok(at >= 0); front.push(game.spiceDeck.splice(at, 1)[0]);
}
game.spiceDeck.unshift(...front);
}
/** Actual paid one-spice auction, including the original optional Ixian pool.
* Desired singleton positions are selected while the original deck is unopened. */
function acquireKarama(game: Game, actions: EcazBetrayalModulesStep[]) {
assert.equal(game.phase, 2);
const at = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(at >= 0);
const card = game.deck.splice(at, 1)[0]; game.deck.unshift(card);
let bid: EcazBetrayalModulesStep | undefined;
const before = structuredClone(game);
for (let count = 0; count < 350; count++) {
if (ecazBetrayalModulesPlayer(game, 'ecaz').hand.some(held => held.id === card.id)) {
assert.ok(bid, 'Ecaz must acquire its real Karama through the actual paid auction');
return { game, card: card.id, before, bid };
}
let step: EcazBetrayalModulesStep | null = null;
if (!game.response && !game.phaseOpening && game.decision?.kind === 'ixAuction') {
const returned = game.ixAuction!.cards.find(held => held.id !== card.id); assert.ok(returned);
step = { actor: game.decision.player, action: { type: 'decision', card: returned.id, position: 'bottom' } };
} else if (ecazBetrayalModulesClean(game) && game.auction?.active === 'ecaz' &&
game.auction.cards[game.auction.index].id === card.id && game.auction.bid === 0) {
step = { actor: 'ecaz', action: { type: 'bid', amount: 1, allyPayment: 0 } }; bid = structuredClone(step);
}
step ??= nextE3IndependentDukeStep(game); assert.ok(step);
game = actEcazBetrayalModules(game, step.actor, step.action, actions);
}
throw Error('The original auction did not deliver its genuine Karama');
}
/** Original first deal, paid outside group, real worm alliance/closing draw,
* and original cooccupation. Return before turn-three first Shipment actions.
* Marker values select only an owner's existing physical token; no concealment,
* force, stance, receipt, wallet or historical ownership is assigned. */
export function createEcazBetrayalModulesFixture(options: EcazBetrayalModulesOptions = {}) {
const program = options.program ?? 'typed';
const ally = program === 'richese' ? 'richese' : program === 'advisors' ? 'beneGesserit' :
program === 'hms' ? 'ixians' : 'emperor';
const source = withClassicDiscoveryNexusLottery(() => originalSetup(options, ally));
let game = structuredClone(source.game);
const afterSetup = structuredClone(game), actions = [...source.actions];
const staging = ['Use original host/setup controls and only unopened land, worm, Karama and Ecaz Nexus card ordering; all paid/typed placements remain original actions.'];
const act = (actor: string, action: Action) => { game = actEcazBetrayalModules(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceEcazBetrayalModules(game, until, actions); };
const shipments: { before: Game; step: EcazBetrayalModulesStep; after: Game }[] = [];
const ship = (actor: string, place: string, sector: number, amount: number, elite: number, field?: string) => {
const homeworldSources = game.homeworlds && !field ? nativeShipmentSources(viewGame(game, actor), amount, elite) : undefined;
assert.ok(!game.homeworlds || field || homeworldSources);
const step: EcazBetrayalModulesStep = { actor, action: { type: 'ship', territory: place, sector,
...(field ? { noField: field, event: ecazBetrayalModulesPlayer(game, actor).noFieldEvent }
: { amount, elite, ...(homeworldSources ? { homeworldSources } : {}) }), allyPayment: 0 } };
const before = structuredClone(game); act(actor, step.action); reach(ecazBetrayalModulesClean);
shipments.push({ before, step, after: structuredClone(game) });
};
orderBlow(game);
reach(state => state.phase === 2 && ecazBetrayalModulesClean(state));
const acquired = acquireKarama(game, actions); game = acquired.game;
reach(state => state.phase === 5 && state.active === ally && ecazBetrayalModulesClean(state));
ship(ally, 'arrakeen', 10, 1, 0);
reach(state => state.turn === 2 && state.phase === 1 && ecazBetrayalModulesClean(state));
const afterFirstTurn = structuredClone(game);
orderBlow(game, true);
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && ecazBetrayalModulesClean(state));
const beforeAlliance = structuredClone(game);
act('ecaz', { type: 'alliance', target: ally }); act(ally, { type: 'alliance', target: 'ecaz' });
reach(state => state.nexusCards?.phase?.stage === 'drawing' && ecazBetrayalModulesClean(state));
const cards = game.nexusCards!.cards!, at = cards.deck.indexOf('ecaz'); assert.ok(at >= 0);
cards.deck.unshift(cards.deck.splice(at, 1)[0]);
const beforeDraw = structuredClone(game), draw: EcazBetrayalModulesStep = { actor: 'guild', action: {
type: 'nexusCardChoice', turn: game.turn, card: cards.hands.guild, choice: 'draw', ownRedraws: 0,
} };
act(draw.actor, draw.action);
const afterDraw = structuredClone(game);
const target = program === 'hms' ? MOBILE_STRONGHOLD : 'imperial_basin';
const location = program === 'hms' ? MOBILE_LOCATION : `imperial_basin:${territory('imperial_basin').sectors[0]}`;
let marker: string | undefined, entryMove: EcazBetrayalModulesStep | undefined;
if (program === 'hms') {
reach(state => state.phase === 5 && state.active === 'ecaz' && ecazBetrayalModulesClean(state));
const pointer = game.mobileStronghold!.location!; assert.equal(pointer, 'polar_sink:0');
const parent = splitLocation(pointer); ship('ecaz', parent.territory, parent.sector, 1, 0);
entryMove = { actor: 'ecaz', action: { type: 'move', from: pointer, amount: 1, elite: 0,
territory: MOBILE_STRONGHOLD, sector: 0 } };
act(entryMove.actor, entryMove.action); reach(ecazBetrayalModulesClean);
} else {
reach(state => state.phase === 5 && state.active === ally && ecazBetrayalModulesClean(state));
if (program === 'richese') {
marker = ecazBetrayalModulesPlayer(game, ally).noField!.tokens.find(token => token.value === (options.marker ?? 3))!.id;
ship(ally, target, territory(target).sectors[0], 1, 0, marker);
} else ship(ally, target, territory(target).sectors[0], program === 'typed' ? 3 : 1,
program === 'typed' && game.advanced ? 2 : 0);
}
const cooccupied = structuredClone(game);
reach(state => state.turn === 3 && state.phase === 1 && ecazBetrayalModulesClean(state));
orderBlow(game);
reach(state => state.turn === 3 && state.phase === 5 && ecazBetrayalModulesClean(state));
const offer = viewGame(game, 'guild').nexusEcazBetrayal;
assert.ok(offer && !offer.blocked, offer?.blocked ?? 'The genuine closing card needs a live first-Shipment offer');
const row = offer.territories.find(row => row.territory === target); assert.ok(row);
const action: Action = { type: 'nexusEcazBetrayal', event: offer.event, territory: target };
return { ...source, afterSetup, afterFirstTurn, acquired, beforeAlliance, beforeDraw, draw, afterDraw,
cooccupied, game, actions, staging, shipments, program, ally, target, location, marker, entryMove,
owner: 'guild', counter: 'ecaz', karama: acquired.card, offer, row, action };
}
export type EcazBetrayalModulesFixture = ReturnType<typeof createEcazBetrayalModulesFixture>;
/** Resolve actual Karama/allowance response queues without consuming Shipment
* or Movement. The counter card was paid for in the original first auction. */
export function resolveEcazBetrayalModules(fixture: EcazBetrayalModulesFixture, cancel = false) {
const actions: EcazBetrayalModulesStep[] = [];
let game = actEcazBetrayalModules(structuredClone(fixture.game), fixture.owner, fixture.action, actions);
const declared = structuredClone(game);
if (cancel) game = actEcazBetrayalModules(game, fixture.counter,
{ type: 'card', card: fixture.karama, mode: 'cancel' }, actions);
game = advanceEcazBetrayalModules(game, ecazBetrayalModulesClean, actions);
return { game, declared, actions };
}
/** Finite minimal-legal policy smoke; every candidate comes from its owner's
* actual view and is accepted through the original response continuation. */
export function smokeEcazBetrayalModules(options: EcazBetrayalModulesOptions = {}) {
const fixture = createEcazBetrayalModulesFixture(options);
return (['Easy', 'Medium', 'Hard', 'Brutal'] as const).map(difficulty => {
const view = viewGame(fixture.game, fixture.owner);
view.players.find(player => player.id === fixture.owner)!.bot = difficulty;
const action = botActions(view).find(action => action.type === 'nexusEcazBetrayal' && action.territory === fixture.target);
assert.ok(action, `${difficulty} must retain the original opening return action`);
const actions: EcazBetrayalModulesStep[] = [];
const game = advanceEcazBetrayalModules(actEcazBetrayalModules(structuredClone(fixture.game), fixture.owner, action, actions),
ecazBetrayalModulesClean, actions);
return { difficulty, actions: actions.length, phase: game.phase, active: game.active,
completed: game.nexusEcazBetrayalHistory!.at(-1)!.stage === 'complete' };
});
}
+238
View File
@@ -0,0 +1,238 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { nativeShipmentSources } from '../game/homeworld-options';
import { createIndependentCardModulesFixture, type IndependentCardModulesOptions, type IndependentCardModulesStep } from './fixture-independent-card-modules';
import { nextPairedChoamSkillsStep } from './fixture-paired-choam-nexus-skills';
import { applyClassicDiscoveryNexusStep, withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
export type AtomicsModulesStep = IndependentCardModulesStep;
export type AtomicsModulesOptions = Pick<IndependentCardModulesOptions, 'advanced' | 'skills' | 'homeworlds' | 'tech' | 'strongholds' | 'expansions'>;
export const atomicsModulesPlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const atomicsModulesClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingGuildBetrayal &&
!game.pendingNexusIxianReplacement && !game.pendingNexusIxianBetrayal && !game.pendingNexusHarkonnenBetrayal;
export function actAtomicsModules(game: Game, actor: string, action: Action, actions?: AtomicsModulesStep[]): Game {
return applyClassicDiscoveryNexusStep(game, { actor, action }, actions);
}
function nextAtomicsModules(game: Game): AtomicsModulesStep | null {
if (game.pendingGuildBetrayal) {
const pending = game.pendingGuildBetrayal, actor = pending.required.find(actor => !pending.passed.includes(actor)); assert.ok(actor);
return { actor, action: { type: 'guildBetrayalPass', event: pending.invoice.event } };
}
if (game.pendingNexusIxianReplacement) return { actor: game.pendingNexusIxianReplacement.buyer,
action: { type: 'nexusIxianReplacementPass', event: game.pendingNexusIxianReplacement.event } };
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
const decision = game.decision;
if (decision?.kind === 'moritaniTerror' || decision?.kind === 'ecazAmbassador') return null;
if (decision?.kind === 'moritaniPlacement' || decision?.kind === 'ecazPlacement')
return { actor: decision.player, action: { type: 'decision', decline: true } };
if (decision?.kind === 'guildTiming') return { actor: decision.player, action: { type: 'decision', take: false } };
if (decision?.kind === 'advisor') return { actor: decision.player,
action: { type: 'decision', accept: game.turn === 1 && decision.destination === 'carthag:11', amount: 1, accompany: true } };
if (decision?.kind === 'intrusion') return { actor: decision.player, action: { type: 'decision', accept: false } };
if (decision?.kind === 'homeworldDefense') return { actor: decision.player,
action: { type: 'decision', event: decision.event, use: false } };
if (decision?.kind === 'homeworldRevivalDeployment' || decision?.kind === 'caladanReinforcement' || decision?.kind === 'grummanCollection')
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
if (decision?.kind === 'richeseUnbid') return { actor: decision.player,
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
if (decision?.kind === 'techToken') return { actor: decision.player, action: { type: 'decision', token: decision.choices[0] } };
}
return nextPairedChoamSkillsStep(game);
}
export function advanceAtomicsModules(game: Game, until: (state: Game) => boolean,
actions: AtomicsModulesStep[] = []): Game {
for (let count = 0; count < 2400; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The actual Atomics programme ended before its control');
const step = nextAtomicsModules(game); assert.ok(step, `Choose the original ${game.decision?.kind ?? 'sealed plan'}`);
game = actAtomicsModules(game, step.actor, step.action, actions);
}
throw Error('The original module Atomics programme did not reach its requested boundary');
}
function orderOriginalBlow(game: Game, worm = false): void {
const front: Game['spiceDeck'] = [];
if (worm) {
const index = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
assert.ok(index >= 0); front.push(game.spiceDeck.splice(index, 1)[0]);
}
for (let count = 0; count < (game.advanced ? 2 : 1); count++) {
const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery && card.territory !== 'imperial_basin');
assert.ok(index >= 0); front.push(game.spiceDeck.splice(index, 1)[0]);
}
game.spiceDeck.unshift(...front);
}
/** Actual bank lots fill the owner's printed four slots, at one spice each.
* No desired identity is put in a hand and no wallet is reassigned. */
function buyOriginalHand(game: Game, actor: string, actions: AtomicsModulesStep[]) {
const bids: Array<{ before: Game; step: AtomicsModulesStep }> = [];
for (let count = 0; count < 650; count++) {
if (atomicsModulesPlayer(game, actor).hand.length === 4) return { game, bids };
let step: AtomicsModulesStep | null = null;
if (atomicsModulesClean(game) && game.auction && !game.richeseAuction &&
game.auction.active === actor && game.auction.bid === 0) {
step = { actor, action: { type: 'bid', amount: 1, allyPayment: 0 } };
bids.push({ before: structuredClone(game), step: structuredClone(step) });
}
step ??= nextAtomicsModules(game); assert.ok(step, 'The original bank auction must expose its current bid');
game = actAtomicsModules(game, step.actor, step.action, actions);
}
throw Error('The original bank lots did not fill the actual hand');
}
function shipNative(game: Game, actor: string, territory: string, sector: number, amount: number, elite: number,
actions: AtomicsModulesStep[]) {
const sources = game.homeworlds ? nativeShipmentSources(viewGame(game, actor), amount, elite) : undefined;
assert.ok(!game.homeworlds || sources);
const before = structuredClone(game), step: AtomicsModulesStep = { actor, action: { type: 'ship', territory, sector,
amount, elite, allyPayment: 0, ...(sources ? { homeworldSources: sources } : {}) } };
game = actAtomicsModules(game, step.actor, step.action, actions);
return { before, step, game };
}
/** Original native setup, paid hand-filling auctions, real placements, a worm
* alliance and closing draw. The paired route additionally earns its one BG
* advisor through an original first-turn Guild shipment. */
function atomicsModulesProgramme(options: AtomicsModulesOptions, paired: boolean) {
const factions: FactionId[] = paired
? ['moritani', 'ecaz', 'guild', 'emperor', 'beneGesserit'] : ['moritani', 'richese', 'guild', 'emperor'];
const source = withClassicDiscoveryNexusLottery(() => createIndependentCardModulesFixture({ ...options,
expansions: options.expansions ?? (paired ? ['ecaz'] : ['ecaz', 'choam']), factions,
independent: true, nexus: true, tech: options.tech ?? false }));
let game = structuredClone(source.game);
const afterSetup = structuredClone(game), actions = [...source.steps], staging: string[] = [];
const act = (actor: string, action: Action) => { game = actAtomicsModules(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceAtomicsModules(game, until, actions); };
orderOriginalBlow(game);
reach(state => state.phase === 2 && atomicsModulesClean(state));
const first = buyOriginalHand(game, 'moritani', actions); game = first.game;
const purchases = [...first.bids];
let firstShipment: { before: Game; step: AtomicsModulesStep; after: Game } | null = null;
if (paired) {
reach(state => state.phase === 5 && state.active === 'guild' && atomicsModulesClean(state));
const advisorsBefore = atomicsModulesPlayer(game, 'beneGesserit').forces['carthag:11'] ?? 0;
const shipment = shipNative(game, 'guild', 'carthag', 11, 1, 0, actions); game = shipment.game;
reach(atomicsModulesClean);
firstShipment = { before: shipment.before, step: shipment.step, after: structuredClone(game) };
assert.equal(atomicsModulesPlayer(game, 'guild').forces['carthag:11'], 1);
assert.equal(atomicsModulesPlayer(game, 'beneGesserit').forces['carthag:11'], advisorsBefore + 1);
assert.ok(atomicsModulesPlayer(game, 'beneGesserit').advisors?.carthag);
}
reach(state => state.turn === 1 && !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement');
const atomics = game.moritaniTerror!.tokens.find(token => token.kind === 'atomics' && token.status === 'available'); assert.ok(atomics);
const beforePlacement = structuredClone(game);
act('moritani', { type: 'decision', token: atomics.id, territory: 'carthag' });
reach(state => state.turn === 2 && state.phase === 1 && atomicsModulesClean(state));
orderOriginalBlow(game, true);
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && atomicsModulesClean(state));
const ally = paired ? 'ecaz' : 'guild', holder = paired ? 'emperor' : 'richese';
const beforeAlliance = structuredClone(game);
act('moritani', { type: 'alliance', target: ally }); act(ally, { type: 'alliance', target: 'moritani' });
reach(state => state.nexusCards?.phase?.stage === 'drawing' && atomicsModulesClean(state));
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('tleilaxu'); assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
const beforeDraw = structuredClone(game), drawStep: AtomicsModulesStep = { actor: holder,
action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[holder], choice: 'draw', ownRedraws: 0 } };
act(drawStep.actor, drawStep.action);
const afterDraw = structuredClone(game);
reach(state => state.phase === 2 && atomicsModulesClean(state));
const second = buyOriginalHand(game, ally, actions); game = second.game; purchases.push(...second.bids);
// Ecaz places after Revival, before Shipment; the first-turn opportunity
// preceded the original Guild accompaniment. Use the later earned opening
// after the real worm alliance and bank purchases, while Atomics is already
// on the board, rather than skipping the first Mentat in search of Ecaz.
let ambassador: string | null = null;
if (paired) {
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
const token = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz' && token.zone === 'supply'); assert.ok(token);
ambassador = token.id;
act('ecaz', { type: 'decision', token: token.id, territory: 'carthag' });
}
staging.push('Only unopened original Spice and Nexus order is selected. Six paid one-spice bank bids fill the actual Moritani and activation-ally hands; no card, force, wallet or token is staged.');
return { source, game, afterSetup, actions, staging, firstShipment, beforePlacement, atomics: atomics.id,
ambassador, ally, holder, beforeAlliance, draw: { before: beforeDraw, step: drawStep, after: afterDraw }, purchases };
}
/** Concealed native Richese 3-marker uses its real paid shipment and Guild
* acknowledgement. Atomics has not yet revealed/materialized or killed it. */
export function createNoFieldAtomicsModulesFixture(options: AtomicsModulesOptions = {}) {
const programme = atomicsModulesProgramme(options, false), actions = programme.actions;
let game = advanceAtomicsModules(programme.game, state => state.phase === 5 && state.active === 'richese' && atomicsModulesClean(state), actions);
const richese = atomicsModulesPlayer(game, 'richese'), token = richese.noField!.tokens.find(token => token.value === 3); assert.ok(token);
const beforeEntry = structuredClone(game), entry: AtomicsModulesStep = { actor: 'richese', action: { type: 'ship',
noField: token.id, event: richese.noFieldEvent, territory: 'carthag', sector: 11, allyPayment: 0 } };
game = actAtomicsModules(game, entry.actor, entry.action, actions);
game = advanceAtomicsModules(game, state => !state.phaseOpening && !state.response && state.decision?.kind === 'moritaniTerror', actions);
assert.ok(atomicsModulesPlayer(game, 'richese').noField!.deployed);
return { ...programme, game, actions, beforeEntry, entry, noField: token.id };
}
/** Paired native Ambassador/Terror arrival queue with a real ordinary Guild
* counter, real BG advisor and the Emperor's typed paid incoming army. */
export function createPairedAtomicsModulesFixture(options: AtomicsModulesOptions = {}) {
const programme = atomicsModulesProgramme({ ...options, advanced: true }, true), actions = programme.actions;
let game = advanceAtomicsModules(programme.game, state => state.phase === 5 && state.active === 'emperor' && atomicsModulesClean(state), actions);
const arrival = shipNative(game, 'emperor', 'carthag', 11, 3, 2, actions); game = arrival.game;
game = advanceAtomicsModules(game, state => !state.phaseOpening && !state.response && !!state.pendingArrivalOverlap, actions);
return { ...programme, game, actions, beforeEntry: arrival.before, entry: arrival.step };
}
/** The live native arrival order chooses which original child is first. After
* destruction, its surviving parent resumes once, without a second invoice. */
export function finishAtomicsModulesReveal(game: Game, actions: AtomicsModulesStep[] = []) {
const seen: string[] = [];
let beforeActivation: Game | null = null;
for (let count = 0; count < 200; count++) {
if (game.moritaniAtomics && !game.pendingArrivalOverlap && !game.pendingAmbassador && !game.pendingTerrorEntry && atomicsModulesClean(game)) {
assert.ok(beforeActivation); return { game, beforeActivation, seen };
}
let step: AtomicsModulesStep | null = null;
if (!game.response && !game.phaseOpening && game.decision?.kind === 'ecazAmbassador') {
seen.push('ambassador'); step = { actor: game.decision.player,
action: { type: 'decision', event: game.pendingAmbassador!.event, decline: true } };
}
if (!game.response && !game.phaseOpening && game.decision?.kind === 'moritaniTerror') {
assert.equal(game.pendingTerrorEntry!.stage, 'offer');
beforeActivation = structuredClone(game); seen.push('atomics');
step = { actor: game.decision.player, action: { type: 'decision', reveal: true } };
}
step ??= nextAtomicsModules(game); assert.ok(step, 'The original arrival child needs its owned control');
game = actAtomicsModules(game, step.actor, step.action, actions);
}
throw Error('The original Atomics reveal did not settle its native arrival queue');
}
export function openLaterAtomicsModulesNexus(game: Game, actions: AtomicsModulesStep[] = []): Game {
const nextTurn = game.turn + 1;
game = advanceAtomicsModules(game, state => state.turn === nextTurn && state.phase === 1 && atomicsModulesClean(state), actions);
orderOriginalBlow(game, true);
return advanceAtomicsModules(game, state => state.nexus && !state.spiceWindow && !state.spiceResolution && atomicsModulesClean(state), actions);
}
/** A later real Sneak Attack placement, followed by a permitted paid shipment
* to a different territory and ordinary movement into Aftermath. No clocks,
* counters, resources, movement flags or pending Terror frames are assigned. */
export function createAtomicsAftermathSneakContinuation(game: Game, actions: AtomicsModulesStep[] = []) {
const activationTurn = game.turn;
game = advanceAtomicsModules(game, state => state.turn === activationTurn && !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement', actions);
const token = game.moritaniTerror!.tokens.find(token => token.kind === 'sneakAttack' && token.status === 'available'); assert.ok(token);
game = actAtomicsModules(game, 'moritani', { type: 'decision', token: token.id, territory: 'carthag' }, actions);
game = advanceAtomicsModules(game, state => state.turn === activationTurn + 1 && state.phase === 1 && atomicsModulesClean(state), actions);
orderOriginalBlow(game);
game = advanceAtomicsModules(game, state => state.phase === 5 && state.active === 'emperor' && atomicsModulesClean(state), actions);
const blockedSources = game.homeworlds ? nativeShipmentSources(viewGame(game, 'emperor'), 1, 0) : undefined;
assert.ok(!game.homeworlds || blockedSources);
const beforeShipment = structuredClone(game), blockedShipment: Action = { type: 'ship', territory: 'carthag', sector: 11,
amount: 1, elite: 0, allyPayment: 0, ...(blockedSources ? { homeworldSources: blockedSources } : {}) };
const arrival = shipNative(game, 'emperor', 'imperial_basin', 11, 1, 0, actions); game = arrival.game;
game = advanceAtomicsModules(game, atomicsModulesClean, actions);
const beforeMove = structuredClone(game), movement: AtomicsModulesStep = { actor: 'emperor', action: { type: 'move',
from: 'imperial_basin:11', amount: 1, territory: 'carthag', sector: 11 } };
game = actAtomicsModules(game, movement.actor, movement.action, actions);
game = advanceAtomicsModules(game, state => !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniTerror', actions);
game = actAtomicsModules(game, 'moritani', { type: 'decision', reveal: true }, actions);
game = advanceAtomicsModules(game, state => !state.response && !state.phaseOpening && state.pendingTerrorEntry?.stage === 'sneakAttack', actions);
const beforeSneak = structuredClone(game);
game = actAtomicsModules(game, 'moritani', { type: 'decision', amount: 1 }, actions);
game = advanceAtomicsModules(game, atomicsModulesClean, actions);
return { game, actions, beforeShipment, blockedShipment, beforeMove, movement, beforeSneak, token: token.id };
}
+132
View File
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { territory } from '../game/board';
import { nativeShipmentSources } from '../game/homeworld-options';
import { nexusMoritaniAction } from '../game/nexus-moritani-options';
import { validateNexusMoritani } from '../game/nexus-moritani';
import { nexusMoritaniRecordSignature } from '../game/terror-location';
import {
createE3IndependentNexusCardSetup, e3IndependentNexusCardPlayer as player,
type E3IndependentNexusCardOptions,
} from './fixture-e3-independent-nexus-cards';
import {
actAtomicsModules, advanceAtomicsModules, atomicsModulesClean,
finishAtomicsModulesReveal, type AtomicsModulesStep,
} from './fixture-moritani-atomics-modules';
export type NexusMoritaniAtomicsOptions = E3IndependentNexusCardOptions;
export const NEXUS_ATOMICS_TERRITORY = 'pasty_mesa';
/** Only unopened original Spice Cards are ordered. In particular, the real
* six-counter Moritani start is never exposed to a selected worm or sand blow. */
function orderOriginalBlow(game: Game, worm = false): void {
const front: Game['spiceDeck'] = [];
if (worm) {
const index = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
assert.ok(index >= 0); front.push(game.spiceDeck.splice(index, 1)[0]);
}
for (let count = 0; count < (game.advanced ? 2 : 1); count++) {
const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery &&
!['pasty_mesa', 'tueks_sietch'].includes(card.territory));
assert.ok(index >= 0); front.push(game.spiceDeck.splice(index, 1)[0]);
}
game.spiceDeck.unshift(...front);
}
function nativeShipment(game: Game, destination: string, sector: number, amount: number): Action {
const sources = game.homeworlds ? nativeShipmentSources(viewGame(game, 'guild'), amount, 0) : undefined;
assert.ok(!game.homeworlds || sources);
return { type: 'ship', territory: destination, sector, amount, elite: 0, allyPayment: 0,
...(sources ? { homeworldSources: sources } : {}) };
}
/** Fresh selected E3/independent/Nexus setup, real closing draw and own
* Moritani Cunning. No dealt hand, spice, army, token location or receipt is
* assigned. The desert is Moritani's original physical starting territory. */
export function createNexusMoritaniAtomicsFixture(options: NexusMoritaniAtomicsOptions = {}) {
const source = createE3IndependentNexusCardSetup(options), actions = source.actions;
let game = structuredClone(source.game);
const reach = (until: (state: Game) => boolean) => { game = advanceAtomicsModules(game, until, actions); };
const act = (actor: string, action: Action) => { game = actAtomicsModules(game, actor, action, actions); };
orderOriginalBlow(game);
reach(state => state.turn === 2 && state.phase === 1 && atomicsModulesClean(state));
orderOriginalBlow(game, true);
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && atomicsModulesClean(state));
const beforeAlliance = structuredClone(game);
act('guild', { type: 'alliance', target: 'emperor' });
act('emperor', { type: 'alliance', target: 'guild' });
reach(state => state.nexusCards?.phase?.stage === 'drawing' && atomicsModulesClean(state));
const deck = game.nexusCards!.cards!.deck, index = deck.indexOf('moritani'); assert.ok(index >= 0);
deck.unshift(deck.splice(index, 1)[0]);
const drawBefore = structuredClone(game), drawStep: AtomicsModulesStep = { actor: 'moritani', action: {
type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } };
act(drawStep.actor, drawStep.action);
const drawAfter = structuredClone(game);
// Basic cannot use the Advanced three-territory flight from the original
// Tuek's army. Earn a separate adjacent counter with an earlier paid native
// shipment; the original five remain at Tuek's throughout the Basic scene.
let movementSupply: { before: Game; step: AtomicsModulesStep; after: Game } | null = null;
if (!game.advanced) {
reach(state => state.turn === 2 && state.phase === 5 && state.active === 'guild' && atomicsModulesClean(state));
const before = structuredClone(game), step: AtomicsModulesStep = {
actor: 'guild', action: nativeShipment(game, 'south_mesa', 6, 1) };
act(step.actor, step.action); reach(atomicsModulesClean);
assert.equal(player(game, 'guild').forces['south_mesa:6'], 1);
assert.equal(player(game, 'guild').forces['tueks_sietch:5'], 5);
movementSupply = { before, step, after: structuredClone(game) };
}
reach(state => state.turn === 2 && !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement');
const atomics = game.moritaniTerror!.tokens.find(token => token.kind === 'atomics' && token.status === 'available'); assert.ok(atomics);
const own = viewGame(game, 'moritani'), offer = own.nexusMoritani; assert.ok(offer);
const cunning = nexusMoritaniAction(own, offer.event, atomics.id, NEXUS_ATOMICS_TERRITORY); assert.ok(cunning);
const placementBefore = structuredClone(game), placementStep: AtomicsModulesStep = { actor: 'moritani', action: cunning };
act(placementStep.actor, placementStep.action);
const placementPending = structuredClone(game);
reach(state => state.nexusMoritaniLast?.stage === 'complete' && atomicsModulesClean(state));
const placementAfter = structuredClone(game), record = game.nexusMoritaniHistory?.find(record =>
record.receipt.event === game.nexusMoritaniLast!.event && record.stage === 'complete');
assert.ok(record, 'The completed Cunning pointer must identify its original committed placement');
assert.equal(record.signature, nexusMoritaniRecordSignature(record));
validateNexusMoritani(game, record.receipt);
const receipt = structuredClone(record.receipt);
reach(state => state.turn === 3 && state.phase === 1 && atomicsModulesClean(state));
orderOriginalBlow(game);
reach(state => state.turn === 3 && state.phase === 5 && state.active === 'guild' && atomicsModulesClean(state));
assert.equal(player(game, 'moritani').forces['pasty_mesa:5'], 6);
assert.equal(player(game, 'guild').forces['tueks_sietch:5'], 5);
const sector = territory(NEXUS_ATOMICS_TERRITORY).sectors.find(sector => sector !== game.storm); assert.ok(sector !== undefined);
const beforeEntry = structuredClone(game), entry: AtomicsModulesStep = {
actor: 'guild', action: nativeShipment(game, NEXUS_ATOMICS_TERRITORY, sector, 3) };
act(entry.actor, entry.action);
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniTerror');
source.staging.push('Keep original armies; use an actual worm and reciprocal Guild/Emperor alliance to draw the unopened Moritani Nexus, then spend it on original Mentat supply Atomics in Pasty Mesa and enter with three paid Guild counters.');
return { ...source, game, actions, beforeAlliance, atomics: atomics.id, receipt, movementSupply,
draw: { before: drawBefore, step: drawStep, after: drawAfter },
placement: { before: placementBefore, step: placementStep, pending: placementPending, after: placementAfter },
beforeEntry, entry };
}
/** Finite native runtime smoke: activate desert Atomics, retain the unused
* Guild movement, then reach a later fresh shipment and provide the real
* forbidden Aftermath request. A separate original paid shipment remains legal. */
export function continueNexusMoritaniAtomics(game: Game, actions: AtomicsModulesStep[] = []) {
const activation = finishAtomicsModulesReveal(game, actions); game = activation.game;
const beforeMove = structuredClone(game), sector = territory(NEXUS_ATOMICS_TERRITORY).sectors.find(sector => sector !== game.storm);
assert.ok(sector !== undefined);
const from = game.advanced ? 'tueks_sietch:5' : 'south_mesa:6', amount = game.advanced ? 5 : 1;
assert.equal(player(game, 'guild').forces[from], amount);
const movement: AtomicsModulesStep = { actor: 'guild', action: { type: 'move', from,
amount, territory: NEXUS_ATOMICS_TERRITORY, sector } };
game = actAtomicsModules(game, movement.actor, movement.action, actions);
game = advanceAtomicsModules(game, atomicsModulesClean, actions);
const afterMove = structuredClone(game), nextTurn = game.turn + 1;
game = advanceAtomicsModules(game, state => state.turn === nextTurn && state.phase === 1 && atomicsModulesClean(state), actions);
orderOriginalBlow(game);
game = advanceAtomicsModules(game, state => state.turn === nextTurn && state.phase === 5 && state.active === 'guild' && atomicsModulesClean(state), actions);
assert.equal(player(game, 'guild').shipped, false);
const forbiddenSector = territory(NEXUS_ATOMICS_TERRITORY).sectors.find(sector => sector !== game.storm); assert.ok(forbiddenSector !== undefined);
const beforeShipment = structuredClone(game), blockedShipment = nativeShipment(game, NEXUS_ATOMICS_TERRITORY, forbiddenSector, 1);
const allowedShipment: AtomicsModulesStep = { actor: 'guild', action: nativeShipment(game, 'polar_sink', 0, 1) };
game = actAtomicsModules(game, allowedShipment.actor, allowedShipment.action, actions);
game = advanceAtomicsModules(game, atomicsModulesClean, actions);
return { game, actions, activation, beforeMove, movement, afterMove, beforeShipment, blockedShipment, allowedShipment };
}
+180
View File
@@ -0,0 +1,180 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { richeseCards } from '../game/richese-cards';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { DIFFICULTIES } from '../game/bot-profiles';
import { botActions } from '../game/bots';
import {
actAtomicsModules, atomicsModulesPlayer as player, createAtomicsAftermathSneakContinuation,
createNoFieldAtomicsModulesFixture, createPairedAtomicsModulesFixture,
finishAtomicsModulesReveal, openLaterAtomicsModulesNexus,
} from './fixture-moritani-atomics-modules';
function stocks(game: Game): void {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? [])];
assert.deepEqual(cards.map(card => card.id).sort(), [...treacheryDeck(game.expansions), ...ecazTreacheryCards(),
...(game.players.some(player => player.faction === 'richese') ? richeseCards() : [])].map(card => card.id).sort());
const nexus = game.nexusCards!.cards!;
assert.deepEqual([...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter(card => card !== null)].sort(), [...NEXUS_FACTIONS].sort());
if (game.leaderSkills) assert.deepEqual([...game.leaderSkills.deck,
...Object.values(game.leaderSkills.offers).flatMap(offer => offer.cards),
...game.leaderSkills.assignments.map(assignment => assignment.skill)].sort(), LEADER_SKILL_CARDS.map(card => card.id).sort());
}
function handLimit(game: Game, actor: string): number {
return viewGame(game, actor).players.find(player => player.id === actor)!.handLimit;
}
function reducedExactlyOnce(before: Game, after: Game, targets: readonly string[]): void {
for (const target of targets) {
const original = player(before, target).hand.map(card => card.id);
assert.equal(original.length, 4, 'The paid bank bids filled the original hand');
assert.equal(player(after, target).hand.length, 3);
assert.equal(handLimit(after, target), 3);
assert.equal(player(after, target).atomicsHandLimitPenalty, true);
assert.equal(after.discard.filter(card => original.includes(card.id)).length -
before.discard.filter(card => original.includes(card.id)).length, 1);
}
for (const other of before.players.filter(player => !targets.includes(player.id)))
assert.deepEqual(player(after, other.id).hand, other.hand);
}
for (const modules of [false, true]) void test(`${modules ? 'Advanced selected all14/World/phase' : 'Basic selected'} actual Richese marker Atomics materializes capped native reserves before casualties and retains the original Guild invoice`, () => {
const fixture = createNoFieldAtomicsModulesFixture({ advanced: modules, skills: modules, homeworlds: modules,
tech: modules, strongholds: modules, expansions: modules ? ['ecaz', 'ix', 'choam'] : ['ecaz', 'choam'] });
assert.equal(fixture.purchases.filter(bid => bid.step.actor === 'moritani').length, 3);
assert.equal(fixture.purchases.filter(bid => bid.step.actor === 'guild').length, 3);
assert.equal(fixture.draw.after.nexusCards!.cards!.hands.richese, 'tleilaxu');
assert.equal(player(fixture.game, 'richese').noField!.deployed!.tokenId, fixture.noField);
assert.equal(player(fixture.game, 'richese').forces['carthag:11'] ?? 0, 0);
assert.equal(player(fixture.game, 'richese').reserves, 20);
assert.equal(player(fixture.game, 'richese').spice, player(fixture.beforeEntry, 'richese').spice - 1);
assert.equal(player(fixture.game, 'guild').spice, player(fixture.beforeEntry, 'guild').spice + 1);
const invoice = fixture.game.nexusGuildBetrayalHistory!.at(-1)!;
assert.equal(invoice.invoice.shipper, 'richese');
assert.equal(invoice.invoice.price, 1);
assert.equal(invoice.invoice.originalReceiver, 'guild');
assert.ok(fixture.actions.some(step => step.actor === 'richese' && step.action.type === 'guildBetrayalPass' && step.action.event === invoice.invoice.event));
const ownForces = structuredClone(player(fixture.game, 'moritani').forces);
const receiptCount = fixture.game.nexusGuildBetrayalHistory!.length;
const finished = finishAtomicsModulesReveal(fixture.game, fixture.actions), game = finished.game;
assert.deepEqual(finished.seen, ['atomics']);
assert.equal(player(game, 'richese').noField!.deployed, null);
assert.equal(player(game, 'richese').noField!.lastShipped, fixture.noField);
assert.equal(player(game, 'richese').reserves, 17);
assert.equal(player(game, 'richese').tanks, player(fixture.game, 'richese').tanks + 3);
assert.equal(player(game, 'richese').forces['carthag:11'] ?? 0, 0);
assert.deepEqual(player(game, 'moritani').forces, ownForces, 'Atomics affects its actual territory, not the separate original Moritani start');
assert.deepEqual(game.moritaniAtomics, { territory: 'carthag', turn: finished.beforeActivation.turn, moritaniId: 'moritani',
allyAtActivation: 'guild', alliancePolicy: 'activation-targets' });
assert.equal(game.moritaniTerror!.tokens.find(token => token.id === fixture.atomics)!.status, 'removed');
reducedExactlyOnce(finished.beforeActivation, game, ['moritani', 'guild']);
assert.equal(game.nexusGuildBetrayalHistory!.length, receiptCount);
assert.equal(player(game, 'richese').spice, player(fixture.game, 'richese').spice);
assert.equal(player(game, 'guild').spice, player(fixture.game, 'guild').spice);
assert.equal(player(game, 'richese').shipped, true);
assert.equal(game.active, 'richese');
assert.equal(game.nexusCards!.cards!.hands.richese, 'tleilaxu');
if (modules) {
const world = viewGame(game, 'richese').homeworlds!.worlds!.find(world => world.id === 'homeworld:richese'); assert.ok(world);
assert.deepEqual(world.forces.richese, { normal: 17, elite: 0 });
}
assert.throws(() => applyAction(game, 'moritani', { type: 'decision', reveal: true }));
stocks(game);
});
void test('paired native Ecaz Atomics consumes its original arrival queue once and destroys ordinary counters, Sardaukar and actual spiritual advisors', () => {
const fixture = createPairedAtomicsModulesFixture({ skills: true, homeworlds: true, tech: true, strongholds: true });
assert.ok(fixture.firstShipment);
assert.equal(fixture.firstShipment.step.actor, 'guild');
assert.ok(fixture.actions.some(step => step.actor === 'beneGesserit' && step.action.type === 'decision' && step.action.accept === true));
const queue = structuredClone(fixture.game.pendingArrivalOverlap!); assert.ok(queue);
assert.equal(queue.entrant, 'emperor');
assert.equal(queue.territory, 'carthag');
assert.equal(player(fixture.game, 'emperor').forces['carthag:11'], 3);
assert.equal(player(fixture.game, 'emperor').elites!.forces['carthag:11'], 2);
assert.equal(player(fixture.game, 'guild').forces['carthag:11'], 1);
const advisors = player(fixture.game, 'beneGesserit').forces['carthag:11']; assert.ok(advisors >= 1);
assert.ok(player(fixture.game, 'beneGesserit').advisors?.carthag);
const invoice = fixture.game.nexusGuildBetrayalHistory!.at(-1)!;
assert.equal(invoice.invoice.shipper, 'emperor');
assert.equal(invoice.invoice.price, 3);
assert.ok(fixture.actions.some(step => step.actor === 'emperor' && step.action.type === 'guildBetrayalPass' && step.action.event === invoice.invoice.event));
const finished = finishAtomicsModulesReveal(fixture.game, fixture.actions), game = finished.game;
assert.equal(finished.seen[0], queue.first === 'terror' ? 'atomics' : 'ambassador');
assert.equal(finished.seen.filter(entry => entry === 'atomics').length, 1);
assert.ok(finished.seen.filter(entry => entry === 'ambassador').length <= 1);
if (queue.first === 'ambassador') assert.deepEqual(finished.seen, ['ambassador', 'atomics']);
assert.ok(!game.pendingArrivalOverlap && !game.pendingAmbassador && !game.pendingTerrorEntry);
assert.equal(player(game, 'emperor').tanks, player(fixture.game, 'emperor').tanks + 3);
assert.equal(player(game, 'emperor').elites!.tanks, player(fixture.game, 'emperor').elites!.tanks + 2);
assert.equal(player(game, 'guild').tanks, player(fixture.game, 'guild').tanks + 1);
assert.equal(player(game, 'beneGesserit').tanks, player(fixture.game, 'beneGesserit').tanks + advisors);
for (const actor of ['emperor', 'guild', 'beneGesserit']) assert.equal(player(game, actor).forces['carthag:11'] ?? 0, 0);
assert.equal(player(game, 'beneGesserit').advisors?.carthag, undefined);
assert.equal(game.moritaniAtomics!.allyAtActivation, 'ecaz');
reducedExactlyOnce(finished.beforeActivation, game, ['moritani', 'ecaz']);
assert.equal(handLimit(game, 'guild'), 4);
assert.equal(game.nexusGuildBetrayalHistory!.length, fixture.game.nexusGuildBetrayalHistory!.length);
assert.equal(player(game, 'emperor').spice, player(fixture.game, 'emperor').spice);
assert.equal(player(game, 'guild').spice, player(fixture.game, 'guild').spice);
stocks(game);
});
void test('module Atomics keeps its original hand targets after an actual later worm changes Moritani’s alliance', () => {
const fixture = createNoFieldAtomicsModulesFixture();
const detonation = finishAtomicsModulesReveal(fixture.game).game;
const aftermath = structuredClone(detonation.moritaniAtomics), discard = detonation.discard.map(card => card.id);
let game = openLaterAtomicsModulesNexus(detonation);
game = actAtomicsModules(game, 'moritani', { type: 'alliance', target: null });
game = actAtomicsModules(game, 'moritani', { type: 'alliance', target: 'emperor' });
game = actAtomicsModules(game, 'emperor', { type: 'alliance', target: 'moritani' });
assert.equal(player(game, 'guild').ally, null);
assert.equal(player(game, 'moritani').ally, 'emperor');
assert.equal(handLimit(game, 'moritani'), 3);
assert.equal(handLimit(game, 'guild'), 3);
assert.equal(handLimit(game, 'emperor'), 4);
assert.deepEqual(game.moritaniAtomics, aftermath);
assert.deepEqual(game.discard.map(card => card.id), discard);
assert.equal(player(game, 'richese').tanks, player(detonation, 'richese').tanks);
});
for (const modules of [false, true]) void test(`${modules ? 'Advanced all14/World/phase' : 'Basic'} Aftermath rejects a genuine new shipment while original adjacent movement and the later placed Sneak Attack remain available`, () => {
const fixture = createNoFieldAtomicsModulesFixture({ advanced: modules, skills: modules, homeworlds: modules,
tech: modules, strongholds: modules, expansions: modules ? ['ecaz', 'ix', 'choam'] : ['ecaz', 'choam'] });
const detonation = finishAtomicsModulesReveal(fixture.game).game;
const continued = createAtomicsAftermathSneakContinuation(detonation);
assert.equal(player(continued.beforeShipment, 'emperor').shipped, false);
assert.throws(() => applyAction(continued.beforeShipment, 'emperor', continued.blockedShipment), /Atomics Aftermath/);
assert.equal(player(continued.beforeMove, 'emperor').forces['imperial_basin:11'], 1);
assert.equal(continued.movement.action.type, 'move');
assert.equal(continued.beforeSneak.pendingTerrorEntry!.stage, 'sneakAttack');
assert.equal(player(continued.game, 'emperor').forces['carthag:11'], 1);
assert.equal(player(continued.game, 'moritani').forces['carthag:11'], 1);
assert.equal(player(continued.game, 'moritani').reserves, player(continued.beforeSneak, 'moritani').reserves - 1);
assert.equal(continued.game.moritaniTerror!.tokens.find(token => token.id === continued.token)!.status, 'removed');
assert.deepEqual(continued.game.moritaniAtomics, detonation.moritaniAtomics);
assert.equal(player(continued.game, 'richese').tanks, player(detonation, 'richese').tanks);
assert.ok(!continued.game.pendingTerrorEntry);
if (modules) {
const world = (state: Game) => {
const home = viewGame(state, 'moritani').homeworlds!.worlds!.find(world => world.id === 'homeworld:moritani'); assert.ok(home);
return home.forces.moritani;
};
assert.deepEqual(world(continued.game), { normal: world(continued.beforeSneak).normal - 1, elite: 0 },
'Sneak Attack withdraws its actual ordinary counter from the original Grumman pool');
for (const difficulty of DIFFICULTIES) {
const view = viewGame(continued.beforeSneak, 'moritani');
view.players.find(player => player.id === 'moritani')!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action, `${difficulty} must expose a legal owned Sneak Attack control`);
assert.doesNotThrow(() => applyAction(continued.beforeSneak, 'moritani', action),
`${difficulty} uses the actual native World withdrawal without policy calibration`);
}
}
stocks(continued.game);
});
+51 -1
View File
@@ -84,7 +84,7 @@ void test('placement, existing Aftermath, alliance inconsistency and malformed p
/still-placed token/);
assert.throws(() => quoteMoritaniAtomics({ token, territory: 'carthag',
moritaniId: 'm', turn: 3, aftermath: null, players: [moritani] }),
/entered ordinary stronghold/);
/still-placed token/);
assert.throws(() => quote([{ ...moritani, ally: 'missing' }]), /mutual ally/);
assert.throws(() => quote([{ ...moritani, forces: { 'arrakeen:11': 1 } }]),
/valid typed forces/);
@@ -108,3 +108,53 @@ void test('a token at a storm-exposed stronghold still quotes all occupied count
[{ playerId: 'm', location: 'tueks_sietch:5', normal: 1, elite: 0, advisor: 0 }]);
assert.equal(stormExposed.aftermath.territory, 'tueks_sietch');
});
void test('an authenticated Cunning desert placement destroys every typed sector and retains its grant in Aftermath', () => {
// The engine authenticates this original event against placement history;
// the pure effect consumes that authority without creating a placement.
const nexusPlacement = 'original-cunning-placement';
const players: AtomicsPlayer[] = [
{ ...moritani, forces: { 'wind_pass:14': 2, 'wind_pass:15': 1, 'arrakeen:10': 3 } },
{ id: 'e', faction: 'emperor', ally: null, tanks: 0, handSize: 4, baseHandLimit: 4,
forces: { 'wind_pass:14': 3, 'wind_pass:15': 2, 'carthag:11': 1 },
elites: { reserves: 0, tanks: 0, revived: 0,
forces: { 'wind_pass:14': 1, 'wind_pass:15': 2 } } },
{ id: 'bg', faction: 'beneGesserit', ally: null, tanks: 0, handSize: 0, baseHandLimit: 4,
forces: { 'wind_pass:15': 1 }, advisors: { wind_pass: {} } },
];
const before = structuredClone(players);
const plan = quoteMoritaniAtomics({ token: { ...token, location: 'wind_pass' },
territory: 'wind_pass', players, moritaniId: 'm', turn: 4, aftermath: null, nexusPlacement });
assert.deepEqual(plan.casualties, [
{ playerId: 'm', location: 'wind_pass:14', normal: 2, elite: 0, advisor: 0 },
{ playerId: 'm', location: 'wind_pass:15', normal: 1, elite: 0, advisor: 0 },
{ playerId: 'e', location: 'wind_pass:14', normal: 2, elite: 1, advisor: 0 },
{ playerId: 'e', location: 'wind_pass:15', normal: 0, elite: 2, advisor: 0 },
{ playerId: 'bg', location: 'wind_pass:15', normal: 1, elite: 0, advisor: 1 },
]);
assert.deepEqual(plan.aftermath, { territory: 'wind_pass', turn: 4, moritaniId: 'm',
allyAtActivation: null, alliancePolicy: 'activation-targets', nexusPlacement });
assert.deepEqual(plan.handReductions, [{ playerId: 'm', limit: 3, randomDiscards: 1 }]);
assert.equal(atomicsShipmentBlocked(plan.aftermath, 'wind_pass'), true);
assert.equal(atomicsShipmentBlocked(plan.aftermath, 'arrakeen'), false);
assert.deepEqual(players, before, 'Quoting changes neither board counters nor Tanks.');
});
void test('Cunning Atomics requires a grant for a printed non-stronghold and cannot admit another location kind', () => {
for (const nexusPlacement of [undefined, ''])
assert.throws(() => quoteMoritaniAtomics({ token: { ...token, location: 'wind_pass' },
territory: 'wind_pass', players: [moritani], moritaniId: 'm', turn: 3,
aftermath: null, nexusPlacement }), /original Nexus placement/);
for (const territory of ['homeworld:moritani', 'hidden_mobile_stronghold', 'jacurutu-sietch', 'not-a-territory'])
assert.throws(() => quoteMoritaniAtomics({ token: { ...token, location: territory },
territory, players: [moritani], moritaniId: 'm', turn: 3,
aftermath: null, nexusPlacement: 'original-cunning-placement' }), /original Nexus placement/);
});
void test('ordinary stronghold Aftermath keeps its original JSON shape even with supplied Cunning context', () => {
const ordinary = quote([moritani]);
const withGrant = quoteMoritaniAtomics({ token, territory: 'arrakeen', players: [moritani],
moritaniId: 'm', turn: 3, aftermath: null, nexusPlacement: 'original-cunning-placement' });
assert.deepEqual(withGrant, ordinary);
assert.equal(Object.hasOwn(withGrant.aftermath, 'nexusPlacement'), false);
});
+247 -6
View File
@@ -1,10 +1,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { Game } from '../game/engine';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
import { createDiscoveryState, placeDiscovery, revealDiscoveryToken } from '../game/discoveries';
import { createHomeworldCustody, quoteHomeworldCustody } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import { reorderOrderedOpportunity } from '../game/ordered-opportunity';
import {
ecazBetrayalOffer, quoteEcazBetrayal, validateEcazBetrayalSnapshot,
ecazBetrayalModeSupported, ecazBetrayalOffer, quoteEcazBetrayal, validateEcazBetrayalSnapshot,
type EcazBetrayalSnapshot,
} from '../game/nexus-ecaz-betrayal';
import { createRicheseNoField, deployRicheseNoField, type NoFieldValue } from '../game/richese-no-field';
import { nexusFixture, nexusReload } from './fixture-nexus-cards';
/** Ordinary printed-board occupation; no other expansion or optional modules. */
@@ -77,7 +83,7 @@ void test('Ecaz Betrayal quotes one selected public territory with every typed s
assert.deepEqual(ecazBetrayalOffer(alteredPrivateAndOpponent, holder), offer);
});
void test('Ecaz Betrayal needs reciprocal native alliance, cooccupation, proper card custody and simple roster', () => {
void test('Ecaz Betrayal needs reciprocal native alliance, cooccupation, proper card custody and a supported profile', () => {
const { g, ecaz, ally, holder } = position();
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === ally)!.ally = null; }), holder)!.blocked!, /reciprocal/);
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === ecaz)!.ally = null; }), holder)!.blocked!, /reciprocal/);
@@ -85,10 +91,177 @@ void test('Ecaz Betrayal needs reciprocal native alliance, cooccupation, proper
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === ecaz)!.forces = {}; }), holder)!.blocked!, /share no territory/);
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === ally)!.forces = {}; copy.players.find(p => p.id === ally)!.elites!.forces = {}; }), holder)!.blocked!, /share no territory/);
assert.equal(ecazBetrayalOffer(editGame(g, copy => { copy.nexusCards!.cards!.hands[holder] = null; }), holder), null);
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === holder)!.faction = 'richese'; }), holder)!.blocked!, /Combined modules/);
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.homeworlds = { custody: null }; }), holder)!.blocked!, /Combined modules/);
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === ally)!.advisors = { wind_pass: {} }; }), holder)!.blocked!, /advisor/);
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.expansions.push('ix'); }), holder)!.blocked!, /Combined modules/);
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === holder)!.faction = 'richese'; }), holder)!.blocked!, /combination is unavailable/);
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.expansions.push('ix'); }), holder)!.blocked!, /combination is unavailable/);
});
void test('canonical E3 and selected-independent profiles admit original optional modules without global presence exclusions', () => {
const { g, holder, ally } = position();
g.expansions = ['ecaz'];
g.homeworlds = { custody: null };
g.techTokens = {} as NonNullable<Game['techTokens']>;
g.strongholdCards = {} as NonNullable<Game['strongholdCards']>;
assert.equal(ecazBetrayalModeSupported(g), true);
assert.equal(ecazBetrayalOffer(g, holder)!.blocked, null);
// This is a pure quote admission fixture, not a module initializer/runtime
// certificate. The original-action lane proves native physical module play.
const selected = editGame(g, copy => {
copy.expansions = ['ecaz', 'ix', 'choam'];
copy.ecazTreachery = true;
copy.players.find(p => p.id === holder)!.faction = 'choam';
copy.leaderSkills = {} as NonNullable<Game['leaderSkills']>;
});
assert.equal(ecazBetrayalModeSupported(selected), true,
'Selected decks need not seat one native from each selected family.');
assert.equal(ecazBetrayalOffer(selected, holder)!.blocked, null);
assert.equal(ecazBetrayalModeSupported(editGame(selected, copy => {
copy.players.find(p => p.id === holder)!.faction = 'harkonnen';
})), false, 'The existing Advanced actual-E3/Harkonnen/Skills boundary stays.');
const skills = editGame(g, copy => {
copy.players.find(p => p.id === holder)!.faction = 'guild';
copy.leaderSkills = {} as NonNullable<Game['leaderSkills']>;
const bg = copy.players.find(p => p.id === ally)!;
bg.faction = 'beneGesserit';
delete bg.elites;
bg.advisors = { wind_pass: { lockedTurn: copy.turn }, carthag: {} };
});
const offer = ecazBetrayalOffer(skills, holder)!;
assert.equal(offer.blocked, null);
assert.deepEqual(offer.territories[0].total, { normal: 5, elite: 0 },
'Advisor stance is public physical custody, not a reason to suppress a whole module.');
});
function markerPosition(value: NoFieldValue, onlyMarker = false) {
const fixture = position();
const { g, ally } = fixture;
g.expansions = ['ecaz', 'choam'];
g.ecazTreachery = true;
const richese = g.players.find(p => p.id === ally)!;
richese.faction = 'richese';
delete richese.elites;
if (onlyMarker) richese.forces = {};
const inventory = createRicheseNoField(['opaque-one', 'opaque-two', 'opaque-three']);
const token = inventory.tokens.find(t => t.value === value)!;
richese.noField = deployRicheseNoField(inventory, {
tokenId: token.id, controller: ally, location: { territory: 'wind_pass', sector: 14 },
});
richese.noFieldEvent = 'private-marker-generation';
return { ...fixture, token };
}
void test('marker-only and mixed public quotes are independent of hidden denomination and reserves', () => {
for (const onlyMarker of [false, true]) {
let baseline: ReturnType<typeof ecazBetrayalOffer> | undefined;
for (const value of [0, 3, 5] as const) {
const { g, ally, holder, token } = markerPosition(value, onlyMarker);
g.players.find(p => p.id === ally)!.reserves = value;
const offer = ecazBetrayalOffer(g, holder)!;
assert.equal(offer.blocked, null);
assert.equal(offer.territories[0].concealed, true);
assert.deepEqual(offer.territories[0].total, { normal: onlyMarker ? 0 : 5, elite: 0 });
if (onlyMarker) assert.deepEqual(offer.territories[0].sectors, {});
if (baseline) assert.deepEqual(offer, baseline);
baseline = offer;
const snapshot = quoteEcazBetrayal(g, holder, offer.event, 'wind_pass');
assert.deepEqual(snapshot.noField, { supplier: ally, event: 'private-marker-generation', tokenId: token.id,
location: { territory: 'wind_pass', sector: 14 } });
assert.equal(Object.hasOwn(snapshot, 'concealed'), false);
assert.equal(Object.hasOwn(snapshot.noField!, 'value'), false);
assert.equal(Object.hasOwn(snapshot.noField!, 'reserves'), false);
assert.doesNotThrow(() => validateEcazBetrayalSnapshot(g, snapshot));
assert.equal(JSON.stringify(offer).includes(token.id), false);
assert.equal(JSON.stringify(offer).includes('private-marker-generation'), false);
}
}
});
void test('pending marker recall binds exact private source while leaving denomination outside the quote', () => {
const { g, holder, ally, token } = markerPosition(5, true);
const snapshot = quoteEcazBetrayal(g, holder, ecazBetrayalOffer(g, holder)!.event, 'wind_pass');
const pending = editGame(g, copy => {
copy.nexusCards!.cards!.hands[holder] = null;
copy.nexusCards!.cards!.discard.push('ecaz');
});
assert.doesNotThrow(() => validateEcazBetrayalSnapshot(pending, snapshot, true));
const mutations: Array<(copy: Game) => void> = [
copy => { copy.players.find(p => p.id === ally)!.noFieldEvent = 'new-event'; },
copy => { copy.players.find(p => p.id === ally)!.noField!.deployed = null; },
copy => { copy.players.find(p => p.id === ally)!.noField!.deployed!.location.sector = 15; },
copy => { copy.players.find(p => p.id === ally)!.noField!.deployed!.controller = holder; },
copy => {
const state = copy.players.find(p => p.id === ally)!.noField!;
state.deployed!.tokenId = state.tokens.find(t => t.id !== token.id)!.id;
state.lastShipped = state.deployed!.tokenId;
},
];
for (const mutate of mutations)
assert.throws(() => validateEcazBetrayalSnapshot(editGame(pending, mutate), snapshot, true));
assert.throws(() => validateEcazBetrayalSnapshot(pending, { ...snapshot,
noField: { ...snapshot.noField!, supplier: holder } }, true));
const { noField: omitted, ...missingMarker } = snapshot;
assert.ok(omitted);
assert.throws(() => validateEcazBetrayalSnapshot(pending, missingMarker, true));
const unrelatedReserves = editGame(pending, copy => { copy.players.find(p => p.id === ally)!.reserves = 0; });
assert.doesNotThrow(() => validateEcazBetrayalSnapshot(unrelatedReserves, snapshot, true));
});
void test('only the target ally marker joins a quote and Ecaz still needs ordinary physical co-presence', () => {
const { g, ecaz, holder, ally } = markerPosition(3, true);
const noEcazCounters = editGame(g, copy => { copy.players.find(p => p.id === ecaz)!.forces = {}; });
assert.match(ecazBetrayalOffer(noEcazCounters, holder)!.blocked!, /share no territory/);
const unrelated = editGame(g, copy => {
copy.players.find(p => p.id === ally)!.noField!.deployed!.controller = holder;
});
assert.match(ecazBetrayalOffer(unrelated, holder)!.blocked!, /share no territory/);
});
void test('current mobile and revealed Discovery territories quote complete groups without treating Homeworlds as territories', () => {
const { g, ecaz, ally, holder } = position();
g.expansions = ['ecaz', 'ix'];
g.players.find(p => p.id === holder)!.faction = 'ixians';
g.mobileStronghold = { location: 'arrakeen:9' };
g.players.find(p => p.id === ecaz)!.forces[MOBILE_LOCATION] = 1;
g.players.find(p => p.id === ally)!.forces[MOBILE_LOCATION] = 2;
const mobileOffer = ecazBetrayalOffer(g, holder)!;
assert.equal(mobileOffer.blocked, null);
assert.deepEqual(mobileOffer.territories.find(row => row.territory === MOBILE_STRONGHOLD), {
territory: MOBILE_STRONGHOLD, sectors: { [MOBILE_LOCATION]: { normal: 2, elite: 0 } },
total: { normal: 2, elite: 0 },
});
const mobileSnapshot = quoteEcazBetrayal(g, holder, mobileOffer.event, MOBILE_STRONGHOLD);
assert.doesNotThrow(() => validateEcazBetrayalSnapshot(g, mobileSnapshot));
// Build a real pure Discovery placement/reveal; this is a quote boundary,
// not a claim that a synthetic game has played the intervening spice blow.
g.discoveryEnabled = true;
const supply = createDiscoveryState(() => 0.25);
const hiereg = supply.tokens.filter(token => token.type === 'hiereg');
const chosen = hiereg.findIndex(token => token.face === 'jacurutu-sietch');
assert.ok(chosen >= 0);
const placed = placeDiscovery(supply, 'discovery-hagga-basin', () => (chosen + 0.25) / hiereg.length);
const hidden = placed.tokens.find(token => token.face === 'jacurutu-sietch')!;
g.discoveries = revealDiscoveryToken(placed, hidden.id, g.turn).state;
g.players.find(p => p.id === ecaz)!.forces['jacurutu-sietch:0'] = 1;
g.players.find(p => p.id === ally)!.forces['jacurutu-sietch:0'] = 3;
const discoveryOffer = ecazBetrayalOffer(g, holder)!;
assert.equal(discoveryOffer.blocked, null);
assert.deepEqual(discoveryOffer.territories.find(row => row.territory === 'jacurutu-sietch')!.total,
{ normal: 3, elite: 0 });
const discoverySnapshot = quoteEcazBetrayal(g, holder, discoveryOffer.event, 'jacurutu-sietch');
assert.doesNotThrow(() => validateEcazBetrayalSnapshot(g, discoverySnapshot));
assert.throws(() => ecazBetrayalOffer(editGame(g, copy => {
copy.discoveries!.tokens.find(token => token.face === 'jacurutu-sietch')!.revealedTurn = null;
}), holder), /board custody/, 'Unrevealed Discovery faces cannot supply a board location.');
const context = homeworldContext(g);
g.homeworlds = { custody: quoteHomeworldCustody(context, createHomeworldCustody(context), [
{ homeworld: 'homeworld:ixians', player: ecaz, withdraw: { normal: 0, elite: 0 }, deposit: { normal: 1, elite: 0 } },
]).state };
assert.equal(ecazBetrayalOffer(g, holder)!.blocked, null,
'Unrelated valid Homeworld custody does not disable an Arrakis quote.');
assert.throws(() => quoteEcazBetrayal(g, holder, ecazBetrayalOffer(g, holder)!.event, 'homeworld:ixians'), /shared territory/);
});
void test('opening ends with any shipment, movement, first-seat pass, pending decision or automatic continuation', () => {
@@ -102,6 +275,10 @@ void test('opening ends with any shipment, movement, first-seat pass, pending de
assert.match(blocked(copy => { copy.players[1].moved = 1; }), /before the first/);
assert.match(blocked(copy => { copy.movementRemaining!.shift(); copy.active = copy.order[1]; }), /before the first/);
assert.match(blocked(copy => { copy.movementRemaining!.shift(); copy.active = copy.order[0]; }), /before the first/);
assert.match(blocked(copy => { copy.movementRemaining = []; }), /before the first/);
assert.match(blocked(copy => { copy.movementRemaining![1] = copy.movementRemaining![0]; }), /before the first/);
assert.match(blocked(copy => { copy.movementRemaining![1] = 'not-seated'; }), /before the first/);
assert.match(blocked(copy => { copy.active = copy.order[1]; }), /before the first/);
assert.match(blocked(copy => { copy.active = null; }), /before the first/);
assert.match(blocked(copy => { copy.phase = 4; }), /before the first/);
assert.match(blocked(copy => { copy.phaseOpening = { passed: [], initialize: true }; }), /current interaction/);
@@ -109,6 +286,70 @@ void test('opening ends with any shipment, movement, first-seat pass, pending de
assert.match(ecazBetrayalOffer(g, holder, true)!.blocked!, /current interaction/);
});
void test('quiet Guild first or wait keeps the pre-action Betrayal opening without changing physical Storm order', () => {
const { g, holder, ecaz, ally } = position();
g.expansions = ['ecaz'];
g.players.find(p => p.id === holder)!.faction = 'guild';
g.guildTimingGranted = true;
g.guildTimingLocked = false;
// Native chooseGuildTiming(take=true) grants the whole turn without
// rewriting movementRemaining. The original-action module fixture exercises
// the real decision/response; this test isolates its resulting quote facts.
g.active = holder;
const physicalOrder = [...g.order];
assert.deepEqual(g.movementRemaining, [ecaz, holder, ally]);
const first = ecazBetrayalOffer(g, holder)!;
assert.equal(first.blocked, null);
assert.doesNotThrow(() => validateEcazBetrayalSnapshot(g,
quoteEcazBetrayal(g, holder, first.event, 'wind_pass')));
// Native wait places Guild at the back of the remaining queue; nobody has
// taken a Shipment/Movement turn merely because this timing choice settled.
const waiting = editGame(g, copy => {
copy.movementRemaining = [ecaz, ally, holder];
copy.active = ecaz;
});
assert.equal(ecazBetrayalOffer(waiting, holder)!.blocked, null);
assert.deepEqual(waiting.order, physicalOrder);
for (const change of [
(copy: Game) => { copy.guildTimingGranted = false; },
(copy: Game) => { copy.guildTimingLocked = true; },
(copy: Game) => { copy.advanced = false; },
(copy: Game) => { copy.active = ally; },
(copy: Game) => { copy.movementRemaining = [ecaz, ally]; },
]) assert.match(ecazBetrayalOffer(editGame(g, change), holder)!.blocked!, /before the first/);
assert.match(ecazBetrayalOffer(editGame(g, copy => {
copy.decision = { kind: 'guildTiming', player: holder, next: ecaz, following: ally };
}), holder)!.blocked!, /current interaction/);
});
void test('an unspent Sapho-reordered first turn and its pending return keep the complete opening queue', () => {
const { g, holder, ally } = position();
const physicalOrder = [...g.order];
const event = `movement:${g.turn}`;
const reordered = reorderOrderedOpportunity({ event, eligible: g.order,
remaining: g.movementRemaining!, completed: [], current: g.active, currentStarted: false },
{ event, player: ally, position: 'first' });
g.movementRemaining = [...reordered.remaining];
g.active = reordered.current;
assert.notEqual(g.active, g.order[0]);
assert.deepEqual(g.order, physicalOrder);
assert.ok(g.players.every(p => !p.shipped && p.moved === 0));
const offer = ecazBetrayalOffer(g, holder)!;
assert.equal(offer.blocked, null);
const snapshot = quoteEcazBetrayal(g, holder, offer.event, 'wind_pass');
const pending = editGame(g, copy => {
copy.nexusCards!.cards!.hands[holder] = null;
copy.nexusCards!.cards!.discard.push('ecaz');
});
assert.doesNotThrow(() => validateEcazBetrayalSnapshot(pending, snapshot, true));
assert.throws(() => validateEcazBetrayalSnapshot(editGame(pending, copy => {
copy.movementRemaining!.shift();
copy.active = copy.movementRemaining![0];
}), snapshot, true), /pending card or alliance/);
});
void test('accepted quote rejects foreign territory, stale event and changing typed board custody', () => {
const { g, ecaz, ally, holder } = position();
const event = ecazBetrayalOffer(g, holder)!.event;
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { terrorLocationAllowed, terrorEntryLocationAllowed } from '../game/terror-location';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { e3IndependentNexusCardPlayer as player } from './fixture-e3-independent-nexus-cards';
import {
createNexusMoritaniAtomicsFixture, continueNexusMoritaniAtomics, NEXUS_ATOMICS_TERRITORY,
} from './fixture-nexus-moritani-atomics';
for (const modules of [false, true]) void test(`${modules ? 'Advanced all14/World/phase selected decks' : 'Basic selected E3'} actual Moritani Cunning Atomics destroys the original desert armies and keeps movement with a receipt-bound Aftermath shipment ban`, () => {
const fixture = createNexusMoritaniAtomicsFixture({ advanced: modules, skills: modules,
homeworlds: modules, tech: modules, strongholds: modules,
expansions: modules ? ['ecaz', 'ix', 'choam'] : ['ecaz'] });
assert.ok(fixture.afterSetup.ecazTreachery && fixture.afterSetup.nexusCards);
assert.equal(!!fixture.afterSetup.homeworlds, modules);
assert.equal(!!fixture.afterSetup.leaderSkills, modules);
assert.equal(!!fixture.afterSetup.techTokens, modules);
assert.equal(!!fixture.afterSetup.strongholdCards, modules);
assert.deepEqual(player(fixture.afterSetup, 'moritani').forces, { 'pasty_mesa:5': 6 });
assert.equal(fixture.draw.after.nexusCards!.cards!.hands.moritani, 'moritani');
assert.equal(fixture.placement.pending.nexusCards!.cards!.hands.moritani, null);
assert.equal(fixture.placement.pending.nexusCards!.cards!.discard.filter(card => card === 'moritani').length, 1);
assert.equal(player(fixture.placement.after, 'moritani').spice, player(fixture.placement.before, 'moritani').spice);
assert.deepEqual(fixture.placement.after.nexusMoritaniLocations?.[fixture.atomics], {
event: fixture.receipt.event, territory: NEXUS_ATOMICS_TERRITORY });
assert.equal(fixture.receipt.source, 'mentat');
assert.equal(fixture.receipt.turn, fixture.placement.before.turn);
assert.equal(fixture.receipt.token, fixture.atomics);
assert.equal(fixture.receipt.territory, NEXUS_ATOMICS_TERRITORY);
assert.equal(terrorLocationAllowed(fixture.game, fixture.atomics, NEXUS_ATOMICS_TERRITORY, fixture.receipt.event), true);
assert.equal(terrorEntryLocationAllowed(fixture.game, fixture.game.pendingTerrorEntry!), true);
assert.equal(player(fixture.game, 'guild').reserves, player(fixture.beforeEntry, 'guild').reserves - 3);
assert.equal(player(fixture.game, 'guild').forces['tueks_sietch:5'], 5);
assert.equal(player(fixture.game, 'guild').spice, player(fixture.beforeEntry, 'guild').spice - 3);
if (!modules) {
const supply = fixture.movementSupply; assert.ok(supply);
assert.equal(supply.step.action.type, 'ship');
assert.equal(player(supply.after, 'guild').spice, player(supply.before, 'guild').spice - 1);
assert.equal(player(supply.after, 'guild').reserves, player(supply.before, 'guild').reserves - 1);
assert.equal(player(supply.after, 'guild').forces['south_mesa:6'], 1);
assert.equal(player(supply.after, 'guild').forces['tueks_sietch:5'], 5);
}
const finished = continueNexusMoritaniAtomics(fixture.game, fixture.actions);
const activated = finished.activation.game, before = finished.activation.beforeActivation;
assert.deepEqual(finished.activation.seen, ['atomics']);
assert.equal(player(activated, 'moritani').tanks, player(before, 'moritani').tanks + 6);
assert.equal(player(activated, 'guild').tanks, player(before, 'guild').tanks + 3);
assert.equal(Object.entries(player(activated, 'moritani').forces).filter(([key]) => key.startsWith('pasty_mesa:')).reduce((sum, [, amount]) => sum + amount, 0), 0);
assert.equal(Object.entries(player(activated, 'guild').forces).filter(([key]) => key.startsWith('pasty_mesa:')).reduce((sum, [, amount]) => sum + amount, 0), 0);
assert.deepEqual(activated.moritaniAtomics, { territory: NEXUS_ATOMICS_TERRITORY, turn: before.turn,
moritaniId: 'moritani', allyAtActivation: null, alliancePolicy: 'activation-targets', nexusPlacement: fixture.receipt.event });
assert.equal(activated.moritaniTerror!.tokens.find(token => token.id === fixture.atomics)!.status, 'removed');
assert.equal(activated.nexusMoritaniLocations?.[fixture.atomics], undefined);
assert.deepEqual(activated.nexusMoritaniHistory, fixture.placement.after.nexusMoritaniHistory);
assert.equal(terrorLocationAllowed(activated, fixture.atomics, NEXUS_ATOMICS_TERRITORY, fixture.receipt.event, false, true), true);
assert.equal(terrorLocationAllowed(activated, fixture.atomics, NEXUS_ATOMICS_TERRITORY, fixture.receipt.event), false);
assert.equal(terrorLocationAllowed(activated, 'another-token', NEXUS_ATOMICS_TERRITORY, fixture.receipt.event, false, true), false);
assert.equal(player(activated, 'moritani').atomicsHandLimitPenalty, true);
assert.equal(viewGame(activated, 'moritani').players.find(player => player.id === 'moritani')!.handLimit, 3);
assert.equal(player(activated, 'guild').atomicsHandLimitPenalty ?? false, false);
assert.equal(player(finished.beforeMove, 'guild').shipped, true);
assert.equal(player(finished.beforeMove, 'guild').moved, 0);
assert.equal(Object.entries(player(finished.afterMove, 'guild').forces).filter(([key]) => key.startsWith('pasty_mesa:')).reduce((sum, [, amount]) => sum + amount, 0), modules ? 5 : 1);
if (!modules) assert.equal(player(finished.afterMove, 'guild').forces['tueks_sietch:5'], 5);
assert.equal(player(finished.afterMove, 'guild').moved, 1);
assert.throws(() => applyAction(finished.beforeShipment, 'guild', finished.blockedShipment), /Aftermath|Atomics/i);
assert.equal(player(finished.game, 'guild').shipped, true);
assert.equal(player(finished.game, 'guild').forces['polar_sink:0'], 1);
assert.deepEqual(finished.game.moritaniAtomics, activated.moritaniAtomics);
const nexus = finished.game.nexusCards!.cards!;
assert.deepEqual([...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter(card => card !== null)].sort(), [...NEXUS_FACTIONS].sort());
});
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createRicheseNoField, deployRicheseNoField, deployRicheseNoFieldPair,
recallRicheseNoField, projectRicheseNoField, revealRicheseNoField,
validateRicheseNoField, type NoFieldValue,
} from '../game/richese-no-field';
for (const value of [0,3,5] as const) void test(`forced face-down ${value} No-Field return keeps its last-shipment ban without revealing its value`, () => {
const start = createRicheseNoField(['zero','three','five']);
const token = start.tokens.find(token => token.value === value)!;
const location = {territory:'imperial_basin',sector:9};
const deployed = deployRicheseNoField(start, {tokenId:token.id,controller:'richese',location});
const before = structuredClone(deployed);
const returned = recallRicheseNoField(deployed,token.id);
assert.deepEqual(deployed,before);
assert.deepEqual(projectRicheseNoField(returned,false), {deployed:null,lastUsed:null});
assert.equal(returned.lastShipped,token.id);
assert.equal(returned.hiddenReturn,true);
assert.deepEqual(projectRicheseNoField(returned,true).private,returned);
assert.throws(()=>recallRicheseNoField(returned,token.id),/not deployed/);
assert.throws(()=>deployRicheseNoField(returned,{tokenId:token.id,controller:'richese',location}),/twice in a row/);
const next = returned.tokens.find(candidate=>candidate.id!==token.id)!;
const redeployed = deployRicheseNoField(returned,{tokenId:next.id,controller:'richese',location});
assert.equal(Object.hasOwn(redeployed,'hiddenReturn'),false);
const revealed = revealRicheseNoField(redeployed,{tokenId:next.id,reserves:2,cause:'atomics'});
assert.equal(revealed.forces,Math.min(next.value,2));
assert.equal(projectRicheseNoField(revealed.state,false).lastUsed,next.value);
const other = returned.tokens.find(candidate=>candidate.id!==token.id&&candidate.id!==next.id)!;
const pair = deployRicheseNoFieldPair(returned,{tokenId:next.id,controller:'richese',location},other.id,7);
assert.equal(Object.hasOwn(pair.state,'hiddenReturn'),false);
assert.equal(pair.revealed.forces,other.value);
});
void test('the hidden-return marker requires an actual prior shipment and cannot coexist with a deployed token', () => {
const start = createRicheseNoField(['zero','three','five']);
assert.throws(()=>validateRicheseNoField({...start,hiddenReturn:true}),/previous shipment/);
const deployed=deployRicheseNoField(start,{tokenId:'three',controller:'richese',location:{territory:'arrakeen',sector:10}});
assert.throws(()=>validateRicheseNoField({...deployed,hiddenReturn:true}),/previous shipment/);
assert.throws(()=>recallRicheseNoField(deployed,'five'),/not deployed/);
const oldReveal=revealRicheseNoField(deployed,{tokenId:'three',reserves:7,cause:'voluntary'});
assert.equal(Object.hasOwn(oldReveal.state,'hiddenReturn'),false);
assert.equal(projectRicheseNoField(oldReveal.state,false).lastUsed,3 as NoFieldValue);
});