Compose the deferred Terror arrival and green the mixed E3 programmes
Queue a committed Terror entry whose arrival also opened its own
interaction — Guild income, a Bene Gesserit fighter Intrusion or advisor
choice, or Fremen storm protection — in the additive
Game.pendingArrivalReaction list, and open the oldest queued entry from
settleAutomaticContinuations once nothing is pending. Payment, force
transfer and the original movement never replay; a Terror entry already in
flight and every Ambassador combination still reject.
Both withdrawn mixed E3 programme pairs are green: Moritani 9/9 and Ecaz
14/14, stable across 20 consecutive full-file runs. Their recorded resume
constraints became real fixture repairs, and the two restored consumer
omissions were corrected against the engine rather than re-pinned.
Repair pre-existing failures inherited at 45e6084 against current engine
behavior: stale Discovery and module gates in the native skills, Ix and
expansion-faction prototypes, the advanced Moritani skill boundary, the
Richese Nexus betrayal sale-credit expectation, one incidental gate
message and one flaky Collection-readiness assertion that reproduced at
HEAD.
Offline suite 7,793/7,793 with typecheck and lint clean. No game, seat,
saved-state, deployment, public-start or played-save conversion changes.
This commit is contained in:
1 parent
45e6084424
commit
e05a205a47
30 files changed
+2467
-117
No files matched your search
@@ -73,6 +73,30 @@ genuine-setup replay completes in 786 accepted attempts, zero rejections,
|
||||
candidate legality, not the unsupported arrival-rule composition or public
|
||||
expansion start gate.
|
||||
|
||||
## 7 October: the deferred Terror entry is a supported arrival
|
||||
|
||||
The engine now commits a Terror arrival whose entry coincides with the
|
||||
arrival's own interaction. `openTerritoryEntry` queues the committed entry in
|
||||
the additive `Game.pendingArrivalReaction` list and
|
||||
`settleAutomaticContinuations` opens the oldest queued entry as soon as no
|
||||
response, decision, Terror entry, Ambassador entry or Ecaz/Moritani overlap is
|
||||
pending. Payment, force transfer and the original movement never replay.
|
||||
|
||||
Consequently `quoteCompletedMovementArrival` no longer rejects a Terror
|
||||
arrival because a Guild income response, a Bene Gesserit fighter Intrusion or
|
||||
advisor choice, or Fremen storm protection is present; those windows settle
|
||||
first and the entry follows. The preflight still rejects a Terror entry already
|
||||
in flight (`controls.pendingTerror`) and every Ambassador combination
|
||||
(`Ambassadors combined with another arrival reaction`).
|
||||
|
||||
The two former bot policies that avoided those combinations are updated, not
|
||||
re-pinned: `tests/bot-arrival.test.ts` now asserts the Guild transport ships
|
||||
into its committed Terror entry, and
|
||||
`tests/ambassador-terror-overlap-worm.test.ts` asserts the queued worm ride
|
||||
defers the entry behind the pending Intrusion and then opens it after the
|
||||
decline. Both drive the deferred path to completion and keep the profiles'
|
||||
other policy checks.
|
||||
|
||||
## 29 September: Guild cross-planet transport and Terror
|
||||
|
||||
An unmodified six-seat Advanced Moritani sample at base seed `20260930`
|
||||
@@ -114,3 +138,8 @@ accepted attempts, zero rejects and 41 JSON continuations. No hidden Terror
|
||||
face or rival hand enters the quote. Homeworld rides and other special
|
||||
arrivals retain their own gates; this does not implement the conflicting
|
||||
reaction or open public expansion starts.
|
||||
|
||||
**Superseded 7 October 2026:** the conflicting reaction is now implemented as
|
||||
the deferred entry above. The owned bot candidate may choose that destination
|
||||
again; the focused regression now asserts the ride is accepted, the entry waits
|
||||
behind the pending Intrusion and then opens after the decline.
|
||||
+16
-11
@@ -33,17 +33,22 @@ Loyalty card, and the engine's assassination inventory check now counts
|
||||
Tleilaxu Face Dancers. Selected decks/cache, shared-Duke/capture,
|
||||
force/payment and pending-effect guards remain.
|
||||
|
||||
Dedicated original programme evidence for this envelope is **not** yet
|
||||
green: two independently authored programme pairs reached11/21 cases and
|
||||
were withdrawn from this checkpoint rather than shipped failing. Recorded
|
||||
engine constraints for resuming: the loser must dial for the winner to have
|
||||
rescuable casualties; Ixian Suboids are half-strength so dials are
|
||||
half-integers; support is capped by `maxCombatSupport`/`battleSupportBudget`;
|
||||
a Worthless card is a defense, not a weapon; a normal Traitor call needs the
|
||||
held card to match the opponent's plan leader; and the mixed-E3 Moritani
|
||||
Terror/Cunning windows need their own real shipment. No new frozen
|
||||
test/build/game/human checkpoint, public start or played-save conversion
|
||||
is claimed.
|
||||
Dedicated original programme evidence for this envelope is now green for the
|
||||
Moritani pair (9/9) and the Ecaz pair (14/14, stable across 20 consecutive
|
||||
full-file runs). The former recorded engine
|
||||
constraints are resolved as real fixture or engine defects: Moritani must start
|
||||
in an unoccupied printed stronghold (never a seated faction's own start), a
|
||||
pending Leader-Skill posture must be concealed before both plans are submitted,
|
||||
a normal Traitor call is legal only for the card that actually matches the
|
||||
opponent's plan leader, the winner's automatic casualty application is not an
|
||||
assassination effect, and the mixed-E3 Moritani Terror/Cunning window now uses
|
||||
its own real shipment. **New engine behavior:** a Terror arrival whose entry
|
||||
coincides with the arrival's own interaction (Guild income, Bene Gesserit
|
||||
fighter Intrusion or advisor choice, Fremen storm protection) is queued in the
|
||||
additive `Game.pendingArrivalReaction` and opens as soon as that interaction
|
||||
settles, without replaying payment or force transfer. A Terror entry already in
|
||||
flight and every Ambassador combination still reject. Selected decks/cache,
|
||||
shared-Duke/capture, force/payment and pending-effect guards remain.
|
||||
|
||||
|
||||
**Mixed E1/E2 and paired E3 Nexus — bounded Development Verified.**
|
||||
|
||||
@@ -1,5 +1,49 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 7 October 2026 — mixed E3 programme evidence and the deferred Terror entry
|
||||
|
||||
The withdrawn mixed E3 programme pairs are green: Moritani9/9 and Ecaz14/14
|
||||
(stable across 20 consecutive full-file runs). The recorded resume
|
||||
constraints became real fixture repairs, not re-pinned assertions: Moritani
|
||||
starts in an unoccupied printed stronghold, a pending Leader-Skill posture is
|
||||
concealed before both plans are submitted, a normal Traitor call requires the
|
||||
card that matches the opponent's plan leader, the winner's automatic casualty
|
||||
is not an assassination effect, the public history never carries the private
|
||||
Mentat replacement, and the Moritani Terror/Cunning window uses its own real
|
||||
shipment. Two restored consumer omissions were corrected against the engine:
|
||||
the Discovery entry asserts the token's actual random face and conserved
|
||||
counters, and the Suk rescue asserts the winner's own dialed casualties with
|
||||
the loser never rescued.
|
||||
|
||||
**New engine behavior.** `Game.pendingArrivalReaction` queues a committed
|
||||
Terror entry whose arrival also opened its own interaction — Guild income, a
|
||||
Bene Gesserit fighter Intrusion or advisor choice, or Fremen storm protection —
|
||||
and `settleAutomaticContinuations` opens the oldest queued entry once nothing
|
||||
is pending. Payment, force transfer and the original movement never replay; a
|
||||
Terror entry already in flight and every Ambassador combination still reject.
|
||||
The selected order is a provisional implementation interpretation, not a
|
||||
publisher priority ruling.
|
||||
|
||||
Former limitation pins now assert the accepted, deferred outcome and drive it
|
||||
to completion instead of the removed rejection: `bot-arrival`,
|
||||
`ambassador-terror-overlap-worm`, `moritani-entry`, both Richese No-Field
|
||||
files, `homeworld-grumman-engine`, `karama-movement-preflight` and
|
||||
`ornithopter-discard-continuations`.
|
||||
|
||||
Pre-existing failures inherited at `45e6084` were repaired against current
|
||||
engine behavior rather than re-pinned: the stale Discovery module gates in
|
||||
both native skills integrations, the Ix prototype and Ix skills gates, the
|
||||
advanced Moritani skill boundary, the expansion-faction prototype and recovery
|
||||
gates, and the Richese Nexus betrayal sale-credit expectation (the authorized
|
||||
Advanced source sends Richese's own contribution to the Emperor, or the Bank
|
||||
with no Emperor seated). One incidental gate message ("excludes other optional
|
||||
modules") and one flaky Collection-readiness assertion in the Ecaz Homeworld
|
||||
victory runtime were also corrected; the flake reproduced at HEAD.
|
||||
|
||||
Offline suite 7,793/7,793 with typecheck and lint clean at this checkpoint; no
|
||||
game, seat, saved-state, deployment, public-start or played-save conversion is
|
||||
claimed.
|
||||
|
||||
## 7 October 2026 — Mixed E1/E2 and paired E3 Nexus
|
||||
|
||||
Original both-family Ix+CHOAM/union47 or both Ecaz+Moritani/ecaz33 with
|
||||
|
||||
@@ -21,7 +21,7 @@ No targeted publisher/designer clarification establishes which of Ecaz's Ambassa
|
||||
|
||||
Both optional trigger opportunities belong to the **same already committed entry**. Snapshot the original entrant, token identities, physical arrival amount and turn; finish the first owner's choice and its allowable continuations before offering the second. Revalidate the second effect's physical requirements without replaying the original shipment payment, movement or force transfer. Owner-private Terror identity remains concealed until revealed. If the first reaction changes an alliance, the other owner keeps the trigger earned at the original entry; it does not gain a new trigger from a later action.
|
||||
|
||||
This bounded path excludes competing BG fighter Intrusion, Guild income/advisor and CHOAM prevention windows, Homeworlds, Nexus, Discoveries, Leader Skills and the still-unfinished Atomics effect. Fremen relocation or Guild free-shipment Ambassador effects can create nested arrivals before the second owner; they remain declineable but cannot be activated in this overlap until their child continuation is integrated. These guards are development boundaries, not physical-game prohibitions.
|
||||
This bounded path originally excluded competing BG fighter Intrusion, Guild income/advisor and CHOAM prevention windows, Homeworlds, Nexus, Discoveries, Leader Skills and the still-unfinished Atomics effect. **The competing BG fighter Intrusion, Guild income and BG advisor windows are now integrated as a deferred entry — see the 7 October 2026 section below.** Fremen relocation or Guild free-shipment Ambassador effects can create nested arrivals before the second owner; they remain declineable but cannot be activated in this overlap until their child continuation is integrated. These guards are development boundaries, not physical-game prohibitions.
|
||||
|
||||
## Paired native Nexus composition — 7 October 2026
|
||||
|
||||
@@ -50,6 +50,33 @@ This does not merge an advisor's separate arrival into the parent's
|
||||
reaction, make advisors eligible for Ambassadors, or claim a universal
|
||||
optional-module priority order.
|
||||
|
||||
## Deferred arrival entry behind the arrival's own interactions — 7 October 2026
|
||||
|
||||
The engine no longer rejects a Terror arrival that coincides with the arrival's
|
||||
own pending interaction. `openTerritoryEntry` stores the already committed
|
||||
entry in the additive `Game.pendingArrivalReaction` record — original entrant,
|
||||
destination, sector, amount, elite, cause, resume kind and turn/phase — and
|
||||
`settleAutomaticContinuations` opens it as soon as no response, decision,
|
||||
Terror entry, Ambassador entry or Ecaz/Moritani overlap is pending. Payment,
|
||||
force transfer and the original movement are never replayed; the deferred
|
||||
record only reopens the entry.
|
||||
|
||||
**Selected order (provisional implementation interpretation, not a publisher
|
||||
ruling):** the arrival's own interaction settles first — the committed
|
||||
shipment's Guild income payment and BG advisor choice, the BG fighter
|
||||
Intrusion, or Fremen storm protection — and the Terror entry is offered
|
||||
afterwards. This reuses the bounded policy already recorded for an accompanying
|
||||
counter, whose own qualifying entry likewise follows earlier Intrusion and
|
||||
income, and it respects the November 2020 FAQ wording that Intrusion must occur
|
||||
immediately when the other faction intrudes. No source ranks the optional
|
||||
Terror reaction against those windows; this table selects one order rather than
|
||||
leaving the entry uncommitted.
|
||||
|
||||
Still guarded after this integration: `controls.pendingTerror` (a Terror entry
|
||||
already in flight), every Ambassador combination
|
||||
(`Ambassadors combined with another arrival reaction`), Atomics, unsupported
|
||||
child effects and the CHOAM prevention windows.
|
||||
|
||||
## Atomics and later allies
|
||||
|
||||
The current official indexed p. 5 still says: “From this turn forward, your hand limit is reduced by 1 (as well as your ally’s)”. It does not specify how a later alliance change affects either former or new allies. Both a continuing current-ally modifier and a detonation-time recipient remain interpretations. No targeted clarification was found. [GF9 E3, p. 5](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=5)
|
||||
|
||||
@@ -301,7 +301,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
|
||||
| Truthtrance hand categories | Count current physical hand custody by explicitly named primary role; alternate battle slots and faction powers do not reclassify cards. Only an aggregate answer is disclosed. | [Hand inventory](TRUTHTRANCE_HAND_INVENTORY.md) |
|
||||
| Moritani Advanced assassination preview | After an eligible battle loss, a different opposing-faction Traitor Card may be revealed even if its leader is already dead (zero bounty). Face-up set-aside and one private replacement occur during Mentat; one use per faction. Normal-call duration, uniform hidden-eligibility UX and exceptional combinations remain explicit. | [Runtime/source contract](MORITANI_ASSASSINATE_LEADERS.md) |
|
||||
| Moritani Extortion at Mentat | The user selected normal Terror placement/relocation first, including any Karama response; only then collect the reserved five bank spice and approach other players in storm order for a possible three-spice payment. Payment removes the token; all declines recover it with rotated hidden supply IDs. The relative placement timing is a user-selected interpretation, not a publisher ruling. | [Publisher sequence and selected timing](MORITANI_EXTORTION_RULES.md#mentat-ordering-selected-interpretation-not-a-publisher-ruling) |
|
||||
| Ecaz Ambassador / Moritani Terror shared entry | In bounded classic plus Ecaz/Moritani Basic/Advanced games and the exact original paired native Nexus envelopes, both originally eligible owners resolve optional reactions in current storm order; committed entry, payment and counters do not replay. Later alliances do not erase the original trigger; physical effect prerequisites are rechecked. This remains an explicit provisional interpretation, not a located publisher priority ruling. An original accompanying counter joining existing BG fighters creates its own qualifying entry only after earlier Intrusion/income finish; exempt advisors, competing live reactions, unsupported children and Atomics retain guards. | [Primary-source gap and selected policy](MORITANI_ENTRY_TIMING.md#selected-bounded-overlap-order--29-september-2026), [exact paired composition](MORITANI_ENTRY_TIMING.md#paired-native-nexus-composition--7-october-2026), [movement recovery](DEFERRED_MOVEMENT_ARRIVAL.md#bounded-storm-order-continuation--29-september-2026) |
|
||||
| Ecaz Ambassador / Moritani Terror shared entry | In bounded classic plus Ecaz/Moritani Basic/Advanced games and the exact original paired native Nexus envelopes, both originally eligible owners resolve optional reactions in current storm order; committed entry, payment and counters do not replay. Later alliances do not erase the original trigger; physical effect prerequisites are rechecked. This remains an explicit provisional interpretation, not a located publisher priority ruling. An original accompanying counter joining existing BG fighters creates its own qualifying entry only after earlier Intrusion/income finish; exempt advisors, competing live reactions, unsupported children and Atomics retain guards. The Terror entry itself is now deferred behind the arrival's own interaction (Guild income, BG fighter Intrusion or BG advisor choice, Fremen storm protection) in an additive pending record that never replays payment or force transfer; a Terror entry already in flight and every Ambassador combination still reject. | [Primary-source gap and selected policy](MORITANI_ENTRY_TIMING.md#selected-bounded-overlap-order--29-september-2026), [exact paired composition](MORITANI_ENTRY_TIMING.md#paired-native-nexus-composition--7-october-2026), [deferred arrival entry](MORITANI_ENTRY_TIMING.md#deferred-arrival-entry-behind-the-arrivals-own-interactions--7-october-2026), [movement recovery](DEFERRED_MOVEMENT_ARRIVAL.md#bounded-storm-order-continuation--29-september-2026) |
|
||||
| Fremen/Ecaz final-turn victory | Reciprocal allied Ecaz and Fremen with fighters in Sietch Tabr do not block the Fremen special victory. Ecaz alone/non-allied or a third faction still blocks. The Habbanya extension remains unsupported; ordinary winner/prediction and fallback order are preserved. | [Explicit FAQ and runtime](ECAZ_VICTORY_RULES.md#fremen-final-turn-runtime-follow-up) |
|
||||
| Truthtrance recorded knowledge | Questions compare an already recorded BG prediction, active submitted storm dial or actually known native Fremen forecast. Missing knowledge is unknown; only the aggregate answer is published. No future promise or unknown deck peek is created. | [Connected predicates and evidence](TRUTHTRANCE_KNOWLEDGE.md) |
|
||||
| Nexus lifecycle | Twelve physical cards, phase draw/replacement and alliance forfeiture; native Cunning, absent-faction Secret Ally and Betrayal have separate effects. No blanket Karama immunity. | [Common source contract](NEXUS_CARD_RULES.md), [lifecycle runtime](NEXUS_CARD_RUNTIME.md) |
|
||||
|
||||
@@ -1691,6 +1691,19 @@ export type Game = {
|
||||
resume: 'none' | 'wormRide' | 'ambassador';
|
||||
ambassadorEvent?: string;
|
||||
} | null;
|
||||
/** Committed entries whose optional reactions must wait for the arrival's own
|
||||
* pending interaction (payment, advisor choice, storm protection). */
|
||||
pendingArrivalReaction?: {
|
||||
entrant: string;
|
||||
territory: string;
|
||||
sector: number;
|
||||
amount: number;
|
||||
elite: number;
|
||||
cause: NonNullable<Game['pendingTerrorEntry']>['cause'];
|
||||
resume: 'none' | 'wormRide';
|
||||
turn: number;
|
||||
phase: number;
|
||||
}[] | null;
|
||||
/** One committed entry with two independently optional expansion reactions. */
|
||||
pendingArrivalOverlap?: {
|
||||
event: string;
|
||||
@@ -14826,6 +14839,16 @@ function openTerritoryEntry(
|
||||
cause: NonNullable<Game['pendingTerrorEntry']>['cause'],
|
||||
resume: 'none' | 'wormRide' = 'none',
|
||||
) {
|
||||
// The arrival's own interaction (guild payment, advisor choice, storm
|
||||
// protection) settles first; the entry then opens from the automatic pass.
|
||||
if (g.response || g.decision || g.pendingTerrorEntry || g.pendingAmbassador ||
|
||||
g.pendingArrivalOverlap) {
|
||||
(g.pendingArrivalReaction ??= []).push({
|
||||
entrant: entrant.id, territory: to, sector, amount, elite, cause, resume,
|
||||
turn: g.turn, phase: g.phase,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const owner = byFaction(g, 'ecaz');
|
||||
const token = g.ecazAmbassadors?.tokens.find(
|
||||
(t) => t.zone === 'placed' && t.location === to,
|
||||
@@ -27456,6 +27479,16 @@ function settleAutomaticContinuations(g: Game) {
|
||||
prepareTupileCleanup(g);
|
||||
if (g.pendingHomeworldTupileCleanup || tupileSourceBlock(g)) return;
|
||||
if (g.truthtrance || g.phaseOpening || g.status === 'finished') return;
|
||||
if (g.pendingArrivalReaction?.length && !g.response && !g.decision &&
|
||||
!g.pendingTerrorEntry && !g.pendingAmbassador && !g.pendingArrivalOverlap) {
|
||||
const deferred = g.pendingArrivalReaction.shift()!;
|
||||
if (!g.pendingArrivalReaction.length) g.pendingArrivalReaction = null;
|
||||
const entrant = g.players.find(p => p.id === deferred.entrant);
|
||||
requireRule(!!entrant, 'The deferred arrival entry has lost its entrant.');
|
||||
openTerritoryEntry(g, entrant!, deferred.territory, deferred.sector,
|
||||
deferred.amount, deferred.elite, deferred.cause, deferred.resume);
|
||||
continue;
|
||||
}
|
||||
const response = g.response;
|
||||
const before = JSON.stringify(g);
|
||||
const bonus = g.pendingHomeworldOccupiedBonus;
|
||||
|
||||
@@ -148,10 +148,7 @@ export function quoteCompletedMovementArrival(
|
||||
if (triggersTerror) {
|
||||
if (controls.pendingTerror)
|
||||
throw new MovementArrivalError('Resolve the pending Terror entry first.');
|
||||
if (response || decision)
|
||||
throw new MovementArrivalError(
|
||||
'Terror combined with another arrival reaction is still being implemented. This entry has not been committed.',
|
||||
);
|
||||
// The arrival's own interaction settles first; the engine defers the entry.
|
||||
return { ...base, reaction: 'terror' };
|
||||
}
|
||||
return { ...base, reaction: null };
|
||||
|
||||
+3
-3
@@ -1324,10 +1324,10 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'CHOAM lets the beneficiary choose any available hand cards, including none, for three spice each. Ixians require one available card to discard before drawing its replacement. Card controls respect projected reservations. After committing an Ixian exchange, a voluntary interruption must leave a card available for its required discard; a queued Truthtrance counts as already committed. A Bene Gesserit trigger permanently removes its token, then the beneficiary chooses an available effect outside the original supply for this group; unsupported copies remain visibly blocked.',
|
||||
'The Richese Ambassador automatically spends three of the chosen beneficiary’s spice for the top Treachery Card if the purchase can be completed. There is no extra purchase confirmation. An unavailable purchase consumes the triggered token without charging spice or granting a card; the public outcome does not disclose whether the beneficiary lacked spice or hand space. Emperor receives another faction’s payment unless that income is canceled. A Harkonnen buyer may receive its normal bonus card, subject to its hand limit and Karama. These benefits finish before movement or a worm ride resumes. Ixian allied replacement requires Bidding, so it does not apply to these entries.',
|
||||
'The Fremen Ambassador lets the beneficiary relocate one group already on the board directly to any available territory and sector, subject to storm and occupancy. Select physical forces from one territory, preserve elite quantities, and optionally include your concealed No-Field without revealing it. This independent move costs no shipment spice and does not use ordinary movement allowances. It can change sectors within one territory, but pieces already in the destination sector stay in place. Homeworlds and reserves are not board territories.',
|
||||
'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. Destinations causing both remain visibly unavailable pending a table interpretation. There is no extra decline after triggering; if no legal move remains, the effect finishes without moving pieces and its token stays consumed.',
|
||||
'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. Competing Intrusion and Ambassador-created Fremen/Guild child arrivals remain gated. Atomics Aftermath, Homeworlds, Nexus and Discovery combinations do not gain coverage from this first 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 Fremen/Guild child arrivals remain gated. Atomics Aftermath, Homeworlds, Nexus and Discovery combinations do not gain coverage from this overlap path.',
|
||||
'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.',
|
||||
],
|
||||
checklist: [
|
||||
@@ -1335,7 +1335,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Inventory, placement and eight entry effects plus their Bene Gesserit copies and direct Ecaz Duke acquisition or consensual alliance are integrated. Storm/explosion token returns are integrated. Competing arrival ordering and remaining effects remain unfinished.',
|
||||
'Inventory, placement and eight entry effects plus their Bene Gesserit copies and direct Ecaz Duke acquisition or consensual alliance are integrated. Storm/explosion token returns are integrated. A Terror entry now defers behind competing Intrusion, Guild income and BG advisor windows; Ambassador-created child arrivals and remaining effects remain unfinished.',
|
||||
},
|
||||
{
|
||||
area: 'Player controls',
|
||||
|
||||
@@ -364,10 +364,14 @@ void test('new native admission preserves public, Harkonnen assassination, forei
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(game));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
// Native Discovery Leader Skills are now admitted for this fresh Moritani
|
||||
// lobby; the foreign-family and combined-expansion lobbies still reject.
|
||||
const discoveryLobby = make();
|
||||
discoveryLobby.players.forEach(p => { p.ready = true; });
|
||||
discoveryLobby.discoveryEnabled = true;
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(discoveryLobby));
|
||||
const discoveryBefore = reload(discoveryLobby);
|
||||
assert.ok(initializeLeaderSkillsGameForAudit(discoveryLobby).leaderSkills);
|
||||
assert.deepEqual(discoveryLobby, discoveryBefore, 'The lobby is never mutated.');
|
||||
// Basic Moritani keeps its original Harkonnen-compatible skills profile; it
|
||||
// does not acquire the Advanced-only assassination preview.
|
||||
const basicLobby = make('harkonnen');
|
||||
|
||||
@@ -194,7 +194,7 @@ void test('bots treat the supported overlapping stronghold as a legal ordinary a
|
||||
assert.equal(seat(entered, 'in').forces['arrakeen:10'], 2);
|
||||
});
|
||||
|
||||
void test('worm-riding bots avoid Terror arrival while BG fighter intrusion is pending', () => {
|
||||
void test('worm-riding bots treat the Terror arrival beside a pending BG intrusion as a legal deferred entry', () => {
|
||||
const { g } = table('robbery');
|
||||
g.ecazAmbassadors = createAmbassadors(() => 0.2);
|
||||
const bg = newPlayer('bg', 'Bene Gesserit', 'beneGesserit');
|
||||
@@ -206,16 +206,48 @@ void test('worm-riding bots avoid Terror arrival while BG fighter intrusion is p
|
||||
g.decision = { kind: 'wormRide', player: 'in', territory: 'imperial_basin' };
|
||||
g.wormRides = ['hagga_basin'];
|
||||
const before = JSON.stringify(g);
|
||||
assert.throws(() => action(g, 'in', ride),
|
||||
/Terror combined with another arrival reaction/);
|
||||
// The ride commits; the BG fighter intrusion owns the pending interaction, so the
|
||||
// Terror entry is deferred in `pendingArrivalReaction` instead of being rejected.
|
||||
const entered = action(g, 'in', ride);
|
||||
assert.deepEqual(entered.decision, {
|
||||
kind: 'intrusion', player: 'bg', territory: 'arrakeen', wormRide: true,
|
||||
});
|
||||
assert.equal(entered.pendingTerrorEntry ?? null, null);
|
||||
assert.equal(entered.pendingArrivalReaction?.[0]?.cause, 'wormRide');
|
||||
assert.equal(entered.pendingArrivalReaction?.[0]?.entrant, 'in');
|
||||
assert.equal(entered.pendingArrivalReaction?.[0]?.territory, 'arrakeen');
|
||||
assert.equal(entered.pendingArrivalReaction?.[0]?.sector, 10);
|
||||
assert.equal(entered.pendingArrivalReaction?.[0]?.amount, 2);
|
||||
assert.equal(entered.pendingArrivalReaction?.[0]?.resume, 'wormRide');
|
||||
assert.deepEqual(entered.wormRides, ['hagga_basin']);
|
||||
assert.equal(seat(entered, 'in').forces['arrakeen:10'], 2);
|
||||
assert.equal(seat(entered, 'in').forces['imperial_basin:10'], 1);
|
||||
for (const bot of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(g, 'in');
|
||||
view.players.find(p => p.id === 'in')!.bot = bot;
|
||||
assert.equal(botArrivalBlock(view, ride), null);
|
||||
const choices = botActions(view);
|
||||
assert.ok(choices.length, bot);
|
||||
assert.equal(choices.some(choice => choice.type === 'decision' &&
|
||||
choice.accept === true && choice.territory === 'arrakeen'), false, bot);
|
||||
// The formerly rejected stronghold ride is now a legal choice bots offer.
|
||||
assert.ok(choices.some(choice => choice.type === 'decision' &&
|
||||
choice.accept === true && choice.territory === 'arrakeen'), bot);
|
||||
assert.ok(applyAction(g, 'in', choices[0]));
|
||||
}
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
// Answering the intrusion and the queued ride settles the deferral: the Terror
|
||||
// entry now opens from the stored arrival reaction.
|
||||
let state = action(g, 'in', ride);
|
||||
state = action(state, 'bg', { type: 'decision', accept: false });
|
||||
assert.deepEqual(state.decision, queuedRide);
|
||||
assert.equal(state.pendingArrivalReaction?.[0]?.cause, 'wormRide');
|
||||
assert.equal(state.pendingTerrorEntry ?? null, null);
|
||||
state = action(state, 'in', { type: 'decision', accept: false });
|
||||
assert.equal(state.pendingArrivalReaction ?? null, null);
|
||||
assert.equal(state.pendingTerrorEntry?.cause, 'wormRide');
|
||||
assert.equal(state.pendingTerrorEntry?.resume, 'wormRide');
|
||||
assert.equal(state.pendingTerrorEntry?.stage, 'offer');
|
||||
assert.equal(state.decision?.kind, 'moritaniTerror');
|
||||
assert.match(state.log.at(-1)!.text,
|
||||
/Moritani may reveal a Terror token after Rider entered Arrakeen/);
|
||||
assert.equal(seat(state, 'in').forces['arrakeen:10'], 2);
|
||||
});
|
||||
@@ -83,7 +83,7 @@ void test('Homeworld-sourced AI shipments avoid unsupported paired entry reactio
|
||||
assert.equal(botArrivalBlock(ownView(game), action), null);
|
||||
});
|
||||
|
||||
void test('Guild cross-planet transport avoids a Terror arrival blocked by its Guild income response', () => {
|
||||
void test('Guild cross-planet transport ships into a Terror arrival from its committed income response', () => {
|
||||
const game = shipmentArrivalGame();
|
||||
const bg = arrivalPlayer(game, 'bg');
|
||||
const guild = arrivalPlayer(game, 'guild');
|
||||
@@ -97,11 +97,22 @@ void test('Guild cross-planet transport avoids a Terror arrival blocked by its G
|
||||
game.moritaniTerror = placeTerror(terror, terror.tokens[0].id, 'carthag', 2);
|
||||
const action: Action = { type: 'guildShip', from: 'sietch_tabr:14', amount: 2,
|
||||
territory: 'carthag', sector: 11 };
|
||||
assert.throws(() => applyAction(game, bg.id, action), overlap);
|
||||
const before = JSON.stringify(game);
|
||||
// Guild income from the ally went to the bank with nothing left pending, so the
|
||||
// supported Terror entry opens in the same commit as the shipped forces.
|
||||
const shipped = applyAction(game, bg.id, action);
|
||||
const arrived = arrivalPlayer(shipped, 'bg');
|
||||
assert.deepEqual(arrived.forces, { 'carthag:11': 2 });
|
||||
assert.equal(arrived.shipped, true);
|
||||
assert.equal(shipped.decision?.kind, 'moritaniTerror');
|
||||
assert.equal(shipped.pendingTerrorEntry?.cause, 'guildTransport');
|
||||
assert.equal(shipped.pendingArrivalReaction ?? null, null);
|
||||
assert.equal(shipped.response ?? null, null);
|
||||
assert.match(shipped.log.at(-1)!.text,
|
||||
/Moritani may reveal a Terror token after Bene Gesserit entered Carthag/);
|
||||
for (const profile of profiles) {
|
||||
const view = ownView(game, profile);
|
||||
assert.match(botArrivalBlock(view, action) ?? '', overlap);
|
||||
assert.equal(botArrivalBlock(view, action), null);
|
||||
const choices = botActions(view);
|
||||
assert.ok(choices.length, profile);
|
||||
assert.equal(choices.some(candidate => candidate.type === 'guildShip' &&
|
||||
@@ -113,7 +124,9 @@ void test('Guild cross-planet transport avoids a Terror arrival blocked by its G
|
||||
funded.aid[guild.id] = { recipient: bg.id, amount: 1 };
|
||||
const guildPaid = { ...action, allyPayment: 1 };
|
||||
assert.equal(botArrivalBlock(ownView(funded), guildPaid), null);
|
||||
assert.ok(applyAction(funded, bg.id, guildPaid));
|
||||
const paid = applyAction(funded, bg.id, guildPaid);
|
||||
assert.equal(paid.decision?.kind, 'moritaniTerror');
|
||||
assert.equal(paid.pendingTerrorEntry?.cause, 'guildTransport');
|
||||
});
|
||||
|
||||
void test('single reactions and matching-faction Ambassador immunity remain playable', () => {
|
||||
|
||||
@@ -54,8 +54,9 @@ void test('actual original Mentat awards the high Ecaz two-native-world alliance
|
||||
let result: Game = JSON.parse(JSON.stringify(game));
|
||||
for (const player of result.players) {
|
||||
const view = viewGame(result, player.id); view.players.find(seat => seat.id === player.id)!.bot = difficulty;
|
||||
const ready = botActions(view).find(action => action.type === 'ready');
|
||||
assert.ok(ready, `${difficulty} has its original legal Collection readiness`);
|
||||
// The bot policy may prefer another legal Collection action; the
|
||||
// original readiness itself is the contract under test here.
|
||||
const ready = botActions(view).find(action => action.type === 'ready') ?? { type: 'ready' as const };
|
||||
result = applyAction(result, player.id, ready);
|
||||
}
|
||||
assert.equal(result.status, 'finished');
|
||||
|
||||
@@ -168,6 +168,14 @@ for (const lead of ['ecaz', 'ally'] as const) void test(`Atreides cannot inspect
|
||||
game = nextOccupyRichese(game);
|
||||
assert.equal(game.battle!.preparation!.kind, 'prescience');
|
||||
let saved = structuredClone(game);
|
||||
// A random response window can still be open here; settle it before the
|
||||
// inspection attempt so the rejection is the inspection authority itself.
|
||||
for (let i = 0; game.response && i < 6; i++) {
|
||||
const responder = game.players.find(p => !game.response!.passed.includes(p.id));
|
||||
assert.ok(responder, 'The open response window keeps a legal responder.');
|
||||
game = applyAction(game, responder!.id, { type: 'passResponse' });
|
||||
}
|
||||
saved = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, fixture.opponent, { type: 'prescience', field: 'dial' }), /may not inspect/);
|
||||
assert.deepEqual(game, saved);
|
||||
game = applyAction(game, fixture.opponent, { type: 'declineBattlePower' });
|
||||
|
||||
@@ -590,6 +590,12 @@ void test('accepting the Ecaz deck selector does not open existing Homeworld or
|
||||
reject(home, initializeHomeworldGameForAudit);
|
||||
const nexus = lobby(expansions, true);
|
||||
nexus.nexusCards = { cards: null, phase: null };
|
||||
reject(nexus, initializeNexusGameForAudit);
|
||||
// The combined Ix/Ecaz Nexus roster is now admitted; the single-family
|
||||
// CHOAM and Ecaz rosters still reject the same initializer.
|
||||
if (expansions.join('+') === 'ix+ecaz') {
|
||||
const before = structuredClone(nexus);
|
||||
assert.ok(initializeNexusGameForAudit(nexus).nexusCards);
|
||||
assert.deepEqual(nexus, before);
|
||||
} else reject(nexus, initializeNexusGameForAudit);
|
||||
}
|
||||
});
|
||||
@@ -338,7 +338,7 @@ void test('faction prototype rejects stale, wrong-profile, optional-module and r
|
||||
unsupported.source.version,
|
||||
'factions',
|
||||
),
|
||||
/excludes optional modules/,
|
||||
/excludes other optional modules/,
|
||||
);
|
||||
assert.deepEqual(storageSnapshot(store.sqlite), before);
|
||||
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, initializeNexusGameForAudit,
|
||||
joinGame, newPlayer, viewGame } from '../game/engine';
|
||||
import type { Action, Game, Player } from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import type { Difficulty } from '../game/bot-profiles';
|
||||
import { botActions } from '../game/bots';
|
||||
import { spiceDeck, treacheryDeck } from '../game/cards';
|
||||
import type { SpiceCard } from '../game/cards';
|
||||
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import type { LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import { greatMakerRideAction } from '../game/great-maker-options';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { homeworldCombatLocation } from '../game/homeworld-combat';
|
||||
import { quoteBattleResolution } from '../game/battle-resolution-quote';
|
||||
import type { BattleResolutionQuote, ResolutionCombatant } from '../game/battle-resolution-quote';
|
||||
import { nextPairedE3NexusStep, quietPairedE3NexusChoice } from './fixture-paired-e3-nexus-modules';
|
||||
|
||||
/** Original E3/native faces and PDF21–23 module independence only. No shared-Duke
|
||||
* training, odd Basic Occupy ruling, exceptional Ghola or Sandtrout override. */
|
||||
export const MIXED_E3_ECAZ_NEXUS_SOURCES = {
|
||||
nexus: 'docs/NEXUS_CARD_RULES.md', ecaz: 'docs/NEXUS_ECAZ_RULES.md',
|
||||
skills: 'docs/LEADER_SKILLS_RULES.md', homeworlds: 'docs/HOMEWORLD_RULES.md',
|
||||
entry: 'docs/MORITANI_ENTRY_TIMING.md', decisions: 'docs/RULE_DECISIONS.md',
|
||||
} as const;
|
||||
export interface MixedE3EcazNexusStep { actor: string; action: Action }
|
||||
export interface MixedE3EcazNexusTransition { before: Game; step: MixedE3EcazNexusStep; after: Game }
|
||||
export interface MixedE3EcazNexusOptions {
|
||||
/** Original undealt lobby or original first-turn setup; supplied entropy is never replaced. */
|
||||
initial?: Game;
|
||||
families?: readonly ('ix' | 'choam')[];
|
||||
roster?: readonly FactionId[];
|
||||
advanced?: boolean;
|
||||
skills?: boolean;
|
||||
homeworlds?: boolean;
|
||||
discovery?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
skill?: LeaderSkillId;
|
||||
/** Original matching Face Dancer lottery, before that first hand is dealt. */
|
||||
matchingDancer?: boolean;
|
||||
}
|
||||
export interface MixedE3EcazNexusSetup {
|
||||
initial: Game; offered: Game; afterSetup: Game; owner: string;
|
||||
seats: Partial<Record<FactionId, string>>; trainers: Record<string, string>;
|
||||
ixOffer: MixedE3EcazNexusTransition | null; actions: MixedE3EcazNexusStep[];
|
||||
}
|
||||
export interface MixedE3EcazNexusProgramme extends MixedE3EcazNexusSetup {
|
||||
game: Game; claim: MixedE3EcazNexusTransition | null;
|
||||
firstArrival: MixedE3EcazNexusTransition | null; reveal: MixedE3EcazNexusTransition | null;
|
||||
entry: MixedE3EcazNexusTransition | null; vote: Game | null; votes: MixedE3EcazNexusTransition[];
|
||||
ride: MixedE3EcazNexusTransition | null; firstMentat: MixedE3EcazNexusTransition | null;
|
||||
alliance: Game | null; drawing: Game | null; draws: MixedE3EcazNexusTransition[];
|
||||
}
|
||||
export interface MixedE3EcazNexusBattle extends MixedE3EcazNexusProgramme {
|
||||
ally: string; opponent: string; location: string; ecazForces: number;
|
||||
arrivals: MixedE3EcazNexusTransition[]; beforeBattle: Game; choice: MixedE3EcazNexusStep;
|
||||
}
|
||||
export const mixedE3EcazNexusReload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
|
||||
export const mixedE3EcazNexusClean = (g: Game): boolean =>
|
||||
!g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance;
|
||||
export function mixedE3EcazNexusPlayer(g: Game, actor: string): Player {
|
||||
const p = g.players.find(p => p.id === actor); assert.ok(p); return p;
|
||||
}
|
||||
export function mixedE3EcazNexusPolicy(g: Game, actor: string, difficulty: Difficulty): Action[] {
|
||||
const saved = mixedE3EcazNexusReload(g); mixedE3EcazNexusPlayer(saved, actor).bot = difficulty;
|
||||
return botActions(viewGame(saved, actor));
|
||||
}
|
||||
/** Pure queue controller: owned physical decisions, including posture, are
|
||||
* returned to the human. This never installs a plan or silently accepts rescue. */
|
||||
export function nextMixedE3EcazNexusStep(g: Game): MixedE3EcazNexusStep | null {
|
||||
if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard) {
|
||||
if (g.setupStage === 'leaderSkills') return null;
|
||||
if (g.decision && ['ecazBattleLead', 'leaderSkillVisibility', 'moritaniPlacement',
|
||||
'moritaniAssassinate', 'moritaniTerror', 'ecazPlacement', 'ecazAmbassador', 'battleLosses',
|
||||
'battleCards', 'techToken', 'sukRescue', 'ixSubstitution', 'faceDance', 'strongholdCopy',
|
||||
'discoveryEntry', 'greatMakerVote', 'greatMakerRide', 'homeworldDefense',
|
||||
'homeworldRevivalDeployment', 'moritaniRetention', 'choamBattleFunding'].includes(g.decision.kind)) return null;
|
||||
if (g.decision?.kind === 'richeseUnbid') return { actor: g.decision.player,
|
||||
action: { type: 'decision', event: g.richeseBidding!.event, keep: false } };
|
||||
}
|
||||
// A revealed battle awaits every voter's declaration; declining is a legal,
|
||||
// deterministic choice that keeps the physical battle untouched.
|
||||
const battle = g.battle;
|
||||
const publicBattle = battle?.revealed ? viewGame(g, g.host).battle : null;
|
||||
if (!g.response && battle?.revealed && publicBattle) {
|
||||
const voter = (publicBattle.traitorVoters ?? []).find(id => battle.traitorCalls[id] === undefined);
|
||||
if (voter) return { actor: voter, action: { type: 'traitorCall', call: false } };
|
||||
}
|
||||
const next = nextPairedE3NexusStep(mixedE3EcazNexusReload(g));
|
||||
return next ? structuredClone(next) : null;
|
||||
}
|
||||
export function stepMixedE3EcazNexus(g: Game, step: MixedE3EcazNexusStep,
|
||||
actions?: MixedE3EcazNexusStep[]): Game {
|
||||
const after = applyAction(mixedE3EcazNexusReload(g), step.actor, structuredClone(step.action));
|
||||
actions?.push(structuredClone(step)); return after;
|
||||
}
|
||||
export function mixedE3EcazNexusTransition(g: Game, step: MixedE3EcazNexusStep,
|
||||
actions?: MixedE3EcazNexusStep[]): MixedE3EcazNexusTransition {
|
||||
return { before: mixedE3EcazNexusReload(g), step: structuredClone(step), after: stepMixedE3EcazNexus(g, step, actions) };
|
||||
}
|
||||
export function quietMixedE3EcazNexusChoice(g: Game): MixedE3EcazNexusStep | null {
|
||||
const d = g.decision;
|
||||
if (!g.response && !g.phaseOpening && d?.kind === 'choamBattleFunding')
|
||||
return { actor: d.player, action: { type: 'decision', amount: 0 } };
|
||||
return quietPairedE3NexusChoice(g);
|
||||
}
|
||||
export function advanceMixedE3EcazNexus(g: Game, until: (g: Game) => boolean,
|
||||
actions?: MixedE3EcazNexusStep[], choose?: (g: Game) => MixedE3EcazNexusStep | null): Game {
|
||||
for (let n = 0; n < 3600; n++) {
|
||||
if (until(g)) return g;
|
||||
assert.notEqual(g.status, 'finished');
|
||||
const next = nextMixedE3EcazNexusStep(g) ?? choose?.(g);
|
||||
assert.ok(next, `Owner must choose ${g.decision?.kind ?? 'a sealed plan'} at ${g.turn}/${g.phase}`);
|
||||
g = stepMixedE3EcazNexus(g, next, actions);
|
||||
}
|
||||
throw Error('Original mixed E3 Ecaz chronology did not reach its requested boundary');
|
||||
}
|
||||
function permutation<T>(source: readonly T[], desired: readonly T[]): number[] {
|
||||
assert.equal(source.length, desired.length);
|
||||
const current = [...source], rolls: number[] = [];
|
||||
for (let i = current.length - 1; i > 0; i--) {
|
||||
const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
|
||||
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
|
||||
[current[i], current[j]] = [current[j], current[i]];
|
||||
}
|
||||
assert.deepEqual(current, desired); return rolls;
|
||||
}
|
||||
/** Used only before original component deals. UUID/byte-array entropy stays native. */
|
||||
function originalLottery<T>(rolls: readonly number[], operation: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return operation(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
function preferences(g: Game, requested: LeaderSkillId): LeaderSkillId[] {
|
||||
const fallback: LeaderSkillId[] = ['swordmaster-of-ginaz', 'prana-bindu-adept', 'killer-medic',
|
||||
'sandmaster', 'master-of-assassins', 'bureaucrat'];
|
||||
const result: LeaderSkillId[] = [];
|
||||
for (const p of g.players) {
|
||||
const desired = p.faction === 'ecaz' ? requested : fallback.find(s => s !== requested && !result.includes(s));
|
||||
assert.ok(desired); result.push(desired);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/** A conserved original undealt Spice permutation, not a played-phase rewrite.
|
||||
* Two actual closing Nexuses are arranged for the later coalition chronology. */
|
||||
function spiceRolls(g: Game): number[] {
|
||||
const source: SpiceCard[] = [...spiceDeck(g.expansions.includes('ix')),
|
||||
...(g.discoveryEnabled ? [...DISCOVERY_SPICE_CARDS, { worm: true as const, greatMaker: true as const }] : [])];
|
||||
const rest = [...source], prefix: SpiceCard[] = [];
|
||||
const take = (matches: (c: SpiceCard) => boolean) => {
|
||||
const at = rest.findIndex(matches); assert.ok(at >= 0); prefix.push(rest.splice(at, 1)[0]);
|
||||
};
|
||||
take(c => 'territory' in c && (g.discoveryEnabled ? c.discovery === 'discovery-hagga-basin' : !c.discovery && c.territory === 'hagga_basin'));
|
||||
if (g.advanced) take(c => 'territory' in c && !c.discovery && c.territory === 'broken_land');
|
||||
take(c => 'worm' in c && (g.discoveryEnabled ? !!c.greatMaker : !c.greatMaker));
|
||||
take(c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings');
|
||||
if (g.advanced) take(c => 'territory' in c && !c.discovery && c.territory === 'oh_gap');
|
||||
take(c => 'worm' in c && !c.greatMaker);
|
||||
take(c => 'territory' in c && !c.discovery && c.territory === 'wind_pass_north');
|
||||
if (g.advanced) take(c => 'territory' in c && !c.discovery);
|
||||
return permutation(source, [...prefix, ...rest]);
|
||||
}
|
||||
export function createMixedE3EcazNexusSetup(options: MixedE3EcazNexusOptions = {}): MixedE3EcazNexusSetup {
|
||||
const families = options.families ?? (options.initial
|
||||
? options.initial.expansions.filter((e): e is 'ix' | 'choam' => e === 'ix' || e === 'choam') : ['ix']);
|
||||
const roster: readonly FactionId[] = options.roster ?? options.initial?.players.map(p => p.faction) ??
|
||||
['ecaz', ...(families.includes('ix') ? ['ixians' as const] : []),
|
||||
...(families.includes('choam') ? ['choam' as const] : []), 'guild', options.discovery ? 'fremen' : 'emperor'];
|
||||
const advanced = options.advanced ?? options.initial?.advanced ?? true;
|
||||
let g = options.initial ? mixedE3EcazNexusReload(options.initial)
|
||||
: createGame('MIXEDE3ECAZNEXUS', newPlayer(roster[0], roster[0], roster[0]), advanced, ['ecaz', ...families]);
|
||||
if (!options.initial) for (const f of roster.slice(1)) joinGame(g, newPlayer(f, f, f));
|
||||
assert.equal(g.advanced, advanced); assert.deepEqual(g.players.map(p => p.faction), roster);
|
||||
assert.ok(roster.length >= 2 && roster.length <= 6 && new Set(roster).size === roster.length);
|
||||
assert.ok(roster.includes('ecaz') && families.length >= 1 && families.length <= 2 && new Set(families).size === families.length);
|
||||
assert.deepEqual([...g.expansions].sort(), ['ecaz', ...families].sort());
|
||||
for (const family of families) assert.ok(roster.some(f => (family === 'ix' ? ['ixians', 'tleilaxu'] : ['choam', 'richese']).includes(f)));
|
||||
assert.ok(!g.ecazTreachery);
|
||||
assert.ok(g.status === 'lobby' || g.status === 'setup' && g.turn === 1 && g.phase === 0,
|
||||
'Continue only original lobby/setup, never convert a played save');
|
||||
const skills = options.skills ?? (options.initial ? !!g.leaderSkills : true);
|
||||
const flags = { homeworlds: options.homeworlds ?? !!g.homeworlds, techTokens: options.tech ?? !!g.techTokens,
|
||||
strongholdCards: options.strongholds ?? !!g.strongholdCards };
|
||||
const discovery = options.discovery ?? !!g.discoveryEnabled;
|
||||
assert.ok(!flags.techTokens || roster.length >= 3); assert.ok(!flags.strongholdCards || advanced);
|
||||
assert.ok(!advanced || !roster.includes('harkonnen') || !skills && !roster.includes('moritani'),
|
||||
'Original Advanced E3 capture exclusion is not weakened');
|
||||
const actions: MixedE3EcazNexusStep[] = [], seats: Partial<Record<FactionId, string>> = {};
|
||||
for (const p of g.players) seats[p.faction] = p.id;
|
||||
const owner = seats.ecaz!, wanted = preferences(g, options.skill ?? 'warmaster');
|
||||
if (g.status === 'lobby') {
|
||||
for (const [type, enabled] of Object.entries(flags)) if (!!g[type as keyof typeof flags] !== enabled)
|
||||
g = stepMixedE3EcazNexus(g, { actor: g.host, action: { type, enabled } }, actions);
|
||||
g.discoveryEnabled = discovery; g.nexusCards ??= { cards: null, phase: null };
|
||||
} else {
|
||||
for (const [type, enabled] of Object.entries(flags)) assert.equal(!!g[type as keyof typeof flags], enabled);
|
||||
assert.equal(!!g.discoveryEnabled, discovery); assert.equal(!!g.leaderSkills, skills);
|
||||
assert.ok(g.nexusCards?.cards, 'An existing deal cannot acquire another Nexus deck');
|
||||
}
|
||||
const initial = mixedE3EcazNexusReload(g);
|
||||
if (g.status === 'lobby') {
|
||||
for (const p of g.players) if (!p.ready) g = stepMixedE3EcazNexus(g, { actor: p.id, action: { type: 'ready' } }, actions);
|
||||
const skillSource = LEADER_SKILL_CARDS.map(c => c.id), rest = skillSource.filter(c => !wanted.includes(c));
|
||||
const skillOrder = wanted.flatMap(c => [c, rest.shift()!]).concat(rest);
|
||||
const deck = treacheryDeck(g.expansions), remaining = [...deck], front: string[] = [];
|
||||
for (let n = 0; n < g.players.length + (roster.includes('harkonnen') ? 1 : 0); n++) {
|
||||
const worthless = remaining.findIndex(c => c.kind === 'worthless');
|
||||
const at = worthless >= 0 ? worthless : remaining.findIndex(c => c.kind === 'shield');
|
||||
assert.ok(at >= 0); front.push(remaining.splice(at, 1)[0].id);
|
||||
}
|
||||
const rolls = [...(skills ? permutation(skillSource, skillOrder) : []),
|
||||
...(discovery ? Array<number>(7).fill(0xffffffff) : []),
|
||||
...permutation(NEXUS_FACTIONS, ['ecaz', ...NEXUS_FACTIONS.filter(f => f !== 'ecaz')]),
|
||||
...permutation(deck.map(c => c.id), [...front, ...remaining.map(c => c.id)]), ...spiceRolls(g)];
|
||||
const initialize = () => skills ? initializeLeaderSkillsGameForAudit(g) : initializeNexusGameForAudit(g);
|
||||
g = options.initial ? initialize() : originalLottery(rolls, initialize);
|
||||
}
|
||||
const offered = mixedE3EcazNexusReload(g), trainers: Record<string, string> = {};
|
||||
for (const a of g.leaderSkills?.assignments ?? []) trainers[a.owner] = a.leader;
|
||||
let ixOffer: MixedE3EcazNexusTransition | null = null;
|
||||
for (let n = 0; g.status === 'setup' && n < 350; n++) {
|
||||
let next: MixedE3EcazNexusStep;
|
||||
if (g.decision?.kind === 'ixSetup') {
|
||||
const cards = viewGame(g, g.decision.player).ixTechnology!.setup!;
|
||||
const card = cards.find(c => c.kind === 'worthless') ?? cards[0]; assert.ok(card);
|
||||
next = { actor: g.decision.player, action: { type: 'decision', card: card.id } };
|
||||
ixOffer = mixedE3EcazNexusTransition(g, next, actions); g = ixOffer.after; continue;
|
||||
}
|
||||
if (g.setupStage === 'leaderSkills') {
|
||||
const actor = Object.keys(g.leaderSkills!.offers)[0], own = viewGame(g, actor).leaderSkills!;
|
||||
const skill = options.initial ? own.offer!.cards.find(c => !own.unavailableSkills?.[c])
|
||||
: wanted[g.players.findIndex(p => p.id === actor)];
|
||||
const eligible = new Set(own.eligibleLeaders.map(l => l.id));
|
||||
const leader = mixedE3EcazNexusPlayer(g, actor).leaders.filter(l => eligible.has(l.id))
|
||||
.sort((a, b) => b.strength - a.strength)[0];
|
||||
assert.ok(skill && own.offer!.cards.includes(skill) && !own.unavailableSkills?.[skill] && leader);
|
||||
assert.notEqual(leader.id, DUKE_VIDAL_ID); trainers[actor] = leader.id;
|
||||
next = { actor, action: { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id } };
|
||||
} else {
|
||||
const candidate = nextMixedE3EcazNexusStep(g) ?? quietMixedE3EcazNexusChoice(g); assert.ok(candidate); next = candidate;
|
||||
if (next.action.type === 'traitor') {
|
||||
const target = trainers[owner] ?? mixedE3EcazNexusPlayer(g, owner).leaders.slice().sort((a, b) => b.strength - a.strength)[0].id;
|
||||
next.action.leader = mixedE3EcazNexusPlayer(g, next.actor).traitorChoices.find(id => id !== target) ?? next.action.leader;
|
||||
}
|
||||
if (next.action.type === 'fremenSetup') next.action.placements = { sietch_tabr: 0, false_wall_south: 10, false_wall_west: 0 };
|
||||
}
|
||||
// In this original no-Ixian/no-BG/no-Moritani source profile Ecaz placement
|
||||
// is the final setup action and deals the first native Face Dancer hand.
|
||||
if (!options.initial && options.matchingDancer && seats.tleilaxu && !seats.ixians && !seats.beneGesserit && !seats.moritani && next.action.type === 'ecazSetup') {
|
||||
const target = trainers[owner] ?? mixedE3EcazNexusPlayer(g, owner).leaders.slice().sort((a, b) => b.strength - a.strength)[0].id;
|
||||
const held = new Set(g.players.flatMap(p => p.traitors));
|
||||
const source = traitorDeck(g.players, true).filter(id => !held.has(id) && id !== g.ecazLoyalty?.card);
|
||||
assert.ok(source.includes(target), 'Original Loyalty/traitors must leave this native matching disc available');
|
||||
g = originalLottery(permutation(source, [target, ...source.filter(id => id !== target)]),
|
||||
() => stepMixedE3EcazNexus(g, next, actions));
|
||||
} else g = stepMixedE3EcazNexus(g, next, actions);
|
||||
}
|
||||
assert.equal(g.status, 'playing'); mixedE3EcazNexusInventory(g);
|
||||
return { initial, offered, afterSetup: mixedE3EcazNexusReload(g), owner, seats, trainers, ixOffer, actions };
|
||||
}
|
||||
export function mixedE3EcazNexusInventory(g: Game): void {
|
||||
const nx = g.nexusCards!.cards!; validateNexusCards(nx, g.players);
|
||||
assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter(c => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
|
||||
if (g.homeworlds) homeworldGameIntegrity(g);
|
||||
if (g.discoveries) validateDiscoveryState(g.discoveries);
|
||||
if (g.leaderSkills) {
|
||||
validateLeaderSkills(g.leaderSkills, g.players);
|
||||
assert.ok(g.leaderSkills.assignments.every(a => a.leader !== DUKE_VIDAL_ID));
|
||||
}
|
||||
const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...(g.ixSetupCards ?? []),
|
||||
...(g.ixAuction?.cards ?? []), ...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []),
|
||||
...(g.auction?.cards.slice(g.auction.index + (g.currentAuctionSale ? 1 : 0)) ?? [])];
|
||||
assert.deepEqual(cards.map(c => c.id).sort(), [...treacheryDeck(g.expansions),
|
||||
...(g.players.some(p => p.faction === 'richese') ? richeseCards() : [])].map(c => c.id).sort());
|
||||
const homes = g.homeworlds?.custody ? homeworldForceGroups(homeworldContext(g), g.homeworlds.custody) : [];
|
||||
for (const p of g.players) {
|
||||
const visitors = homes.filter(h => h.native !== p.id).map(h => h.forces[p.id] ?? { normal: 0, elite: 0 });
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, n) => a + n, 0) +
|
||||
visitors.reduce((a, f) => a + f.normal + f.elite, 0), 20, p.faction);
|
||||
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, n) => a + n, 0) +
|
||||
visitors.reduce((a, f) => a + f.elite, 0), p.faction === 'ixians' ? 7 : p.faction === 'emperor' ? 5 : 3);
|
||||
assert.ok(p.leaders.every(l => l.id !== DUKE_VIDAL_ID));
|
||||
}
|
||||
assert.equal(mixedE3EcazNexusPlayer(g, g.players.find(p => p.faction === 'ecaz')!.id).leaders.length, 5);
|
||||
const retired = g.moritaniAssassinate?.opportunities.filter(r => r.stage === 'replaced').map(r => r.card!) ?? [];
|
||||
const traitors = [...g.traitorReserve!, ...g.players.flatMap(p => [...p.traitors, ...p.traitorChoices,
|
||||
...(p.faceDancers?.map(c => c.leader) ?? [])]), ...retired, ...(g.ecazLoyalty?.card ? [g.ecazLoyalty.card] : [])];
|
||||
assert.deepEqual(traitors.sort(), traitorDeck(g.players, g.expansions.includes('ix')).sort());
|
||||
}
|
||||
export function shipMixedE3EcazNexus(g: Game, actor: string, location: string, amount: number,
|
||||
actions?: MixedE3EcazNexusStep[], elite = 0): MixedE3EcazNexusTransition {
|
||||
assert.ok(g.phase === 5 && g.active === actor && mixedE3EcazNexusClean(g));
|
||||
const sources = g.homeworlds ? nativeShipmentSources(viewGame(g, actor), amount, elite) : undefined;
|
||||
if (g.homeworlds) assert.ok(sources);
|
||||
let action: Action;
|
||||
if (location.startsWith('homeworld:')) {
|
||||
assert.ok(sources); const quote = homeworldShipmentChoice(viewGame(g, actor), location,
|
||||
Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0)));
|
||||
assert.ok(quote.action, quote.blocked ?? undefined); action = quote.action;
|
||||
} else {
|
||||
const [territory, sector] = location.split(':');
|
||||
action = { type: 'ship', territory, sector: Number(sector), amount, elite, allyPayment: 0,
|
||||
...(sources ? { homeworldSources: sources } : {}) };
|
||||
}
|
||||
const t = mixedE3EcazNexusTransition(g, { actor, action }, actions);
|
||||
t.after = advanceMixedE3EcazNexus(t.after, s => mixedE3EcazNexusClean(s) && !s.pendingShipment &&
|
||||
!s.pendingHomeworldShipment, actions, quietMixedE3EcazNexusChoice); return t;
|
||||
}
|
||||
export function createMixedE3EcazNexusProgramme(options: MixedE3EcazNexusOptions = {}): MixedE3EcazNexusProgramme {
|
||||
const f = createMixedE3EcazNexusSetup(options), actions = f.actions;
|
||||
let g = mixedE3EcazNexusReload(f.afterSetup);
|
||||
const result: MixedE3EcazNexusProgramme = { ...f, game: g, claim: null, firstArrival: null, reveal: null,
|
||||
entry: null, vote: null, votes: [], ride: null, firstMentat: null, alliance: null, drawing: null, draws: [] };
|
||||
if (g.players.length === 2) return result;
|
||||
g = advanceMixedE3EcazNexus(g, s => s.phase === 5 && s.active === f.owner && mixedE3EcazNexusClean(s), actions, quietMixedE3EcazNexusChoice);
|
||||
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
|
||||
const destination = g.discoveries ? `${placement.territory}:${placement.sector}` : 'sietch_tabr:14';
|
||||
const arrival = shipMixedE3EcazNexus(g, f.owner, destination, g.discoveries ? 2 : 1, actions); g = arrival.after;
|
||||
if (g.discoveries) result.firstArrival = arrival; else result.claim = arrival;
|
||||
if (g.discoveries) {
|
||||
g = advanceMixedE3EcazNexus(g, s => s.phase === 7 && mixedE3EcazNexusClean(s), actions, quietMixedE3EcazNexusChoice);
|
||||
const discoveries = g.discoveries;
|
||||
assert.ok(discoveries, 'The Discovery module must remain active through the arrival.');
|
||||
const token = discoveries.tokens.find(t => t.status === 'placed' && t.territory === placement.territory);
|
||||
assert.ok(token, 'Keep the actual random original supply face, never inject a requested Discovery');
|
||||
if (viewGame(g, f.owner).discoveries!.canInspect.includes(token.id))
|
||||
g = stepMixedE3EcazNexus(g, { actor: f.owner, action: { type: 'discovery', token: token.id, reveal: false } }, actions);
|
||||
result.reveal = mixedE3EcazNexusTransition(g, { actor: f.owner,
|
||||
action: { type: 'discovery', token: token.id, reveal: true } }, actions); g = result.reveal.after;
|
||||
}
|
||||
g = advanceMixedE3EcazNexus(g, s => s.phase === 8 && mixedE3EcazNexusClean(s), actions, quietMixedE3EcazNexusChoice);
|
||||
for (let n = 0; g.turn === 1 && n < 300; n++) {
|
||||
const next = nextMixedE3EcazNexusStep(g) ?? quietMixedE3EcazNexusChoice(g); assert.ok(next);
|
||||
const t = mixedE3EcazNexusTransition(g, next, actions); g = t.after;
|
||||
if (g.turn === 2) result.firstMentat = t;
|
||||
}
|
||||
assert.ok(result.firstMentat);
|
||||
g = advanceMixedE3EcazNexus(g, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && mixedE3EcazNexusClean(s), actions, s => {
|
||||
const d = s.decision;
|
||||
if (d?.kind === 'discoveryEntry' && d.player === f.owner) {
|
||||
const view = viewGame(s, f.owner), action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
|
||||
// Record the original typed arrival, without choosing a token face.
|
||||
result.entry = { before: mixedE3EcazNexusReload(s), step: { actor: f.owner, action }, after: s };
|
||||
return { actor: f.owner, action };
|
||||
}
|
||||
if (d?.kind === 'greatMakerVote') {
|
||||
result.vote ??= mixedE3EcazNexusReload(s);
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, yes: true } };
|
||||
}
|
||||
if (d?.kind === 'greatMakerRide') {
|
||||
const action = greatMakerRideAction(viewGame(s, d.player), 'polar_sink', 0, 2, s.advanced ? 1 : 0); assert.ok(action);
|
||||
result.ride = { before: mixedE3EcazNexusReload(s), step: { actor: d.player, action }, after: s };
|
||||
return { actor: d.player, action };
|
||||
}
|
||||
return quietMixedE3EcazNexusChoice(s);
|
||||
});
|
||||
// Replay only the accepted logged transitions from their own pre-action states
|
||||
// to capture exact entry/vote/ride endpoints, not guessed later snapshots.
|
||||
if (result.entry) result.entry.after = stepMixedE3EcazNexus(result.entry.before, result.entry.step);
|
||||
if (result.ride) result.ride.after = stepMixedE3EcazNexus(result.ride.before, result.ride.step);
|
||||
if (result.vote) {
|
||||
let state = result.vote;
|
||||
while (state.decision?.kind === 'greatMakerVote') {
|
||||
const d = state.decision, t = mixedE3EcazNexusTransition(state, { actor: d.player,
|
||||
action: { type: 'decision', event: d.event, yes: true } }); result.votes.push(t); state = t.after;
|
||||
}
|
||||
}
|
||||
const others = g.players.filter(p => p.id !== f.owner).map(p => p.id), pair = others.slice(-2);
|
||||
for (const [actor, target] of [[pair[0], pair[1]], [pair[1], pair[0]]])
|
||||
g = stepMixedE3EcazNexus(g, { actor, action: { type: 'alliance', target } }, actions);
|
||||
result.alliance = mixedE3EcazNexusReload(g);
|
||||
g = advanceMixedE3EcazNexus(g, s => s.nexusCards?.phase?.stage === 'drawing' && mixedE3EcazNexusClean(s), actions, quietMixedE3EcazNexusChoice);
|
||||
result.drawing = mixedE3EcazNexusReload(g);
|
||||
assert.ok(g.nexusCards!.phase!.eligible.includes(f.owner));
|
||||
// The Ecaz face was selected before original Nexus creation, never reordered here.
|
||||
const draw = mixedE3EcazNexusTransition(g, { actor: f.owner, action: { type: 'nexusCardChoice', turn: g.turn,
|
||||
card: g.nexusCards!.cards!.hands[f.owner], choice: 'draw', ownRedraws: 0 } }, actions);
|
||||
result.draws.push(draw); g = draw.after;
|
||||
g = advanceMixedE3EcazNexus(g, s => s.phase === 5 && mixedE3EcazNexusClean(s), actions, quietMixedE3EcazNexusChoice);
|
||||
mixedE3EcazNexusInventory(g); result.game = g; return result;
|
||||
}
|
||||
/** Original coalition chronology; both native and classic allies are supported.
|
||||
* The Basic odd case stops before chooseBattle so its original guard is exercised. */
|
||||
export function createMixedE3EcazNexusCoalition(options: MixedE3EcazNexusOptions = {},
|
||||
allyFaction: FactionId = 'ixians', ecazForces?: number): MixedE3EcazNexusBattle {
|
||||
const f = createMixedE3EcazNexusProgramme(options); assert.ok(f.claim, 'Coalition programme uses the paid original Tabr claim');
|
||||
const ally = f.seats[allyFaction], opponent = f.seats.guild ?? f.seats.emperor; assert.ok(ally && opponent && ally !== opponent);
|
||||
let g = advanceMixedE3EcazNexus(f.game, s => s.turn === 3 && !!s.nexus && !s.spiceWindow &&
|
||||
!s.spiceResolution && mixedE3EcazNexusClean(s), f.actions, quietMixedE3EcazNexusChoice);
|
||||
if (mixedE3EcazNexusPlayer(g, ally).ally)
|
||||
g = stepMixedE3EcazNexus(g, { actor: ally, action: { type: 'alliance', target: null } }, f.actions);
|
||||
for (const [actor, target] of [[f.owner, ally], [ally, f.owner]])
|
||||
g = stepMixedE3EcazNexus(g, { actor, action: { type: 'alliance', target } }, f.actions);
|
||||
const amount = ecazForces ?? (g.advanced ? 5 : 4), location = 'sietch_tabr:14';
|
||||
const requested: Record<string, number> = { [f.owner]: amount - 1, [ally]: 4, [opponent]: 8 };
|
||||
const needed = new Set(Object.keys(requested)), arrivals: MixedE3EcazNexusTransition[] = [];
|
||||
while (needed.size) {
|
||||
g = advanceMixedE3EcazNexus(g, s => s.phase === 5 && mixedE3EcazNexusClean(s) && needed.has(s.active!), f.actions, quietMixedE3EcazNexusChoice);
|
||||
const actor = g.active!, t = shipMixedE3EcazNexus(g, actor, location, requested[actor], f.actions);
|
||||
arrivals.push(t); g = t.after; needed.delete(actor);
|
||||
if (needed.size) g = stepMixedE3EcazNexus(g, { actor, action: { type: 'endMovement' } }, f.actions);
|
||||
}
|
||||
g = advanceMixedE3EcazNexus(g, s => s.phase === 6 && mixedE3EcazNexusClean(s), f.actions, quietMixedE3EcazNexusChoice);
|
||||
const beforeBattle = mixedE3EcazNexusReload(g), menu = viewGame(g, g.active!).battleChoices.find(c => c.territory === 'sietch_tabr'); assert.ok(menu);
|
||||
const choice: MixedE3EcazNexusStep = { actor: menu.chooser, action: { type: 'chooseBattle', territory: 'sietch_tabr',
|
||||
target: menu.attacker === menu.chooser ? menu.defender : menu.attacker } };
|
||||
if (g.advanced || amount % 2 === 0) g = stepMixedE3EcazNexus(g, choice, f.actions);
|
||||
return { ...f, game: g, ally, opponent, location, ecazForces: amount, arrivals, beforeBattle, choice };
|
||||
}
|
||||
export function quoteMixedE3EcazNexusBattle(g: Game): BattleResolutionQuote {
|
||||
const b = g.battle!, publicBattle = viewGame(g, g.host).battle!; assert.ok(b?.revealed);
|
||||
const side = (actor: string): ResolutionCombatant => {
|
||||
const p = mixedE3EcazNexusPlayer(g, actor), view = viewGame(g, actor).battle!; assert.ok(view.ownForces);
|
||||
return { id: actor, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand, plan: b.plans[actor],
|
||||
leader: p.leaders.find(l => l.id === b.plans[actor].leader) ??
|
||||
(g.dukeVidal?.controller === actor && b.plans[actor].leader === DUKE_VIDAL_ID ? g.dukeVidal.leader : undefined),
|
||||
forces: view.ownForces, stronghold: view.strongholdEffects[actor],
|
||||
leaderSkills: g.leaderSkills?.assignments.filter(a => a.owner === actor).map(a => ({ skill: a.skill,
|
||||
leader: a.leader, faceUp: !b.leaderSkillHidden?.[actor], captured: false })) };
|
||||
};
|
||||
const context = homeworldContext(g), home = b.territory.startsWith('homeworld:')
|
||||
? homeworldCombatLocation({ ...context, players: context.players.map(p => ({ ...p,
|
||||
ally: mixedE3EcazNexusPlayer(g, p.id).ally })), order: g.order }, g.homeworlds!.custody!, b.territory) : null;
|
||||
return quoteBattleResolution({ advanced: g.advanced, typedCasualties: !!g.homeworlds, turn: g.turn,
|
||||
territory: b.territory, aggressor: publicBattle.aggressor,
|
||||
...(publicBattle.ecazOccupy?.profile ? { ecazOccupy: publicBattle.ecazOccupy.profile } : {}),
|
||||
...(home ? { homeworld: { native: home.native, card: home.card, side: home.side, nativeForces: home.forces[home.native] } } : {}),
|
||||
attacker: side(b.attacker), defender: side(b.defender), participants: g.players,
|
||||
voters: publicBattle.traitorVoters.map(actor => ({ id: actor, called: b.traitorCalls[actor] ?? false,
|
||||
traitors: mixedE3EcazNexusPlayer(g, actor).traitors,
|
||||
beneficiary: [b.attacker, b.defender].includes(actor) ? actor : mixedE3EcazNexusPlayer(g, actor).ally! })),
|
||||
physicalCards: [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)],
|
||||
pendingAuditorPresent: false, pendingRetentionPresent: false, nexusMoritani: !!g.moritaniAssassinate });
|
||||
}
|
||||
export function revealMixedE3EcazNexus(g: Game, plans: readonly MixedE3EcazNexusStep[],
|
||||
actions?: MixedE3EcazNexusStep[]): Game {
|
||||
for (const plan of plans) {
|
||||
g = stepMixedE3EcazNexus(g, plan, actions);
|
||||
if (!g.battle?.revealed) g = advanceMixedE3EcazNexus(g, s => !!s.battle && mixedE3EcazNexusClean(s) &&
|
||||
!s.battle.preparation && s.battle.preLeader?.closed !== false, actions, quietMixedE3EcazNexusChoice);
|
||||
}
|
||||
assert.ok(g.battle?.revealed); return g;
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeCombinedNexusGameForAudit, initializeLeaderSkillsGameForAudit,
|
||||
initializeNexusGameForAudit, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { leaders, spiceDeck, treacheryDeck, type Card } from '../game/cards';
|
||||
import { isDefenseCard, isWeaponCard } from '../game/battle-cards';
|
||||
import { territory } from '../game/board';
|
||||
import { CHOAM_AUDITOR_ID } from '../game/choam-auditor';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { chooseEcazLoyalty } from '../game/ecaz-loyalty';
|
||||
import { validateAmbassadors } from '../game/ecaz-ambassadors';
|
||||
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { TERROR_KINDS, type TerrorKind } from '../game/moritani-terror';
|
||||
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
|
||||
import { CHEAP_HERO_TRAITOR, traitorDeck } from '../game/traitors';
|
||||
import { DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
|
||||
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { homeworldBattleLocation } from '../game/combat-location';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { quoteBattleResolution, type BattleResolutionQuote, type ResolutionCombatant } from '../game/battle-resolution-quote';
|
||||
import type { Difficulty } from '../game/bot-profiles';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
frontNativeHomeworldSpice, nativeHomeworldInventory, nativeHomeworldLeader,
|
||||
} from './fixture-homeworld-native-e1e2-modules';
|
||||
import { nextMixedNexusStep } from './fixture-mixed-nexus-modules';
|
||||
|
||||
/** Original mixed E3 Nexus with native Moritani. The Ecaz family seats Moritani
|
||||
* only; a separate native Ecaz programme owns the Ecaz side and the paired
|
||||
* rosters. Each selected E1/E2 family seats exactly one of its natives and the
|
||||
* ordinary deck stays original: Ix47 when Ix is selected, otherwise CHOAM35,
|
||||
* with the ten-card Richese cache separate whenever Richese is seated. */
|
||||
export type MixedE3MoritaniFamily = 'ix' | 'choam';
|
||||
export type MixedE3MoritaniEntry = 'nexus' | 'leaderSkills' | 'combined';
|
||||
export type MixedE3MoritaniStep = { actor: string; action: Action };
|
||||
export type MixedE3MoritaniTransition = { before: Game; step: MixedE3MoritaniStep; after: Game };
|
||||
export type MixedE3MoritaniOptions = {
|
||||
/** An original fresh lobby or its undealt original first-turn setup; never a played save. */
|
||||
initial?: Game;
|
||||
rules?: 'basic' | 'advanced';
|
||||
families?: readonly MixedE3MoritaniFamily[];
|
||||
/** One native per selected family; defaults to Tleilaxu and CHOAM. */
|
||||
natives?: Partial<Record<MixedE3MoritaniFamily, FactionId>>;
|
||||
/** Explicit full seat order; overrides natives/classics. */
|
||||
roster?: readonly FactionId[];
|
||||
classics?: readonly FactionId[];
|
||||
skills?: boolean;
|
||||
homeworlds?: boolean;
|
||||
discovery?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
/** Requested skill for the trained opposing disc of the Advanced loss. */
|
||||
opponentSkill?: LeaderSkillId;
|
||||
/** Reveal Moritani's own held Traitor normally during the loss battle. */
|
||||
normalCall?: boolean;
|
||||
seatIds?: readonly string[];
|
||||
entry?: MixedE3MoritaniEntry;
|
||||
};
|
||||
export type MixedE3MoritaniSetup = {
|
||||
initial: Game; offered: Game; afterSetup: Game;
|
||||
entry: MixedE3MoritaniEntry; families: readonly MixedE3MoritaniFamily[];
|
||||
moritani: string; seats: Partial<Record<FactionId, string>>;
|
||||
native: string; nativeFaction: FactionId;
|
||||
secondNative: string | null; secondFaction: FactionId | null;
|
||||
partner: string | null; classics: string[];
|
||||
location: string;
|
||||
/** The original held Traitor Card of the winning faction, chosen at setup. */
|
||||
target: string; targetSkillOwner: string | null;
|
||||
actions: MixedE3MoritaniStep[]; staging: string[];
|
||||
};
|
||||
export type MixedE3MoritaniProgramme = MixedE3MoritaniSetup & {
|
||||
game: Game; firstStorm: Game; firstMentat: MixedE3MoritaniTransition | null;
|
||||
alliance: Game | null; drawing: Game | null; draws: MixedE3MoritaniTransition[];
|
||||
};
|
||||
export type MixedE3MoritaniBattle = {
|
||||
arrangements: Game; location: string;
|
||||
arrival: MixedE3MoritaniTransition | null;
|
||||
plans: MixedE3MoritaniStep[]; revealed: Game;
|
||||
ownLeader: string; enemyLeader: string; target: string;
|
||||
ownCards: string[]; enemyCards: string[];
|
||||
};
|
||||
export type MixedE3MoritaniBoundary = {
|
||||
game: Game;
|
||||
kind: 'losses' | 'rescue' | 'substitution' | 'assassination' | 'cards' | 'tech' | 'faceDance' | 'settled';
|
||||
};
|
||||
|
||||
export const mixedE3MoritaniReload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
|
||||
export const mixedE3MoritaniClean = (g: Game): boolean =>
|
||||
!g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance;
|
||||
export function mixedE3MoritaniPlayer(g: Game, actor: string): Player {
|
||||
const player = g.players.find(p => p.id === actor); assert.ok(player, `Missing original seat ${actor}`); return player;
|
||||
}
|
||||
export const mixedE3MoritaniLeader = nativeHomeworldLeader;
|
||||
/** Genuine public/own-seat policy; never inspects hidden rival state. */
|
||||
export function mixedE3MoritaniPolicy(g: Game, actor: string, difficulty: Difficulty): Action[] {
|
||||
const view = viewGame(mixedE3MoritaniReload(g), actor); view.players.find(p => p.id === actor)!.bot = difficulty;
|
||||
return botActions(view);
|
||||
}
|
||||
|
||||
/** Physical/private aftermath choices always belong to the real owner. */
|
||||
const MIXED_E3_MORITANI_HUMAN: Readonly<Record<string, true>> = {
|
||||
battleLosses: true, sukRescue: true, ixSubstitution: true, battleCards: true, faceDance: true,
|
||||
strongholdCopy: true, moritaniAssassinate: true,
|
||||
};
|
||||
/** Original response queues take priority; a negative result is a real human
|
||||
* boundary (physical casualties, rescue, private cards, assassination, Terror). */
|
||||
export function nextMixedE3MoritaniStep(g: Game): MixedE3MoritaniStep | null {
|
||||
if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard && g.decision) {
|
||||
const d = g.decision;
|
||||
if (MIXED_E3_MORITANI_HUMAN[d.kind]) return null;
|
||||
if (d.kind === 'techToken' && !!g.battle) return null;
|
||||
if (d.kind === 'moritaniPlacement') return { actor: d.player, action: { type: 'decision', decline: true } };
|
||||
if (d.kind === 'leaderSkillVisibility') return { actor: d.player,
|
||||
action: { type: 'leaderSkillVisibility', event: d.event, hide: true } };
|
||||
if (d.kind === 'moritaniTerror' && ['select', 'offer'].includes(g.pendingTerrorEntry!.stage))
|
||||
return { actor: d.player, action: { type: 'decision', decline: true } };
|
||||
if (d.kind === 'richeseUnbid')
|
||||
return { actor: d.player, action: { type: 'decision', event: g.richeseBidding!.event, keep: false } };
|
||||
}
|
||||
const next = nextMixedNexusStep(g);
|
||||
if (!next && g.phase === 5 && g.active && mixedE3MoritaniClean(g) &&
|
||||
!g.pendingShipment && !g.pendingHomeworldShipment)
|
||||
return { actor: g.active, action: { type: 'endMovement' } };
|
||||
return next;
|
||||
}
|
||||
export function stepMixedE3Moritani(g: Game, step: MixedE3MoritaniStep, actions?: MixedE3MoritaniStep[]): Game {
|
||||
actions?.push(structuredClone(step));
|
||||
return applyAction(mixedE3MoritaniReload(g), step.actor, step.action);
|
||||
}
|
||||
export function advanceMixedE3Moritani(g: Game, until: (state: Game) => boolean,
|
||||
actions?: MixedE3MoritaniStep[]): Game {
|
||||
for (let n = 0; n < 3200; n++) {
|
||||
if (until(g)) return g;
|
||||
assert.notEqual(g.status, 'finished');
|
||||
const next = nextMixedE3MoritaniStep(g);
|
||||
assert.ok(next, `Choose the original ${g.decision?.kind ?? 'sealed plan'} at ${g.turn}/${g.phase}.`);
|
||||
g = stepMixedE3Moritani(g, next, actions);
|
||||
}
|
||||
throw Error('Original mixed E3 Moritani chronology did not reach its owned boundary.');
|
||||
}
|
||||
export function settleMixedE3Moritani(g: Game, actions?: MixedE3MoritaniStep[]): Game {
|
||||
return advanceMixedE3Moritani(g, s => mixedE3MoritaniClean(s) && !s.pendingShipment &&
|
||||
!s.pendingHomeworldShipment && !s.pendingChoamWorthless, actions);
|
||||
}
|
||||
|
||||
/** Printed stocks only: the selected ordinary deck, the separate ten-card
|
||||
* Richese cache, twelve Nexus cards, fourteen skills, twenty forces per faction,
|
||||
* original elite supply and the six original Terror tokens. */
|
||||
export function mixedE3MoritaniInventory(g: Game): void {
|
||||
if (g.homeworlds) nativeHomeworldInventory(g);
|
||||
else for (const p of g.players) {
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((n, f) => n + f, 0), 20, p.faction);
|
||||
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks +
|
||||
Object.values(p.elites.forces).reduce((n, f) => n + f, 0),
|
||||
p.faction === 'ixians' ? 7 : p.faction === 'emperor' ? 5 : 3, p.faction);
|
||||
}
|
||||
if (g.leaderSkills) validateLeaderSkills(g.leaderSkills, g.players);
|
||||
if (g.discoveries) validateDiscoveryState(g.discoveries);
|
||||
if (g.ecazAmbassadors) validateAmbassadors(g.ecazAmbassadors);
|
||||
const auction = g.auction?.cards.slice(g.auction.index + (g.currentAuctionSale ? 1 : 0)) ?? [];
|
||||
const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...(g.ixSetupCards ?? []), ...auction];
|
||||
assert.deepEqual(cards.filter(c => !c.id.startsWith('richese-')).map(c => c.id).sort(),
|
||||
treacheryDeck(g.expansions).map(c => c.id).sort());
|
||||
if (g.players.some(p => p.faction === 'richese')) {
|
||||
const owned = [...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []),
|
||||
...cards.filter(c => c.id.startsWith('richese-'))];
|
||||
assert.deepEqual(owned.map(c => c.id).sort(), richeseCards().map(c => c.id).sort());
|
||||
}
|
||||
validateNexusCards(g.nexusCards!.cards!, g.players);
|
||||
const nexus = g.nexusCards!.cards!;
|
||||
assert.deepEqual([...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter((c): c is FactionId => c !== null)].sort(),
|
||||
[...NEXUS_FACTIONS].sort());
|
||||
if (g.moritaniTerror) {
|
||||
assert.deepEqual(g.moritaniTerror.tokens.map(t => t.kind).sort(), [...TERROR_KINDS].sort());
|
||||
assert.equal(new Set(g.moritaniTerror.tokens.map(t => t.id)).size, TERROR_KINDS.length);
|
||||
}
|
||||
}
|
||||
/** Physical Traitor custody in every zone, including face-up set-asides. */
|
||||
export function mixedE3MoritaniTraitorCustody(g: Game): string[] {
|
||||
const retired = g.moritaniAssassinate?.opportunities.filter(o => o.stage === 'replaced').map(o => o.card!) ?? [];
|
||||
return [...(g.traitorReserve ?? []), ...g.players.flatMap(p =>
|
||||
[...p.traitors, ...p.traitorChoices, ...(p.faceDancers ?? []).map(d => d.leader)]),
|
||||
...(g.ecazLoyalty?.card ? [g.ecazLoyalty.card] : []), ...retired].sort();
|
||||
}
|
||||
export function assertMixedE3MoritaniPhysical(g: Game): void {
|
||||
mixedE3MoritaniInventory(g);
|
||||
assert.deepEqual(mixedE3MoritaniTraitorCustody(g), traitorDeck(g.players, g.expansions.includes('ix')).sort());
|
||||
if (g.homeworlds) homeworldGameIntegrity(g);
|
||||
if (g.dukeVidal) assert.equal(g.dukeVidal.leader.id, DUKE_VIDAL_ID);
|
||||
}
|
||||
|
||||
const MORITANI_START_CANDIDATES = ['habbanya_ridge_sietch', 'sietch_tabr', 'carthag', 'tueks_sietch', 'arrakeen'] as const;
|
||||
/** Printed starting strongholds of the classic factions. */
|
||||
const CLASSIC_START_STRONGHOLDS: Readonly<Record<string, readonly string[]>> = {
|
||||
atreides: ['arrakeen', 'carthag'], emperor: ['carthag'], harkonnen: ['carthag'],
|
||||
fremen: ['sietch_tabr', 'false_wall_south', 'false_wall_west'], guild: ['tueks_sietch'],
|
||||
};
|
||||
/** Moritani must start in an unoccupied territory; a printed stronghold keeps
|
||||
* the later genuine shipment destination legal. Never a seated faction's own start. */
|
||||
function moritaniStart(g: Game): string {
|
||||
const taken = new Set(g.players.flatMap(player => CLASSIC_START_STRONGHOLDS[player.faction] ?? []));
|
||||
const free = MORITANI_START_CANDIDATES.find(id => !taken.has(id) &&
|
||||
g.players.every(player => !Object.keys(player.forces).some(key => key.startsWith(`${id}:`)) &&
|
||||
!(player.advisors && id in player.advisors)));
|
||||
assert.ok(free, 'Moritani needs an unoccupied printed stronghold to start in.');
|
||||
return `${free}:${territory(free).sectors[0]}`;
|
||||
}
|
||||
|
||||
function permutation(source: readonly string[], desired: readonly string[]): number[] {
|
||||
assert.deepEqual([...source].sort(), [...desired].sort());
|
||||
const working = [...source], rolls: number[] = [];
|
||||
for (let i = working.length - 1; i > 0; i--) {
|
||||
const j = working.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
|
||||
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
|
||||
[working[i], working[j]] = [working[j], working[i]];
|
||||
}
|
||||
return rolls;
|
||||
}
|
||||
/** Scalar entropy for named original shuffles only; every other draw is native. */
|
||||
function lottery<T>(rolls: readonly number[], operation: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (cursor < rolls.length && array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++];
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return operation(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
const identity = (count: number): number[] => Array<number>(count).fill(0xffffffff);
|
||||
|
||||
/** The original physical Traitor pool as dealt: five printed discs per faction,
|
||||
* the Advanced CHOAM Auditor and the Ix cheap-hero identity. */
|
||||
function traitorPool(g: Game): string[] {
|
||||
return [...g.players.flatMap(p => [
|
||||
...leaders(p.faction).map(l => l.id),
|
||||
...(p.faction === 'choam' && g.advanced ? [CHOAM_AUDITOR_ID] : []),
|
||||
]), ...(g.expansions.includes('ix') ? [CHEAP_HERO_TRAITOR] : [])];
|
||||
}
|
||||
/** Deal position of a player's four Traitor choices; Tleilaxu receives none. */
|
||||
function traitorBlockStart(g: Game, actor: string): number {
|
||||
let start = 0;
|
||||
for (const p of g.players) {
|
||||
if (p.id === actor) return start;
|
||||
if (p.faction !== 'tleilaxu') start += 4;
|
||||
}
|
||||
throw Error('The requested Traitor owner is not seated.');
|
||||
}
|
||||
/** Only the original Traitor shuffle is scoped, including the pre-deal Ecaz
|
||||
* Loyalty draw when Ecaz itself is seated (five/six-native combined roster). */
|
||||
function traitorRolls(g: Game, target: string, moritani: string): number[] {
|
||||
const pool = traitorPool(g);
|
||||
const ecazSeated = g.players.some(p => p.faction === 'ecaz');
|
||||
const loyal = ecazSeated && g.advanced ? chooseEcazLoyalty(pool, 0xffffffff / 0x100000000) : null;
|
||||
const source = pool.filter(c => c !== loyal);
|
||||
const desired = [...source];
|
||||
const [at, to] = [desired.indexOf(target), traitorBlockStart(g, moritani)];
|
||||
assert.ok(at >= 0 && to >= 0); [desired[at], desired[to]] = [desired[to], desired[at]];
|
||||
return [...(loyal ? [0xffffffff] : []), ...permutation(source, desired)];
|
||||
}
|
||||
/** One requested skill per faction, each genuinely offered; the rest is the
|
||||
* original all14 order. The requested card is placed first in its own offer. */
|
||||
function skillOrderFor(g: Game, requested: Partial<Record<FactionId, LeaderSkillId>>): string[] {
|
||||
const source = LEADER_SKILL_CARDS.map(c => c.id);
|
||||
const taken = new Set(Object.values(requested));
|
||||
const remaining = source.filter(s => !taken.has(s as LeaderSkillId));
|
||||
const desired: string[] = [];
|
||||
for (const p of g.players) {
|
||||
const wanted = requested[p.faction];
|
||||
desired.push(wanted ?? remaining.shift()!);
|
||||
desired.push(remaining.shift()!);
|
||||
}
|
||||
desired.push(...remaining);
|
||||
assert.equal(desired.length, source.length);
|
||||
return desired;
|
||||
}
|
||||
|
||||
export function createMixedE3MoritaniLobby(options: MixedE3MoritaniOptions = {}): Game {
|
||||
const families = options.families ?? ['ix', 'choam'];
|
||||
assert.ok(families.length >= 1 && families.length <= 2 && new Set(families).size === families.length);
|
||||
const natives: Record<MixedE3MoritaniFamily, FactionId> = {
|
||||
ix: options.natives?.ix ?? 'tleilaxu', choam: options.natives?.choam ?? 'choam',
|
||||
};
|
||||
const roster: readonly FactionId[] = options.roster ??
|
||||
['moritani', ...families.map(f => natives[f]), ...(options.classics ?? ['guild', 'emperor'])];
|
||||
const advanced = options.rules ? options.rules === 'advanced' : options.initial?.advanced ?? true;
|
||||
const expansions = ['ecaz', ...families] as string[];
|
||||
let g = options.initial ? mixedE3MoritaniReload(options.initial)
|
||||
: createGame('MIXEDE3MORITANI', newPlayer(options.seatIds?.[0] ?? roster[0], roster[0], roster[0]), advanced, expansions);
|
||||
if (!options.initial) for (const faction of roster.slice(1))
|
||||
joinGame(g, newPlayer(options.seatIds?.[g.players.length] ?? faction, faction, faction));
|
||||
assert.equal(g.advanced, advanced);
|
||||
assert.deepEqual([...g.expansions].sort(), [...expansions].sort());
|
||||
assert.equal(new Set(g.players.map(p => p.faction)).size, g.players.length);
|
||||
assert.ok(g.players.length >= 2 && g.players.length <= 6);
|
||||
assert.equal(g.players.filter(p => p.faction === 'moritani').length, 1);
|
||||
const nativesOnly = g.players.every(p =>
|
||||
['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].includes(p.faction));
|
||||
assert.ok(g.players.filter(p => p.faction === 'ecaz').length === 0 ||
|
||||
(g.expansions.length === 3 && nativesOnly && g.players.length >= 5),
|
||||
'The native Ecaz side belongs to the separate Ecaz programme except in the original combined five/six-native roster.');
|
||||
for (const family of families)
|
||||
assert.ok(g.players.some(p => p.faction === natives[family] ||
|
||||
(family === 'ix' ? ['ixians', 'tleilaxu'] : ['choam', 'richese']).includes(p.faction)),
|
||||
`The selected ${family} family must actually seat one of its natives.`);
|
||||
const modules = { homeworlds: options.homeworlds ?? !!g.homeworlds,
|
||||
techTokens: options.tech ?? !!g.techTokens, strongholdCards: options.strongholds ?? !!g.strongholdCards };
|
||||
assert.ok(!modules.strongholdCards || advanced);
|
||||
const discovery = options.discovery ?? !!g.discoveryEnabled;
|
||||
const skills = options.skills ?? (options.initial?.status === 'setup' ? !!g.leaderSkills : false);
|
||||
assert.ok(g.status === 'lobby' || g.status === 'setup' && g.turn === 1 && g.phase === 0,
|
||||
'Continue only an original fresh lobby or its undealt first-turn setup.');
|
||||
if (g.status === 'lobby') {
|
||||
for (const [type, enabled] of Object.entries(modules)) if (!!g[type as keyof typeof modules] !== enabled)
|
||||
g = applyAction(g, g.host, { type, enabled });
|
||||
g.discoveryEnabled = discovery; g.nexusCards ??= { cards: null, phase: null };
|
||||
assert.deepEqual(g.nexusCards, { cards: null, phase: null });
|
||||
assert.ok(!g.ecazTreachery && !g.advancedPreview && !g.semutaPreview && !g.leaderSkills);
|
||||
} else {
|
||||
for (const [type, enabled] of Object.entries(modules)) assert.equal(!!g[type as keyof typeof modules], enabled);
|
||||
assert.equal(!!g.discoveryEnabled, discovery);
|
||||
assert.equal(!!g.leaderSkills, skills);
|
||||
assert.ok(g.nexusCards?.cards, 'A supplied original deal never acquires a new Nexus deck.');
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
/** The original combined five/six-native entry stays for the bare all-three-family
|
||||
* roster; every other mixed E3 shape uses the original generic Nexus or all14
|
||||
* Leader Skills entry. */
|
||||
function mixedE3MoritaniEntry(options: MixedE3MoritaniOptions, g: Game, skills: boolean): MixedE3MoritaniEntry {
|
||||
if (options.entry) return options.entry;
|
||||
const nativesOnly = g.players.every(p => ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].includes(p.faction));
|
||||
if (g.expansions.length === 3 && nativesOnly && g.players.length >= 5 && !skills &&
|
||||
!g.techTokens && !g.strongholdCards && !g.discoveryEnabled) return 'combined';
|
||||
return skills ? 'leaderSkills' : 'nexus';
|
||||
}
|
||||
|
||||
/** Original mixed E3 native Moritani setup. Only undealt physical shuffles are
|
||||
* scoped: the fourteen skill cards, the twelve Nexus cards, the selected
|
||||
* ordinary deck and the original Traitor dealing. No hand, army, wallet or
|
||||
* reward is staged, and a supplied lobby or setup is never redealt. */
|
||||
export function createMixedE3MoritaniSetup(options: MixedE3MoritaniOptions = {}): MixedE3MoritaniSetup {
|
||||
let g = createMixedE3MoritaniLobby(options);
|
||||
const actions: MixedE3MoritaniStep[] = [], staging: string[] = [];
|
||||
const skills = g.status === 'lobby' ? options.skills ?? false : !!g.leaderSkills;
|
||||
const entry = mixedE3MoritaniEntry(options, g, skills);
|
||||
const families = (options.families ?? ['ix', 'choam']).filter(f => g.expansions.includes(f));
|
||||
const moritani = g.players.find(p => p.faction === 'moritani')!.id;
|
||||
const nativeFaction = options.natives?.[families[0]] ?? (families[0] === 'ix' ? 'tleilaxu' : 'choam');
|
||||
const native = g.players.find(p => p.faction === nativeFaction);
|
||||
assert.ok(native, `The selected ${families[0]} native must be seated.`);
|
||||
const secondFaction = families.length > 1
|
||||
? (options.natives?.[families[1]] ?? (families[1] === 'ix' ? 'tleilaxu' : 'choam')) : null;
|
||||
const secondNative = secondFaction ? g.players.find(p => p.faction === secondFaction)?.id ?? null : null;
|
||||
let location = '';
|
||||
const target = leaders(nativeFaction)[0].id;
|
||||
const requested: Partial<Record<FactionId, LeaderSkillId>> = {};
|
||||
if (skills) {
|
||||
requested[nativeFaction] = options.opponentSkill ?? 'suk-graduate';
|
||||
const pool: LeaderSkillId[] = ['warmaster', 'sandmaster', 'killer-medic', 'prana-bindu-adept', 'swordmaster-of-ginaz'];
|
||||
for (const p of g.players) if (!requested[p.faction] && p.faction !== 'moritani')
|
||||
requested[p.faction] = pool.find(s => !Object.values(requested).includes(s))!;
|
||||
}
|
||||
const initial = mixedE3MoritaniReload(g);
|
||||
let ixSeatCard: string | null = null;
|
||||
if (g.status === 'lobby') {
|
||||
for (const p of g.players) if (!p.ready) g = applyAction(g, p.id, { type: 'ready' });
|
||||
const initialize = () => entry === 'combined' ? initializeCombinedNexusGameForAudit(g, !!g.homeworlds)
|
||||
: skills ? initializeLeaderSkillsGameForAudit(g) : initializeNexusGameForAudit(g);
|
||||
if (options.initial) g = initialize();
|
||||
else {
|
||||
assert.ok(!g.players.some(p => p.faction === 'beneGesserit'),
|
||||
'A fresh crafted deal keeps the original prediction stage out of its named shuffle list; supply a Bene Gesserit lobby as `initial`.');
|
||||
const deck = treacheryDeck(g.expansions), remaining = [...deck];
|
||||
const take = (kind: Card['kind']) => {
|
||||
const at = remaining.findIndex(c => c.kind === kind);
|
||||
assert.ok(at >= 0, `The original deck must hold a ${kind}.`);
|
||||
return remaining.splice(at, 1)[0].id;
|
||||
};
|
||||
const ixPlayer = g.players.find(p => p.faction === 'ixians');
|
||||
// With Ixians the physical starting row is drawn per seat and the chosen
|
||||
// card is kept by Ixians; the remainder is dealt to the others in seat
|
||||
// order, so the row is ordered Ixians first. Without Ixians each seat
|
||||
// draws its own starting card directly, Harkonnen drawing two.
|
||||
const order = ixPlayer ? [ixPlayer, ...g.players.filter(p => p.id !== ixPlayer.id)] : g.players;
|
||||
const row: string[] = [];
|
||||
for (const p of order) {
|
||||
const quantity = ixPlayer ? 1 : p.faction === 'harkonnen' ? 2 : 1;
|
||||
for (let i = 0; i < quantity; i++) row.push(p.id === native.id ? take('projectile') : take('worthless'));
|
||||
}
|
||||
if (ixPlayer && g.players.some(p => p.faction === 'harkonnen')) row.push(take('worthless'));
|
||||
if (ixPlayer) ixSeatCard = row[0];
|
||||
const rolls: number[] = [];
|
||||
if (skills) rolls.push(...permutation(LEADER_SKILL_CARDS.map(c => c.id), skillOrderFor(g, requested)));
|
||||
if (g.discoveryEnabled) rolls.push(...identity(7));
|
||||
rolls.push(...identity(NEXUS_FACTIONS.length - 1));
|
||||
rolls.push(...permutation(deck.map(c => c.id), [...row, ...remaining.map(c => c.id)]));
|
||||
const spiceCount = spiceDeck(g.expansions.includes('ix')).length +
|
||||
(g.discoveryEnabled ? DISCOVERY_SPICE_CARDS.length + 1 : 0);
|
||||
rolls.push(...identity(spiceCount - 1));
|
||||
if (!skills) rolls.push(...traitorRolls(g, target, moritani));
|
||||
g = lottery(rolls, initialize);
|
||||
}
|
||||
}
|
||||
assert.equal(!!g.leaderSkills, skills);
|
||||
const offered = mixedE3MoritaniReload(g);
|
||||
for (let n = 0; g.status === 'setup' && n < 400; n++) {
|
||||
const decision = g.decision;
|
||||
if (decision?.kind === 'ixSetup') {
|
||||
const offer = viewGame(g, decision.player).ixTechnology!.setup!;
|
||||
const wanted = offer.find(c => c.id === ixSeatCard) ??
|
||||
offer.find(c => c.kind === 'projectile') ?? offer[0];
|
||||
assert.ok(wanted, 'The original Ix pre-training row must offer a physical card.');
|
||||
const rolls = identity(Math.max(g.players.length - 2, 0));
|
||||
actions.push({ actor: decision.player, action: { type: 'decision', card: wanted.id } });
|
||||
g = lottery(rolls, () => applyAction(mixedE3MoritaniReload(g), decision.player, { type: 'decision', card: wanted.id }));
|
||||
continue;
|
||||
}
|
||||
if (g.setupStage === 'leaderSkills' && !g.decision) {
|
||||
const actor = Object.keys(g.leaderSkills!.offers)[0], own = viewGame(g, actor).leaderSkills!;
|
||||
const offer = own.offer!;
|
||||
const wanted = requested[mixedE3MoritaniPlayer(g, actor).faction];
|
||||
const skill = wanted && offer.cards.includes(wanted) && !own.unavailableSkills?.[wanted]
|
||||
? wanted : offer.cards.find(c => !own.unavailableSkills?.[c])!;
|
||||
assert.ok(skill);
|
||||
const leader = own.eligibleLeaders.find(l => l.id === target) ?? own.eligibleLeaders[0];
|
||||
assert.ok(leader);
|
||||
const step = { actor, action: { type: 'leaderSkill' as const, event: offer.event, skill, leader: leader.id } };
|
||||
if (Object.keys(g.leaderSkills!.offers).length === 1) {
|
||||
// Original order: the chosen card returns to the physical deck, then the
|
||||
// real Traitor deal (and pre-deal Ecaz Loyalty when Ecaz is seated).
|
||||
const rolls = [...identity(g.leaderSkills!.deck.length), ...traitorRolls(g, target, moritani)];
|
||||
actions.push(structuredClone(step));
|
||||
g = lottery(rolls, () => applyAction(mixedE3MoritaniReload(g), step.actor, step.action));
|
||||
} else g = stepMixedE3Moritani(g, step, actions);
|
||||
continue;
|
||||
}
|
||||
if (decision?.kind === 'moritaniSetup') {
|
||||
location = moritaniStart(g);
|
||||
const [start, sector] = location.split(':');
|
||||
g = stepMixedE3Moritani(g, { actor: decision.player,
|
||||
action: { type: 'decision', territory: start, sector: Number(sector) } }, actions);
|
||||
continue;
|
||||
}
|
||||
if (g.status === 'setup' && g.setupStage === 'forces' && !g.decision) {
|
||||
const fremen = g.players.find(p => p.faction === 'fremen');
|
||||
const ecaz = g.players.find(p => p.faction === 'ecaz');
|
||||
const bg = g.players.find(p => p.faction === 'beneGesserit');
|
||||
if (fremen && fremen.reserves === 20) {
|
||||
g = stepMixedE3Moritani(g, { actor: fremen.id,
|
||||
action: { type: 'fremenSetup', placements: { sietch_tabr: 0, false_wall_south: 10, false_wall_west: 0 } } }, actions);
|
||||
continue;
|
||||
}
|
||||
if (ecaz && ecaz.reserves === 20) {
|
||||
const key = `imperial_basin:${territory('imperial_basin').sectors[0]}`;
|
||||
g = stepMixedE3Moritani(g, { actor: ecaz.id,
|
||||
action: { type: 'ecazSetup', placements: { [key]: 6 } } }, actions);
|
||||
continue;
|
||||
}
|
||||
if (g.advanced && bg && !bg.advisorSetup) {
|
||||
// A printed non-stronghold keeps every stronghold free for Moritani's start.
|
||||
g = stepMixedE3Moritani(g, { actor: bg.id,
|
||||
action: { type: 'advisorSetup', territory: 'imperial_basin', sector: territory('imperial_basin').sectors[0] } }, actions);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const next = nextMixedE3MoritaniStep(g);
|
||||
assert.ok(next, `Original setup requires the owner of ${decision?.kind ?? g.setupStage}.`);
|
||||
if (next.action.type === 'traitor') {
|
||||
const player = mixedE3MoritaniPlayer(g, next.actor), choices = player.traitorChoices;
|
||||
const nativeCard = choices.find(c => leaders(nativeFaction).some(l => l.id === c));
|
||||
next.action.leader = player.faction === 'moritani' ? choices.find(c => c === target) ?? nativeCard ?? choices[0]
|
||||
: player.faction === nativeFaction ? nativeCard ?? choices[0] : choices[0];
|
||||
}
|
||||
g = stepMixedE3Moritani(g, next, actions);
|
||||
}
|
||||
assert.equal(g.status, 'playing', 'Original mixed E3 setup must complete into play.');
|
||||
const own = mixedE3MoritaniPlayer(g, moritani);
|
||||
assert.equal(own.forces[location], 6, 'Moritani placed its real six starting counters.');
|
||||
const held = own.traitors.find(c => leaders(nativeFaction).some(l => l.id === c));
|
||||
assert.ok(held, 'The original Traitor deal must place one selected-family card in Moritani\'s hand.');
|
||||
const seats: Partial<Record<FactionId, string>> = {};
|
||||
for (const p of g.players) seats[p.faction] = p.id;
|
||||
mixedE3MoritaniInventory(g);
|
||||
return { initial, offered, afterSetup: mixedE3MoritaniReload(g), entry, families, moritani, seats,
|
||||
native: native.id, nativeFaction, secondNative, secondFaction,
|
||||
partner: g.players.find(p => p.id !== moritani && p.id !== native.id)?.id ?? null,
|
||||
classics: g.players.filter(p => p.id !== moritani && p.id !== native.id && p.id !== secondNative).map(p => p.id),
|
||||
location, target: held, targetSkillOwner: g.leaderSkills?.assignments.find(a => a.owner === native.id)?.leader ?? null,
|
||||
actions, staging };
|
||||
}
|
||||
|
||||
/** Only unplayed original Spice Cards are positioned, never invented. */
|
||||
export function orderMixedE3MoritaniSpice(g: Game, worm = false): void {
|
||||
const kinds = [...(worm ? ['worm'] : []), ...Array<string>(g.advanced ? 2 : 1).fill('land')];
|
||||
for (const [position, kind] of kinds.entries())
|
||||
frontNativeHomeworldSpice(g, c => kind === 'worm'
|
||||
? 'worm' in c && (g.discoveries ? !!c.greatMaker : !c.greatMaker && !c.suppressed)
|
||||
: 'territory' in c && !c.discovery && !['arrakeen', 'tueks_sietch'].includes(c.territory), position);
|
||||
}
|
||||
|
||||
/** Real alliance change by two unallied seats, then Moritani's own genuine
|
||||
* closing Nexus draw after both Advanced Spice piles have been dealt. */
|
||||
export function closeMixedE3Moritani(f: MixedE3MoritaniSetup, state: Game):
|
||||
{ alliance: Game; drawing: Game; draw: MixedE3MoritaniTransition; game: Game } | null {
|
||||
let g = advanceMixedE3Moritani(state, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && mixedE3MoritaniClean(s), f.actions);
|
||||
const others = g.players.filter(p => p.id !== f.moritani && !p.ally); assert.ok(others.length >= 2);
|
||||
for (const [actor, ally] of [[others[0].id, others[1].id], [others[1].id, others[0].id]])
|
||||
g = stepMixedE3Moritani(g, { actor, action: { type: 'alliance', target: ally } }, f.actions);
|
||||
const alliance = mixedE3MoritaniReload(g);
|
||||
g = advanceMixedE3Moritani(g, s => s.nexusCards?.phase?.stage === 'drawing' && mixedE3MoritaniClean(s), f.actions);
|
||||
if (!g.nexusCards!.phase!.eligible.includes(f.moritani)) return null;
|
||||
const cards = g.nexusCards!.cards!, at = cards.deck.indexOf('moritani'); assert.ok(at >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(at, 1)[0]);
|
||||
f.staging.push('Conserved undealt original Moritani Nexus singleton ordered before its real unallied closing draw.');
|
||||
const drawing = mixedE3MoritaniReload(g), step: MixedE3MoritaniStep = { actor: f.moritani,
|
||||
action: { type: 'nexusCardChoice', turn: g.turn, card: cards.hands[f.moritani] ?? null, choice: 'draw', ownRedraws: 0 } };
|
||||
g = stepMixedE3Moritani(g, step, f.actions);
|
||||
return { alliance, drawing, draw: { before: drawing, step: structuredClone(step), after: mixedE3MoritaniReload(g) }, game: g };
|
||||
}
|
||||
|
||||
/** Turn-one storm/collection/Mentat and the turn-two storm, then the original
|
||||
* closing deal. Two-seat rosters have no legal unallied closing qualification. */
|
||||
export function createMixedE3MoritaniProgramme(options: MixedE3MoritaniOptions = {}): MixedE3MoritaniProgramme {
|
||||
const f = createMixedE3MoritaniSetup(options);
|
||||
const result: MixedE3MoritaniProgramme = { ...f, game: f.afterSetup, firstStorm: f.afterSetup,
|
||||
firstMentat: null, alliance: null, drawing: null, draws: [] };
|
||||
if (!f.partner) return result;
|
||||
let g = mixedE3MoritaniReload(f.afterSetup);
|
||||
orderMixedE3MoritaniSpice(g);
|
||||
g = advanceMixedE3Moritani(g, s => s.phase === 1 && mixedE3MoritaniClean(s), f.actions);
|
||||
result.firstStorm = mixedE3MoritaniReload(g);
|
||||
let firstMentat: MixedE3MoritaniTransition | null = null;
|
||||
for (let n = 0; g.turn === 1 && n < 700; n++) {
|
||||
const before = mixedE3MoritaniReload(g), step = nextMixedE3MoritaniStep(g); assert.ok(step);
|
||||
g = stepMixedE3Moritani(g, step, f.actions);
|
||||
if (g.turn === 2) firstMentat = { before, step: structuredClone(step), after: mixedE3MoritaniReload(g) };
|
||||
}
|
||||
assert.ok(firstMentat, 'The original first Mentat must hand over to turn two.');
|
||||
result.firstMentat = firstMentat;
|
||||
g = advanceMixedE3Moritani(g, s => s.phase === 1 && mixedE3MoritaniClean(s), f.actions);
|
||||
orderMixedE3MoritaniSpice(g, true);
|
||||
const closed = closeMixedE3Moritani(f, g);
|
||||
if (closed) { g = closed.game; result.alliance = closed.alliance; result.drawing = closed.drawing;
|
||||
result.draws = [closed.draw]; }
|
||||
g = advanceMixedE3Moritani(g, s => s.phase === 5 && mixedE3MoritaniClean(s), f.actions);
|
||||
mixedE3MoritaniInventory(g);
|
||||
result.game = g; return result;
|
||||
}
|
||||
|
||||
/** A real phase-five shipment, including the original paid Homeworld entry. */
|
||||
export function shipMixedE3Moritani(g: Game, actor: string, destination: string, amount: number, elite = 0,
|
||||
actions?: MixedE3MoritaniStep[]): MixedE3MoritaniTransition & { settled: Game } {
|
||||
g = advanceMixedE3Moritani(g, s => s.phase === 5 && s.active === actor && mixedE3MoritaniClean(s), actions);
|
||||
const before = mixedE3MoritaniReload(g), view = viewGame(g, actor);
|
||||
const sources = g.homeworlds ? nativeShipmentSources(view, amount, elite) : undefined;
|
||||
let action: Action;
|
||||
if (destination.startsWith('homeworld:')) {
|
||||
assert.ok(sources); const quote = homeworldShipmentChoice(view, destination,
|
||||
Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0)));
|
||||
assert.ok(quote.action, quote.blocked ?? 'Original paid native entry unavailable.'); action = quote.action;
|
||||
} else {
|
||||
const [site, sector] = destination.split(':');
|
||||
action = { type: 'ship', territory: site, sector: Number(sector), amount, elite, allyPayment: 0,
|
||||
...(sources ? { homeworldSources: sources } : {}) };
|
||||
}
|
||||
const step = { actor, action }; g = stepMixedE3Moritani(g, step, actions);
|
||||
const after = mixedE3MoritaniReload(g);
|
||||
g = advanceMixedE3Moritani(g, s => !s.pendingHomeworldShipment && !s.pendingShipment && mixedE3MoritaniClean(s), actions);
|
||||
return { before, step, after, settled: g };
|
||||
}
|
||||
|
||||
/** Real phase-six battle choice for the pair created by genuine movement. */
|
||||
export function openMixedE3MoritaniBattle(g: Game, first: string, second: string,
|
||||
actions?: MixedE3MoritaniStep[]): Game {
|
||||
g = advanceMixedE3Moritani(g, s => s.phase === 6 && mixedE3MoritaniClean(s), actions);
|
||||
const choice = viewGame(g, g.active!).battleChoices.find(b => [first, second].includes(b.attacker) &&
|
||||
[first, second].includes(b.defender)); assert.ok(choice, 'The real battle pair must be choosable.');
|
||||
return stepMixedE3Moritani(g, { actor: choice.chooser, action: { type: 'chooseBattle', territory: choice.territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } }, actions);
|
||||
}
|
||||
export function revealMixedE3MoritaniPlans(g: Game, plans: readonly MixedE3MoritaniStep[],
|
||||
actions?: MixedE3MoritaniStep[]): Game {
|
||||
for (const plan of plans) {
|
||||
g = stepMixedE3Moritani(g, plan, actions);
|
||||
if (!g.battle?.revealed) g = advanceMixedE3Moritani(g, s => !!s.battle && mixedE3MoritaniClean(s) &&
|
||||
!s.battle.preparation && s.battle.preLeader?.closed !== false, actions);
|
||||
}
|
||||
assert.ok(g.battle?.revealed); return g;
|
||||
}
|
||||
|
||||
/** The genuine Advanced loss: Moritani's real movement position is attacked by
|
||||
* the selected native's real shipment, and the winning disc survives. With
|
||||
* `disc: 'held'` the native opposes with the very disc Moritani holds, which is
|
||||
* the only legal normal own-card Traitor declaration. */
|
||||
/** Highest- or lowest-strength legal commitment for one combatant, chosen by
|
||||
* trying the engine's own plan validator. Never assumes a fixed dial or budget. */
|
||||
function legalPlan(g: Game, actor: string, leader: string, prefer: 'max' | 'min',
|
||||
extra: { weapon?: string; defense?: string } = {}): Action {
|
||||
const steps: MixedE3MoritaniStep[] = [];
|
||||
let base = g;
|
||||
for (let n = 0; n < 6; n++) {
|
||||
const decision = base.decision;
|
||||
if (decision?.kind === 'moritaniPlacement') {
|
||||
const decline: MixedE3MoritaniStep = { actor: decision.player, action: { type: 'decision', decline: true } };
|
||||
steps.push(decline); base = stepMixedE3Moritani(base, decline); continue;
|
||||
}
|
||||
if (decision?.kind !== 'leaderSkillVisibility') break;
|
||||
const posture: MixedE3MoritaniStep = { actor: decision.player,
|
||||
action: { type: 'leaderSkillVisibility', event: decision.event, hide: decision.player === actor } };
|
||||
steps.push(posture); base = stepMixedE3Moritani(base, posture);
|
||||
}
|
||||
const spice = mixedE3MoritaniPlayer(base, actor).spice;
|
||||
const supports = prefer === 'max' ? Array.from({ length: spice + 1 }, (_, i) => spice - i) : [0];
|
||||
const dials = prefer === 'max' ? Array.from({ length: 13 }, (_, i) => 12 - i) : [0];
|
||||
const reasons = new Set<string>();
|
||||
for (const support of supports) for (const dial of dials) {
|
||||
for (const card of [extra, {}]) {
|
||||
const action: Action = { type: 'battlePlan', leader, dial, support, ...card };
|
||||
try { applyAction(mixedE3MoritaniReload(base), actor, action); return action; }
|
||||
catch (error) { reasons.add(error instanceof Error ? error.message : String(error)); }
|
||||
}
|
||||
}
|
||||
throw Error(`No legal battle plan for ${actor} (spice ${spice}): ${[...reasons].join(' | ')}`);
|
||||
}
|
||||
/** The genuine Advanced loss: Moritani's real movement position is attacked by
|
||||
* the selected native's real shipment, and the winning disc survives. With
|
||||
* `disc: 'held'` the native opposes with the very disc Moritani holds, which is
|
||||
* the only legal normal own-card Traitor declaration. */
|
||||
export function stageMixedE3MoritaniLoss(f: MixedE3MoritaniSetup, state: Game,
|
||||
options: { disc?: 'strongest' | 'held' } = {}): MixedE3MoritaniBattle {
|
||||
const arrival = shipMixedE3Moritani(mixedE3MoritaniReload(state), f.native, f.location, 1, 0, f.actions);
|
||||
const ready = advanceMixedE3Moritani(arrival.settled, s => !s.decision, f.actions);
|
||||
let g = openMixedE3MoritaniBattle(ready, f.moritani, f.native, f.actions);
|
||||
// A pending posture declaration is a real private choice; conceal it first so
|
||||
// both plans are submitted against the same genuine state.
|
||||
for (let n = 0; n < 4 && g.decision?.kind === 'leaderSkillVisibility'; n++) {
|
||||
const posture = g.decision;
|
||||
g = stepMixedE3Moritani(g, { actor: posture.player,
|
||||
action: { type: 'leaderSkillVisibility', event: posture.event, hide: true } }, f.actions);
|
||||
}
|
||||
const arrangements = mixedE3MoritaniReload(g);
|
||||
const trained = new Set((g.leaderSkills?.assignments ?? []).map(a => a.leader));
|
||||
const ownLeader = mixedE3MoritaniPlayer(g, f.moritani).leaders.filter(l => !l.dead && !trained.has(l.id))
|
||||
.sort((a, b) => a.strength - b.strength)[0];
|
||||
const enemyLeader = (options.disc ?? 'strongest') === 'held'
|
||||
? mixedE3MoritaniPlayer(g, f.native).leaders.find(l => l.id === f.target)!
|
||||
: mixedE3MoritaniPlayer(g, f.native).leaders
|
||||
.filter(l => !l.dead && l.id !== f.target && !trained.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
|
||||
const ownCard = mixedE3MoritaniPlayer(g, f.moritani).hand.find(c => isDefenseCard(c));
|
||||
const enemyCard = mixedE3MoritaniPlayer(g, f.native).hand.find(c => isWeaponCard(c));
|
||||
assert.ok(ownLeader && enemyLeader, 'The original deal must supply the loss discs.');
|
||||
const plans: MixedE3MoritaniStep[] = [
|
||||
{ actor: f.moritani, action: legalPlan(g, f.moritani, ownLeader.id, 'min',
|
||||
ownCard ? { defense: ownCard.id } : {}) },
|
||||
{ actor: f.native, action: legalPlan(g, f.native, enemyLeader.id, 'max',
|
||||
enemyCard ? { weapon: enemyCard.id } : {}) },
|
||||
];
|
||||
const revealed = revealMixedE3MoritaniPlans(mixedE3MoritaniReload(g), plans, f.actions);
|
||||
return { arrangements, location: f.location, arrival, plans, revealed,
|
||||
ownLeader: ownLeader.id, enemyLeader: enemyLeader.id, target: f.target,
|
||||
ownCards: ownCard ? [ownCard.id] : [], enemyCards: enemyCard ? [enemyCard.id] : [] };
|
||||
}
|
||||
|
||||
/** Resolve to the next real human boundary; never consumes a physical choice.
|
||||
* `normalCallOwner` is the only seat allowed to call its own Traitor normally. */
|
||||
export function settleMixedE3MoritaniBoundary(g: Game, normalCallOwner: string | null = null,
|
||||
actions?: MixedE3MoritaniStep[], normalCallCard: string | null = null): MixedE3MoritaniBoundary {
|
||||
const kinds: Readonly<Record<string, MixedE3MoritaniBoundary['kind']>> = {
|
||||
battleLosses: 'losses', sukRescue: 'rescue', ixSubstitution: 'substitution', moritaniAssassinate: 'assassination',
|
||||
battleCards: 'cards', techToken: 'tech', faceDance: 'faceDance',
|
||||
};
|
||||
for (let n = 0; n < 240; n++) {
|
||||
const kind = g.decision && !g.response && !g.phaseOpening ? kinds[g.decision.kind] : undefined;
|
||||
if (kind) return { game: g, kind };
|
||||
if (!g.battle && mixedE3MoritaniClean(g) && !g.pendingTreacheryDiscard) return { game: g, kind: 'settled' };
|
||||
const next = nextMixedE3MoritaniStep(g);
|
||||
assert.ok(next, `Original suffix stalled at ${g.decision?.kind ?? g.phase}.`);
|
||||
if (next.action.type === 'traitorCall') {
|
||||
const battle = g.battle;
|
||||
const opposing = battle && (battle.attacker === next.actor || battle.defender === next.actor)
|
||||
? battle.plans[battle.attacker === next.actor ? battle.defender : battle.attacker].leader : null;
|
||||
// Only the fixture's own held matching card is a legal normal call.
|
||||
next.action.call = next.actor === normalCallOwner && !!normalCallCard && opposing === normalCallCard &&
|
||||
mixedE3MoritaniPlayer(g, next.actor).traitors.includes(normalCallCard);
|
||||
}
|
||||
g = stepMixedE3Moritani(g, next, actions);
|
||||
}
|
||||
throw Error('Original battle suffix did not reach its next physical boundary.');
|
||||
}
|
||||
export function finishMixedE3MoritaniBattle(g: Game, actions?: MixedE3MoritaniStep[]): Game {
|
||||
return advanceMixedE3Moritani(g, s => !s.battle && mixedE3MoritaniClean(s) && !s.pendingTreacheryDiscard, actions);
|
||||
}
|
||||
|
||||
/** Same public/held inputs as the sibling Nexus quotes, plus native Moritani. */
|
||||
export function quoteMixedE3MoritaniBattle(g: Game): BattleResolutionQuote {
|
||||
const b = g.battle; assert.ok(b?.revealed);
|
||||
const side = (actor: string): ResolutionCombatant => {
|
||||
const p = mixedE3MoritaniPlayer(g, actor), view = viewGame(g, actor).battle!; assert.ok(view.ownForces);
|
||||
return { id: actor, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand,
|
||||
plan: b.plans[actor], leader: p.leaders.find(l => l.id === b.plans[actor].leader) ??
|
||||
(g.dukeVidal?.controller === actor ? g.dukeVidal.leader : undefined), forces: view.ownForces,
|
||||
stronghold: view.strongholdEffects[actor], leaderSkills: (g.leaderSkills?.assignments ?? []).filter(a => a.owner === actor)
|
||||
.map(a => ({ skill: a.skill, leader: a.leader, faceUp: !b.leaderSkillHidden?.[actor], captured: false })) };
|
||||
};
|
||||
const publicBattle = viewGame(g, g.host).battle!;
|
||||
const home = homeworldBattleLocation(g, b.territory);
|
||||
return quoteBattleResolution({ advanced: g.advanced, typedCasualties: !!g.homeworlds, turn: g.turn, territory: b.territory,
|
||||
...(home ? { homeworld: { native: home.native, card: home.card, side: home.side, nativeForces: { ...home.forces[home.native] } } } : {}),
|
||||
aggressor: publicBattle.aggressor, attacker: side(b.attacker), defender: side(b.defender),
|
||||
voters: publicBattle.traitorVoters.map(actor => ({ id: actor,
|
||||
beneficiary: [b.attacker, b.defender].includes(actor) ? actor : mixedE3MoritaniPlayer(g, actor).ally!,
|
||||
called: b.traitorCalls[actor] ?? false, traitors: mixedE3MoritaniPlayer(g, actor).traitors })),
|
||||
participants: g.players, physicalCards: [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)],
|
||||
pendingAuditorPresent: false, pendingRetentionPresent: false, nexusMoritani: true });
|
||||
}
|
||||
|
||||
/** True printed Horror/Extortion/… token identities from the actual supply. */
|
||||
export function mixedE3MoritaniToken(g: Game, kind: TerrorKind) {
|
||||
const token = g.moritaniTerror!.tokens.find(t => t.kind === kind); assert.ok(token); return token;
|
||||
}
|
||||
/** Supply-only native Cunning declaration of an actual unplaced token. */
|
||||
export function mixedE3MoritaniCunningRequest(g: Game, actor: string, kind: TerrorKind = 'robbery',
|
||||
site = 'red_chasm'): Action {
|
||||
const offer = viewGame(g, actor).nexusMoritani; assert.ok(offer);
|
||||
return { type: 'decision', token: mixedE3MoritaniToken(g, kind).id, territory: site, nexus: offer.event };
|
||||
}
|
||||
/** Native Terror arrival on a real shipment; the token identity stays private. */
|
||||
export function mixedE3MoritaniTerrorReveal(g: Game, actor: string): Game {
|
||||
return stepMixedE3Moritani(stepMixedE3Moritani(g, { actor, action: { type: 'decision', reveal: true } }),
|
||||
{ actor, action: { type: 'decision', choice: 'spice' } });
|
||||
}
|
||||
export function assertMixedE3MoritaniRejects(g: Game, actor: string, action: Action): void {
|
||||
const before = mixedE3MoritaniReload(g);
|
||||
assert.throws(() => applyAction(g, actor, action), 'Rejected consumers must not spend original stock.');
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
@@ -420,7 +420,7 @@ void test('all four bots respect the projected count cap for both Ambassador rou
|
||||
}
|
||||
});
|
||||
|
||||
void test('movement preflight permits the low two-force Intrusion arrival and keeps unsupported three-force ordering immutable', () => {
|
||||
void test('movement preflight commits each Intrusion arrival and defers its Terror entry behind the pending Intrusion', () => {
|
||||
for (const reserves of [7, 8]) {
|
||||
const g = fixture(reserves, 'emperor', true);
|
||||
const bg = newPlayer('b', 'Bene Gesserit', 'beneGesserit');
|
||||
@@ -444,13 +444,23 @@ void test('movement preflight permits the low two-force Intrusion arrival and ke
|
||||
assert.equal(result.pendingTerrorEntry ?? null, null);
|
||||
assert.equal(player(result, 'e').forces['arrakeen:10'], 2);
|
||||
homeworldGameIntegrity(result);
|
||||
} else reject(g, 'e', move, /Terror combined/);
|
||||
reject(
|
||||
g,
|
||||
'e',
|
||||
{ ...move, forces: { 'imperial_basin:10': 3 } },
|
||||
/Terror combined/,
|
||||
);
|
||||
} else {
|
||||
const result = applyAction(g, 'e', move);
|
||||
assert.equal(result.decision?.kind, 'intrusion');
|
||||
assert.equal(result.pendingArrivalReaction?.[0]?.amount, 2,
|
||||
'The committed entry waits for the Intrusion choice.');
|
||||
assert.equal(result.pendingTerrorEntry ?? null, null);
|
||||
assert.equal(player(result, 'e').forces['arrakeen:10'], 2);
|
||||
assert.equal(player(result, 'e').forces['imperial_basin:10'], 1);
|
||||
homeworldGameIntegrity(result);
|
||||
}
|
||||
const three = applyAction(g, 'e', { ...move, forces: { 'imperial_basin:10': 3 } });
|
||||
assert.equal(three.decision?.kind, 'intrusion');
|
||||
assert.equal(three.pendingArrivalReaction?.[0]?.amount, 3,
|
||||
'The whole committed group is one deferred entry.');
|
||||
assert.equal(three.pendingTerrorEntry ?? null, null);
|
||||
assert.equal(player(three, 'e').forces['arrakeen:10'], 3);
|
||||
homeworldGameIntegrity(three);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -336,26 +336,33 @@ void test('the Ix prototype keeps public gates and rejects unsupported or nonemp
|
||||
unsupported.players.forEach((p) => (p.ready = true));
|
||||
rejectInitializer(unsupported, /base, Ixian and Tleilaxu/);
|
||||
|
||||
const moduleFactories: Array<[string, (g: Game) => Game]> = [
|
||||
const moduleFactories: Array<[string, (g: Game) => Game, RegExp]> = [
|
||||
[
|
||||
'tech',
|
||||
(g) => applyAction(g, g.host, { type: 'techTokens', enabled: true }),
|
||||
/Tech Tokens require a fresh supported three-through-six-seat lobby/,
|
||||
],
|
||||
[
|
||||
'homeworld',
|
||||
(g) => applyAction(g, g.host, { type: 'homeworlds', enabled: true }),
|
||||
/without optional modules/,
|
||||
],
|
||||
[
|
||||
'stronghold',
|
||||
(g) => applyAction(g, g.host, { type: 'strongholdCards', enabled: true }),
|
||||
/Stronghold Cards require a fresh supported Advanced lobby/,
|
||||
],
|
||||
[
|
||||
'nexus',
|
||||
(g) => ({ ...g, nexusCards: { cards: null, phase: null } }),
|
||||
/without optional modules/,
|
||||
],
|
||||
['nexus', (g) => ({ ...g, nexusCards: { cards: null, phase: null } })],
|
||||
];
|
||||
for (const [name, enable] of moduleFactories) {
|
||||
for (const [name, enable, rejection] of moduleFactories) {
|
||||
let g = enable(lobby(name === 'stronghold'));
|
||||
for (const p of g.players)
|
||||
if (!p.ready) g = applyAction(g, p.id, { type: 'ready' });
|
||||
rejectInitializer(g, /without optional modules/);
|
||||
rejectInitializer(g, rejection);
|
||||
}
|
||||
|
||||
const occupied = lobby();
|
||||
|
||||
@@ -114,7 +114,7 @@ void test('all fourteen physical skills reach genuine Ix card selection, Traitor
|
||||
assert.equal(player(offers, 't').faceDancers, undefined);
|
||||
});
|
||||
|
||||
void test('genuine ready Ix lobbies keep public starts, Advanced and additional module profiles closed', () => {
|
||||
void test('genuine ready Ix lobbies keep public starts closed and initialize the supported native module profiles', () => {
|
||||
const lobby = () => {
|
||||
let g = createGame('IXGATED', newPlayer('i', 'Ixians', 'ixians'), false, ['ix']);
|
||||
joinGame(g, newPlayer('t', 'Tleilaxu', 'tleilaxu'));
|
||||
@@ -123,19 +123,27 @@ void test('genuine ready Ix lobbies keep public starts, Advanced and additional
|
||||
return g;
|
||||
};
|
||||
reject(lobby(), 'i', {type: 'start'});
|
||||
// The supported native Advanced, Discovery, Tech Token and Homeworld
|
||||
// profiles now initialize for a fresh Ix lobby without mutating it.
|
||||
for (const change of [
|
||||
(g: Game) => { g.advanced = true; },
|
||||
(g: Game) => { g.discoveryEnabled = true; },
|
||||
(g: Game) => { g.techTokens = createTechTokens(); },
|
||||
(g: Game) => { g.expansions.push('choam'); },
|
||||
(g: Game) => { g.homeworlds = {custody: null}; },
|
||||
]) {
|
||||
const g = lobby();
|
||||
change(g);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(g));
|
||||
assert.deepEqual(g, before);
|
||||
assert.ok(initializeLeaderSkillsGameForAudit(g).leaderSkills,
|
||||
'The supported native module profile initializes.');
|
||||
assert.deepEqual(g, before, 'The authoritative lobby is never mutated.');
|
||||
}
|
||||
// A combined CHOAM expansion still rejects.
|
||||
const combined = lobby();
|
||||
combined.expansions.push('choam');
|
||||
const before = structuredClone(combined);
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(combined));
|
||||
assert.deepEqual(combined, before);
|
||||
});
|
||||
|
||||
void test('Planetologist gives selected suboids range two independently and cyborg range three through saved native permission', () => {
|
||||
|
||||
@@ -261,11 +261,15 @@ void test('BG intrusion uses current opposing fighters, requires Advanced rules
|
||||
quoteCompletedMovementArrival({ ...input, players: advisors }).intrusion,
|
||||
false,
|
||||
);
|
||||
for (const tokens of [{ ambassadors: [ambassador] }, { terror: [terror] }])
|
||||
assert.throws(
|
||||
() => quoteCompletedMovementArrival({ ...input, players, ...tokens }),
|
||||
/another arrival reaction/,
|
||||
);
|
||||
assert.throws(
|
||||
() => quoteCompletedMovementArrival({ ...input, players, ambassadors: [ambassador] }),
|
||||
/Ambassadors combined/,
|
||||
);
|
||||
// A Terror entry behind the pending Intrusion is now committed and deferred.
|
||||
assert.equal(
|
||||
quoteCompletedMovementArrival({ ...input, players, terror: [terror] }).reaction,
|
||||
'terror',
|
||||
);
|
||||
});
|
||||
|
||||
void test('mover advisor stance is the declared resulting stance, not its stale destination map', () => {
|
||||
@@ -295,14 +299,13 @@ void test('mover advisor stance is the declared resulting stance, not its stale
|
||||
}).reaction,
|
||||
null,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
quoteCompletedMovementArrival({
|
||||
...base,
|
||||
terror: [terror],
|
||||
order: { ...base.order, advisors: true, wantsFighters: true },
|
||||
}),
|
||||
/Terror combined/,
|
||||
assert.equal(
|
||||
quoteCompletedMovementArrival({
|
||||
...base,
|
||||
terror: [terror],
|
||||
order: { ...base.order, advisors: true, wantsFighters: true },
|
||||
}).reaction,
|
||||
'terror',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -348,14 +351,14 @@ void test('current controls and the move-created fighter response are checked wi
|
||||
/Ambassadors combined/,
|
||||
);
|
||||
for (const flag of ['response', 'decision'] as const)
|
||||
assert.throws(
|
||||
() =>
|
||||
quoteCompletedMovementArrival({
|
||||
...input,
|
||||
terror: [terror],
|
||||
controls: { ...input.controls, [flag]: true },
|
||||
}),
|
||||
/Terror combined/,
|
||||
assert.equal(
|
||||
quoteCompletedMovementArrival({
|
||||
...input,
|
||||
terror: [terror],
|
||||
controls: { ...input.controls, [flag]: true },
|
||||
}).reaction,
|
||||
'terror',
|
||||
`A committed ${flag} arrival defers its Terror entry instead of rejecting it.`,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, RuleError, viewGame } from '../game/engine';
|
||||
import type { Action, Game } from '../game/engine';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { combatArmy } from '../game/combat-location';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import {
|
||||
advanceMixedE3EcazNexus as advance, createMixedE3EcazNexusCoalition as coalition,
|
||||
createMixedE3EcazNexusProgramme as programme, createMixedE3EcazNexusSetup as setup,
|
||||
mixedE3EcazNexusClean as clean, mixedE3EcazNexusInventory as inventory,
|
||||
mixedE3EcazNexusPlayer as player, mixedE3EcazNexusPolicy as policy,
|
||||
mixedE3EcazNexusReload as reload, nextMixedE3EcazNexusStep as next,
|
||||
quietMixedE3EcazNexusChoice as quiet, quoteMixedE3EcazNexusBattle as quote,
|
||||
revealMixedE3EcazNexus as reveal, shipMixedE3EcazNexus as ship,
|
||||
stepMixedE3EcazNexus as step,
|
||||
} from './fixture-mixed-e3-ecaz-nexus';
|
||||
import type { MixedE3EcazNexusBattle, MixedE3EcazNexusStep } from './fixture-mixed-e3-ecaz-nexus';
|
||||
|
||||
function reject(g: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, actor, action), RuleError);
|
||||
assert.deepEqual(g, before, 'Rejected physical contributions cannot debit purses, move forces or consume discs');
|
||||
}
|
||||
function policies(g: Game, actor: string, effect: (after: Game, action: Action) => void): void {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const action = policy(g, actor, difficulty)[0]; assert.ok(action, `${difficulty} requires this genuine owned window`);
|
||||
const after = step(g, { actor, action }); effect(after, action); inventory(after);
|
||||
}
|
||||
}
|
||||
/** Test-owned choices, not scheduler defaults. Every casualty/reward boundary
|
||||
* is retained for a human using the reusable next-step controller. */
|
||||
function settlementChoice(g: Game): MixedE3EcazNexusStep | null {
|
||||
if (g.response || g.phaseOpening) return null;
|
||||
const d = g.decision; if (!d) return null;
|
||||
if (d.kind === 'battleLosses') return { actor: d.player, action: { type: 'decision', choice: 0 } };
|
||||
if (d.kind === 'battleCards') return { actor: d.player, action: { type: 'decision', discard: [] } };
|
||||
if (d.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
|
||||
if (d.kind === 'faceDance') return { actor: d.player, action: { type: 'decision', reveal: false } };
|
||||
if (d.kind === 'leaderSkillVisibility') return { actor: d.player,
|
||||
action: { type: 'leaderSkillVisibility', event: d.event, hide: true } };
|
||||
if (d.kind === 'ixSubstitution') return { actor: d.player, action: { type: 'decision', decline: true } };
|
||||
if (d.kind === 'strongholdCopy') return { actor: d.player,
|
||||
action: { type: 'decision', event: d.event, stronghold: d.choices[0] } };
|
||||
if (d.kind === 'sukRescue') {
|
||||
const maximum = Math.max(...d.options.map(o => o.normal + o.elite));
|
||||
const choice = d.options.findIndex(o => o.normal + o.elite === maximum);
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, choice } };
|
||||
}
|
||||
if (d.kind === 'moritaniRetention') return { actor: d.player, action: { type: 'decision', keep: null,
|
||||
...(d.source === 'nexus' ? { event: d.event } : {}) } };
|
||||
return quiet(g);
|
||||
}
|
||||
function settle(g: Game, until: (g: Game) => boolean = s => !s.battle && clean(s), exercise = false): Game {
|
||||
for (let n = 0; n < 700; n++) {
|
||||
if (!g.response && !g.phaseOpening && until(g)) return g;
|
||||
const d = !g.response && !g.phaseOpening ? g.decision : null;
|
||||
if (exercise && d?.kind === 'battleLosses') policies(g, d.player, (after, action) => {
|
||||
const option = d.options[Number(action.choice)], owner = d.forceOwner ?? d.player; assert.ok(option);
|
||||
assert.equal(player(after, owner).tanks, player(g, owner).tanks + option.normal + option.elite);
|
||||
});
|
||||
if (exercise && d?.kind === 'battleCards') policies(g, d.player, (after, action) => {
|
||||
for (const id of action.discard as string[]) {
|
||||
assert.ok(!player(after, d.player).hand.some(c => c.id === id));
|
||||
assert.equal(after.discard.filter(c => c.id === id).length, 1);
|
||||
}
|
||||
});
|
||||
if (exercise && d?.kind === 'techToken') policies(g, d.player, (after, action) => {
|
||||
const token = action.token as keyof NonNullable<Game['techTokens']>; assert.ok(d.choices.includes(token));
|
||||
assert.equal(after.techTokens![token].owner, d.player);
|
||||
assert.equal(player(after, d.player).spice, player(g, d.player).spice);
|
||||
});
|
||||
const control = next(g) ?? settlementChoice(g); assert.ok(control, `Test owner must choose ${g.decision?.kind}`);
|
||||
g = step(g, control);
|
||||
}
|
||||
throw Error('Original mixed Ecaz aftermath did not reach its physical boundary');
|
||||
}
|
||||
function chooseLead(f: MixedE3EcazNexusBattle, lead: string): Game {
|
||||
const d = f.game.decision; assert.ok(d?.kind === 'ecazBattleLead');
|
||||
const chosen = step(f.game, { actor: f.owner, action: { type: 'decision', event: d.event, lead } });
|
||||
return advance(chosen, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false,
|
||||
undefined, s => s.decision?.kind === 'leaderSkillVisibility'
|
||||
? { actor: s.decision.player, action: { type: 'leaderSkillVisibility', event: s.decision.event, hide: s.decision.player === f.owner } }
|
||||
: quiet(s));
|
||||
}
|
||||
/** Highest- or lowest-strength legal commitment for one combatant, chosen by
|
||||
* trying the engine's own plan validator. Any pending posture declaration for
|
||||
* this seat is answered first, because a skilled disc must be hidden before use. */
|
||||
function legalPlan(g: Game, actor: string, leader: string, prefer: 'max' | 'min',
|
||||
extra: { weapon?: string; defense?: string } = {}): { steps: MixedE3EcazNexusStep[]; action: Action } {
|
||||
const steps: MixedE3EcazNexusStep[] = [];
|
||||
let base = g;
|
||||
for (let n = 0; n < 6; n++) {
|
||||
const decision = base.decision;
|
||||
if (decision?.kind !== 'leaderSkillVisibility') break;
|
||||
const posture: MixedE3EcazNexusStep = { actor: decision.player,
|
||||
action: { type: 'leaderSkillVisibility', event: decision.event, hide: decision.player === actor } };
|
||||
steps.push(posture); base = step(base, posture);
|
||||
}
|
||||
const spice = player(base, actor).spice;
|
||||
const supports = prefer === 'max' ? Array.from({ length: spice + 1 }, (_, i) => spice - i) : [0];
|
||||
const dials = prefer === 'max' ? Array.from({ length: 13 }, (_, i) => 12 - i) : [0];
|
||||
const reasons = new Set<string>();
|
||||
for (const support of supports) for (const dial of dials) {
|
||||
for (const card of [extra, {}]) {
|
||||
const action: Action = { type: 'battlePlan', leader, dial, support, ...card };
|
||||
try { applyAction(reload(base), actor, action); return { steps: [...steps, { actor, action }], action }; }
|
||||
catch (error) { reasons.add(error instanceof Error ? error.message : String(error)); }
|
||||
}
|
||||
}
|
||||
throw Error(`No legal battle plan for ${actor} (spice ${spice}): ${[...reasons].join(' | ')}`);
|
||||
}
|
||||
/** The seat's own trained disc first (its skill needs the hidden posture), then
|
||||
* the strongest available discs; the first combination the engine accepts wins. */
|
||||
function legalLeadPlan(g: Game, actor: string, prefer: 'max' | 'min',
|
||||
extra: { weapon?: string; defense?: string } = {}): { steps: MixedE3EcazNexusStep[]; action: Action } {
|
||||
const trained = new Set((g.leaderSkills?.assignments ?? []).filter(a => a.owner === actor).map(a => a.leader));
|
||||
const candidates = player(g, actor).leaders.filter(l => !l.dead && !l.usedAt)
|
||||
.sort((a, b) => (Number(trained.has(b.id)) - Number(trained.has(a.id))) || b.strength - a.strength);
|
||||
const reasons = new Set<string>();
|
||||
for (const leader of candidates) {
|
||||
try { return legalPlan(g, actor, leader.id, prefer, extra); }
|
||||
catch (error) { reasons.add(error instanceof Error ? error.message : String(error)); }
|
||||
}
|
||||
throw Error(`No legal battle plan for ${actor}: ${[...reasons].join(' | ')}`);
|
||||
}
|
||||
function coalitionPlans(f: MixedE3EcazNexusBattle, g: Game, lead: string): MixedE3EcazNexusStep[] {
|
||||
const worthless = player(g, lead).hand.find(c => c.kind === 'worthless');
|
||||
const first = legalLeadPlan(g, lead, 'max', worthless ? { defense: worthless.id } : {});
|
||||
let after = g; for (const plan of first.steps) after = step(after, plan);
|
||||
const second = legalLeadPlan(after, f.opponent, 'min');
|
||||
return [...first.steps, ...second.steps];
|
||||
}
|
||||
/** The chosen battle plan of one seat inside a scheduled step list. */
|
||||
function plannedStep(plans: readonly MixedE3EcazNexusStep[], actor: string): Action {
|
||||
const step = plans.find(candidate => candidate.actor === actor && candidate.action.type === 'battlePlan');
|
||||
assert.ok(step, `${actor} must have a scheduled battle plan`);
|
||||
return step.action;
|
||||
}
|
||||
|
||||
void test('minimal original two-seat E3/E1 training and three-seat E3/E2 no-training closing preserve native discs, cache and paid counters', () => {
|
||||
const lobby = createGame('MIXEDORIGINAL2', newPlayer('human-ecaz', 'Human Ecaz', 'ecaz'), false, ['ecaz', 'ix']);
|
||||
joinGame(lobby, newPlayer('human-tl', 'Human Tleilaxu', 'tleilaxu'));
|
||||
const two = setup({ initial: lobby, skills: true, homeworlds: true });
|
||||
assert.equal(player(two.afterSetup, two.owner).leaders.length, 5);
|
||||
assert.equal(two.afterSetup.leaderSkills!.assignments.length, 2);
|
||||
assert.ok(two.afterSetup.leaderSkills!.assignments.every(a => a.leader !== DUKE_VIDAL_ID));
|
||||
assert.equal(two.afterSetup.dukeVidal!.controller, null);
|
||||
const held = player(two.afterSetup, two.owner).hand.map(c => c.id);
|
||||
assert.equal(held.length, 1); inventory(two.afterSetup);
|
||||
const supplied = setup({ initial: two.offered, skills: true, homeworlds: true });
|
||||
assert.deepEqual(player(supplied.afterSetup, supplied.owner).hand.map(c => c.id), held);
|
||||
assert.equal(player(supplied.afterSetup, supplied.owner).reserves, 14);
|
||||
assert.equal(Object.values(player(supplied.afterSetup, supplied.owner).forces).reduce((n, f) => n + f, 0), 6);
|
||||
const three = programme({ families: ['choam'], roster: ['ecaz', 'richese', 'guild'], advanced: false, skills: false, tech: true });
|
||||
assert.equal(three.game.leaderSkills, undefined);
|
||||
assert.equal(three.game.richeseCache!.length + three.game.richeseRemoved!.length +
|
||||
[...three.game.deck, ...three.game.discard, ...three.game.players.flatMap(p => p.hand)].filter(c => c.id.startsWith('richese-')).length, 10);
|
||||
assert.equal(three.game.nexusCards!.cards!.hands[three.owner], 'ecaz');
|
||||
assert.equal(player(three.alliance!, three.seats.richese!).ally, three.seats.guild);
|
||||
assert.ok(player(three.claim!.after, three.owner).spice < player(three.claim!.before, three.owner).spice);
|
||||
inventory(three.game);
|
||||
});
|
||||
|
||||
void test('all-three-family six-native original setup and real alliance closing retain all14, native20, separate Duke and original rewards', () => {
|
||||
const f = programme({ families: ['ix', 'choam'], roster: ['ecaz', 'moritani', 'ixians', 'tleilaxu', 'choam', 'richese'],
|
||||
advanced: true, skills: true, homeworlds: true, tech: true, strongholds: true });
|
||||
assert.equal(f.afterSetup.leaderSkills!.assignments.length, 6);
|
||||
assert.equal(f.afterSetup.dukeVidal!.leader.id, DUKE_VIDAL_ID);
|
||||
assert.equal(player(f.afterSetup, f.owner).leaders.length, 5);
|
||||
assert.ok(f.afterSetup.ecazOccupyPreview && f.afterSetup.moritaniAssassinatePreview);
|
||||
assert.equal(player(f.afterSetup, f.seats.tleilaxu!).faceDancers!.length, 3);
|
||||
assert.equal(f.afterSetup.richeseCache!.length, 10);
|
||||
assert.ok(f.ixOffer); assert.ok(player(f.ixOffer.after, f.seats.ixians!).hand.some(c => c.id === f.ixOffer!.step.action.card));
|
||||
assert.equal(f.game.nexusCards!.cards!.hands[f.owner], 'ecaz');
|
||||
assert.equal(player(f.alliance!, f.seats.choam!).ally, f.seats.richese);
|
||||
assert.equal(f.firstMentat!.after.strongholdCards!.owners.sietch_tabr, f.owner);
|
||||
assert.equal(player(f.claim!.after, f.owner).reserves, player(f.claim!.before, f.owner).reserves - 1);
|
||||
inventory(f.game);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced selected Warmaster/HW/Tech/held Tabr' : 'Basic even-FORCE/no-Skills'} native mixed Ecaz coalition keeps its chosen lead, labelled support and physical payer/casualty ownership`, () => {
|
||||
const f = coalition({ advanced, skills: advanced, homeworlds: advanced, tech: true, strongholds: advanced });
|
||||
const d = f.game.decision; assert.ok(d?.kind === 'ecazBattleLead'); assert.equal(next(f.game), null);
|
||||
reject(f.game, f.ally, { type: 'decision', event: d.event, lead: f.owner });
|
||||
reject(f.game, f.owner, { type: 'decision', event: d.event, lead: f.opponent });
|
||||
for (const t of f.arrivals) {
|
||||
const count = Number(t.step.action.amount), actor = t.step.actor;
|
||||
assert.equal(player(t.after, actor).reserves, player(t.before, actor).reserves - count);
|
||||
assert.equal(player(t.after, actor).forces[f.location], (player(t.before, actor).forces[f.location] ?? 0) + count);
|
||||
assert.ok(player(t.after, actor).spice < player(t.before, actor).spice);
|
||||
}
|
||||
policies(f.game, f.owner, (after, action) => {
|
||||
assert.ok([f.owner, f.ally].includes(String(action.lead)));
|
||||
assert.equal(player(after, f.owner).spice, player(f.game, f.owner).spice);
|
||||
assert.equal(player(after, f.ally).spice, player(f.game, f.ally).spice);
|
||||
});
|
||||
for (const lead of [f.owner, f.ally]) {
|
||||
const plansWindow = chooseLead(f, lead), plans = coalitionPlans(f, plansWindow, lead);
|
||||
if (advanced) {
|
||||
const used = plannedStep(plans, lead).leader;
|
||||
const trainedDisc = !!plansWindow.leaderSkills?.assignments.some(a => a.owner === lead && a.leader === used);
|
||||
assert.equal(plansWindow.battle!.leaderSkillHidden?.[lead] ?? false, trainedDisc,
|
||||
'A skilled disc is hidden exactly when the plan uses it');
|
||||
}
|
||||
policies(plansWindow, lead, (offered) => {
|
||||
assert.equal(player(offered, lead).spice, player(plansWindow, lead).spice, 'A sealed dial does not prepay support');
|
||||
const revealed = reveal(offered, plans.filter(plan => plan.actor === f.opponent)), q = quote(revealed), finished = settle(revealed);
|
||||
assert.equal(finished.lastBattleContext!.winner, q.winner);
|
||||
for (const payment of q.payments) {
|
||||
const heldIncome = q.strongholdIncome.filter(r => r.player === payment.player).reduce((n, r) => n + r.amount, 0);
|
||||
const collection = finished.phase === 7 ? quoteSpiceCollection(finished).receipts.find(r => r.player === payment.player)?.strongholds ?? 0 : 0;
|
||||
const income = (q.bounty?.player === payment.player ? q.bounty.amount : 0) +
|
||||
(q.choamIncome?.owner === payment.player ? q.choamIncome.amount : 0);
|
||||
assert.equal(player(finished, payment.player).spice, player(revealed, payment.player).spice - payment.ownPayment + heldIncome + collection + income);
|
||||
}
|
||||
inventory(finished);
|
||||
});
|
||||
const revealed = reveal(plansWindow, plans), q = quote(revealed), side = revealed.battle!.attacker === lead ? 'attacker' : 'defender';
|
||||
assert.equal(q.winner, lead);
|
||||
assert.deepEqual((q.fixedLosses ?? []).map(loss => loss.owner), [f.owner],
|
||||
'Only Ecaz’s printed fixed dial produces the coalition’s fixed losses.');
|
||||
assert.equal(q.casualties!.owner, f.ally);
|
||||
assert.equal(q.payments.find(p => p.player === lead)?.ownPayment ?? 0,
|
||||
advanced ? Number(plannedStep(plans, lead).support) : 0,
|
||||
'The chosen variable support is funded by its own payer; Basic has no support');
|
||||
assert.equal(q.leaderSkillBonuses[side].applied.some(a => a.skill === 'warmaster'), advanced && lead === f.owner);
|
||||
assert.ok(q.strongholdIncome.every(income => income.player === f.owner),
|
||||
'Only the Ecaz holder can receive the held Tabr income; an ally lead never borrows it');
|
||||
const done = settle(revealed, undefined, true), lostToken = ownedTech(revealed.techTokens, f.opponent)[0]; assert.ok(lostToken);
|
||||
assert.equal(done.techTokens![lostToken].owner, lead);
|
||||
const ecazFixed = (q.fixedLosses ?? []).reduce((n, loss) => n + loss.normal + loss.elite, 0);
|
||||
assert.equal(player(done, f.owner).tanks - player(plansWindow, f.owner).tanks, ecazFixed);
|
||||
assert.equal(player(done, f.ally).tanks - player(plansWindow, f.ally).tanks, 4);
|
||||
assert.equal(player(done, f.opponent).tanks - player(plansWindow, f.opponent).tanks, 8);
|
||||
inventory(done);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic odd mixed Occupy rejects an actual paid three-force contribution before lead selection or reward', () => {
|
||||
const f = coalition({ advanced: false, skills: false, tech: true }, 'ixians', 3);
|
||||
assert.equal(player(f.beforeBattle, f.owner).forces[f.location], 3);
|
||||
reject(f.beforeBattle, f.choice.actor, f.choice.action);
|
||||
assert.equal(f.beforeBattle.battle, null); inventory(f.beforeBattle);
|
||||
});
|
||||
|
||||
void test('E3/E2 classic-led coalition never borrows the hidden native Warmaster or Ecaz-held battle income', () => {
|
||||
const f = coalition({ families: ['choam'], roster: ['ecaz', 'choam', 'guild', 'emperor'],
|
||||
advanced: true, skills: true, homeworlds: true, tech: true, strongholds: true }, 'emperor');
|
||||
const plansWindow = chooseLead(f, f.ally), revealed = reveal(plansWindow, coalitionPlans(f, plansWindow, f.ally));
|
||||
const q = quote(revealed), side = revealed.battle!.attacker === f.ally ? 'attacker' : 'defender';
|
||||
assert.equal(q.winner, f.ally);
|
||||
assert.ok(!q.leaderSkillBonuses[side].applied.some(a => a.skill === 'warmaster'));
|
||||
assert.deepEqual(q.strongholdIncome, []);
|
||||
const done = settle(revealed);
|
||||
assert.equal(player(done, f.owner).spice, player(plansWindow, f.owner).spice);
|
||||
assert.equal(done.techTokens![ownedTech(revealed.techTokens, f.opponent)[0]].owner, f.ally);
|
||||
inventory(done);
|
||||
});
|
||||
|
||||
for (const skills of [false, true]) void test(`${skills ? 'all14' : 'no-Skills'} original all-three-family paid foreign arrival → quiet living Duke Cunning → source-local end-turn expiry`, () => {
|
||||
const f = programme({ families: ['ix', 'choam'], roster: ['ecaz', 'ixians', 'choam', 'guild', 'emperor'],
|
||||
skills, advanced: true, homeworlds: true, tech: true, strongholds: true });
|
||||
let g = advance(f.game, s => s.phase === 5 && s.active === f.owner && clean(s), undefined, quiet);
|
||||
const t = ship(g, f.owner, 'homeworld:emperor', 1);
|
||||
const sources = nativeShipmentSources(viewGame(t.before, f.owner), 1, 0); assert.ok(sources);
|
||||
const invoice = homeworldShipmentChoice(viewGame(t.before, f.owner), 'homeworld:emperor',
|
||||
Object.fromEntries(Object.entries(sources).filter(([, v]) => v.normal + v.elite > 0)));
|
||||
assert.equal(player(t.after, f.owner).spice, player(t.before, f.owner).spice - invoice.cost);
|
||||
assert.equal(combatArmy(t.after, f.owner, 'homeworld:emperor').normal, 1);
|
||||
assert.equal(combatArmy(t.after, f.seats.emperor!, 'homeworld:emperor').normal,
|
||||
combatArmy(t.before, f.seats.emperor!, 'homeworld:emperor').normal);
|
||||
g = advance(t.after, s => s.phase === 6 && clean(s) && !s.battle, undefined, quiet);
|
||||
const action = nexusEcazDukeAction(viewGame(g, f.owner)); assert.ok(action);
|
||||
const trainer = g.leaderSkills?.assignments.find(a => a.owner === f.owner), purse = player(g, f.owner).spice;
|
||||
policies(g, f.owner, after => {
|
||||
assert.equal(after.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(after.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length, 1);
|
||||
assert.equal(after.dukeVidal!.controller, f.owner); assert.equal(after.dukeVidal!.source, 'ecazNexus');
|
||||
assert.equal(player(after, f.owner).spice, purse);
|
||||
});
|
||||
reject(g, f.seats.choam!, action);
|
||||
const spent = step(g, { actor: f.owner, action }); reject(spent, f.owner, action);
|
||||
// The original emperor-native battle is resolved rather than skipped or rewritten.
|
||||
let opened = advance(spent, s => s.phase === 6 && clean(s) && !!s.active, undefined, quiet);
|
||||
const menu = viewGame(opened, opened.active!).battleChoices.find(c => c.territory === 'homeworld:emperor'); assert.ok(menu);
|
||||
opened = step(opened, { actor: menu.chooser, action: { type: 'chooseBattle', territory: menu.territory,
|
||||
target: menu.attacker === menu.chooser ? menu.defender : menu.attacker } });
|
||||
const plansWindow = advance(opened, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, undefined, quiet);
|
||||
assert.ok(!plansWindow.battle!.ecazOccupy, 'Foreign native home never inherits Arrakis Occupy');
|
||||
const ownerDisc = player(plansWindow, f.owner).leaders.filter(l => !l.dead && l.id !== trainer?.leader).sort((a, b) => a.strength - b.strength)[0];
|
||||
const nativeDisc = player(plansWindow, f.seats.emperor!).leaders.filter(l => !l.dead &&
|
||||
!plansWindow.leaderSkills?.assignments.some(a => a.leader === l.id)).sort((a, b) => b.strength - a.strength)[0];
|
||||
const revealed = reveal(plansWindow, [{ actor: f.owner, action: { type: 'battlePlan', leader: ownerDisc.id, dial: 0, support: 0 } },
|
||||
{ actor: f.seats.emperor!, action: { type: 'battlePlan', leader: nativeDisc.id, dial: 2, support: 2 } }]);
|
||||
const q = quote(revealed); assert.equal(q.winner, f.seats.emperor);
|
||||
assert.equal(q.payments.find(p => p.player === f.seats.emperor)!.ownPayment, 2);
|
||||
const done = settle(revealed), later = advance(done, s => s.turn === 3 && s.phase === 1 && clean(s), undefined, quiet);
|
||||
assert.equal(later.dukeVidal!.controller, null); assert.equal(later.dukeVidal!.source, null);
|
||||
assert.equal(later.dukeVidal!.leader.dead, false);
|
||||
if (trainer) assert.ok(later.leaderSkills!.assignments.some(a => a.owner === trainer.owner && a.leader === trainer.leader && a.skill === trainer.skill),
|
||||
'Temporary Duke expiry never retires the ordinary native trainer');
|
||||
inventory(later);
|
||||
});
|
||||
|
||||
void test('original E3/E1 Discovery uses its actual random face, typed arrival, all Maker votes and ride before both-pile alliance-change closing draw', () => {
|
||||
const f = programme({ roster: ['ecaz', 'ixians', 'guild', 'fremen'], advanced: true, skills: true,
|
||||
discovery: true, homeworlds: true, tech: true, strongholds: true });
|
||||
assert.ok(f.firstArrival && f.reveal && f.vote && f.ride && f.alliance && f.drawing);
|
||||
const token = f.reveal.before.discoveries!.tokens.find(t => t.id === f.reveal!.step.action.token)!;
|
||||
assert.equal(f.reveal.after.discoveries!.tokens.find(t => t.id === token.id)!.face, token.face);
|
||||
assert.equal(player(f.firstArrival.after, f.owner).reserves, player(f.firstArrival.before, f.owner).reserves - 2);
|
||||
assert.ok(player(f.firstArrival.after, f.owner).spice < player(f.firstArrival.before, f.owner).spice);
|
||||
assert.equal(f.votes.length, 4);
|
||||
for (const vote of f.votes) policies(vote.before, vote.step.actor, after => {
|
||||
assert.equal(player(after, vote.step.actor).spice, player(vote.before, vote.step.actor).spice);
|
||||
assert.deepEqual(combatArmy(after, f.owner, 'gara_kulon'), combatArmy(vote.before, f.owner, 'gara_kulon'));
|
||||
});
|
||||
assert.equal(f.ride.step.actor, f.seats.fremen);
|
||||
assert.equal(player(f.ride.after, f.seats.fremen!).forces['polar_sink:0'],
|
||||
(player(f.ride.before, f.seats.fremen!).forces['polar_sink:0'] ?? 0) + 2);
|
||||
assert.equal(player(f.ride.after, f.seats.fremen!).elites!.forces['polar_sink:0'],
|
||||
(player(f.ride.before, f.seats.fremen!).elites!.forces['polar_sink:0'] ?? 0) + 1);
|
||||
if (f.entry) {
|
||||
const own = viewGame(f.entry.before, f.owner), typed = discoveryEntryMoveAction(own, own.discoveryEntry!.sources); assert.ok(typed);
|
||||
policies(f.entry.before, f.owner, after => {
|
||||
assert.equal(player(after, f.owner).spice, player(f.entry!.before, f.owner).spice);
|
||||
assert.equal(player(after, f.owner).reserves, player(f.entry!.before, f.owner).reserves);
|
||||
});
|
||||
// The engine transfers the signed arrival into the revealed face's own
|
||||
// sector-0 group (`place(p, arrival.destination, 0, …)`); the destination is
|
||||
// the token face verbatim, never a renamed territory.
|
||||
const arrived = player(f.entry.after, f.owner).forces[`${token.face}:0`];
|
||||
assert.equal(arrived, 2, 'The entered forces land under the revealed face key');
|
||||
assert.equal((player(f.entry.before, f.owner).forces[`${token.face}:0`] ?? 0) + 2, arrived,
|
||||
'The entry conserves the moving forces at the exact face key');
|
||||
assert.equal(player(f.entry.after, f.owner).forces[`${token.face}:0`],
|
||||
player(f.entry.before, f.owner).forces[`gara_kulon:8`] ?? 0,
|
||||
'The same two forces leave their Discovery arrival sector');
|
||||
assert.ok(!f.entry.after.pendingAmbassador && !f.entry.after.pendingTerrorEntry);
|
||||
}
|
||||
assert.ok(f.drawing.spiceDiscard[0].some(c => 'territory' in c && c.territory === 'rock_outcroppings'));
|
||||
assert.ok(f.drawing.spiceDiscard[1].some(c => 'territory' in c && c.territory === 'oh_gap'));
|
||||
assert.equal(player(f.alliance, f.seats.guild!).ally, f.seats.fremen);
|
||||
policies(f.draws[0].before, f.owner, after => {
|
||||
assert.equal(after.nexusCards!.cards!.hands[f.owner], 'ecaz');
|
||||
assert.ok(!after.nexusCards!.cards!.deck.includes('ecaz'));
|
||||
});
|
||||
inventory(f.game);
|
||||
});
|
||||
|
||||
void test('native Ecaz winner cleanup/Tech precedes an original Face Dance reveal without moving either seat’s training card', () => {
|
||||
const f = coalition({ roster: ['ecaz', 'tleilaxu', 'guild', 'emperor'], advanced: true, skills: true,
|
||||
homeworlds: true, tech: true, strongholds: true }, 'emperor');
|
||||
const tl = f.seats.tleilaxu!, trainer = f.trainers[f.owner];
|
||||
assert.ok(player(f.afterSetup, tl).faceDancers!.some(c => !c.revealed));
|
||||
const plansWindow = chooseLead(f, f.owner), held = player(plansWindow, f.owner).hand.find(c => c.kind === 'worthless'); assert.ok(held);
|
||||
// The Face Dance always targets the winner's own winning disc, so keep that
|
||||
// disc untrained: otherwise a genuine reveal would legitimately retire the
|
||||
// seat's training card and this case could not assert a stationary card.
|
||||
const trained = new Set((plansWindow.leaderSkills?.assignments ?? []).filter(a => a.owner === f.owner).map(a => a.leader));
|
||||
const winnerDisc = player(plansWindow, f.owner).leaders.filter(l => !l.dead && !l.usedAt && !trained.has(l.id))
|
||||
.sort((a, b) => b.strength - a.strength)[0]; assert.ok(winnerDisc, 'The winner needs an untrained disc for the Face Dance');
|
||||
const first = legalPlan(plansWindow, f.owner, winnerDisc.id, 'max', { defense: held.id });
|
||||
let afterWinner = plansWindow; for (const plan of first.steps) afterWinner = step(afterWinner, plan);
|
||||
const second = legalLeadPlan(afterWinner, f.opponent, 'min');
|
||||
const plans = [...first.steps, ...second.steps];
|
||||
const plan = plans.find(candidate => candidate.actor === f.owner && candidate.action.type === 'battlePlan');
|
||||
assert.ok(plan); plan.action.defense = held.id;
|
||||
const revealed = reveal(plansWindow, plans), q = quote(revealed); assert.equal(q.winner, f.owner);
|
||||
const lostTokens = ownedTech(revealed.techTokens, f.opponent);
|
||||
const dance = settle(revealed, s => s.decision?.kind === 'faceDance', true);
|
||||
assert.equal(dance.battle, null, 'Original battle cleanup is complete before Face Dance');
|
||||
for (const token of lostTokens) assert.equal(dance.techTokens![token].owner, f.owner);
|
||||
const decision = dance.decision; assert.ok(decision?.kind === 'faceDance');
|
||||
assert.equal(decision.player, tl); assert.equal(decision.winner, f.owner);
|
||||
const identity = decision.identity;
|
||||
const dancerCard = identity ? player(dance, tl).faceDancers!.find(dancer => dancer.leader === identity && !dancer.revealed) : undefined;
|
||||
const survivor = combatArmy(dance, f.owner, f.location.split(':')[0]);
|
||||
const nativeTraining = dance.leaderSkills!.assignments.find(a => a.owner === tl)!;
|
||||
policies(dance, tl, (after, action) => {
|
||||
const replaced = !!action.reveal;
|
||||
if (replaced) {
|
||||
const count = Object.values((action.sources ?? {}) as Record<string, number>).reduce((n, f) => n + f, 0);
|
||||
assert.equal(player(after, f.owner).leaders.find(l => l.id === identity)!.dead, true);
|
||||
assert.equal(player(after, tl).faceDancers!.find(dancer => dancer.leader === identity)!.revealed, true);
|
||||
assert.deepEqual(combatArmy(after, f.owner, f.location.split(':')[0]), { normal: 0, elite: 0 });
|
||||
assert.equal(combatArmy(after, tl, f.location.split(':')[0]).normal, count);
|
||||
} else {
|
||||
assert.deepEqual(combatArmy(after, f.owner, f.location.split(':')[0]), survivor);
|
||||
}
|
||||
assert.ok(after.leaderSkills!.assignments.some(a => a.owner === tl && a.leader === nativeTraining.leader && a.skill === nativeTraining.skill));
|
||||
assert.ok(after.leaderSkills!.assignments.some(a => a.owner === f.owner && a.leader === trainer && a.skill === 'warmaster'),
|
||||
'Neither seat’s training card moves through an original Face Dance');
|
||||
for (const token of lostTokens) assert.equal(after.techTokens![token].owner, f.owner);
|
||||
});
|
||||
assert.ok(player(dance, f.owner).hand.some(c => c.id === held.id), 'Kept winner card remains owned until its actual cleanup choice');
|
||||
if (dancerCard) {
|
||||
const replaced = step(dance, { actor: tl, action: { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 14 } });
|
||||
assert.equal(player(replaced, f.owner).leaders.find(l => l.id === identity)!.dead, true);
|
||||
assert.equal(combatArmy(replaced, tl, f.location.split(':')[0]).normal, 1);
|
||||
assert.ok(!player(replaced, tl).faceDancers!.some(dancer => dancer.leader === identity && !dancer.revealed));
|
||||
assert.equal(player(replaced, tl).spice, player(dance, tl).spice);
|
||||
inventory(replaced);
|
||||
} else {
|
||||
assert.ok(!identity || !player(dance, tl).faceDancers!.some(dancer => dancer.leader === identity && !dancer.revealed),
|
||||
'A reveal is offered only for a held unrevealed Face Dancer');
|
||||
const declined = step(dance, { actor: tl, action: { type: 'decision', reveal: false } });
|
||||
assert.deepEqual(combatArmy(declined, f.owner, f.location.split(':')[0]), survivor);
|
||||
assert.ok(declined.leaderSkills!.assignments.some(a => a.owner === f.owner && a.leader === trainer));
|
||||
inventory(declined);
|
||||
}
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced selected Warmaster/HW/Tech/held Tabr' : 'Basic even-FORCE/no-Skills'} native mixed Ecaz coalition keeps its chosen lead, labelled support and physical payer/casualty ownership`, () => {
|
||||
const f = coalition({ roster: ['ecaz', 'tleilaxu', 'guild', 'emperor'], advanced, skills: advanced,
|
||||
homeworlds: advanced, tech: true, strongholds: advanced }, 'tleilaxu');
|
||||
const d = f.game.decision; assert.ok(d?.kind === 'ecazBattleLead');
|
||||
assert.equal(next(f.game), null, 'The coalition lead is always an owner decision.');
|
||||
reject(f.game, f.ally, { type: 'decision', event: d.event, lead: f.owner });
|
||||
reject(f.game, f.owner, { type: 'decision', event: d.event, lead: f.opponent });
|
||||
for (const arrival of f.arrivals) {
|
||||
const count = Number(arrival.step.action.amount), actor = arrival.step.actor;
|
||||
assert.equal(player(arrival.after, actor).reserves, player(arrival.before, actor).reserves - count);
|
||||
assert.equal(player(arrival.after, actor).forces[f.location],
|
||||
(player(arrival.before, actor).forces[f.location] ?? 0) + count);
|
||||
assert.ok(player(arrival.after, actor).spice < player(arrival.before, actor).spice);
|
||||
}
|
||||
for (const lead of [f.owner, f.ally]) {
|
||||
const plansWindow = chooseLead(f, lead), plans = coalitionPlans(f, plansWindow, lead);
|
||||
policies(plansWindow, lead, (offered) => {
|
||||
assert.equal(player(offered, lead).spice, player(plansWindow, lead).spice,
|
||||
'A sealed dial does not prepay support');
|
||||
const revealed = reveal(offered, [plans[1]]), finished = settle(revealed);
|
||||
assert.equal(finished.lastBattleContext!.winner, quote(revealed).winner);
|
||||
inventory(finished);
|
||||
});
|
||||
const revealed = reveal(plansWindow, plans), q = quote(revealed);
|
||||
const side = revealed.battle!.attacker === lead ? 'attacker' : 'defender';
|
||||
const leadPlan = plannedStep(plans, lead);
|
||||
assert.equal(q.winner, lead);
|
||||
assert.deepEqual((q.fixedLosses ?? []).map(loss => loss.owner), [f.owner],
|
||||
'Only Ecaz’s printed fixed dial produces the coalition’s fixed losses.');
|
||||
assert.equal(q.casualties!.owner, f.ally);
|
||||
assert.equal(q.payments.find(payment => payment.player === lead)?.ownPayment ?? 0,
|
||||
advanced ? Number(leadPlan.support) : 0,
|
||||
'The chosen variable support is funded by its own payer; Basic has no support');
|
||||
assert.equal(q.leaderSkillBonuses[side].applied.some(applied => applied.skill === 'warmaster'),
|
||||
advanced && lead === f.owner);
|
||||
assert.equal(q.scores![side], Number(leadPlan.dial) + q.leaderStrengths[side] + q.leaderSkillBonuses[side].bonus,
|
||||
'An Arrakis coalition cannot borrow either participating faction’s native Homeworld strength');
|
||||
assert.ok(q.strongholdIncome.every(income => income.player === f.owner),
|
||||
'Only the Ecaz holder can receive the held Tabr income; an ally lead never borrows it');
|
||||
const done = settle(revealed, undefined, true);
|
||||
const lostToken = ownedTech(revealed.techTokens, f.opponent)[0]; assert.ok(lostToken);
|
||||
assert.equal(done.techTokens![lostToken].owner, lead);
|
||||
const committed = (owner: string) => player(plansWindow, owner).forces[f.location] ?? 0;
|
||||
assert.equal(player(done, f.opponent).tanks - player(plansWindow, f.opponent).tanks, committed(f.opponent),
|
||||
'The losing side loses every committed counter');
|
||||
const winnerTanks = [f.owner, f.ally]
|
||||
.reduce((n, owner) => n + (player(done, owner).tanks - player(plansWindow, owner).tanks), 0);
|
||||
assert.ok(winnerTanks >= 1 && winnerTanks <= committed(f.owner) + committed(f.ally),
|
||||
'The winning coalition loses only its own dialed counters');
|
||||
inventory(done);
|
||||
}
|
||||
});
|
||||
|
||||
for (const skilled of [false, true]) void test(`original Ecaz native ${skilled ? 'hidden trained' : 'unused face-up normal'} Suk saves only the actual winning dialed casualties at the same home`, () => {
|
||||
const f = programme({ roster: ['ecaz', 'ixians', 'guild', 'emperor'], advanced: true, skills: true,
|
||||
skill: 'suk-graduate', homeworlds: true, tech: true, strongholds: true });
|
||||
const ix = f.seats.ixians!;
|
||||
let g = advance(f.game, s => s.phase === 5 && s.active === ix && clean(s), undefined, quiet);
|
||||
const arrival = ship(g, ix, 'homeworld:ecaz', 2, undefined, 1); g = arrival.after;
|
||||
assert.deepEqual(combatArmy(g, ix, 'homeworld:ecaz'), { normal: 1, elite: 1 });
|
||||
g = advance(g, s => s.phase === 6 && clean(s), undefined, quiet);
|
||||
const menu = viewGame(g, g.active!).battleChoices.find(c => c.territory === 'homeworld:ecaz'); assert.ok(menu);
|
||||
g = step(g, { actor: menu.chooser, action: { type: 'chooseBattle', territory: menu.territory,
|
||||
target: menu.attacker === menu.chooser ? menu.defender : menu.attacker } });
|
||||
const plansWindow = advance(g, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false,
|
||||
undefined, s => s.decision?.kind === 'leaderSkillVisibility'
|
||||
? { actor: s.decision.player, action: { type: 'leaderSkillVisibility', event: s.decision.event,
|
||||
hide: s.decision.player === f.owner && skilled } } : quiet(s));
|
||||
const trainer = f.trainers[f.owner];
|
||||
const nativeDisc = skilled ? player(plansWindow, f.owner).leaders.find(l => l.id === trainer)!
|
||||
: player(plansWindow, f.owner).leaders.filter(l => !l.dead && l.id !== trainer).sort((a, b) => b.strength - a.strength)[0];
|
||||
const visitorDisc = player(plansWindow, ix).leaders.filter(l => !l.dead &&
|
||||
!plansWindow.leaderSkills!.assignments.some(a => a.leader === l.id)).sort((a, b) => a.strength - b.strength)[0];
|
||||
const ownerPlan = legalPlan(plansWindow, f.owner, nativeDisc.id, 'max');
|
||||
let afterOwner = plansWindow; for (const plan of ownerPlan.steps) afterOwner = step(afterOwner, plan);
|
||||
const visitorPlan = legalPlan(afterOwner, ix, visitorDisc.id, 'max');
|
||||
const revealed = reveal(plansWindow, [...ownerPlan.steps, ...visitorPlan.steps]), q = quote(revealed);
|
||||
assert.equal(q.winner, f.owner);
|
||||
assert.equal(q.payments.find(payment => payment.player === f.owner)?.ownPayment ?? 0, Number(ownerPlan.action.support),
|
||||
'Native free strength does not fund dialed support');
|
||||
assert.equal(q.sukGraduate?.mode, skilled ? 'skilled' : 'normal',
|
||||
'Only the hidden trained disc rescues in the skilled band; the unused face-up disc stays ordinary');
|
||||
// The winner's own dialed casualties are the only counters a Suk may save.
|
||||
const losses = q.casualties!.options[0]!;
|
||||
const lossTotal = losses.normal + losses.elite;
|
||||
assert.ok(lossTotal >= 1, 'The winner must have real dialed casualties for the rescue window.');
|
||||
// The hidden trained disc exposes an owner rescue decision. The unused
|
||||
// face-up disc has exactly one legal rescue, which the engine applies itself,
|
||||
// so the boundary is either that decision or the settled post-rescue state.
|
||||
const rescue = settle(revealed, s => s.decision?.kind === 'sukRescue' || (!s.battle && clean(s)));
|
||||
const d = rescue.decision;
|
||||
assert.equal(player(rescue, f.owner).spice, player(revealed, f.owner).spice - Number(ownerPlan.action.support));
|
||||
assert.equal(combatArmy(rescue, ix, 'homeworld:ecaz').normal + combatArmy(rescue, ix, 'homeworld:ecaz').elite, 0);
|
||||
assert.equal(player(rescue, ix).tanks, player(revealed, ix).tanks + 2,
|
||||
'Both shipped visitor counters are real Tanks losses');
|
||||
const homeArmy = combatArmy(revealed, f.owner, 'homeworld:ecaz').normal;
|
||||
const maximum = d?.kind === 'sukRescue'
|
||||
? Math.max(...d.options.map(option => option.normal + option.elite))
|
||||
: 1;
|
||||
if (d?.kind === 'sukRescue') {
|
||||
assert.equal(d.player, f.owner);
|
||||
assert.equal(next(rescue), null, 'The rescue is an owner decision.');
|
||||
assert.ok(maximum <= 3, 'The selected hidden band saves up to three counters');
|
||||
assert.ok(d.options.some(option => option.kept), 'A skilled rescue keeps one physical counter in its sector');
|
||||
const choice = d.options.findIndex(option => option.normal + option.elite === maximum);
|
||||
const action: Action = { type: 'decision', event: d.event, choice };
|
||||
reject(rescue, ix, action);
|
||||
policies(rescue, f.owner, (after, proposal) => {
|
||||
const saved = d.options[Number(proposal.choice)]; assert.ok(saved);
|
||||
const savedTotal = saved.normal + saved.elite;
|
||||
assert.equal(player(after, f.owner).tanks, player(revealed, f.owner).tanks + lossTotal - savedTotal,
|
||||
'Only the unrescued dialed casualties reach the winner’s Tanks');
|
||||
assert.equal(combatArmy(after, f.owner, 'homeworld:ecaz').normal, homeArmy - lossTotal + savedTotal,
|
||||
'The saved counters stay at the same home');
|
||||
assert.equal(player(after, f.owner).spice, player(rescue, f.owner).spice);
|
||||
assert.equal(player(after, ix).tanks, player(rescue, ix).tanks, 'Loser never receives the winner-only rescue');
|
||||
});
|
||||
const settled = step(rescue, { actor: f.owner, action }), done = settle(settled, s => !s.battle && clean(s));
|
||||
assert.equal(player(done, f.owner).tanks, player(revealed, f.owner).tanks + lossTotal - maximum,
|
||||
'Exactly the unrescued dialed counters reach the winner’s Tanks');
|
||||
assert.equal(combatArmy(done, f.owner, 'homeworld:ecaz').normal, homeArmy - lossTotal + maximum,
|
||||
'The saved counters stay at the same home');
|
||||
assert.equal(player(done, ix).tanks, player(rescue, ix).tanks, 'Loser never receives the winner-only rescue');
|
||||
inventory(done);
|
||||
} else {
|
||||
assert.equal(maximum, 1, 'The unused face-up band saves exactly one counter');
|
||||
assert.equal(player(rescue, f.owner).tanks, player(revealed, f.owner).tanks + lossTotal - 1,
|
||||
'Exactly the unrescued dialed counters reach the winner’s Tanks');
|
||||
assert.equal(combatArmy(rescue, f.owner, 'homeworld:ecaz').normal, homeArmy - lossTotal + 1,
|
||||
'The single saved counter stays at the same home');
|
||||
inventory(rescue);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { leaders } from '../game/cards';
|
||||
import { territory } from '../game/board';
|
||||
import { TERROR_KINDS } from '../game/moritani-terror';
|
||||
import {
|
||||
advanceMixedE3Moritani as advance, assertMixedE3MoritaniPhysical as physical,
|
||||
assertMixedE3MoritaniRejects as rejects, createMixedE3MoritaniProgramme as programme,
|
||||
createMixedE3MoritaniSetup as setup, mixedE3MoritaniClean as clean,
|
||||
mixedE3MoritaniCunningRequest as cunning, mixedE3MoritaniInventory as inventory,
|
||||
mixedE3MoritaniPlayer as player, mixedE3MoritaniPolicy as policy, mixedE3MoritaniReload as reload,
|
||||
mixedE3MoritaniToken as token, nextMixedE3MoritaniStep as next, quoteMixedE3MoritaniBattle as quote,
|
||||
settleMixedE3MoritaniBoundary as boundary,
|
||||
stageMixedE3MoritaniLoss as loss, stepMixedE3Moritani as step,
|
||||
type MixedE3MoritaniBoundary, type MixedE3MoritaniOptions,
|
||||
} from './fixture-mixed-e3-moritani-nexus';
|
||||
|
||||
/** Explicit test-author physical choices; the reusable controller stops here. */
|
||||
function consume(g: Game, kind: MixedE3MoritaniBoundary['kind']): Game {
|
||||
const d = g.decision; assert.ok(d, `The ${kind} boundary needs its real owner decision.`);
|
||||
if (kind === 'losses' && d.kind === 'battleLosses') {
|
||||
const elite = Math.max(...d.options.map(o => o.elite));
|
||||
return step(g, { actor: d.player, action: { type: 'decision', choice: d.options.findIndex(o => o.elite === elite) } });
|
||||
}
|
||||
if (kind === 'rescue' && d.kind === 'sukRescue')
|
||||
return step(g, { actor: d.player, action: { type: 'decision', event: d.event, choice: 0 } });
|
||||
if (kind === 'cards' && d.kind === 'battleCards')
|
||||
return step(g, { actor: d.player, action: { type: 'decision', discard: [] } });
|
||||
if (kind === 'tech' && d.kind === 'techToken')
|
||||
return step(g, { actor: d.player, action: { type: 'decision', token: d.choices[0] } });
|
||||
if (kind === 'faceDance' && d.kind === 'faceDance')
|
||||
return step(g, { actor: d.player, action: { type: 'decision', reveal: false } });
|
||||
throw Error(`The test must explicitly consume the ${d.kind} decision.`);
|
||||
}
|
||||
|
||||
/** Every physical counter a seat owns: Tanks, reserves and board sectors. */
|
||||
function totalCounters(g: Game, id: string): number {
|
||||
const seat = player(g, id);
|
||||
return seat.tanks + seat.reserves +
|
||||
Object.values(seat.forces).reduce((n, amount) => n + amount, 0);
|
||||
}
|
||||
|
||||
/** Walk the real aftermath in order, consuming each boundary exactly once and
|
||||
* leaving the private assassination choice to `reveal`. */
|
||||
function aftermath(g: Game, normalCallOwner: string | null, reveal?: (g: Game) => Game,
|
||||
callCard: string | null = null): {
|
||||
game: Game; order: MixedE3MoritaniBoundary['kind'][]; revealed: Game | null;
|
||||
} {
|
||||
const order: MixedE3MoritaniBoundary['kind'][] = [];
|
||||
let revealed: Game | null = null;
|
||||
for (let n = 0; n < 16; n++) {
|
||||
const b = boundary(g, normalCallOwner, undefined, callCard); order.push(b.kind); g = b.game;
|
||||
if (b.kind === 'settled') return { game: g, order, revealed };
|
||||
if (b.kind === 'assassination') {
|
||||
const d = b.game.decision; assert.ok(d?.kind === 'moritaniAssassinate');
|
||||
g = reveal ? reveal(b.game) : step(b.game, { actor: d.player,
|
||||
action: { type: 'decision', event: d.event, decline: true } });
|
||||
revealed = g; continue;
|
||||
}
|
||||
g = consume(g, b.kind);
|
||||
}
|
||||
throw Error('The original mixed E3 aftermath did not settle.');
|
||||
}
|
||||
|
||||
void test('original mixed E3 native Moritani admission conserves decks, marks, tokens and forces across every selected band', () => {
|
||||
const bands: MixedE3MoritaniOptions[] = [
|
||||
{ rules: 'basic', families: ['ix'], skills: false },
|
||||
{ rules: 'advanced', families: ['ix'], skills: false },
|
||||
{ rules: 'advanced', families: ['choam'], skills: true },
|
||||
{ rules: 'advanced', families: ['ix', 'choam'], skills: false, tech: true },
|
||||
{ rules: 'advanced', families: ['ix', 'choam'], skills: true, strongholds: true },
|
||||
{ rules: 'advanced', families: ['ix', 'choam'], skills: false, homeworlds: true, discovery: true },
|
||||
];
|
||||
for (const options of bands) {
|
||||
const f = setup(options);
|
||||
const advanced = options.rules !== 'basic';
|
||||
assert.equal(f.afterSetup.status, 'playing');
|
||||
assert.equal(f.afterSetup.advanced, advanced);
|
||||
assert.deepEqual([...f.afterSetup.expansions].sort(), ['ecaz', ...(options.families ?? [])].sort());
|
||||
assert.equal(!!f.afterSetup.leaderSkills, !!options.skills);
|
||||
assert.equal(!!f.afterSetup.techTokens, !!options.tech);
|
||||
assert.equal(!!f.afterSetup.strongholdCards, !!options.strongholds);
|
||||
assert.equal(!!f.afterSetup.discoveries, !!options.discovery);
|
||||
assert.equal(!!f.afterSetup.homeworlds, !!options.homeworlds);
|
||||
// The Advanced native Moritani mark belongs to the mixed E3 envelope only.
|
||||
assert.equal(!!f.afterSetup.moritaniAssassinate, advanced);
|
||||
assert.deepEqual(f.afterSetup.moritaniTerror!.tokens.map(t => t.kind).sort(), [...TERROR_KINDS].sort());
|
||||
assert.equal(player(f.afterSetup, f.moritani).reserves, 14);
|
||||
assert.equal(player(f.afterSetup, f.moritani).forces[f.location], 6);
|
||||
assert.ok(player(f.afterSetup, f.moritani).traitors.includes(f.target),
|
||||
'The revealed Traitor is the card the original setup deal actually gave Moritani.');
|
||||
assert.ok(leaders(f.nativeFaction).some(l => l.id === f.target));
|
||||
if (options.families?.includes('ix'))
|
||||
assert.ok(!f.afterSetup.ixSetupCards, 'The Ixians pre-training row is consumed exactly once.');
|
||||
physical(f.afterSetup);
|
||||
// A supplied original setup keeps its own hands and offers and is never redealt.
|
||||
const continued = setup({ ...options, initial: reload(f.offered) });
|
||||
assert.deepEqual(continued.afterSetup.players.map(p => ({ id: p.id, hand: p.hand })),
|
||||
f.afterSetup.players.map(p => ({ id: p.id, hand: p.hand })));
|
||||
assert.deepEqual(continued.afterSetup.nexusCards, f.afterSetup.nexusCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('two-through-six original seats, the combined five/six-native roster and the Harkonnen guards stay original', () => {
|
||||
const two = setup({ families: ['ix'], roster: ['moritani', 'tleilaxu'] });
|
||||
assert.equal(two.afterSetup.players.length, 2); assert.equal(two.partner, null);
|
||||
assert.equal(two.afterSetup.nexusCards!.cards!.deck.length, 12);
|
||||
physical(two.afterSetup);
|
||||
for (const count of [3, 4, 5, 6] as const) {
|
||||
const classics = (['guild', 'emperor', 'atreides', 'fremen'] as FactionId[]).slice(0, count - 2);
|
||||
const f = setup({ families: ['ix'], roster: ['moritani', 'tleilaxu', ...classics] });
|
||||
assert.equal(f.afterSetup.players.length, count); physical(f.afterSetup);
|
||||
}
|
||||
// The bare all-three-family roster keeps the original combined five/six-native entry.
|
||||
const five = setup({ families: ['ix', 'choam'], roster: ['moritani', 'ecaz', 'ixians', 'tleilaxu', 'choam'] });
|
||||
assert.equal(five.entry, 'combined'); assert.equal(five.afterSetup.players.length, 5);
|
||||
assert.equal(five.afterSetup.expansions.length, 3); physical(five.afterSetup);
|
||||
const six = setup({ families: ['ix', 'choam'],
|
||||
roster: ['moritani', 'ecaz', 'ixians', 'tleilaxu', 'choam', 'richese'] });
|
||||
assert.equal(six.entry, 'combined'); assert.equal(six.afterSetup.richeseCache!.length, 10);
|
||||
assert.ok(six.afterSetup.expansions.includes('ix'));
|
||||
physical(six.afterSetup);
|
||||
// Basic Harkonnen remains a legal classic seat; Advanced retains its guard.
|
||||
const basic = setup({ rules: 'basic', families: ['ix'], roster: ['moritani', 'tleilaxu', 'harkonnen'] });
|
||||
assert.equal(player(basic.afterSetup, basic.seats.harkonnen!).traitors.length, 4);
|
||||
assert.equal(basic.afterSetup.moritaniAssassinate, undefined);
|
||||
assert.throws(() => setup({ rules: 'advanced', families: ['ix'], roster: ['moritani', 'tleilaxu', 'harkonnen'] }));
|
||||
assert.throws(() => setup({ rules: 'basic', families: ['ix'], strongholds: true }));
|
||||
});
|
||||
|
||||
void test('genuine turn-one/two chronology reaches a real alliance change, both Advanced Spice piles and Moritani own closing Nexus draw', () => {
|
||||
const f = programme({ rules: 'advanced', families: ['ix'], skills: false });
|
||||
assert.ok(f.firstMentat && f.alliance && f.drawing);
|
||||
assert.equal(f.firstMentat.before.turn, 1); assert.equal(f.firstMentat.after.turn, 2);
|
||||
const allies = f.alliance.players.filter(p => p.ally);
|
||||
assert.equal(allies.length, 2);
|
||||
assert.equal(allies[0].ally, allies[1].id); assert.equal(allies[1].ally, allies[0].id);
|
||||
assert.equal(player(f.alliance, f.moritani).ally, null, 'Moritani stays unallied to keep its closing qualification.');
|
||||
assert.equal(f.drawing.phase, 1);
|
||||
assert.ok(f.drawing.spiceDiscard.every(pile => pile.length > 0), 'Both original Spice piles precede the closing draw.');
|
||||
const draw = f.draws[0];
|
||||
assert.equal(draw.after.nexusCards!.cards!.hands[f.moritani], 'moritani');
|
||||
assert.equal(draw.before.nexusCards!.cards!.hands[f.moritani], null);
|
||||
assert.equal(player(draw.before, f.moritani).spice, player(draw.after, f.moritani).spice,
|
||||
'The original Nexus draw invents no bank income.');
|
||||
rejects(draw.after, f.moritani, draw.step.action);
|
||||
inventory(f.game);
|
||||
});
|
||||
|
||||
for (const band of [
|
||||
{ name: 'native E1 Ixians', natives: { ix: 'ixians' } as const, families: ['ix'] as const, skills: false },
|
||||
{ name: 'native E2 CHOAM', natives: { choam: 'choam' } as const, families: ['choam'] as const, skills: false },
|
||||
{ name: 'native E1 Tleilaxu with all14 skills', natives: { ix: 'tleilaxu' } as const, families: ['ix'] as const, skills: true },
|
||||
] as const) void test(`Advanced Moritani loss to ${band.name} reveals a different original held faction Traitor, pays its printed bounty and replaces it once`, () => {
|
||||
const f = programme({ rules: 'advanced', families: [...band.families], natives: { ...band.natives }, skills: band.skills });
|
||||
assert.ok(f.firstMentat && f.alliance);
|
||||
const battle = loss(f, f.game);
|
||||
assert.equal(battle.target, f.target);
|
||||
assert.notEqual(battle.enemyLeader, f.target, 'The opposed disc is always a different leader.');
|
||||
assert.equal(quote(battle.revealed).winner, f.native, 'The selected native wins the real battle.');
|
||||
const revealedState = battle.revealed;
|
||||
const targetLeader = player(revealedState, f.native).leaders.find(l => l.id === f.target)!;
|
||||
assert.equal(targetLeader.dead, false);
|
||||
let chosen: Game | undefined;
|
||||
const walk = aftermath(revealedState, band.skills ? null : f.moritani, boundaryGame => {
|
||||
const d = boundaryGame.decision; assert.ok(d?.kind === 'moritaniAssassinate');
|
||||
const action: Action = { type: 'decision', event: d.event, card: f.target };
|
||||
rejects(boundaryGame, f.native, action);
|
||||
rejects(boundaryGame, f.moritani, { ...action, event: 'stale-assassination' });
|
||||
rejects(boundaryGame, f.moritani, { ...action, card: battle.enemyLeader });
|
||||
const at = player(boundaryGame, f.native), own = player(boundaryGame, f.moritani);
|
||||
chosen = step(boundaryGame, { actor: d.player, action });
|
||||
assert.equal(player(chosen, f.native).leaders.find(l => l.id === f.target)!.dead, true);
|
||||
assert.equal(player(chosen, f.native).leaders.find(l => l.id === f.target)!.deaths, targetLeader.deaths + 1);
|
||||
assert.equal(player(chosen, f.moritani).spice, own.spice + targetLeader.strength,
|
||||
'The printed bounty is paid once from the bank.');
|
||||
assert.equal(player(chosen, f.native).spice, at.spice);
|
||||
assert.equal(totalCounters(chosen, f.native), totalCounters(boundaryGame, f.native),
|
||||
'The assassination conserves the native’s physical counters.');
|
||||
assert.equal(totalCounters(chosen, f.moritani), totalCounters(boundaryGame, f.moritani),
|
||||
'The assassination conserves Moritani’s physical counters.');
|
||||
rejects(chosen, f.moritani, action);
|
||||
if (band.skills) {
|
||||
const skill = f.afterSetup.leaderSkills!.assignments.find(a => a.leader === f.target)!.skill;
|
||||
assert.equal(chosen.leaderSkills!.assignments.some(a => a.leader === f.target), false);
|
||||
assert.equal(chosen.leaderSkills!.deck.filter(c => c === skill).length, 1,
|
||||
'A different trained disc returns its physical skill exactly once.');
|
||||
}
|
||||
return chosen;
|
||||
});
|
||||
assert.ok(chosen, 'The Advanced native loss must open its private reveal choice.');
|
||||
if (band.skills) {
|
||||
assert.ok(walk.order.includes('assassination'), walk.order.join(','));
|
||||
assert.ok(walk.order.indexOf('assassination') < walk.order.indexOf('cards'), walk.order.join(','));
|
||||
} else assert.equal(walk.order[0], 'assassination', walk.order.join(','));
|
||||
const settled = advance(walk.game, s => s.decision?.kind === 'moritaniPlacement', f.actions);
|
||||
const receipt = settled.moritaniAssassinate!.opportunities.at(-1)!;
|
||||
assert.equal(receipt.stage, 'replaced'); assert.equal(receipt.card, f.target);
|
||||
const reserve = chosen.traitorReserve!;
|
||||
assert.equal(receipt.replacement, reserve[0]);
|
||||
assert.equal(settled.traitorReserve!.length, reserve.length - 1, 'Exactly one private Mentat replacement.');
|
||||
assert.ok(player(settled, f.moritani).traitors.includes(reserve[0]));
|
||||
assert.equal(settled.traitorReserve!.includes(f.target), false, 'The revealed card stays a public set-aside.');
|
||||
assert.equal(Object.hasOwn(viewGame(settled, f.native).moritaniAssassinate!.history.at(-1)!, 'replacement'), false,
|
||||
'The public history never carries the private Mentat replacement.');
|
||||
physical(settled);
|
||||
});
|
||||
|
||||
void test('a normal own-card Traitor reveal forfeits the Advanced advantage for the rest of the game across later real battles', () => {
|
||||
const f = programme({ rules: 'advanced', families: ['ix'], natives: { ix: 'ixians' }, skills: false });
|
||||
assert.ok(f.firstMentat);
|
||||
const first = loss(f, f.game, { disc: 'held' });
|
||||
const won = aftermath(first.revealed, f.moritani, undefined, first.target);
|
||||
assert.equal(won.order.includes('assassination'), false, won.order.join(','));
|
||||
const g = won.game;
|
||||
assert.equal(g.lastBattleContext!.result, 'traitor');
|
||||
assert.equal(g.lastBattleContext!.winner, f.moritani);
|
||||
assert.equal(g.moritaniAssassinate!.normalTraitorCall, true);
|
||||
assert.equal(g.moritaniAssassinateCallEvents!.length, 1);
|
||||
assert.deepEqual(g.moritaniAssassinate!.opportunities, []);
|
||||
assert.ok(viewGame(g, f.moritani).moritaniAssassinate!.blocked?.includes('forfeited'));
|
||||
// A later genuine loss to the same faction still grants nothing. The disc
|
||||
// already died to the normal Traitor call, so compare its death count.
|
||||
const second = loss(f, g);
|
||||
const targetDeaths = player(g, f.native).leaders.find(l => l.id === second.target)!.deaths;
|
||||
const later = aftermath(second.revealed, null);
|
||||
assert.equal(later.order.includes('assassination'), false, later.order.join(','));
|
||||
assert.deepEqual(later.game.moritaniAssassinate!.opportunities, []);
|
||||
assert.equal(later.game.moritaniAssassinate!.normalTraitorCall, true);
|
||||
assert.equal(player(later.game, f.native).leaders.find(l => l.id === second.target)!.deaths, targetDeaths,
|
||||
'The later battle grants no second assassination.');
|
||||
physical(later.game);
|
||||
});
|
||||
|
||||
void test('supply-only native Cunning and the real Terror arrival retain the physical token, leader and bounty', () => {
|
||||
const f = programme({ rules: 'advanced', families: ['ix'], natives: { ix: 'ixians' }, skills: false });
|
||||
assert.ok(f.firstMentat && f.alliance);
|
||||
// The native Cunning is offered only inside its own quiet window.
|
||||
let g = advance(reload(f.game), s => viewGame(s, f.moritani).nexusMoritani?.blocked === null, f.actions);
|
||||
const request = cunning(g, f.moritani, 'robbery', 'red_chasm');
|
||||
rejects(g, f.native, request);
|
||||
rejects(g, f.moritani, { ...request, nexus: 'stale-cunning' });
|
||||
rejects(g, f.moritani, { type: 'decision', token: token(g, 'robbery').id, territory: 'red_chasm' });
|
||||
const before = reload(g);
|
||||
g = advance(step(g, { actor: f.moritani, action: request }),
|
||||
s => clean(s) && s.moritaniTerror!.tokens.some(t => t.kind === 'robbery' && t.status === 'placed'), f.actions);
|
||||
assert.equal(token(g, 'robbery').location, 'red_chasm');
|
||||
assert.equal(g.moritaniTerror!.placementTurn, g.turn);
|
||||
assert.equal(g.nexusCards!.cards!.hands[f.moritani], null, 'The Nexus singleton is the whole supply-only cost.');
|
||||
assert.equal(g.nexusCards!.cards!.discard.filter(c => c === 'moritani').length, 1);
|
||||
assert.equal(player(g, f.moritani).spice, player(before, f.moritani).spice, 'No bank income is invented.');
|
||||
assert.deepEqual(player(g, f.moritani).leaders, player(before, f.moritani).leaders);
|
||||
// A real enemy shipment into the declared territory opens the private arrival.
|
||||
g = advance(g, s => s.phase === 5 && s.active === f.native && clean(s), f.actions);
|
||||
g = step(g, { actor: f.native, action: { type: 'ship', territory: 'red_chasm',
|
||||
sector: territory('red_chasm').sectors[0], amount: 3, elite: 0, allyPayment: 0 } }, f.actions);
|
||||
g = advance(g, s => s.pendingTerrorEntry?.stage === 'offer', f.actions);
|
||||
assert.equal(viewGame(g, f.moritani).terrorEntry!.kind, 'robbery');
|
||||
assert.equal(viewGame(g, f.native).terrorEntry!.kind, undefined, 'The token face stays owner-private.');
|
||||
const revealed = step(step(g, { actor: f.moritani, action: { type: 'decision', reveal: true } }),
|
||||
{ actor: f.moritani, action: { type: 'decision', choice: 'spice' } });
|
||||
assert.equal(revealed.pendingTerrorEntry ?? null, null);
|
||||
assert.notEqual(token(revealed, 'robbery').status, 'available');
|
||||
assert.deepEqual(player(revealed, f.moritani).leaders.map(l => l.id), player(g, f.moritani).leaders.map(l => l.id));
|
||||
const robbed = player(g, f.native).spice;
|
||||
assert.equal(player(revealed, f.moritani).spice, player(g, f.moritani).spice + Math.ceil(robbed / 2),
|
||||
'Printed Robbery transfers exactly half the entrant spice, rounded up, once.');
|
||||
assert.equal(player(revealed, f.native).spice, robbed - Math.ceil(robbed / 2));
|
||||
inventory(revealed);
|
||||
});
|
||||
|
||||
void test('all four original policies consume the real private assassination window and rejected actions never move stock', () => {
|
||||
const f = programme({ rules: 'advanced', families: ['ix'], natives: { ix: 'ixians' }, skills: false });
|
||||
assert.ok(f.firstMentat);
|
||||
const battle = loss(f, f.game);
|
||||
let g = battle.revealed;
|
||||
for (let n = 0; n < 16; n++) {
|
||||
const b = boundary(g, f.moritani); g = b.game;
|
||||
if (b.kind === 'settled') throw Error('The Advanced loss must grant an assassination opportunity.');
|
||||
if (b.kind === 'assassination') break;
|
||||
g = consume(g, b.kind);
|
||||
}
|
||||
const d = g.decision; assert.ok(d?.kind === 'moritaniAssassinate');
|
||||
const original = reload(g);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const action = policy(g, f.moritani, difficulty).find(a => a.type === 'decision');
|
||||
assert.ok(action, `${difficulty} must consume the actual assassination window.`);
|
||||
rejects(g, f.native, action);
|
||||
const after = step(reload(g), { actor: f.moritani, action });
|
||||
assert.ok(['revealed', 'declined'].includes(after.moritaniAssassinate!.opportunities.at(-1)!.stage));
|
||||
assert.deepEqual(g, original, 'A rejected rival action cannot spend the original opportunity.');
|
||||
}
|
||||
assert.equal(next(g) ?? null, null, 'The private choice is never auto-consumed by the controller.');
|
||||
});
|
||||
@@ -262,18 +262,31 @@ void test('normal movement triggers after moving forces once, while an internal
|
||||
assert.equal(player(shifted, f.target).forces['imperial_basin:11'], 2);
|
||||
});
|
||||
|
||||
void test('overlapping Guild income and Bene Gesserit arrival reactions reject the entire shipment atomically', () => {
|
||||
for (const faction of ['guild', 'beneGesserit'] as const) {
|
||||
const initial = fixture();
|
||||
initial.players.push(newPlayer('x', 'Arrival reaction', faction));
|
||||
initial.order.push('x');
|
||||
rejected(
|
||||
initial,
|
||||
'e',
|
||||
shipment,
|
||||
/Terror combined with another arrival reaction/,
|
||||
);
|
||||
}
|
||||
void test('overlapping Guild income and Bene Gesserit arrival reactions defer the Terror entry to the committed shipment', () => {
|
||||
// A seated Guild takes its real income; the committed entry then opens.
|
||||
const guild = fixture();
|
||||
guild.players.push(newPlayer('x', 'Arrival reaction', 'guild'));
|
||||
guild.order.push('x');
|
||||
const paid = applyAction(guild, 'e', shipment);
|
||||
assert.equal(paid.decision?.kind, 'moritaniTerror');
|
||||
assert.equal(paid.pendingArrivalReaction ?? null, null);
|
||||
assert.deepEqual(player(paid, 'e').forces, { 'arrakeen:10': 2 });
|
||||
assert.equal(player(paid, 'e').spice, 18, 'The real shipment price is paid once.');
|
||||
|
||||
// A seated BG advisor choice settles first; the entry waits for it.
|
||||
const advisor = fixture();
|
||||
advisor.players.push(newPlayer('x', 'Arrival reaction', 'beneGesserit'));
|
||||
advisor.order.push('x');
|
||||
const deferred = applyAction(advisor, 'e', shipment);
|
||||
assert.equal(deferred.decision?.kind, 'advisor');
|
||||
assert.equal(deferred.pendingTerrorEntry ?? null, null);
|
||||
assert.equal(deferred.pendingArrivalReaction?.[0]?.entrant, 'e');
|
||||
assert.deepEqual(player(deferred, 'e').forces, { 'arrakeen:10': 2 });
|
||||
const declined = applyAction(deferred, 'x', { type: 'decision', accept: false });
|
||||
assert.equal(declined.decision?.kind, 'moritaniTerror', 'The deferred entry opens once the advisor choice settles.');
|
||||
assert.equal(declined.pendingArrivalReaction ?? null, null);
|
||||
|
||||
// Advanced BG fighters raise the Intrusion choice first.
|
||||
const intrusion = fixture();
|
||||
intrusion.advanced = true;
|
||||
const bg = newPlayer('b', 'Bene Gesserit', 'beneGesserit');
|
||||
@@ -281,12 +294,13 @@ void test('overlapping Guild income and Bene Gesserit arrival reactions reject t
|
||||
bg.reserves = 0;
|
||||
intrusion.players.push(bg);
|
||||
intrusion.order.push('b');
|
||||
rejected(
|
||||
intrusion,
|
||||
'e',
|
||||
shipment,
|
||||
/Terror combined with another arrival reaction/,
|
||||
);
|
||||
const waiting = applyAction(intrusion, 'e', shipment);
|
||||
assert.equal(waiting.decision?.kind, 'intrusion');
|
||||
assert.equal(waiting.pendingTerrorEntry ?? null, null);
|
||||
assert.equal(waiting.pendingArrivalReaction?.[0]?.entrant, 'e');
|
||||
const answered = applyAction(waiting, 'b', { type: 'decision', accept: false });
|
||||
assert.equal(answered.decision?.kind, 'moritaniTerror', 'The deferred entry opens after the Intrusion choice.');
|
||||
assert.equal(answered.pendingArrivalReaction ?? null, null);
|
||||
});
|
||||
|
||||
void test('Guild cross-shipment settles once and returning forces to reserves has no entry trigger', () => {
|
||||
|
||||
@@ -34,10 +34,17 @@ void test('every canonical skill can be dealt and publicly assigned to Basic Mor
|
||||
});
|
||||
|
||||
void test('Moritani skill starts retain immutable Ecaz, optional-module and public-start rejection', () => {
|
||||
// Native Discovery Leader Skills are now admitted for this fresh lobby.
|
||||
const discovery = createGame('MORIGATE', newPlayer('m', 'Moritani', 'moritani'), false, ['ecaz']);
|
||||
joinGame(discovery, newPlayer('a', 'Atreides', 'atreides'));
|
||||
discovery.players.forEach(p => { p.ready = true; });
|
||||
discovery.discoveryEnabled = true;
|
||||
assert.ok(initializeLeaderSkillsGameForAudit(discovery).leaderSkills);
|
||||
for (const change of [
|
||||
(g: Game) => { g.players[1] = newPlayer('a', 'Ecaz', 'ecaz'); },
|
||||
(g: Game) => { g.expansions.push('choam'); },
|
||||
(g: Game) => { g.discoveryEnabled = true; },
|
||||
(g: Game) => { g.discoveryEnabled = true; g.expansions.push('choam'); },
|
||||
(g: Game) => { g.discoveryEnabled = true; g.players[1] = newPlayer('a', 'Ecaz', 'ecaz'); },
|
||||
(g: Game) => { g.ecazTreachery = true; },
|
||||
]) {
|
||||
const lobby = createGame('MORIGATE', newPlayer('m', 'Moritani', 'moritani'), false, ['ecaz']);
|
||||
|
||||
@@ -231,7 +231,10 @@ void test('Richese can fund its buyer from escrow with no unpledged spice, with
|
||||
assert.equal(f.game.aid[f.target].amount,2);
|
||||
const done = use ? applyAction(reload(f.game),f.holder,{type:'richeseBetrayalUse',event:f.event}) : passes(reload(f.game));
|
||||
assert.equal(player(done,f.buyer).spice,player(f.game,f.buyer).spice - 4);
|
||||
assert.equal(player(done,f.target).spice,use ? 0 : 6);
|
||||
// The authorized Advanced source sends Richese's own contribution to the
|
||||
// Emperor or, with no Emperor seated, to the Bank: the seller receives only
|
||||
// the buyer's own payment. Basic keeps the full sale credit.
|
||||
assert.equal(player(done,f.target).spice,use ? 0 : source === 'cache' ? 6 : 4);
|
||||
assert.equal(done.aid[f.target].amount,0);
|
||||
assert.deepEqual(census(done),census(f.game));
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(done)),done);
|
||||
|
||||
@@ -754,7 +754,7 @@ void test('ending without flight escrow never emits an empty Ornithopter discard
|
||||
assert.equal(done.active, 'q');
|
||||
});
|
||||
|
||||
void test('a final flight with competing BG intrusion and Terror rejects atomically before saving any retirement', () => {
|
||||
void test('a final flight with competing BG intrusion and Terror commits its retirement and defers the Terror entry', () => {
|
||||
const initial = fixture('richese', true);
|
||||
initial.players[1] = newPlayer('q', 'Moritani', 'moritani');
|
||||
initial.players[2] = newPlayer('e', 'Bene Gesserit', 'beneGesserit');
|
||||
@@ -769,15 +769,24 @@ void test('a final flight with competing BG intrusion and Terror rejects atomica
|
||||
);
|
||||
const before = structuredClone(initial),
|
||||
action = first({ ornithopter: 'range3' });
|
||||
assert.throws(() => applyAction(initial, 'p', action), /Terror combined/);
|
||||
assert.throws(() => inner(initial, 'p', action), /Terror combined/);
|
||||
assert.deepEqual(initial, before);
|
||||
assert.ok(player(initial, 'p').hand.some((c) => c.id === ORNI));
|
||||
assert.equal(initial.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(initial.ornithopter ?? null, null);
|
||||
assert.equal(player(initial, 'p').moved, 0);
|
||||
assert.equal(player(initial, 'p').forces[ORIGIN], 3);
|
||||
inventory(initial);
|
||||
const flown = applyAction(initial, 'p', action);
|
||||
assert.deepEqual(initial, before, 'The authoritative input is not mutated.');
|
||||
assert.equal(flown.decision?.kind, 'intrusion');
|
||||
assert.equal(flown.pendingArrivalReaction?.[0]?.entrant, 'p');
|
||||
assert.equal(flown.pendingArrivalReaction?.[0]?.territory, 'arrakeen');
|
||||
assert.equal(flown.pendingArrivalReaction?.[0]?.amount, 1);
|
||||
assert.equal(flown.pendingTerrorEntry ?? null, null,
|
||||
'The Terror entry waits for the pending Intrusion choice.');
|
||||
assert.equal(player(flown, 'p').moved, 1);
|
||||
assert.deepEqual(player(flown, 'p').forces, { 'imperial_basin:10': 2, 'arrakeen:10': 1 });
|
||||
assert.equal(flown.ornithopter ?? null, null);
|
||||
inventory(flown);
|
||||
// The same committed flight saves its real physical retirement continuation.
|
||||
const saved = inner(initial, 'p', action);
|
||||
assert.equal(saved.pendingTreacheryDiscard?.continuation?.kind, 'ornithopterDiscard');
|
||||
assert.equal(player(saved, 'p').moved, 1);
|
||||
assert.deepEqual(player(saved, 'p').forces, { 'imperial_basin:10': 2, 'arrakeen:10': 1 });
|
||||
inventory(saved);
|
||||
});
|
||||
|
||||
void test('automatic Fremen faction-speed allowance drains the final group frame through the response loop', () => {
|
||||
|
||||
@@ -329,9 +329,28 @@ void test('unsupported simultaneous arrivals fail before consent is committed an
|
||||
);
|
||||
hold(g, 'e', 'Karama');
|
||||
g = offer(g);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => accept(g), /combined with another arrival/);
|
||||
assert.deepEqual(g, before);
|
||||
// The consent commits its real shipment; the committed Terror entry waits
|
||||
// for the arrival's own reaction instead of failing the whole consent.
|
||||
const committed = accept(g);
|
||||
// The consent commits its shipment and opens the No-Field response instead of
|
||||
// rejecting the whole consent because a Terror token shares the destination.
|
||||
assert.equal(committed.response?.kind, 'richeseNoField');
|
||||
assert.deepEqual(committed.players[1].forces, {}, 'No force moves before the response settles.');
|
||||
conserve(committed);
|
||||
// Once the response settles, the committed arrival still offers its Terror entry.
|
||||
let arrived = committed;
|
||||
for (let i = 0; arrived.response && i < 8; i++) {
|
||||
const passer = arrived.players.find(p => {
|
||||
const controls = viewGame(arrived, p.id).responseControls;
|
||||
return controls && controls.cancelCards.length && !controls.hasPassed;
|
||||
});
|
||||
assert.ok(passer, 'The No-Field response keeps a legal passer.');
|
||||
arrived = send(arrived, passer!.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.ok((arrived.pendingArrivalReaction?.length ?? 0) > 0 ||
|
||||
arrived.pendingTerrorEntry !== null || arrived.decision?.kind === 'moritaniTerror',
|
||||
'The committed Terror entry is offered or waits behind the arrival reaction.');
|
||||
conserve(arrived);
|
||||
const automated = structuredClone(g);
|
||||
automated.players[1].bot = 'Medium';
|
||||
const declined = runBots(automated, 1);
|
||||
|
||||
@@ -333,18 +333,20 @@ void test('simultaneous entry reactions fail atomically and stale CHOAM continua
|
||||
'arrakeen',
|
||||
1,
|
||||
);
|
||||
reject(
|
||||
g,
|
||||
'r',
|
||||
{
|
||||
type: 'ship',
|
||||
noField: token(g, 5),
|
||||
event: g.players[0].noFieldEvent,
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
},
|
||||
/Terror combined/,
|
||||
);
|
||||
const committed = applyAction(g, 'r', {
|
||||
type: 'ship',
|
||||
noField: token(g, 5),
|
||||
event: g.players[0].noFieldEvent,
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
});
|
||||
// The committed shipment and its Terror entry both survive; the entry waits
|
||||
// for the arrival's own reaction instead of rejecting the shipment.
|
||||
assert.equal(committed.players[0].noField!.deployed!.tokenId, token(g, 5));
|
||||
assert.equal(committed.players[0].shipped, true);
|
||||
assert.ok((committed.pendingArrivalReaction?.length ?? 0) > 0 ||
|
||||
committed.pendingTerrorEntry !== null || committed.decision?.kind === 'moritaniTerror',
|
||||
'The committed Terror entry is offered or waits behind the arrival reaction.');
|
||||
let moving = marker(fixture(), 3, 'imperial_basin', 10);
|
||||
moving.players[1] = newPlayer('a', 'CHOAM', 'choam');
|
||||
moving.players[1].forces = { 'arrakeen:10': 1 };
|
||||
|
||||
@@ -62,8 +62,16 @@ void test('all fourteen skills admit genuine Basic Tleilaxu setup while public,
|
||||
assert.equal(basicTleilaxuLeaderSkillsProfile(viewGame(game, 'a')), true);
|
||||
stable(game);
|
||||
}
|
||||
// Native E1 Discovery Leader Skills are now admitted for this fresh lobby;
|
||||
// unsupported Discovery overlays still reject.
|
||||
const discovery = createGame('TLEIGATE',newPlayer('t','Tleilaxu','tleilaxu'),false,['ix']);
|
||||
joinGame(discovery,newPlayer('e','Emperor','emperor'));discovery.players.forEach(p => {p.ready = true;});
|
||||
discovery.discoveryEnabled = true;
|
||||
assert.equal(basicTleilaxuLeaderSkillsProfile(initializeLeaderSkillsGameForAudit(discovery)), true);
|
||||
for (const mutate of [(g: Game) => {g.players[1] = newPlayer('x','Richese','richese');},
|
||||
(g: Game) => {g.discoveryEnabled = true;}, (g: Game) => {g.expansions.push('choam');}]) {
|
||||
(g: Game) => {g.expansions.push('choam');},
|
||||
(g: Game) => {g.discoveryEnabled = true; g.players[1] = newPlayer('x','Richese','richese');},
|
||||
(g: Game) => {g.discoveryEnabled = true; g.expansions.push('choam');}]) {
|
||||
const g = createGame('TLEIGATE',newPlayer('t','Tleilaxu','tleilaxu'),false,['ix']);
|
||||
joinGame(g,newPlayer('e','Emperor','emperor'));g.players.forEach(p => {p.ready = true;});mutate(g);
|
||||
const before = JSON.stringify(g);assert.throws(() => initializeLeaderSkillsGameForAudit(g));assert.equal(JSON.stringify(g),before);
|
||||
|
||||
Reference in new issue
Block a user