Connect bounded Orgiz spice transfers to Discovery Collection

This commit is contained in:
Quintine committed 2026-09-28 09:49:14 +10:00
1 parent c9a3625ff2
commit 209fb18a27
11 files changed
+327 -123

No files matched your search

+1 -1
View File
@@ -92,7 +92,7 @@ export function DiscoveryPanel({ game, act, busy }: Props) {
); );
})} })}
</div> </div>
<p className="fine">Inspection and reveal are optional. Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter movement are available in the development prototype. Jacurutu income, Testing Station storm choices and Shrine card conversion are prototyped. Orgiz, mixed-force Jacurutu rewards and contested Cistern/Testing Station benefits remain pending.</p> <p className="fine">Inspection and reveal are optional. Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter movement are available in the development prototype. Jacurutu income, Testing Station storm choices and Shrine card conversion are prototyped. A sole Orgiz occupant takes one spice per board deposit collected by a rival. Contested Orgiz, unresolved Ecaz shared lots, mixed-force Jacurutu rewards and contested Cistern/Testing Station benefits remain pending.</p>
</section> </section>
); );
} }
+20 -11
View File
@@ -6,16 +6,25 @@ Expansion starts and full rules/publication acceptance remain gated.**
## Current checkpoint and work ## Current checkpoint and work
The live NAS Apps `dune` image is now the immutable The live NAS Apps `dune` image is the immutable
`ghcr.io/quintine/dune:sha-4801ce003bd28d5f0aaf26c3bcded3dcfea4df07` `ghcr.io/quintine/dune:sha-c9a3625ff2a9ddafdbc08e64de809eca6029db85`
from successful container workflow `36355611201`. The public homepage displays from successful container workflow `36358270534`. The public footer's title and
that full revision. After the update, owner sign-in succeeded and the nine accessibility name show that full revision; visible text shows `c9a3625`.
existing rooms remained in the directory, including a playing room at version After the update, owner sign-in succeeded and nine existing rooms remained,
54. A 0600 online SQLite backup in the persistent app volume passed including a playing room at version 54. A fresh 0600 online SQLite backup in
`PRAGMA quick_check` before the update; the temporary remote plaintext owner the persistent app volume passed `PRAGMA quick_check` before the update. The
key and provisioning SQL were removed afterward. Private saved-seat earlier temporary remote plaintext owner key and provisioning SQL were removed.
reconnection and complete historical-data comparison were not exercised. Private saved-seat reconnection, complete historical-data comparison and live
The pending Diplomat retreat work is not in this deployed image. Diplomat retreat gameplay were not exercised; its local behavior is verified,
not production gameplay-proven.
The bounded Discovery Orgiz Collection transfer is connected in current local
source for Basic/Advanced, with an automatic public log and saved continuation.
It treats each positive collected board deposit as one observable blow, a source
inference pending a publisher clarification. A focused engine/browser/SQLite
checkpoint is in [Discovery prototype](DISCOVERY_PROTOTYPE.md); this code is
not yet in the live `c9a3625` image. Contested/shared transfers and full
Discovery release acceptance remain gated.
The bounded [Fremen Nexus Cunning](NEXUS_FREMEN_RULES.md#connected-native-cunning-path) The bounded [Fremen Nexus Cunning](NEXUS_FREMEN_RULES.md#connected-native-cunning-path)
prototype connects an initially empty worm's public offer, one spent private prototype connects an initially empty worm's public offer, one spent private
@@ -242,7 +251,7 @@ These links define bounded working behavior, not complete module certification.
| Base/Advanced setup | [Normal Advanced preview](ADVANCED_PREVIEW.md), [genuine setup](ADVANCED_SETUP_TEST_SEAM.md), [Advanced study](AI_ADVANCED_SETUP_CALIBRATION_20260907.md), [Fremen cancellation](FREMEN_MOVEMENT_KARAMA_RULES.md), [Atreides full-plan audit](ATREIDES_FULL_PLAN_TIMING.md). Earlier Basic-then-flip fixtures do not establish current setup coverage. | | Base/Advanced setup | [Normal Advanced preview](ADVANCED_PREVIEW.md), [genuine setup](ADVANCED_SETUP_TEST_SEAM.md), [Advanced study](AI_ADVANCED_SETUP_CALIBRATION_20260907.md), [Fremen cancellation](FREMEN_MOVEMENT_KARAMA_RULES.md), [Atreides full-plan audit](ATREIDES_FULL_PLAN_TIMING.md). Earlier Basic-then-flip fixtures do not establish current setup coverage. |
| Expansion factions | [Ixians/Tleilaxu](IX_PROTOTYPE.md) and [all seven faction selections](EXPANSION_FACTIONS_PROTOTYPE.md): genuine setup, Ecaz six-force placement and [Advanced Loyalty](ECAZ_LOYALTY.md), separate optional card variants, private controls and saved per-lot Ixian/Richese decline. Actual special-lot exchange remains pending. | | Expansion factions | [Ixians/Tleilaxu](IX_PROTOTYPE.md) and [all seven faction selections](EXPANSION_FACTIONS_PROTOTYPE.md): genuine setup, Ecaz six-force placement and [Advanced Loyalty](ECAZ_LOYALTY.md), separate optional card variants, private controls and saved per-lot Ixian/Richese decline. Actual special-lot exchange remains pending. |
| Leader Skills | [Common lifecycle](LEADER_SKILLS_RUNTIME.md), [known skilled capture](LEADER_SKILLS_CAPTURE.md), [Mentat question preview](MENTAT_QUESTION.md), [Bureaucrat payments](BUREAUCRAT_PAYMENTS.md), five battle disciplines, [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md), [Rihani and other battle effects](LEADER_BATTLE_EFFECTS.md), [Smuggler shipment](SMUGGLER_SHIPMENT.md), [No-Field](SMUGGLER_NO_FIELD.md) and [battle collection](SMUGGLER_BATTLE.md), [Sandmaster routes](SANDMASTER_MOVEMENT.md) and [worm rides](SANDMASTER_WORM.md), [Banker spending](SPICE_BANKER_RUNTIME.md), [Diplomat defense](DIPLOMAT_DEFENSE.md) and [bounded retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation). Remaining bands and combinations are explicit in those contracts. | | Leader Skills | [Common lifecycle](LEADER_SKILLS_RUNTIME.md), [known skilled capture](LEADER_SKILLS_CAPTURE.md), [Mentat question preview](MENTAT_QUESTION.md), [Bureaucrat payments](BUREAUCRAT_PAYMENTS.md), five battle disciplines, [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md), [Rihani and other battle effects](LEADER_BATTLE_EFFECTS.md), [Smuggler shipment](SMUGGLER_SHIPMENT.md), [No-Field](SMUGGLER_NO_FIELD.md) and [battle collection](SMUGGLER_BATTLE.md), [Sandmaster routes](SANDMASTER_MOVEMENT.md) and [worm rides](SANDMASTER_WORM.md), [Banker spending](SPICE_BANKER_RUNTIME.md), [Diplomat defense](DIPLOMAT_DEFENSE.md) and [bounded retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation). Remaining bands and combinations are explicit in those contracts. |
| Discoveries | [Prototype](DISCOVERY_PROTOTYPE.md): genuine setup, Great Maker, seven cards/eight tokens, inspection and stash rewards, nested sites, signed later free entry, carried Ornithopter, sole Cistern, bounded Jacurutu income, Testing Station and Shrine. Orgiz and contested/mixed cases remain pending. | | Discoveries | [Prototype](DISCOVERY_PROTOTYPE.md): genuine setup, Great Maker, seven cards/eight tokens, inspection and stash rewards, nested sites, signed later free entry, carried Ornithopter, sole Cistern, bounded Orgiz transfers, Jacurutu income, Testing Station and Shrine. Orgiz's per-deposit interpretation, contested/shared benefits and mixed physical allocation remain provisional or pending. The Orgiz path in current source is not yet deployed. |
| Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Collection trade](NEXUS_CHOAM_SECRET_ALLY.md) and [Emperor extra revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) are connected; CHOAM inspection and Emperor purchase remain pending. A source audit or helper alone is not a completed effect. | | Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Collection trade](NEXUS_CHOAM_SECRET_ALLY.md) and [Emperor extra revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) are connected; CHOAM inspection and Emperor purchase remain pending. A source audit or helper alone is not a completed effect. |
| Truthtrance | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card counts](TRUTHTRANCE_CARD_COUNT.md), [hand inventory](TRUTHTRANCE_HAND_INVENTORY.md), [recorded knowledge](TRUTHTRANCE_KNOWLEDGE.md), and [Basic/no-Guild Advanced reserve-shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md) use authoritative private state. Earlier readiness audits do not supersede these follow-ups. | | Truthtrance | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card counts](TRUTHTRANCE_CARD_COUNT.md), [hand inventory](TRUTHTRANCE_HAND_INVENTORY.md), [recorded knowledge](TRUTHTRANCE_KNOWLEDGE.md), and [Basic/no-Guild Advanced reserve-shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md) use authoritative private state. Earlier readiness audits do not supersede these follow-ups. |
| Other cards | [Sapho runtime](JUICE_OF_SAPHO_RUNTIME.md) includes first among remaining unstarted movement turns after Advanced Guild has finished. [Recruits](RECRUITS_RUNTIME.md) connects clean Revival play; [Harass & Withdraw](HARASS_WITHDRAW_RUNTIME.md) connects the bounded battle return in the same independent three-card preview. Richese card contracts and the checklist identify other connected effects and explicit gaps. | | Other cards | [Sapho runtime](JUICE_OF_SAPHO_RUNTIME.md) includes first among remaining unstarted movement turns after Advanced Guild has finished. [Recruits](RECRUITS_RUNTIME.md) connects clean Revival play; [Harass & Withdraw](HARASS_WITHDRAW_RUNTIME.md) connects the bounded battle return in the same independent three-card preview. Richese card contracts and the checklist identify other connected effects and explicit gaps. |
+25 -10
View File
@@ -69,9 +69,17 @@ to redeal a started game. The default `ix` profile remains available.
territory into sector zero of that location. This spends no spice, shipment or territory into sector zero of that location. This spends no spice, shipment or
normal movement. Empty, storm-blocked and occupancy-blocked offers skip without normal movement. Empty, storm-blocked and occupancy-blocked offers skip without
holding up the turn. Accepted groups and arrival reactions survive restoration. holding up the turn. Accepted groups and arrival reactions survive restoration.
- **Cistern:** during Collection, a sole occupant receives two bank spice. If two - **Cistern:** during Collection, a sole fighter occupant receives two bank
factions occupy Cistern, the unresolved benefit is withheld without blocking spice after ordinary advisor releases. If two fighters occupy Cistern, the
Collection; this boundary does not decide which occupant should receive it. unresolved benefit is withheld without blocking Collection; this boundary
does not decide which occupant should receive it.
- **Orgiz:** during Collection, a sole fighter occupant takes one spice from
the unique rival collector of each positive board deposit after ordinary
advisor releases. One collected pile is one observable blow even if previous
blows stacked there. The transfer occurs after ordinary collection, conserves
player spice, appears in the public log, and resumes from saved state without
another payment. Unresolved Ecaz shared lots and contested Orgiz occupation
withhold their uncertain transfers, not the ordinary Collection phase.
- **Jacurutu:** winning a battle here automatically grants one bank spice per - **Jacurutu:** winning a battle here automatically grants one bank spice per
opposing undialed physical force sent to the Tanks. Winner casualties and opposing undialed physical force sent to the Tanks. Winner casualties and
@@ -120,10 +128,16 @@ answers or newly approved table rulings.
The printed Cistern text names an occupant but permits two factions to occupy a The printed Cistern text names an occupant but permits two factions to occupy a
revealed location. The prototype pays an ordinary sole occupant and withholds revealed location. The prototype pays an ordinary sole occupant and withholds
the contested bonus. Orgiz remains disabled because the source also does not the contested bonus. The publisher's [Ecaz & Moritani rulebook, page 13](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf)
settle whether a stacked board deposit represents one or several collected says an Orgiz occupant steals one spice of each collected blow. Its bundled
spice blows; contested occupancy and unresolved Ecaz shared collection add Q&A does not define a stacked pile as one or several blows; searches of the
further unanswered cases. publisher/designer and community rules pages found no explicit clarification.
The bounded implementation treats each positive board deposit collected as one
observable blow, transfers one spice from its unique collector, and does not
take spice from the occupant itself. This is an inference, not an official
ruling. Two fighters occupying Orgiz withhold all theft. An Ecaz shared lot
with no assigned collector withholds theft from its entire territory; independent
deposits elsewhere and ordinary collection proceed.
The Testing Station's two-occupant case leaves ordering and cumulative adjustment The Testing Station's two-occupant case leaves ordering and cumulative adjustment
unresolved. The prototype opens only the sole-occupant path and continues an unresolved. The prototype opens only the sole-occupant path and continues an
@@ -211,9 +225,10 @@ to Spice Blow. Refresh restored the same station forces and private Snooper.
This is targeted control/recovery evidence, not an unmodified complete game. This is targeted control/recovery evidence, not an unmodified complete game.
The 187 opening saved rooms remain preserved; no server restart was needed. The 187 opening saved rooms remain preserved; no server restart was needed.
1. Resolve Orgiz's collected-blow interpretation, contested Cistern/Testing 1. Resolve contested Cistern/Orgiz/Testing Station benefits, Ecaz shared-lot
Station benefits and Jacurutu's mixed physical dial allocation; complete their Orgiz allocation and Jacurutu's mixed physical dial allocation; complete
remaining controls and effects when settled. remaining controls and effects when settled. The per-collected-deposit
Orgiz interpretation remains provisional.
2. Integrate combined modules and complete games, then refine interaction, 2. Integrate combined modules and complete games, then refine interaction,
strategy and presentation coverage before opening normal start gates. strategy and presentation coverage before opening normal start gates.
3. Use the existing prototype starter and saved-room verification for further 3. Use the existing prototype starter and saved-room verification for further
+8
View File
@@ -1,11 +1,19 @@
# Dune implementation status # Dune implementation status
## 27 September 2026 — Bounded Orgiz Processing Station Collection
The [Discovery prototype](DISCOVERY_PROTOTYPE.md#connected-behavior) now composes a sole Orgiz occupant's one-spice transfer over ordinary Collection: one uniquely collected board deposit is one observable blow, even when older blows stacked there. Separate deposits pay separately; contested Orgiz occupancy and unresolved Ecaz shared lots withhold their uncertain transfers without blocking ordinary collection. Advisor releases follow the ordinary Collection quote. This is a documented inference from the *Ecaz & Moritani* rulebook, not a publisher ruling. Basic/Advanced engine, JSON restoration, simultaneous in-memory SQLite CAS, conserved spice, rejected-action custody and player-visible log are covered by 20 focused Discovery cases. An actual staged engine continuation reached Collection and credited the Orgiz owner; an isolated local browser table rendered its token guidance and chronicle log without writing a room. The broader Discovery, combined-expansion and publication gates remain closed; this source is not yet the live `c9a3625` image.
The complete offline suite passed 5,549 tests across 696 files, the app build passed, and scoped TypeScript/ESLint checks passed. `npm run check` still stops at an unrelated in-progress Nexus/Fremen draft with `fremenNexusMovementBlockedTurn` missing from `Player`; that draft was not changed or included in this checkpoint.
## 27 September 2026 — Bounded skilled Diplomat retreat ## 27 September 2026 — Bounded skilled Diplomat retreat
The standalone [Leader Skills retreat interpretation](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation) now offers a surviving losing trained leader an event-bound, saved decision before ordinary loser casualties. The server quotes destination-specific undialed normal/elite counts using dial commitments, occupied strongholds, sector adjacency and the storm; accepted choices transfer those exact physical counters, while a decline or dead leader follows ordinary losses. Human controls and all four AI profiles use the same projected choices. Nine focused engine, controls and concurrent in-memory SQLite cases passed, including JSON restoration, split-sector custody, legacy-battle continuation and rejected-action immutability. An isolated browser view from a genuine revealed battle rendered the destination/count selectors and decline; POST was blocked, so no room was changed. The scoped type check and production build passed. This is a provisional strength/outcome-order interpretation, not a complete skill or combined-module ruling, and it is not yet deployed. The standalone [Leader Skills retreat interpretation](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation) now offers a surviving losing trained leader an event-bound, saved decision before ordinary loser casualties. The server quotes destination-specific undialed normal/elite counts using dial commitments, occupied strongholds, sector adjacency and the storm; accepted choices transfer those exact physical counters, while a decline or dead leader follows ordinary losses. Human controls and all four AI profiles use the same projected choices. Nine focused engine, controls and concurrent in-memory SQLite cases passed, including JSON restoration, split-sector custody, legacy-battle continuation and rejected-action immutability. An isolated browser view from a genuine revealed battle rendered the destination/count selectors and decline; POST was blocked, so no room was changed. The scoped type check and production build passed. This is a provisional strength/outcome-order interpretation, not a complete skill or combined-module ruling, and it is not yet deployed.
The complete offline regression suite passed 5,545 tests across 696 files after the retreat changes. The unrelated in-progress Nexus/Fremen draft still blocks the repository-wide type/lint check; the scoped type check covers this retreat slice. The live `4801ce0` image predates this slice. The complete offline regression suite passed 5,545 tests across 696 files after the retreat changes. The unrelated in-progress Nexus/Fremen draft still blocks the repository-wide type/lint check; the scoped type check covers this retreat slice. The live `4801ce0` image predates this slice.
Deployment follow-up: successful workflow `36358270534` published `c9a3625ff2a9ddafdbc08e64de809eca6029db85`, and a dedicated deployment agent updated only the NAS Apps `dune` image. The public footer title/accessibility name matched the full revision, the owner signed in, and all nine prior rooms remained (one playing room at version 54). The 0600 online SQLite backup passed `PRAGMA quick_check`. Live Diplomat battle gameplay and private saved-seat recovery were not exercised; the local browser fixture remains the feature-specific UI evidence.
## 27 September 2026 — Ixian/Tleilaxu with Nexus Cards ## 27 September 2026 — Ixian/Tleilaxu with Nexus Cards
The paired-faction Nexus sample path now includes genuine Ixian/Tleilaxu games without opening the public expansion/module starts. At seed 20260927 all ten two-through-six-seat Basic/Advanced [samples](VERIFICATION_WORKFLOW.md#reusable-base-and-faction-sample-games) completed with 3,853 accepted actions, 100 JSON restores, five unallied card choices and no rejected actions. The three-seat Basic and Advanced traces reach Ixian auction decisions and Nexus draws; Basic reaches a Face Dancer decision, Advanced an Ixian Technology decision. A five-seat Basic trace also plays one Atreides Nexus effect, while an interrupted four-seat Advanced game resumes its own roster. The source-bound report is `/tmp/dune-ix-nexus-checkpoint-20260927/report.json`. This does not certify all card effects, combined module configurations or complete expansion rules. The paired-faction Nexus sample path now includes genuine Ixian/Tleilaxu games without opening the public expansion/module starts. At seed 20260927 all ten two-through-six-seat Basic/Advanced [samples](VERIFICATION_WORKFLOW.md#reusable-base-and-faction-sample-games) completed with 3,853 accepted actions, 100 JSON restores, five unallied card choices and no rejected actions. The three-seat Basic and Advanced traces reach Ixian auction decisions and Nexus draws; Basic reaches a Face Dancer decision, Advanced an Ixian Technology decision. A five-seat Basic trace also plays one Atreides Nexus effect, while an interrupted four-seat Advanced game resumes its own roster. The source-bound report is `/tmp/dune-ix-nexus-checkpoint-20260927/report.json`. This does not certify all card effects, combined module configurations or complete expansion rules.
+2 -2
View File
@@ -38,7 +38,7 @@ Updated 14 September 2026. This is the authoritative navigation index for existi
| Basic Ixians / Leader Skills | Per-origin cyborg range, room-local HMS force routes, rescue before actual cyborg Tank substitution, Rihani before faction substitution and mandatory cleanup compose existing contracts. Ambiguous rescued cyborg reserve origins are explicit player choices. Exact green Ix Specials are Thumper, Harvester and Amal. | [Integration and physical sources](IX_LEADER_SKILLS.md) | | Basic Ixians / Leader Skills | Per-origin cyborg range, room-local HMS force routes, rescue before actual cyborg Tank substitution, Rihani before faction substitution and mandatory cleanup compose existing contracts. Ambiguous rescued cyborg reserve origins are explicit player choices. Exact green Ix Specials are Thumper, Harvester and Amal. | [Integration and physical sources](IX_LEADER_SKILLS.md) |
| Basic Tleilaxu / Leader Skills | Compose settled native Rihani Face Dancer eligibility, own revival offers and Face Dance death return through the full deck. Zoal copies opposing printed disc value for unmodified Smuggler collection. Advanced foreign gholas and existing skill questions remain gated. | [Integration and sources](TLEILAXU_LEADER_SKILLS.md) | | Basic Tleilaxu / Leader Skills | Compose settled native Rihani Face Dancer eligibility, own revival offers and Face Dance death return through the full deck. Zoal copies opposing printed disc value for unmodified Smuggler collection. Advanced foreign gholas and existing skill questions remain gated. | [Integration and sources](TLEILAXU_LEADER_SKILLS.md) |
| Leader Skills | Fourteen physical cards, variant setup order, public/hidden assignment, death return, own-leader revival and captured lower-band effects are sourced. Five battle disciplines, Planetologist movement/Special substitution, Suk Graduate rescues and common lifecycle are prototyped; Rihani, lower Mentat/Bureaucrat/Sandmaster, normal Smuggler and normal Diplomat now have connected follow-ups. Remaining bands and combinations stay missing. Known skilled capture identifies the leader through its unique public card. | [Source contract](LEADER_SKILLS_RULES.md), [capture inference](LEADER_SKILLS_CAPTURE.md), [runtime](LEADER_SKILLS_RUNTIME.md), [Diplomat](DIPLOMAT_DEFENSE.md), [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md) | | Leader Skills | Fourteen physical cards, variant setup order, public/hidden assignment, death return, own-leader revival and captured lower-band effects are sourced. Five battle disciplines, Planetologist movement/Special substitution, Suk Graduate rescues and common lifecycle are prototyped; Rihani, lower Mentat/Bureaucrat/Sandmaster, normal Smuggler and normal Diplomat now have connected follow-ups. Remaining bands and combinations stay missing. Known skilled capture identifies the leader through its unique public card. | [Source contract](LEADER_SKILLS_RULES.md), [capture inference](LEADER_SKILLS_CAPTURE.md), [runtime](LEADER_SKILLS_RUNTIME.md), [Diplomat](DIPLOMAT_DEFENSE.md), [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md) |
| Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Revealed locations are separate territories; only Jacurutu counts toward stronghold victory. Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter spending are prototyped; Jacurutu income, Testing Station and Shrine have bounded prototypes; Orgiz and contested/mixed cases remain pending. | [Component authority](DISCOVERY_COMPONENTS.md), [prototype boundary](DISCOVERY_PROTOTYPE.md) | | Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Revealed locations are separate territories; only Jacurutu counts toward stronghold victory. Next-turn free entry, sole-occupant Cistern income, bounded sole-occupant Orgiz transfer and later-turn Ornithopter spending are prototyped; Jacurutu income, Testing Station and Shrine also have bounded paths. Contested and mixed cases remain pending. | [Component authority](DISCOVERY_COMPONENTS.md), [prototype boundary](DISCOVERY_PROTOTYPE.md) |
| Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) | | Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
| Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) | | Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) |
| Homeworld effects | Keep original payment/revival/placement identities and qualification observations; observations alone do not choose an occupied-benefit owner. | [Payments](HOMEWORLD_PAYMENT_INCOME_RULES.md), [card economy](HOMEWORLD_CARD_ECONOMY_RULES.md), [Collection](HOMEWORLD_COLLECTION_RULES.md), [revival deployment](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md), [victory reinforcement](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md), [replacement](HOMEWORLD_REPLACEMENT_RULES.md), [Tupile](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md) | | Homeworld effects | Keep original payment/revival/placement identities and qualification observations; observations alone do not choose an occupied-benefit owner. | [Payments](HOMEWORLD_PAYMENT_INCOME_RULES.md), [card economy](HOMEWORLD_CARD_ECONOMY_RULES.md), [Collection](HOMEWORLD_COLLECTION_RULES.md), [revival deployment](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md), [victory reinforcement](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md), [replacement](HOMEWORLD_REPLACEMENT_RULES.md), [Tupile](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md) |
@@ -79,7 +79,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Guild Secret Ally/Homeworlds | Whether the reserve-return grant overrides the Homeworld return restriction. The question was already sent; that route stays guarded. | [Homeworld return](NEXUS_GUILD_SECRET_ALLY_RULES.md#homeworld-prices-and-the-unresolved-return), [checkpoint](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) | | Guild Secret Ally/Homeworlds | Whether the reserve-return grant overrides the Homeworld return restriction. The question was already sent; that route stays guarded. | [Homeworld return](NEXUS_GUILD_SECRET_ALLY_RULES.md#homeworld-prices-and-the-unresolved-return), [checkpoint](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
| Occupied Homeworlds | Entitlement/expiry after departure, replacement or contest; unique powers and shared income. The first-occupier/current-occupier question was already sent. | [Occupation questions](HOMEWORLD_OCCUPATION_RULES.md#questions-the-retrieved-sources-do-not-settle), [lifecycle audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md) | | Occupied Homeworlds | Entitlement/expiry after departure, replacement or contest; unique powers and shared income. The first-occupier/current-occupier question was already sent. | [Occupation questions](HOMEWORLD_OCCUPATION_RULES.md#questions-the-retrieved-sources-do-not-settle), [lifecycle audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md) |
| Nexus responses and composition | Betrayal private-response timing, partial revival/payment choices, Emperor fewer-than-five, fresh advisor/storm cases, Duke restoration and remaining faction-specific combinations. | [Common boundaries](NEXUS_CARD_RULES.md#remaining-source-questions-and-release-boundaries) and the individual faction source/runtime documents above | | Nexus responses and composition | Betrayal private-response timing, partial revival/payment choices, Emperor fewer-than-five, fresh advisor/storm cases, Duke restoration and remaining faction-specific combinations. | [Common boundaries](NEXUS_CARD_RULES.md#remaining-source-questions-and-release-boundaries) and the individual faction source/runtime documents above |
| Orgiz collection | User question pending: take one spice per positive board pile collected, or leave the effect pending? The publisher does not clarify stacked Spice Blows. The draft quote remains opt-in and is not enabled by the engine. Contested Cistern income also remains outside the ordinary sole-occupant prototype. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) | | Orgiz collection | The [publisher rulebook, page 13](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf) says an occupant steals one spice of each collected blow, but its Q&A and searched designer/community sources do not define stacked board piles. Choose one transfer per positive collected board deposit as the observable first version, from its unique rival collector; this is an inference, not a publisher ruling. Contested Orgiz occupancy and Ecaz shared lots withhold only the uncertain transfer while ordinary collection continues. | [Discovery interpretation](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| Jacurutu physical dial | The user question asks whether the losing player should choose the physical normal/elite forces represented by its dial. When legal Advanced allocations produce different rewards, income remains explicitly unpaid; numeric wheel subtraction is not a ruling. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) | | Jacurutu physical dial | The user question asks whether the losing player should choose the physical normal/elite forces represented by its dial. When legal Advanced allocations produce different rewards, income remains explicitly unpaid; numeric wheel subtraction is not a ruling. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| Testing Station contest/timing | Only sole non-advisor occupation is prototyped. Two-occupant priority/stacking remains unresolved; opening after the ordinary storm-card window is an explicit source-timing inference. Weather Control is excluded by the printed text. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) | | Testing Station contest/timing | Only sole non-advisor occupation is prototyped. Two-occupant priority/stacking remains unresolved; opening after the ordinary storm-card window is an explicit source-timing inference. Weather Control is excluded by the printed text. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| CHOAM Secret Ally battle timing | The printed after-victory inspection is optional and the Nexus identity stays secret. No existing postbattle choice covers every winner: a pause only for a holder leaks ownership, precommitment removes the after-win choice, and a universal window makes nonholders pass. The product-exception question has been sent; no timing policy is implemented pending the answer. Ordinary Karama cancellation is also not established by the retrieved sources. | [After-victory inspection audit](NEXUS_CHOAM_SECRET_ALLY.md#after-victory-inspection-audit) | | CHOAM Secret Ally battle timing | The printed after-victory inspection is optional and the Nexus identity stays secret. No existing postbattle choice covers every winner: a pause only for a holder leaks ownership, precommitment removes the after-win choice, and a universal window makes nonholders pass. The product-exception question has been sent; no timing policy is implemented pending the answer. Ordinary Karama cancellation is also not established by the retrieved sources. | [After-victory inspection audit](NEXUS_CHOAM_SECRET_ALLY.md#after-victory-inspection-audit) |
+59 -54
View File
@@ -1,7 +1,8 @@
import { fighterCount, isAdvisor } from './advisors'; import { isAdvisor } from './advisors';
import { gameTerritories, splitLocation, type MobileBoard } from './board'; import { gameTerritories, splitLocation, type MobileBoard } from './board';
import type { AdvisorRelease } from './board-resolution-quote';
import type { Player } from './engine'; import type { Player } from './engine';
import { presenceByLocation, type ForcePresence } from './force-presence'; import { presenceAt, type ForcePresence } from './force-presence';
export const CISTERN = 'cistern'; export const CISTERN = 'cistern';
export const ORGIZ_PROCESSING_STATION = 'orgiz-processing-station'; export const ORGIZ_PROCESSING_STATION = 'orgiz-processing-station';
@@ -32,6 +33,8 @@ export type DiscoveryBaseCollectionReceipt = {
}; };
export type DiscoveryBaseCollectionQuote = { export type DiscoveryBaseCollectionQuote = {
/** Advisor stance changes already established by the ordinary quote. */
released: readonly AdvisorRelease[];
/** Deposits remaining after ordinary and shared collection. */ /** Deposits remaining after ordinary and shared collection. */
spice: Readonly<Record<string, number>>; spice: Readonly<Record<string, number>>;
receipts: readonly DiscoveryBaseCollectionReceipt[]; receipts: readonly DiscoveryBaseCollectionReceipt[];
@@ -65,14 +68,6 @@ export type DiscoveryCollectionQuote = {
effects: (CisternCollectionReceipt | OrgizCollectionReceipt)[]; effects: (CisternCollectionReceipt | OrgizCollectionReceipt)[];
}; };
export type DiscoveryCollectionOptions = {
/**
* Draft interpretation only. The publisher text does not clarify whether a
* stacked physical spice pile represents one or several “spice blows.”
*/
orgizObservableDeposits?: boolean;
};
const record = (value: unknown): value is Record<string, unknown> => const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value); !!value && typeof value === 'object' && !Array.isArray(value);
const whole = (value: unknown): value is number => const whole = (value: unknown): value is number =>
@@ -84,29 +79,41 @@ function requireCollection(
if (!condition) throw new DiscoveryCollectionError(message); if (!condition) throw new DiscoveryCollectionError(message);
} }
function isCollectingFighter(
player: DiscoveryCollectionSeat,
territory: string,
released: readonly AdvisorRelease[],
): boolean {
return !isAdvisor(player, territory) ||
released.some((entry) => entry.player === player.id && entry.territory === territory);
}
function occupants( function occupants(
context: DiscoveryCollectionContext, context: DiscoveryCollectionContext,
location: string, location: string,
released: readonly AdvisorRelease[],
): DiscoveryCollectionSeat[] { ): DiscoveryCollectionSeat[] {
if (!gameTerritories(context).some((territory) => territory.id === location)) if (!gameTerritories(context).some((territory) => territory.id === location))
return []; return [];
return context.players.filter((player) => fighterCount(player, location) > 0); return context.players.filter(
(player) => isCollectingFighter(player, location, released) &&
presenceAt(player, location) > 0,
);
} }
/** /**
* Compose Cistern, plus an explicitly enabled bounded Orgiz draft, over the * Compose sole-occupant Cistern income and a bounded Orgiz transfer over the
* canonical ordinary collection quote. The ordinary `collected` and `desert` * canonical ordinary collection quote. The ordinary `collected` and `desert`
* fields stay factual: Cistern is bank income and Orgiz moves spice afterward. * fields remain factual: Cistern is bank income; Orgiz transfers player spice.
* *
* The printed source does not resolve two occupants claiming the same location * The publisher does not specify whether a stacked physical pile is one or
* or identify a payer before an Ecaz shared lot is allocated. Cistern therefore * several blows. One positive collected board deposit is treated as one
* pays only an ordinary sole occupant; the unresolved contested case is retained * observable blow. Contested location benefits and unresolved shared lots
* without a bonus instead of blocking the Collection phase. * withhold only their uncertain effect; they never block ordinary collection.
*/ */
export function quoteDiscoveryCollection( export function quoteDiscoveryCollection(
context: DiscoveryCollectionContext, context: DiscoveryCollectionContext,
collection: DiscoveryBaseCollectionQuote, collection: DiscoveryBaseCollectionQuote,
options: DiscoveryCollectionOptions = {},
): DiscoveryCollectionQuote { ): DiscoveryCollectionQuote {
requireCollection( requireCollection(
record(context) && record(context) &&
@@ -130,10 +137,13 @@ export function quoteDiscoveryCollection(
record(collection.spice) && record(collection.spice) &&
Array.isArray(collection.receipts) && Array.isArray(collection.receipts) &&
Array.isArray(collection.shared) && Array.isArray(collection.shared) &&
record(options) && Array.isArray(collection.released) &&
Object.keys(options).every((key) => key === 'orgizObservableDeposits') && collection.released.every(
(options.orgizObservableDeposits === undefined || (entry) => record(entry) &&
typeof options.orgizObservableDeposits === 'boolean'), typeof entry.player === 'string' &&
context.order.includes(entry.player) &&
typeof entry.territory === 'string',
),
'Discovery collection needs the canonical public collection context and quote.', 'Discovery collection needs the canonical public collection context and quote.',
); );
@@ -195,7 +205,7 @@ export function quoteDiscoveryCollection(
); );
const effects: DiscoveryCollectionQuote['effects'] = []; const effects: DiscoveryCollectionQuote['effects'] = [];
const cistern = occupants(context, CISTERN); const cistern = occupants(context, CISTERN, collection.released);
if (cistern.length === 1) { if (cistern.length === 1) {
const receipt = receipts.find((entry) => entry.player === cistern[0].id)!; const receipt = receipts.find((entry) => entry.player === cistern[0].id)!;
requireCollection( requireCollection(
@@ -211,39 +221,34 @@ export function quoteDiscoveryCollection(
}); });
} }
const orgiz = occupants(context, ORGIZ_PROCESSING_STATION); const orgiz = occupants(context, ORGIZ_PROCESSING_STATION, collection.released);
const collectedLocations = sourceKeys.filter( if (orgiz.length === 1) {
(key) => context.spice[key] > collection.spice[key],
);
requireCollection(
!options.orgizObservableDeposits ||
orgiz.length <= 1 ||
collectedLocations.length === 0,
'Orgiz theft with two occupying factions awaits an explicit ruling.',
);
if (
options.orgizObservableDeposits &&
orgiz.length === 1 &&
collectedLocations.length > 0
) {
requireCollection(
!collection.shared.some((lot) => lot.amount > 0),
'Orgiz theft from an unresolved Ecaz shared lot awaits an explicit ruling.',
);
const owner = orgiz[0]; const owner = orgiz[0];
const ownerReceipt = receipts.find((entry) => entry.player === owner.id)!; const ownerReceipt = receipts.find((entry) => entry.player === owner.id)!;
for (const key of collectedLocations) { for (const key of sourceKeys) {
const collectors = context.players.filter( if (context.spice[key] <= collection.spice[key]) continue;
(player) => const { territory, sector } = splitLocation(key);
!isAdvisor(player, splitLocation(key).territory) && if (
(presenceByLocation(player)[key] ?? 0) > 0, collection.shared.some(
); (lot) => lot.amount > 0 && lot.territory === territory,
requireCollection( )
collectors.length === 1, ) continue;
'Orgiz theft needs one ordinary collector for each collected spice blow.', let collector: DiscoveryCollectionSeat | undefined;
); let contested = false;
const collector = collectors[0]; for (const player of context.players) {
if (collector.id === owner.id) continue; if (!isCollectingFighter(player, territory, collection.released)) continue;
const marker = player.noField?.deployed?.location;
if (
(player.forces[key] ?? 0) <= 0 &&
!(marker?.territory === territory && marker.sector === sector)
) continue;
if (collector) {
contested = true;
break;
}
collector = player;
}
if (contested || !collector || collector.id === owner.id) continue;
const collectorReceipt = receipts.find( const collectorReceipt = receipts.find(
(entry) => entry.player === collector.id, (entry) => entry.player === collector.id,
)!; )!;
-1
View File
@@ -14110,7 +14110,6 @@ function commitCollection(
) { ) {
let discovery: ReturnType<typeof quoteDiscoveryCollection>; let discovery: ReturnType<typeof quoteDiscoveryCollection>;
try { try {
// Orgiz remains disabled pending a source ruling on stacked spice blows.
discovery = quoteDiscoveryCollection(g, quote); discovery = quoteDiscoveryCollection(g, quote);
} catch (error) { } catch (error) {
if (error instanceof DiscoveryCollectionError) if (error instanceof DiscoveryCollectionError)
+6 -6
View File
@@ -3887,7 +3887,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Advanced & expansions', category: 'Advanced & expansions',
coverage: 'Partial', coverage: 'Partial',
developmentStage: 'Prototyped', developmentStage: 'Prototyped',
summary: 'A development prototype connects Great Maker, six Discovery spice blows, private token inspection, stash rewards, revealed locations, next-turn entry, Cistern income, Ornithopter movement, Jacurutu income, Testing Station and Shrine. Remaining interpretations and normal public starts stay gated.', summary: 'A development prototype connects Great Maker, six Discovery spice blows, private token inspection, stash rewards, revealed locations, next-turn entry, Cistern income, bounded Orgiz transfers, Ornithopter movement, Jacurutu income, Testing Station and Shrine. Remaining interpretations and normal public starts stay gated.',
searchText: 'Hiereg Smuggler Jacurutu Sietch Cistern Ecological Testing Station Shrine Orgiz Processing Station Treachery Card Stash Spice Stash Ornithopter', searchText: 'Hiereg Smuggler Jacurutu Sietch Cistern Ecological Testing Station Shrine Orgiz Processing Station Treachery Card Stash Spice Stash Ornithopter',
steps: [ steps: [
'The Discovery prototype starts through genuine base-faction setup in Basic or Advanced, with seven extra Spice Cards and eight physical tokens. Its normal public start gate stays closed while the remaining effects are connected and verified.', 'The Discovery prototype starts through genuine base-faction setup in Basic or Advanced, with seven extra Spice Cards and eight physical tokens. Its normal public start gate stays closed while the remaining effects are connected and verified.',
@@ -3898,7 +3898,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Revealing Ornithopter records who carries it and the turn it was gained. On a later turn, its owner may spend it on one ordinary movement action to give that selected group a fixed range of three. It grants no extra action, adds no shipment and is removed only after the movement commits; saved arrival interactions retain the exact selected group.', 'Revealing Ornithopter records who carries it and the turn it was gained. On a later turn, its owner may spend it on one ordinary movement action to give that selected group a fixed range of three. It grants no extra action, adds no shipment and is removed only after the movement commits; saved arrival interactions retain the exact selected group.',
'The five revealed location tokens become separate, initially empty territories inside their surroundings. A private peek does not open the location. Normal ground movement enters through the surrounding territory, shipment uses stronghold prices, at most two factions may occupy it, and forces inside are protected from storms and sandworms. Only Jacurutu Sietch counts toward stronghold victory.', 'The five revealed location tokens become separate, initially empty territories inside their surroundings. A private peek does not open the location. Normal ground movement enters through the surrounding territory, shipment uses stronghold prices, at most two factions may occupy it, and forces inside are protected from storms and sandworms. Only Jacurutu Sietch counts toward stronghold victory.',
'At the start of the next turn, before the storm and mobile stronghold movement, each eligible faction may move any positive subset of its non-advisor groups from the surrounding territory into the newly revealed location. The signed choice preserves ordinary and elite forces across sectors, normal source storm and occupancy restrictions, and costs no spice, shipment or normal movement. The printed text gives no sequence and does not restate the source-storm restriction; processing tokens in reveal order and factions in storm order while excluding storm sources are source inferences.', 'At the start of the next turn, before the storm and mobile stronghold movement, each eligible faction may move any positive subset of its non-advisor groups from the surrounding territory into the newly revealed location. The signed choice preserves ordinary and elite forces across sectors, normal source storm and occupancy restrictions, and costs no spice, shipment or normal movement. The printed text gives no sequence and does not restate the source-storm restriction; processing tokens in reveal order and factions in storm order while excluding storm sources are source inferences.',
'A sole occupant of Cistern receives two bank spice during Collection. The printed text does not settle which of two occupants receives this benefit, so the contested bonus is withheld without blocking the phase. Orgiz transfer remains unavailable pending a ruling on what counts as each collected spice blow.', 'A sole occupant of Cistern receives two bank spice during Collection; contested occupancy withholds that bonus without blocking the phase. A sole Orgiz occupant takes one spice from each rival who collected a positive board deposit, using each collected pile as one observable blow. This is a provisional interpretation of the printed blow wording. Contested Orgiz occupancy and unresolved Ecaz shared lots withhold only the uncertain transfer while ordinary collection continues.',
'Jacurutu automatically pays one bank spice for each opposing undialed physical force sent to the Tanks. Where Advanced normal/elite allocations would produce different rewards, this prototype records the unpaid gap pending a ruling; it never guesses from the numeric wheel alone.', 'Jacurutu automatically pays one bank spice for each opposing undialed physical force sent to the Tanks. Where Advanced normal/elite allocations would produce different rewards, this prototype records the unpaid gap pending a ruling; it never guesses from the numeric wheel alone.',
'A sole non-advisor occupant of Ecological Testing Station may decrease ordinary storm movement by one, keep it, or increase it by one. Choose after the ordinary storm-card window, before movement and storm protection. Weather Control cannot be changed. Two-occupant adjustment order remains unresolved. An already saved pending storm without its original movement source keeps its distance until a new storm is produced.', 'A sole non-advisor occupant of Ecological Testing Station may decrease ordinary storm movement by one, keep it, or increase it by one. Choose after the ordinary storm-card window, before movement and storm protection. Weather Control cannot be changed. Two-occupant adjustment order remains unresolved. An already saved pending storm without its original movement source keeps its distance until a new storm is produced.',
'A current non-advisor Shrine occupant may play a physical Truthtrance card as Karama, or a physical Karama as Truthtrance, within the implemented timing and effect paths. The printed identity and actual card custody remain unchanged; a committed Truthtrance conversion survives departure. This does not create missing Karama powers or settle their pending rules.', 'A current non-advisor Shrine occupant may play a physical Truthtrance card as Karama, or a physical Karama as Truthtrance, within the implemented timing and effect paths. The printed identity and actual card custody remain unchanged; a committed Truthtrance conversion survives departure. This does not create missing Karama powers or settle their pending rules.',
@@ -3908,12 +3908,12 @@ export const RULE_TOPICS: RuleTopic[] = [
checklist: [ checklist: [
{ {
area: 'Implementation', status: 'Partial', area: 'Implementation', status: 'Partial',
detail: 'Working prototypes cover genuine setup, physical inventory, destructive blows, Great Maker vote/ride, private inspection/reveal, stash rewards, public nested locations, signed next-turn entry, sole-occupant Cistern income and later-turn Ornithopter spending, unambiguous Jacurutu income, sole-occupant Testing Station and Shrine card roles. Orgiz and material interpretations remain pending.', detail: 'Working prototypes cover genuine setup, physical inventory, destructive blows, Great Maker vote/ride, private inspection/reveal, stash rewards, public nested locations, signed next-turn entry, sole-occupant Cistern income, bounded Orgiz transfers and later-turn Ornithopter spending, unambiguous Jacurutu income, sole-occupant Testing Station and Shrine card roles. Contested and mixed interpretations remain pending.',
evidence: ['game/discoveries.ts', 'game/discovery-actions.ts', 'game/discovery-entry.ts', 'game/discovery-collection.ts', 'game/discovery-flight.ts', 'game/discovery-battle.ts', 'game/discovery-storm.ts', 'game/shrine.ts', 'game/great-maker.ts', 'game/board.ts', 'game/engine.ts'], evidence: ['game/discoveries.ts', 'game/discovery-actions.ts', 'game/discovery-entry.ts', 'game/discovery-collection.ts', 'game/discovery-flight.ts', 'game/discovery-battle.ts', 'game/discovery-storm.ts', 'game/shrine.ts', 'game/great-maker.ts', 'game/board.ts', 'game/engine.ts'],
}, },
{ {
area: 'Player controls', status: 'Partial', area: 'Player controls', status: 'Partial',
detail: 'Private backs and readable authorized faces, optional inspection/reveal, owned stash discard with card inspection, ordered Great Maker controls, multi-sector free entry and carried-Ornithopter movement use shared table controls. Testing Station offers three legal distances and Shrine uses existing card controls. Unambiguous Jacurutu income is automatic; unresolved rewards have no invented choice.', detail: 'Private backs and readable authorized faces, optional inspection/reveal, owned stash discard with card inspection, ordered Great Maker controls, multi-sector free entry and carried-Ornithopter movement use shared table controls. Testing Station offers three legal distances and Shrine uses existing card controls. Unambiguous Jacurutu and bounded Orgiz income are automatic and logged; unresolved rewards have no invented choice.',
evidence: ['components/discoveries.tsx', 'components/discovery-entry.tsx', 'components/discovery-flight-movement.tsx', 'components/discovery-storm.tsx', 'components/truthtrance.tsx', 'components/great-maker.tsx', 'components/spice-card-inspector.tsx', 'components/game-table.tsx'], evidence: ['components/discoveries.tsx', 'components/discovery-entry.tsx', 'components/discovery-flight-movement.tsx', 'components/discovery-storm.tsx', 'components/truthtrance.tsx', 'components/great-maker.tsx', 'components/spice-card-inspector.tsx', 'components/game-table.tsx'],
}, },
{ {
@@ -3923,7 +3923,7 @@ export const RULE_TOPICS: RuleTopic[] = [
}, },
{ {
area: 'Documentation', status: 'Partial', area: 'Documentation', status: 'Partial',
detail: 'Sourced identities, connected behavior and the remaining dependency queue are recorded. Great Maker first-turn/Sandtrout handling, exhausted token supply, free-entry order and source-storm handling remain identified interpretations for refinement; contested Cistern/Testing Station, mixed-force Jacurutu and Orgiz collection cases remain unresolved.', detail: 'Sourced identities, connected behavior and the remaining dependency queue are recorded. Great Maker first-turn/Sandtrout handling, exhausted token supply, free-entry order, source-storm handling and Orgiz per-collected-deposit interpretation remain explicit inferences; contested Cistern/Testing Station/Orgiz, mixed-force Jacurutu and shared-lot theft remain unresolved.',
evidence: ['docs/DISCOVERY_COMPONENTS.md', 'docs/DISCOVERY_PROTOTYPE.md'], evidence: ['docs/DISCOVERY_COMPONENTS.md', 'docs/DISCOVERY_PROTOTYPE.md'],
}, },
{ {
@@ -4294,7 +4294,7 @@ export const RULE_TOPICS: RuleTopic[] = [
...(id === 'ecaz-modules' ...(id === 'ecaz-modules'
? [ ? [
'Ecaz inventory, public Ambassador inspection, end-of-Revival placement, six entry effects with Bene Gesserit copies and direct Ecaz Duke acquisition have focused support. Storm/explosion token returns are supported. Other effects and competing arrival ordering remain unfinished. See the linked feature checklist; full expansion starts remain disabled.', 'Ecaz inventory, public Ambassador inspection, end-of-Revival placement, six entry effects with Bene Gesserit copies and direct Ecaz Duke acquisition have focused support. Storm/explosion token returns are supported. Other effects and competing arrival ordering remain unfinished. See the linked feature checklist; full expansion starts remain disabled.',
'The Discovery prototype connects seven Spice Cards, eight tokens, Great Maker, private Collection inspection/reveal, stash rewards, revealed nested locations, next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter movement. Jacurutu income, Testing Station and Shrine now have bounded prototypes. Orgiz, mixed-force Jacurutu and contested benefits remain pending; see the Discovery checklist.', 'The Discovery prototype connects seven Spice Cards, eight tokens, Great Maker, private Collection inspection/reveal, stash rewards, revealed nested locations, next-turn free entry, sole-occupant Cistern income, bounded Orgiz transfers and later-turn Ornithopter movement. Jacurutu income, Testing Station and Shrine also have bounded prototypes. Mixed-force Jacurutu and contested benefits remain pending; see the Discovery checklist.',
] ]
: []), : []),
'The expansion catalog is present. Expansion faction mechanics, remaining modules and their full interaction reference remain in development.', 'The expansion catalog is present. Expansion faction mechanics, remaining modules and their full interaction reference remain in development.',
+140 -23
View File
@@ -66,15 +66,11 @@ function reveal(game: Game, id: DiscoveryLocationId) {
validateDiscoveryState(game.discoveries!); validateDiscoveryState(game.discoveries!);
} }
function composed(game: Game, orgizObservableDeposits = false) { function composed(game: Game) {
const ordinary = quoteSpiceCollection(game); const ordinary = quoteSpiceCollection(game);
return { return {
ordinary, ordinary,
discovery: quoteDiscoveryCollection( discovery: quoteDiscoveryCollection(game, ordinary),
game,
ordinary,
orgizObservableDeposits ? { orgizObservableDeposits: true } : {},
),
}; };
} }
@@ -123,7 +119,7 @@ void test('a sole Cistern occupant receives two bank spice in Basic and Advanced
} }
}); });
void test('Cistern requires non-advisor occupation and has no effect while unrevealed or empty', () => { void test('Cistern follows Collection advisor releases and excludes remaining advisors', () => {
const hidden = fixture(true); const hidden = fixture(true);
const hiddenToken = hidden.discoveries!.tokens.find( const hiddenToken = hidden.discoveries!.tokens.find(
(entry) => entry.face === CISTERN, (entry) => entry.face === CISTERN,
@@ -142,7 +138,17 @@ void test('Cistern requires non-advisor occupation and has no effect while unrev
reveal(advisors, CISTERN); reveal(advisors, CISTERN);
advisors.players[0].forces = { [`${CISTERN}:0`]: 1 }; advisors.players[0].forces = { [`${CISTERN}:0`]: 1 };
advisors.players[0].advisors = { [CISTERN]: {} }; advisors.players[0].advisors = { [CISTERN]: {} };
assert.deepEqual(composed(advisors).discovery.effects, []); const released = composed(advisors);
assert.deepEqual(released.ordinary.released, [{ player: 'b', territory: CISTERN }]);
assert.deepEqual(released.discovery.effects, [
{ kind: 'cistern', player: 'b', amount: 2, source: 'bank' },
]);
advisors.players[1].forces = { [`${CISTERN}:0`]: 1 };
const remainingAdvisor = composed(advisors);
assert.deepEqual(remainingAdvisor.ordinary.released, []);
assert.deepEqual(remainingAdvisor.discovery.effects, [
{ kind: 'cistern', player: 'h', amount: 2, source: 'bank' },
]);
}); });
void test('the bounded Orgiz quote transfers one spice from each uniquely collected observable board deposit', () => { void test('the bounded Orgiz quote transfers one spice from each uniquely collected observable board deposit', () => {
@@ -160,7 +166,7 @@ void test('the bounded Orgiz quote transfers one spice from each uniquely collec
'cielago_south:2': 3, 'cielago_south:2': 3,
}; };
const before = structuredClone(game); const before = structuredClone(game);
const { ordinary, discovery } = composed(game, true); const { ordinary, discovery } = composed(game);
assert.deepEqual(discovery.effects, [ assert.deepEqual(discovery.effects, [
{ {
kind: 'orgiz', kind: 'orgiz',
@@ -199,6 +205,25 @@ void test('the bounded Orgiz quote transfers one spice from each uniquely collec
assert.deepEqual(quoteSpiceCollection(game), ordinary); assert.deepEqual(quoteSpiceCollection(game), ordinary);
}); });
void test('one rival pays once per collected pile, not once per player or stacked spice amount', () => {
const game = fixture();
reveal(game, ORGIZ_PROCESSING_STATION);
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
game.players[1].forces = { 'hagga_basin:12': 1, 'cielago_south:2': 1 };
game.spice = { 'hagga_basin:12': 8, 'cielago_south:2': 2 };
const { ordinary, discovery } = composed(game);
assert.deepEqual(
discovery.effects.filter(effect => effect.kind === 'orgiz')
.map(effect => [effect.from, effect.location]),
[['h', 'cielago_south:2'], ['h', 'hagga_basin:12']],
);
assert.equal(balance(discovery, 'a'), 2);
assert.equal(balance(discovery, 'h'), balance({
...discovery, receipts: ordinary.receipts,
}, 'h') - 2);
assert.equal(ordinary.spice['hagga_basin:12'], 6);
});
void test('Orgiz does not transfer spice from its occupant to itself and ignores untouched deposits', () => { void test('Orgiz does not transfer spice from its occupant to itself and ignores untouched deposits', () => {
const game = fixture(); const game = fixture();
reveal(game, ORGIZ_PROCESSING_STATION); reveal(game, ORGIZ_PROCESSING_STATION);
@@ -212,10 +237,7 @@ void test('Orgiz does not transfer spice from its occupant to itself and ignores
'hagga_basin:12': 2, 'hagga_basin:12': 2,
'cielago_south:2': 6, 'cielago_south:2': 6,
}; };
const disabled = quoteDiscoveryCollection(game, quoteSpiceCollection(game)); const { ordinary, discovery } = composed(game);
assert.deepEqual(disabled.effects, []);
assert.deepEqual(disabled.receipts, quoteSpiceCollection(game).receipts);
const { ordinary, discovery } = composed(game, true);
assert.deepEqual(discovery.effects, [ assert.deepEqual(discovery.effects, [
{ {
kind: 'orgiz', kind: 'orgiz',
@@ -234,7 +256,27 @@ void test('Orgiz does not transfer spice from its occupant to itself and ignores
assert.equal(discovery.receipts[0].desert, ordinary.receipts[0].desert); assert.equal(discovery.receipts[0].desert, ordinary.receipts[0].desert);
}); });
void test('Orgiz retains unresolved Ecaz shared custody instead of creating or assigning spice', () => { void test('Orgiz charges a collector whose advisors become fighters before Collection', () => {
const game = fixture(true, [
['a', 'atreides'], ['b', 'beneGesserit'], ['h', 'harkonnen'],
]);
reveal(game, ORGIZ_PROCESSING_STATION);
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
game.players[1].forces = { 'hagga_basin:12': 1 };
game.players[1].advisors = { hagga_basin: { lockedTurn: 1 } };
game.spice = { 'hagga_basin:12': 2 };
const ordinary = quoteSpiceCollection(game);
assert.deepEqual(ordinary.released, [{ player: 'b', territory: 'hagga_basin' }]);
const discovery = quoteDiscoveryCollection(game, ordinary);
assert.deepEqual(discovery.effects, [{
kind: 'orgiz', player: 'a', from: 'b', location: 'hagga_basin:12',
amount: 1, source: 'player',
}]);
assert.equal(balance(discovery, 'a'), 1);
assert.equal(balance(discovery, 'b'), 1);
});
void test('Orgiz withholds uncertain Ecaz shared theft while collecting an independent deposit', () => {
const game = fixture(false, [ const game = fixture(false, [
['e', 'ecaz'], ['e', 'ecaz'],
['a', 'atreides'], ['a', 'atreides'],
@@ -243,25 +285,56 @@ void test('Orgiz retains unresolved Ecaz shared custody instead of creating or a
reveal(game, ORGIZ_PROCESSING_STATION); reveal(game, ORGIZ_PROCESSING_STATION);
game.players[0].ally = 'a'; game.players[0].ally = 'a';
game.players[1].ally = 'e'; game.players[1].ally = 'e';
game.players[0].forces = { 'hagga_basin:12': 1 }; game.players[0].forces = { 'hagga_basin:12': 1, 'red_chasm:7': 1 };
game.players[1].forces = { 'hagga_basin:12': 1 }; game.players[1].forces = { 'hagga_basin:12': 1 };
game.players[2].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 }; game.players[2].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
game.spice = { 'hagga_basin:12': 4 }; game.spice = { 'hagga_basin:12': 4, 'red_chasm:7': 2 };
const ordinary = quoteSpiceCollection(game); const ordinary = quoteSpiceCollection(game);
assert.deepEqual(ordinary.shared, [ assert.deepEqual(ordinary.shared, [
{ territory: 'hagga_basin', ecaz: 'e', ally: 'a', amount: 4 }, { territory: 'hagga_basin', ecaz: 'e', ally: 'a', amount: 4 },
]); ]);
const before = structuredClone({ game, ordinary }); const before = structuredClone({ game, ordinary });
assert.throws( const discovery = quoteDiscoveryCollection(game, ordinary);
() => assert.deepEqual(discovery.effects, [{
quoteDiscoveryCollection(game, ordinary, { kind: 'orgiz', player: 'g', from: 'e', location: 'red_chasm:7',
orgizObservableDeposits: true, amount: 1, source: 'player',
}), }]);
/unresolved Ecaz shared lot/, assert.equal(balance(discovery, 'e'), 1);
); assert.equal(balance(discovery, 'g'), 1);
assert.deepEqual({ game, ordinary }, before); assert.deepEqual({ game, ordinary }, before);
}); });
void test('two allied Orgiz occupants leave ordinary collection playable without uncertain theft', () => {
const game = fixture(false, [
['e', 'ecaz'], ['a', 'atreides'], ['g', 'guild'],
]);
game.discoveryEnabled = true;
reveal(game, ORGIZ_PROCESSING_STATION);
game.players[0].ally = 'a';
game.players[1].ally = 'e';
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
game.players[1].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
game.players[2].forces = { 'hagga_basin:12': 1 };
game.players.forEach(player => {
Object.assign(player, {
hand: [], traitors: [], traitorChoices: [], shipped: true,
moved: 1, reserves: 19,
});
});
game.spice = { 'hagga_basin:12': 2 };
const ordinary = quoteSpiceCollection(game);
assert.deepEqual(quoteDiscoveryCollection(game, ordinary), {
receipts: ordinary.receipts, effects: [],
});
Object.assign(game, {
phase: 5, active: 'g', movementRemaining: ['g'], phaseOpening: null,
});
const collected = applyAction(game, 'g', { type: 'endMovement' });
assert.equal(collected.phase, 7);
assert.deepEqual(collected.players.map(player => player.spice), [0, 0, 2]);
assert.equal(collected.spice['hagga_basin:12'], 0);
});
void test('a contested Cistern retains the unresolved bonus without blocking collection', () => { void test('a contested Cistern retains the unresolved bonus without blocking collection', () => {
const game = fixture(false, [ const game = fixture(false, [
['e', 'ecaz'], ['e', 'ecaz'],
@@ -336,6 +409,50 @@ void test('the real collection phase credits Cistern once and preserves the rece
); );
}); });
void test('a revealed sole Orgiz occupant steals one from each rival collected deposit in Basic and Advanced', () => {
for (const advanced of [false, true]) {
const game = fixture(advanced);
game.discoveryEnabled = true;
reveal(game, ORGIZ_PROCESSING_STATION);
Object.assign(game, {
phase: 5,
active: 'g',
movementRemaining: ['g'],
phaseOpening: null,
});
for (const player of game.players)
Object.assign(player, {
hand: [], traitors: [], traitorChoices: [], shipped: true,
moved: 1, reserves: 20,
});
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1, 'red_chasm:7': 1 };
game.players[0].reserves = 18;
game.players[1].forces = { 'hagga_basin:12': 1 };
game.players[1].reserves = 19;
game.players[2].forces = { 'cielago_south:2': 1 };
game.players[2].reserves = 19;
game.spice = { 'red_chasm:7': 2, 'hagga_basin:12': 4, 'cielago_south:2': 3 };
const before = structuredClone(game);
const collected = applyAction(game, 'g', { type: 'endMovement' });
assert.deepEqual(game, before);
assert.equal(collected.phase, 7);
assert.deepEqual(collected.players.map(player => player.spice), [4, 1, 1]);
assert.equal(collected.spice['red_chasm:7'], 0);
assert.equal(collected.spice['hagga_basin:12'], 2);
assert.equal(collected.spice['cielago_south:2'], 1);
assert.equal(
collected.players.reduce((total, player) => total + player.spice, 0) +
Object.values(collected.spice).reduce((total, spice) => total + spice, 0),
9,
);
assert.equal(collected.log.filter(entry => entry.automatic?.name === 'Orgiz Processing Station').length, 2);
const restored = JSON.parse(JSON.stringify(collected)) as Game;
assert.deepEqual(normalizeAutomaticGame(restored), restored);
assert.deepEqual(restored.players.map(player => player.spice), [4, 1, 1]);
}
});
void test('a saved Ecaz response commits Cistern once only after the collection response resolves', () => { void test('a saved Ecaz response commits Cistern once only after the collection response resolves', () => {
const game = createGame( const game = createGame(
'DISCOVERYECAZCOLLECTION', 'DISCOVERYECAZCOLLECTION',
+1 -15
View File
@@ -8,7 +8,7 @@ import {
} from '../game/engine'; } from '../game/engine';
import { botActions } from '../game/bots'; import { botActions } from '../game/bots';
import { import {
DISCOVERY_TOKENS, placeDiscovery, rememberDiscoveryFace, revealDiscoveryToken, placeDiscovery, rememberDiscoveryFace, revealDiscoveryToken,
type DiscoveryTokenFace, type DiscoveryTokenFace,
} from '../game/discoveries'; } from '../game/discoveries';
import { discoveryAction, discoveryBotActions, discoveryDiscardAction } from '../game/discovery-options'; import { discoveryAction, discoveryBotActions, discoveryDiscardAction } from '../game/discovery-options';
@@ -94,20 +94,6 @@ void test('Fremen and Guild privileged faces remain distinct from another seat
assert.deepEqual(discoveryBotActions(guild), []); assert.deepEqual(discoveryBotActions(guild), []);
}); });
void test('all eight authorized faces have readable rules while shared location rules preserve parent-territory meaning', () => {
const game = fixture();
for (const definition of DISCOVERY_TOKENS) place(game, definition.id, true);
const markup = html(viewGame(game, 'p'));
for (const definition of DISCOVERY_TOKENS) assert.ok(markup.includes(`Read ${definition.name} rules`), definition.name);
for (const text of ['opposing undialed force', 'two spice from the bank', 'Weather Control',
'use a Truthtrance card as Karama', 'each spice blow that is collected', 'including the new card',
'Receive seven spice', 'On a later turn', 'separate territory inside', 'At most two factions',
'protected from storms and sandworms', 'before the storm and the mobile stronghold move']) assert.ok(markup.includes(text), text);
assert.doesNotMatch(markup, /href="https?:/);
assert.ok(markup.includes('Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter movement are available in the development prototype.'));
assert.ok(markup.includes('Orgiz, mixed-force Jacurutu rewards and contested Cistern/Testing Station benefits remain pending.'));
});
void test('all four AI profiles inspect and then reveal through fresh private views and actual saved actions', () => { void test('all four AI profiles inspect and then reveal through fresh private views and actual saved actions', () => {
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
let game = fixture(); let game = fixture();
+65
View File
@@ -4,8 +4,10 @@ import { unitStore } from './fixture-nexus-room-store';
import { import {
discoveryFixture, discoveryFixture,
enterDiscoveryCollection, enterDiscoveryCollection,
putDiscovery,
} from './fixture-discovery'; } from './fixture-discovery';
import { applyAction, viewGame, type Game } from '../game/engine'; import { applyAction, viewGame, type Game } from '../game/engine';
import { revealDiscoveryToken } from '../game/discoveries';
import { discoveryStashSignature } from '../game/discovery-actions'; import { discoveryStashSignature } from '../game/discovery-actions';
import { startPrototypeRoom } from '../tools/prototype-room'; import { startPrototypeRoom } from '../tools/prototype-room';
import type { RoomsClock } from '../db/rooms'; import type { RoomsClock } from '../db/rooms';
@@ -180,3 +182,66 @@ void test('SQLite Discovery prototype preserves seats and competing stash reveal
store.sqlite.close(); store.sqlite.close();
} }
}); });
void test('competing saved Collection commits transfer Orgiz spice exactly once', async () => {
const store = unitStore();
try {
const made = await store.rooms.createRoom('Orgiz SQL', 'atreides', false, []);
const code = made.view.code;
const tokens = [made.token];
for (const faction of ['guild', 'fremen'] as const)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
const ids = auths.map(auth => auth.playerId) as [string, string, string];
const g = discoveryFixture(false, ids);
g.code = code;
g.version = (await store.rooms.readRoom(code)).version;
const token = putDiscovery(g, 'orgiz-processing-station');
g.discoveries = revealDiscoveryToken(g.discoveries!, token.id, g.turn).state;
Object.assign(g, {
phase: 5, active: ids[2], movementRemaining: [ids[2]],
phaseOpening: null, response: null, decision: null,
storm: 18, stormPending: null,
spice: { 'hagga_basin:12': 4 },
});
for (const player of g.players)
Object.assign(player, {
forces: {}, reserves: 20, shipped: true, moved: 1,
});
g.players[0].forces = { 'orgiz-processing-station:0': 1 };
g.players[0].reserves = 19;
g.players[1].forces = { 'hagga_basin:12': 1 };
g.players[1].reserves = 19;
const beforeOwner = g.players[0].spice, beforeCollector = g.players[1].spice;
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(g), g.version, code);
let arrivals = 0, release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
store.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await gate;
};
const timer = setTimeout(release, 2000);
try {
const results = await Promise.allSettled([0, 1].map(() =>
store.restart().act(code, auths[2], g.version, { type: 'endMovement' }, clock)));
assert.equal(arrivals, 2);
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
} finally {
clearTimeout(timer);
delete store.hooks.beforeWrite;
}
const done = await store.restart().readRoom(code);
assert.equal(done.version, g.version + 1);
assert.equal(done.players[0].spice, beforeOwner + 1);
assert.equal(done.players[1].spice, beforeCollector + 1);
assert.equal(done.spice['hagga_basin:12'], 2);
assert.equal(done.log.filter(entry => entry.automatic?.name === 'Orgiz Processing Station').length, 1);
const settled = JSON.stringify(done);
await store.restart().continueRoomAutomatic(code, clock);
assert.equal(JSON.stringify(await store.restart().readRoom(code)), settled);
} finally {
store.sqlite.close();
}
});