Initialize occupation benefits for registered World Skills starts

This commit is contained in:
Quintine committed 2026-10-26 11:59:03 +11:00
1 parent 7f9daa10fa
commit 396f9e0ccb
21 files changed
+737 -26

No files matched your search

+29
View File
@@ -16,6 +16,35 @@ open mode gates.
## Current checkpoint and work
**Leader Skills / mandatory World occupation — 26 October. Prototyped / Partial.**
Existing clean registered World/all14 starts now initialize original occupation
benefits beside normal Banker, with optional independent cards and phase modules.
Roster, role and phase limits remain; Nexus/Discovery/Semuta retain separate
contexts. Original offers train eligible native leaders through actual controls.
Disjoint profile/training/native lanes and root integration pass **21 files,
158/158**, types/lint/build and **six native runtime paths**,18 GameTable SSR
renders. Bare mixed/classic starts use25/13 actions and47/33 cards. All-six Basic
and Advanced use257/274 actions,57/60 cards, actual Storm losses/free returns,
earned token income/cards and four legal minimal-policy actions each.
A/Ecaz/Ix sources use61/66 actions/card50 plus20 continuations: Atreides pays10
to empty Caladan; Ecaz Banker earns deferred1 from that other payer, then pays1
to occupy Caladan. Actual occupied Collection2 leaves Banker1 deferred until
actual Mentat. Heighliners1, Advanced ordinary Arrakeen2 and earned first
end-Mentat cards stay separate. No played card, force, wallet, qualification,
phase, owner or receipt is installed. All original14 Skills remain physical.
Feedback repairs older Banker/Tleilaxu fixtures to await current Advanced
Revival actors/beneficiaries, selecting original lobby circles where a later
opportunity is required. Payment, diversion, free-return and skill-custody
assertions remain. Frozen report/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-world-skills-occupation-proof-v3/`;
journeys/HTML: adjacent `20261026-world-skills-occupation-runtime/`.
No saved-room/D1/server, reset/restart, deployment or assurance campaign.
SSR/public-action evidence leaves browser/mobile/deployed, wider combinations
and complete-mode acceptance open. [Source and contract](HOMEWORLD_RULES.md#registered-world-skills-occupation--26-october-2026).
**Homeworld occupation / original phase modules — 26 October. Prototyped / Partial.**
Clean registered classic/no-deck and selected-family Worlds now compose optional
independent cards, Tech3–6 Basic/Advanced and Strongholds2–6 Advanced without
+47
View File
@@ -2,6 +2,53 @@
Source audit, 9 September 2026. No runtime activation. The primary module source is the [GF9 Ecaz & Moritani rulebook](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf), especially printed pp.4, 9–10 and the Homeworld FAQ on pp.15–16. The local publisher-text transcription is `/tmp/dune-rules/ecaz-audit.txt`; fresh official indexed pp.9, 10 and 15 were checked against it. Direct PDF retrieval currently returns HTTP 403. Physical card faces are separately audited in [Homeworld component audit](HOMEWORLD_COMPONENT_AUDIT.md); a rulebook statement and a card-face statement are not interchangeable evidence.
## Registered World Skills occupation — 26 October 2026
Fresh World/all14 Skills starts now initialize the existing occupation benefit
state whenever the unchanged registered World/Skills admission accepts that
clean context. Original selected families, native role exclusions, Tech3+ and
Advanced Strongholds2+ remain; optional independent cards remain optional.
This introduces no new start permission. Nexus/Discovery/Sandtrout/Semuta and
other reaction contexts retain their own existing scopes.
Shared fresh state includes qualification history, occupied Collection and
percentage ledgers, native CHOAM Tupile and native Ecaz World victory. Skills
training remains original fourteen-card offers and actual eligible leader
choices. Existing normal Banker state is retained; the legacy explicit
classic occupation-Skills wrapper does not initialize occupation twice.
Native actions, printed incomes, skill posture, physical forces and private
choices continue through existing handlers. Played games receive no new stamp.
Authority is the same audited Homeworld/occupied components below and supplied
Advanced physical21/printed19 variant/Skill independence. Banker observes an
eligible payment by another faction; the trainer cannot earn from its own
payment. Its earned bank spice waits for actual Mentat, separately from current
occupied Collection, ordinary Collection and phase-token payout. This is
existing arithmetic/order, not a new combined ruling or retrospective refund.
Frozen integration passes **21 files,158/158**, types/lint/build and six native
runtime paths with18 GameTable SSR renders. Bare mixed/classic original starts
use25/13 actions,47/33 cards. All-six Basic/Advanced World Skills programmes use
257/274 actions,57/60 cards, actual Storm/free-return/token/card outcomes and
four accepted minimal-policy actions each. Every original Skill Card remains
in its physical deck, offer or trained slot.
Actual A/Ecaz/Ix all3 World/card50 sources use61/66 actions plus20 continuations:
Atreides pays Bank10 to empty Caladan, earning Ecaz Banker deferred1. Ecaz then
pays1 for sole Caladan entry. Actual occupied Collection2, Heighliners1 and
Advanced ordinary Arrakeen2 retain distinct receipts; Banker1 waits until actual
Mentat, followed by earned first end-Mentat cards. No played-state skill, card,
army, wallet, qualification, phase, owner or receipt is installed. Related old
Banker/Tleilaxu fixtures now follow current Advanced Revival actors/beneficiaries
and original lobby-circle sequencing without changing economic assertions.
Report/source manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-world-skills-occupation-proof-v3/`;
journeys/HTML: adjacent `20261026-world-skills-occupation-runtime/`.
No saved-room/D1/server, reset/restart, deployment or separate assurance campaign.
SSR/public actions do not certify browser/mobile/deployed acceptance, wider
module combinations or complete modes; those remain open.
## Clean Homeworlds and phase modules — 26 October 2026
Fresh clean Homeworld starts now use registered classic or selected-family
+28
View File
@@ -1,5 +1,33 @@
# Dune implementation status
## 26 October 2026 — Registered World Skills occupation benefits
**Prototyped / Partial.** Existing clean registered
World/all14 starts now include original occupation state beside normal Banker,
without changing admission, roles, phase-module limits or played games. Native
Skill offers/trainers and qualification/income handlers remain original.
Disjoint permission, public training/control and native income sources feed
root integration: **21 files,158/158**, types/lint/build and **six native runtime
paths**,18 GameTable SSR renders. Bare mixed/classic starts use25/13 actions,
47/33 cards; all-six Basic/Advanced use257/274 actions,57/60 cards and four legal
minimal-policy actions each. Native Storm losses, free returns, token income,
earned cards and original14 Skill custody remain.
A/Ecaz/Ix World/card50 sources use61/66 actual actions plus20 continuations.
Atreides Bank10 shipment empties Caladan and earns Ecaz deferred Banker1; Ecaz
Bank1 entry earns sole occupation. Actual occupied Collection2, Heighliners1,
Advanced ordinary Arrakeen2, Mentat Banker1 and first end-Mentat cards retain
separate receipts. No played-state card, force, wallet, qualification, phase,
owner or receipt is installed. Feedback repairs old Banker/Tleilaxu Advanced
Revival drivers and original lobby-circle timing; economics/custody assertions
remain. Frozen report/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-world-skills-occupation-proof-v3/`;
journeys/HTML: adjacent `20261026-world-skills-occupation-runtime/`.
No saved-game/D1/server/reset/restart/deployment or separate assurance campaign.
Browser/mobile/deployed, wider combinations and complete-mode acceptance remain.
[Source and contract](HOMEWORLD_RULES.md#registered-world-skills-occupation--26-october-2026).
## 26 October 2026 — Clean Homeworld occupation and phase modules
**Prototyped / Partial.** Clean registered classic/
+10
View File
@@ -281,6 +281,16 @@ no browser errors/root overflow. Local working-tree evidence is not deployed
revision-matched, full-mode, calibration or deferred assurance acceptance.
## Registered World Skills occupation — 26 October 2026
The existing clean registered World/all14 starts now initialize mandatory
occupation benefit state alongside native locations and normal Banker state.
Roster, role, ordinary Skill offers and original phase-module limits remain;
no new admission or played-game conversion is introduced. Other registered
module contexts keep their separate scopes. The [World source and income
contract](HOMEWORLD_RULES.md#registered-world-skills-occupation--26-october-2026)
distinguishes occupied Collection, other-payer Banker at Mentat and token payout.
## World Skills and selected decks — 17 October 2026
Fresh ordinary all14 setup now validates selected ordinary decks and actual
+1
View File
@@ -1483,6 +1483,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| 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) |
| Registered World Skills / occupation benefits | Existing registered clean World/all14 admission now initializes original occupation benefits alongside ordinary Skill/Banker state, with optional independent cards and original phase modules. No new roster/role permission or numeric policy. Real other-payer Bank payment earns deferred Banker; occupied Collection, ordinary Collection, token payout and actual Mentat settlement stay distinct. Preserve original native flags, physical offers/leaders and private choices; Nexus/Discovery/Semuta and played-game contexts remain separate. | [Current source and contract](HOMEWORLD_RULES.md#registered-world-skills-occupation--26-october-2026) |
| Clean Homeworlds / original phase modules | Audited printed World/occupied faces and supplied Advanced variant independence compose registered classic/no-deck and selected-family paired/mixed/all-three rosters with optional independent cards, Tech3–6 and Advanced Strongholds2–6. Fresh setup includes original occupation bank/percentage ledgers, native CHOAM leases and Ecaz World victory; module locations alone are insufficient. Native qualification/income/auction/Duke/tariff and existing Advanced cutovers remain. No new numeric policy, played-game stamp/retrofit or Skills/Nexus/Discovery/Semuta expansion. | [Current source and scope](HOMEWORLD_RULES.md#clean-homeworlds-and-phase-modules--26-october-2026) |
| Selected independent cards / optional phase modules | Printed E3 variant independence and supplied Advanced physical21/printed19 compose clean registered selected-deck paired/mixed/all-three families and classic-only unused decks. Basic mixed Ecaz admits bare cards without incidental Tech; optional original Tech3–6 Basic/Advanced and Strongholds2–6 Advanced retain real setup/owners/claims. Existing Recruits current rates, separate Tleilaxu bank income, delayed Axlotl, normal-first reserve fee, owner-only coalition Harass and selected-winner Tech remain. Advanced native Assassinate uses the existing captured-leader contract; no new numerical ruling, bare mixed World/Skills/Nexus/Discovery/Semuta permission or played-game conversion. | [Current source and scope](ECAZ_TREACHERY_RULES.md#selected-independent-cards-and-phase-modules--26-october-2026) |
| Independent Treachery / original phase modules | Printed independence retains original Tech3–6 Basic/Advanced and/or Strongholds2–6 Advanced with classic/E1/E2 and original standalone/paired E3 bare cards; all14 also composes registered mixed selected families and Worlds. Real first-Storm/end-Mentat owners, separate delayed income and physical funding remain. Fees/H are not revival/shipment and +2 is not supported force. Native coalition/assassination and original queue stay source-labelled. Bare mixed E3 World/phase, original Nexus/Discovery/Semuta and role/effect guards remain. | [Original phase contract](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026), [registered scope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026) |
+4 -3
View File
@@ -17,7 +17,7 @@ import { MovementClaimError, liveMovementPromises, movementEventAllowed, movemen
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, selectedFactionStrongholdProfile, selectedIndependentCardsProfile } from './faction-module-profile';
import { homeworldPhaseModulesProfile } from './homeworld-module-profile';
import { homeworldOccupationLeaderSkillsProfile, homeworldPhaseModulesProfile } from './homeworld-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';
@@ -10858,7 +10858,7 @@ export function initializeHomeworldOccupationGameForAudit(state: Game, leaderSki
'Occupation with Leader Skills requires original classic Homeworlds without other modules.');
const g = leaderSkills ? initializeLeaderSkillsGameForAudit(state)
: initializeSetupGameForAudit(state, true, false, false, false, false, false, true);
initializeHomeworldOccupationState(g);
if (!g.homeworldOccupationPreview) initializeHomeworldOccupationState(g);
if (moritaniAssassinateModeSupported(g)) initializeMoritaniAssassinateState(g);
return g;
}
@@ -12098,6 +12098,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
mixedE3 || standaloneE3NexusLeaderSkillsProfile(g) || pairedE3 || guildBetrayalNexusLeaderSkillsProfile(g), false,
independentDiscoveryProfile(g) || classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || (g.discoveryEnabled === true && classicNexus), true,
g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g), independent);
if (homeworldOccupationLeaderSkillsProfile(initialized)) initializeHomeworldOccupationState(initialized);
if (advancedMoritaniLeaderSkillsProfile(initialized) || ((independentDiscoveryProfile(initialized) || independentNexusProfile(initialized) || mixedE3 || pairedE3) && initialized.advanced &&
byFaction(initialized, 'moritani'))) initializeMoritaniAssassinateState(initialized);
if ((independentDiscoveryProfile(initialized) || independentNexusProfile(initialized) || nativeEcazLeaderSkillsProfile(initialized) || mixedE3 || homeworlds || nativeDiscoveryLeaderSkillsProfile(initialized) || pairedE3) && byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
@@ -24222,7 +24223,7 @@ function homeworldOccupiedIncomeIntegrity(g: Game) {
return;
}
requireRule(g.homeworldOccupationPreview === true && g.homeworlds &&
(homeworldPhaseModulesProfile(g) || (!g.nexusCards &&
(homeworldPhaseModulesProfile(g) || homeworldOccupationLeaderSkillsProfile(g) || (!g.nexusCards &&
(!g.leaderSkills || !g.expansions.length && homeworldLeaderSkillsProfile(g)) &&
!g.techTokens && !g.strongholdCards && !g.discoveryEnabled)) &&
Array.isArray(history) && pending !== undefined,
+24
View File
@@ -1,5 +1,6 @@
import { FACTIONS } from './catalog';
import type { FactionModuleProfile } from './faction-module-profile';
import { homeworldLeaderSkillsProfile } from './leader-skill-profile';
export type HomeworldPhaseModulesProfile = FactionModuleProfile & {
homeworldOccupationPreview?: unknown;
@@ -48,3 +49,26 @@ export function homeworldPhaseModulesProfile(game: HomeworldPhaseModulesProfile)
(!game.homeworldTupilePreview || game.homeworldOccupationPreview === true && choam) &&
(!game.homeworldEcazVictoryPreview || game.homeworldOccupationPreview === true && ecaz);
}
/** Occupation benefits for an already-admitted original World/all14 setup.
* The same unchanged library admission predicate remains authoritative; this
* only selects the clean occupation context and accepts its native stamps. */
export function homeworldOccupationLeaderSkillsProfile(game: HomeworldPhaseModulesProfile): boolean {
if (!game || typeof game !== 'object' || !game.homeworlds || !game.leaderSkills ||
typeof game.advanced !== 'boolean' || !registeredRoster(game) || !optionalFlag(game.ecazTreachery) ||
(game.techTokens && game.players.length < 3) || (game.strongholdCards && !game.advanced) ||
game.nexusCards || game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker ||
game.sandtrout || game.semutaPreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview ||
game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false;
if (![game.ecazOccupyPreview, game.moritaniAssassinatePreview, game.homeworldOccupationPreview,
game.homeworldTupilePreview, game.homeworldEcazVictoryPreview, game.spiceBankerIncomePreview].every(optionalFlag)) return false;
const ecaz = game.players.some(player => player.faction === 'ecaz');
const moritani = game.players.some(player => player.faction === 'moritani');
const choam = game.players.some(player => player.faction === 'choam');
return (!game.ecazOccupyPreview || ecaz) &&
(!game.moritaniAssassinatePreview || game.advanced && moritani) &&
(!game.homeworldTupilePreview || game.homeworldOccupationPreview === true && choam) &&
(!game.homeworldEcazVictoryPreview || game.homeworldOccupationPreview === true && ecaz) &&
homeworldLeaderSkillsProfile(game);
}
+2 -1
View File
@@ -4815,6 +4815,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Homeworlds are an optional module from Ecaz & Moritani, available under the official rules with Basic or Advanced play. They are separate locations outside Arrakis; they do not receive ordinary storm movement or count as ordinary strongholds.',
'Clean World starts admit registered classic/no-deck and selected-family paired/mixed/all-three rosters with optional independent cards, Tech3–6 and Advanced Strongholds2–6. Skills or Nexus are not incidental prerequisites. Fresh setup includes original occupation qualification, bank/percentage benefits, native Tupile and Ecaz victory state alongside physical locations; existing played games are not retrofitted. Original module-specific profiles and complete-mode gates stay separate.',
'Fresh World all14 uses actual registered seats and selected ordinary decks in Basic/Advanced, including mixed Ecaz/Moritani with E1/E2 natives. Neither Nexus, independent cards, one exact family nor a seated native from every selected deck is required. Original Tech3+/Advanced Strongholds2+ may remain. Existing no-card Nexus/Discovery and Semuta keep their separate limits. Actual hands, Ix card-before-training and native five-disc assignment remain; Auditor/shared Duke never train. Native Suk saves stay at that home; external Emperor returns retain original typed destinations. Nested locations gain no ordinary Stronghold effect or invented saved history.',
'Fresh clean registered World/all14 starts also include original occupation benefit state beside normal Banker. Admission and native role limits remain. Real other-payer Bank payments can earn a deferred skill grant; occupied Collection and phase tokens do not become immediate Banker cash. Actual Mentat settles the original skill receipt once. Nexus/Discovery/Semuta contexts retain their own scope; played games are not retrofitted.',
'Each faction uses its own card and matching world token. In Basic play the Emperor uses Kaitain alone. Advanced play adds Salusa Secundus for its Sardaukar; movement can transfer both force types between the two Emperor worlds.',
'Registered selected-family World all14 composes independent36/38/50-card setup and original Tech3+/Advanced Strongholds2+, including mixed E3. Clean bare World cards now use the registered selected-family profile with optional phase modules and original occupation state. Recruits uses current native rates/typed limits and recorded paid/free use; Banker and phase-end Axlotl remain separate. Reinforcements seals exact normal-first sources and excludes an own-World fee from dial/casualties while preserving original face/support. Suk rescues committed losses, not fees. Harass returns actual visitors through native custody and is forbidden on every own World. Original Nexus/Discovery/Semuta and role/effect guards remain.',
'Native reserves determine high or low population. Salusa counts Sardaukar, while the other cards count the faction’s reserves there. Reaching the high face’s minimum restores high population. Occupation also applies the low penalty.',
@@ -5008,7 +5009,7 @@ export const RULE_TOPICS: RuleTopic[] = [
status: 'Partial',
detail:
"Basic Tupile current-sole follow-up passes70/70 cases in10 files, types/lint/build, two original public gameplay/rendering programmes and four legal cleanup profiles. Its provisional policy and broader acceptance limits stay explicit. Earlier mixed E1/E2 and paired E3 Nexus added31 original physical programmes plus2 Loyalty-stock regressions. Final489/489 affected checks across42 files pass with types/lint. All64 original families finish258/288 six-seat games with338931 accepted/339426 attempted actions and9013 JSON across three frozen trees and preserved-prefix continuations. All30 remaining source-guard traces are read. Actual390px original Ixian offer/training, typed native and foreign-world payment/free Suboid plan and paired Ecaz foreign entry/living shared Duke preserve20 physical counters. Human Ixian loses, so no winning Suk rescue is claimed. Earlier qualified evidence remains canonical; full rules, assurance, calibration and deployed acceptance remain open. Current Junction follow-up passes131/131 affected cases and14 runtime/policy paths (11 original/3 controlled), with four actual original-action390px journeys. No saved/deployed acceptance is claimed.",
evidence: ['tests/homeworld-module-profile.test.ts', 'tests/homeworld-phase-modules-runtime.test.ts', 'tests/homeworld-phase-modules-controls.test.tsx', 'tests/homeworld-phase-occupation-income.test.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/homeworld-classic-skills-runtime.test.ts',
evidence: ['tests/homeworld-occupation-skills-profile.test.ts', 'tests/world-skills-occupation-runtime.test.ts', 'tests/world-skills-occupation-controls.test.tsx', 'tests/world-skills-occupation-income.test.ts', 'tests/homeworld-module-profile.test.ts', 'tests/homeworld-phase-modules-runtime.test.ts', 'tests/homeworld-phase-modules-controls.test.tsx', 'tests/homeworld-phase-occupation-income.test.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/homeworld-classic-skills-runtime.test.ts',
'tests/homeworld-classic-discovery-runtime.test.ts',
'tests/homeworld-skills-discovery-runtime.test.ts',
'tests/homeworld-optional-modules-runtime.test.ts',
+6 -5
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeHomeworldOccupationGameForAudit, joinGame, newPlayer,
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { territory } from '../game/board';
@@ -189,9 +189,10 @@ export function withHomeworldBankerEntropy<T>(run: () => T): T {
}
}
/** Fresh classic Advanced Homeworld admission through the real Banker
* initializer. Only the original all-fourteen skill shuffle is controlled; the
* decks, hands, offers, traitors and native custody stay original. */
/** Fresh classic Advanced Homeworld admission through the ordinary Skills
* initializer, which also creates mandatory original occupation state. Only the
* original all-fourteen skill shuffle is controlled; decks, hands, offers,
* traitors and native custody stay original, with no occupation retrofit. */
function admitHomeworldBankerGame(): Game {
let game = createGame('HOMEWORLDBANKER', newPlayer(SEATS[0], 'Banker', SEATS[0]), true);
for (const faction of SEATS.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
@@ -200,7 +201,7 @@ function admitHomeworldBankerGame(): Game {
const canonical = LEADER_SKILL_CARDS.map(card => card.id);
const rest = canonical.filter(id => !REQUESTED_SKILLS.includes(id));
const desired = REQUESTED_SKILLS.flatMap(id => [id, rest.shift()!]).concat(rest);
return originalLottery(permutationDraws(canonical, desired), () => initializeHomeworldOccupationGameForAudit(game, true));
return originalLottery(permutationDraws(canonical, desired), () => initializeLeaderSkillsGameForAudit(game));
}
/** Genuine setup selections: each requested skill is chosen from its real
+3 -3
View File
@@ -7,7 +7,7 @@ import { quoteStableHomeworldOccupation } from '../game/homeworld-stable-occupat
import {
createMixedPhaseModulesFixture, actMixedPhaseModules, mixedPhaseModulesClean,
mixedPhaseModulesInventory, mixedPhaseModulesPlayer, nextMixedPhaseModules,
type MixedPhaseModulesStep, type MixedPhaseModulesTransition,
type MixedPhaseModulesOptions, type MixedPhaseModulesStep, type MixedPhaseModulesTransition,
} from './fixture-mixed-phase-modules';
export type HomeworldPhaseOccupationStep = MixedPhaseModulesStep;
@@ -48,12 +48,12 @@ export function finishHomeworldPhaseOccupation(game: Game, actions: HomeworldPha
* original scalar and unopened Spice lotteries are controlled. No card, army,
* wallet, World owner, qualification, income ledger or clock is installed.
* Stop at the real final Movement actor before automatic Collection credit. */
export function createHomeworldPhaseOccupationFixture(options: { advanced?: boolean } = {}) {
export function createHomeworldPhaseOccupationFixture(options: { advanced?: boolean } & Pick<MixedPhaseModulesOptions, 'skills' | 'training'> = {}) {
const advanced = options.advanced ?? false, native = 'atreides', owner = 'ecaz', techOwner = 'ixians';
const world = 'homeworld:atreides', card = 'caladan' as const;
const source = createMixedPhaseModulesFixture({ factions: [native, owner, techOwner],
expansions: ['ix', 'choam', 'ecaz'], advanced, homeworlds: true,
tech: true, strongholds: advanced, independent: true });
tech: true, strongholds: advanced, independent: true, skills: options.skills, training: options.training });
let game = source.game;
const actions = [...source.actions];
const act = (actor: string, action: Action) => { game = actHomeworldPhaseOccupation(game, actor, action, actions); };
+65 -5
View File
@@ -1,10 +1,12 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { TERRITORIES, MOBILE_STRONGHOLD, location, territory } from '../game/board';
import type { FactionId } from '../game/catalog';
import { withNativeShipmentSources } from '../game/homeworld-options';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { actAdvisorPreparation } from './fixture-advisor-preparation';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import { skillOfferRolls } from './fixture-independent-card-modules';
export type MixedPhaseModulesStep = { actor: string; action: Action };
export type MixedPhaseModulesTransition = { before: Game; steps: MixedPhaseModulesStep[]; after: Game };
@@ -16,6 +18,8 @@ export type MixedPhaseModulesOptions = {
strongholds?: boolean;
independent?: boolean;
homeworlds?: boolean;
skills?: boolean;
training?: Partial<Record<FactionId, LeaderSkillId>>;
};
export const MIXED_PHASE_NATIVE_FACTIONS = ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'] as const;
export const actMixedPhaseModules = actAdvisorPreparation;
@@ -68,6 +72,26 @@ export function finishMixedPhaseModules(game: Game, actions: MixedPhaseModulesSt
return advanceMixedPhaseModules(game, state => (state.phase !== phase || state.turn !== turn) && mixedPhaseModulesClean(state), actions);
}
/** Choose only the original undealt skill lottery. Native UUID/byte entropy
* stays native, and later scalar draws retain the existing source policy. */
function startWithSkillOffers(game: Game, actor: string, action: Action,
training: Partial<Record<FactionId, LeaderSkillId>>, actions: MixedPhaseModulesStep[]): Game {
const rolls = skillOfferRolls(game, training), descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
else Reflect.apply(original, crypto, [array]);
return array;
};
try {
const next = applyAction(game, actor, action);
actions.push({ actor, action: structuredClone(action) }); return next;
} finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** 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. */
@@ -78,16 +102,49 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions
const initial = structuredClone(game), actions: MixedPhaseModulesStep[] = [];
const act = (actor: string, action: Action) => { game = actMixedPhaseModules(game, actor, action, actions); };
if (options.homeworlds) act(host, { type: 'homeworlds', enabled: true });
if (options.skills) act(host, { type: 'moduleOption', module: 'leaderSkills', enabled: true });
if (options.independent) act(host, { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
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' });
// Fill unspecified seats without consuming a role requested for a later
// seat. The resulting full map still selects only the original lottery.
let training: Partial<Record<FactionId, LeaderSkillId>> | undefined;
if (options.skills && options.training) {
training = {};
const requested = game.players.flatMap(player => options.training?.[player.faction] ?? []);
const chosen = new Set<LeaderSkillId>();
for (const player of game.players) {
const skill = options.training[player.faction] ?? LEADER_SKILL_CARDS.find(card =>
!requested.includes(card.id) && !chosen.has(card.id))?.id;
assert.ok(skill && !chosen.has(skill), 'Choose distinct original skill offers');
training[player.faction] = skill; chosen.add(skill);
}
}
const start: Action = options.advanced ? { type: 'start', advancedPreview: true } : { type: 'start' };
if (training) game = startWithSkillOffers(game, host, start, training, actions);
else act(host, start);
const setup = structuredClone(game); assert.equal(game.status, 'setup');
assert.ok(game.players.every(player => !player.hand.length));
if (!options.skills) assert.ok(game.players.every(player => !player.hand.length));
const originalInventory = mixedPhaseModulesInventory(game);
game = advanceMixedPhaseModules(game, state => state.status === 'playing', actions);
if (options.skills) {
for (let count = 0; game.status === 'setup' && count < 300; count++) {
if (!game.decision && !game.response && !game.phaseOpening && game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0]; assert.ok(actor);
const own = viewGame(game, actor).leaderSkills!;
const player = mixedPhaseModulesPlayer(game, actor);
const skill = training?.[player.faction] ?? own.offer?.cards.find(card => !own.unavailableSkills?.[card]);
const leader = own.eligibleLeaders[0];
assert.ok(own.offer && skill && own.offer.cards.includes(skill) && !own.unavailableSkills?.[skill] && leader,
'Choose one currently offered original skill and an eligible own leader');
act(actor, { type: 'leaderSkill', event: own.offer.event, skill, leader: leader.id });
} else {
const next = nextMixedPhaseModules(game); assert.ok(next); act(next.actor, next.action);
}
}
assert.equal(game.status, 'playing');
} else 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 => {
@@ -102,6 +159,7 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions
assert.deepEqual(mixedPhaseModulesInventory(game), originalInventory);
return { game, sourceGame: structuredClone(game), independent: options.independent ?? false,
homeworlds: options.homeworlds ?? false,
skills: options.skills ?? false,
initial, lobby, setup, afterSetup, firstStorm,
originalInventory, originalSpice, spiceOrder, actions, sourceActions: structuredClone(actions),
sourceActionCount: actions.length, staging: [] as string[] };
@@ -113,11 +171,13 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions
* entry plus original native choices, with no played-state installation. */
export function createMixedPhaseModulesProgramme(options: {
advanced?: boolean; strongholds?: boolean; independent?: boolean; homeworlds?: boolean;
skills?: boolean; training?: Partial<Record<FactionId, LeaderSkillId>>;
} = {}) {
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,
independent: options.independent, homeworlds: options.homeworlds });
independent: options.independent, homeworlds: options.homeworlds,
skills: options.skills, training: options.training });
let game = source.game;
const actions = [...source.actions];
const act = (actor: string, action: Action) => { game = actMixedPhaseModules(game, actor, action, actions); };
+37 -2
View File
@@ -12,7 +12,7 @@ import {quoteSpiceCollection} from '../game/board-resolution-quote';
import {leaderSkillStrongholdCount} from '../game/leader-skill-battle-board';
import type {Card} from '../game/cards';
export type SpiceBankerIncomeFixtureOptions = {initial?:Game;advanced?:boolean;kind?:BankerIncomeSourceKind|'cross-shipment'|'return-shipment';ownerIsPayer?:boolean;payerFaction?:FactionId;bankerFaction?:FactionId;stageKarama?:boolean;stageIncomeCounter?:boolean;amount?:number;allySplit?:number;emperor?:boolean;guild?:boolean;seatIds?:string[];trainerSelected?:boolean;trainerDies?:boolean;trainerHidden?:boolean};
export type SpiceBankerIncomeFixtureOptions = {initial?:Game;advanced?:boolean;kind?:BankerIncomeSourceKind|'cross-shipment'|'return-shipment';ownerIsPayer?:boolean;payerFaction?:FactionId;bankerFaction?:FactionId;revivalBeforeBanker?:FactionId;stageKarama?:boolean;stageIncomeCounter?:boolean;amount?:number;allySplit?:number;emperor?:boolean;guild?:boolean;seatIds?:string[];trainerSelected?:boolean;trainerDies?:boolean;trainerHidden?:boolean};
export type SpiceBankerIncomeNativeStep = {actor:string;action:Action};
export type SpiceBankerIncomeFixture = {game:Game;beforePayment:Game;actor:string;payer:string;paymentAction:Action;owner:string;leader:string;event:string;kind:NonNullable<SpiceBankerIncomeFixtureOptions['kind']>;amount:number;bankLegs:{payer:string;amount:number}[]};
@@ -37,6 +37,10 @@ export function nextSpiceBankerIncomeNativeStep(game:Game):SpiceBankerIncomeNati
const voter=viewGame(game,game.host).battle!.traitorVoters.find(id=>game.battle!.traitorCalls[id]===undefined);
if(voter) return {actor:voter,action:{type:'traitorCall',call:false}};
}
if(game.phase===4&&game.revivalOrder) {
const actor=game.revivalOrder.remaining[0];
if(actor) return {actor,action:{type:'ready'}};
}
if([1,2,4,7,8].includes(game.phase)||(game.phase===0&&game.stormPending!==null)) {
const seat=game.players.find(p=>!game.ready.includes(p.id));
if(seat) return {actor:seat.id,action:{type:'ready'}};
@@ -101,6 +105,21 @@ function phase(game:Game,target:number):Game {
assert.equal(game.phase,target);return game;
}
/** Finish preceding native opportunities through their real Ready actions.
* Basic and an already-open unstamped Revival retain concurrent timing. */
export function awaitSpiceBankerIncomeRevivalActor(state:Game,actor:string):Game {
let game=state;
assert.equal(game.phase,4);
for(let i=0;game.revivalOrder&&i<200;i++) {
assert.ok(game.revivalOrder.remaining.includes(actor),'The requested native Revival opportunity must still be pending.');
if(game.phaseOpening||game.response||game.decision) game=step(game);
else if(game.revivalOrder.remaining[0]===actor) return game;
else game=applyAction(game,game.revivalOrder.remaining[0],{type:'ready'});
}
assert.ok(!game.revivalOrder,'The native Revival actor must be reached before continuing.');
return game;
}
/** Reuse real custody first; any explicit staging transfers the canonical card
* out of the remaining deck, without duplicating or redealing starting hands. */
function stageHeldCard(game:Game,player:Game['players'][number],matches:(card:Card)=>boolean):Card {
@@ -170,6 +189,17 @@ export function createSpiceBankerIncomeFixture(options:SpiceBankerIncomeFixtureO
const opponentFaction=bankerFaction==='harkonnen'?'atreides':'harkonnen';
joinGame(game,newPlayer(ids[1],'Opponent',opponentFaction));
}
if(options.revivalBeforeBanker) {
assert.ok(game.advanced);
const preceding=game.players.find(p=>p.faction===options.revivalBeforeBanker)!;
const banker=game.players.find(p=>p.faction===bankerFaction)!;
assert.ok(preceding&&banker&&preceding.id!==banker.id);
// First Storm0 ends at1; the native next card1..6 ends at2..7.
// Original printed circles14/17 keep the future skilled payer before
// the original Banker without reopening an already completed turn.
game=applyAction(game,preceding.id,{type:'seatPosition',position:5});
game=applyAction(game,banker.id,{type:'seatPosition',position:6});
}
}
game=setup(game);
const assignment=game.leaderSkills!.assignments.find(a=>a.skill==='spice-banker')!;
@@ -220,7 +250,11 @@ export function createSpiceBankerIncomeFixture(options:SpiceBankerIncomeFixtureO
game=stageSpiceBankerIncomeTrainerBattle(game,{trainerOwner:actor,death:true,kwisatz:kind==='kh-revival'});
game=advanceSpiceBankerIncomeToMentat(game);
}
game=phase(game,4);const payer=game.players.find(p=>p.id===actor)!;
game=phase(game,4);
const beneficiary=kind==='emperor-extra-revival'
?game.players.find(p=>p.faction!=='emperor'&&p.id!==owner)!.id:actor;
game=awaitSpiceBankerIncomeRevivalActor(game,beneficiary);
const payer=game.players.find(p=>p.id===actor)!;
if(kind==='force-revival') {
payer.reserves-=3;payer.tanks+=3;paymentAction={type:'revive',amount:3};amount=forceRevivalQuote(game,payer,3).cost;
} else if(kind==='leader-revival') {
@@ -378,6 +412,7 @@ export function createSpiceBankerIncomeReassignmentFixture(state:Game):SpiceBank
let game=stageSpiceBankerIncomeTrainerBattle(state,{death:true,reassignToOpponent:true});
game=advanceSpiceBankerIncomeToMentat(game);
game=phase(game,4);
game=awaitSpiceBankerIncomeRevivalActor(game,actor);
const trainer=game.players.find(p=>p.id===actor)!.leaders.find(l=>l.id===leader)!;
assert.ok(trainer.dead&&game.players.find(p=>p.id===actor)!.spice>=trainer.strength);
assert.ok(game.leaderSkills!.deck.slice(0,2).includes('spice-banker'),'The actual singleton return shuffle must supply the native two-card offer.');
+8 -2
View File
@@ -83,13 +83,19 @@ export function advanceTleilaxuSkillsTechToPhase(state: Game, target: number): G
assert.ok(game.phase >= target && !game.phaseOpening && !game.response && !game.decision);
return game;
}
function setup(options: TleilaxuSkillsTechOptions) {
function setup(options: TleilaxuSkillsTechOptions & { revivalTleilaxuFirst?: boolean }) {
const faction = options.winner ?? 'guild';
let initial = options.initial ? structuredClone(options.initial)
: createGame('TLEISKILLTECH', newPlayer(faction, faction, faction), options.advanced ?? true, ['ix']);
if (!options.initial) {
joinGame(initial, newPlayer('tleilaxu', 'Tleilaxu', 'tleilaxu'));
joinGame(initial, newPlayer('emperor', 'Emperor', 'emperor'));
if (options.revivalTleilaxuFirst && initial.advanced) {
// Original lobby circles keep the native Tleilaxu opportunity before
// Guild, allowing both free returns without reopening a completed turn.
initial = applyAction(initial, 'tleilaxu', { type: 'seatPosition', position: 5 });
initial = applyAction(initial, faction, { type: 'seatPosition', position: 6 });
}
}
assert.equal(initial.status, 'lobby');
assert.ok(initial.players.length >= 3 && initial.players.length <= 6);
@@ -213,7 +219,7 @@ export type TleilaxuSkillsTechRevivalFixture = {
* untrained original discs' dead states are controlled, never funding. The
* native dead disc makes genuine room in Tleilaxu's five-leader active pool. */
export function createTleilaxuSkillsTechRevivalFixture(options: Pick<TleilaxuSkillsTechOptions, 'initial' | 'advanced'> = {}): TleilaxuSkillsTechRevivalFixture {
const { game: started } = setup({ ...options, winner: 'guild' });
const { game: started } = setup({ ...options, winner: 'guild', revivalTleilaxuFirst: true });
const game = advanceTleilaxuSkillsTechToPhase(started, 4);
const tleilaxu = advancedNativePlayer(game, 'tleilaxu').id;
const other = advancedNativePlayer(game, 'guild').id;
+69
View File
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import { viewGame, type Game } from '../game/engine';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import {
actHomeworldPhaseOccupation, advanceHomeworldPhaseOccupation, completeHomeworldPhaseOccupation,
createHomeworldPhaseOccupationFixture, finishHomeworldPhaseOccupation,
homeworldPhaseOccupationClean, homeworldPhaseOccupationInventory, homeworldPhaseOccupationPlayer,
} from './fixture-homeworld-phase-occupation';
/** Banker observes another payer: Ecaz earns from Atreides' actual Bank10.
* Atreides cannot earn its own shipment payment through its own Banker. */
export const WORLD_SKILLS_OCCUPATION_TRAINING = {
atreides: 'warmaster', ecaz: 'spice-banker', ixians: 'mentat',
} as const;
export const actWorldSkillsOccupation = actHomeworldPhaseOccupation;
export const advanceWorldSkillsOccupation = advanceHomeworldPhaseOccupation;
export const finishWorldSkillsOccupation = finishHomeworldPhaseOccupation;
export const worldSkillsOccupationClean = homeworldPhaseOccupationClean;
export const worldSkillsOccupationInventory = homeworldPhaseOccupationInventory;
export const worldSkillsOccupationPlayer = homeworldPhaseOccupationPlayer;
export function worldSkillsOccupationSkillInventory(game: Game) {
const skills = game.leaderSkills; assert.ok(skills);
const cards = [...skills.deck, ...skills.assignments.map(assignment => assignment.skill),
...Object.values(skills.offers).flatMap(offer => offer.cards)].sort();
assert.equal(new Set(cards).size, cards.length, 'Each original Skill Card retains one physical slot');
assert.deepEqual(cards, LEADER_SKILL_CARDS.map(card => card.id).sort()); return cards;
}
export function worldSkillsOccupationDeferred(game: Game, owner = 'ecaz') {
return viewGame(game, owner).spiceBankerIncome?.deferred.find(credit => credit.owner === owner)?.amount ?? 0;
}
/** Entirely native shared Caladan programme with original all14 offers. Only
* original unopened skill/deck/scalar lotteries are selected; actual public
* leaderSkill actions choose living native discs. There is no installed skill,
* played card, army, wallet, occupation event or income receipt. The source
* stops before the existing final Ix End Movement callback. */
export function createWorldSkillsOccupationFixture(options: { advanced?: boolean } = {}) {
const source = createHomeworldPhaseOccupationFixture({ advanced: options.advanced ?? false,
skills: true, training: WORLD_SKILLS_OCCUPATION_TRAINING });
const banker = source.owner;
const originalSkills = worldSkillsOccupationSkillInventory(source.setup);
worldSkillsOccupationSkillInventory(source.game);
const trainingActions = source.sourceActions.filter(step => step.action.type === 'leaderSkill');
assert.equal(trainingActions.length, 3);
for (const [actor, skill] of Object.entries(WORLD_SKILLS_OCCUPATION_TRAINING)) {
const assignment = source.afterSetup.leaderSkills!.assignments.find(assignment => assignment.owner === actor);
assert.ok(assignment); assert.equal(assignment.skill, skill);
assert.ok(worldSkillsOccupationPlayer(source.afterSetup, actor).leaders.some(leader => leader.id === assignment.leader && !leader.dead));
assert.ok(trainingActions.some(step => step.actor === actor && step.action.skill === skill && step.action.leader === assignment.leader));
}
const bankerSource = source.evacuation.after.spiceBankerIncome?.sources.find(receipt =>
receipt.source.kind === 'shipment' && receipt.source.bankLegs.some(leg => leg.payer === source.native && leg.amount === 10));
assert.ok(bankerSource?.grant);
assert.equal(bankerSource.grant.owner, banker); assert.equal(bankerSource.grant.amount, 1);
assert.equal(worldSkillsOccupationDeferred(source.game, banker), 1);
return { ...source, banker, training: WORLD_SKILLS_OCCUPATION_TRAINING, trainingActions,
originalSkills, bankerSource: structuredClone(bankerSource), staging: [] as string[],
sourceDisclosure: 'Original all14 offer lottery is selected before the public Start. Three actual leaderSkill choices train native A Warmaster, Ecaz Banker and Ix Mentat; the shared programme earns both paid shipments and Caladan sole occupation.' };
}
export type WorldSkillsOccupationFixture = ReturnType<typeof createWorldSkillsOccupationFixture>;
export function completeWorldSkillsOccupation(f: WorldSkillsOccupationFixture, state = f.game) {
const result = completeHomeworldPhaseOccupation(f, state);
const bankerCollection = result.game.spiceBankerIncome?.collections.find(collection =>
collection.turn === f.game.turn && collection.grants.includes(f.bankerSource.cursor));
assert.ok(bankerCollection, 'The original Mentat event collects the earned Banker receipt');
worldSkillsOccupationSkillInventory(result.game);
return { ...result, bankerCollection: structuredClone(bankerCollection) };
}
+5
View File
@@ -59,6 +59,11 @@ void test('fresh classic Homeworld Banker admission keeps the original board, fo
const f = createHomeworldBankerFixture();
assert.equal(f.initial.spiceBankerIncomePreview, true);
assert.equal(f.initial.homeworldOccupationPreview, true);
assert.equal(f.initial.pendingHomeworldOccupiedIncome, null);
assert.deepEqual(f.initial.homeworldOccupiedIncomeHistory, []);
assert.ok(f.initial.homeworldOccupiedPercentageLedger,
'Ordinary fresh World Skills admission supplies its original occupation ledger.');
assert.deepEqual(f.initial.homeworldOccupiedPercentageLedger.receipts, []);
assert.ok(f.initial.spiceBankerIncome, 'The fresh Homeworld Banker game owns the real income ledger.');
assert.equal(f.initial.status, 'setup');
assert.equal(f.setup.status, 'playing');
@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
homeworldOccupationLeaderSkillsProfile as supported,
homeworldPhaseModulesProfile as untrained,
type HomeworldPhaseModulesProfile,
} from '../game/homeworld-module-profile';
import { homeworldLeaderSkillsProfile as admitted } from '../game/leader-skill-profile';
import { createTechTokens } from '../game/tech-tokens';
import { createStrongholdCards } from '../game/stronghold-cards';
function profile(factions: readonly string[] = ['atreides', 'emperor', 'guild'], expansions: readonly string[] = [],
extra: Partial<HomeworldPhaseModulesProfile> = {}): HomeworldPhaseModulesProfile {
return { advanced: false, expansions, players: factions.map(faction => ({ faction })),
homeworlds: { custody: null }, leaderSkills: true, ...extra };
}
void test('original classic World/all14 admission gains clean occupation benefits with its optional phase modules', () => {
for (const game of [profile(), profile(undefined, undefined, { advanced: true }),
profile(undefined, undefined, { techTokens: createTechTokens(), ecazTreachery: true }),
profile(undefined, undefined, { advanced: true, techTokens: createTechTokens(), strongholdCards: createStrongholdCards() }),
]) {
assert.equal(admitted(game), true); assert.equal(supported(game), true);
assert.equal(untrained(game), false, 'The original untrained World predicate still rejects Skills');
}
});
void test('the unchanged selected-family all14 predicate remains the source of native roster and role admission', () => {
for (const game of [
profile(['ecaz', 'ixians', 'emperor'], ['ecaz', 'ix']),
profile(['ecaz', 'moritani', 'choam'], ['ecaz', 'choam'], { advanced: true }),
profile(['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'], ['ix', 'choam', 'ecaz'],
{ advanced: true, ecazTreachery: true, techTokens: createTechTokens(), strongholdCards: createStrongholdCards() }),
profile(['atreides', 'emperor', 'guild'], ['ix', 'choam', 'ecaz']),
]) {
assert.equal(admitted(game), true); assert.equal(supported(game), true);
}
for (const game of [
profile(['ecaz', 'harkonnen', 'guild'], ['ecaz'], { advanced: true }),
profile(['moritani', 'harkonnen', 'guild'], ['ecaz'], { advanced: true }),
]) {
assert.equal(admitted(game), false); assert.equal(supported(game), false);
}
});
void test('required modules, physical seat bounds and existing phase limits do not change with benefit initialization', () => {
assert.equal(supported(profile(undefined, undefined, { homeworlds: null })), false);
assert.equal(supported(profile(undefined, undefined, { leaderSkills: null })), false);
const two = profile(['atreides', 'emperor']);
assert.equal(supported(two), true);
assert.equal(supported({ ...two, techTokens: createTechTokens() }), false);
assert.equal(supported({ ...two, strongholdCards: createStrongholdCards() }), false);
assert.equal(supported({ ...two, advanced: true, strongholdCards: createStrongholdCards() }), true);
for (const game of [profile(['atreides']), profile(['atreides', 'atreides']),
profile(['ecaz', 'guild', 'emperor']), profile(undefined, ['ix', 'ix']),
profile(['atreides', 'emperor', 'guild', 'fremen', 'beneGesserit', 'harkonnen', 'ecaz'], ['ecaz']),
]) assert.equal(supported(game), false);
});
void test('occupation benefit context excludes Nexus, Discovery, Sandtrout and unrelated reaction selectors without changing library admission', () => {
for (const field of ['nexusCards', 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker',
'sandtrout', 'semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview', 'guildBetrayalPreview',
'richeseBetrayalPreview', 'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview', 'nexusHarkonnenBetrayalPreview',
] as const) assert.equal(supported({ ...profile(), [field]: true }), false, field);
const nexus = profile(undefined, undefined, { nexusCards: { cards: null, phase: null } });
assert.equal(admitted(nexus), true, 'Existing Nexus/World/all14 library entry stays independently admitted');
assert.equal(supported(nexus), false, 'This clean benefit slice does not claim that separate combination');
});
void test('canonical Banker and native occupation stamps require their actual parent factions and retain the Advanced assassination band', () => {
const native = profile(['ecaz', 'moritani', 'choam'], ['ecaz', 'choam'], { advanced: true,
homeworldOccupationPreview: true, homeworldTupilePreview: true, homeworldEcazVictoryPreview: true,
ecazOccupyPreview: true, moritaniAssassinatePreview: true, spiceBankerIncomePreview: true });
assert.equal(admitted(native), true); assert.equal(supported(native), true);
assert.equal(supported(profile(undefined, undefined, { spiceBankerIncomePreview: true })), true);
for (const game of [
{ ...native, homeworldOccupationPreview: undefined }, { ...native, advanced: false },
profile(undefined, undefined, { ecazOccupyPreview: true }),
profile(undefined, undefined, { advanced: true, moritaniAssassinatePreview: true }),
profile(undefined, undefined, { homeworldOccupationPreview: true, homeworldTupilePreview: true }),
profile(undefined, undefined, { homeworldOccupationPreview: true, homeworldEcazVictoryPreview: true }),
]) assert.equal(supported(game), false);
for (const field of ['spiceBankerIncomePreview', 'ecazOccupyPreview', 'moritaniAssassinatePreview',
'homeworldOccupationPreview', 'homeworldTupilePreview', 'homeworldEcazVictoryPreview'] as const)
assert.equal(supported({ ...native, [field]: 'forged' }), false, field);
});
void test('fresh offers and live occupied posture remain configuration-neutral rather than a new deal or ownership predicate', () => {
const skills = { deck: ['sandmaster'], offers: {}, assignments: [
{ owner: 'seat-a', leader: 'disc-a', skill: 'spice-banker', faceUp: false },
] };
const custody = { visitors: { 'homeworld:atreides': { 'seat-e': { normal: 1, elite: 1 } } },
salusa: { normal: 0, elite: 4 } };
const game = profile(undefined, undefined, { advanced: true, leaderSkills: skills,
homeworlds: { custody, historyVersion: 1 }, homeworldOccupationPreview: true,
spiceBankerIncomePreview: true, techTokens: createTechTokens(), strongholdCards: createStrongholdCards() });
const before = structuredClone(game); Object.freeze(game.players); Object.freeze(game);
assert.equal(admitted(game), true); assert.equal(supported(game), true); assert.deepEqual(game, before);
assert.equal(game.leaderSkills, skills); assert.equal((game.homeworlds as { custody: unknown }).custody, custody);
});
void test('the previous untrained helper retains its behavior and malformed configuration never widens the all14 route', () => {
const plain = profile(undefined, undefined, { leaderSkills: undefined });
assert.equal(untrained(plain), true); assert.equal(supported(plain), false);
assert.equal(untrained(profile()), false); assert.equal(supported(profile()), true);
assert.equal(untrained(profile(undefined, undefined, { leaderSkills: undefined, spiceBankerIncomePreview: true })), false);
for (const game of [profile(undefined, undefined, { ecazTreachery: 'true' }),
profile(undefined, undefined, { advanced: 'advanced' as unknown as boolean })]) assert.equal(supported(game), false);
for (const value of [null, {}, { ...profile(), expansions: 'ix' }, { ...profile(), players: [{ faction: 'atreides' }, null] }])
assert.equal(supported(value as unknown as HomeworldPhaseModulesProfile), false);
});
+11 -2
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {applyAction,viewGame,type Game} from '../game/engine';
import {advanceSpiceBankerIncomeToMentat,createSpiceBankerIncomeFixture,createSpiceBankerIncomeReassignmentFixture,nextSpiceBankerIncomeNativeStep,quoteSpiceBankerIncomeBattleEconomics,stageSpiceBankerIncomeTrainerBattle,type SpiceBankerIncomeFixture,type SpiceBankerIncomeFixtureOptions} from './fixture-spice-banker-income';
import {advanceSpiceBankerIncomeToMentat,awaitSpiceBankerIncomeRevivalActor,createSpiceBankerIncomeFixture,createSpiceBankerIncomeReassignmentFixture,nextSpiceBankerIncomeNativeStep,quoteSpiceBankerIncomeBattleEconomics,stageSpiceBankerIncomeTrainerBattle,type SpiceBankerIncomeFixture,type SpiceBankerIncomeFixtureOptions} from './fixture-spice-banker-income';
import {forceRevivalQuote} from '../game/revival';
function pay(fixture:SpiceBankerIncomeFixture,action=fixture.paymentAction):Game {
@@ -49,6 +49,10 @@ for(const kind of ['shipment','force-revival','leader-revival','emperor-extra-re
void test(`actual ${kind} original native cost and effect commit once with deferred normal Banker income`,()=>{
const fixture=createSpiceBankerIncomeFixture({kind,advanced:true,...(kind==='force-revival'?{payerFaction:'emperor' as const}:{})});
const payerBefore=fixture.game.players.find(p=>p.id===fixture.payer)!;
if(fixture.game.phase===4) {
assert.ok(fixture.game.revivalOrder);
assert.equal(fixture.game.revivalOrder.remaining[0],kind==='emperor-extra-revival'?payerBefore.ally:fixture.payer);
}
const balance=payerBefore.spice,forces=payerBefore.reserves,tanks=payerBefore.tanks;
if(kind==='leader-revival') {
assert.ok(payerBefore.leaders.every(l=>l.dead));
@@ -282,8 +286,10 @@ void test('deferred automatic spice cannot fund a bid before actual Mentat colle
assert.equal(JSON.stringify(game),before);
});
void test('a real singleton skill return and different-faction native revival retain old owner custody but use the new eligible owner',()=>{
const fixture=createSpiceBankerIncomeFixture({advanced:true});const paid=pay(fixture);
const fixture=createSpiceBankerIncomeFixture({advanced:true,revivalBeforeBanker:'harkonnen'});const paid=pay(fixture);
const lifecycle=createSpiceBankerIncomeReassignmentFixture(paid);
assert.equal(lifecycle.game.revivalOrder!.remaining[0],lifecycle.actor);
assert.ok(lifecycle.game.revivalOrder!.remaining.includes(fixture.owner));
assert.equal(lifecycle.game.spiceBankerIncome!.sources.find(r=>r.grant)!.grant!.owner,fixture.owner);
assert.equal(lifecycle.game.spiceBankerIncome!.collections[0].credits.find(c=>c.owner===fixture.owner)!.amount,1);
let game=applyAction(lifecycle.game,lifecycle.actor,lifecycle.revivalAction);
@@ -295,6 +301,9 @@ void test('a real singleton skill return and different-faction native revival re
game=applyAction(game,lifecycle.actor,{type:'leaderSkill',event:offer.event,skill:'spice-banker',leader:lifecycle.leader});
assert.equal(game.leaderSkills!.assignments.find(a=>a.skill==='spice-banker')!.owner,lifecycle.actor);
const balance=game.players.find(p=>p.id===lifecycle.actor)!.spice;
game=awaitSpiceBankerIncomeRevivalActor(game,fixture.owner);
assert.equal(game.revivalOrder!.remaining[0],fixture.owner);
assert.ok(game.revivalOrder!.completed.includes(lifecycle.actor));
game=applyAction(game,fixture.owner,{type:'reviveLeader',leader:fixture.leader});game=nativeCounters(game);
assert.equal(deferred(game,lifecycle.actor),1);
assert.equal(deferred(game,fixture.owner),0);
+11 -3
View File
@@ -6,7 +6,7 @@ import { DIFFICULTIES } from '../game/bot-profiles';
import { validateLeaderSkills } from '../game/leader-skills';
import { quoteSukRescue } from '../game/suk-graduate';
import { ownedTech } from '../game/tech-tokens';
import { quoteSpiceBankerIncomeBattleEconomics } from './fixture-spice-banker-income';
import { awaitSpiceBankerIncomeRevivalActor, quoteSpiceBankerIncomeBattleEconomics } from './fixture-spice-banker-income';
import {
createTleilaxuSkillsTechFixture, createTleilaxuSkillsTechRevivalFixture,
advanceTleilaxuSkillsTechToPhase, settleTleilaxuSkillsTechRevival,
@@ -159,11 +159,17 @@ void test('four original native policies replace the actual winner using legal s
void test('only-Tleilaxu real free revival excludes Axlotl; another faction real free revival accrues and pays exactly its owned-token count', () => {
for (const anotherFaction of [false, true]) {
const f = createTleilaxuSkillsTechRevivalFixture();
let game = settleTleilaxuSkillsTechRevival(applyAction(f.game, f.tleilaxu, f.ownFreeAction));
let game = awaitSpiceBankerIncomeRevivalActor(f.game, f.tleilaxu);
assert.equal(game.revivalOrder!.remaining[0], f.tleilaxu);
assert.ok(game.revivalOrder!.remaining.includes(f.other));
game = settleTleilaxuSkillsTechRevival(applyAction(game, f.tleilaxu, f.ownFreeAction));
assert.equal(player(game, f.tleilaxu).tanks, 0);
assert.equal(player(game, f.tleilaxu).freeForcesRevived, 1);
assert.deepEqual(game.techTokens, f.game.techTokens, 'The native Tleilaxu-only free revival is not an Axlotl trigger.');
if (anotherFaction) {
game = awaitSpiceBankerIncomeRevivalActor(game, f.other);
assert.equal(game.revivalOrder!.remaining[0], f.other);
assert.ok(game.revivalOrder!.completed.includes(f.tleilaxu));
game = settleTleilaxuSkillsTechRevival(applyAction(game, f.other, f.otherFreeAction));
assert.equal(player(game, f.other).tanks, 0);
assert.equal(player(game, f.other).freeForcesRevived, 1);
@@ -181,7 +187,9 @@ void test('only-Tleilaxu real free revival excludes Axlotl; another faction real
void test('real native foreign Ghola revival grants no new Tleilaxu skill and preserves its living native trainer', () => {
const f = createTleilaxuSkillsTechRevivalFixture();
const assignments = structuredClone(f.game.leaderSkills!.assignments);
const after = settleTleilaxuSkillsTechRevival(applyAction(f.game, f.tleilaxu, f.foreignGholaAction));
const current = awaitSpiceBankerIncomeRevivalActor(f.game, f.tleilaxu);
assert.equal(current.revivalOrder!.remaining[0], f.tleilaxu);
const after = settleTleilaxuSkillsTechRevival(applyAction(current, f.tleilaxu, f.foreignGholaAction));
const foreign = after.players.flatMap(p => p.leaders).find(l => l.id === f.foreignLeader)!;
assert.equal(foreign.dead, false); assert.equal(foreign.gholaBy, f.tleilaxu);
assert.deepEqual(after.leaderSkills!.assignments, assignments);
@@ -0,0 +1,54 @@
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 { viewGame } 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();
// Original actions select the modules, Start and eligible training. Stateful
// React SSR confirms visible controls without claiming DOM callback execution.
void test('original World Skills controls retain selected modules and Basic/Advanced preview Start with live trained leaders', () => {
for (const advanced of [false, true]) {
const f = createMixedPhaseModulesFixture({ factions: ['atreides', 'ecaz', 'ixians'], expansions: ['ix', 'choam', 'ecaz'],
advanced, homeworlds: true, skills: true, tech: true, strongholds: advanced, independent: advanced,
training: { atreides: 'warmaster', ecaz: 'spice-banker', ixians: 'mentat' } });
const render = (game: typeof f.game) => renderToStaticMarkup(createElement(GameTable, {
game: viewGame(game, game.host), busy: false, send: async () => {}, onExit() {},
}));
const html = render(f.lobby);
const labels = [...html.matchAll(/<label\b[^>]*>([\s\S]*?)<\/label>/g)];
for (const text of ['Homeworlds · preview', 'Leader Skills · all fourteen skill cards', 'Tech tokens · optional',
...(advanced ? ['Stronghold Cards', 'Ecaz Treachery Cards'] : [])]) {
const label = labels.find(([, children]) => children.includes(text)); assert.ok(label, text);
assert.match(label[1], /<input\b[^>]*checked=""/);
}
const text = advanced ? 'Begin Advanced preview' : 'Begin game';
const button = [...html.matchAll(/<button\b([^>]*)>([\s\S]*?)<\/button>/g)]
.find(([, , children]) => children.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim() === text);
assert.ok(button); assert.doesNotMatch(button[1], /(?:^|\s)disabled(?:=""|\s|$)/);
assert.ok(f.actions.some(step => step.actor === f.initial.host && step.action.type === 'moduleOption' &&
step.action.module === 'leaderSkills' && step.action.enabled === true));
assert.ok(f.actions.some(step => step.action.type === 'start' && !!step.action.advancedPreview === advanced));
assert.equal(f.setup.homeworldOccupationPreview, true);
assert.equal(f.actions.filter(step => step.action.type === 'leaderSkill').length, 3);
const playing = render(f.game);
assert.match(playing, /Warmaster/); assert.match(playing, /Spice Banker/); assert.match(playing, /Mentat/);
if (advanced) assert.match(playing, /Inspect Stronghold Cards/);
}
});
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { TECH_TOKENS } from '../game/tech-tokens';
import {
completeWorldSkillsOccupation, createWorldSkillsOccupationFixture,
worldSkillsOccupationDeferred as deferred, worldSkillsOccupationInventory as inventory,
worldSkillsOccupationPlayer as player, worldSkillsOccupationSkillInventory as skills,
} from './fixture-world-skills-occupation';
for (const advanced of [false, true]) {
void test(`public trained ${advanced ? 'Advanced Worlds Tech Strongholds' : 'Basic Worlds Tech'} keeps Caladan income, other-payer Banker and phase rewards distinct`, () => {
const f = createWorldSkillsOccupationFixture({ advanced });
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.deepEqual(f.game.players.map(seat => seat.faction), ['atreides', 'ecaz', 'ixians']);
assert.deepEqual(f.game.expansions, ['ix', 'choam', 'ecaz']);
assert.ok(f.game.homeworlds && f.game.ecazTreachery && f.game.techTokens && f.game.leaderSkills);
assert.equal(!!f.game.strongholdCards, advanced);
assert.ok(f.game.homeworldOccupationPreview && f.game.spiceBankerIncomePreview);
assert.ok(!f.game.nexusCards && !f.game.discoveryEnabled && !f.game.semutaPreview);
assert.equal(f.originalInventory.length, 50); assert.equal(f.originalSkills.length, 14);
assert.deepEqual(inventory(f.game), f.originalInventory); assert.deepEqual(skills(f.game), f.originalSkills);
assert.equal(f.trainingActions.length, 3); assert.deepEqual(f.staging, []);
assert.equal(f.sourceActionCount, f.sourceActions.length);
for (const [actor, skill] of Object.entries(f.training)) {
const assignment = viewGame(f.game, actor).leaderSkills!.assignments.find(assignment => assignment.owner === actor);
assert.ok(assignment); assert.equal(assignment.skill, skill);
assert.equal(assignment.faceUp, true); assert.equal(assignment.captured, false);
assert.equal(assignment.controller, actor);
}
assert.deepEqual(f.evacuationSources, { 'homeworld:atreides': { normal: 10, elite: 0 } });
assert.equal(player(f.evacuation.after, f.native).reserves, 0);
assert.equal(player(f.evacuation.after, f.native).forces['arrakeen:10'], 20);
assert.equal(player(f.evacuation.before, f.native).spice - player(f.evacuation.after, f.native).spice, 10);
assert.deepEqual(f.bankerSource.source.bankLegs, [{ payer: f.native, amount: 10 }]);
assert.equal(f.bankerSource.source.phase, 5); assert.equal(f.bankerSource.source.turn, 1);
assert.equal(f.bankerSource.source.trainer?.assignment.owner, f.banker);
assert.equal(f.bankerSource.source.trainer?.faceUp, true);
assert.equal(f.bankerSource.grant?.owner, f.banker); assert.equal(f.bankerSource.grant?.amount, 1);
assert.notEqual(f.banker, f.native, 'The qualifying Bank10 belongs to another payer');
assert.equal(player(f.evacuation.after, f.banker).spice, player(f.evacuation.before, f.banker).spice,
'The mandatory normal grant waits in front of the shield');
assert.equal(deferred(f.evacuation.after, f.banker), 1); assert.equal(deferred(f.game, f.banker), 1);
assert.deepEqual(f.invasion.after.homeworlds?.custody?.visitors[f.world]?.[f.owner], { normal: 1, elite: 0 });
assert.equal(f.entitlement.qualification, f.qualification.event); assert.equal(f.entitlement.spice, 2);
assert.equal(f.game.techTokens?.heighliners.spice, 1);
assert.equal(f.game.spiceBankerIncome!.sources.filter(receipt => receipt.grant !== null).length, 1);
const before = structuredClone(f.game), originalTech = structuredClone(f.game.techTokens);
const result = completeWorldSkillsOccupation(f);
assert.deepEqual(f.game, before, 'The genuine trained source remains reusable before its final callback');
assert.deepEqual(result.collection.steps[0], { actor: f.current, action: f.action });
assert.equal(result.collection.after.phase, 7);
assert.equal(player(result.collection.after, f.owner).spice - player(result.collection.before, f.owner).spice, 2);
assert.equal(deferred(result.collection.after, f.banker), 1, 'Caladan Collection does not prematurely collect Banker');
assert.deepEqual(result.incomeReceipt, { world: f.world, status: 'settled', ownAmount: 2,
credits: [{ player: f.owner, amount: 2 }] });
assert.equal(player(result.collection.after, f.techOwner).spice - player(result.collection.before, f.techOwner).spice, 1);
assert.equal(result.collection.after.techTokens?.heighliners.spice, 0);
assert.equal(player(result.collection.after, f.native).spice - player(result.collection.before, f.native).spice, advanced ? 2 : 0,
'Ordinary Advanced Arrakeen Collection belongs to Atreides');
assert.equal(result.collection.after.spiceBankerIncome!.sources.length, before.spiceBankerIncome!.sources.length,
'Bank awards and Tech payouts are not another player Bank invoice');
assert.equal(player(result.mentat.after, f.banker).spice - player(result.mentat.before, f.banker).spice, 1);
assert.deepEqual(result.bankerCollection.credits, [{ owner: f.banker, amount: 1 }]);
assert.deepEqual(result.bankerCollection.grants, [f.bankerSource.cursor]);
assert.equal(result.bankerCollection.phase, 8); assert.equal(result.bankerCollection.turn, 1);
assert.equal(deferred(result.game, f.banker), 0);
assert.equal(result.game.spiceBankerIncome!.sources.filter(receipt => receipt.grant !== null).length, 1);
assert.equal(result.game.turn, 2); assert.equal(result.game.phase, 0);
for (const { id } of TECH_TOKENS) assert.equal(result.game.techTokens![id].owner, originalTech![id].owner);
if (advanced) {
assert.equal(result.game.strongholdCards?.claimedTurn, 1);
assert.equal(result.game.strongholdCards?.owners.arrakeen, f.native);
assert.equal(result.game.strongholdCards?.owners.hidden_mobile_stronghold, f.techOwner);
}
assert.deepEqual(inventory(result.game), f.originalInventory); assert.deepEqual(skills(result.game), f.originalSkills);
assert.deepEqual(result.game.leaderSkills?.assignments, f.game.leaderSkills!.assignments);
homeworldGameIntegrity(result.game);
});
}
@@ -0,0 +1,129 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { MOBILE_STRONGHOLD } from '../game/board';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import type { FactionId } from '../game/catalog';
import { quotePhaseResources } from '../game/phase-resource-quote';
import { forceRevivalQuote } from '../game/revival';
import { nativeShipmentSources } from '../game/homeworld-options';
import {
createMixedPhaseModulesFixture, createMixedPhaseModulesProgramme, mixedPhaseModulesInventory,
mixedPhaseModulesPlayer as player, mixedPhaseModulesClean, type MixedPhaseModulesOptions,
} from './fixture-mixed-phase-modules';
const training: Partial<Record<FactionId, LeaderSkillId>> = {
ixians: 'prana-bindu-adept', tleilaxu: 'swordmaster-of-ginaz', choam: 'killer-medic',
richese: 'warmaster', ecaz: 'master-of-assassins', moritani: 'mentat',
};
function originalSkills(game: Game) {
const state = game.leaderSkills; assert.ok(state);
const identities = [...state.deck, ...Object.values(state.offers).flatMap(offer => offer.cards),
...state.assignments.map(assignment => assignment.skill)].sort();
assert.deepEqual(identities, LEADER_SKILL_CARDS.map(card => card.id).sort());
}
function originalOccupation(game: Game) {
assert.equal(game.homeworldOccupationPreview, true);
assert.equal(game.pendingHomeworldOccupiedIncome, null);
assert.deepEqual(game.homeworldOccupiedIncomeHistory, []);
assert.ok(game.homeworldOccupiedPercentageLedger);
if (game.players.some(seat => seat.faction === 'choam')) {
assert.equal(game.homeworldTupilePreview, true); assert.ok(game.homeworldTupileState);
assert.equal(game.pendingHomeworldTupileCleanup, null);
}
if (game.players.some(seat => seat.faction === 'ecaz')) {
assert.equal(game.homeworldEcazVictoryPreview, true); assert.equal(game.ecazOccupyPreview, true);
}
}
function actualTraining(f: ReturnType<typeof createMixedPhaseModulesFixture>, requested: typeof training) {
originalSkills(f.setup); originalSkills(f.afterSetup); originalOccupation(f.setup);
const assignments = f.afterSetup.leaderSkills!.assignments;
assert.equal(assignments.length, f.afterSetup.players.length);
for (const assignment of assignments) {
const owner = player(f.afterSetup, assignment.owner);
assert.ok(owner.leaders.some(leader => leader.id === assignment.leader));
if (requested[owner.faction]) assert.equal(assignment.skill, requested[owner.faction]);
assert.ok(f.actions.some(step => step.actor === assignment.owner && step.action.type === 'leaderSkill' &&
step.action.skill === assignment.skill && step.action.leader === assignment.leader && typeof step.action.event === 'string'));
}
assert.ok(!f.game.nexusCards && !f.game.discoveryEnabled && !f.game.semutaPreview);
assert.deepEqual(mixedPhaseModulesInventory(f.game), f.originalInventory);
assert.deepEqual(f.staging, []);
}
void test('public mixed and base World Skills starts retain mandatory original occupation state and original fourteen-card offers', () => {
const cases: MixedPhaseModulesOptions[] = [
{ factions: ['ecaz', 'ixians', 'tleilaxu'], expansions: ['ix', 'choam', 'ecaz'], training: { ecaz: 'spice-banker' } },
{ factions: ['atreides', 'emperor'], expansions: [], training: { emperor: 'bureaucrat' } },
];
for (const options of cases) {
const f = createMixedPhaseModulesFixture({ ...options, homeworlds: true, skills: true });
assert.equal(f.skills, true); assert.equal(f.homeworlds, true);
const option = f.actions.findIndex(step => step.action.type === 'moduleOption' && step.action.module === 'leaderSkills');
const ready = f.actions.findIndex(step => step.action.type === 'ready');
assert.ok(option >= 0 && option < ready); assert.equal(f.actions[option].actor, f.initial.host);
assert.equal(f.lobby.lobbyOptions?.leaderSkills, true);
assert.equal(f.game.phase, 1); assert.equal(f.game.turn, 1); assert.equal(f.game.advanced, false);
assert.ok(!f.game.techTokens && !f.game.strongholdCards && !f.game.ecazTreachery);
actualTraining(f, options.training ?? {});
}
});
void test('public Advanced mixed World Skills and phase selections preserve the original requested eligible training', () => {
const f = createMixedPhaseModulesFixture({ factions: ['ixians', 'ecaz', 'moritani'], expansions: ['ix', 'ecaz'],
advanced: true, homeworlds: true, skills: true, tech: true, strongholds: true, independent: true,
training: { ixians: 'mentat', ecaz: 'spice-banker', moritani: 'warmaster' } });
actualTraining(f, { ixians: 'mentat', ecaz: 'spice-banker', moritani: 'warmaster' });
assert.equal(f.setup.ecazTreachery, true); assert.ok(f.game.techTokens && f.game.strongholdCards);
assert.equal(f.originalInventory.length, 50);
});
for (const advanced of [false, true]) void test(`public all-six ${advanced ? 'Advanced World Skills, independent cards and Strongholds' : 'Basic World Skills'} keeps earned native phase income and a real free return`, () => {
const f = createMixedPhaseModulesProgramme({ advanced, homeworlds: true, skills: true, training,
independent: advanced });
actualTraining(f, training); originalSkills(f.game);
assert.equal(f.originalInventory.length, advanced ? 60 : 57);
for (const [actor, receipt, location] of [
['choam', f.choamShipment, f.choamLocation], ['moritani', f.moritaniShipment, f.casualtyLocation],
] as const) {
const action = receipt.steps[0].action;
assert.equal(action.type, 'ship'); assert.equal(action.amount, 1);
assert.deepEqual(action.homeworldSources, nativeShipmentSources(viewGame(receipt.before, actor), 1, 0));
assert.equal(player(receipt.after, actor).forces[location], 1);
assert.equal(player(receipt.after, actor).reserves, player(receipt.before, actor).reserves - 1);
assert.equal(receipt.after.techTokens?.heighliners.spice, 1);
}
// Compare the actual current phase invoice; role-bearing profiles need not
// retain an unrelated no-Skills shipment debit assumption.
const shipping = quotePhaseResources(f.shipmentPayout.before);
assert.ok(shipping.credits.some(credit => credit.kind === 'tech' && credit.token === 'heighliners' && credit.amount === 1));
assert.equal(player(f.shipmentPayout.after, 'ixians').spice, shipping.balances.ixians);
assert.equal(f.shipmentPayout.after.techTokens?.heighliners.spice, 0);
if (advanced) {
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');
}
assert.equal(f.storm.before.stormMovementSource?.kind, advanced ? 'card' : 'dials');
assert.equal(player(f.storm.after, 'moritani').tanks, 1);
assert.equal(player(f.storm.after, 'moritani').forces[f.casualtyLocation], undefined);
const quote = forceRevivalQuote(f.revival.before, player(f.revival.before, 'moritani'), 1, 0);
assert.equal(quote.free, 1); assert.equal(quote.cost, 0);
assert.deepEqual(f.revival.steps[0], { actor: 'moritani', action: { type: 'revive', amount: 1, elite: 0 } });
assert.equal(viewGame(f.revival.before, 'moritani').revival.tleilaxuHomeworldIncomeBlocked, false);
assert.equal(player(f.revival.after, 'moritani').tanks, 0);
assert.equal(player(f.revival.after, 'moritani').reserves, player(f.revival.before, 'moritani').reserves + 1);
const revival = quotePhaseResources(f.revivalPayout.before);
assert.ok(revival.credits.some(credit => credit.kind === 'tech' && credit.token === 'axlotl' && credit.amount === 1));
assert.equal(player(f.revivalPayout.after, 'tleilaxu').spice, revival.balances.tleilaxu);
assert.equal(f.revivalPayout.after.techTokens?.axlotl.spice, 0);
assert.equal(f.game.phase, 5); assert.equal(f.game.turn, 2); assert.ok(mixedPhaseModulesClean(f.game));
if (advanced) {
const actor = f.game.active; assert.ok(actor);
const own = viewGame(f.game, actor); own.players.find(seat => seat.id === actor)!.bot = 'Easy';
const candidate = botActions(own).find(action => action.type === 'endMovement'); assert.ok(candidate);
assert.doesNotThrow(() => applyAction(f.game, actor, candidate));
}
});