Distinguish Atreides special No-Field plan inspection

This commit is contained in:
Quintine committed 2026-10-16 09:37:01 +11:00
1 parent 0dfdeb9578
commit 24c38ebccd
15 files changed
+1092 -33

No files matched your search

+9
View File
@@ -3186,6 +3186,15 @@ export function GameTable({
either combatant’s full plan. The chosen player commits
first, then only you receive the inspection.
</p>
{!!g.battle?.noFieldPlayers.length && (
<p className="notice">
Provisional power distinction: special Karama reads the
complete committed No-Field plan, including dial and
support. Ordinary Prescience still cannot request its dial.
This private read does not reveal or materialize the marker
before both plans seal.
</p>
)}
<HelpTip topic="fullPlan" />
{me.hand
?.filter(canActivateKarama)
+9
View File
@@ -36,6 +36,15 @@ export function PrivateBattlePlan({ game }: { game: GameView }) {
other combatant can prepare and seal their plan without an inspection
confirmation.
</p>
{game.battle?.noFieldPlayers.includes(insight.target) && (
<p className="notice">
Provisional special-power interpretation: this is the actual committed
dial and support, not the concealed token inventory. Ordinary dial
Prescience remains prohibited. The original marker materializes only
at the public two-plan reveal; this private inspection is not a
voluntary token reveal.
</p>
)}
<dl>
<div>
<dt>Dial</dt>
+55
View File
@@ -4,6 +4,61 @@
paths without promoting an implementation timing choice to an official ruling.
The public Advanced and unfinished expansion gates remain closed.
## Supplied-source No-Field composition — 16 October 2026
The authorized supplied revision, physical **p27 C**, restricts the ordinary
one-element Battle Prescience dial choice against a No-Field. The distinct
**p27 F** special Karama says to look at any one player's **entire Battle
Plan** before public revelation. Physical **p36 B** names Prescience in its
No-Field dial exception; it does not separately name this special power.
Under standing choose-recommended, the prototype provisionally keeps those
powers distinct: ordinary dial Prescience stays prohibited, but native
Advanced special Karama may inspect the actual complete committed plan.
This is explicit textual composition, not a newly retrieved combined FAQ or
a newly answered user question. Other native special-use/counter/mode limits
and the provisional original target-first offer order remain.
The actual target commits first. Only Atreides receives that plan's dial,
support, leader and physical played faces. This entitlement does not expose
the token inventory or a prospective maximum to other seats. Private reading
is not public token revelation: reserves, marker and original event remain
unchanged until both combatants seal, then the native zero/capped materialization
and card/cost/loss history run once. This private-read-versus-public-reveal
composition is provisional as well. No saved field, Basic power or public
expansion start is added.
The same distinction follows native mixed Occupy: an active Ecaz plan uses
the original fixed increment plus Richese variable dial; prevention restores
only the chosen lead's own pool. It does not erase the unrelated marker's
original revelation or alter original card-user funding/physical losses.
### Scoped native proof
Eighteen new actual-shipment inspection cases and the frozen29-file393/393
related union pass, with types/lint/build and existing warnings. A final
type-narrowed real-Traitor case passes1/1. Fixture repairs preserve real shared
preleader readiness and test an actually legal alternate leader's ordinary
commitment; no product preparation or target-first guard is weakened.
Four actual390px journeys finish original battle-card cleanup/CHOAM market
into phase7: zero with real leader/cards; mixed ordinary dial3.5/support2;
Ecaz total5/support0 with marker5 capped to2; Richese H/R total6/support3.
Each actual private read leaves the native marker, reserves and force arrays
unchanged. Ordinary Prescience controls still offer only leader/weapon/defense.
The mixed defeat loses6 real counters. The capped co-side loses4 Richese and3
Ecaz counters; H/R spends its original3 after materialization, returns2 and
ends Richese reserves2/Tanks6, Ecaz Tanks3. Native token history remains.
Sixteen fresh native programs across all four profiles accept92 policy actions
through Collection, including original active/prevented owners and actual
Traitor outcomes. The guide imports with34 evidence paths/source anchor valid;
five No-Field facets and both topic coverages stay Partial. No overflow/browser/
application error; owned host/tab/scaffolds removed. Native controls here use
explicit conserved battle staging after genuine setup/shipment, not complete
unstaged games, new save assurance or deployed acceptance.
## Published requirements and remaining interpretation
The [GF9 base rulebook, printed p14](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf)
+23
View File
@@ -29,6 +29,29 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
**Distinct Advanced special Karama / No-Field — 16 October.** Supplied p27 C
and p36 B bar ordinary dial Prescience; distinct p27 F grants an entire-plan
special. Standing choose-recommended provisionally keeps them separate.
Native special Karama now reads the complete committed No-Field plan while
ordinary dial remains barred. Private target-first reading is not public
token revelation; both seals still materialize once, then original fees,
returns, support/losses and history run. No new saved field, Basic power,
mode or module gate. This is explicit composition, not a new combined FAQ.
Eighteen new actual-shipment cases and29-file393/393 related union pass.
Types/lint and build pass with existing warnings; the final narrowed real
Traitor case also passes1/1. Four actual390px journeys show zero with real
cards, ordinary mixed3.5/support2, capped Ecaz5/support0 and Richese H/R6/
support3 private reads, followed by native cleanup/market into phase7.
First reads retain concealed marker/force/reserve custody; ordinary UI still
offers no dial request. Sixteen fresh native programs/all four policies accept
92 policy actions through Collection. Actual guide import resolves34 evidence
paths/source anchor; five No-Field facets and both topic coverages stay Partial.
Fixture-only fixes complete real preleader readiness, test a different legal
leader's broken commitment, and narrow Action.leader. No product guard weakened.
Owned host/tab/scaffolds removed; no human game, D1 or deployment changed.
[Exact supplied-source distinction and proof](ATREIDES_FULL_PLAN_TIMING.md#supplied-source-no-field-composition--16-october-2026).
**Mixed ordinary/No-Field native Occupy — 16 October.** The selected Advanced
profile extends the existing explicitly provisional additive pool to allied
and opposing Richese. Original fixed Ecaz strength stays separate. The selected
+7 -2
View File
@@ -302,13 +302,18 @@ only available reserves, and reveals with Battle Plans. A marker-only Richese
ally therefore remains original battle presence even with a zero prospective
pool. The selected plan actor receives its private variable pool; rivals do
not receive that pool or an Occupy profile containing the secret denomination.
The physical Richese owner reveals once after both seals. Dial prescience and
whole-plan restrictions follow the actual variable-force owner rather than the
The physical Richese owner reveals once after both seals. Ordinary dial
Prescience restrictions follow the actual variable-force owner rather than the
original chooser. Preventing Occupy with Ecaz lead makes only Ecaz's own pool
inspectable; the unrelated marker stays concealed until original reveal timing.
Richese's ordinary-plus-marker pool now composes under the separate
[explicitly provisional native interpretation](RICHESE_NO_FIELD_RULES.md#mixed-native-occupy-pool--16-october-2026).
Broader disclosure and Basic mixed-co-side/module rulings remain separate.
The distinct Advanced special Karama may privately inspect the actual complete
No-Field plan under the
[provisional supplied-source distinction](ATREIDES_FULL_PLAN_TIMING.md#supplied-source-no-field-composition--16-october-2026);
the private read neither reveals the token early nor recalculates fixed losses.
Stone Burner compares **floor(E/2) + native ally physical undialed tokens** for
active Occupy, or the **selected lead's own pool only** after prevention.
+38
View File
@@ -1,5 +1,43 @@
# Dune implementation status
## 16 October 2026 — Distinct Advanced special inspection against No-Fields
Read the supplied authorized revision's physical p27 C/F and p36 B. Ordinary
dial Prescience's explicit exception is distinct from F's entire-plan special
Karama. Standing choose-recommended provisionally permits the special's exact
sealed-plan read while retaining ordinary dial prohibition. This textual
distinction and private-read-versus-public-reveal order are explicit composition,
not a new combined publisher FAQ or answered user question.
Before-state genuine mixed Occupy with either lead rejected the actual special
at its original offer. Remove only that guard. Existing native card/once-use,
target-first/private entitlement, ordinary commitments and actual both-seals
marker materialization/history remain. No saved field, Basic power/profile,
module or public start gate changes. Offer and private panel explain timing.
Eighteen new actual-shipment cases plus29-file393/393 pass. Types/lint/build
pass; existing module-register/chunk-size/route warnings remain. Final narrowed
real-Traitor case passes1/1. The new fixture first stopped before native shared
preleader readiness; it now completes the actual controls before powers. An
ordinary-commitment probe now selects a different legal leader instead of
failing earlier for no leader. Action.leader is explicitly type-narrowed.
These repair test preconditions/types, not product guards or error suppression.
Four actual390px journeys pass: marker0 with physical cards, ordinary mixed
dial3.5/support2, capped Ecaz total5/support0, and real Richese H/R total6/
support3. Private reads show exact faces/numbers with native marker/reserves
untouched. Subsequent original reveal/Traitors/cleanup/market reach phase7;
ordinary prescience still omits dial. Mixed defeat loses6 real Richese counters;
capped coalition loses4 Richese/3 Ecaz; inspected H/R ends Richese reserves2/
Tanks6 and Ecaz Tanks3, with both cards discarded once. No overflow/browser/
application errors. Owned host/tab/scaffolds removed.
Sixteen fresh native programs/four profiles accept92 policy actions through
Collection. Actual generated guide import resolves34 evidence paths/source
anchor; five No-Field facets and both topic coverages remain Partial. No human
game, D1, deployment or new broad/HTTP/recovery assurance changed.
[Canonical source/priority distinction](ATREIDES_FULL_PLAN_TIMING.md#supplied-source-no-field-composition--16-october-2026).
## 16 October 2026 — Mixed ordinary/No-Field native Occupy
Actual native admission previously rejected both allied and opposing Richese
+17 -5
View File
@@ -2,6 +2,17 @@
Audit date: 2026-09-06. This document distinguishes the printed token lifecycle from engine integration and unresolved combinations. It does not certify Richese, Homeworlds, or any other gated game mode.
## Atreides special inspection — 16 October 2026
The supplied p27 C ordinary Prescience dial exception remains. Its distinct
p27 F entire-plan special Karama now follows the
[explicit provisional power/read distinction](ATREIDES_FULL_PLAN_TIMING.md#supplied-source-no-field-composition--16-october-2026):
Atreides may privately read the complete sealed No-Field plan, including dial/
support, without a premature token reveal or reserve transfer. Both original
plans must still seal before native materialization and public history.
This supersedes older whole-plan guard notes below, not the unrelated
structured Truthtrance, Basic/module or complete-mode boundaries.
## Mixed native Occupy pool — 16 October 2026
Standing choose-recommended extends the existing explicitly provisional
@@ -16,8 +27,8 @@ Either Ecaz or Richese may form the active allied plan. The selected plan
actor receives its private mixed variable wheel and explicit provisional
notice, not Ecaz's own ordinary maximum. Richese as opposing army instead
uses only its own additive pool. Visible ordinary counters never disclose
the concealed prospective increment to rivals. Original prescience/full-plan
restrictions remain attached to the physical force owner.
the concealed prospective increment to rivals outside a distinct authorized
special plan read. Ordinary Prescience restrictions follow the physical owner.
Original Karama prevention preserves the chosen lead but removes the fixed
contribution and all unselected ally strength. Selected Ecaz uses only Ecaz
@@ -92,8 +103,9 @@ Advanced ordinary mixed battles and discounted allied shipments now reuse
the explicitly provisional policies below. Supplied physical p36 B gives
reserve-limited materialization and p36 D permits allied off-planet forces;
p37 C gives the rounded Guild fee. These passages do not specifically
adjudicate the mixed dial cap or price/payee precedence. Combined Ecaz
Occupy and whole-plan inspection remain guarded; mode gates are unchanged.
adjudicate the mixed dial cap or price/payee precedence. At that checkpoint,
combined Occupy and whole-plan inspection were guarded; current cutovers above
retain their explicit provisional interpretations and mode gates.
`Battle.mixedNoFieldPlayers` and the owner-only `battle.mixedNoField` notice
apply in both modes. Native Advanced support is committed separately and
@@ -209,7 +221,7 @@ This table records the original adapter contract. The implemented subset and cur
1. **Reserve shortage when destroyed.** Voluntary/battle text explicitly caps by reserves; the destruction sentence says to lose the indicated quantity without repeating the shortage clause. The pure model caps materialization to avoid creating physical units, but that is conservation-based composition for storm/worm losses, not an independently answered FAQ. It does not select substitute losses from unrelated board forces.
2. **Mixed forces and battle dials.** The FAQ forbids dialing more than reserves in a No-Field battle. Existing ordinary counters are possible, but no retrieved specific answer defines the cap across those counters, multiple sectors, typed units or independent reserve uses. Basic/Advanced ordinary and the selected Advanced Occupy prototype use the explicitly provisional additive pool above. This does not settle that source question or admit Basic mixed coalition/module profiles.
3. **Full-plan special Karama and structured Truthtrance.** The ordinary Atreides dial prohibition is explicit. Whether a broader entire-plan power or a particular promise question overrides it was not resolved by a retrieved specific answer. Do not leak the value through private legal-plan previews, public maximum dials, error strings or bot reasoning.
3. **Full-plan special Karama and structured Truthtrance.** The current prototype distinguishes supplied p27 F's special entire-plan power from p27 C/p36 B ordinary Prescience under the explicit provisional contract above; private reading does not reveal the physical token. No retrieved dedicated combined FAQ adjudicates that distinction or broader promise questions. Structured Truthtrance, public maxima, other-player legal previews and reasoning retain their original entitled boundaries.
4. **Homeworld use.** R3 recommends Richese in a Homeworld combination but supplies no retrieved No-Field-specific rule defining off-planet hidden custody, one-on-planet versus off-planet coexistence, or homeworld threshold disclosure. General module availability is not an answer to those lifecycle questions. The isolated model represents a single Arrakis marker and does not authorize Homeworld placement.
5. **Capturing, transferring or returning a concealed marker as a force.** Movement-as-a-force and Gamont's explicit forced reveal do not establish a general conversion for every card or Guild transport-to-reserves. A generic return helper must not erase a hidden token without a source-backed reveal/custody cause. This module exposes no such automatic escape from last-use history.
6. **Allied discounts and reserve source.** Ordinary consent, typed allocation and funding are integrated. Guild-recipient and already-active Karama price/payee use the explicitly provisional prototype in both modes. Ordinary Fremen reserves are on-planet and remain ineligible for this off-planet method. See `RICHESE_ALLIED_SHIPMENT_AUDIT.md`.
+1
View File
@@ -660,6 +660,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Harass & Withdraw | Either slot but neither category; private Prescience answers null. Return own undialed physical forces before weapon losses, selecting complete typed/sector returns after reveal where necessary; canceled by an opposing successful Traitor. Mandatory after-use discard overrides winner retention. Explosion ordering and own-Traitor disposal are explicit timing/specificity inferences, consistent with the recorded Planetologist precedent. | [Runtime and sources](HARASS_WITHDRAW_RUNTIME.md) |
| No-Field battle cards | Under standing choose-recommended, preserve native materialization before card effects: actual reserve-limited token reveal, original Reinforcements cost, then successful Harass return. Privately forecast physical counters/sector and require three own reserves after reveal before sealing the fee card; future returns or ally reserves cannot fund it. Zero, original token/history, support and fixed/variable owners remain native. This is explicitly provisional composition, not a new publisher FAQ or user answer. Other profile/module and inspection gates stay. | [Harass contract](HARASS_WITHDRAW_RUNTIME.md#own-no-field-composition--16-october-2026) · [Native lifecycle](RICHESE_NO_FIELD_RULES.md#concealed-battle-card-composition--16-october-2026) |
| Mixed No-Field Occupy | Extend the existing provisional additive ordinary-plus-reserve-capped-marker pool into selected native Advanced coalition battles, as ally or opponent. Original fixed Ecaz strength, support payer/counter owners, Traitor/actual reveal and history stay; prevention restores selected-lead-only pool. Selected Ecaz plan controls the ally's private variable quote, not its own ordinary maximum; rivals get no concealed increment. The FAQ mixed-cap ambiguity remains unresolved, not newly adjudicated. Basic mixed-co-side and other profile/module gates remain. | [Exact composition](RICHESE_NO_FIELD_RULES.md#mixed-native-occupy-pool--16-october-2026) |
| No-Field / Atreides special plan | Supplied physical p27 C bars ordinary dial Prescience; distinct p27 F grants an entire-plan special Karama and p36 B names Prescience, not separately F. Under standing choose-recommended, provisionally allow F's actual complete private plan read, retaining C's dial bar. Original target-first commitment is not public No-Field revelation; both seals still materialize once, including zero/caps, then original costs/losses/history run. No token inventory or automatic allied inspection entitlement is granted. This is explicit textual/timing composition, not a new combined FAQ/user answer or mode opening. | [Exact source and scope](ATREIDES_FULL_PLAN_TIMING.md#supplied-source-no-field-composition--16-october-2026) |
| Clean Ix Stone / Tooth / Mirror | Reuse the existing CHOAM prototype order: original Stone first, then uncopied Tooth. The earlier priority question is unanswered; standing choose-recommended extends that order provisionally, not as a publisher ruling or user answer. Printed Mirror copy-first and user-selected physical retention stay independent. Clean selected Ix/Richese supports native Ixians/Tleilaxu in both modes; Ecaz/Moritani stays Advanced-only. Typed winner ambiguity, new Ix Tech/Stronghold and other module bands remain guarded. | [Source and profile distinction](MIRROR_WEAPON_ENGINE_AUDIT.md#clean-selected-ix-composition--16-october-2026) |
| Expansion-faction setup | Selecting Ecaz/Moritani factions does not select their independent three-card variant. Ordinary decks contain 33/35/47 cards; Richese's ten-card cache stays separate. Ecaz allocates six total forces within Imperial Basin sectors 9–11, retaining fourteen reserves. Flexible sector allocation is the literal printed territory constraint; Fremen→Ecaz→Advanced BG order is an implementation inference, while Moritani-last is printed. | [Source and profile contract](EXPANSION_FACTIONS_PROTOTYPE.md) |
| Ixian Technology on Richese lots | Superseded 8 October by the authorized supplied Advanced physical p34, which expressly excludes Richese auctions from Ixian Technology. Richese cache/Black Market lots now open directly; the once-per-round Technology use remains for ordinary lots. The temporary per-lot decline scaffolding and its tests were deleted. | [Auction source boundary](RICHESE_AUCTION_RULES.md#13-september-saved-per-lot-technology-decline--superseded-8-october) |
+1 -4
View File
@@ -28981,10 +28981,7 @@ export function prepareSpecialKaramaIntent(
[b.attacker, b.defender].includes(target),
'Choose a player in this battle.',
);
requireRule(
!battleNoFieldPlan(g, target),
'Whole-plan special prescience against a No-Field awaits its disclosure ruling. Ordinary permitted elements remain available.',
);
// Distinct special Karama inspects the sealed plan, not the token inventory.
return {
...base,
kind: 'atreides',
+17 -12
View File
@@ -76,7 +76,7 @@ export const HELP = {
choamCombat:
'CHOAM may reserve spice for an ally’s advanced battle support. The ally chooses the payment split when sealing. A traitor victory costs the winner no support; any revealed traitor prevents CHOAM force-payment income.',
fullPlan:
'Atreides may spend its special Karama once per game to inspect a full battle plan. The chosen combatant commits dial, leader, weapon, defense, spice support and KH before the private inspection. Only Atreides receives this view. The private panel stays available while the other combatant plans, without an inspection confirmation.',
'Atreides may spend its Advanced special Karama once per game to inspect the full committed plan. Only Atreides receives dial, leader, actual played faces, support and KH while the other combatant plans; no inspection confirmation. The provisional No-Field distinction permits this separate special power while ordinary dial Prescience stays barred. A private read does not reveal or materialize the token before both plans seal.',
guildShipment:
'The Guild may spend its special Karama once per game to stop a declared off-planet shipment before payment or arrival. The shipper keeps its forces and spice and may still move. Fremen’s southern reinforcements are on-planet.',
specialKarama:
@@ -1033,11 +1033,12 @@ export const RULE_TOPICS: RuleTopic[] = [
'Development tables support your own concealed shipment, movement of a marker alone or with a physical group, voluntary reveal and one-force board presence. Physical reserves remain unchanged until reveal. Karama can prevent the No-Field shipment, with Guild shipment prevention and payment handled separately. Price and chosen allied funding are shown before shipment.',
'The map shows a concealed marker without its denomination to everyone. Richese privately sees its token values, last-use restriction and reveal control. Movement selection keeps the marker separate from physical forces. No hidden denomination or physical token identity is included in another player’s projected board state.',
'Marker-only battles use the owner’s private reserve-limited force pool. Both plans must be sealed before the token materializes. A zero-token battle still resolves with its leader, cards and traitor decisions. Ordinary Atreides prescience cannot request this opponent’s dial; other eligible plan elements remain available.',
'Advanced Atreides special Karama is provisionally distinct from ordinary Prescience: it may privately inspect the complete committed No-Field plan, including dial/support and actual played faces. The target seals first; only Atreides receives that inspection, not automatic allies or observers. This is not a token inventory/maximum disclosure or early physical reveal. Both combatants still seal before native materialization. Ordinary dial Prescience remains prohibited.',
'Basic and Advanced ordinary mixed battles have a provisional private pool: physical forces already here plus the token value capped by current reserves. The Battle Wheel labels that interpretation explicitly. Both plans seal before materialization, then native support, casualties and aftermath use actual forces. This first version does not adjudicate the FAQ mixed dial cap.',
'In supported card profiles, provisional materialization-first order forecasts the own marker privately without moving forces early. Both plans seal, the original token reveals and materializes its reserve-limited ordinary counters at its sector, then Harass selects/returns actual undialed counters. Reinforcements must leave three original reserves after that reveal and before any future Harass return; an unfunded card cannot seal a plan. Marker0, last-used identity and original reveal-event history remain native.',
'Concealed collection, occupancy and control use one-force presence. Exposed storm sectors and unprotected worm destruction reveal and remove the resulting actual forces. Storm shelter and other sectors remain separate. Trip to Gamont at a location with a marker reveals it, even if ordinary forces also occupy that sector, then returns one actual force if present. It is still used if zero forces remain. Canceling the CHOAM power leaves the marker concealed.',
'All four AI profiles choose from their own private token inventory, pay only the one-force shipment price, move the marker explicitly and reveal positive tokens when useful. They retain zero-token presence and use only public information to assess opponents. They also offer safe positive tokens from their own inventory during the allied opportunity, fund them with their own spice and accept or decline private offers using their own force and payment information. A human Richese player’s offer window holds an AI ally’s shipment. Pending responses, decisions and Truthtrance retain priority.',
'Marker-only and ordinary-plus-marker No-Fields compose with the selected Advanced Occupy profile under the same provisional additive pool. Either selected lead gets its actual variable-owner quote; original fixed Ecaz strength stays separate. Prevention restores only the chosen lead’s own pool. Basic mixed co-side, entire-plan special Karama, Homeworld custody, wider Truthtrance and other profile/module boundaries remain. Complete Richese starts and final acceptance stay gated.',
'Marker-only and ordinary-plus-marker No-Fields compose with the selected Advanced Occupy profile under the same provisional additive pool. Either selected lead gets its actual variable-owner quote; original fixed Ecaz strength stays separate. Prevention restores only the chosen lead’s own pool. Basic mixed co-side, Homeworld custody, wider Truthtrance and other profile/module boundaries remain. Complete Richese starts and final acceptance stay gated.',
],
related: [
'richese-cards',
@@ -1051,35 +1052,35 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
'Native lifecycle, hazards and authorized allied pricing remain. Own concealed Harass now forecasts actual materialization and sector counters without moving them before both seals. Reinforcements checks original post-reveal reserves before allowing a plan, preventing the accepted-plan funding deadlock. Marker history, physical owners, fixed Ecaz commitment and other profile gates remain separate.',
'Native lifecycle, allied pricing, private additive pools and card funding remain. The distinct Advanced full-plan special now reads the complete sealed plan while ordinary dial Prescience stays barred; private reading never materializes the token early. Original both-seals reveal, fixed/variable owners, fees, returns and history stay.',
evidence: ['game/shipment-price.ts', 'game/engine.ts'],
},
{
area: 'Player controls',
status: 'Partial',
detail:
'Private inventory, movement/reveal and original loan controls remain. Owner-only Harass and reserve-cost previews explain projected materialization and post-reveal funding. Actual mixed-sector selection occurs after native revelation; invalid partial returns disable confirmation. Four mobile card journeys include zero marker, early blocked cost/legal fallback and funded native Occupy through original CHOAM market.',
evidence: ['components/richese-allied-no-field.tsx', 'components/richese-no-field.tsx', 'components/harass-withdraw.tsx', 'components/game-table.tsx'],
'Original inventory/shipment/movement/card controls remain. Before spending and during the entitled private read, the provisional special-power distinction is explicit. Four actual390px journeys show zero/card legality, fractional dial versus support, capped Ecaz-variable plan and actual H/R faces, then original card cleanup/market into Collection.',
evidence: ['components/richese-allied-no-field.tsx', 'components/richese-no-field.tsx', 'components/harass-withdraw.tsx', 'components/game-table.tsx', 'components/private-battle-plan.tsx'],
},
{
area: 'AI',
status: 'Partial',
detail:
'Own/allied pricing participation remains. All four profiles use private own/selected variable materialization, original reserve funding and physical return quotes. Sixteen current native programs accept80 policy actions across active/prevented allied/opposing mixed battles; no strategic tuning or calibration is claimed.',
'Own/allied policies remain. All four profiles spend the actual special once, privately inspect the original first seal and complete native reveal/declared outcomes. Sixteen current ordinary/active/prevented coalition programs accept92 policy actions through Collection. Strategy and calibration stay deferred.',
evidence: ['game/bots.ts'],
},
{
area: 'Documentation',
status: 'Partial',
detail:
'Source, original loan consent/pricing and current controls remain documented. The provisional materialize-then-original-reserve-cost-then-Harass sequence is explicit, not a new publisher FAQ or user ruling. Token history and remaining mixed/inspection/module boundaries remain separate.',
evidence: ['docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md', 'docs/GUILD_RATE_KARAMA_RULES.md', 'docs/RICHESE_NO_FIELD_RULES.md'],
'Supplied p27 C/F and p36 B ground the explicitly provisional ordinary-versus-special distinction and private-read-versus-public-token-reveal timing. Original additive caps, cost-before-return and remaining Truthtrance/module boundaries stay separately qualified.',
evidence: ['docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md', 'docs/GUILD_RATE_KARAMA_RULES.md', 'docs/RICHESE_NO_FIELD_RULES.md', 'docs/ATREIDES_FULL_PLAN_TIMING.md'],
},
{
area: 'Verification',
status: 'Partial',
detail:
'Earlier allied/native/recovery proof remains historical. New actual-shipment card regressions cover zero/caps, mixed sectors, original funding, typed physical custody, Traitor cancellation and marker history; mobile and four-profile continuations are bounded current evidence. No new saved/deployed or complete-mode assurance is claimed.',
'Eighteen new actual-shipment inspection cases and the29-file393/393 union cover zero/caps, mixed sectors, native target-first read, ordinary commitments, real Traitors and original costs/history. Four mobile and16 policy continuations are scoped current proof; earlier recovery/allied evidence remains historical, not new assurance or complete-mode/deployed acceptance.',
evidence: [
'tests/richese-no-field.test.ts',
'tests/richese-no-field-engine.test.ts',
@@ -1099,6 +1100,10 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/ecaz-occupy-mixed-no-field.test.ts',
'tests/fixture-ecaz-occupy-mixed-no-field.ts',
'tests/ecaz-occupy-mixed-no-field-opponent.test.ts',
'tests/no-field-full-plan.test.ts',
'tests/fixture-no-field-full-plan.ts',
'tests/no-field-full-plan-occupy.test.ts',
'tests/fixture-no-field-full-plan-occupy.ts',
],
},
],
@@ -1391,7 +1396,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Eligible Karama can prevent Occupy after the lead choice and before powers/plans. The chosen lead remains lead and dials its own army; the other ally contributes zero. The plan editor then uses the new force pool and no fixed Ecaz strength.',
'An Ixian variable army takes its own typed losses and chooses native surviving-Suboid substitution, including when Ecaz formed the plan. Actual Ixians own the exchange/counter; the selected lead still owns winner cards and continuation. CHOAM support income uses original eligible payer/donor shares, not the free fixed contribution.',
'A Tleilaxu winning co-side cannot Face Dance itself. External Face Dance returns both actual winning allied armies with a combined survivor maximum, while the selected winner keeps original rewards and card cleanup. Reserve/board zero/partial/full replacements remain native choices, not a copied player.',
'A native Richese ally may supply ordinary counters, a concealed No-Field, or both under the selected Advanced provisional additive pool. The selected plan actor gets that private variable pool and mixed-pool notice; rivals do not. The original Richese owner materializes once after both seals, including zero/caps. Prescience/whole-plan restrictions follow the physical force owner. Preventing Occupy restores only the selected lead’s own army; selected Ecaz neither borrows Richese counters nor exposes its hidden maximum.',
'A native Richese ally may supply ordinary counters, a concealed No-Field, or both under the selected Advanced provisional additive pool. The selected actor gets its private variable pool/mixed notice; rivals do not. Richese materializes once after both seals, including zero/caps. Ordinary dial Prescience follows the actual force owner; distinct Advanced special Karama may privately inspect its complete sealed plan under the provisional power distinction. Prevented Ecaz uses only its own pool; neither private read nor prevention reveals the marker early.',
'Stone Burner compares physical undialed counters, not total dial or paid strength: active Occupy contributes floor(Ecaz forces / 2) plus the ally’s native undialed tokens. Canceled Occupy compares only the selected lead’s own pool. Physical Mirror copies use the same comparison and retain their original cleanup. Hidden opposing No-Field preflight uses public possible pools, not its actual denomination; genuinely winner-changing typed allocations remain blocked.',
'With the independent variant, Harass returns only the actual card user’s undialed forces. An Ecaz user keeps the original mandatory ceil contribution committed at free full strength while its floor count returns; ally support is separate. An allied user subtracts fixed strength before choosing its native typed commitment. Canceled Occupy uses its selected lead’s own pool. Returned reserves are outside later casualties/explosion/Face Dance.',
'Reinforcements consumes three card-user reserves normal-first and adds +2 normal score, never support or physical arrivals. Under the provisional native order, an own No-Field materializes before that original reserve fee and successful Harass returns; three reserves must remain after reveal before this plan may seal. Original/copied Stone ignores +2 score but not the fee. Existing all-revealed-outcome cost/disposal remains provisional. Basic/unprofiled co-side cards stay guarded.',
@@ -3551,9 +3556,9 @@ export const RULE_TOPICS: RuleTopic[] = [
'Native faction special powers have connected first versions, including Ecaz’s unarmed disc-difference and Moritani’s winner-card disposal; timing and combination refinement remain.',
steps: [
'Spend an actual Karama card to activate your faction’s special power once per game. The usage remains spent on later turns, but you may still use other Karama cards normally. These powers resolve directly without a normal faction cancellation window.',
'After Voice and ordinary prescience are resolved or declined, Atreides may spend its special Karama to inspect either combatant’s entire plan, including in a battle it is not fighting. The target commits first; Atreides receives the dial, leader, weapon, defense, spice support and KH selection privately. Its ally does not automatically receive this view.',
'After Voice and ordinary Prescience finish, Atreides may spend its Advanced special Karama to inspect either combatant’s entire committed plan, even outside its own battle. Target seals first; only Atreides gets actual dial, leader, physical played cards, support and KH. The provisional supplied-source distinction includes No-Field plans: ordinary dial Prescience is still barred, but the separate special reads the sealed plan without revealing/materializing its marker early. Allies receive no automatic inspection.',
'The inspected plan remains privately visible in Atreides’ action panel while the other combatant prepares a plan. No inspection acknowledgement is required. This also applies when Atreides is outside the battle helping an ally; that ally does not receive the inspection automatically. Both committed plans trigger the public reveal, which ends the private inspection. The inspected plan stays committed. Atreides may retain the power by declining this battle’s offer. A canceled ordinary prescience request does not remove the special power.',
'The full-plan commitment order is an implementation timing choice awaiting the remaining card-interaction audit; it must not be treated as proof that every pre-reveal Karama or expansion timing interaction is complete.',
'The after-preparation offer, target-first commitment and private-read-versus-public-No-Field-reveal order remain explicit provisional timing interpretations, not a newly retrieved combined FAQ or certification of complete Karama/expansion interactions.',
'During Revival, Emperor may revive up to three of its own forces or one of its own dead leaders for free. This is separate from normal revival allowances. The shared one-Sardaukar-per-turn limit still applies.',
'During Bidding, Harkonnen chooses another player and takes up to four unseen cards. Inspect the combined hand, then return exactly the same number of cards. Newly taken cards may be included in the return.',
'Harkonnen may temporarily exceed eight cards during this choice. The return restores normal limits. Other players see the exchange count, but only each hand’s owner sees its cards.',
+1 -10
View File
@@ -147,7 +147,7 @@ void test('Original concealed Richese presence permits a zero prospective pool,
});
for (const lead of ['ecaz', 'ally'] as const) void test(`Atreides cannot inspect concealed partner's ${lead} plan dial or whole plan`, () => {
for (const lead of ['ecaz', 'ally'] as const) void test(`Atreides ordinary Prescience cannot inspect concealed partner's ${lead} plan dial`, () => {
const fixture = ecazOccupyRicheseFixture({ opponentFaction: 'atreides', marker: 5, richeseReserves: 2 });
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
assert.ok(fixture.game.decision?.kind === 'ecazBattleLead');
@@ -167,15 +167,6 @@ for (const lead of ['ecaz', 'ally'] as const) void test(`Atreides cannot inspect
saved = structuredClone(game);
assert.throws(() => applyAction(game, fixture.opponent, { type: 'prescience', field: 'dial' }), /may not inspect/);
assert.deepEqual(game, saved);
game = applyAction(game, fixture.opponent, { type: 'declineBattlePower' });
for (let attempt = 0; attempt < 100 && game.decision?.kind !== 'fullPlanOffer'; attempt++) game = nextOccupyRichese(game);
assert.equal(game.decision!.kind, 'fullPlanOffer');
const karama = occupyRicheseSeat(game, fixture.opponent).hand.find(c => c.effect === 'karama')!;
saved = structuredClone(game);
assert.throws(() => applyAction(game, fixture.opponent, { type: 'card', mode: 'special', card: karama.id, target: actor }),
/Whole-plan special prescience against a No-Field/);
assert.deepEqual(game, saved);
assert.ok(occupyRicheseSeat(game, fixture.richese).noField!.deployed);
});
for (const marker of [0, 5] as const) for (const lead of ['ecaz', 'ally'] as const)
+178
View File
@@ -0,0 +1,178 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeEcazOccupyGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import type { NoFieldValue } from '../game/richese-no-field';
import { defaultHarassWithdrawAllocation } from '../game/harass-withdraw';
import { stageEcazOccupyCard } from './fixture-ecaz-occupy';
import { holdOccupyCardsTraitor } from './fixture-ecaz-occupy-cards';
import {
ecazOccupyRicheseFixture, finishOccupyRichese, nextOccupyRichese, occupyRicheseSeat,
type EcazOccupyRicheseFixture,
} from './fixture-ecaz-occupy-richese';
export type NoFieldFullPlanOccupyOptions = {
initial?: Game;
lead?: 'ecaz' | 'ally';
marker?: NoFieldValue;
richeseReserves?: number;
ordinary?: number;
prevented?: boolean;
utilities?: boolean;
opposingTraitor?: boolean;
variableDial?: number;
support?: number;
};
export type NoFieldFullPlanOccupyFixture = EcazOccupyRicheseFixture & {
source: EcazOccupyRicheseFixture;
beforeLead: Game;
prescience: Game;
owner: string;
atreides: string;
inspectionCard: string;
preventionCard: string | null;
ordinary: number;
marker: NoFieldValue;
materializes: number;
fixedEcazDial: number;
variableDial: number;
support: number;
targetPlan: Action;
atreidesPlan: Action;
utilities: boolean;
opposingTraitor: boolean;
};
/** Select the independent physical three-card source before the one original
* initialization. A captured setup is instead continued without redealing. */
export function createNoFieldFullPlanOccupySetup(): Game {
const roster = ['ecaz', 'richese', 'atreides', 'choam'] as const;
let game = createGame('NOFIELDFULLPLANOCCUPY', newPlayer(roster[0], roster[0], roster[0]), true, ['ecaz', 'choam']);
for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
for (let i = roster.length - 1; i >= 0; i--)
if (viewGame(game, roster[i]).playerPositions[roster[i]] !== i + 1)
game = applyAction(game, roster[i], { type: 'seatPosition', position: i + 1 });
for (const faction of roster) game = applyAction(game, faction, { type: 'ready' });
game.ecazTreachery = true;
return initializeEcazOccupyGameForAudit(game);
}
/** The driver never consumes the boundary that the actual control must offer. */
export function advanceNoFieldFullPlanOccupy(state: Game, boundary: 'prescience' | 'offer'): Game {
let game = state;
for (let attempt = 0; attempt < 160; attempt++) {
const prepared = !game.battle?.preLeader || game.battle.preLeader.closed;
if (!game.response && !game.phaseOpening && prepared &&
(boundary === 'prescience'
? game.battle?.preparation?.kind === 'prescience'
: game.decision?.kind === 'fullPlanOffer')) return game;
assert.notEqual(game.decision?.kind, 'fullPlanOffer', 'Do not auto-decline a real full-plan offer.');
assert.notEqual(!game.response && !game.phaseOpening && prepared && game.battle?.preparation?.kind, 'prescience',
'Ordinary Prescience requires an explicit choice before advancing to the special offer.');
game = nextOccupyRichese(game);
}
throw Error(`Native coalition continuation did not reach ${boundary}.`);
}
/** The shared fixture performs genuine setup, Nexus alliance, native token
* shipment and chooseBattle. Only labeled conserved card/Traitor staging follows. */
export function noFieldFullPlanOccupyFixture(options: NoFieldFullPlanOccupyOptions = {}): NoFieldFullPlanOccupyFixture {
const marker = options.marker ?? 3;
const ordinary = options.ordinary ?? 2;
assert.ok(Number.isSafeInteger(ordinary) && ordinary >= 0);
const source = ecazOccupyRicheseFixture({
initial: options.initial ?? (options.utilities ? createNoFieldFullPlanOccupySetup() : undefined),
opponentFaction: 'atreides', marker, mixedRicheseForces: ordinary,
richeseReserves: options.richeseReserves ?? 6,
});
if (options.utilities) assert.equal(source.initial.ecazTreachery, true);
const owner = options.lead === 'ecaz' ? source.ecaz : source.richese;
const atreides = source.opponent;
const inspectionCard = occupyRicheseSeat(source.game, atreides).hand.find(card => card.effect === 'karama')!.id;
// Return, do not replace, unselected original cards. Keep the actual Karama
// already staged by the shared source; all subsequent draws use that same deck.
for (const player of source.game.players) {
const returned = player.hand.filter(card => card.id !== inspectionCard);
source.game.deck.push(...returned);
player.hand = player.hand.filter(card => card.id === inspectionCard);
}
source.staging.push('Return all unselected original held cards to the remaining native deck; retain the original Atreides inspection Karama and stage only the selected real battle faces.');
const preventionCard = options.prevented
? stageEcazOccupyCard(source.game, 'choam', card => card.effect === 'karama' && card.id !== inspectionCard)
: null;
if (preventionCard) source.staging.push(`Conserve distinct original CHOAM Karama ${preventionCard} for the actual Occupy response.`);
const weapon = stageEcazOccupyCard(source.game, owner, card => options.utilities
? card.id === 'ecaz-reinforcements' : card.kind === 'projectile');
const defense = stageEcazOccupyCard(source.game, owner, card => options.utilities
? card.id === 'ecaz-harass-withdraw' : card.kind === 'shield');
const enemyDefense = stageEcazOccupyCard(source.game, atreides,
card => card.kind === 'shield' && card.id !== defense);
const physicalTraitors = new Set([...(source.game.traitorReserve ?? []),
...source.game.players.flatMap(player => player.traitors)]);
const leader = occupyRicheseSeat(source.game, owner).leaders
.filter(face => !face.dead && !face.usedAt && (!options.opposingTraitor || physicalTraitors.has(face.id)))
.sort((a, b) => b.strength - a.strength)[0];
const enemyLeader = occupyRicheseSeat(source.game, atreides).leaders
.filter(face => !face.dead && !face.usedAt).sort((a, b) => a.strength - b.strength)[0];
assert.ok(leader && enemyLeader);
if (options.opposingTraitor) {
holdOccupyCardsTraitor(source.game, atreides, leader.id);
source.staging.push(`Exchange an original held Traitor Card for actual ${leader.id} in the Atreides hand.`);
}
const beforeLead = structuredClone(source.game);
assert.equal(source.game.decision?.kind, 'ecazBattleLead');
let game = applyAction(source.game, source.ecaz, { type: 'decision', event: source.game.decision!.event, lead: owner });
if (preventionCard) {
assert.equal(game.response?.kind, 'ecazOccupy');
game = applyAction(game, 'choam', { type: 'card', mode: 'cancel', card: preventionCard });
}
const prescience = advanceNoFieldFullPlanOccupy(game, 'prescience');
const profile = viewGame(prescience, owner).battle!.ecazOccupy!.profile!;
assert.ok(profile);
const materializes = Math.min(marker, occupyRicheseSeat(prescience, source.richese).reserves);
const variableDial = options.variableDial ?? Math.min(3, profile.forces.normal);
const support = options.support ?? variableDial;
return {
...source, source, beforeLead, game: prescience, prescience, owner, atreides,
inspectionCard, preventionCard, ordinary, marker, materializes,
fixedEcazDial: profile.fixedEcazDial, variableDial, support,
targetPlan: { type: 'battlePlan', dial: profile.fixedEcazDial + variableDial,
support, leader: leader.id, weapon, defense },
atreidesPlan: { type: 'battlePlan', dial: 0, support: 0, leader: enemyLeader.id, defense: enemyDefense },
utilities: options.utilities ?? false, opposingTraitor: options.opposingTraitor ?? false,
};
}
export function offerNoFieldFullPlanOccupy(fixture: NoFieldFullPlanOccupyFixture, state = fixture.prescience): Game {
assert.equal(state.battle?.preparation?.kind, 'prescience');
return advanceNoFieldFullPlanOccupy(applyAction(state, fixture.atreides, { type: 'declineBattlePower' }), 'offer');
}
export function inspectNoFieldFullPlanOccupy(fixture: NoFieldFullPlanOccupyFixture,
state = offerNoFieldFullPlanOccupy(fixture)): Game {
return applyAction(state, fixture.atreides,
{ type: 'card', mode: 'special', card: fixture.inspectionCard, target: fixture.owner });
}
/** Real post-reveal allocation and declaration windows, including an optional
* actual opposing Traitor call; never assigns an outcome or clears a decision. */
export function finishNoFieldFullPlanOccupy(fixture: NoFieldFullPlanOccupyFixture, state: Game): Game {
let game = state;
for (let attempt = 0; attempt < 100; attempt++) {
if (game.response || game.phaseOpening) { game = nextOccupyRichese(game); continue; }
if (game.decision?.kind === 'harassWithdraw') {
const decision = game.decision;
const allocation = viewGame(game, decision.player).battle?.harassAllocation;
assert.ok(allocation);
game = applyAction(game, decision.player, { type: 'decision', event: decision.event,
returns: defaultHarassWithdrawAllocation(allocation.context, allocation.dial, allocation.support) });
continue;
}
const battle = viewGame(game, fixture.owner).battle;
const voter = !game.decision && battle?.traitorVoters.find(id => !battle.traitorSubmitted.includes(id));
if (!voter) return finishOccupyRichese(game);
game = applyAction(game, voter,
{ type: 'traitorCall', call: fixture.opposingTraitor && voter === fixture.atreides });
}
throw Error('Native inspected coalition declarations did not finish.');
}
+273
View File
@@ -0,0 +1,273 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeFactionExpansionsGameForAudit, joinGame,
newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import type { Card } from '../game/cards';
import type { NoFieldValue } from '../game/richese-no-field';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
export type NoFieldFullPlanStep = { actor: string; action: Action };
export type NoFieldFullPlanOptions = {
initial?: Game;
seatIds?: [string, string, string];
marker?: NoFieldValue;
/** Own physical reserves immediately before the original shipment. */
reserves?: number;
ordinaryAtMarker?: number;
ordinaryOtherSector?: number;
dial?: number;
support?: number;
ordinaryField?: 'leader' | 'weapon' | 'defense';
traitor?: 'owner' | 'atreides';
};
export type NoFieldFullPlanFixture = {
options: NoFieldFullPlanOptions;
initial: Game;
afterSetup: Game;
beforeShipment: Game;
shipmentStep: NoFieldFullPlanStep;
afterShipment: Game;
beforeBattle: Game;
battleStep: NoFieldFullPlanStep;
/** Original ordinary prescience, with native pre-leader preparation closed. */
beforePrescience: Game;
prescienceStep: NoFieldFullPlanStep;
beforeAnswer: Game;
answerStep: NoFieldFullPlanStep;
/** Original fullPlanOffer, not consumed by a generic continuation driver. */
game: Game;
owner: string;
atreides: string;
opponent: string;
observer: string;
territory: string;
location: string;
otherLocation: string;
token: { id: string; value: NoFieldValue };
materializes: number;
cards: { karama: Card; spareKarama: Card; weapon: Card; defense: Card; atreidesDefense: Card };
inspectStep: NoFieldFullPlanStep;
declineStep: NoFieldFullPlanStep;
planActions: [NoFieldFullPlanStep, NoFieldFullPlanStep];
nativeSteps: NoFieldFullPlanStep[];
staging: string[];
};
export function noFieldFullPlanPlayer(game: Game, actor: string) {
const player = game.players.find(p => p.id === actor);
assert.ok(player, `Missing original full-plan seat ${actor}`);
return player;
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
function initialize(game: Game): Game {
// Scope deterministic dealer randomness to the original fresh initializer only.
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return initializeFactionExpansionsGameForAudit(game); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function place(game: Game, actor: string, location: string, amount: number) {
const player = noFieldFullPlanPlayer(game, actor);
assert.ok(Number.isSafeInteger(amount) && amount >= 0 && amount <= player.reserves);
player.reserves -= amount;
if (amount) player.forces[location] = (player.forces[location] ?? 0) + amount;
}
function take(game: Game, actor: string, matches: (card: Card) => boolean): Card {
const player = noFieldFullPlanPlayer(game, actor);
assert.ok(player.hand.length < 4);
const at = game.deck.findIndex(matches);
assert.ok(at >= 0, 'Move only a physical card from the originally selected native deck');
const card = game.deck.splice(at, 1)[0];
player.hand.push(card);
return structuredClone(card);
}
function exchangeTraitor(game: Game, actor: string, identity: string, staging: string[]) {
const player = noFieldFullPlanPlayer(game, actor);
if (player.traitors.includes(identity)) return;
const returned = player.traitors[0];
assert.ok(returned);
const at = game.traitorReserve?.indexOf(identity) ?? -1;
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
assert.ok(at >= 0 || donor, 'Exchange an original physical Traitor, never fabricate one');
if (at >= 0) game.traitorReserve!.splice(at, 1, returned);
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
player.traitors[0] = identity;
staging.push(`Conserved original Traitor ${identity} exchanged for ${returned} into ${actor}'s hand.`);
}
/** Original Advanced faction setup, native CHOAM-selected deck and separate
* Richese cache. Only labeled conserved board/hand/bank staging follows setup;
* shipment, chooser, pre-leader readiness, prescience and offer are real actions. */
export function createNoFieldFullPlanFixture(options: NoFieldFullPlanOptions = {}): NoFieldFullPlanFixture {
let game: Game;
if (options.initial) {
game = structuredClone(options.initial);
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.players.every(p => !p.hand.length));
assert.equal(game.advanced, true);
assert.deepEqual(game.expansions, ['choam']);
assert.deepEqual(game.players.map(p => p.faction).sort(), ['atreides', 'guild', 'richese']);
assert.equal(game.ecazTreachery, undefined);
} else {
const ids = options.seatIds ?? ['richese', 'atreides', 'guild'];
game = createGame('NOFIELDFULLPLAN', newPlayer(ids[0], 'Richese', 'richese'), true, ['choam']);
joinGame(game, newPlayer(ids[1], 'Atreides', 'atreides'));
joinGame(game, newPlayer(ids[2], 'Guild', 'guild'));
}
if (game.status === 'lobby') {
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
game = initialize(game);
}
const initial = structuredClone(game);
for (let count = 0; game.status === 'setup' && count < 200; count++) {
let step: NoFieldFullPlanStep | undefined;
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(p => p.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) { step = { actor: player.id, action }; break; }
}
assert.ok(step, `Original full-plan setup stalled at ${game.setupStage}`);
game = applyAction(game, step.actor, step.action);
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game);
const owner = game.players.find(p => p.faction === 'richese')!.id;
const atreides = game.players.find(p => p.faction === 'atreides')!.id;
const observer = game.players.find(p => p.faction === 'guild')!.id;
const territory = 'imperial_basin', sector = 9;
const location = `${territory}:${sector}`, otherLocation = `${territory}:10`;
const staging: string[] = [], nativeSteps: NoFieldFullPlanStep[] = [];
const act = (step: NoFieldFullPlanStep) => {
nativeSteps.push(structuredClone(step));
game = applyAction(game, step.actor, step.action);
};
const advance = (predicate: (state: Game) => boolean) => {
for (let count = 0; count < 1600; count++) {
if (predicate(game)) return;
// Never silently decline ordinary prescience or the special inspection.
assert.notEqual(game.decision?.kind, 'fullPlanOffer');
assert.ok(!game.battle?.preparation || game.response || game.battle.preLeader && !game.battle.preLeader.closed);
const step = nextRicheseStrongholdsNativeStep(game);
assert.ok(step, 'Native continuation stops before either human plan');
if (game.turn === 1 && step.action.type === 'stormDial') step.action.amount = 0;
act(step);
}
throw Error('Native full-plan continuation did not reach its boundary');
};
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0);
player.forces = {};
player.spice = 30;
}
staging.push('After actual faction setup: return each original board counter to its own reserves and stage thirty-spice bank balances.');
advance(g => g.phase === 5 && g.active === owner && clean(g));
const ordinary = (options.ordinaryAtMarker ?? 0) + (options.ordinaryOtherSector ?? 0);
const reserves = options.reserves ?? 20 - ordinary;
assert.ok(Number.isSafeInteger(reserves) && reserves >= 0 && reserves + ordinary <= 20);
place(game, owner, location, options.ordinaryAtMarker ?? 0);
place(game, owner, otherLocation, options.ordinaryOtherSector ?? 0);
place(game, owner, 'polar_sink:0', 20 - reserves - ordinary);
place(game, atreides, location, 5);
staging.push(`Before actual shipment: conserve ${ordinary} ordinary Richese battle counters, ${reserves} own reserves, the remainder in Polar Sink and five opposing Atreides counters at ${location}.`);
const beforeShipment = structuredClone(game);
const token = structuredClone(noFieldFullPlanPlayer(game, owner).noField!.tokens.find(t => t.value === (options.marker ?? 3))!);
assert.ok(token);
const shipmentStep: NoFieldFullPlanStep = { actor: owner, action: {
type: 'ship', territory, sector, noField: token.id,
event: noFieldFullPlanPlayer(game, owner).noFieldEvent,
} };
act(shipmentStep);
advance(clean);
const afterShipment = structuredClone(game);
advance(g => g.phase === 6 && clean(g));
for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; }
const cards = {
karama: take(game, atreides, c => c.effect === 'karama'),
spareKarama: take(game, atreides, c => c.effect === 'karama'),
weapon: take(game, owner, c => c.kind === 'poison'),
defense: take(game, owner, c => c.kind === 'shield'),
atreidesDefense: take(game, atreides, c => c.kind === 'snooper'),
};
staging.push('Before actual battle admission: return original dealt hands to their original deck; transfer two physical Karama and Snooper to Atreides, poison weapon and Shield to Richese. Cache/removed custody is unchanged; no hand exceeds four.');
const ownLeader = noFieldFullPlanPlayer(game, owner).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
const otherLeader = noFieldFullPlanPlayer(game, atreides).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
assert.ok(ownLeader && otherLeader);
if (options.traitor === 'owner') exchangeTraitor(game, owner, otherLeader.id, staging);
if (options.traitor === 'atreides') exchangeTraitor(game, atreides, ownLeader.id, staging);
const materializes = Math.min(token.value, reserves);
const dial = options.dial ?? Math.min(2, ordinary + materializes);
const planActions: [NoFieldFullPlanStep, NoFieldFullPlanStep] = [
{ actor: owner, action: { type: 'battlePlan', dial, support: options.support ?? dial,
leader: ownLeader.id, weapon: cards.weapon.id, defense: cards.defense.id } },
{ actor: atreides, action: { type: 'battlePlan', dial: 5, support: 5,
leader: otherLeader.id, defense: cards.atreidesDefense.id } },
];
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === territory);
assert.ok(choice, 'A genuine concealed zero must still enter an ordinary battle');
const battleStep: NoFieldFullPlanStep = { actor: choice.chooser, action: {
type: 'chooseBattle', territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker,
} };
act(battleStep);
// Stop at original ordinary prescience rather than letting the shared driver decline it.
advance(g => g.battle?.preparation?.kind === 'prescience' && !g.response && !g.decision && !!g.battle.preLeader?.closed);
const beforePrescience = structuredClone(game);
const field = options.ordinaryField ?? 'leader';
const prescienceStep: NoFieldFullPlanStep = { actor: atreides, action: { type: 'prescience', field } };
act(prescienceStep);
while (game.response) {
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next); act(next);
}
const beforeAnswer = structuredClone(game);
const answerStep: NoFieldFullPlanStep = { actor: owner, action: {
type: 'prescienceAnswer', value: field === 'leader' ? ownLeader.id : cards[field].id,
} };
act(answerStep);
assert.ok(game.decision?.kind === 'fullPlanOffer');
assert.equal(game.decision.player, atreides);
assert.equal(game.battle?.preparation, undefined);
assert.deepEqual(game.battle?.plans, {});
return { options: { ...options, initial: undefined }, initial, afterSetup,
beforeShipment, shipmentStep, afterShipment, beforeBattle, battleStep,
beforePrescience, prescienceStep, beforeAnswer, answerStep, game,
owner, atreides, opponent: atreides, observer, territory, location, otherLocation,
token, materializes, cards,
inspectStep: { actor: atreides, action: { type: 'card', mode: 'special', card: cards.karama.id, target: owner } },
declineStep: { actor: atreides, action: { type: 'decision', decline: true } },
planActions, nativeSteps, staging };
}
export function applyNoFieldFullPlanStep(game: Game, step: NoFieldFullPlanStep): Game {
return applyAction(game, step.actor, step.action);
}
/** Consume only actual response queues; never decide a power or seal a plan. */
export function finishNoFieldFullPlanResponses(game: Game): Game {
for (let count = 0; game.response && count < 80; count++) {
const step = nextRicheseStrongholdsNativeStep(game); assert.ok(step);
game = applyNoFieldFullPlanStep(game, step);
}
assert.equal(game.response, null);
return game;
}
/** Original Traitor declarations, native loss allocation, cards and aftermath. */
export function finishNoFieldFullPlanBattle(game: Game, callers: string[] = []): Game {
assert.ok(game.battle?.revealed, 'Both original plans must already be sealed');
for (let count = 0; count < 160; count++) {
if (!game.battle && clean(game) && !game.pendingTreacheryDiscard) return game;
const step = nextRicheseStrongholdsNativeStep(game);
assert.ok(step, 'Native aftermath cannot invent a battle plan');
if (step.action.type === 'traitorCall') step.action.call = callers.includes(step.actor);
game = applyNoFieldFullPlanStep(game, step);
}
throw Error('Original full-plan No-Field battle did not finish');
}
+259
View File
@@ -0,0 +1,259 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { nextOccupyRichese, occupyRicheseSeat } from './fixture-ecaz-occupy-richese';
import {
noFieldFullPlanOccupyFixture, offerNoFieldFullPlanOccupy, inspectNoFieldFullPlanOccupy,
finishNoFieldFullPlanOccupy, type NoFieldFullPlanOccupyFixture,
} from './fixture-no-field-full-plan-occupy';
function assertCustody(fixture: NoFieldFullPlanOccupyFixture, game: Game): void {
const cards = (state: Game) => [...state.deck, ...state.discard,
...(state.richeseCache ?? []), ...(state.richeseRemoved ?? []),
...state.players.flatMap(player => player.hand)].map(card => card.id).sort();
assert.deepEqual(cards(game), cards(fixture.initial), 'Every original dealer/cache/removed face retains custody.');
const traitors = (state: Game) => [...(state.traitorReserve ?? []),
...(state.ecazLoyalty?.card ? [state.ecazLoyalty.card] : []),
...state.players.flatMap(player => player.traitors)].sort();
assert.deepEqual(traitors(game), traitors(fixture.afterSetup));
for (const player of game.players) {
const counters = [player.reserves, player.tanks, ...Object.values(player.forces)];
assert.equal(counters.reduce((sum, count) => sum + count, 0), 20,
`${player.faction} retains twenty counters of its original physical ownership.`);
assert.ok(counters.every(count => Number.isSafeInteger(count) && count >= 0));
assert.ok(player.hand.length <= 4);
}
}
function assertNativeMarker(fixture: NoFieldFullPlanOccupyFixture, game: Game): void {
const before = occupyRicheseSeat(fixture.prescience, fixture.richese);
const now = occupyRicheseSeat(game, fixture.richese);
assert.deepEqual(now.noField, before.noField);
assert.equal(now.noFieldEvent, before.noFieldEvent);
assert.equal(now.reserves, before.reserves);
assert.equal(now.forces[fixture.location] ?? 0, fixture.ordinary);
assert.equal(now.tanks, before.tanks);
}
function inspectAndSeal(fixture: NoFieldFullPlanOccupyFixture, offered = offerNoFieldFullPlanOccupy(fixture)): Game {
assert.equal(offered.decision?.kind, 'fullPlanOffer');
const inspection = inspectNoFieldFullPlanOccupy(fixture, offered);
assert.equal(occupyRicheseSeat(inspection, fixture.atreides).specialKaramaUsed, true);
assert.equal(occupyRicheseSeat(inspection, fixture.atreides).hand.some(card => card.id === fixture.inspectionCard), false);
assert.equal(inspection.discard.filter(card => card.id === fixture.inspectionCard).length, 1);
assertNativeMarker(fixture, inspection);
const saved = structuredClone(inspection);
assert.throws(() => applyAction(inspection, fixture.atreides, fixture.atreidesPlan), /requested by special/);
assert.deepEqual(inspection, saved, 'The actual selected lead must seal before the inspecting opponent.');
const first = applyAction(inspection, fixture.owner, fixture.targetPlan);
assert.equal(first.battle!.revealed, false);
assert.equal(first.decision, null, 'Reading the plan needs no acknowledgement or fabricated continuation.');
assertNativeMarker(fixture, first);
for (const actor of [fixture.ecaz, fixture.richese, fixture.atreides]) {
const before = occupyRicheseSeat(fixture.prescience, actor);
const sealed = occupyRicheseSeat(first, actor);
assert.equal(sealed.spice, before.spice, 'The private read is not a battle payment.');
assert.equal(sealed.reserves, before.reserves, 'R cannot spend the card holder reserves before the second seal.');
assert.equal(sealed.tanks, before.tanks);
assert.deepEqual(sealed.forces, before.forces, 'H cannot return any physical counter before the second seal.');
}
const privateView = viewGame(first, fixture.atreides).battle!;
const insight = privateView.fullPlanInsight!;
assert.ok(insight);
assert.equal(insight.target, fixture.owner);
assert.equal(insight.plan.dial, fixture.fixedEcazDial + fixture.variableDial);
assert.equal(insight.plan.support, fixture.support);
assert.equal(insight.plan.leader, fixture.targetPlan.leader);
assert.equal(insight.plan.weapon, fixture.targetPlan.weapon);
assert.equal(insight.plan.defense, fixture.targetPlan.defense);
assert.deepEqual(insight.cards, occupyRicheseSeat(first, fixture.owner).hand.filter(card =>
[fixture.targetPlan.weapon, fixture.targetPlan.defense].includes(card.id)),
'The entitlement supplies the actual committed original physical card faces.');
assert.equal(privateView.plans[fixture.owner], undefined, 'The special read does not turn the seal into an ordinary visible plan.');
const concealed = occupyRicheseSeat(first, fixture.richese).noField!.deployed!;
assert.equal(JSON.stringify(insight).includes(concealed.tokenId), false,
'Complete plan entitlement is not ownership of the physical token identity.');
for (const player of first.players) if (player.id !== fixture.atreides)
assert.equal(viewGame(first, player.id).battle!.fullPlanInsight, null);
assertCustody(fixture, first);
return first;
}
function secondSeal(fixture: NoFieldFullPlanOccupyFixture, first: Game): Game {
const revealed = applyAction(first, fixture.atreides, fixture.atreidesPlan);
const original = occupyRicheseSeat(fixture.prescience, fixture.richese);
const richese = occupyRicheseSeat(revealed, fixture.richese);
assert.equal(revealed.battle!.revealed, true);
assert.equal(richese.noField!.deployed, null);
assert.equal(richese.noField!.lastShipped, original.noField!.deployed!.tokenId);
assert.deepEqual(richese.noField!.tokens, original.noField!.tokens);
assert.notEqual(richese.noFieldEvent, original.noFieldEvent);
assert.equal(richese.forces[fixture.location] ?? 0, fixture.ordinary + fixture.materializes,
'Both seals add the reserve-capped marker to the original ordinary counters, without replacing them.');
assert.equal(richese.reserves, original.reserves - fixture.materializes);
assert.equal(richese.tanks, original.tanks);
for (const player of revealed.players) assert.equal(viewGame(revealed, player.id).battle!.fullPlanInsight, null);
const saved = structuredClone(revealed);
assert.throws(() => applyAction(revealed, fixture.atreides, fixture.atreidesPlan));
assert.deepEqual(revealed, saved, 'A repeated seal cannot materialize a marker twice.');
assertCustody(fixture, revealed);
return revealed;
}
function assertHistory(fixture: NoFieldFullPlanOccupyFixture, revealed: Game, done: Game): void {
assert.equal(done.battle, null);
assert.equal(done.decision, null);
const richese = occupyRicheseSeat(done, fixture.richese);
assert.equal(richese.noField!.deployed, null);
assert.equal(richese.noFieldEvent, occupyRicheseSeat(revealed, fixture.richese).noFieldEvent,
'Payments, losses and aftermath cannot perform another marker reveal.');
for (const player of done.players) {
const history = viewGame(done, player.id).players.find(seat => seat.id === fixture.richese)!.noField!;
assert.equal(history.deployed, null);
assert.equal(history.lastUsed, fixture.marker);
}
assertCustody(fixture, done);
}
function ordinaryDialRemainsBlocked(fixture: NoFieldFullPlanOccupyFixture): void {
const before = fixture.prescience;
assert.equal(before.battle!.preparation!.kind, 'prescience');
const saved = structuredClone(before);
assert.throws(() => applyAction(before, fixture.atreides, { type: 'prescience', field: 'dial' }), /may not inspect/);
assert.deepEqual(before, saved);
assertNativeMarker(fixture, before);
const profile = viewGame(before, fixture.owner).battle!.ecazOccupy!.profile!;
assert.equal(profile.forceOwner, fixture.richese,
'Ordinary dial protection follows physical Richese ownership even when Ecaz forms the plan.');
assert.equal(profile.fixedEcazDial, 3);
assert.equal(profile.forces.normal, fixture.ordinary + fixture.materializes);
assert.equal(profile.maxDial, 3 + fixture.ordinary + fixture.materializes);
const rival = viewGame(before, fixture.atreides).battle!;
assert.equal(rival.opponentForces, null);
assert.equal(rival.ecazOccupy!.profile, null,
'The distinct complete-plan entitlement does not broaden ordinary concealed force disclosure.');
}
const cityIncome = (game: Game, actor: string, fixture: NoFieldFullPlanOccupyFixture) =>
(occupyRicheseSeat(game, actor).forces[fixture.location] ?? 0) > 0 ? 2 : 0;
// One zero, one full positive and one capped positive marker, with both actual
// selected faction leads. The capped Ecaz case also distinguishes paid strength
// from unsupported physical losses without creating a cross-product of options.
for (const lead of ['ecaz', 'ally'] as const)
for (const [marker, reserves] of [[0, 0], [3, 6], [5, 2]] as const)
void test(`Private complete coalition plan: mixed marker ${marker}, reserves ${reserves}, ${lead} lead`, () => {
const unsupported = lead === 'ecaz' && marker === 5;
const fixture = noFieldFullPlanOccupyFixture({ lead, marker, richeseReserves: reserves,
...(unsupported ? { variableDial: 2, support: 0 } : {}) });
ordinaryDialRemainsBlocked(fixture);
const first = inspectAndSeal(fixture);
assert.equal(viewGame(first, fixture.atreides).battle!.opponentForces, null);
assert.equal(viewGame(first, fixture.atreides).battle!.ecazOccupy!.profile, null);
const revealed = secondSeal(fixture, first);
const done = finishNoFieldFullPlanOccupy(fixture, revealed);
assert.equal(done.lastBattleContext!.result, 'normal');
assert.equal(done.lastBattleContext!.winner, fixture.owner);
const losses = unsupported ? fixture.variableDial * 2 : fixture.variableDial;
assert.equal(occupyRicheseSeat(done, fixture.richese).forces[fixture.location] ?? 0,
fixture.ordinary + fixture.materializes - losses);
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(fixture.prescience, fixture.richese).tanks, losses);
assert.equal(occupyRicheseSeat(done, fixture.richese).reserves, reserves - fixture.materializes);
assert.equal(occupyRicheseSeat(done, fixture.ecaz).forces[fixture.location], 2);
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(fixture.prescience, fixture.ecaz).tanks, 3);
assert.equal(occupyRicheseSeat(done, fixture.owner).spice,
occupyRicheseSeat(fixture.prescience, fixture.owner).spice - fixture.support + cityIncome(done, fixture.owner, fixture));
const nonLead = fixture.owner === fixture.ecaz ? fixture.richese : fixture.ecaz;
assert.equal(occupyRicheseSeat(done, nonLead).spice,
occupyRicheseSeat(fixture.prescience, nonLead).spice + cityIncome(done, nonLead, fixture));
assert.equal(occupyRicheseSeat(done, fixture.atreides).forces[fixture.location] ?? 0, 0);
assert.equal(occupyRicheseSeat(done, fixture.atreides).tanks - occupyRicheseSeat(fixture.prescience, fixture.atreides).tanks, 8);
assertHistory(fixture, revealed, done);
});
for (const [lead, marker, reserves] of [['ecaz', 0, 0], ['ally', 5, 2]] as const)
void test(`Marker-only ${lead} coalition still permits a private full plan, including marker ${marker}`, () => {
const fixture = noFieldFullPlanOccupyFixture({ lead, marker, richeseReserves: reserves, ordinary: 0 });
ordinaryDialRemainsBlocked(fixture);
const revealed = secondSeal(fixture, inspectAndSeal(fixture));
const done = finishNoFieldFullPlanOccupy(fixture, revealed);
assert.equal(done.lastBattleContext!.winner, fixture.owner);
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(fixture.prescience, fixture.richese).tanks,
fixture.variableDial);
assert.equal(occupyRicheseSeat(done, fixture.ecaz).forces[fixture.location], 2);
assertHistory(fixture, revealed, done);
});
for (const lead of ['ecaz', 'ally'] as const)
void test(`Distinct real CHOAM prevention leaves ${lead}'s own plan inspectable with native later marker reveal`, () => {
const fixture = noFieldFullPlanOccupyFixture({ lead, marker: 5, richeseReserves: 2, prevented: true });
assert.notEqual(fixture.preventionCard, fixture.inspectionCard);
assert.equal(fixture.prescience.discard.filter(card => card.id === fixture.preventionCard).length, 1);
assert.equal(occupyRicheseSeat(fixture.prescience, fixture.atreides).hand.some(card => card.id === fixture.inspectionCard), true);
const profile = viewGame(fixture.prescience, fixture.owner).battle!.ecazOccupy!.profile!;
assert.equal(profile.canceled, true);
assert.equal(profile.forceOwner, fixture.owner);
assert.equal(profile.fixedEcazDial, 0);
assert.equal(profile.forces.normal, lead === 'ecaz' ? 5 : 4);
assert.equal(profile.maxDial, lead === 'ecaz' ? 5 : 4);
let offered: Game;
if (lead === 'ecaz') {
let asked = applyAction(fixture.prescience, fixture.atreides, { type: 'prescience', field: 'dial' });
assert.equal(asked.battle!.preparation!.owner, fixture.ecaz);
assertNativeMarker(fixture, asked);
while (asked.response) asked = nextOccupyRichese(asked);
const answered = applyAction(asked, fixture.ecaz, { type: 'prescienceAnswer', value: fixture.targetPlan.dial! });
assert.equal(viewGame(answered, fixture.atreides).battle!.insight!.value, fixture.targetPlan.dial);
assertNativeMarker(fixture, answered);
offered = answered;
} else {
assert.throws(() => applyAction(fixture.prescience, fixture.atreides, { type: 'prescience', field: 'dial' }), /may not inspect/);
offered = offerNoFieldFullPlanOccupy(fixture);
}
const revealed = secondSeal(fixture, inspectAndSeal(fixture, offered));
const done = finishNoFieldFullPlanOccupy(fixture, revealed);
assert.equal(done.lastBattleContext!.winner, fixture.owner);
const ecazLoss = lead === 'ecaz' ? fixture.variableDial : 0;
const richeseLoss = lead === 'ally' ? fixture.variableDial : 0;
assert.equal(occupyRicheseSeat(done, fixture.ecaz).forces[fixture.location] ?? 0, 5 - ecazLoss);
assert.equal(occupyRicheseSeat(done, fixture.richese).forces[fixture.location] ?? 0, 4 - richeseLoss);
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(fixture.prescience, fixture.ecaz).tanks, ecazLoss);
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(fixture.prescience, fixture.richese).tanks, richeseLoss);
assert.equal(occupyRicheseSeat(done, fixture.owner).spice,
occupyRicheseSeat(fixture.prescience, fixture.owner).spice - fixture.support + cityIncome(done, fixture.owner, fixture));
assertHistory(fixture, revealed, done);
});
for (const [lead, opposingTraitor] of [['ally', false], ['ecaz', true]] as const)
void test(`Inspected actual ${lead}-held H/R ${opposingTraitor ? 'is canceled by a real opposing Traitor' : 'settles its original owner returns and costs'}`, () => {
const fixture = noFieldFullPlanOccupyFixture({ lead, utilities: true, opposingTraitor });
ordinaryDialRemainsBlocked(fixture);
const revealed = secondSeal(fixture, inspectAndSeal(fixture));
const done = finishNoFieldFullPlanOccupy(fixture, revealed);
assert.equal(done.lastBattleContext!.result, opposingTraitor ? 'traitor' : 'normal');
assert.equal(done.lastBattleContext!.winner, opposingTraitor ? fixture.atreides : fixture.owner);
for (const card of ['ecaz-reinforcements', 'ecaz-harass-withdraw'])
assert.equal(done.discard.filter(face => face.id === card).length, 1);
for (const actor of [fixture.ecaz, fixture.richese]) {
const before = occupyRicheseSeat(fixture.prescience, actor);
const after = occupyRicheseSeat(done, actor);
const materialized = actor === fixture.richese ? fixture.materializes : 0;
const reserveCost = actor === fixture.owner ? 3 : 0;
const returned = !opposingTraitor && actor === fixture.owner ? 2 : 0;
assert.equal(after.reserves, before.reserves - materialized - reserveCost + returned,
'R spends the card holder reserves; an opposing real Traitor cancels every H return.');
const losses = opposingTraitor ? actor === fixture.ecaz ? 5 : fixture.ordinary + materialized
: actor === fixture.ecaz ? 3 : fixture.variableDial;
assert.equal(after.tanks - before.tanks, losses + reserveCost);
const remaining = opposingTraitor || actor === fixture.owner ? 0 : 2;
assert.equal(after.forces[fixture.location] ?? 0, remaining);
}
if (opposingTraitor) {
const committedLeader = fixture.targetPlan.leader;
assert.ok(typeof committedLeader === 'string');
assert.ok(occupyRicheseSeat(fixture.prescience, fixture.atreides).traitors.includes(committedLeader));
assert.equal(occupyRicheseSeat(done, fixture.owner).leaders.find(face => face.id === committedLeader)!.dead, true);
} else assert.equal(occupyRicheseSeat(done, fixture.owner).spice,
occupyRicheseSeat(fixture.prescience, fixture.owner).spice - fixture.support);
assertHistory(fixture, revealed, done);
});
+204
View File
@@ -0,0 +1,204 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import {
applyNoFieldFullPlanStep, createNoFieldFullPlanFixture, finishNoFieldFullPlanBattle,
noFieldFullPlanPlayer, type NoFieldFullPlanFixture, type NoFieldFullPlanOptions,
} from './fixture-no-field-full-plan';
function rejectedUnchanged(game: Game, actor: string, action: Action, reason?: RegExp) {
const before = structuredClone(game);
if (reason) assert.throws(() => applyAction(game, actor, action), reason);
else assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function assertCustody(fixture: NoFieldFullPlanFixture, game: Game) {
const cards = (state: Game) => [
...state.deck, ...state.discard, ...(state.richeseCache ?? []), ...(state.richeseRemoved ?? []),
...state.players.flatMap(p => p.hand),
].map(c => c.id).sort();
const actual = cards(game);
assert.equal(actual.length, new Set(actual).size);
assert.deepEqual(actual, cards(fixture.afterSetup));
const traitors = (state: Game) => [
...(state.traitorReserve ?? []), ...state.players.flatMap(p => p.traitors),
].sort();
assert.deepEqual(traitors(game), traitors(fixture.afterSetup));
for (const player of game.players) {
assert.ok(player.hand.length <= 4);
const counters = [player.reserves, player.tanks, ...Object.values(player.forces)];
assert.equal(counters.reduce((sum, n) => sum + n, 0), 20);
assert.ok(counters.every(n => Number.isSafeInteger(n) && n >= 0));
}
}
function assertStillConcealed(fixture: NoFieldFullPlanFixture, game: Game) {
const original = noFieldFullPlanPlayer(fixture.game, fixture.owner);
const current = noFieldFullPlanPlayer(game, fixture.owner);
assert.deepEqual(current.noField, original.noField);
assert.equal(current.noFieldEvent, original.noFieldEvent);
assert.equal(current.reserves, original.reserves);
assert.deepEqual(current.forces, original.forces);
assert.equal(current.spice, original.spice);
for (const actor of [fixture.atreides, fixture.observer]) {
const visible = viewGame(game, actor).players.find(p => p.id === fixture.owner)!;
assert.ok(visible.noField?.deployed);
assert.equal(visible.noField.lastUsed, null);
assert.equal('private' in visible.noField, false);
assert.equal(visible.hand, undefined);
}
assertCustody(fixture, game);
}
function assertNativeMaterialization(fixture: NoFieldFullPlanFixture, revealed: Game, done: Game) {
const original = noFieldFullPlanPlayer(fixture.game, fixture.owner);
const exposed = noFieldFullPlanPlayer(revealed, fixture.owner);
const finished = noFieldFullPlanPlayer(done, fixture.owner);
assert.equal(revealed.battle?.revealed, true);
assert.equal(exposed.noField!.deployed, null);
assert.equal(exposed.reserves, original.reserves - fixture.materializes);
assert.equal(exposed.forces[fixture.location] ?? 0,
(original.forces[fixture.location] ?? 0) + fixture.materializes);
assert.equal(exposed.forces[fixture.otherLocation] ?? 0, original.forces[fixture.otherLocation] ?? 0);
assert.deepEqual(exposed.noField!.tokens, original.noField!.tokens);
assert.equal(exposed.noField!.lastShipped, fixture.token.id);
assert.notEqual(exposed.noFieldEvent, original.noFieldEvent);
assert.equal(done.battle, null);
assert.equal(finished.noField!.deployed, null);
assert.deepEqual(finished.noField!.tokens, original.noField!.tokens);
assert.equal(finished.noField!.lastShipped, fixture.token.id);
assert.equal(finished.noFieldEvent, exposed.noFieldEvent);
for (const actor of [fixture.owner, fixture.atreides, fixture.observer]) {
const publicOwner = viewGame(done, actor).players.find(p => p.id === fixture.owner)!;
assert.equal(publicOwner.noField!.deployed, null);
assert.equal(publicOwner.noField!.lastUsed, fixture.token.value);
assert.equal(viewGame(revealed, actor).battle!.fullPlanInsight, null);
assert.deepEqual(viewGame(revealed, actor).battle!.plans[fixture.owner], revealed.battle!.plans[fixture.owner]);
}
assertCustody(fixture, revealed);
assertCustody(fixture, done);
}
const materializationCases: { name: string; options: NoFieldFullPlanOptions }[] = [
{ name: 'zero token still commits a leader and both physical battle cards', options: { marker: 0 } },
{ name: 'positive token materializes three counters only at both seals', options: { marker: 3 } },
{ name: 'five token is capped by two actual own reserves', options: { marker: 5, reserves: 2, dial: 1 } },
{ name: 'ordinary counters in two sectors combine with the marker pool', options: {
marker: 3, ordinaryAtMarker: 1, ordinaryOtherSector: 2, dial: 3.5, support: 2,
} },
];
for (const { name, options } of materializationCases) {
void test(`Advanced special full-plan inspection: ${name}`, () => {
const fixture = createNoFieldFullPlanFixture(options);
const { owner, atreides, observer, cards } = fixture;
const original = noFieldFullPlanPlayer(fixture.game, owner);
assert.ok(noFieldFullPlanPlayer(fixture.afterShipment, owner).noField!.deployed);
assert.equal(fixture.beforePrescience.battle?.preLeader?.closed, true);
assert.equal(fixture.beforePrescience.battle?.preparation?.kind, 'prescience');
rejectedUnchanged(fixture.beforePrescience, atreides, { type: 'prescience', field: 'dial' }, /may not inspect the number dialed/);
rejectedUnchanged(fixture.beforePrescience, atreides, fixture.inspectStep.action, /preparation/);
assert.equal(fixture.beforeAnswer.battle?.preparation?.kind, 'prescienceAnswer');
assert.equal(viewGame(fixture.game, atreides).battle!.insight!.field, 'leader');
assert.equal(viewGame(fixture.game, atreides).battle!.fullPlanInsight, null);
assert.equal(fixture.game.decision?.kind, 'fullPlanOffer');
const waiting = applyNoFieldFullPlanStep(fixture.game, fixture.inspectStep);
assert.equal(noFieldFullPlanPlayer(waiting, atreides).specialKaramaUsed, true);
assert.equal(noFieldFullPlanPlayer(waiting, atreides).hand.some(c => c.id === cards.karama.id), false);
assert.equal(waiting.discard.filter(c => c.id === cards.karama.id).length, 1);
assert.ok(noFieldFullPlanPlayer(waiting, atreides).hand.some(c => c.id === cards.spareKarama.id));
assert.equal(waiting.response, null);
assert.equal(waiting.decision, null);
rejectedUnchanged(waiting, atreides, fixture.planActions[1].action, /requested by special/);
rejectedUnchanged(waiting, atreides, { ...fixture.inspectStep.action, card: cards.spareKarama.id }, /already been used/);
// Ordinary prescience remains a commitment even when the separate full-plan power is spent.
const changedLeader = original.leaders.find(leader =>
!leader.dead && leader.id !== fixture.planActions[0].action.leader)!;
rejectedUnchanged(waiting, owner, { ...fixture.planActions[0].action, leader: changedLeader.id }, /remain unchanged/);
assertStillConcealed(fixture, waiting);
const reading = applyNoFieldFullPlanStep(waiting, fixture.planActions[0]);
assert.equal(reading.battle!.revealed, false);
assert.equal(reading.decision, null);
assert.equal(reading.response, null);
assertStillConcealed(fixture, reading);
const insight = viewGame(reading, atreides).battle!.fullPlanInsight!;
assert.equal(insight.target, owner);
assert.equal(insight.plan.dial, fixture.planActions[0].action.dial);
assert.equal(insight.plan.support, fixture.planActions[0].action.support);
assert.equal(insight.plan.leader, fixture.planActions[0].action.leader);
assert.equal(insight.plan.weapon, cards.weapon.id);
assert.equal(insight.plan.defense, cards.defense.id);
assert.deepEqual(insight.cards.map(c => ({ id: c.id, name: c.name })).sort((a, b) => a.id.localeCompare(b.id)),
[cards.weapon, cards.defense].map(c => ({ id: c.id, name: c.name })).sort((a, b) => a.id.localeCompare(b.id)));
assert.equal(viewGame(reading, atreides).battle!.plans[owner], undefined);
assert.deepEqual(viewGame(reading, owner).battle!.plans[owner], reading.battle!.plans[owner]);
for (const actor of [owner, observer]) assert.equal(viewGame(reading, actor).battle!.fullPlanInsight, null);
assert.equal(viewGame(reading, observer).battle!.plans[owner], undefined);
assert.equal(viewGame(reading, observer).battle!.insight, null);
const newLogs = reading.log.slice(fixture.game.log.length);
assert.ok(newLogs.every(entry => !entry.text.includes(cards.weapon.name) && !entry.text.includes(cards.defense.name)));
rejectedUnchanged(reading, owner, { ...fixture.planActions[0].action, dial: 0 }, /not available/);
rejectedUnchanged(reading, owner, { ...fixture.planActions[0].action, defense: null }, /not available/);
rejectedUnchanged(reading, atreides, { type: 'decision', continue: true }, /not available/);
// The private read continues directly into the original opposing plan; no acknowledgement or marker action.
const revealed = applyNoFieldFullPlanStep(reading, fixture.planActions[1]);
const done = finishNoFieldFullPlanBattle(revealed);
assertNativeMaterialization(fixture, revealed, done);
assert.equal(done.lastBattleContext?.winner, atreides);
const lost = noFieldFullPlanPlayer(done, owner);
const battlePool = (original.forces[fixture.location] ?? 0) + (original.forces[fixture.otherLocation] ?? 0) + fixture.materializes;
assert.equal(lost.tanks, battlePool);
assert.equal(lost.forces[fixture.location] ?? 0, 0);
assert.equal(lost.forces[fixture.otherLocation] ?? 0, 0);
assert.equal(lost.spice, original.spice - Number(fixture.planActions[0].action.support));
assert.ok(done.discard.some(c => c.id === cards.weapon.id));
assert.ok(done.discard.some(c => c.id === cards.defense.id));
assert.equal(done.discard.filter(c => c.id === cards.karama.id).length, 1);
assert.equal(lost.leaders.find(l => l.id === fixture.planActions[0].action.leader)!.dead, false);
assert.equal(noFieldFullPlanPlayer(done, atreides).specialKaramaUsed, true);
});
}
void test('each permitted ordinary element can answer against a concealed marker, then declining preserves the physical power and ordinary planning', () => {
for (const ordinaryField of ['leader', 'weapon', 'defense'] as const) {
const fixture = createNoFieldFullPlanFixture({ marker: 3, ordinaryField });
const { owner, atreides, cards } = fixture;
rejectedUnchanged(fixture.beforePrescience, atreides, { type: 'prescience', field: 'dial' }, /may not inspect the number dialed/);
assert.equal(fixture.beforeAnswer.battle?.preparation?.kind, 'prescienceAnswer');
assert.equal(viewGame(fixture.game, atreides).battle!.insight!.field, ordinaryField);
assert.equal(viewGame(fixture.game, atreides).battle!.insight!.value, fixture.answerStep.action.value);
const declined = applyNoFieldFullPlanStep(fixture.game, fixture.declineStep);
assert.equal(noFieldFullPlanPlayer(declined, atreides).specialKaramaUsed, undefined);
assert.ok(noFieldFullPlanPlayer(declined, atreides).hand.some(c => c.id === cards.karama.id));
rejectedUnchanged(declined, atreides, fixture.inspectStep.action, /preparation/);
// No special target-first gate survives decline: Atreides may seal first.
const first = applyNoFieldFullPlanStep(declined, fixture.planActions[1]);
assert.equal(first.battle!.revealed, false);
assert.equal(viewGame(first, owner).battle!.plans[atreides], undefined);
assertStillConcealed(fixture, first);
const revealed = applyNoFieldFullPlanStep(first, fixture.planActions[0]);
const done = finishNoFieldFullPlanBattle(revealed);
assertNativeMaterialization(fixture, revealed, done);
assert.equal(done.lastBattleContext?.winner, atreides);
assert.ok(noFieldFullPlanPlayer(done, atreides).hand.some(c => c.id === cards.karama.id));
assert.equal(noFieldFullPlanPlayer(done, atreides).specialKaramaUsed, undefined);
assert.equal(done.discard.some(c => c.id === cards.karama.id), false);
}
});
void test('a real conserved Traitor declaration after inspected mixed-plan reveal uses native losses and cannot reveal the marker twice', () => {
const fixture = createNoFieldFullPlanFixture({ marker: 5, reserves: 2,
ordinaryAtMarker: 1, ordinaryOtherSector: 1, dial: 2, traitor: 'atreides' });
let game = applyNoFieldFullPlanStep(fixture.game, fixture.inspectStep);
game = applyNoFieldFullPlanStep(game, fixture.planActions[0]);
assertStillConcealed(fixture, game);
const revealed = applyNoFieldFullPlanStep(game, fixture.planActions[1]);
const done = finishNoFieldFullPlanBattle(revealed, [fixture.atreides]);
assertNativeMaterialization(fixture, revealed, done);
assert.equal(done.lastBattleContext?.winner, fixture.atreides);
const owner = noFieldFullPlanPlayer(done, fixture.owner);
assert.equal(owner.tanks, 4);
assert.equal(owner.leaders.find(l => l.id === fixture.planActions[0].action.leader)!.dead, true);
assert.equal(noFieldFullPlanPlayer(done, fixture.atreides).tanks, 0);
assert.equal(noFieldFullPlanPlayer(done, fixture.atreides).forces[fixture.location], 5);
});