Preserve World occupation capabilities while Sandtrout waits
This commit is contained in:
1 parent
09eb857583
commit
4ab20a2838
12 files changed
+403
-15
No files matched your search
@@ -16,6 +16,31 @@ open mode gates.
|
|||||||
|
|
||||||
|
|
||||||
## Current checkpoint and work
|
## Current checkpoint and work
|
||||||
|
**World occupation / live Sandtrout — 26 October. Prototyped / Partial.**
|
||||||
|
A real original Basic World/Tech draw reproduced an occupation-context rejection.
|
||||||
|
Clean World and World/all14 profiles now retain canonical pending Sandtrout as
|
||||||
|
live card state; original Nexus/Discovery contexts also reject malformed flags.
|
||||||
|
Start/role limits and printed effects remain unchanged.
|
||||||
|
|
||||||
|
Root profile/pure integration and one disjoint native lane pass **11 files,
|
||||||
|
70/70**, types/lint/build and **three native paths**,19 GameTable SSR renders.
|
||||||
|
Basic and first-turn ignored-worm sources use25+66 actions; Advanced all14/Tech/
|
||||||
|
Strongholds uses30+71. Original A/E/Ix +Ix setup retains47 Treachery,22 Spice and
|
||||||
|
optional14 Skills. Actual A2 Caladan→Broken Land costs4; waiting Sandtrout spans
|
||||||
|
Collection/Mentat/Storm, then suppresses the real next worm without killing that
|
||||||
|
army or removing its ground spice. Only immediate Hagga doubles; Advanced's
|
||||||
|
second ordinary pile remains undoubled. Ix Heighliners1, deferred E Banker1 at
|
||||||
|
Mentat and earned Strongholds stay separate. Four minimal policies each submit
|
||||||
|
a legal next action per source. No played flag/card/force/wallet/clock is installed.
|
||||||
|
|
||||||
|
The persistent waiting notice appears at the source, Collection, Mentat and next
|
||||||
|
Storm, then clears after suppression. Frozen report/manifest:
|
||||||
|
`/home/quintine/.local/share/dune/checkpoints/20261026-world-sandtrout-proof-v1/`;
|
||||||
|
journeys/HTML: adjacent `20261026-world-sandtrout-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#live-world-sandtrout--26-october-2026).
|
||||||
|
|
||||||
**Homeworld occupation / original Nexus and Discovery — 26 October. Prototyped / Partial.**
|
**Homeworld occupation / original Nexus and Discovery — 26 October. Prototyped / Partial.**
|
||||||
Shared fresh World setup now initializes mandatory occupation benefits in its
|
Shared fresh World setup now initializes mandatory occupation benefits in its
|
||||||
existing Nexus and/or Discovery envelopes, optionally Skills and phase modules.
|
existing Nexus and/or Discovery envelopes, optionally Skills and phase modules.
|
||||||
|
|||||||
+44
-2
@@ -2,6 +2,47 @@
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
## Live World Sandtrout — 26 October 2026
|
||||||
|
|
||||||
|
An actual original Sandtrout draw exposed a context error in clean World/Tech:
|
||||||
|
the pending printed Spice Card was treated as an unrelated module, causing
|
||||||
|
occupation validation to reject the draw. Clean World and World/all14 profiles
|
||||||
|
now retain their original capability while the canonical boolean card flag is
|
||||||
|
pending. Original World Nexus/Discovery profiles retain the same live behavior;
|
||||||
|
malformed non-boolean effect flags are rejected. Fresh setup still requires
|
||||||
|
unused gameplay state, so no pre-existing effect can be installed at Start.
|
||||||
|
|
||||||
|
The [existing original Spice contract](IX_PROTOTYPE.md#original-spice-cards-and-selected-policy--24-october-2026)
|
||||||
|
remains authoritative: break alliances, hold the card across turns, preserve
|
||||||
|
the provisional first-turn ignoring priority, suppress the next active worm,
|
||||||
|
and double only its immediate territory replacement. No numerical, timing,
|
||||||
|
roster, role or module selection is changed by this fix. Existing World/Skill
|
||||||
|
benefits and original minimal participation remain available while it waits.
|
||||||
|
|
||||||
|
Frozen integration passes **11 files,70/70**, types/lint/build and three native
|
||||||
|
runtime paths with19 GameTable SSR renders. Original public A/E/Ix +Ix World/
|
||||||
|
Tech starts retain47 Treachery and22 Spice; Advanced adds original14 training
|
||||||
|
and Strongholds. Basic and first-turn ignored-worm paths use25+66 actions;
|
||||||
|
Advanced uses30+71. Only original unopened lotteries are selected.
|
||||||
|
|
||||||
|
Actual A2 Caladan→Broken Land shipment pays4 and supplies a real later worm
|
||||||
|
victim. Waiting Sandtrout spans Collection/Mentat/Storm; its later suppression
|
||||||
|
preserves both that army and its ground spice, grants no Nexus/ride, and doubles
|
||||||
|
only immediate Hagga. Advanced's next ordinary blow remains undoubled. Ix's
|
||||||
|
Heighliners1 pays at phase end; E Banker1 waits for Mentat, beside earned first
|
||||||
|
end-Mentat Strongholds. Four minimal policies each submit a legal next action
|
||||||
|
per source. No played card, force, wallet, effect flag, occupation or clock is
|
||||||
|
installed. All original Spice cards remain, including the one set aside while
|
||||||
|
pending and the same physical discard after suppression.
|
||||||
|
|
||||||
|
Actual table SSR retains the existing waiting notice at the source, Collection,
|
||||||
|
Mentat and next Storm, then clears it after suppression. Report/source manifest:
|
||||||
|
`/home/quintine/.local/share/dune/checkpoints/20261026-world-sandtrout-proof-v1/`;
|
||||||
|
journeys/HTML: adjacent `20261026-world-sandtrout-runtime/`.
|
||||||
|
No saved-room/D1/server, reset/restart, deployment or separate assurance campaign.
|
||||||
|
SSR/public actions leave broader combinations, browser/mobile/deployed and
|
||||||
|
complete-mode acceptance open.
|
||||||
|
|
||||||
## Original World Nexus and Discovery occupation — 26 October 2026
|
## Original World Nexus and Discovery occupation — 26 October 2026
|
||||||
|
|
||||||
Every original admitted fresh World setup now initializes occupation benefit
|
Every original admitted fresh World setup now initializes occupation benefit
|
||||||
@@ -62,8 +103,9 @@ Fresh World/all14 Skills starts now initialize the existing occupation benefit
|
|||||||
state whenever the unchanged registered World/Skills admission accepts that
|
state whenever the unchanged registered World/Skills admission accepts that
|
||||||
clean context. Original selected families, native role exclusions, Tech3+ and
|
clean context. Original selected families, native role exclusions, Tech3+ and
|
||||||
Advanced Strongholds2+ remain; optional independent cards remain optional.
|
Advanced Strongholds2+ remain; optional independent cards remain optional.
|
||||||
This introduces no new start permission. Nexus/Discovery/Sandtrout/Semuta and
|
This introduces no new start permission. Nexus/Discovery/Semuta and other
|
||||||
other reaction contexts retain their own existing scopes.
|
reaction contexts retain their own existing scopes. Pending printed Sandtrout
|
||||||
|
uses the [live-state clarification](#live-world-sandtrout--26-october-2026) above.
|
||||||
|
|
||||||
Shared fresh state includes qualification history, occupied Collection and
|
Shared fresh state includes qualification history, occupied Collection and
|
||||||
percentage ledgers, native CHOAM Tupile and native Ecaz World victory. Skills
|
percentage ledgers, native CHOAM Tupile and native Ecaz World victory. Skills
|
||||||
|
|||||||
@@ -1,5 +1,33 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 26 October 2026 — Live World Sandtrout
|
||||||
|
|
||||||
|
**Prototyped / Partial.** A real original Basic
|
||||||
|
World/Tech Sandtrout draw reproduced an occupation-context rejection. Clean
|
||||||
|
World and World/all14 selectors now treat the canonical pending card flag as
|
||||||
|
live state, preserving the existing module/role admission and fresh-start rules.
|
||||||
|
Original Nexus/Discovery contexts also reject malformed effect flags. Printed
|
||||||
|
mechanics and the existing provisional timing policy remain unchanged.
|
||||||
|
|
||||||
|
Root profile/pure integration and a disjoint native lane pass **11 files,70/70**,
|
||||||
|
types/lint/build and **three native paths**,19 GameTable SSR renders. Basic and
|
||||||
|
first-turn ignored-worm paths use25+66 actions; Advanced World/all14/Tech/
|
||||||
|
Strongholds uses30+71. Actual A2 Caladan→Broken Land pays4, producing a real
|
||||||
|
later worm victim. Pending Sandtrout spans Collection/Mentat/Storm; suppression
|
||||||
|
preserves that army and ground spice, doubles only immediate Hagga and gives no
|
||||||
|
Nexus/ride. Advanced's next ordinary pile stays undoubled. Original47 Treachery,
|
||||||
|
22 Spice and optional14 Skills remain. Ix Heighliners1, deferred E Banker1 and
|
||||||
|
earned Strongholds retain their separate payouts. Four minimal policies each
|
||||||
|
submit a legal next action per source; no played state is installed.
|
||||||
|
|
||||||
|
The persistent notice appears through waiting phases and clears after
|
||||||
|
suppression. Frozen report/manifest:
|
||||||
|
`/home/quintine/.local/share/dune/checkpoints/20261026-world-sandtrout-proof-v1/`;
|
||||||
|
journeys/HTML: adjacent `20261026-world-sandtrout-runtime/`.
|
||||||
|
No saved-room/D1/server/reset/restart/deployment or assurance campaign.
|
||||||
|
Broader combinations, browser/mobile/deployed and complete-mode acceptance remain.
|
||||||
|
[Source and scope](HOMEWORLD_RULES.md#live-world-sandtrout--26-october-2026).
|
||||||
|
|
||||||
## 26 October 2026 — Original World Nexus and Discovery occupation
|
## 26 October 2026 — Original World Nexus and Discovery occupation
|
||||||
|
|
||||||
**Prototyped / Partial.** Shared fresh World setup
|
**Prototyped / Partial.** Shared fresh World setup
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Ixians & Tleilaxu functional prototype
|
# Ixians & Tleilaxu functional prototype
|
||||||
|
|
||||||
|
The [26 October live World follow-up](HOMEWORLD_RULES.md#live-world-sandtrout--26-october-2026)
|
||||||
|
keeps occupation capabilities available while an original Sandtrout waits.
|
||||||
|
It corrects a module-context rejection; the original Spice effects and
|
||||||
|
provisional compositions below retain their existing source and timing.
|
||||||
|
|
||||||
## Original Spice cards and selected policy — 24 October 2026
|
## Original Spice cards and selected policy — 24 October 2026
|
||||||
|
|
||||||
Normal lobbies now offer labelled Basic/Advanced expansion previews through
|
Normal lobbies now offer labelled Basic/Advanced expansion previews through
|
||||||
|
|||||||
@@ -1457,6 +1457,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
|
|||||||
|
|
||||||
| Topic | Decision to preserve | Detailed authority and evidence |
|
| Topic | Decision to preserve | Detailed authority and evidence |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| Live World Sandtrout | The pending original Spice Card is runtime state, not a new module selection. Original clean World and World/all14 occupation contexts stay available across its real draw and waiting interval, as original Nexus/Discovery contexts already did. Canonical boolean validation and unused fresh setup remain; printed suppression/doubling, provisional first-turn precedence and all roster/role limits are unchanged. | [Observed draw failure and current contract](HOMEWORLD_RULES.md#live-world-sandtrout--26-october-2026) |
|
||||||
| Original Ix Spice cards | Under standing choose-recommended authority, retain first-turn ignoring ahead of Thumper/Sandtrout, later generated-worm suppression and sequential doubling by each distinct Harvester. First-turn Thumper remains spendable with a persistent warning. Immediate replacement-worm behavior follows the E1 source; the stated precedence/equivalence/multiple-copy compositions remain provisional, not newly confirmed GF9 rulings. No numerical change or wider module certification. | [Sources, policy and original Basic/Advanced proof](IX_PROTOTYPE.md#original-spice-cards-and-selected-policy--24-october-2026) |
|
| Original Ix Spice cards | Under standing choose-recommended authority, retain first-turn ignoring ahead of Thumper/Sandtrout, later generated-worm suppression and sequential doubling by each distinct Harvester. First-turn Thumper remains spendable with a persistent warning. Immediate replacement-worm behavior follows the E1 source; the stated precedence/equivalence/multiple-copy compositions remain provisional, not newly confirmed GF9 rulings. No numerical change or wider module certification. | [Sources, policy and original Basic/Advanced proof](IX_PROTOTYPE.md#original-spice-cards-and-selected-policy--24-october-2026) |
|
||||||
| Stable Homeworld occupation | Fresh original Homeworlds alone and source history. Advanced uses authorized p22 sole qualification retained until last-force departure; Basic uses the visibly provisional current-sole policy recorded in the Basic table above, including Tupile through observed continuous activation sources and original normal-limit cleanup. Turn boundaries do not stack slots; reacquisition cannot erase an owed discard. Actual bank/percentage income, sharing, Caladan/Ix/Richese/Giedi ownership and native defenses are connected. Native CHOAM fresh entries separately mark original Tupile slots, owner-selected normal-limit cleanup and low-query restoration; old profiles are not upgraded. No inferred controller, back-award, invented canceled-auction count, public/save conversion or unrelated overlay. Exceptional Duke remains separate. | [Source cutover](HOMEWORLD_OCCUPATION_RULES.md#occupied-economics-bidding-and-advanced-source-cutover--5-october-2026), [Basic policy](HOMEWORLD_OCCUPATION_RULES.md#basic-current-sole-benefit-policy--9-october-2026), [Tupile Basic lifecycle and original consumers](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#basic-current-sole-slot-lifecycle--24-october-2026) |
|
| Stable Homeworld occupation | Fresh original Homeworlds alone and source history. Advanced uses authorized p22 sole qualification retained until last-force departure; Basic uses the visibly provisional current-sole policy recorded in the Basic table above, including Tupile through observed continuous activation sources and original normal-limit cleanup. Turn boundaries do not stack slots; reacquisition cannot erase an owed discard. Actual bank/percentage income, sharing, Caladan/Ix/Richese/Giedi ownership and native defenses are connected. Native CHOAM fresh entries separately mark original Tupile slots, owner-selected normal-limit cleanup and low-query restoration; old profiles are not upgraded. No inferred controller, back-award, invented canceled-auction count, public/save conversion or unrelated overlay. Exceptional Duke remains separate. | [Source cutover](HOMEWORLD_OCCUPATION_RULES.md#occupied-economics-bidding-and-advanced-source-cutover--5-october-2026), [Basic policy](HOMEWORLD_OCCUPATION_RULES.md#basic-current-sole-benefit-policy--9-october-2026), [Tupile Basic lifecycle and original consumers](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#basic-current-sole-slot-lifecycle--24-october-2026) |
|
||||||
| Printed high Ecaz Homeworld victory | Fresh native Ecaz/Homeworld entry only: current high7+, reciprocal alliance jointly holds one actual uncontested stronghold and proved physically held worlds of two other native factions. Separate source joins ordinary winner union before BG prediction/final fallback; no ordinary point inflation, Tech substitute or Duke permission. Basic holdings now follow the provisional current-sole policy, so a repopulated, contested or departed world simply stops counting instead of blocking the win; old/public profiles unchanged. | [Card contract and actual original win](HOMEWORLD_RULES.md#printed-high-ecaz-alliance-victory--5-october-2026) |
|
| Printed high Ecaz Homeworld victory | Fresh native Ecaz/Homeworld entry only: current high7+, reciprocal alliance jointly holds one actual uncontested stronghold and proved physically held worlds of two other native factions. Separate source joins ordinary winner union before BG prediction/final fallback; no ordinary point inflation, Tech substitute or Duke permission. Basic holdings now follow the provisional current-sole policy, so a repopulated, contested or departed world simply stops counting instead of blocking the win; old/public profiles unchanged. | [Card contract and actual original win](HOMEWORLD_RULES.md#printed-high-ecaz-alliance-victory--5-october-2026) |
|
||||||
|
|||||||
@@ -49,13 +49,14 @@ const optionalFlag = (value: unknown) => value === undefined || typeof value ===
|
|||||||
|
|
||||||
/** Configuration permission only: original setup validates freshness, and live
|
/** Configuration permission only: original setup validates freshness, and live
|
||||||
* custody/history validators retain their own physical and occupation rules.
|
* custody/history validators retain their own physical and occupation rules.
|
||||||
* Optional independent cards do not require either phase module. */
|
* Optional independent cards do not require either phase module. Sandtrout is
|
||||||
|
* a live printed-card flag; fresh setup checks that no effect is already held. */
|
||||||
export function homeworldPhaseModulesProfile(game: HomeworldPhaseModulesProfile): boolean {
|
export function homeworldPhaseModulesProfile(game: HomeworldPhaseModulesProfile): boolean {
|
||||||
if (!game || typeof game !== 'object' || !game.homeworlds || typeof game.advanced !== 'boolean' ||
|
if (!game || typeof game !== 'object' || !game.homeworlds || typeof game.advanced !== 'boolean' ||
|
||||||
!registeredRoster(game) || !optionalFlag(game.ecazTreachery) ||
|
!registeredRoster(game) || !optionalFlag(game.ecazTreachery) || !optionalFlag(game.sandtrout) ||
|
||||||
(game.techTokens && game.players.length < 3) || (game.strongholdCards && !game.advanced) ||
|
(game.techTokens && game.players.length < 3) || (game.strongholdCards && !game.advanced) ||
|
||||||
game.leaderSkills || game.nexusCards || game.discoveryEnabled || game.discoveries ||
|
game.leaderSkills || game.nexusCards || game.discoveryEnabled || game.discoveries ||
|
||||||
game.discoveryStash || game.greatMaker || game.sandtrout || game.spiceBankerIncomePreview ||
|
game.discoveryStash || game.greatMaker || game.spiceBankerIncomePreview ||
|
||||||
game.semutaPreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
|
game.semutaPreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
|
||||||
game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview ||
|
game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview ||
|
||||||
game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false;
|
game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false;
|
||||||
@@ -75,10 +76,10 @@ export function homeworldPhaseModulesProfile(game: HomeworldPhaseModulesProfile)
|
|||||||
* only selects the clean occupation context and accepts its native stamps. */
|
* only selects the clean occupation context and accepts its native stamps. */
|
||||||
export function homeworldOccupationLeaderSkillsProfile(game: HomeworldPhaseModulesProfile): boolean {
|
export function homeworldOccupationLeaderSkillsProfile(game: HomeworldPhaseModulesProfile): boolean {
|
||||||
if (!game || typeof game !== 'object' || !game.homeworlds || !game.leaderSkills ||
|
if (!game || typeof game !== 'object' || !game.homeworlds || !game.leaderSkills ||
|
||||||
typeof game.advanced !== 'boolean' || !registeredRoster(game) || !optionalFlag(game.ecazTreachery) ||
|
typeof game.advanced !== 'boolean' || !registeredRoster(game) || !optionalFlag(game.ecazTreachery) || !optionalFlag(game.sandtrout) ||
|
||||||
(game.techTokens && game.players.length < 3) || (game.strongholdCards && !game.advanced) ||
|
(game.techTokens && game.players.length < 3) || (game.strongholdCards && !game.advanced) ||
|
||||||
game.nexusCards || game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker ||
|
game.nexusCards || game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker ||
|
||||||
game.sandtrout || game.semutaPreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
|
game.semutaPreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
|
||||||
game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview ||
|
game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview ||
|
||||||
game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false;
|
game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false;
|
||||||
if (![game.ecazOccupyPreview, game.moritaniAssassinatePreview, game.homeworldOccupationPreview,
|
if (![game.ecazOccupyPreview, game.moritaniAssassinatePreview, game.homeworldOccupationPreview,
|
||||||
@@ -98,7 +99,7 @@ export function homeworldOccupationLeaderSkillsProfile(game: HomeworldPhaseModul
|
|||||||
* live effect stamps and Discovery frames are not replacement module choices. */
|
* live effect stamps and Discovery frames are not replacement module choices. */
|
||||||
export function homeworldOccupationModulesProfile(game: HomeworldPhaseModulesProfile): boolean {
|
export function homeworldOccupationModulesProfile(game: HomeworldPhaseModulesProfile): boolean {
|
||||||
if (!game || typeof game !== 'object' || !game.homeworlds || typeof game.advanced !== 'boolean' ||
|
if (!game || typeof game !== 'object' || !game.homeworlds || typeof game.advanced !== 'boolean' ||
|
||||||
!registeredRoster(game) || !optionalFlag(game.ecazTreachery) ||
|
!registeredRoster(game) || !optionalFlag(game.ecazTreachery) || !optionalFlag(game.sandtrout) ||
|
||||||
(game.techTokens && game.players.length < 3) || (game.strongholdCards && !game.advanced) ||
|
(game.techTokens && game.players.length < 3) || (game.strongholdCards && !game.advanced) ||
|
||||||
(!game.nexusCards && game.discoveryEnabled !== true) ||
|
(!game.nexusCards && game.discoveryEnabled !== true) ||
|
||||||
(game.discoveryEnabled !== true && (game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker)) ||
|
(game.discoveryEnabled !== true && (game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker)) ||
|
||||||
|
|||||||
+2
-1
@@ -2347,6 +2347,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'If the immediate replacement is a territory, double its spice. Harvester can then double that enhanced blow again. Spice still cannot appear in the storm.',
|
'If the immediate replacement is a territory, double its spice. Harvester can then double that enhanced blow again. Spice still cannot appear in the storm.',
|
||||||
'If the immediate replacement is another Shai-Hulud, resolve it normally and do not double a later territory. A normal worm can trigger a Nexus, and further normal worms can offer advanced Fremen placement.',
|
'If the immediate replacement is another Shai-Hulud, resolve it normally and do not double a later territory. A normal worm can trigger a Nexus, and further normal worms can offer advanced Fremen placement.',
|
||||||
'The pending Sandtrout effect survives until consumed, including between the two advanced spice blows and across turns. Its status is public. A still-hidden Sandtrout at the top of the deck is visible only to authorized Atreides foresight.',
|
'The pending Sandtrout effect survives until consumed, including between the two advanced spice blows and across turns. Its status is public. A still-hidden Sandtrout at the top of the deck is visible only to authorized Atreides foresight.',
|
||||||
|
'Homeworld occupation and Leader Skills retain their ordinary benefits while Sandtrout is waiting. Its later worm suppression and immediate-replacement doubling follow the same printed effect.',
|
||||||
'First-turn preview policy gives ignored worms precedence: Sandtrout remains waiting and no doubling is earned. Later Thumper equivalence lets Sandtrout suppress that generated encounter. Both compositions are labelled provisional. A separately summoned Fremen worm and wider module/phase-opening interactions retain their separate contracts and audit boundaries.',
|
'First-turn preview policy gives ignored worms precedence: Sandtrout remains waiting and no doubling is earned. Later Thumper equivalence lets Sandtrout suppress that generated encounter. Both compositions are labelled provisional. A separately summoned Fremen worm and wider module/phase-opening interactions retain their separate contracts and audit boundaries.',
|
||||||
],
|
],
|
||||||
related: [
|
related: [
|
||||||
@@ -5010,7 +5011,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
status: 'Partial',
|
status: 'Partial',
|
||||||
detail:
|
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.",
|
"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-occupation-modules-profile.test.ts', 'tests/world-module-occupation-runtime.test.ts', 'tests/world-module-occupation-controls.test.tsx', 'tests/world-module-occupation-income.test.ts', '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',
|
evidence: ['tests/world-sandtrout-runtime.test.ts', 'tests/homeworld-occupation-modules-profile.test.ts', 'tests/world-module-occupation-runtime.test.ts', 'tests/world-module-occupation-controls.test.tsx', 'tests/world-module-occupation-income.test.ts', '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-classic-discovery-runtime.test.ts',
|
||||||
'tests/homeworld-skills-discovery-runtime.test.ts',
|
'tests/homeworld-skills-discovery-runtime.test.ts',
|
||||||
'tests/homeworld-optional-modules-runtime.test.ts',
|
'tests/homeworld-optional-modules-runtime.test.ts',
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import { spiceDeck } from '../game/cards';
|
||||||
|
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||||
|
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
||||||
|
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||||
|
import {
|
||||||
|
actMixedPhaseModules, createMixedPhaseModulesFixture, mixedPhaseModulesClean,
|
||||||
|
mixedPhaseModulesInventory, mixedPhaseModulesPlayer, nextMixedPhaseModules,
|
||||||
|
type MixedPhaseModulesStep, type MixedPhaseModulesTransition,
|
||||||
|
} from './fixture-mixed-phase-modules';
|
||||||
|
import { beforeIxSpiceDraw, orderIxSpiceCards, type IxSpicePick } from './fixture-ix-spice-cards';
|
||||||
|
|
||||||
|
export type WorldSandtroutStep = MixedPhaseModulesStep;
|
||||||
|
export type WorldSandtroutTransition = MixedPhaseModulesTransition;
|
||||||
|
export const WORLD_SANDTROUT_TRAINING = { atreides: 'warmaster', emperor: 'spice-banker', ixians: 'mentat' } as const;
|
||||||
|
export const actWorldSandtrout = actMixedPhaseModules;
|
||||||
|
export const nextWorldSandtrout = nextMixedPhaseModules;
|
||||||
|
export const worldSandtroutClean = mixedPhaseModulesClean;
|
||||||
|
export const worldSandtroutPlayer = mixedPhaseModulesPlayer;
|
||||||
|
export const worldSandtroutInventory = mixedPhaseModulesInventory;
|
||||||
|
export function advanceWorldSandtrout(game: Game, until: (state: Game) => boolean,
|
||||||
|
actions: WorldSandtroutStep[] = []): Game {
|
||||||
|
for (let count = 0; count < 1800; count++) {
|
||||||
|
if (until(game)) return game;
|
||||||
|
assert.notEqual(game.status, 'finished', 'The original World Sandtrout source ended before its native control');
|
||||||
|
const next = nextWorldSandtrout(game); assert.ok(next);
|
||||||
|
game = actWorldSandtrout(game, next.actor, next.action, actions);
|
||||||
|
}
|
||||||
|
throw Error('The original World Sandtrout source did not reach its finite boundary');
|
||||||
|
}
|
||||||
|
export function finishWorldSandtrout(game: Game, actions: WorldSandtroutStep[] = []): Game {
|
||||||
|
const turn = game.turn;
|
||||||
|
return advanceWorldSandtrout(game, state => state.turn === turn && state.phase === 2 && worldSandtroutClean(state), actions);
|
||||||
|
}
|
||||||
|
export function worldSandtroutDeferred(game: Game, owner = 'emperor') {
|
||||||
|
return viewGame(game, owner).spiceBankerIncome?.deferred.find(row => row.owner === owner)?.amount ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sandtrout is set aside while active: count its live boolean once, then its
|
||||||
|
* physical discard once after suppression. Suppression annotates an original
|
||||||
|
* worm; it does not mint another Spice Card. */
|
||||||
|
export function worldSandtroutSpice(game: Game) {
|
||||||
|
const cards = [...game.spiceDeck, ...game.spiceDiscard.flat(), ...(game.spiceSequence?.skipped ?? [])]
|
||||||
|
.filter(card => !('worm' in card && card.thumper));
|
||||||
|
const inventory = cards.map(card => JSON.stringify('worm' in card ? { worm: true } : card));
|
||||||
|
if (game.sandtrout) inventory.push(JSON.stringify({ sandtrout: true }));
|
||||||
|
assert.equal(cards.filter(card => 'worm' in card).length, 6);
|
||||||
|
assert.equal(cards.filter(card => 'territory' in card).length, 15);
|
||||||
|
assert.equal(cards.filter(card => 'sandtrout' in card).length + Number(!!game.sandtrout), 1);
|
||||||
|
assert.deepEqual(inventory.sort(), spiceDeck(true).map(card => JSON.stringify(card)).sort());
|
||||||
|
return inventory.sort();
|
||||||
|
}
|
||||||
|
export function worldSandtroutSkills(game: Game) {
|
||||||
|
const skills = game.leaderSkills ? [...game.leaderSkills.deck,
|
||||||
|
...game.leaderSkills.assignments.map(assignment => assignment.skill),
|
||||||
|
...Object.values(game.leaderSkills.offers).flatMap(offer => offer.cards)].sort() : [];
|
||||||
|
if (game.leaderSkills) assert.deepEqual(skills, LEADER_SKILL_CARDS.map(card => card.id).sort());
|
||||||
|
return skills;
|
||||||
|
}
|
||||||
|
export function worldSandtroutStocks(game: Game) {
|
||||||
|
return game.players.map(player => ({ player: player.id, reserves: player.reserves, tanks: player.tanks,
|
||||||
|
forces: structuredClone(player.forces), eliteReserves: player.elites?.reserves ?? 0,
|
||||||
|
eliteTanks: player.elites?.tanks ?? 0, eliteForces: structuredClone(player.elites?.forces ?? {}) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Genuine public A/E/Ix +Ix World/Tech setup. Advanced additionally trains
|
||||||
|
* three original all14 offers and enables original Strongholds. Only the
|
||||||
|
* original scalar/Skill lottery and unopened Spice prefix are selected. No
|
||||||
|
* played Sandtrout flag, card, force, wallet, occupation or clock is installed.
|
||||||
|
* Stop at the actual first blow with Sandtrout physically waiting. */
|
||||||
|
export function createWorldSandtroutFixture(options: { advanced?: boolean; firstTurnWorm?: boolean } = {}) {
|
||||||
|
const advanced = options.advanced ?? false, firstTurnWorm = options.firstTurnWorm ?? false;
|
||||||
|
const source = createMixedPhaseModulesFixture({ factions: ['atreides', 'emperor', 'ixians'], expansions: ['ix'],
|
||||||
|
advanced, homeworlds: true, tech: true, strongholds: advanced, skills: advanced,
|
||||||
|
training: advanced ? WORLD_SANDTROUT_TRAINING : undefined });
|
||||||
|
let game = structuredClone(source.game);
|
||||||
|
const actions = [...source.actions];
|
||||||
|
assert.ok(beforeIxSpiceDraw(game));
|
||||||
|
const picks: IxSpicePick[] = ['sandtrout', ...(firstTurnWorm ? ['worm' as const] : []),
|
||||||
|
{ territory: 'broken_land' }, ...(advanced ? [{ territory: 'funeral_plain' }] : [])];
|
||||||
|
const spiceOrder = orderIxSpiceCards(game, picks);
|
||||||
|
const beforeDraw = structuredClone(game), from = actions.length;
|
||||||
|
game = advanceWorldSandtrout(game, state => !!state.spiceWindow && worldSandtroutClean(state), actions);
|
||||||
|
const draw: WorldSandtroutTransition = { before: beforeDraw, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
|
||||||
|
assert.equal(game.sandtrout, true); assert.equal(game.spiceWindow!.territory, 'broken_land');
|
||||||
|
assert.equal(game.nexus, false);
|
||||||
|
const originalSpice = worldSandtroutSpice(source.setup), originalSkills = worldSandtroutSkills(source.setup);
|
||||||
|
assert.deepEqual(worldSandtroutSpice(game), originalSpice);
|
||||||
|
assert.deepEqual(worldSandtroutInventory(game), source.originalInventory);
|
||||||
|
homeworldGameIntegrity(game);
|
||||||
|
return { ...source, game, sourceGame: structuredClone(game), advanced, firstTurnWorm,
|
||||||
|
actor: 'atreides', owner: 'atreides', action: { type: 'ready' } as Action, banker: advanced ? 'emperor' : null,
|
||||||
|
origin: 'homeworld:atreides', target: game.spiceWindow!.territory, targetSector: game.spiceWindow!.sector,
|
||||||
|
targetLocation: `${game.spiceWindow!.territory}:${game.spiceWindow!.sector}`,
|
||||||
|
spiceOrder, beforeDraw, draw, originalSpice, originalSkills,
|
||||||
|
training: advanced ? WORLD_SANDTROUT_TRAINING : null,
|
||||||
|
actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
|
||||||
|
staging: [] as string[], sourceDisclosure: 'Original public A/E/Ix Worlds/Tech and optional Advanced all14/Strongholds use actual controls. Only original scalar/Skill lotteries and unopened Sandtrout/territory/worm positions are selected; no played flags, hands, armies, wallets, occupation state or turn is installed. First-turn ignoring and immediate-only doubling retain the provisional source policy in docs/IX_PROTOTYPE.md.' };
|
||||||
|
}
|
||||||
|
export type WorldSandtroutFixture = ReturnType<typeof createWorldSandtroutFixture>;
|
||||||
|
|
||||||
|
/** An actual paid native A2 arrival becomes the later worm's physical source.
|
||||||
|
* The active original Sandtrout survives real Collection, Mentat and Storm;
|
||||||
|
* the next drawn worm is suppressed and only its immediate territory doubles. */
|
||||||
|
export function completeWorldSandtrout(f: WorldSandtroutFixture, state = f.game) {
|
||||||
|
let game = state;
|
||||||
|
const actions: WorldSandtroutStep[] = [];
|
||||||
|
const act = (actor: string, action: Action) => { game = actWorldSandtrout(game, actor, action, actions); };
|
||||||
|
const reach = (until: (state: Game) => boolean) => { game = advanceWorldSandtrout(game, until, actions); };
|
||||||
|
const transition = (run: () => void): WorldSandtroutTransition => {
|
||||||
|
const before = structuredClone(game), from = actions.length; run();
|
||||||
|
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
|
||||||
|
};
|
||||||
|
const firstSpice = transition(() => { game = finishWorldSandtrout(game, actions); });
|
||||||
|
reach(state => state.turn === 1 && state.phase === 5 && state.active === f.actor && worldSandtroutClean(state));
|
||||||
|
const shipmentSources = nativeShipmentSources(viewGame(game, f.actor), 2, 0); assert.ok(shipmentSources);
|
||||||
|
const shipmentAction: Action = { type: 'ship', territory: f.target, sector: f.targetSector, amount: 2, elite: 0,
|
||||||
|
allyPayment: 0, homeworldSources: shipmentSources };
|
||||||
|
const shipment = transition(() => { act(f.actor, shipmentAction); reach(worldSandtroutClean); });
|
||||||
|
assert.equal(worldSandtroutPlayer(shipment.before, f.actor).spice - worldSandtroutPlayer(game, f.actor).spice, 4);
|
||||||
|
assert.equal(worldSandtroutPlayer(game, f.actor).forces[f.targetLocation], 2);
|
||||||
|
reach(state => state.phase === 5 && state.active === state.order.at(-1) && worldSandtroutClean(state));
|
||||||
|
const techOwner = game.techTokens!.heighliners.owner; assert.ok(techOwner);
|
||||||
|
const techAmount = game.techTokens!.heighliners.spice;
|
||||||
|
const collection = transition(() => {
|
||||||
|
act(game.active!, { type: 'endMovement' });
|
||||||
|
reach(state => state.phase === 7 && worldSandtroutClean(state));
|
||||||
|
});
|
||||||
|
assert.equal(game.sandtrout, true);
|
||||||
|
const mentat = transition(() => {
|
||||||
|
reach(state => state.turn === 2 && state.phase === 0 && worldSandtroutClean(state));
|
||||||
|
});
|
||||||
|
assert.equal(game.sandtrout, true);
|
||||||
|
const storm = transition(() => { reach(state => state.turn === 2 && beforeIxSpiceDraw(state)); });
|
||||||
|
assert.equal(game.sandtrout, true);
|
||||||
|
assert.equal(worldSandtroutPlayer(game, f.actor).forces[f.targetLocation], 2);
|
||||||
|
const secondSpiceOrder = orderIxSpiceCards(game, ['worm', { territory: 'hagga_basin' },
|
||||||
|
...(f.advanced ? [{ territory: 'rock_outcroppings' }] : [])]);
|
||||||
|
const beforeSuppression = structuredClone(game);
|
||||||
|
const suppression = transition(() => { reach(state => !!state.spiceWindow && worldSandtroutClean(state)); });
|
||||||
|
const revealed = structuredClone(game);
|
||||||
|
assert.equal(game.sandtrout, false); assert.equal(game.nexus, false);
|
||||||
|
assert.equal(game.spiceWindow!.territory, 'hagga_basin');
|
||||||
|
let secondWindow: Game | null = null;
|
||||||
|
if (f.advanced) {
|
||||||
|
reach(state => !!state.spiceWindow && state.spiceSequence?.pile === 1 && worldSandtroutClean(state));
|
||||||
|
secondWindow = structuredClone(game);
|
||||||
|
}
|
||||||
|
const finishedSpice = transition(() => { game = finishWorldSandtrout(game, actions); });
|
||||||
|
homeworldGameIntegrity(game);
|
||||||
|
assert.deepEqual(worldSandtroutSpice(game), f.originalSpice);
|
||||||
|
assert.deepEqual(worldSandtroutInventory(game), f.originalInventory);
|
||||||
|
assert.deepEqual(worldSandtroutSkills(game), f.originalSkills);
|
||||||
|
const bankerSource = shipment.after.spiceBankerIncome?.sources.find(receipt => receipt.source.kind === 'shipment' &&
|
||||||
|
receipt.source.bankLegs.some(leg => leg.payer === f.actor && leg.amount === 4)) ?? null;
|
||||||
|
const bankerCollection = bankerSource ? game.spiceBankerIncome?.collections.find(receipt =>
|
||||||
|
receipt.turn === 1 && receipt.grants.includes(bankerSource.cursor)) ?? null : null;
|
||||||
|
return { game, firstSpice, shipmentSources, shipmentAction, shipment, techOwner, techAmount,
|
||||||
|
collection, mentat, storm, secondSpiceOrder, beforeSuppression, suppression, revealed,
|
||||||
|
secondWindow, finishedSpice, bankerSource: structuredClone(bankerSource),
|
||||||
|
bankerCollection: structuredClone(bankerCollection), actions, actionCount: actions.length };
|
||||||
|
}
|
||||||
@@ -61,15 +61,23 @@ void test('independent-card selection is an exact optional boolean rather than a
|
|||||||
for (const ecazTreachery of [null, 0, 1, 'true', {}]) assert.equal(supported(profile(undefined, undefined, { ecazTreachery })), false);
|
for (const ecazTreachery of [null, 0, 1, 'true', {}]) assert.equal(supported(profile(undefined, undefined, { ecazTreachery })), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('Skills, Nexus, Discovery, active Sandtrout and unrelated reaction selectors remain outside the clean World scope', () => {
|
void test('Skills, Nexus, Discovery and unrelated reaction selectors remain outside the clean World scope', () => {
|
||||||
for (const field of [
|
for (const field of [
|
||||||
'leaderSkills', 'nexusCards', 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'sandtrout',
|
'leaderSkills', 'nexusCards', 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker',
|
||||||
'spiceBankerIncomePreview', 'semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview',
|
'spiceBankerIncomePreview', 'semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview',
|
||||||
'guildBetrayalPreview', 'richeseBetrayalPreview', 'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview',
|
'guildBetrayalPreview', 'richeseBetrayalPreview', 'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview',
|
||||||
'nexusHarkonnenBetrayalPreview',
|
'nexusHarkonnenBetrayalPreview',
|
||||||
] as const) assert.equal(supported({ ...profile(), [field]: true }), false, field);
|
] as const) assert.equal(supported({ ...profile(), [field]: true }), false, field);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('a pending original Sandtrout preserves the live World context without becoming a module selection', () => {
|
||||||
|
const game = profile(['ixians', 'emperor', 'atreides'], ['ix'], {
|
||||||
|
homeworldOccupationPreview: true, techTokens: createTechTokens(),
|
||||||
|
});
|
||||||
|
for (const sandtrout of [undefined, false, true]) assert.equal(supported({ ...game, sandtrout }), true);
|
||||||
|
for (const sandtrout of [null, 0, 1, 'true', {}]) assert.equal(supported({ ...game, sandtrout }), false);
|
||||||
|
});
|
||||||
|
|
||||||
void test('canonical occupation and native benefit stamps retain their actual seated source and required parent', () => {
|
void test('canonical occupation and native benefit stamps retain their actual seated source and required parent', () => {
|
||||||
const native = profile(['ecaz', 'moritani', 'choam'], ['choam', 'ecaz'], { advanced: true,
|
const native = profile(['ecaz', 'moritani', 'choam'], ['choam', 'ecaz'], { advanced: true,
|
||||||
ecazOccupyPreview: true, moritaniAssassinatePreview: true, homeworldOccupationPreview: true,
|
ecazOccupyPreview: true, moritaniAssassinatePreview: true, homeworldOccupationPreview: true,
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ void test('unrelated previews and mismatched native, parent, reaction or Banker
|
|||||||
assert.equal(supported({ ...game, [field]: 'forged' }), false, field);
|
assert.equal(supported({ ...game, [field]: 'forged' }), false, field);
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('live native Discovery, active printed Sandtrout and occupied custody stay configuration-neutral without changing clean helpers', () => {
|
void test('live native Discovery, active printed Sandtrout and occupied custody stay configuration-neutral in original module contexts', () => {
|
||||||
const custody = { visitors: { 'homeworld:ixians': { emperor: { normal: 1, elite: 1 } } } };
|
const custody = { visitors: { 'homeworld:ixians': { emperor: { normal: 1, elite: 1 } } } };
|
||||||
const discoveries = { turn: 2, tokens: [], encounters: [] };
|
const discoveries = { turn: 2, tokens: [], encounters: [] };
|
||||||
const game = profile(['ixians', 'tleilaxu', 'emperor'], ['ix'], { nexusCards: nexus(), discoveryEnabled: true,
|
const game = profile(['ixians', 'tleilaxu', 'emperor'], ['ix'], { nexusCards: nexus(), discoveryEnabled: true,
|
||||||
@@ -148,7 +148,8 @@ void test('live native Discovery, active printed Sandtrout and occupied custody
|
|||||||
const plain = profile(); const trained = { ...plain, leaderSkills: true };
|
const plain = profile(); const trained = { ...plain, leaderSkills: true };
|
||||||
assert.equal(clean(plain), true); assert.equal(cleanSkills(trained), true);
|
assert.equal(clean(plain), true); assert.equal(cleanSkills(trained), true);
|
||||||
assert.equal(supported(plain), false); assert.equal(supported(trained), false);
|
assert.equal(supported(plain), false); assert.equal(supported(trained), false);
|
||||||
assert.equal(clean({ ...plain, sandtrout: true }), false); assert.equal(cleanSkills({ ...trained, sandtrout: true }), false);
|
assert.equal(clean({ ...plain, sandtrout: true }), true); assert.equal(cleanSkills({ ...trained, sandtrout: true }), true);
|
||||||
|
for (const sandtrout of [null, 0, 1, 'true', {}]) assert.equal(supported({ ...game, sandtrout }), false);
|
||||||
for (const value of [null, {}, { ...game, expansions: 'ix' }, { ...game, players: [{ faction: 'ixians' }, null] }])
|
for (const value of [null, {}, { ...game, expansions: 'ix' }, { ...game, players: [{ faction: 'ixians' }, null] }])
|
||||||
assert.equal(supported(value as unknown as HomeworldPhaseModulesProfile), false);
|
assert.equal(supported(value as unknown as HomeworldPhaseModulesProfile), false);
|
||||||
});
|
});
|
||||||
@@ -57,9 +57,9 @@ void test('required modules, physical seat bounds and existing phase limits do n
|
|||||||
]) assert.equal(supported(game), false);
|
]) assert.equal(supported(game), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('occupation benefit context excludes Nexus, Discovery, Sandtrout and unrelated reaction selectors without changing library admission', () => {
|
void test('occupation benefit context excludes Nexus, Discovery and unrelated reaction selectors without changing library admission', () => {
|
||||||
for (const field of ['nexusCards', 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker',
|
for (const field of ['nexusCards', 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker',
|
||||||
'sandtrout', 'semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview', 'guildBetrayalPreview',
|
'semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview', 'guildBetrayalPreview',
|
||||||
'richeseBetrayalPreview', 'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview', 'nexusHarkonnenBetrayalPreview',
|
'richeseBetrayalPreview', 'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview', 'nexusHarkonnenBetrayalPreview',
|
||||||
] as const) assert.equal(supported({ ...profile(), [field]: true }), false, field);
|
] as const) assert.equal(supported({ ...profile(), [field]: true }), false, field);
|
||||||
const nexus = profile(undefined, undefined, { nexusCards: { cards: null, phase: null } });
|
const nexus = profile(undefined, undefined, { nexusCards: { cards: null, phase: null } });
|
||||||
@@ -67,6 +67,19 @@ void test('occupation benefit context excludes Nexus, Discovery, Sandtrout and u
|
|||||||
assert.equal(supported(nexus), false, 'This clean benefit slice does not claim that separate combination');
|
assert.equal(supported(nexus), false, 'This clean benefit slice does not claim that separate combination');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('pending printed Sandtrout retains occupation and all14 roles under the same original World admission', () => {
|
||||||
|
const game = profile(['ixians', 'emperor', 'atreides'], ['ix'], { advanced: true,
|
||||||
|
homeworldOccupationPreview: true, spiceBankerIncomePreview: true,
|
||||||
|
techTokens: createTechTokens(), strongholdCards: createStrongholdCards(),
|
||||||
|
});
|
||||||
|
for (const sandtrout of [undefined, false, true]) {
|
||||||
|
const active = { ...game, sandtrout };
|
||||||
|
assert.equal(admitted(active), true);
|
||||||
|
assert.equal(supported(active), true);
|
||||||
|
}
|
||||||
|
for (const sandtrout of [null, 0, 1, 'true', {}]) assert.equal(supported({ ...game, sandtrout }), false);
|
||||||
|
});
|
||||||
|
|
||||||
void test('canonical Banker and native occupation stamps require their actual parent factions and retain the Advanced assassination band', () => {
|
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,
|
const native = profile(['ecaz', 'moritani', 'choam'], ['ecaz', 'choam'], { advanced: true,
|
||||||
homeworldOccupationPreview: true, homeworldTupilePreview: true, homeworldEcazVictoryPreview: true,
|
homeworldOccupationPreview: true, homeworldTupilePreview: true, homeworldEcazVictoryPreview: true,
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { viewGame } from '../game/engine';
|
||||||
|
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||||
|
import { TECH_TOKENS } from '../game/tech-tokens';
|
||||||
|
import {
|
||||||
|
completeWorldSandtrout, createWorldSandtroutFixture, worldSandtroutDeferred as deferred,
|
||||||
|
worldSandtroutInventory as inventory, worldSandtroutPlayer as player,
|
||||||
|
worldSandtroutSkills as skills, worldSandtroutSpice as spice, worldSandtroutStocks as stocks,
|
||||||
|
} from './fixture-world-sandtrout';
|
||||||
|
|
||||||
|
for (const advanced of [false, true]) {
|
||||||
|
void test(`native ${advanced ? 'Advanced Worlds Skills Tech Strongholds' : 'Basic Worlds Tech'} Sandtrout waits through real phases and suppresses only the later worm`, () => {
|
||||||
|
const f = createWorldSandtroutFixture({ advanced });
|
||||||
|
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
|
||||||
|
assert.deepEqual(f.game.players.map(seat => seat.faction), ['atreides', 'emperor', 'ixians']);
|
||||||
|
assert.deepEqual(f.game.expansions, ['ix']); assert.ok(f.game.homeworlds && f.game.techTokens);
|
||||||
|
assert.ok(f.game.homeworldOccupationPreview && f.game.homeworldOccupationHistory);
|
||||||
|
assert.equal(!!f.game.leaderSkills, advanced); assert.equal(!!f.game.strongholdCards, advanced);
|
||||||
|
assert.ok(!f.game.nexusCards && !f.game.discoveryEnabled && !f.game.semutaPreview);
|
||||||
|
assert.equal(f.originalInventory.length, 47); assert.equal(f.originalSkills.length, advanced ? 14 : 0);
|
||||||
|
assert.equal(f.originalSpice.length, 22); assert.deepEqual(f.staging, []);
|
||||||
|
assert.equal(f.sourceActionCount, f.sourceActions.length);
|
||||||
|
assert.equal(f.beforeDraw.sandtrout, false); assert.equal(f.game.sandtrout, true);
|
||||||
|
assert.equal(viewGame(f.game, f.actor).sandtrout, true);
|
||||||
|
assert.deepEqual(stocks(f.game), stocks(f.beforeDraw));
|
||||||
|
assert.deepEqual(f.game.players.map(seat => seat.spice), f.beforeDraw.players.map(seat => seat.spice));
|
||||||
|
assert.deepEqual(inventory(f.game), f.originalInventory); assert.deepEqual(spice(f.game), f.originalSpice);
|
||||||
|
if (advanced) for (const [owner, skill] of Object.entries(f.training!)) {
|
||||||
|
const assignment = f.afterSetup.leaderSkills!.assignments.find(row => row.owner === owner); assert.ok(assignment);
|
||||||
|
assert.equal(assignment.skill, skill);
|
||||||
|
assert.ok(f.sourceActions.some(step => step.actor === owner && step.action.type === 'leaderSkill' &&
|
||||||
|
step.action.skill === skill && step.action.leader === assignment.leader));
|
||||||
|
}
|
||||||
|
const before = structuredClone(f.game), result = completeWorldSandtrout(f);
|
||||||
|
assert.deepEqual(f.game, before);
|
||||||
|
assert.equal(result.firstSpice.after.sandtrout, true); assert.equal(result.firstSpice.after.phase, 2);
|
||||||
|
assert.deepEqual(result.shipmentSources, { 'homeworld:atreides': { normal: 2, elite: 0 } });
|
||||||
|
assert.equal(player(result.shipment.after, f.actor).reserves, player(result.shipment.before, f.actor).reserves - 2);
|
||||||
|
assert.equal(player(result.shipment.after, f.actor).forces[f.targetLocation], 2);
|
||||||
|
assert.equal(player(result.shipment.before, f.actor).spice - player(result.shipment.after, f.actor).spice, 4);
|
||||||
|
const ordinary = quoteSpiceCollection(result.collection.before).receipts;
|
||||||
|
for (const seat of result.collection.before.players) {
|
||||||
|
const receipt = ordinary.find(row => row.player === seat.id);
|
||||||
|
assert.equal(player(result.collection.after, seat.id).spice - seat.spice,
|
||||||
|
(receipt ? receipt.strongholds + receipt.collected : 0) + (seat.id === result.techOwner ? result.techAmount : 0));
|
||||||
|
}
|
||||||
|
assert.equal(result.collection.after.sandtrout, true);
|
||||||
|
assert.ok(result.techAmount > 0);
|
||||||
|
assert.equal(result.collection.after.techTokens!.heighliners.spice, 0);
|
||||||
|
assert.equal(result.mentat.after.turn, 2); assert.equal(result.mentat.after.phase, 0);
|
||||||
|
assert.equal(result.mentat.after.sandtrout, true); assert.equal(result.storm.after.sandtrout, true);
|
||||||
|
assert.equal(player(result.mentat.after, 'emperor').spice - player(result.mentat.before, 'emperor').spice, advanced ? 1 : 0);
|
||||||
|
if (advanced) {
|
||||||
|
assert.deepEqual(result.bankerSource!.source.bankLegs, [{ payer: f.actor, amount: 4 }]);
|
||||||
|
assert.equal(result.bankerSource!.grant!.owner, f.banker);
|
||||||
|
assert.equal(deferred(result.collection.after), 1);
|
||||||
|
assert.deepEqual(result.bankerCollection!.credits, [{ owner: f.banker, amount: 1 }]);
|
||||||
|
assert.equal(result.mentat.after.strongholdCards!.claimedTurn, 1);
|
||||||
|
assert.equal(result.mentat.after.strongholdCards!.owners.arrakeen, f.actor);
|
||||||
|
assert.equal(result.mentat.after.strongholdCards!.owners.hidden_mobile_stronghold, 'ixians');
|
||||||
|
} else assert.equal(result.bankerSource, null);
|
||||||
|
assert.equal(deferred(result.mentat.after), 0);
|
||||||
|
const land = result.secondSpiceOrder[1].card; assert.ok('territory' in land);
|
||||||
|
assert.equal(result.revealed.spiceWindow!.amount, land.amount * 2);
|
||||||
|
assert.equal(result.revealed.spice[`${land.territory}:${land.sector}`] -
|
||||||
|
(result.beforeSuppression.spice[`${land.territory}:${land.sector}`] ?? 0), land.amount * 2);
|
||||||
|
assert.equal(result.revealed.sandtrout, false); assert.equal(result.revealed.nexus, false);
|
||||||
|
assert.deepEqual(stocks(result.revealed), stocks(result.beforeSuppression), 'The suppressed worm destroys no physical force');
|
||||||
|
assert.equal(result.revealed.spice[f.targetLocation], result.beforeSuppression.spice[f.targetLocation],
|
||||||
|
'The suppressed worm leaves its actual previous pile intact');
|
||||||
|
assert.deepEqual(result.revealed.players.map(seat => seat.spice), result.beforeSuppression.players.map(seat => seat.spice));
|
||||||
|
assert.equal(result.revealed.decision, null); assert.deepEqual(result.revealed.wormRides, []);
|
||||||
|
assert.equal(result.revealed.spiceDiscard.flat().filter(card => 'worm' in card && card.suppressed).length, 1);
|
||||||
|
assert.equal(result.revealed.spiceDiscard.flat().filter(card => 'sandtrout' in card).length, 1);
|
||||||
|
if (advanced) {
|
||||||
|
const next = result.secondSpiceOrder[2].card; assert.ok('territory' in next);
|
||||||
|
assert.equal(result.secondWindow!.spiceWindow!.territory, next.territory);
|
||||||
|
assert.equal(result.secondWindow!.spiceWindow!.amount, next.amount, 'Doubling is limited to the immediate replacement');
|
||||||
|
}
|
||||||
|
assert.equal(result.game.phase, 2); assert.equal(result.game.turn, 2); assert.equal(result.game.sandtrout, false);
|
||||||
|
for (const { id } of TECH_TOKENS) assert.equal(result.game.techTokens![id].owner, result.beforeSuppression.techTokens![id].owner);
|
||||||
|
assert.deepEqual(inventory(result.game), f.originalInventory); assert.deepEqual(skills(result.game), f.originalSkills);
|
||||||
|
assert.deepEqual(spice(result.game), f.originalSpice);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('native Basic World Sandtrout keeps the original ignored first-turn worm and waits for its later encounter', () => {
|
||||||
|
const f = createWorldSandtroutFixture({ firstTurnWorm: true });
|
||||||
|
assert.equal(f.game.turn, 1); assert.equal(f.game.sandtrout, true); assert.equal(f.game.nexus, false);
|
||||||
|
const land = f.spiceOrder[2].card; assert.ok('territory' in land);
|
||||||
|
assert.equal(f.game.spiceWindow!.amount, land.amount);
|
||||||
|
assert.equal(f.game.spiceSequence!.skipped.filter(card => 'worm' in card).length, 1);
|
||||||
|
assert.ok(f.game.log.some(entry => /first-turn worm rule leaves Sandtrout waiting/.test(entry.text)));
|
||||||
|
assert.deepEqual(spice(f.game), f.originalSpice);
|
||||||
|
const result = completeWorldSandtrout(f);
|
||||||
|
assert.equal(result.beforeSuppression.sandtrout, true); assert.equal(result.beforeSuppression.turn, 2);
|
||||||
|
assert.equal(result.revealed.nexus, false); assert.equal(result.revealed.sandtrout, false);
|
||||||
|
assert.equal(result.game.phase, 2); assert.deepEqual(spice(result.game), f.originalSpice);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user