Compose selected-deck Tech and Stronghold modules

This commit is contained in:
Quintine committed 2026-10-26 11:07:56 +11:00
1 parent 82370d906f
commit 68a7870e2e
21 files changed
+1168 -50

No files matched your search

+31
View File
@@ -16,6 +16,37 @@ open mode gates.
## Current checkpoint and work
**Selected-deck Tech / Stronghold composition — 26 October. Prototyped / Partial.**
Fresh normal starts now compose Tech3–6 Basic/Advanced and Strongholds2–6
Advanced across paired E3, mixed/all-three families and classic-only selected
decks. Mandatory Ecaz Occupy and Advanced Moritani Assassinate continue through
their original handlers, including native captured-leader targets. Explicit
Semuta retains its earlier narrower envelope; unrelated profiles stay separate.
Disjoint profile, native target and setup/control lanes plus the bounded Ecaz
supplement pass **14 files,120/120**, types/lint/build and **ten runtime paths**,
26 GameTable SSR renders on unchanged source. Six paths are native: all six
expansion factions publicly start all3 decks, ship actual CHOAM1/city1 and
Moritani1/desert2, accrue/pay Heighliners once, earn first Mentat cards in
Advanced, lose a counter to Basic dials2/Advanced card1 and return it freely
with separate Tleilaxu bank1 and actual Axlotl phase-end1 (248/264 actions).
Four actual Harkonnen/Moritani capture/assassination sources use48–49 actions,
then original winner Tech and one actual Mentat replacement. Inventories remain
57 and33 respectively. All four minimal policies submit legal next actions.
Four labelled controlled Ecaz/Ixian coalition paths supplement that native
evidence: actual public setup/Nexus/alliance/earned Tabr, conserved armies and
Shield, real selected lead, fixed Ecaz and one Suboid loss, holder-only income
and winner Tech after cleanup. No natural invasion/acquisition claim is made.
Feedback corrects Basic Storm/closing-window assumptions, decorative Start
markup, separately admitted overlays, old battle acknowledgements and native
foreign-Ghola turn/phase completion; one fixture type narrowing is repaired.
Frozen report/manifest: `/home/quintine/.local/share/dune/checkpoints/20261026-selected-phase-modules-proof-v3/`;
journeys/HTML: adjacent `20261026-selected-phase-modules-runtime/`.
No saved-room/D1/server, reset/restart, deployment or assurance operation occurs.
SSR and engine proof leave browser/mobile/deployed, wider interactions and full
mode/publication acceptance open. [Current source and scope](STRONGHOLD_CARDS.md#selected-deck-phase-modules--26-october-2026).
**Sapho in ordered Advanced Revival — 26 October. Prototyped / Partial.**
First/last changes unfinished opportunities, preserving physical First Player
order and completed participants. A started current player finishes first under
+7
View File
@@ -116,6 +116,13 @@ rejected.
## Native Tech composition — 4 October 2026
**Current scope:** the [26 October selected-deck phase contract](STRONGHOLD_CARDS.md#selected-deck-phase-modules--26-october-2026)
supersedes the dated plain phase-only roster exclusions below. Normal lobby
starts admit paired/mixed/all-three selected families and classic-only selected
decks, Tech3–6 Basic/Advanced and Strongholds2–6 Advanced. Mandatory native
Ecaz/Moritani handlers compose; explicit Semuta and other optional profiles keep
their own envelopes. Complete-mode and deployed acceptance remain open.
Enable original Tech Tokens in a fresh ready lobby before using the existing
`factions` entry. This bounded composition needs three through six unique seats,
at least one native selected E1/E2 faction with distinct required `ix`/`choam`
+30
View File
@@ -1,5 +1,35 @@
# Dune implementation status
## 26 October 2026 — Selected-deck Tech / Stronghold composition
**Prototyped / Partial.** Fresh clean normal starts
compose original Tech3–6 Basic/Advanced and Strongholds2–6 Advanced across
registered paired/mixed/all-three selected families and classic-only unused
decks. Mandatory Ecaz Occupy and Advanced Moritani Assassinate retain their
native handlers. The existing captured-leader target contract supersedes the
old plain-phase Harkonnen/Moritani fence; explicit Semuta retains its narrower
legacy admission. No played-game retrofit or full-mode gate change.
Root integration passes **14 files,120/120**, types/lint/build and **ten runtime
paths**,26 GameTable SSR renders on unchanged source. Native all-six-faction
Basic/Advanced programmes use248/264 source actions: CHOAM city1/Moritani desert2,
deferred Heighliners1, actual Advanced Mentat claims, Basic dials2/Advanced card1
casualty, free return and separate Tleilaxu bank1/Axlotl end-phase1. Four native
Harkonnen capture/assassination sources use48–49 actions plus actual replacement
and Tech cleanup. Each retains its original57/33-card inventory respectively;
all four minimal policies submit legal next actions. Four explicitly conserved
Ecaz/Ixian coalition supplements retain actual setup/alliance/claims and then
prove typed losses, holder-only income and actual selected-winner Tech.
Feedback repairs Basic source/closing-window assumptions, decorative Start
markup and overlay scope, old battle acknowledgements, ordered foreign-Ghola
turn/phase completion and one fixture type narrowing. Frozen report/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-selected-phase-modules-proof-v3/`;
journeys/HTML: adjacent `20261026-selected-phase-modules-runtime/`. No saved-game,
server, reset/restart, deployment or assurance operation. Broader interactions,
browser/mobile/deployed and complete-mode acceptance remain open.
[Current source and scope](STRONGHOLD_CARDS.md#selected-deck-phase-modules--26-october-2026).
## 26 October 2026 — Sapho first/last in ordered Advanced Revival
**Prototyped / Partial.** Apply printed first/last to unfinished Advanced Revival
+1
View File
@@ -1479,6 +1479,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Classic independent Treachery / Nexus | Printed independent-variant authority composes fresh classic2–6 Basic/Advanced independent36/Nexus12, optionally all14, through one public configuration predicate and original setup. Recruits changes ordinary rates/total7, not fixed Tleilaxu group price/free classification. Cunning keeps temporary ordinary counters, separate Reinforcements reserve fees, actual Harass returns and original cancellation. No new numeric rule or played-save conversion. Native selected families, World/phase/Discovery/Semuta, reaction-preview and other effect-specific bounds remain separate; inherited provisional timings are not promoted to publisher rulings. | [Source and exact native proof](NEXUS_CARD_RULES.md#classic-independent-treachery-composition--18-october-2026) |
| Classic independent native Nexus reactions | Fresh independent36/Nexus12 optional14 initializes the existing Ixian purchase and actual Harkonnen declared-Traitor effect stamps; played games remain unchanged. Public selection binds canonical faces and the original census, with a new independent marker only where selected. Under standing choose-recommended authority, inherit the existing bounded native-counter-first/neutral acknowledgement and replacement-before-Harkonnen-bonus conventions, visibly provisional, not new publisher or universal rulings. Personal/allied source, real Mentat replacement and original paid/card/force mechanics remain. Other native/module/reaction envelopes stay separate. | [Current source and actual controls](NEXUS_CARD_RULES.md#classic-selected-card-reactions--18-october-2026) |
| Classic Nexus core / funded card shipments | Original Nexus independence admits fresh classic2–6 Basic/Advanced base33 or selected independent36, optionally all14, without a three-card dependency. Initialize actual Guild/Harkonnen/Ixian reaction states only at fresh setup. Original signed Cunning reserve/cross/return and Richese one-counter grants retain native counters, funding and physical continuation through the same Guild fee gate. One Guild singleton cannot simultaneously fund Cunning and rival Use; actual wrong-face Pass remains neutral. Actual third-party Bureaucrat sees the real recipient and printed payer exclusions. No new numerical/universal ruling, played reflag or wider module opening; inherited timing stays provisional. | [Current source and exact proof](NEXUS_CARD_RULES.md#classic-nexus-core-and-funded-card-shipments--18-october-2026) |
| Selected-deck original phase modules | Supplied Advanced physical21/printed19 permits variants together/separately irrespective of factions. Fresh clean selected-deck rosters admit paired/mixed/all-three families and classic-only unused decks: Tech3–6 Basic/Advanced, Strongholds2–6 Advanced. Each seated native needs its family. Mandatory Ecaz Occupy and Advanced Moritani Assassinate retain existing original handlers, including the 24 October native captured-leader contract; the old plain-phase Harkonnen/Moritani exclusion is superseded. First-Storm assignment, delayed owner-count payout, actual end-Mentat claims and selected-winner reward remain. Explicit Semuta keeps its earlier envelope, and unrelated module/preview profiles remain separate. No new numeric ruling, old-save retrofit or complete-mode acceptance. | [Current scope and source](STRONGHOLD_CARDS.md#selected-deck-phase-modules--26-october-2026), [native target contract](MORITANI_ASSASSINATE_LEADERS.md) |
| Classic Nexus core / original phase modules | Fresh classic base33 or selected36/Nexus12, optionally14, composes original Tech3–6 Basic/Advanced and Strongholds2–6 Advanced through the public core capability. Real first-Storm tokens, owner-count pending income, actual end-Mentat held cards and original matching payout remain. Fixed paid force groups do not become free under Recruits; free requests accrue once, pending income cannot fund earlier costs, and fees/Harass are not Revival/shipment. Earned local bank support does not fund reserve fees; physical effective Carthag defense, native counters/cleanup/winner token/private replacement keep their original order. No new numerical or universal policy, played reflag or wider family/World/Discovery opening. | [Source, controls and owner-frozen439/439](NEXUS_CARD_RULES.md#classic-nexus-original-phase-modules--18-october-2026) |
| Original World Nexus core | Fresh base33 classic Homeworld/Nexus composes optional all14 and original Tech3+/Advanced Strongholds2+ through the public core capability; independent36 World/Nexus stays a separate pending own-World fee/conversion gate with explicit public messaging. Pending parents bind only present World custody; signed Cunning World frames and genuine discounted Smuggler invoices supply the funded Guild fee while standalone envelopes keep narrower sources. Typed visitor/native pools, printed tariffs, native permission/rate order, singleton spending, temporary-role support and ordinary-only casualties, native Traitor callers and original cleanup/replacement remain. No new numeric policy, played reflag or wider envelope opening. | [Owner-frozen448/448, scenes and limits](NEXUS_CARD_RULES.md#original-world-nexus-core--18-october-2026) |
| Fresh native Ecaz/Moritani defaults | Fresh actual Eca/Mori roles reuse registered selected-deck all14 capability, including mixed families and original World/phase/cards without Nexus. Printed Eca participation and Advanced Mori assassination initialize only for actual seats; Basic gains no Advanced power and unused decks create no faction seat. Bare rosters and original Nexus/Discovery/Semuta retain their envelopes; Advanced role/shared Duke guards stay. No played-save forfeiture/opportunity history is fabricated. | [Current registered source envelope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026), [Mori authority](MORITANI_ASSASSINATE_LEADERS.md#connected-scope-and-private-information) |
+65
View File
@@ -199,6 +199,71 @@ All original responses, market finish and phase-opening readiness remain.
Read the [combined scope and exact outcomes](LEADER_SKILLS_RUNTIME.md#advanced-stronghold-composition--4-october-2026).
This is bounded Development Verified, not complete-module/public/deployed proof.
## Selected-deck phase modules — 26 October 2026
Fresh normal lobby starts now compose original Tech Tokens with any registered
two-to-six-seat selected-deck roster, including paired Ecaz/Moritani, mixed
E1/E2/E3, all six expansion natives and classic-only rosters with unused selected
decks. Tech independently requires three seats. Stronghold Cards independently
require Advanced; they may be selected alone at two seats or beside Tech at
three through six. Every native faction still needs its selected family. No
duplicate/unknown families or seats are admitted, and the base no-expansion
route remains separate.
The authorized Advanced PDF physical21/printed19 permits these variants
together or separately irrespective of factions. This extends admission rather
than changing their original printed calculations: real first-Storm owners,
one pending owner-count pile per matching phase, actual phase-end payout,
winner Tech after cleanup and actual end-Mentat Stronghold claims. No held
Stronghold is assigned at setup or automatically shared with an allied plan.
Basic keeps the original Tech mechanics; this does not import Advanced combat
or Stronghold Cards into Basic.
Mandatory Basic/Advanced Ecaz Occupy and Advanced Moritani Assassinate use the
same admitted phase profile. Harkonnen/Moritani no longer inherits the old
standalone phase exclusion: the 24 October native Assassinate target contract
already handles captured/concealed leaders and original custodians. This is
distinct from random Terror Assassination. Existing provisional Occupy odd-force
rounding and skill/capture interpretations retain their own contracts.
This clean phase-only profile excludes Skills, Worlds, Nexus, Discovery,
independent Ecaz cards and other preview selectors. Their separately registered
profiles remain. Explicit Semuta validation retains its previous E1/E2 or
exactly-one-E3-native Tech envelope and older Stronghold envelope; it does not
inherit new paired/mixed/classic-only permission. Played games receive no
module retrofit. The dated 4 October scopes below are historical; this section
supersedes their plain phase-only roster exclusions.
The broadened profile remains Prototyped / Partial. Frozen root proof passes
14 files,120/120, types/lint/build and ten runtime paths with26 GameTable SSR
renders. Two all-six-native public programmes use248 Basic /264 Advanced source
actions and57 physical Treachery identities: actual city/desert shipments,
one Heighliners pile/payout, earned Advanced first-Mentat cards, later native
Storm loss and free return, separate Tleilaxu bank1/Axlotl delayed1. Four actual
Harkonnen capture/assassination paths use48–49 actions and33 identities, actual
winner-token transfer and one native private replacement. Four minimal policies
submit legal next actions from each six-native phase-five boundary.
Four controlled Ecaz/Ixian supplements use real normal all3-deck start, worm
Nexus, reciprocal alliance and earned Tabr. Conserved physical armies/Shield
then expose both selected leads, original fixed Ecaz and one Suboid casualty,
holder-only Advanced Tabr1 and selected-winner Tech after actual card cleanup.
These positions and the Shield acquisition are explicitly staged; they are
not natural battle histories or full combination campaigns. The new setup
controls use SSR plus public actions, without a DOM click claim.
Feedback corrects Basic later dials1+1 versus Advanced card1, actual closing
windows, nested decorative Begin markup and independent-overlay admission.
Old Semuta tests now acknowledge actual battle-outcome windows, while the
foreign-Ghola source finishes preceding/current Revival turns and reaches
Battle through native phase completion instead of assigning phase/order.
One callback-state fixture narrowing is corrected. Report/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-selected-phase-modules-proof-v3/`;
journeys/HTML: adjacent `20261026-selected-phase-modules-runtime/`.
No saved-game/server/reset/restart/deployment or assurance operation occurs.
Complete-mode, wider interactions, browser/mobile, deployed and publication
acceptance remain open.
## Standalone E3 Stronghold composition — 4 October 2026
Fresh `stronghold-factions` additionally admits Advanced native Ecaz OR native
+11 -11
View File
@@ -16,7 +16,7 @@ import { movementShipmentPreparations } from './movement-shipment-preparation';
import { MovementClaimError, liveMovementPromises, movementEventAllowed, movementPromiseModeSupported, movementPromiseTargetAvailable, settleMovementEvent, finishMovementPromises, validMovementClaim, type MovementClaim, type MovementPromise } from './movement-promises';
import { findMovementCompletion, type MovementCompletion } from './movement-promise-search';
import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
import { independentDiscoveryProfile, independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile } from './faction-module-profile';
import { independentDiscoveryProfile, independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile, selectedFactionStrongholdProfile } from './faction-module-profile';
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile';
import { advancedMoritaniLeaderSkillsProfile, selectedDeckLeaderSkillsProfile, homeworldLeaderSkillsProfile, independentTreacheryLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeEcazLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3LeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
@@ -9836,7 +9836,7 @@ function moritaniAssassinateModeSupported(g: Game) {
(f.expansion === 'base' || g.expansions.includes(f.expansion)))) &&
(!g.nexusCards || (!!g.leaderSkills && standaloneE3NexusLeaderSkillsProfile(g))) &&
(!g.leaderSkills || advancedMoritaniLeaderSkillsProfile(g)) &&
(!g.strongholdCards || e3StrongholdFactionProfile(g) || independentCardModulesProfile(g) ||
(!g.strongholdCards || selectedFactionStrongholdProfile(g) || e3StrongholdFactionProfile(g) || independentCardModulesProfile(g) ||
(!!g.leaderSkills && strongholdLeaderSkillsProfile(g))) &&
(!g.homeworlds || g.homeworldOccupationPreview === true || ecazHomeworldProfile(g) ||
(!!g.leaderSkills && homeworldLeaderSkillsProfile(g))) &&
@@ -11841,7 +11841,7 @@ function initializeFactionExpansionsGame(state: Game, semuta = false): Game {
requireRule(!state.homeworlds && !state.nexusCards && !state.leaderSkills &&
!state.discoveryEnabled && !state.discoveries && !state.discoveryStash && !state.greatMaker &&
(!state.techTokens || nativeFactionTechProfile(state)) && !state.strongholdCards,
'The faction prototype excludes other optional modules; Tech Tokens require a supported native three-through-six-seat lobby.');
'The faction prototype excludes other optional modules; Tech Tokens require a supported selected-deck three-through-six-seat lobby.');
requireFreshFactionInventory(state);
const initialized = initializeSetupGameForAudit(state, false, false, false, false, false, false, true, false, false, semuta);
if (byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
@@ -11849,11 +11849,11 @@ function initializeFactionExpansionsGame(state: Game, semuta = false): Game {
return initialized;
}
function ecazOccupyCompositionSupported(g: Game): boolean {
// Bare native Basic uses the same physical coalition and selected decks.
// Native Basic uses the same physical coalition and selected decks.
// Family/roster checks below remain authoritative; optional configurations
// retain their separate registered profiles rather than inheriting admission.
const basicNatives = g.advanced === false &&
!g.ecazTreachery && !g.techTokens && !g.strongholdCards;
!g.ecazTreachery && (!g.techTokens || nativeFactionTechProfile(g)) && !g.strongholdCards;
if ((independentNexusProfile(g) || independentDiscoveryProfile(g)) && byFaction(g, 'ecaz')) return true;
if (g.leaderSkills && nativeEcazLeaderSkillsProfile(g)) return true;
if (g.ecazOccupyPreview === true && (mixedE3NexusProfile(g) || standaloneE3NexusProfile(g) || pairedE3NexusProfile(g)) &&
@@ -11888,7 +11888,7 @@ function ecazOccupyCompositionSupported(g: Game): boolean {
(['ecaz', 'moritani', 'ixians', 'tleilaxu', 'choam', 'richese'].includes(p.faction) &&
g.expansions.includes(faction(p.faction).expansion))) &&
!g.homeworlds && !g.nexusCards && !g.leaderSkills &&
(!g.strongholdCards || e3StrongholdFactionProfile(g) || independentCardModulesProfile(g)) &&
(!g.strongholdCards || selectedFactionStrongholdProfile(g) || e3StrongholdFactionProfile(g) || independentCardModulesProfile(g)) &&
(!g.techTokens || nativeFactionTechProfile(g) || independentCardModulesProfile(g)) &&
!g.discoveryEnabled && !g.discoveries &&
!g.discoveryStash && !g.greatMaker &&
@@ -11897,7 +11897,7 @@ function ecazOccupyCompositionSupported(g: Game): boolean {
/** Fresh native Occupy profile; Basic odd counts use the main printed rule provisionally. */
export function initializeEcazOccupyGameForAudit(state: Game): Game {
requireRule(ecazOccupyCompositionSupported(state),
'Occupy requires supported native factions and their selected decks; bare Basic native families exclude optional modules.');
'Occupy requires supported native factions and their selected decks; Basic phase modules admit Tech Tokens only.');
requireFreshBaseRuntime(state);
requireFreshFactionInventory(state);
const g = initializeSetupGameForAudit(state, false, false, false, false, false, false, true, state.ecazTreachery === true);
@@ -11917,8 +11917,8 @@ function nativeStrongholdFactionRoster(g: Game): boolean {
}
/** Shared fresh native Stronghold setup; other overlays retain their own admission. */
export function initializeStrongholdFactionsGameForAudit(state: Game): Game {
requireRule(e3StrongholdFactionProfile(state) || nativeStrongholdFactionRoster(state),
'Stronghold factions require supported Advanced native factions, classic opponents and their selected distinct decks.');
requireRule(selectedFactionStrongholdProfile(state) || e3StrongholdFactionProfile(state) || nativeStrongholdFactionRoster(state),
'Stronghold factions require supported Advanced factions and their selected distinct decks.');
requireRule(!state.homeworlds && !state.nexusCards && !state.leaderSkills &&
!state.discoveryEnabled && !state.discoveries && !state.discoveryStash && !state.greatMaker &&
(!state.techTokens || nativeFactionTechProfile(state)) && !state.ecazTreachery &&
@@ -11927,8 +11927,8 @@ export function initializeStrongholdFactionsGameForAudit(state: Game): Game {
requireFreshFactionInventory(state);
requireFreshBaseRuntime(state);
const initialized = initializeSetupGameForAudit(state, false, false, false, false, false, false, true, false, true);
if (e3StrongholdFactionProfile(initialized) && byFaction(initialized, 'moritani'))
initializeMoritaniAssassinateState(initialized);
if (byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
if (moritaniAssassinateModeSupported(initialized)) initializeMoritaniAssassinateState(initialized);
return initialized;
}
function requireFreshFactionInventory(state: Game) {
+59 -13
View File
@@ -24,6 +24,9 @@ export type FactionModuleProfile = {
nexusIxianReplacementPreview?: unknown;
nexusIxianBetrayalPreview?: unknown;
nexusHarkonnenBetrayalPreview?: unknown;
/** Native runtime stamps, not independent optional-module selections. */
ecazOccupyPreview?: unknown;
moritaniAssassinatePreview?: unknown;
};
type NexusCoreConfiguration = Partial<FactionModuleProfile> & Pick<FactionModuleProfile, 'expansions'> & {
@@ -105,19 +108,46 @@ export function independentNexusProfile(game: NexusCoreConfiguration): boolean {
return game.ecazTreachery === true && nexusCoreProfile(game);
}
/** Original E1/E2 families or standalone Ecaz/Moritani; explicit Semuta context only. */
export function nativeFactionTechProfile(game: FactionModuleProfile, semuta = false): boolean {
if (typeof game.advanced !== 'boolean' || !game.techTokens ||
game.players.length < 3 || game.players.length > 6 ||
game.expansions.length < 1 || game.expansions.length > 2 ||
(game.strongholdCards && !game.advanced) || game.leaderSkills ||
game.homeworlds || game.nexusCards || game.discoveryEnabled || game.discoveries ||
game.discoveryStash || game.greatMaker || game.ecazTreachery ||
(game.semutaPreview && (!semuta || game.semutaPreview !== true)) ||
game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
game.guildBetrayalPreview || game.richeseBetrayalPreview ||
game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview ||
game.nexusHarkonnenBetrayalPreview) return false;
/** Selected decks do not require their native factions to be seated. This is
* roster validity only; each phase module retains its own mode and seat limits. */
export function selectedPhaseModuleRoster(
game: Pick<FactionModuleProfile, 'expansions' | 'players'>,
): boolean {
if (!game || typeof game !== 'object' || !Array.isArray(game.expansions) ||
game.expansions.length < 1 || game.expansions.length > 3 ||
!game.expansions.every(id => id === 'ix' || id === 'choam' || id === 'ecaz') ||
new Set(game.expansions).size !== game.expansions.length ||
!Array.isArray(game.players) || game.players.length < 2 || game.players.length > 6) return false;
const seated = new Set<string>();
for (const player of game.players) {
if (!player || typeof player !== 'object' || Array.isArray(player) ||
typeof player.faction !== 'string' || seated.has(player.faction)) return false;
const faction = FACTIONS.find(faction => faction.id === player.faction);
if (!faction || (faction.expansion !== 'base' && !game.expansions.includes(faction.expansion))) return false;
seated.add(player.faction);
}
return true;
}
/** Keep unrelated overlays outside this phase-only permission. Semuta's caller
* additionally retains its old roster envelope below, rather than inheriting
* new selected-family admission through its explicit context flag. */
function phaseModuleContext(game: FactionModuleProfile, semuta = false): boolean {
return typeof game.advanced === 'boolean' &&
!(game.strongholdCards && !game.advanced) && !game.leaderSkills &&
!game.homeworlds && !game.nexusCards && !game.discoveryEnabled && !game.discoveries &&
!game.discoveryStash && !game.greatMaker && !game.ecazTreachery &&
(!game.semutaPreview || (semuta && game.semutaPreview === true)) &&
!game.advancedPreview && !game.kullPreview && !game.nexusKullPreview &&
!game.guildBetrayalPreview && !game.richeseBetrayalPreview &&
!game.nexusIxianReplacementPreview && !game.nexusIxianBetrayalPreview &&
!game.nexusHarkonnenBetrayalPreview;
}
/** Exact earlier E1/E2 or one-E3-native scope for existing Semuta consumers. */
function legacySemutaTechRoster(game: FactionModuleProfile): boolean {
if (!Array.isArray(game.players) || game.players.length < 3 || game.players.length > 6 ||
!Array.isArray(game.expansions) || game.expansions.length < 1 || game.expansions.length > 2) return false;
const e3 = game.expansions.length === 1 && game.expansions[0] === 'ecaz';
if (!e3) {
for (let i = 0; i < game.expansions.length; i++) {
@@ -130,6 +160,7 @@ export function nativeFactionTechProfile(game: FactionModuleProfile, semuta = fa
let harkonnen = false;
for (let i = 0; i < game.players.length; i++) {
const player = game.players[i];
if (!player || typeof player !== 'object') return false;
for (let j = 0; j < i; j++) if (player.faction === game.players[j].faction) return false;
let expansion: string | undefined;
for (const faction of FACTIONS)
@@ -147,6 +178,21 @@ export function nativeFactionTechProfile(game: FactionModuleProfile, semuta = fa
!(game.advanced && moritani && harkonnen);
}
/** Tech-only Basic/Advanced or original Advanced Strongholds at three-six seats.
* Explicit Semuta validation keeps its earlier family/native/Harkonnen scope. */
export function nativeFactionTechProfile(game: FactionModuleProfile, semuta = false): boolean {
return !!game && phaseModuleContext(game, semuta) && !!game.techTokens &&
(semuta ? legacySemutaTechRoster(game) :
selectedPhaseModuleRoster(game) && game.players.length >= 3);
}
/** Clean selected-deck Strongholds. The original initializer may create their
* unused state when absent; physical freshness and native stamps stay external. */
export function selectedFactionStrongholdProfile(game: FactionModuleProfile): boolean {
return !!game && game.advanced === true && phaseModuleContext(game) &&
selectedPhaseModuleRoster(game) && (!game.techTokens || game.players.length >= 3);
}
export type MixedE3NativeRoster = {
advanced?: boolean;
expansions: readonly string[];
+8 -8
View File
@@ -3963,10 +3963,10 @@ export const RULE_TOPICS: RuleTopic[] = [
title: 'Expansion factions with Stronghold Cards',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Verified',
summary: 'Fresh Advanced Stronghold play connects native E1/E2 families and standalone Ecaz or Moritani through original holder-only rule handlers.',
developmentStage: 'Prototyped',
summary: 'Fresh Advanced selected-deck Stronghold play connects registered native or classic rosters through original holder-only rule handlers.',
steps: [
'This private local profile admits selected native Ixian/Tleilaxu and CHOAM/Richese families with classic opponents in unique two-to-six-seat Advanced rosters. Standalone Ecaz OR Moritani with classic opponents additionally uses the exact ecaz deck. Moritani excludes Harkonnen under its original assassination/Duke boundary. Original unused Tech Tokens may additionally be selected with three through six seats, without Leader Skills; an E3 pair, E1/E2/E3 mixtures, Basic Strongholds and unrelated overlays remain separate.',
'Fresh Advanced normal starts admit unique two-to-six-seat selected-deck rosters: paired Ecaz/Moritani, mixed E1/E2/E3, all six natives or classic-only seats with unused selected decks. Each native needs its family selected. Original unused Tech optionally requires three through six seats. Mandatory Occupy and Advanced Assassinate compose, including the existing captured-leader Harkonnen/Moritani contract. Basic Strongholds and unrelated overlays keep separate profiles; explicit Semuta retains its earlier narrower envelope.',
'CHOAM-only ordinary Treachery has 35 physical cards; Ix or both decks have47, not49, because CHOAM replaces the shared Ix cards. Native Richese’s ten-card cache remains separate. Genuine setup retains original faction offers/leaders/forces and any native Auditor/HMS, with all six Stronghold Cards initially unowned.',
'Card ownership begins only at the real first end of Mentat and is retained through the following turn. It is not assigned by a battle fixture or reset on refresh. Hidden Mobile Stronghold interior forces and its outside pointing territory stay distinct.',
'The mobile card copies an advantage of another currently controlled stronghold, not merely a retained card. A sole choice is automatic; multiple choices use the existing public pre-plan selection. Accepted choices stay bound to the same original battle through private plans and saved continuation.',
@@ -3978,11 +3978,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['stronghold-cards', 'mobile-stronghold', 'choam-combat', 'setup', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Implemented', detail: 'Fresh selected E1/E2 setup and standalone Ecaz/Moritani exact ecaz deck, actual end-Mentat ownership, holder-only Ecaz coalition funding and original Moritani loss/assassination compose existing effects. Optional original Tech requires three through six seats and awards the actual selected lead before Face Dance, without public-start or old-game changes.', evidence: ['game/engine.ts', 'game/faction-module-profile.ts', 'game/stronghold-cards.ts', 'game/battle-resolution-quote.ts'] },
{ area: 'Implementation', status: 'Implemented', detail: 'Fresh registered selected-deck rosters, including paired/mixed E3 and classic-only unused decks, use original setup and actual end-Mentat ownership. Mandatory Ecaz coalition and Moritani Assassinate retain their existing handlers. Optional Tech3–6 awards the actual selected lead before Face Dance. Clean phase admission does not widen explicit Semuta or retrofit played games.', evidence: ['game/engine.ts', 'game/faction-module-profile.ts', 'game/stronghold-cards.ts', 'game/battle-resolution-quote.ts'] },
{ area: 'Player controls', status: 'Implemented', detail: 'Original local390px controls cover HMS copy/support, real cache Stone reveal mode and partial Face Dance replacement. Standalone E3 human controls select the Ecaz holder lead, show bank2/personal1 for actual support3, seal native plans and keep the physical Shield; Moritani reveals printed Master Bewt bounty through its original inspector and choice. Original Collection and Mentat continue after refresh.', evidence: ['components/stronghold-cards.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal legal policies consume actual marker/Stone/FaceDance/copy choices; eighteen genuine paired/mixed Advanced2..6 games complete. Strategy/calibration and other combinations remain deferred.', evidence: ['game/bots.ts', 'tools/faction-games.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Advanced family/deck/cache admission, existing six effects, original winner versus replacement and explicit guarded combinations are recorded without completing every card or faction.', evidence: ['docs/STRONGHOLD_CARDS.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Prior E1/E2 evidence remains dated. Standalone E3 adds20 focused cases, affected native/CLI179, types/lint/build and actual controlled holder-only funding, tie/income, assassination/death/forfeiture and four legal policies. Four genuine two/three-seat E3 games complete642 accepted actions,15 JSON continuations and3 battles without rejection; no natural assassination claim. Human390px Ecaz reaches v18/Mentat with bank2/personal1, separate Collection2 and physical Ecaz2/Guild1 survivors; Moritani reaches v11/Mentat with wallet18 and one original private replacement. No new assurance/full-suite campaign or public/deployed certification.', evidence: ['tests/ecaz-stronghold-runtime.test.ts', 'tests/moritani-stronghold-runtime.test.ts', 'tests/richese-strongholds-runtime.test.ts', 'tests/tleilaxu-strongholds-runtime.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'New selected-deck cases cover normal mixed/classic setup, actual six-native income and claims, native Harkonnen/Moritani assassination and labelled controlled Ecaz/Ixian coalition battles. Full modes remain unfinished. Prior E1/E2 evidence remains dated. Standalone E3 adds20 focused cases, affected native/CLI179, types/lint/build and actual controlled holder-only funding, tie/income, assassination/death/forfeiture and four legal policies. Four genuine two/three-seat E3 games complete642 accepted actions,15 JSON continuations and3 battles without rejection; no natural assassination claim. Human390px Ecaz reaches v18/Mentat with bank2/personal1, separate Collection2 and physical Ecaz2/Guild1 survivors; Moritani reaches v11/Mentat with wallet18 and one original private replacement. No new assurance/full-suite campaign or public/deployed certification.', evidence: ['tests/phase-module-profile.test.ts', 'tests/mixed-phase-modules-runtime.test.ts', 'tests/mixed-phase-modules-controls.test.tsx', 'tests/phase-module-ecaz-runtime.test.ts', 'tests/phase-module-moritani-runtime.test.ts', 'tests/ecaz-stronghold-runtime.test.ts', 'tests/moritani-stronghold-runtime.test.ts', 'tests/richese-strongholds-runtime.test.ts', 'tests/tleilaxu-strongholds-runtime.test.ts'] },
],
},
{
@@ -4000,7 +4000,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A battle winner takes one tech token from the defeated faction after casualties and played-card decisions. With several available tokens, the winner chooses. A winner still takes the token if no winning forces survive. Mutual destruction has no winner and transfers no token.',
'Owning all three tokens counts as one additional stronghold at victory checks. An alliance cannot combine separate token holdings to qualify: one member must own the complete set. Multiple sides reaching the victory target can share the win.',
'Token ownership and pending income are public and persist. Fresh registered selected-family all14 keeps original Tech3–6 Basic/Advanced and optional Advanced Strongholds2–6, including supported Worlds and independent cards, without an incidental Nexus or single-family E3 requirement. Original Nexus/Discovery/Semuta and role/effect guards remain. Rescue/board-spice precedes winner Tech and Face Dance. Mandatory normal Banker waits for Mentat, separate from phase-end Tech; fees/H returns are not revival/shipment. Native CHOAM opening income is not Charity. Two-player Tech and full combined acceptance remain unfinished.',
'Without Skills, fresh native E1/E2 families or standalone Ecaz OR Moritani with classic opponents may preserve unused original Tech Tokens through the local factions entry, Basic/Advanced three through six players; Advanced stronghold-factions adds the original six Stronghold Cards. Native payments, typed casualties, selected Ecaz lead rewards and Moritani original assassination remain separate. Split allied holdings never qualify as a complete set. Basic odd Occupy, Richese cache/No-Field rulings, unrelated overlays, public starts and saved-game conversion remain guarded.',
'Without other optional overlays, fresh normal selected-deck starts retain original Tech3–6 Basic/Advanced with registered paired/mixed/all-three native families or classic-only seats and unused decks. Advanced Strongholds2–6 optionally compose. Native payments, typed casualties, selected Ecaz lead rewards and existing captured-leader Moritani assassination remain separate; split allied holdings never form a complete set. Explicit Semuta keeps its earlier scope. Existing provisional Basic odd Occupy, Richese rulings and unrelated profiles are not newly decided; played games are not converted.',
'Classic/E1/E2 and original standalone/paired E3 bare independent-card previews retain Worlds, Tech3–6 and optional Advanced Strongholds2–6. All14 extends this to registered mixed selected families. Recruits changes current native rates without repeating Axlotl or advancing payout. Harass deposits and reserve fees are not free Revival or off-planet shipment. Original phase income/native custody/winner rewards remain; bare mixed-E3 World/phase and unrelated module/role guards stay separate.',
'Classic Nexus core composes original Tech3–6 Basic/Advanced and optional Advanced Strongholds2–6 with base33 or selected independent36, optionally all14. Real first-Storm owners and actual token counts set income. A paid fixed Tleilaxu force group does not become free because Recruits is active; subsequent genuine free returns accrue one Axlotl pile, not one pile per request. Pending spice cannot fund Revival before phase end. Harass returns and reserve fees are not token-triggering Revival or shipment; actual battle winners take one original opposing token.',
],
@@ -4015,8 +4015,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'ix-modules',
],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Original first-Storm ownership, owner-count pending income, matching phase-end payout and native winner transfer remain. Classic Nexus core now admits original Tech with selected33/36 and optional14, preserving funded card shipment and fixed-versus-free Revival sources. Other module/native envelopes keep their bounds.', evidence: ['game/tech-tokens.ts', 'game/engine.ts', 'game/phase-resource-quote.ts', 'game/revival-resume.ts', 'game/faction-module-profile.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Actual normal five-module lobby reaches original training/Storm. Native fixed-five and ordinary-free controls show original prices, physical Tanks and pending pile separately; actual phase close pays it. Stronghold and card inspectors retain their own entitlements.', evidence: ['components/game-table.tsx', 'tests/fixture-nexus-core-tech.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Original first-Storm ownership, owner-count pending income, matching phase-end payout and native winner transfer remain. Clean selected-deck phase rosters now include paired/mixed/all-three families and classic-only unused decks through normal lobby starts. Classic Nexus core admits original Tech with selected33/36 and optional14, preserving funded card shipment and fixed-versus-free Revival sources. Other module/native envelopes keep their bounds.', evidence: ['game/tech-tokens.ts', 'game/engine.ts', 'game/phase-resource-quote.ts', 'game/revival-resume.ts', 'game/faction-module-profile.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Normal mixed selected-deck module controls and Start reach original setup/Storm; separate existing five-module lobby reaches original training/Storm. Native fixed-five and ordinary-free controls show original prices, physical Tanks and pending pile separately; actual phase close pays it. Stronghold and card inspectors retain their own entitlements.', evidence: ['components/game-table.tsx', 'tests/fixture-nexus-core-tech.ts'] },
{ area: 'AI', status: 'Partial', detail: 'Four existing own-view policies legally claim funded card shipment, complete real Revival into phase-end payout and finish earned Stronghold/Nexus battles. No strategic calibration or fixed-choice pin.', evidence: ['game/bots.ts', 'tests/fixture-nexus-core-tech.ts', 'tests/fixture-nexus-core-strongholds.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Printed trigger/exception, owner count, pending-versus-spendable spice, original payout, winner reward and complete-set victory remain distinct. Current classic core phase scope and inherited timing are explicit; full module/faction combinations and assurance remain open.', evidence: ['game/reference.ts', 'docs/NEXUS_CARD_RULES.md', 'docs/STRONGHOLD_CARDS.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Original-action consumers cover funded five-for-one delivery, paid fixed5 versus free2, a second genuine free request, pending-income affordability and Harass/winner transfer. Actual all14 Revival UI and four legal policies reach payout; older broader and saved evidence remains dated.', evidence: ['tests/nexus-core-tech-runtime.test.ts', 'tests/fixture-nexus-core-tech.ts', 'tests/tech-tokens.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts'] },
+11 -5
View File
@@ -295,11 +295,17 @@ for (const strongholds of [false, true])
for (const strongholds of [false, true]) {
const initialize = strongholds ? initializeStrongholdFactionsGameForAudit : initializeFactionExpansionsGameForAudit;
void test(`${strongholds ? 'Stronghold+Tech' : 'plain Tech'} initializer rejects paired E3 and mixed selected families without admitting unrelated overlays`, () => {
assert.throws(() => initialize(lobby(['ecaz', 'moritani', 'guild'])));
assert.throws(() => initialize(lobby(['ecaz', 'ixians', 'guild'], true, ['ecaz', 'ix'])));
assert.throws(() => initialize(lobby(['moritani', 'choam', 'guild'], true, ['ecaz', 'choam'])));
assert.throws(() => initialize(lobby(['moritani', 'harkonnen', 'guild'])));
void test(`${strongholds ? 'Stronghold+Tech' : 'plain Tech'} initializer admits selected mixed families while excluding unrelated overlays and used inventory`, () => {
for (const admitted of [lobby(['ecaz', 'moritani', 'guild']),
lobby(['ecaz', 'ixians', 'guild'], true, ['ecaz', 'ix']),
lobby(['moritani', 'choam', 'guild'], true, ['ecaz', 'choam']),
lobby(['moritani', 'harkonnen', 'guild'])]) {
const setup = initialize(admitted);
assert.equal(setup.status, 'setup');
assert.deepEqual(setup.expansions, admitted.expansions);
assert.equal(!!setup.strongholdCards, strongholds);
assert.equal(!!setup.moritaniAssassinate, admitted.players.some(p => p.faction === 'moritani'));
}
assert.throws(() => initialize(lobby(['ecaz', 'guild'])));
if (strongholds) assert.throws(() => initialize(lobby(['ecaz', 'guild', 'emperor'], false)));
for (const overlay of ['leaderSkills', 'homeworlds', 'nexusCards', 'discoveryEnabled', 'ecazTreachery',
+176
View File
@@ -0,0 +1,176 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { TERRITORIES, MOBILE_STRONGHOLD, location, territory } from '../game/board';
import type { FactionId } from '../game/catalog';
import { actAdvisorPreparation } from './fixture-advisor-preparation';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
export type MixedPhaseModulesStep = { actor: string; action: Action };
export type MixedPhaseModulesTransition = { before: Game; steps: MixedPhaseModulesStep[]; after: Game };
export type MixedPhaseModulesOptions = {
factions: readonly FactionId[];
expansions: Game['expansions'];
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
};
export const MIXED_PHASE_NATIVE_FACTIONS = ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'] as const;
export const actMixedPhaseModules = actAdvisorPreparation;
export const mixedPhaseModulesPlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const mixedPhaseModulesClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.pendingTreacheryDiscard && !game.pendingRevival && !game.truthtrance &&
!game.pendingShipment && !game.pendingKarama && !game.currentAuctionSale;
/** Current physical zones, excluding the ordinary auction's historical aliases. */
export function mixedPhaseModulesInventory(game: Game): string[] {
const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? []),
...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'Each original physical Treachery Card has one current slot');
return ids;
}
/** Native decisions only. Optional Terror placement is declined so the positive
* shipment source does not depend on a separately selected Terror effect. */
export function nextMixedPhaseModules(game: Game): MixedPhaseModulesStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard && game.decision) {
const d = game.decision;
if (d.kind === 'moritaniSetup') return { actor: d.player,
action: { type: 'decision', territory: 'carthag', sector: territory('carthag').sectors[0] } };
if (d.kind === 'moritaniPlacement' || d.kind === 'choamStorm' || d.kind === 'choamMovement' ||
d.kind === 'revivalStop') return { actor: d.player, action: { type: 'decision', decline: true } };
if (d.kind === 'guildTiming') return { actor: d.player, action: { type: 'decision', take: false } };
if (d.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
if (d.kind === 'richeseUnbid') return { actor: d.player,
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
}
return nextRicheseStrongholdsNativeStep(game);
}
export function advanceMixedPhaseModules(game: Game, until: (state: Game) => boolean,
actions: MixedPhaseModulesStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original phase-module source ended before its requested boundary');
const step = nextMixedPhaseModules(game);
assert.ok(step, `Original continuation required at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actMixedPhaseModules(game, step.actor, step.action, actions);
}
throw Error('The original phase-module source did not reach its finite boundary');
}
export function finishMixedPhaseModules(game: Game, actions: MixedPhaseModulesStep[] = []): Game {
const phase = game.phase, turn = game.turn;
return advanceMixedPhaseModules(game, state => (state.phase !== phase || state.turn !== turn) && mixedPhaseModulesClean(state), actions);
}
/** Public module selections, ready/start and every original setup decision.
* Only scalar lotteries and unopened Spice positions are selected. No played
* card, force, wallet, Tanks, phase, token or Stronghold owner is installed. */
export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions) {
const [host, ...others] = options.factions; assert.ok(host);
let game = createGame('MIXEDPHASEMODULES', newPlayer(host, host, host), options.advanced ?? false, options.expansions);
for (const faction of others) joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: MixedPhaseModulesStep[] = [];
const act = (actor: string, action: Action) => { game = actMixedPhaseModules(game, actor, action, actions); };
if (options.tech) act(host, { type: 'techTokens', enabled: true });
if (options.strongholds) act(host, { type: 'strongholdCards', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(host, options.advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game); assert.equal(game.status, 'setup');
assert.ok(game.players.every(player => !player.hand.length));
const originalInventory = mixedPhaseModulesInventory(game);
game = advanceMixedPhaseModules(game, state => state.status === 'playing', actions);
const afterSetup = structuredClone(game);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1, 2, 3].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position);
const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
return { position, from, card: structuredClone(card) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
game = advanceMixedPhaseModules(game, state => state.phase === 1 && mixedPhaseModulesClean(state), actions);
const firstStorm = structuredClone(game);
assert.deepEqual(mixedPhaseModulesInventory(game), originalInventory);
return { game, sourceGame: structuredClone(game), initial, lobby, setup, afterSetup, firstStorm,
originalInventory, originalSpice, spiceOrder, actions, sourceActions: structuredClone(actions),
sourceActionCount: actions.length, staging: [] as string[] };
}
/** Actual all-six-native two-turn programme. The first two positive shipments
* accrue Heighliners once; native first Mentat claims precede a real later
* Storm casualty (Basic dials2 / Advanced card1) and that owner's free return. Sources remain ordinary public
* entry plus original native choices, with no played-state installation. */
export function createMixedPhaseModulesProgramme(options: { advanced?: boolean; strongholds?: boolean } = {}) {
const advanced = options.advanced ?? false, strongholds = options.strongholds ?? advanced;
const source = createMixedPhaseModulesFixture({ factions: MIXED_PHASE_NATIVE_FACTIONS,
expansions: ['ix', 'choam', 'ecaz'], advanced, tech: true, strongholds });
let game = source.game;
const actions = [...source.actions];
const act = (actor: string, action: Action) => { game = actMixedPhaseModules(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceMixedPhaseModules(game, until, actions); };
const transition = (run: () => void): MixedPhaseModulesTransition => {
const before = structuredClone(game), from = actions.length; run();
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
};
reach(state => state.turn === 1 && state.phase === 5 && state.active === 'choam' && mixedPhaseModulesClean(state));
const tabr = territory('sietch_tabr').sectors[0], choamLocation = location('sietch_tabr', tabr);
assert.ok(game.players.every(player => !player.forces[choamLocation]));
const choamShipment = transition(() => {
act('choam', { type: 'ship', territory: 'sietch_tabr', sector: tabr, amount: 1, elite: 0, allyPayment: 0 });
reach(mixedPhaseModulesClean);
});
reach(state => state.turn === 1 && state.phase === 5 && state.active === 'moritani' && mixedPhaseModulesClean(state));
const sector = game.storm % 18 + 1;
const target = TERRITORIES.find(t => t.type === 'sand' && t.id !== 'imperial_basin' && t.sectors.includes(sector) &&
game.players.every(player => !Object.entries(player.forces).some(([key, count]) => count > 0 && key.startsWith(`${t.id}:`))));
assert.ok(target, 'The next original Storm sector has an empty desert shipment destination');
const casualtyLocation = location(target.id, sector);
const moritaniShipment = transition(() => {
act('moritani', { type: 'ship', territory: target.id, sector, amount: 1, elite: 0, allyPayment: 0 });
reach(mixedPhaseModulesClean);
});
// Capture the actual phase-closing action, rather than folding later income
// into the Heighliners receipt.
reach(state => state.phase === 5 && mixedPhaseModulesClean(state) &&
state.order.at(-1) === state.active);
const shipmentPayout = transition(() => {
act(game.active!, { type: 'endMovement' });
reach(state => state.phase !== 5);
});
reach(state => state.turn === 1 && state.phase === 8 && mixedPhaseModulesClean(state));
const mentat = transition(() => {
reach(state => state.turn === 2 && state.phase === 0 && state.stormPending !== null && mixedPhaseModulesClean(state));
});
assert.equal(game.stormMovementSource?.kind, advanced ? 'card' : 'dials'); assert.equal(game.stormPending, advanced ? 1 : 2);
const storm = transition(() => { reach(state => state.turn === 2 && state.phase === 1 && mixedPhaseModulesClean(state)); });
assert.equal(mixedPhaseModulesPlayer(game, 'moritani').forces[casualtyLocation], undefined);
assert.equal(mixedPhaseModulesPlayer(game, 'moritani').tanks, 1);
reach(state => state.turn === 2 && state.phase === 4 && mixedPhaseModulesClean(state) &&
(!state.revivalOrder || state.revivalOrder.remaining[0] === 'moritani'));
const revival = transition(() => {
act('moritani', { type: 'revive', amount: 1, elite: 0 }); reach(mixedPhaseModulesClean);
});
// Complete earlier/remaining owned opportunities with the original ready
// controls, and expose only the final phase-end token payout as its receipt.
reach(state => state.phase === 4 && mixedPhaseModulesClean(state) && (state.revivalOrder
? state.revivalOrder.remaining.length === 1 : state.ready.length === state.players.length - 1));
const revivalPayout = transition(() => {
const step = nextMixedPhaseModules(game); assert.ok(step); act(step.actor, step.action);
reach(state => state.phase === 5 && mixedPhaseModulesClean(state));
});
assert.equal(game.phase, 5);
reach(state => state.phase === 5 && mixedPhaseModulesClean(state));
assert.deepEqual(mixedPhaseModulesInventory(game), source.originalInventory);
if (strongholds) {
assert.equal(mentat.after.strongholdCards?.owners.carthag, 'moritani');
assert.equal(mentat.after.strongholdCards?.owners.sietch_tabr, 'choam');
assert.equal(mentat.after.strongholdCards?.owners[MOBILE_STRONGHOLD], 'ixians');
}
return { ...source, game, sourceGame: structuredClone(game), advanced, strongholds,
choamLocation, casualtyLocation, choamShipment, moritaniShipment, shipmentPayout, mentat, storm,
revival, revivalPayout, actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length };
}
@@ -98,12 +98,14 @@ export function createMoritaniAssassinateGholaTargetFixture() {
while (source.actions[sourceActionCount]?.action.type === 'passResponse') sourceActionCount++;
const sourceActions = structuredClone(source.actions.slice(0, sourceActionCount));
let game = mixedE3MoritaniReload(prefix);
const actions: MixedE3MoritaniStep[] = [];
// Complete the remaining actual Revival opportunities and closing windows.
// The later conserved position is arranged only after native Shipment and
// Movement opens; its real end-turn actions then produce the Battle phase.
let game = advanceMixedE3Moritani(mixedE3MoritaniReload(prefix),
state => state.phase === 5 && mixedE3MoritaniClean(state), actions);
assert.ok(!game.revivalOrder, 'Native phase completion retires the original Revival frame.');
const swap = swapHeldTraitor(game, owner, target);
game.phase = 6;
game.ready = [];
game.order = [opponent, owner, ...game.order.filter(id => id !== opponent && id !== owner)];
game.active = opponent;
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0);
player.forces = {};
@@ -136,7 +138,6 @@ export function createMoritaniAssassinateGholaTargetFixture() {
.sort((first, second) => first.strength - second.strength)[0];
assert.ok(winningDisc && losingDisc && winningDisc.strength > losingDisc.strength,
'Different living native Guild and Moritani discs must produce the unarmed loss.');
const actions: MixedE3MoritaniStep[] = [];
game = openMixedE3MoritaniBattle(game, opponent, owner, actions);
game = advanceMixedE3Moritani(game, state => !!state.battle && mixedE3MoritaniClean(state) &&
!state.battle.preparation && state.battle.preLeader?.closed !== false, actions);
@@ -170,7 +171,7 @@ export function createMoritaniAssassinateGholaTargetFixture() {
...source.staging,
'Reuse only the source fixture’s genuine native Guild death and paid Tleilaxu foreign-revival prefix. The later source winning-Ghola battle is not used as this target witness.',
`Explicitly swap Moritani’s physical ${swap.previous} Traitor Card with ${target} from the ${swap.zone}; preserve the complete original Traitor/Face Dancer census. This is controlled card positioning, not a new deal or a natural draw.`,
'Explicitly stage the later phase-six/order/army position: return every current board counter to its own reserves, retain all Tanks and elite stocks, then place three normal Guild and three normal Moritani counters together outside the unchanged storm. No wallet, hand, mode, target disc or custody field is assigned.',
'Actually finish remaining native Revival opportunities and closing windows. At the original Shipment and Movement phase, explicitly stage only the conserved army position: return every current board counter to its own reserves, retain all Tanks and elite stocks, then place three normal Guild and three normal Moritani counters together outside the unchanged storm. Native end-turn actions open Battle; no phase, order, active player, wallet, hand, mode, target disc or custody field is assigned.',
'Use actual chooseBattle, ordinary preparation, two unarmed private Battle Plans and normal Traitor declines. A different living native Guild disc wins; the original paid foreign Ghola remains unchanged in the Guild roster with Tleilaxu custody. Stop at the real Moritani Assassinate Leaders decision.',
];
return { game, owner, opponent, target, event, potentialBounty: choice.potentialBounty,
+18 -4
View File
@@ -127,7 +127,9 @@ function declareCapture(source: Game, target: string, actions: AssassinateTarget
* the first original battle, captures Burseg through its native lottery and
* keeps or executes him. A different living Emperor disc then beats Moritani.
* Stop at that actual assassination opportunity, before any reveal/decline. */
export function createMoritaniAssassinateTargetFixture(options: { execute?: boolean } = {}) {
export function createMoritaniAssassinateTargetFixture(options: {
execute?: boolean; techTokens?: boolean; strongholds?: boolean;
} = {}) {
const captor = 'harkonnen', opponent = 'emperor', owner = 'moritani', target = 'emperor-3';
let game = createGame('MORITANIASSASSINATETARGET', newPlayer(captor, 'Harkonnen', 'harkonnen'), true, ['ecaz']);
joinGame(game, newPlayer(opponent, 'Emperor', 'emperor'));
@@ -139,13 +141,22 @@ export function createMoritaniAssassinateTargetFixture(options: { execute?: bool
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
const moduleSelections: AssassinateTargetTransition[] = [];
if (options.techTokens) moduleSelections.push(transition(() => {
act(game.host, { type: 'techTokens', enabled: true });
}));
if (options.strongholds) moduleSelections.push(transition(() => {
act(game.host, { type: 'strongholdCards', enabled: true });
}));
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true);
assert.deepEqual(game.players.map(player => player.faction), [captor, opponent, owner]);
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills &&
!game.discoveryEnabled && !game.ecazTreachery && !game.semutaPreview);
assert.equal(!!game.techTokens, options.techTokens ?? false);
assert.equal(!!game.strongholdCards, options.strongholds ?? false);
assert.ok(game.players.every(player => player.hand.length === 0));
assert.ok(assassinateTargetPlayer(game, owner).traitorChoices.includes(target),
'The identity-order original third-seat Traitor offer really contains Burseg');
@@ -239,13 +250,16 @@ export function createMoritaniAssassinateTargetFixture(options: { execute?: bool
owner, moritani: owner, opponent, emperor: opponent, captor, harkonnen: captor, target,
winningDisc: 'emperor-0', territory: 'imperial_basin', sector: 11, location: 'imperial_basin:11',
event: receipt.event, receipt: structuredClone(receipt), execute: !!options.execute,
techTokens: !!options.techTokens, strongholds: !!options.strongholds, moduleSelections,
potentialBounty: victim.strength, action: reveal,
reveal, revealStep: { actor: owner, action: reveal } satisfies AssassinateTargetStep,
decline, actions, sourceActionCount: actions.length, originalInventory, originalTraitors,
originalSpice, dealOrder, spiceOrder,
staging: [
'Actual public native Advanced Harkonnen/Emperor/Moritani ready/start and private setup. Original scalar shuffle order naturally offers emperor-3 in Moritani’s third-seat Traitor packet; Moritani actually chooses it.',
'Select four original unopened ordinary Treachery positions and two original unopened ordinary Spice positions. Native Moritani setup really places six counters in empty Imperial Basin11. No optional module is selected.',
`Select four original unopened ordinary Treachery positions and two original unopened ordinary Spice positions. Native Moritani setup really places six counters in empty Imperial Basin11. ${options.techTokens || options.strongholds
? `Actual fresh lobby controls select ${[options.techTokens ? 'original Tech Tokens' : '', options.strongholds ? 'six Advanced Stronghold Cards' : ''].filter(Boolean).join(' and ')} before normal start; no other overlay is selected.`
: 'No optional module is selected.'}`,
'Emperor actually pays two to ship two reserves to Carthag11, then really moves one to Imperial Basin11. Native first Storm, dual Spice, Charity, Bidding, Revival and movement turns execute normally.',
'Harkonnen actually wins Carthag with Feyd6 against Bashar2, without weapons or leader deaths. Scope only the ensuing native capture permutation to choose unused Burseg3; no candidate body, captive flag, death or concealment is assigned. The original capturedLeader action really keeps or executes that disc.',
'A different surviving native Emperor disc, Fenring6, really wins Imperial Basin against Moritani’s Vando1. The original printed-Emperor assassination opportunity opens while Moritani still physically holds Burseg’s original Traitor Card.',
@@ -192,6 +192,17 @@ export function createMoritaniForeignGholaWinnerFixture(): ForeignGholaWinnerFix
// Turn two revival: the genuine half-price foreign return, settled by the engine.
g = advanceMixedE3Moritani(guildBattle.after,
(s) => s.turn === 2 && s.phase === 4 && mixedE3MoritaniClean(s), actions);
// A fresh Advanced phase now has native player order. Finish only the
// preceding actual owners; the paid foreign return belongs to Tleilaxu's
// still-unstarted opportunity, not the opening actor's turn.
while (g.revivalOrder && g.revivalOrder.remaining[0] !== tleilaxu) {
assert.ok(g.revivalOrder.remaining.includes(tleilaxu),
'The original Tleilaxu Revival opportunity must remain pending.');
const actor = g.revivalOrder.remaining[0]; assert.ok(actor);
g = stepMixedE3Moritani(g, { actor, action: { type: 'ready' } }, actions);
g = advanceMixedE3Moritani(g,
(s) => s.phase === 4 && mixedE3MoritaniClean(s), actions);
}
const beforeRevival = mixedE3MoritaniReload(g);
const cost = Math.ceil(killedGuild.strength / 2);
assert.ok(mixedE3MoritaniPlayer(beforeRevival, tleilaxu).spice >= cost,
+196
View File
@@ -0,0 +1,196 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { territory } from '../game/board';
import { treacheryDeck } from '../game/cards';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { ownedTech } from '../game/tech-tokens';
import { stageEcazOccupyCard } from './fixture-ecaz-occupy';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import { actAdvisorPreparation } from './fixture-advisor-preparation';
export type PhaseModuleEcazStep = { actor: string; action: Action };
export const phaseModuleEcazClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.pendingTreacheryDiscard;
export function phaseModuleEcazPlayer(game: Game, actor: string) {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
}
export function phaseModuleEcazInventory(game: Game) {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? [])]
.map(card => card.id).sort();
assert.deepEqual(cards, treacheryDeck(game.expansions).map(card => card.id).sort());
assert.equal(new Set(cards).size, cards.length);
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, count) => sum + count, 0), 20);
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0), player.faction === 'ixians' ? 7 : 5);
}
return cards;
}
export function actPhaseModuleEcaz(game: Game, actor: string, action: Action, actions: PhaseModuleEcazStep[] = []): Game {
return actAdvisorPreparation(game, actor, action, actions);
}
export function nextPhaseModuleEcazStep(game: Game): PhaseModuleEcazStep | null {
const next = nextRicheseStrongholdsNativeStep(game);
if (next?.action.type === 'stormDial' && game.turn === 1) next.action.amount = 0;
return next;
}
export function advancePhaseModuleEcaz(game: Game, until: (state: Game) => boolean, actions: PhaseModuleEcazStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextPhaseModuleEcazStep(game);
assert.ok(next, `Supply the actual plan or owned choice at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actPhaseModuleEcaz(game, next.actor, next.action, actions);
}
throw Error('The native selected phase-module coalition did not reach its bounded continuation');
}
/** CONTROLLED physical redistribution only; keep original wallets, phases,
* alliances, native printed subtypes, Tech owners and held-card receipts. */
function reserveBoard(game: Game) {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0); player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0);
player.elites.forces = {};
}
}
}
function place(game: Game, actor: string, key: string, count: number, elite = 0) {
const player = phaseModuleEcazPlayer(game, actor);
assert.ok(player.reserves >= count && (player.elites?.reserves ?? 0) >= elite &&
player.reserves - (player.elites?.reserves ?? 0) >= count - elite);
player.reserves -= count; player.forces[key] = count;
if (elite) { player.elites!.reserves -= elite; player.elites!.forces[key] = elite; }
}
function orderBlow(game: Game, worm: boolean, position: number) {
const index = game.spiceDeck.findIndex((card, index) => index >= position && (worm
? 'worm' in card && !card.greatMaker && !card.suppressed : 'territory' in card && !card.discovery));
assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
/** Actual public Ecaz/Ixians/Emperor setup selects all three family decks and
* Tech, plus Advanced Strongholds. A real worm Nexus forms the alliance; the
* real end-Mentat checkpoint earns Ecaz's Tabr card. Explicit conserved board
* and Shield staging is a later coalition supplement, not natural invasion or
* acquisition evidence. Never stage a wallet, phase, token owner or outcome. */
export function createPhaseModuleEcazFixture(options: { advanced?: boolean; lead?: 'ecaz' | 'ally' } = {}) {
const advanced = options.advanced ?? false, lead = options.lead ?? 'ecaz';
const ecaz = 'ecaz', ally = 'ixians', opponent = 'emperor', actor = lead === 'ecaz' ? ecaz : ally;
let game = createGame('PHASEMODULEECAZ', newPlayer(ecaz, 'Ecaz', 'ecaz'), advanced, ['ix', 'choam', 'ecaz']);
joinGame(game, newPlayer(ally, 'Ixians', 'ixians')); joinGame(game, newPlayer(opponent, 'Emperor', 'emperor'));
const actions: PhaseModuleEcazStep[] = [], initial = structuredClone(game);
const act = (owner: string, action: Action) => { game = actPhaseModuleEcaz(game, owner, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advancePhaseModuleEcaz(game, until, actions); };
act(ecaz, { type: 'techTokens', enabled: true });
if (advanced) act(ecaz, { type: 'strongholdCards', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const ready = structuredClone(game);
act(ecaz, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const started = structuredClone(game);
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
const originalInventory = phaseModuleEcazInventory(game);
orderBlow(game, false, 0); orderBlow(game, false, 1);
reach(state => state.turn === 1 && state.phase === 5 && phaseModuleEcazClean(state));
const afterFirstStorm = structuredClone(game);
reserveBoard(game); phaseModuleEcazInventory(game);
reach(state => state.turn === 2 && state.phase === 0 && phaseModuleEcazClean(state));
orderBlow(game, true, 0); orderBlow(game, false, 1); orderBlow(game, false, 2);
reach(state => state.phase === 1 && !!state.nexus && !state.spiceWindow && !state.spiceResolution && phaseModuleEcazClean(state));
const beforeAlliance = structuredClone(game);
act(ecaz, { type: 'alliance', target: ally }); act(ally, { type: 'alliance', target: ecaz });
const afterAlliance = structuredClone(game);
reach(state => state.turn === 2 && state.phase === 8 && phaseModuleEcazClean(state));
const kind = 'sietch_tabr', location = `${kind}:${territory(kind).sectors[0]}`;
reserveBoard(game); place(game, ecaz, location, 1); place(game, ally, location, 1);
phaseModuleEcazInventory(game);
let beforeMentat: Game | null = null, mentatStep: PhaseModuleEcazStep | null = null;
for (let count = 0; game.turn === 2 && count < 200; count++) {
const next = nextPhaseModuleEcazStep(game); assert.ok(next);
const before = structuredClone(game);
game = actPhaseModuleEcaz(game, next.actor, next.action, actions);
if (game.turn === 3) { beforeMentat = before; mentatStep = next; }
}
assert.ok(beforeMentat && mentatStep); const afterMentat = structuredClone(game);
if (advanced) { assert.equal(game.strongholdCards!.owners[kind], ecaz); assert.equal(game.strongholdCards!.claimedTurn, 2); }
orderBlow(game, false, 0); orderBlow(game, false, 1);
reach(state => state.turn === 3 && state.phase === 5 && phaseModuleEcazClean(state));
reserveBoard(game);
const ecazForces = advanced ? 5 : 4, allyForces = 6, allyElite = 3, opponentForces = 8;
place(game, ecaz, location, ecazForces); place(game, ally, location, allyForces, allyElite);
place(game, opponent, location, opponentForces);
const shield = stageEcazOccupyCard(game, actor, card => card.kind === 'shield');
phaseModuleEcazInventory(game);
reach(state => state.turn === 3 && state.phase === 6 && phaseModuleEcazClean(state));
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(choice => choice.territory === kind); assert.ok(choice);
const battleStep: PhaseModuleEcazStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: kind,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
act(battleStep.actor, battleStep.action);
assert.ok(game.decision?.kind === 'ecazBattleLead'); const beforeLead = structuredClone(game);
const leadAction: Action = { type: 'decision', event: game.decision.event, lead: actor };
act(ecaz, leadAction);
reach(state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && phaseModuleEcazClean(state));
const profile = viewGame(game, actor).battle!.ecazOccupy!.profile!;
const winnerLeader = phaseModuleEcazPlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => b.strength - a.strength)[0];
const loserLeader = phaseModuleEcazPlayer(game, opponent).leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => a.strength - b.strength)[0]; assert.ok(winnerLeader && loserLeader);
const planActions: PhaseModuleEcazStep[] = [
{ actor, action: { type: 'battlePlan', leader: winnerLeader.id, dial: profile.fixedEcazDial + 0.5,
support: 0, weapon: null, defense: shield } },
{ actor: opponent, action: { type: 'battlePlan', leader: loserLeader.id, dial: 1, support: 0, weapon: null, defense: null } },
];
const originalTech = structuredClone(game.techTokens!), enemyTokens = ownedTech(game.techTokens, opponent);
assert.equal(enemyTokens.length, 1);
return { game, rawGame: structuredClone(game), sourceGame: structuredClone(game), advanced, lead, actor,
ecaz, ally, opponent, kind, territory: kind, location, ecazForces, allyForces, allyElite, opponentForces,
initial, ready, started, afterSetup, afterFirstStorm, beforeAlliance, afterAlliance,
beforeMentat, mentatStep, afterMentat, beforeBattle, beforeLead, battleStep, leadAction, profile,
shield, winnerLeader: winnerLeader.id, loserLeader: loserLeader.id, planActions,
originalInventory, originalTech, enemyToken: enemyTokens[0], actions, publicActions: actions, sourceActionCount: actions.length,
staging: [
'Actual public Ecaz/Ixians/Emperor lobby selects ix+choam+ecaz decks and original Tech; Advanced additionally selects Stronghold Cards before readiness and explicit preview start. Actual private traitors, Ecaz six-force allocation and Ix starting-card/HMS choices complete setup. The existing original scalar lottery is selected throughout (Advanced Storm cards draw1); UUID and other byte entropy remain native.',
'CONTROLLED conserved positions follow the actual first Storm: return existing counters to their own normal/special reserves, retaining every wallet, phase, card and originally assigned Tech owner. Only remaining original Spice Cards are ordered to cause the actual turn-two worm Nexus; reciprocal alliance actions form Ecaz/Ixians.',
'CONTROLLED one-counter-per-native co-occupation of Tabr precedes the actual turn-two end-Mentat action. Advanced earns the original Tabr card for Ecaz through shared control; no Stronghold owner or claim turn is installed.',
'CONTROLLED later phase-five position conserves Ecaz4 Basic/5 Advanced, Ix3 Suboids+3 Cyborgs and opposing Emperor8 normals at Tabr. One original Shield is moved through real card custody to the selected lead. No shipment, native wallet, phase, Tech owner, plan or victory receipt is installed.',
'Actual chooseBattle, Ecaz lead choice and native preparation stop at unsealed plans. The original fixed Ecaz2/3 and variable0.5/support0 represent one actual Suboid casualty; original winner cleanup precedes the selected winner’s original enemy Tech award. This is a bounded controlled coalition supplement, not complete combination or mode acceptance.',
] };
}
export type PhaseModuleEcazFixture = ReturnType<typeof createPhaseModuleEcazFixture>;
export function quotePhaseModuleEcazBattle(game: Game) {
const battle = game.battle; assert.ok(battle?.revealed);
const view = viewGame(game, game.host).battle!, profile = view.ecazOccupy!.profile!;
const side = (actor: string): ResolutionCombatant => {
const player = phaseModuleEcazPlayer(game, actor), own = viewGame(game, actor).battle!; assert.ok(own.ownForces);
return { id: actor, faction: player.faction, ally: player.ally, spice: player.spice, hand: player.hand,
plan: battle.plans[actor], leader: player.leaders.find(leader => leader.id === battle.plans[actor].leader),
forces: own.ownForces, stronghold: own.strongholdEffects[actor] };
};
return quoteBattleResolution({ advanced: game.advanced, turn: game.turn, territory: battle.territory,
ecazOccupy: profile, aggressor: view.aggressor, attacker: side(battle.attacker), defender: side(battle.defender),
voters: view.traitorVoters.map(actor => ({ id: actor, called: false, traitors: phaseModuleEcazPlayer(game, actor).traitors,
beneficiary: [battle.attacker, battle.defender].includes(actor) ? actor : phaseModuleEcazPlayer(game, actor).ally! })),
participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)],
pendingAuditorPresent: false, pendingRetentionPresent: false });
}
/** Caller may replay these exact original steps through GameTable callbacks.
* Return the earned cleanup boundary as well as the completed battle. */
export function resolvePhaseModuleEcazFixture(fixture: PhaseModuleEcazFixture, state = structuredClone(fixture.game),
actions: PhaseModuleEcazStep[] = []) {
let game = state;
for (const step of fixture.planActions) game = actPhaseModuleEcaz(game, step.actor, step.action, actions);
const revealed = structuredClone(game), quote = quotePhaseModuleEcazBattle(game);
game = advancePhaseModuleEcaz(game, state => state.decision?.kind === 'battleCards' && phaseModuleEcazCleanExceptDecision(state), actions);
const beforeCleanup = structuredClone(game), cleanupAction: Action = { type: 'decision', discard: [] };
assert.equal(game.decision!.player, fixture.actor); assert.equal(game.techTokens![fixture.enemyToken].owner, fixture.opponent);
game = actPhaseModuleEcaz(game, fixture.actor, cleanupAction, actions);
const afterCleanup = structuredClone(game);
game = advancePhaseModuleEcaz(game, state => !state.battle && phaseModuleEcazClean(state), actions);
return { game, revealed, quote, beforeCleanup, cleanupAction, afterCleanup, actions };
}
function phaseModuleEcazCleanExceptDecision(game: Game) {
return !game.phaseOpening && !game.response && !game.pendingTreacheryDiscard;
}
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { createMixedPhaseModulesFixture } from './fixture-mixed-phase-modules';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
const { GameTable } = await import('../components/game-table');
aliases.deregister();
const render = (game: Game, actor = game.host) => renderToStaticMarkup(createElement(GameTable, {
game: viewGame(game, actor), busy: false, send: async () => {}, onExit() {},
}));
// These are stateful React SSR renders and real public actions. They make no
// browser/DOM click claim; authoritative Start is exercised through applyAction.
void test('Basic paired E3 lobby exposes selected Tech and a usable original Begin game path', () => {
const f = createMixedPhaseModulesFixture({ factions: ['ecaz', 'moritani', 'guild'], expansions: ['ecaz'], tech: true });
const html = render(f.lobby);
assert.match(html, /Tech tokens · optional, 3\+ players/); assert.match(html, /Begin game/);
assert.match(html, /Stronghold Cards · Advanced rules required/);
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, false);
assert.ok(f.actions.some(step => step.action.type === 'techTokens' && step.action.enabled === true));
assert.ok(f.actions.some(step => step.action.type === 'start' && step.action.advancedPreview === undefined));
assert.match(render(f.game), /Heighliners|Axlotl Tanks|Spice Production/);
});
void test('Advanced mixed selected decks retain public module controls, preview Start and the live Stronghold inspector', () => {
const f = createMixedPhaseModulesFixture({ factions: ['ixians', 'ecaz', 'moritani'], expansions: ['ix', 'ecaz'],
advanced: true, tech: true, strongholds: true });
const html = render(f.lobby);
assert.match(html, /Tech tokens · optional/); assert.match(html, /Stronghold Cards · Advanced rules required/);
assert.match(html, /Begin Advanced preview/);
assert.ok(f.actions.some(step => step.action.type === 'strongholdCards' && step.action.enabled === true));
assert.ok(f.actions.some(step => step.action.type === 'start' && step.action.advancedPreview === true));
const playing = render(f.game);
assert.match(playing, /Inspect Stronghold Cards/); assert.match(playing, /Heighliners/);
assert.match(playing, /Carthag|Sietch Tabr/);
});
void test('public module changes clear readiness and the two-player Tech lobby keeps Begin disabled', () => {
let game = createGame('MIXEDPHASELOBBYCONTROLS', newPlayer('ecaz', 'Ecaz', 'ecaz'), false, ['ecaz']);
joinGame(game, newPlayer('moritani', 'Moritani', 'moritani'));
for (const seat of game.players) game = applyAction(game, seat.id, { type: 'ready' });
game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
assert.ok(game.players.every(seat => !seat.ready));
for (const seat of game.players) game = applyAction(game, seat.id, { type: 'ready' });
const html = render(game);
const begin = [...html.matchAll(/<button\b([^>]*)>([\s\S]*?)<\/button>/g)]
.filter(([, , children]) => children.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim() === 'Begin game');
assert.equal(begin.length, 1, 'The lobby exposes one Begin game button, including its decorative children');
assert.match(begin[0][1], /(?:^|\s)disabled=""(?:\s|$)/);
assert.throws(() => applyAction(game, game.host, { type: 'start' }));
});
+119
View File
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { treacheryDeck } from '../game/cards';
import { createTechTokens, ownedTech } from '../game/tech-tokens';
import { MOBILE_STRONGHOLD } from '../game/board';
import { nativeFactionTechProfile } from '../game/faction-module-profile';
import {
createMixedPhaseModulesFixture, createMixedPhaseModulesProgramme, mixedPhaseModulesInventory,
mixedPhaseModulesPlayer as player, mixedPhaseModulesClean,
type MixedPhaseModulesOptions,
} from './fixture-mixed-phase-modules';
const entries: (MixedPhaseModulesOptions & { label: string })[] = [
{ label: 'Basic paired E3 Tech', factions: ['ecaz', 'moritani', 'guild'], expansions: ['ecaz'], tech: true },
{ label: 'Advanced E1 plus E3 Tech', factions: ['ixians', 'moritani', 'guild'], expansions: ['ix', 'ecaz'], advanced: true, tech: true },
{ label: 'Advanced E2 plus E3 Strongholds', factions: ['choam', 'ecaz', 'guild'], expansions: ['choam', 'ecaz'], advanced: true, strongholds: true },
{ label: 'Basic classic-only all-three-deck Tech', factions: ['atreides', 'emperor', 'guild'], expansions: ['ix', 'choam', 'ecaz'], tech: true },
{ label: 'Advanced two-seat classic-only selected E3 Strongholds', factions: ['atreides', 'emperor'], expansions: ['ecaz'], advanced: true, strongholds: true },
];
for (const options of entries) void test(`public ${options.label} starts native setup and completes its original first Storm`, () => {
const f = createMixedPhaseModulesFixture(options);
assert.equal(f.initial.status, 'lobby'); assert.equal(f.lobby.status, 'lobby');
assert.ok(f.lobby.players.every(seat => seat.ready));
assert.equal(f.setup.status, 'setup'); assert.equal(f.afterSetup.status, 'playing');
assert.equal(f.game.phase, 1); assert.equal(f.game.turn, 1); assert.equal(f.game.storm, 1);
assert.equal(f.game.advanced, !!options.advanced);
assert.deepEqual(f.game.expansions, options.expansions);
assert.equal(!!f.game.techTokens, !!options.tech); assert.equal(!!f.game.strongholdCards, !!options.strongholds);
assert.ok(!f.game.homeworlds && !f.game.leaderSkills && !f.game.nexusCards && !f.game.discoveryEnabled &&
!f.game.semutaPreview && !f.game.ecazTreachery);
assert.equal(f.originalInventory.length, treacheryDeck(options.expansions).length + Number(options.factions.includes('richese')) * 10);
assert.deepEqual(mixedPhaseModulesInventory(f.game), f.originalInventory);
if (options.tech) {
assert.deepEqual(f.setup.techTokens, createTechTokens(f.setup.players));
const owners = Object.values(f.game.techTokens!).map(token => token.owner);
assert.equal(new Set(owners).size, 3); assert.ok(owners.every(Boolean));
}
if (options.strongholds) assert.ok(Object.values(f.game.strongholdCards!.owners).every(owner => owner === null));
assert.ok(f.actions.some(step => step.action.type === 'start' && !!step.action.advancedPreview === !!options.advanced));
assert.ok(f.actions.filter(step => step.action.type === 'stormDial').every(step => step.action.amount === 0));
assert.deepEqual(f.staging, []); assert.equal(f.sourceActionCount, f.actions.length);
});
for (const advanced of [false, true]) void test(`public all-six-native ${advanced ? 'Advanced Tech and Strongholds' : 'Basic Tech'} earns both token payouts, native claims and a real free return`, () => {
const f = createMixedPhaseModulesProgramme({ advanced });
assert.deepEqual(f.setup.players.map(seat => seat.faction), ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani']);
assert.equal(f.setup.techTokens?.heighliners.owner, 'ixians');
assert.equal(f.setup.techTokens?.axlotl.owner, 'tleilaxu');
assert.equal(player(f.choamShipment.before, 'choam').forces[f.choamLocation], undefined);
assert.equal(player(f.choamShipment.after, 'choam').forces[f.choamLocation], 1);
assert.equal(player(f.choamShipment.before, 'choam').spice - player(f.choamShipment.after, 'choam').spice, 1);
assert.equal(player(f.moritaniShipment.after, 'moritani').forces[f.casualtyLocation], 1);
assert.equal(player(f.moritaniShipment.before, 'moritani').spice - player(f.moritaniShipment.after, 'moritani').spice, 2);
for (const shipment of [f.choamShipment, f.moritaniShipment]) {
assert.equal(shipment.steps[0].action.type, 'ship'); assert.equal(shipment.steps[0].action.amount, 1);
assert.equal(shipment.after.techTokens?.heighliners.spice, 1);
assert.equal(shipment.after.techTokens?.heighliners.triggeredTurn, 1);
assert.equal(player(shipment.after, 'ixians').spice, player(shipment.before, 'ixians').spice);
}
assert.equal(f.shipmentPayout.before.phase, 5); assert.equal(f.shipmentPayout.after.phase, 6);
assert.equal(f.shipmentPayout.after.techTokens?.heighliners.spice, 0);
assert.equal(player(f.shipmentPayout.after, 'ixians').spice - player(f.shipmentPayout.before, 'ixians').spice, 1);
if (advanced) {
assert.equal(f.mentat.before.strongholdCards?.claimedTurn, 0);
assert.equal(f.mentat.after.strongholdCards?.claimedTurn, 1);
assert.equal(f.mentat.after.strongholdCards?.owners.carthag, 'moritani');
assert.equal(f.mentat.after.strongholdCards?.owners.sietch_tabr, 'choam');
assert.equal(f.mentat.after.strongholdCards?.owners[MOBILE_STRONGHOLD], 'ixians');
} else assert.equal(f.mentat.after.strongholdCards, undefined);
assert.equal(f.storm.before.stormMovementSource?.kind, advanced ? 'card' : 'dials'); assert.equal(f.storm.before.stormPending, advanced ? 1 : 2);
assert.equal(player(f.storm.before, 'moritani').tanks, 0);
assert.equal(player(f.storm.after, 'moritani').tanks, 1);
assert.equal(player(f.storm.after, 'moritani').forces[f.casualtyLocation], undefined);
assert.deepEqual(f.revival.steps[0], { actor: 'moritani', action: { type: 'revive', amount: 1, elite: 0 } });
if (advanced) assert.equal(f.revival.before.revivalOrder?.remaining[0], 'moritani');
assert.equal(player(f.revival.after, 'moritani').tanks, 0); assert.equal(player(f.revival.after, 'moritani').revived, 1);
assert.equal(player(f.revival.after, 'moritani').reserves - player(f.revival.before, 'moritani').reserves, 1);
assert.equal(player(f.revival.after, 'moritani').spice, player(f.revival.before, 'moritani').spice);
assert.equal(player(f.revival.after, 'tleilaxu').spice - player(f.revival.before, 'tleilaxu').spice, 1);
assert.equal(f.revival.after.techTokens?.axlotl.spice, 1);
assert.equal(f.revival.after.techTokens?.axlotl.triggeredTurn, 2);
assert.equal(f.revivalPayout.after.techTokens?.axlotl.spice, 0);
assert.equal(player(f.revivalPayout.after, 'tleilaxu').spice - player(f.revivalPayout.before, 'tleilaxu').spice, 1);
assert.equal(ownedTech(f.game.techTokens, 'tleilaxu').length, 1);
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 5); assert.ok(mixedPhaseModulesClean(f.game));
assert.deepEqual(mixedPhaseModulesInventory(f.game), f.originalInventory); assert.deepEqual(f.staging, []);
// A minimal own-view policy path, without a strategic simulation campaign.
const actor = f.game.active; assert.ok(actor);
const view = viewGame(f.game, actor); view.players.find(seat => seat.id === actor)!.bot = 'Easy';
const legal = botActions(view).find(action => action.type === 'endMovement'); assert.ok(legal);
assert.doesNotThrow(() => applyAction(f.game, actor, legal));
});
function lobby(count: number, advanced: boolean): Game {
const seats = ['ecaz', 'moritani', 'guild'] as const;
const game = createGame('MIXEDPHASEINVALID', newPlayer(seats[0], seats[0], seats[0]), advanced, ['ecaz']);
for (const faction of seats.slice(1, count)) joinGame(game, newPlayer(faction, faction, faction));
return game;
}
function rejected(game: Game, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, game.host, action)); assert.deepEqual(game, before);
}
void test('public two-seat Tech and Basic Strongholds are rejected while registered overlays stay outside the new phase-only profile', () => {
let two = applyAction(lobby(2, false), 'ecaz', { type: 'techTokens', enabled: true });
for (const seat of two.players) two = applyAction(two, seat.id, { type: 'ready' });
rejected(two, { type: 'start' });
rejected(lobby(3, false), { type: 'strongholdCards', enabled: true });
let overlay = applyAction(lobby(3, true), 'ecaz', { type: 'techTokens', enabled: true });
overlay = applyAction(overlay, 'ecaz', { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
for (const seat of overlay.players) overlay = applyAction(overlay, seat.id, { type: 'ready' });
// This existing independent-card start has its own registered composition.
// Its permission must not be attributed to the new phase-only profile.
const started = applyAction(overlay, overlay.host, { type: 'start', advancedPreview: true });
assert.equal(started.status, 'setup'); assert.equal(started.ecazTreachery, true);
assert.equal(nativeFactionTechProfile(started), false);
assert.equal(nativeFactionTechProfile({ ...started, ecazTreachery: false }), true);
});
+1 -2
View File
@@ -85,10 +85,9 @@ void test('native Tech seats three through six complete actual Basic/Advanced se
}
});
void test('native Tech rejects below-three, missing-family, wrong-deck and already-owned inventory without dealing', () => {
void test('native Tech rejects below-three, wrong-deck and already-owned inventory without dealing', () => {
const invalid = [
lobby(['ixians', 'guild']),
lobby(['emperor', 'fremen', 'guild']),
lobby(['ixians', 'emperor', 'guild'], ['choam']),
];
const owned = lobby(['ixians', 'emperor', 'guild']);
+92
View File
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ownedTech } from '../game/tech-tokens';
import { strongholdBenefit } from '../game/stronghold-cards';
import {
createPhaseModuleEcazFixture as fixture, resolvePhaseModuleEcazFixture as resolve,
phaseModuleEcazInventory as inventory, phaseModuleEcazPlayer as player,
type PhaseModuleEcazFixture,
} from './fixture-phase-module-ecaz';
function source(f: PhaseModuleEcazFixture) {
assert.equal(f.initial.status, 'lobby'); assert.equal(f.ready.status, 'lobby'); assert.equal(f.started.status, 'setup');
assert.equal(f.afterSetup.status, 'playing'); assert.equal(f.game.status, 'playing');
assert.equal(f.game.turn, 3); assert.equal(f.game.phase, 6); assert.equal(f.game.advanced, f.advanced);
assert.deepEqual(f.game.expansions, ['ix', 'choam', 'ecaz']);
assert.deepEqual(f.game.players.map(p => p.faction), ['ecaz', 'ixians', 'emperor']);
assert.ok(f.game.techTokens); assert.equal(!!f.game.strongholdCards, f.advanced);
assert.ok(!f.game.leaderSkills && !f.game.homeworlds && !f.game.nexusCards && !f.game.discoveryEnabled && !f.game.ecazTreachery && !f.game.semutaPreview);
assert.ok(f.actions.some(step => step.action.type === 'techTokens' && step.action.enabled === true));
assert.ok(f.actions.some(step => step.action.type === 'start' && (!f.advanced || step.action.advancedPreview === true)));
assert.equal(f.beforeAlliance.turn, 2); assert.equal(f.beforeAlliance.phase, 1); assert.equal(f.beforeAlliance.nexus, true);
assert.ok(f.beforeAlliance.spiceDiscard.flat().some(card => 'worm' in card && !card.suppressed));
assert.equal(player(f.afterAlliance, f.ecaz).ally, f.ally); assert.equal(player(f.afterAlliance, f.ally).ally, f.ecaz);
assert.ok(f.actions.some(step => step.actor === f.ecaz && step.action.type === 'alliance' && step.action.target === f.ally));
assert.ok(f.actions.some(step => step.actor === f.ally && step.action.type === 'alliance' && step.action.target === f.ecaz));
assert.equal(f.beforeMentat.turn, 2); assert.equal(f.beforeMentat.phase, 8); assert.equal(f.afterMentat.turn, 3);
assert.equal(f.mentatStep.action.type, 'ready');
if (f.advanced) {
assert.equal(f.afterMentat.strongholdCards!.owners.sietch_tabr, f.ecaz);
assert.equal(f.afterMentat.strongholdCards!.claimedTurn, 2);
assert.equal(strongholdBenefit(f.game.strongholdCards, f.ecaz, f.territory), f.territory);
assert.equal(strongholdBenefit(f.game.strongholdCards, f.ally, f.territory), null);
}
assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead'); assert.equal(f.beforeLead.decision?.player, f.ecaz);
assert.equal(f.game.battle!.revealed, false); assert.equal(f.game.decision ?? null, null); assert.equal(f.game.response ?? null, null);
assert.equal(f.profile.ecaz, f.ecaz); assert.equal(f.profile.ally, f.ally); assert.equal(f.profile.planOwner, f.actor);
assert.equal(f.profile.payer, f.actor); assert.equal(f.profile.forceOwner, f.ally); assert.equal(f.profile.canceled, false);
assert.equal(f.profile.fixedEcazDial, Math.ceil(f.ecazForces / 2));
assert.equal(f.profile.ecazForces.normal, f.ecazForces);
assert.equal(f.profile.allyForces.normal, 3); assert.equal(f.profile.allyForces.elite, 3);
assert.equal(f.profile.allyForces.normalFixedHalf, true); assert.equal(f.profile.allyForces.eliteStrength, 2);
assert.equal(player(f.game, f.ecaz).forces[f.location], f.ecazForces);
assert.equal(player(f.game, f.ally).forces[f.location], 6); assert.equal(player(f.game, f.ally).elites!.forces[f.location], 3);
assert.equal(player(f.game, f.opponent).forces[f.location], 8);
assert.equal(ownedTech(f.afterFirstStorm.techTokens, f.ecaz).length, 1);
assert.equal(ownedTech(f.afterFirstStorm.techTokens, f.ally).length, 1);
assert.equal(ownedTech(f.afterFirstStorm.techTokens, f.opponent).length, 1);
assert.deepEqual(f.game.techTokens, f.afterFirstStorm.techTokens);
assert.ok(player(f.game, f.actor).hand.some(card => card.id === f.shield));
assert.equal(f.sourceActionCount, f.actions.length); assert.match(f.staging[1], /CONTROLLED/); assert.match(f.staging[2], /CONTROLLED/);
assert.deepEqual(inventory(f.game), f.originalInventory); assert.equal(f.originalInventory.length, 47);
}
for (const advanced of [false, true]) for (const lead of ['ecaz', 'ally'] as const) {
void test(`public ${advanced ? 'Advanced Stronghold+Tech' : 'Basic Tech'} all-three-deck coalition: ${lead} lead retains original native losses, holder benefit and winner Tech`, () => {
const f = fixture({ advanced, lead }); source(f);
const before = structuredClone(f.game), result = resolve(f), { quote, revealed, beforeCleanup, game } = result;
assert.deepEqual(f.game, before);
assert.equal(quote.winner, f.actor); assert.equal(beforeCleanup.lastBattleContext!.winner, f.actor);
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: Math.ceil(f.ecazForces / 2), elite: 0 }]);
assert.equal(quote.casualties!.owner, f.ally);
assert.deepEqual(quote.casualties!.options, [{ normal: 1, elite: 0, paidNormal: 0, paidElite: 0 }]);
assert.ok(quote.payments.every(payment => payment.ownPayment === 0 && payment.bankSupport === 0));
const income = advanced && lead === 'ecaz' ? 1 : 0;
assert.deepEqual(quote.strongholdIncome, income ? [{ player: f.actor, amount: income }] : []);
assert.equal(player(beforeCleanup, f.actor).spice, player(revealed, f.actor).spice + income);
const nonLead = f.actor === f.ecaz ? f.ally : f.ecaz;
assert.equal(player(beforeCleanup, nonLead).spice, player(revealed, nonLead).spice);
assert.equal(player(beforeCleanup, f.opponent).spice, player(revealed, f.opponent).spice);
assert.equal(beforeCleanup.decision?.kind, 'battleCards'); assert.equal(beforeCleanup.decision?.player, f.actor);
assert.equal(beforeCleanup.techTokens![f.enemyToken].owner, f.opponent, 'Original winner-card cleanup precedes the original enemy token transfer');
assert.equal(player(beforeCleanup, f.ecaz).tanks - player(f.game, f.ecaz).tanks, Math.ceil(f.ecazForces / 2));
assert.equal(player(beforeCleanup, f.ally).tanks - player(f.game, f.ally).tanks, 1);
assert.equal(player(beforeCleanup, f.ally).elites!.tanks, player(f.game, f.ally).elites!.tanks);
assert.equal(player(beforeCleanup, f.opponent).tanks - player(f.game, f.opponent).tanks, 8);
assert.equal(player(game, f.ecaz).forces[f.location], 2);
assert.equal(player(game, f.ally).forces[f.location], 5); assert.equal(player(game, f.ally).elites!.forces[f.location], 3);
assert.equal(player(game, f.opponent).forces[f.location] ?? 0, 0);
assert.equal(game.lastBattleContext!.winner, f.actor); assert.equal(game.lastBattleContext!.result, 'normal');
assert.equal(game.techTokens![f.enemyToken].owner, f.actor);
assert.equal(ownedTech(game.techTokens, f.actor).length, 2); assert.equal(ownedTech(game.techTokens, nonLead).length, 1);
assert.equal(ownedTech(game.techTokens, f.opponent).length, 0);
assert.ok(player(game, f.actor).hand.some(card => card.id === f.shield));
assert.equal(player(game, f.actor).leaders.find(leader => leader.id === f.winnerLeader)!.dead, false);
if (advanced) assert.equal(game.strongholdCards!.owners.sietch_tabr, f.ecaz, 'Held-card custody is not transferred to the selected ally lead');
assert.ok(result.actions.some(step => step.actor === f.actor && step.action.type === 'decision' && Array.isArray(step.action.discard)));
assert.equal(result.actions.filter(step => step.action.type === 'battlePlan').length, 2);
assert.ok(result.actions.some(step => step.action.type === 'traitorCall' && step.action.call === false));
assert.equal(game.battle ?? null, null); assert.equal(game.decision ?? null, null);
assert.deepEqual(inventory(game), f.originalInventory);
});
}
+118
View File
@@ -0,0 +1,118 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { ownedTech, TECH_TOKENS } from '../game/tech-tokens';
import { STRONGHOLD_CARDS } from '../game/stronghold-cards';
import {
actAssassinateTarget as act, advanceAssassinateTarget as advance,
assassinateTargetInventory as inventory, assassinateTargetPlayer as player,
assassinateTargetTraitors as traitors, createMoritaniAssassinateTargetFixture as fixture,
finishMoritaniAssassinateTarget as finish, type AssassinateTargetStep,
} from './fixture-moritani-assassinate-target';
const cases = [
{ label: 'Tech', techTokens: true, strongholds: false, execute: false },
{ label: 'Strongholds', techTokens: false, strongholds: true, execute: false },
{ label: 'Tech and Strongholds', techTokens: true, strongholds: true, execute: false },
{ label: 'Tech and Strongholds with actual Execute', techTokens: true, strongholds: true, execute: true },
] as const;
for (const selected of cases) {
void test(`native Advanced Harkonnen/Moritani ${selected.label} keeps original capture, assassination and phase-module rewards`, () => {
const f = fixture(selected);
assert.equal(f.initial.status, 'lobby');
assert.equal(!!f.initial.techTokens, false); assert.equal(!!f.initial.strongholdCards, false);
assert.equal(f.moduleSelections.length, Number(selected.techTokens) + Number(selected.strongholds));
for (const selection of f.moduleSelections) {
assert.equal(selection.before.status, 'lobby'); assert.equal(selection.after.status, 'lobby');
assert.ok(selection.before.players.every(seat => !seat.hand.length && !seat.ready));
assert.equal(selection.steps.length, 1); assert.equal(selection.steps[0].actor, f.initial.host);
assert.ok(['techTokens', 'strongholdCards'].includes(selection.steps[0].action.type));
assert.equal(selection.steps[0].action.enabled, true);
}
assert.ok(f.actions.some(step => step.action.type === 'start' && step.action.advancedPreview === true));
assert.equal(f.setup.status, 'setup'); assert.ok(f.setup.moritaniAssassinate);
assert.deepEqual(f.setup.players.map(seat => seat.faction), ['harkonnen', 'emperor', 'moritani']);
assert.equal(!!f.setup.techTokens, selected.techTokens);
assert.equal(!!f.setup.strongholdCards, selected.strongholds);
assert.ok(!f.game.homeworlds && !f.game.leaderSkills && !f.game.nexusCards &&
!f.game.discoveryEnabled && !f.game.semutaPreview && !f.game.ecazTreachery);
assert.equal(f.originalInventory.length, 33);
assert.deepEqual(inventory(f.game), f.originalInventory); assert.deepEqual(traitors(f.game), f.originalTraitors);
assert.equal(f.sourceActionCount, f.actions.length);
assert.equal(f.shipment.steps[0].actor, f.opponent);
assert.equal(f.shipment.steps[0].action.type, 'ship');
assert.equal(player(f.shipment.before, f.opponent).spice - player(f.shipment.after, f.opponent).spice, 2);
assert.equal(player(f.movement.after, f.opponent).forces['carthag:11'], 1);
assert.equal(player(f.movement.after, f.opponent).forces['imperial_basin:11'], 1);
assert.equal(f.beforeCaptureChoice.decision?.kind, 'capturedLeader');
assert.ok(f.capture.steps.some(step => step.action.type === 'decision' && step.action.accept === true));
assert.equal(f.captureChoiceStep.action.mode, selected.execute ? 'execute' : 'keep');
const captured = player(f.afterCapture, f.opponent).leaders.find(disc => disc.id === f.target)!;
assert.ok(captured.concealed);
assert.equal(captured.dead, selected.execute);
assert.equal(captured.capturedBy, selected.execute ? undefined : f.captor);
const emperorToken = selected.techTokens ? ownedTech(f.beforeFirstBattle.techTokens, f.opponent) : [];
const moritaniToken = selected.techTokens ? ownedTech(f.beforeSecondBattle.techTokens, f.owner) : [];
if (selected.techTokens) {
assert.equal(emperorToken.length, 1); assert.equal(moritaniToken.length, 1);
assert.equal(new Set(TECH_TOKENS.map(token => f.beforeFirstBattle.techTokens![token.id].owner)).size, 3,
'The real first Storm assigns one original token to each original seat');
assert.equal(f.afterCapture.techTokens![emperorToken[0]].owner, f.captor,
'The first real Harkonnen win earns the Emperor token through original cleanup');
assert.equal(f.game.techTokens![moritaniToken[0]].owner, f.owner,
'The later winner reward waits for this actual assassination opportunity');
}
if (selected.strongholds) {
assert.equal(f.setup.strongholdCards?.claimedTurn, 0);
assert.equal(Object.keys(f.setup.strongholdCards!.owners).length, STRONGHOLD_CARDS.length);
assert.ok(Object.values(f.game.strongholdCards!.owners).every(owner => owner === null),
'No Stronghold advantage is assigned before the first actual END Mentat');
}
const pending = viewGame(f.game, f.owner).moritaniAssassinate?.pending; assert.ok(pending);
assert.ok(pending.cards.some(choice => choice.card === f.target));
assert.equal(f.receipt.opposingLeader, 'emperor-0'); assert.notEqual(f.target, f.receipt.opposingLeader);
const targetBefore = structuredClone(player(f.game, f.opponent).leaders.find(disc => disc.id === f.target)!);
const money = player(f.game, f.owner).spice;
let game = act(f.game, f.owner, f.reveal);
const killed = player(game, f.opponent).leaders.find(disc => disc.id === f.target)!;
assert.equal(killed.dead, true);
assert.equal(killed.deaths, targetBefore.deaths + Number(!selected.execute));
assert.equal(killed.capturedBy, undefined); assert.equal(killed.concealed, undefined);
assert.equal(killed.faction, 'emperor'); assert.equal(killed.strength, targetBefore.strength);
assert.equal(killed.controller, targetBefore.controller); assert.equal(killed.gholaBy, targetBefore.gholaBy);
assert.equal(player(game, f.owner).spice, money + (selected.execute ? 0 : f.potentialBounty));
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.bounty, selected.execute ? 0 : f.potentialBounty);
const continuation: AssassinateTargetStep[] = [];
game = finish(game, f, continuation);
const receipt = game.moritaniAssassinate!.opportunities.find(r => r.event === f.event)!;
assert.equal(receipt.stage, 'replaced'); assert.ok(receipt.replacement && receipt.replacement !== f.target);
assert.equal(player(game, f.owner).traitors.includes(f.target), false);
assert.ok(player(game, f.owner).traitors.includes(receipt.replacement));
assert.deepEqual(traitors(game), f.originalTraitors); assert.deepEqual(inventory(game), f.originalInventory);
if (selected.techTokens) {
assert.equal(game.techTokens![emperorToken[0]].owner, f.captor);
assert.equal(game.techTokens![moritaniToken[0]].owner, f.opponent,
'The surviving native Emperor winner receives the original Moritani token once');
assert.equal(ownedTech(game.techTokens, f.captor).length, 2);
assert.equal(ownedTech(game.techTokens, f.opponent).length, 1);
assert.equal(ownedTech(game.techTokens, f.owner).length, 0);
}
game = advance(game, state => state.turn === 2 && state.phase === 0, continuation);
if (selected.strongholds) {
assert.equal(game.strongholdCards!.claimedTurn, 1);
assert.equal(game.strongholdCards!.owners.carthag, f.captor,
'The actual first END Mentat awards the city still physically held after the native capture');
assert.ok(Object.entries(game.strongholdCards!.owners).every(([id, owner]) => id === 'carthag' || owner === null));
}
assert.ok(continuation.some(step => step.action.type === 'ready'));
assert.deepEqual(inventory(game), f.originalInventory); assert.deepEqual(traitors(game), f.originalTraitors);
const finalTarget = player(game, f.opponent).leaders.find(disc => disc.id === f.target)!;
assert.equal(finalTarget.deaths, killed.deaths);
assert.equal(finalTarget.capturedBy, undefined); assert.equal(finalTarget.concealed, undefined);
});
}
+130
View File
@@ -0,0 +1,130 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
nativeFactionTechProfile as tech, selectedFactionStrongholdProfile as strongholds,
selectedPhaseModuleRoster as roster, type FactionModuleProfile,
} from '../game/faction-module-profile';
import { createStrongholdCards } from '../game/stronghold-cards';
import { createTechTokens } from '../game/tech-tokens';
function profile(factions: readonly string[] = ['ecaz', 'moritani', 'guild'],
expansions: readonly string[] = ['ecaz'], extra: Partial<FactionModuleProfile> = {}): FactionModuleProfile {
return { advanced: true, expansions, players: factions.map(faction => ({ faction })),
techTokens: createTechTokens(), ...extra };
}
void test('selected phase rosters admit paired E3, mixed families and all six natives without changing selected-deck order', () => {
for (const game of [
profile(), profile(['ecaz', 'ixians', 'guild'], ['ecaz', 'ix']),
profile(['moritani', 'choam', 'guild'], ['choam', 'ecaz']),
profile(['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'], ['ix', 'choam', 'ecaz']),
]) {
const before = structuredClone(game);
assert.equal(roster(game), true); assert.equal(tech(game), true); assert.equal(strongholds(game), true);
assert.deepEqual(game, before);
}
});
void test('classic-only selected-deck rosters and unused selected families are valid while no-expansion base admission stays separate', () => {
for (const expansions of [['ix'], ['choam'], ['ecaz'], ['ix', 'choam', 'ecaz']]) {
const classic = profile(['atreides', 'emperor', 'guild'], expansions);
assert.equal(roster(classic), true); assert.equal(tech(classic), true); assert.equal(strongholds(classic), true);
}
const unused = profile(['ixians', 'guild', 'emperor'], ['ecaz', 'choam', 'ix']);
assert.equal(roster(unused), true); assert.equal(tech(unused), true); assert.equal(strongholds(unused), true);
const base = profile(['atreides', 'emperor', 'guild'], []);
assert.equal(roster(base), false); assert.equal(tech(base), false); assert.equal(strongholds(base), false);
});
void test('unknown, missing and duplicate selected families or seated identities cannot grant phase modules', () => {
for (const game of [
profile(['ixians', 'guild', 'emperor'], ['choam']),
profile(['ecaz', 'guild', 'emperor'], ['ix', 'choam']),
profile(['ixians', 'guild', 'emperor'], ['ix', 'ix']),
profile(['ecaz', 'guild', 'emperor'], ['ecaz', 'unknown']),
profile(['ixians', 'guild', 'emperor'], ['ix', 'choam', 'ecaz', 'ix']),
profile(['unknown', 'guild', 'emperor']), profile(['ecaz', 'ecaz', 'guild']),
]) {
assert.equal(roster(game), false); assert.equal(tech(game), false); assert.equal(strongholds(game), false);
}
for (const value of [null, {}, { expansions: 'ix', players: [{ faction: 'ixians' }, { faction: 'guild' }] },
{ expansions: ['ix'], players: [{ faction: 'ixians' }, null] }])
assert.equal(roster(value as unknown as Pick<FactionModuleProfile, 'expansions' | 'players'>), false);
});
void test('the roster admits two through six seats while Tech independently requires at least three', () => {
const seats = ['ecaz', 'moritani', 'ixians', 'tleilaxu', 'choam', 'richese'];
for (let count = 2; count <= 6; count++) {
const game = profile(seats.slice(0, count), ['ix', 'choam', 'ecaz']);
assert.equal(roster(game), true); assert.equal(tech(game), count >= 3);
assert.equal(strongholds({ ...game, techTokens: null }), true);
assert.equal(strongholds(game), count >= 3);
}
for (const game of [profile(['ecaz']), profile([...seats, 'guild'], ['ix', 'choam', 'ecaz'])]) {
assert.equal(roster(game), false); assert.equal(tech(game), false); assert.equal(strongholds(game), false);
}
assert.equal(tech({ ...profile(), techTokens: null }), false);
});
void test('Basic Tech remains available but Strongholds require Advanced and may start with absent or unused card state', () => {
const basic = profile(undefined, undefined, { advanced: false });
assert.equal(tech(basic), true); assert.equal(strongholds(basic), false);
assert.equal(tech({ ...basic, strongholdCards: createStrongholdCards() }), false);
const advanced = profile();
assert.equal(strongholds(advanced), true);
assert.equal(strongholds({ ...advanced, strongholdCards: createStrongholdCards() }), true);
assert.equal(tech({ ...advanced, strongholdCards: createStrongholdCards() }), true);
assert.equal(tech({ ...advanced, advanced: 'advanced' as unknown as boolean }), false);
assert.equal(strongholds({ ...advanced, advanced: 'advanced' as unknown as boolean }), false);
});
void test('phase permission wrappers reject unrelated modules and preview selectors while roster validity stays independent', () => {
for (const field of [
'leaderSkills', 'homeworlds', 'nexusCards', 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker',
'ecazTreachery', 'semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview', 'guildBetrayalPreview',
'richeseBetrayalPreview', 'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview', 'nexusHarkonnenBetrayalPreview',
] as const) {
const game = { ...profile(), [field]: true };
assert.equal(roster(game), true); assert.equal(tech(game), false, field); assert.equal(strongholds(game), false, field);
}
});
void test('canonical Ecaz and Moritani runtime stamps and the supported Advanced Harkonnen roster do not block new phase admission', () => {
const game = profile(['ecaz', 'moritani', 'harkonnen'], ['ecaz'], {
ecazOccupyPreview: true, moritaniAssassinatePreview: true, strongholdCards: createStrongholdCards(),
});
assert.equal(roster(game), true); assert.equal(tech(game), true); assert.equal(strongholds(game), true);
assert.equal(tech(game, true), false, 'Explicit Semuta keeps its earlier Advanced Moritani/Harkonnen boundary');
});
void test('explicit Semuta preserves its original E1/E2 and exactly-one-E3-native positive envelope', () => {
for (const game of [
profile(['ixians', 'guild', 'emperor'], ['ix']),
profile(['richese', 'choam', 'guild'], ['choam']),
profile(['ixians', 'guild', 'emperor'], ['ix', 'choam']),
profile(['ecaz', 'guild', 'emperor'], ['ecaz']),
profile(['moritani', 'guild', 'emperor'], ['ecaz']),
profile(['moritani', 'harkonnen', 'guild'], ['ecaz'], { advanced: false }),
]) {
assert.equal(tech(game, true), true);
const stamped = { ...game, semutaPreview: true };
assert.equal(tech(stamped), false); assert.equal(tech(stamped, true), true);
assert.equal(strongholds(stamped), false);
assert.equal(tech({ ...stamped, semutaPreview: 'foreign-stamp' }, true), false);
}
});
void test('explicit Semuta cannot inherit paired, mixed, all-three-family, classic-only or Advanced Moritani/Harkonnen admission', () => {
for (const game of [
profile(), profile(['ecaz', 'ixians', 'guild'], ['ecaz', 'ix']),
profile(['moritani', 'choam', 'guild'], ['ecaz', 'choam']),
profile(['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'], ['ix', 'choam', 'ecaz']),
profile(['ixians', 'guild', 'emperor'], ['ix', 'choam', 'ecaz']),
profile(['atreides', 'emperor', 'guild'], ['choam']),
profile(['moritani', 'harkonnen', 'guild'], ['ecaz']),
]) {
const before = structuredClone(game);
assert.equal(tech(game), true); assert.equal(tech(game, true), false);
assert.equal(tech({ ...game, semutaPreview: true }, true), false); assert.deepEqual(game, before);
}
});
+10
View File
@@ -37,6 +37,16 @@ for (const advanced of [false, true]) {
state = applyAction(state, 'e', { type: 'battlePlan', dial: 0, support: 0,
leader: player(state, 'e').leaders[0].id, defense: f.defense });
for (const id of ['a', 'e']) state = applyAction(state, id, { type: 'traitorCall', call: false });
// Traitor votes now open the original signed final-action opportunity;
// both combatants must finish it before mandatory disposal can be offered.
let acknowledgements = 0;
while (state.battle?.outcomeWindow && !state.battle.outcomeWindow.closed) {
const window = state.battle.outcomeWindow;
const actor = [window.attacker, window.defender].find(id => !window.ready.includes(id)); assert.ok(actor);
state = applyAction(state, actor, { type: 'battleOutcomeReady', event: window.event });
acknowledgements++;
}
assert.equal(acknowledgements, 2);
assert.equal(state.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const winner = applyAction(pass(reload(state)), 'a', { type: 'decision', discard: [f.weapon] });
assert.equal(winner.pendingTreacheryDiscard?.reaction?.stage, 'offer');