From 1f3e71fe033deb18470f8bc0f29a96dfa01e8578 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:54:50 +1100 Subject: [PATCH] Connect bare Basic native Ecaz coalitions Admit fresh public Basic Ecaz starts with selected native E1/E2 families while retaining roster, inventory, seat and optional-profile guards. Keep actual coalition battle assertions. Include allied marker-only and mixed Richese presence, zero and capped materialization after both seals, and owner-specific casualties. Extend the explicitly provisional additive mixed-marker interpretation to Basic without new arithmetic or FAQ ruling. Use the current physical force pool for battle-wheel half steps so Ecaz can commit Ixian Suboids; cancellation restores Ecaz-only whole steps. Preserve Basic free support, zero funding/income, native Ix substitution, lead-owned cards and original Collection. Verify related Basic/Advanced rules, types/lint/build, nine actual-control/rendered-table programmes after genuine public Basic setup, explicit controlled board/budget/card staging and four existing profiles with legal lead/plan/native-substitution proposals. Full modes, wider optional configurations, comprehensive assurance and deployed acceptance remain unfinished. No strategic calibration, saved-room operation, reset, restart or deployment. --- components/game-table.tsx | 2 +- docs/CURRENT_STATUS.md | 21 +++ docs/ECAZ_OCCUPY_RULES.md | 82 +++++++++++ docs/IMPLEMENTATION_STATUS.md | 22 +++ docs/RICHESE_NO_FIELD_RULES.md | 18 +++ docs/RULE_DECISIONS.md | 3 +- game/engine.ts | 22 ++- game/reference.ts | 16 +-- tests/basic-ecaz-native-entry.test.ts | 29 ++++ tests/basic-ecaz-native-occupy.test.ts | 184 +++++++++++++++++++++++++ tests/fixture-basic-ecaz-native.ts | 184 +++++++++++++++++++++++++ tests/fixture-ecaz-occupy-richese.ts | 15 +- 12 files changed, 570 insertions(+), 28 deletions(-) create mode 100644 tests/basic-ecaz-native-entry.test.ts create mode 100644 tests/basic-ecaz-native-occupy.test.ts create mode 100644 tests/fixture-basic-ecaz-native.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 6acbb5f0..c4686bf4 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -498,7 +498,7 @@ export function GameTable({ : g.advanced || me.faction === 'ixians' ? 40 : battleForces); - const battleDialStep = g.advanced || me.faction === 'ixians' ? 0.5 : 1; + const battleDialStep = g.advanced || ownBattleForces?.normalFixedHalf || me.faction === 'ixians' ? 0.5 : 1; // A draft carried from a previous battle must show and submit the same bounded value. const battleDial = Number.isFinite(dial) ? Math.max( diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 0bce72da..9f219f4f 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,27 @@ open mode gates. ## Current checkpoint and work +**Bare Basic native Ecaz coalitions — 24 October. Prototyped / Partial.** +Fresh public Basic Ecaz now admits selected native E1/E2 families while retaining +original setup/roster and actual battle guards. Include allied zero/mixed Richese +markers through both seals and owner-specific losses. The current physical Ixian +pool supplies half-step wheels even under Ecaz; cancellation restores the +selected-only pool. Basic support/funding/income stays0 and CHOAM keeps five +discs. Existing odd rounding and additive mixed-marker interpretations remain +provisional; optional-module boundaries are unchanged. + +A disjoint consumer lane, bounded review and owner integration pass +**12 files244/244**, types/lint/build, nine controlled native gameplay/rendering +programmes and four existing profiles with12 legal proposals. Real public setup, +Nexus/alliance/marker shipment precede labelled conserved board/card and thirty- +spice staging. Native typed substitution, total3.5, zero/capped/mixed markers and +both physical leads reach Basic Collection. Source35/47 plus cache10, actual HMS +placement and Basic CHOAM5 remain. Feedback repaired only fixture type narrowing +and the private driver's HMS observation stage; runtime alone reran on identical +source. [Scope, source, exact evidence and limits](ECAZ_OCCUPY_RULES.md#bare-basic-native-families--24-october-2026). +Full modes, wider optional configurations and comprehensive/deployed acceptance +remain open. No strategic calibration or saved-room/reset/restart/deployment. + **Ambassador repeated leaders and KH — 24 October. Prototyped / Partial.** A shared death-cohort quote removes the generic lifetime first-death cap while retaining ordinary repeat conditions. First returns remain immediate; actual diff --git a/docs/ECAZ_OCCUPY_RULES.md b/docs/ECAZ_OCCUPY_RULES.md index 8623383e..892164d7 100644 --- a/docs/ECAZ_OCCUPY_RULES.md +++ b/docs/ECAZ_OCCUPY_RULES.md @@ -2,6 +2,88 @@ Historical primary-source audit, 2026-09-07, corrected after independent verification. The publisher/designer odd-force conflict remains unresolved as a publisher correction. Under the 8 October Basic-first prototype instruction, Basic now provisionally uses the main p8 paragraph: ceiling contribution/loss and floor survivors, with a visible control warning. This is not a numerical ruling by the user or designer. Advanced retains its separately authorized source. The earlier false attribution to Jack Reda remains withdrawn. +## Bare Basic native families — 24 October 2026 + +**Prototyped / Partial.** Fresh public Basic Ecaz tables now admit native Ixians, +Tleilaxu, CHOAM and Richese with their selected E1/E2 decks, optional Moritani and +classic seats, within the original two-through-six-seat limits. The same +selected-family/roster, fresh setup and actual combined-battle assertions remain; +no played Advanced game is reflagged. This supersedes the earlier bare +Tleilaxu-only Basic admission below. Independent E3 cards and optional modules +retain their own registered profiles rather than inheriting this new scope. +Starts and full rules remain previews, not complete-mode certification. + +Basic retains the existing provisional main-p8 odd policy: Ecaz contributes and +loses `ceil(E/2)`, leaving `floor(E/2)` on an ordinary victory. The selected lead +owns leaders/cards/cleanup, while the physical partner owns variable forces and +their casualties. Basic has zero spice support/funding/income; fixed Ecaz strength +never becomes a CHOAM payment or Advanced city income. Suboids retain half +strength and Cyborgs two without support. A surviving physical Suboid funds the +actual Ixian's substitution; Ecaz cannot spend its own counters for that exchange. +The original Tleilaxu winning-co-side exclusion and external whole-side Face Dance +remain. No new printed arithmetic or resolution priority is claimed. + +The actual total-wheel now derives half steps from its current physical force +pool, including when Ecaz plans an Ixian half commitment. It returns to ordinary +Basic whole steps if cancellation leaves only Ecaz's own army. Native CHOAM +keeps its actual Basic five-disc roster; no Advanced Auditor/funding is added. + +A marker-only allied Richese remains part of the admitted coalition even when +Ecaz is the original battle representative and the marker has value0. Mixed +ordinary-plus-marker Basic battles use the existing explicitly provisional +additive quote: ordinary counters plus `min(printed token, current reserves)`. +This extends the selected Advanced composition, not a new adjudication of the +FAQ mixed-cap ambiguity. The actual force owner supplies private bounds; rivals +receive no concealed increment. The first seal retains the marker/reserves; the +second reveals the original marker and materializes its capped group once, +retaining ordinary counters. Richese takes variable losses separately from fixed +Ecaz losses. Cancellation retains the original lead, removes the unselected +pool and preserves original marker reveal/history. + +### Focused Basic native proof and limits + +A disjoint native-consumer lane, bounded dependency review and owner integration +pass **12 files244/244**, `npm run check:quick`, production build and nine +actual-gameplay/rendered-table programmes. Four public-entry cases cover each +new native family and the six-native roster. Seventeen consumer cases retain +real public ready/start/setup, original native offers/components, real Nexus +alliance and actual No-Field shipment, then explicitly controlled conserved +board/typed/card positions and thirty-spice budgets. These are not unstaged +complete games or earned starting wallets for their later battle positions. + +Runtime covers both Ixian leads with actual typed loss/substitution, Ecaz total +3.5 losing one Suboid, actual Karama returning to Ecaz-only dial2, both CHOAM +leads, marker-only zero and both mixed Richese leads. Each reaches original +Basic Collection in14–38 lead/plan/continuation actions. Selected ordinary decks +remain47 or35 plus the separate ten-card cache; native CHOAM has five leaders. +Actual Ix offers and subsequent native HMS placement are retained. The private +driver originally expected HMS placement in the earlier after-setup snapshot; +checking its actual pre-battle placement repaired only that driver. Source, +tests, types and build stayed identical while runtime alone reran. + +Actual GameTable numeric wheels expose step0.5 for the Ixian pool under either +lead and step1 after Ecaz-only cancellation. Rendered first/second-seal, typed +loss, actual-Ixian substitution and Collection states use real player views. +Four existing profiles supply twelve legal lead/plan/native-substitution +proposals; no strategy tuning. Framework aliases/CSS stubs mean runtime +rendering, not browser/visual/deployed acceptance. Frozen type feedback repaired +only a fixture's stale lead-choice narrowing across action closures. Related +Advanced, Basic Tleilaxu, mixed-marker, typed and Face Dance regressions pass. + +Frozen source: base `152039be5b052d5ea20462c5313481ec595b6e5b`, tree +`87032d020159fa6258fa2c2df9327bb1043511377dcd36648fbc9e4a2aff1498`, +2,619 tracked/nonignored files including only the untracked supplied PDF's hash. +The PDF is not committed or republished. Private logs, HTML, snapshots and +runtime results are in +`/home/quintine/.local/share/dune/checkpoints/20261024-basic-ecaz-native/`. +`frozen-integrated-final/report.json` combines identical-source tests/quick/build +from `frozen-integrated-2` with the passed `runtime-repair`; failed feedback is +preserved separately. Final prose/reference changes are checked against all +frozen hashes. Full modes, other optional configurations, comprehensive +assurance and deployed acceptance remain open. No saved-room/database/reset/ +restart/deployment operation or strategic calibration. + + ## Bare Basic native Tleilaxu — 24 October 2026 The Basic profile now admits native Tleilaxu with exactly the selected `ecaz` diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 865feab8..d27716a0 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,27 @@ # Dune implementation status +## 24 October 2026 — Bare Basic native Ecaz coalitions + +Remove the conservative Basic native-family admission restriction while keeping +fresh public setup, selected decks/rosters and actual coalition assertions. +Include allied marker-only/mixed Richese in actual admission, preserve original +both-seal materialization and physical owner losses, and use the actual Ixian +force pool for half-step wheels under Ecaz. Cancellation returns to selected-only +forces/whole steps. Basic retains zero support/funding/income and CHOAM5. Existing +odd-count and additive-marker interpretations remain explicitly provisional. + +A disjoint consumer lane, bounded dependency review and owner integration pass +12-file244/244, types/lint/build, nine actual-control/rendered-table programmes +and four existing profiles with12 legal proposals. Public Basic setup/Nexus/ +alliance/marker shipment are genuine; later conserved board/card/typed/thirty- +spice staging is controlled. Source47/35 plus cache10 and actual native HMS +placement remain. Typed loss/substitution, total3.5, original Karama and zero/ +capped/mixed markers reach native Collection in14–38 continuations. Feedback +repaired fixture narrowing and a private HMS stage assumption; runtime alone +reran with source unchanged. [Current scope, source and exact frozen evidence](ECAZ_OCCUPY_RULES.md#bare-basic-native-families--24-october-2026). +Full modes, optional-module combinations, comprehensive assurance and deployed +acceptance remain open. No strategy tuning or saved-room/reset/restart/deployment. + ## 24 October 2026 — Cohort-eligible Ambassador leader and KH repeats Use one phase/slot/price-independent death-cohort quote for normal Revival and diff --git a/docs/RICHESE_NO_FIELD_RULES.md b/docs/RICHESE_NO_FIELD_RULES.md index f672f021..c95c7ce4 100644 --- a/docs/RICHESE_NO_FIELD_RULES.md +++ b/docs/RICHESE_NO_FIELD_RULES.md @@ -2,6 +2,24 @@ 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. +## Basic native coalition composition — 24 October 2026 + +Bare Basic Ecaz/native E1/E2 tables now keep allied marker presence and the +existing provisional additive ordinary-plus-reserve-capped-marker quote through +actual coalition admission and both seals. This supersedes only the Basic +coalition boundary in the historical mixed-pool checkpoint below. Richese's +variable counters, fixed Ecaz losses, private force-owner bounds, original token +history and selected-only cancellation stay separate. Basic support remains0; +no Advanced funding or city income is added. Positive ordinary forces plus +marker5/reserves2 retain the ordinary group and materialize only2 once; marker0 +retains battle presence without creating a counter. + +This is source composition using the earlier explicitly provisional mixed-pool +policy, not a resolution of the FAQ reserve-cap ambiguity or full Richese/module +acceptance. [Current scope, controls and exact evidence](ECAZ_OCCUPY_RULES.md#bare-basic-native-families--24-october-2026) +remain in the canonical coalition contract. Optional configurations retain their +own profiles; no saved-game conversion is introduced. + ## Current concealed-value Truthtrance — 16 October 2026 The separate current-fact question asks whether the respondent **currently has diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index a353b87b..b3edb0b6 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -441,6 +441,7 @@ They supersede earlier software-absence claims only within their named scope. | Basic behavior | First-version policy | Canonical contract | | --- | --- | --- | | Odd-force Ecaz Occupy | Main paragraph: ceiling contribution/loss, floor survivors; conflicting FAQ remains unresolved. | [Occupy](ECAZ_OCCUPY_RULES.md) | +| Bare Basic native Occupy | Fresh public Ecaz with selected native E1/E2 families retains actual setup/force owners and Basic zero support. Ixian half steps follow the physical pool; original Ixian substitution belongs to Ixians. Marker-only/ordinary-plus-marker Richese uses the existing provisional additive pool, preserves both seals and actual owner casualties. No new odd-count or mixed-cap ruling, Advanced Auditor/funding or optional-module opening. | [Scope and proof](ECAZ_OCCUPY_RULES.md#bare-basic-native-families--24-october-2026) | | New-ally Duke loan | Optional existing-disc loan after Ambassador alliance consent; one battle and unused turn-end set-aside. | [Duke acquisition](ECAZ_DUKE_ACQUISITION.md) | | Tleilaxu Ambassador leader alternative | An immediate first-death own native leader or an ordinary-cohort-eligible repeat returns free instead of forces under the selected provisional composition; no Tleilaxu early-service waiver is imported. Snapshot/persist an actual opening before clearing death, including an all-first-dead opening. Ecaz retains native identity-cohort recording. Existing Advanced Auditor permission remains independent of the ordinary five-disc cycle. Atreides' KH keeps first-return permission; repeats require its saved marker in the actual ordinary cohort, which is preserved before advancing the marker above both cycles. Activation losses and five native discs remain. Ecaz's independently revivable shared Duke remains separate. Duke reuses the canonical dead-disc permission and selected alive/set-aside result, preserves death history and any satisfied native cohort opening, and grants no automatic battle control. Only an Ecaz beneficiary may select Duke. Ordinary allowance independence remains provisional. A positive Ambassador return shares the actual beneficiary’s ordinary free-income entitlement once per turn; actual Ghola remains independently rewarded. Cancellation suppresses only the earned bank spice and retains the completed return and spent entitlement. This source composition is provisional; wider custody/module envelopes stay separate. | [Native repeat policy](TLEILAXU_AMBASSADOR_RULES.md#ordinary-repeated-leaders--24-october-2026), [Ambassador income policy](TLEILAXU_AMBASSADOR_RULES.md#provisional-free-revival-income--24-october-2026), [shared Duke route](TLEILAXU_AMBASSADOR_RULES.md#ecaz-shared-duke-alternative--24-october-2026) | | Ordinary allied shipment transit | Depart during the shipper's turn; excess new physical visitors still present at ending go to Tanks, even when exit was prevented. Old co-occupation timing is separate. | [Allied transit](ALLIED_TRANSIT_RULES.md) | @@ -813,7 +814,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Sapho battle order | First/last reorders remaining Battle-phase choosers at clean boundaries. Phase priority persists through aftermath; actual legal pairs determine eligibility. Separating the chooser from physical attacker/tie advantage is an explicit implementation interpretation of the card's distinct alternatives, not a dedicated FAQ ruling. Last does not prevent others from choosing the holder as an opponent. | [Runtime scope and source interpretation](SAPHO_BATTLE_ORDER.md) | | 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) | +| Mixed No-Field Occupy | Extend the existing provisional additive ordinary-plus-reserve-capped-marker pool into selected native Advanced and now bare Basic 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. Bare Basic keeps zero support/funding/income and original native setup; other profile/module gates remain. | [Original mixed composition](RICHESE_NO_FIELD_RULES.md#mixed-native-occupy-pool--16-october-2026), [Basic extension](RICHESE_NO_FIELD_RULES.md#basic-native-coalition-composition--24-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) | | No-Field / current Truthtrance fact | Broad game-related yes/no-fact authority supports the distinct current concealed printed-value predicate0/3/5. No marker/revealed history is No even for0; capped/exhausted5 remains printed5. Read the respondent's own model, never an ally or planned shipment. Publish only aggregate Yes/No after the private expected answer; information disclosure is not physical revelation or future-use obligation. This is explicit composition, not a dedicated No-Field FAQ. Existing physical-counter exclusion, native disposal/parent and mode gates remain. | [Source and current-fact scope](RICHESE_NO_FIELD_RULES.md#current-concealed-value-truthtrance--16-october-2026) | | Harass / classic Homeworlds | Fresh classic2–6 Basic/Advanced independent36-card/Homeworld composition admits Arrakis and foreign-World returns, never an own World. Return exact typed board/visitor counters through native custody: Basic primary; Advanced Emperor normal→Kaitain/elite→Salusa. This destination/timing composition is explicit, not a new card FAQ or separate Nexus ruling. Original sealed commitment/support remains; subsequent population observations cannot rescore it. Homeworld loss receipts retain fully-committed Harass complements; native-only Traitors, late defense/explosion and after-use discard stay. Recruits and Reinforcements have their separate native compositions below; other faction/overlay/public gates remain. | [Source and native routing](HARASS_WITHDRAW_RUNTIME.md#classic-homeworld-reserve-routing--16-october-2026) | diff --git a/game/engine.ts b/game/engine.ts index 289bc0c2..a275fcca 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -10758,14 +10758,10 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game { return initialized; } function ecazOccupyCompositionSupported(g: Game): boolean { - // Native Tleilaxu's ordinary Basic armies and Face Dance already use the - // same coalition outcome. Admit their bare selected decks without opening - // unrelated native factions or optional-module compositions. - const basicTleilaxu = g.advanced === false && Array.isArray(g.expansions) && g.expansions.length === 2 && - g.expansions.includes('ecaz') && g.expansions.includes('ix') && - !!byFaction(g, 'tleilaxu') && - g.players.every(p => p.faction === 'ecaz' || p.faction === 'moritani' || - p.faction === 'tleilaxu' || CLASSIC_FACTIONS[p.faction]) && + // Bare native Basic uses the same physical coalition and selected decks. + // Family/roster checks below remain authoritative; optional configurations + // retain their separate registered profiles rather than inheriting admission. + const basicNatives = g.advanced === false && !g.ecazTreachery && !g.techTokens && !g.strongholdCards; if ((independentNexusProfile(g) || independentDiscoveryProfile(g)) && byFaction(g, 'ecaz')) return true; if (g.leaderSkills && nativeEcazLeaderSkillsProfile(g)) return true; @@ -10792,7 +10788,7 @@ function ecazOccupyCompositionSupported(g: Game): boolean { g.expansions.includes('ecaz') && g.expansions.every(id => ['ecaz', 'ix', 'choam'].includes(id)) && new Set(g.expansions).size === g.expansions.length && - (g.advanced || basicTleilaxu || (g.expansions.length === 1 && g.expansions[0] === 'ecaz' && + (g.advanced || basicNatives || (g.expansions.length === 1 && g.expansions[0] === 'ecaz' && g.players.every(p => p.faction === 'ecaz' || p.faction === 'moritani' || CLASSIC_FACTIONS[p.faction]))) && g.players.length >= 2 && g.players.length <= 6 && @@ -10810,7 +10806,7 @@ function ecazOccupyCompositionSupported(g: Game): boolean { /** Fresh native Occupy profile; Basic odd counts use the main printed rule provisionally. */ export function initializeEcazOccupyGameForAudit(state: Game): Game { requireRule(ecazOccupyCompositionSupported(state), - 'Occupy requires supported native factions and their selected decks; Basic additionally admits native Tleilaxu with the bare Ecaz and Ix decks.'); + 'Occupy requires supported native factions and their selected decks; bare Basic native families exclude optional modules.'); requireFreshBaseRuntime(state); requireFreshFactionInventory(state); const g = initializeSetupGameForAudit(state, false, false, false, false, false, false, true, state.ecazTreachery === true); @@ -34859,7 +34855,7 @@ function applyActionInner( const noFieldPlayers = g.players.filter(player => player.noField?.deployed?.location.territory === choice.territory && (player.id === choice.attacker || player.id === choice.defender || - (g.advanced && connected && ecaz && ally?.ally === ecaz.id && player.id === ally.id && + (combined && ecaz && player.id === ally?.id && (choice.attacker === ecaz.id || choice.defender === ecaz.id)))).map(player => player.id); let mixedNoFieldPlayers: string[] | undefined; for (const playerId of noFieldPlayers) { @@ -34867,8 +34863,8 @@ function applyActionInner( ([key, n]) => n > 0 && splitLocation(key).territory === choice.territory, ); if (!mixed) continue; - requireRule(!combined || g.advanced, - 'Combined mixed No-Field dialing requires the selected Advanced Occupy composition.'); + requireRule(!combined || ecazOccupyCompositionSupported(g), + 'Combined mixed No-Field dialing requires an admitted native Occupy composition.'); (mixedNoFieldPlayers ??= []).push(playerId); } if (combined) { diff --git a/game/reference.ts b/game/reference.ts index 9d9de4ae..b0c1889f 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1453,24 +1453,24 @@ export const RULE_TOPICS: RuleTopic[] = [ summary: 'Ecaz chooses who leads its combined army. Basic provisionally follows the main printed paragraph for odd as well as even counts: ceiling contribution/loss and floor survivors. Advanced retains its user-authorized revision. Usable controls and legal participation are connected; interaction refinement remains.', steps: [ 'When reciprocal Ecaz allies coexist with fighting forces, Ecaz chooses Ecaz or its ally to lead. Every participating whole-territory army must be clear of storm and connected; no component-only battle or casualties are created. The selected lead uses its own leaders, Treachery cards and spice; the original battle-order chooser is not replaced. Advisors are not fighters but retain their native voluntary fighter-conversion opportunity.', - 'Basic combined battles use the native Ecaz deck and existing roster prerequisites. Bare Ecaz+Ix with native Tleilaxu now supports ordinary Tleilaxu coalition and Face Dance behavior, with classic seats and optional Moritani; it adds no other Basic native faction or optional-module combination. Ecaz contributes the ceiling of half its forces at full strength and loses that many on an ordinary win; survivors round down. This follows the main printed paragraph provisionally because its odd-count FAQ example conflicts. A visible control warning distinguishes the prototype from a settled correction. Basic has no Emperor/Fremen elite inventory and its supplied support normalizes to zero.', + 'Bare Basic Ecaz tables support native Ixians, Tleilaxu, CHOAM and Richese with their selected families, classic seats and optional Moritani. Real setup and combined-battle guards remain; optional configurations retain their existing boundaries. Ecaz contributes and loses the ceiling of half its forces on an ordinary win; survivors round down. This follows the main paragraph provisionally because its odd-count FAQ conflicts. Basic support, funding and battle-support income are zero; CHOAM keeps five native leaders and Emperor/Fremen have no Advanced elite inventory.', 'With Advanced Occupy active, the total dial includes the ceiling of half the Ecaz fighters as mandatory full-strength contribution without spice cost, plus the chosen allied army strength. The variable army retains its native Fremen/Fedaykin or Emperor/Sardaukar properties even when Ecaz forms the plan.', 'An ordinary victory leaves the floor of half the Ecaz fighters alive and separately applies the variable army’s dial losses. A sole successful Traitor-call winner preserves both armies; an ordinary defeat, mutual Traitor result or resolved explosion removes the actual combined side. Ecaz and its ally share the printed same-faction Traitor/Face Dancer relation.', '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.', + 'An Ixian variable army takes its own typed losses and chooses surviving-Suboid substitution, even when Ecaz plans. Basic Suboids remain half strength and Cyborgs two without spice support. Your wheel uses that physical pool’s half steps, returning to whole Basic steps after Ecaz-only cancellation. Actual Ixians own substitution; the lead owns winner cards/continuation. Advanced CHOAM support income retains its original payer shares; fixed Ecaz strength and Basic plans create none.', '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 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.', + 'A native Richese ally may supply ordinary counters, a concealed No-Field or both under the selected Basic/Advanced provisional additive pool. Even a zero marker retains coalition presence. The selected actor gets its private variable pool/mixed notice; rivals do not. Both plans seal before one reserve-capped materialization, retaining ordinary counters and Richese-only variable losses. Ordinary Prescience follows the physical force owner; distinct Advanced special Karama keeps its private plan-read interpretation. Prevented Ecaz uses only its own pool; neither reading 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. Unsupported co-side profiles stay guarded.', - 'Advanced numbers come from the user-authorized revised PDF, physical pages 32 and 19, not a new publisher erratum. Basic follows the main printed paragraph provisionally. Original faction/deck prerequisites, separate ten-card Richese cache and independent three-card variant selection stay. Ordinary-plus-marker Advanced Occupy extends the provisional additive interpretation, not the unresolved FAQ cap. Fresh plain/card Ecaz and standalone Ecaz/classic Skills retain ordinary Arrakis Occupy, including independent cards without other modules. Basic mixed-co-side markers, unsupported overlays and full-mode acceptance remain separate.', + 'Advanced numbers come from the user-authorized revised PDF, physical pages 32 and 19, not a new publisher erratum. Basic follows the main printed paragraph provisionally. Original faction/deck prerequisites, separate ten-card Richese cache and independent three-card variant selection stay. Ordinary-plus-marker Basic/Advanced Occupy extends the provisional additive interpretation, not the unresolved FAQ cap. Fresh plain/card Ecaz and standalone Ecaz/classic Skills retain ordinary Arrakis Occupy, including independent cards without other modules. Bare Basic native mixed markers are connected; unsupported overlays and full-mode acceptance remain separate.', ], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Combined-side enumeration, lead choice, native Occupy response and owner-labelled physical outcomes are connected. Basic odd counts now follow the main printed paragraph provisionally; Advanced retains its authorized arithmetic. Optional/exotic composition refinement remains.', evidence: ['game/ecaz-occupy-battle.ts', 'game/board-resolution-quote.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] }, - { area: 'Player controls', status: 'Partial', detail: 'Original total-wheel, lead and Karama controls now support native mixed Richese as ally or opponent. Selected Ecaz sees the private mixed variable notice, not its own maximum; prevention returns to selected-only forces. Four new390px journeys include capped mixed Ecaz lead, funded H/R Richese lead, opposing zero with ordinary counters and actual Karama redial. Earlier native/basic controls remain historical evidence.', evidence: ['components/ecaz-occupy.tsx', 'components/face-dance-decision.tsx', 'components/game-table.tsx', 'game/ecaz-occupy-options.ts'] }, - { area: 'AI', status: 'Partial', detail: 'All four native policies actually seal legal total plans for either lead, active or canceled, using native leaders/cards and physical support. Strategy remains deferred.', evidence: ['game/ecaz-occupy-options.ts', 'game/bots.ts', 'tests/ecaz-occupy-options.test.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Bare Basic selected native-family starts now retain their original setup and owner-labelled coalition outcomes, including allied zero/mixed Richese presence and actual Ix typed losses/substitution. Basic support stays zero; current physical pools drive half-step controls. Main-paragraph odd Basic and additive-marker policies remain provisional; optional/exotic refinement remains.', evidence: ['game/ecaz-occupy-battle.ts', 'game/board-resolution-quote.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Actual Basic wheels expose half-step Ixian totals under either lead, whole steps after Ecaz-only cancellation, zero-support native plans and private Richese mixed bounds. Nine current rendered-table programmes retain original seal/reveal, typed/substitution and Collection controls. Earlier mobile journeys stay separate; no new visual/browser certification.', evidence: ['components/ecaz-occupy.tsx', 'components/face-dance-decision.tsx', 'components/game-table.tsx', 'game/ecaz-occupy-options.ts'] }, + { area: 'AI', status: 'Partial', detail: 'Four existing Basic profiles make twelve legal lead/plan/native-Ixian-substitution proposals through original Collection. Existing Advanced legal participation remains separate. Strategy and strength calibration remain deferred.', evidence: ['game/ecaz-occupy-options.ts', 'game/bots.ts', 'tests/ecaz-occupy-options.test.ts'] }, { area: 'Documentation', status: 'Implemented', detail: 'Authorized Advanced arithmetic and provisional main-paragraph Basic arithmetic are distinguished from the conflicting FAQ, specific Duke ruling and deferred interaction refinement.', evidence: ['docs/ECAZ_OCCUPY_RULES.md', 'docs/RULE_DECISIONS.md', 'game/reference.ts'] }, - { area: 'Verification', status: 'Partial', detail: 'Current mixed regressions cover both physical roles/leads, zero/caps, prevention, original support/losses, real Traitors and H/R funding/disposal. Four actual mobile journeys and16 native programs/80 policy actions reach original Collection. Earlier363/26-program and Basic full-game evidence remains historical, not a fresh broad/recovery/deployed certification. Complete modes, overlays and final assurance remain open.', evidence: ['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/ecaz-occupy-battle.test.ts', 'tests/basic-ecaz-occupy-runtime.test.ts', 'tests/basic-ecaz-tleilaxu-occupy.test.ts', 'tests/ecaz-occupy-richese.test.ts', 'tests/ecaz-occupy-stone.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Current Basic native12-file244/244, types/lint/build and nine controlled gameplay/rendering programmes pass. Public setup and actual Nexus/alliance/marker shipping precede labelled board/card/typed/budget staging; both leads, total3.5, native substitution, cancellation and zero/capped/mixed markers reach Collection. Four existing profiles provide12 legal proposals. Earlier Advanced/mobile/full-game evidence is historical; full modes, optional configurations, comprehensive assurance and deployed acceptance remain open.', evidence: ['tests/basic-ecaz-native-entry.test.ts', 'tests/basic-ecaz-native-occupy.test.ts', 'tests/fixture-basic-ecaz-native.ts', 'tests/fixture-ecaz-occupy-richese.ts', '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/ecaz-occupy-battle.test.ts', 'tests/basic-ecaz-occupy-runtime.test.ts', 'tests/basic-ecaz-tleilaxu-occupy.test.ts', 'tests/ecaz-occupy-richese.test.ts', 'tests/ecaz-occupy-stone.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] }, ], related: ['faction-ecaz', 'advanced-ecaz', 'ecaz-ambassadors', 'battle', 'victory', 'implementation-checklist'], }, diff --git a/tests/basic-ecaz-native-entry.test.ts b/tests/basic-ecaz-native-entry.test.ts new file mode 100644 index 00000000..531a94a1 --- /dev/null +++ b/tests/basic-ecaz-native-entry.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, joinGame, newPlayer } from '../game/engine'; +import { faction, type FactionId } from '../game/catalog'; + +function lobby(roster: FactionId[]) { + const expansions = [...new Set(roster.map(id => faction(id).expansion).filter(id => id !== 'base'))]; + let game = createGame('BASICECAZNATIVEENTRY', newPlayer(roster[0], roster[0], roster[0]), false, expansions); + for (const id of roster.slice(1)) joinGame(game, newPlayer(id, id, id)); + for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' }); + return game; +} +for (const native of ['ixians', 'choam', 'richese'] as const) + void test(`a fresh public Basic Ecaz/${native} lobby starts with its actual native selected family`, () => { + const source = lobby(['ecaz', native, 'guild']), before = structuredClone(source); + const game = applyAction(source, source.host, { type: 'start' }); + assert.equal(game.status, 'setup'); assert.equal(game.advanced, false); + assert.equal(game.ecazOccupyPreview, true); + assert.deepEqual(game.expansions, source.expansions); + assert.deepEqual(game.players.map(p => p.faction), source.players.map(p => p.faction)); + assert.equal(game.leaderSkills, undefined); assert.equal(game.ecazTreachery, undefined); + assert.deepEqual(source, before); + }); +void test('the fresh six-native Basic lobby preserves all three selected families through public start', () => { + const source = lobby(['ecaz', 'ixians', 'choam', 'richese', 'moritani', 'tleilaxu']); + const game = applyAction(source, source.host, { type: 'start' }); + assert.equal(game.status, 'setup'); assert.equal(game.advanced, false); + assert.equal(game.players.length, 6); assert.deepEqual(new Set(game.expansions), new Set(['ecaz', 'ix', 'choam'])); +}); diff --git a/tests/basic-ecaz-native-occupy.test.ts b/tests/basic-ecaz-native-occupy.test.ts new file mode 100644 index 00000000..2c0b7a6b --- /dev/null +++ b/tests/basic-ecaz-native-occupy.test.ts @@ -0,0 +1,184 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { viewGame, type Game } from '../game/engine'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { treacheryDeck } from '../game/cards'; +import { richeseCards } from '../game/richese-cards'; +import { ecazOccupyPlanControl } from '../game/ecaz-occupy-options'; +import { occupyRicheseSeat as player } from './fixture-ecaz-occupy-richese'; +import { basicEcazNativeFixture, resolveBasicEcazNative } from './fixture-basic-ecaz-native'; + +const cards = (game: Game) => [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []), + ...game.players.flatMap(p => p.hand)].map(card => card.id).sort(); +const allCards = (game: Game) => [...cards(game), ...(game.richeseCache ?? []).map(card => card.id), + ...(game.richeseRemoved ?? []).map(card => card.id)].sort(); +function conserved(game: Game) { + for (const p of game.players) { + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20, + `${p.faction} retains its original physical counters.`); + if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0), p.faction === 'ixians' ? 7 : 5); + } +} +function originalBasic(fixture: ReturnType) { + assert.equal(fixture.source.status, 'setup'); + assert.equal(fixture.source.turn, 1); + assert.equal(fixture.source.advanced, false); + assert.equal(fixture.afterSetup.advanced, false); + assert.equal(fixture.afterSetup.status, 'playing'); + assert.deepEqual(cards(fixture.source), treacheryDeck(fixture.source.expansions).map(card => card.id).sort()); + assert.deepEqual(fixture.source.richeseCache, richeseCards()); + const choam = fixture.afterSetup.players.find(p => p.faction === 'choam'); + if (choam) assert.equal(choam.leaders.length, 5, 'Original Basic CHOAM adds no Advanced Auditor disc.'); + assert.ok(fixture.afterSetup.players.every(p => p.leaders.length > 0 && + (p.faction === 'tleilaxu' ? p.faceDancers!.length > 0 : p.traitors.length > 0))); + assert.equal(player(fixture.game, fixture.ecaz).ally, fixture.ally); + assert.equal(player(fixture.game, fixture.ally).ally, fixture.ecaz); + assert.ok(fixture.staging.some(line => line.includes('thirty-spice'))); + conserved(fixture.game); +} +function nativeWin(result: ReturnType) { + const { fixture, actor, before, game, profile } = result; + assert.equal(profile.planOwner, actor); + assert.equal(profile.forceOwner, fixture.ally); + assert.equal(profile.fixedEcazDial, 3, 'Basic odd-five policy provisionally uses main p8 ceil/floor.'); + assert.equal(profile.maxSupport, 0); + assert.equal(ecazOccupyPlanControl(viewGame(before, actor), Number(result.plan.dial), 0)?.blocked, null); + assert.ok(ecazOccupyPlanControl(viewGame(before, actor), Number(result.plan.dial), 1)?.blocked); + assert.equal(player(game, fixture.ecaz).forces[fixture.location], 2); + assert.equal(player(game, fixture.ecaz).tanks - player(before, fixture.ecaz).tanks, 3); + assert.equal(player(game, fixture.opponent).forces[fixture.location] ?? 0, 0); + assert.equal(player(game, fixture.opponent).tanks - player(before, fixture.opponent).tanks, 8); + for (const id of [fixture.ecaz, fixture.ally]) assert.equal(player(game, id).spice, player(before, id).spice, + 'Basic support and shared Carthag occupancy produce no Advanced spice debit or city income.'); + assert.equal(game.lastBattleContext?.event, before.battle?.event); + assert.equal(game.lastBattleContext?.winner, actor); + assert.equal(game.phase, 7); + assert.deepEqual(allCards(game), allCards(fixture.source)); + conserved(game); +} + +for (const allyFaction of ['ixians', 'choam', 'richese'] as const) + for (const lead of ['ecaz', 'ally'] as const) + void test(`Public Basic native ${allyFaction} coalition with ${lead} lead resolves free actual-owner losses`, () => { + const fixture = basicEcazNativeFixture({ allyFaction, + opponentFaction: allyFaction === 'ixians' ? 'choam' : 'guild' }); + originalBasic(fixture); + if (allyFaction === 'ixians') assert.deepEqual(fixture.source.players.map(p => p.faction), + ['ecaz', 'ixians', 'choam', 'richese', 'moritani', 'tleilaxu']); + const result = resolveBasicEcazNative(fixture, lead); + nativeWin(result); + const loss = allyFaction === 'ixians' ? 4 : 2; + assert.equal(player(result.game, fixture.ally).forces[fixture.location] ?? 0, 4 - loss); + assert.equal(player(result.game, fixture.ally).tanks - player(result.before, fixture.ally).tanks, loss); + assert.equal(result.profile.forces.normalFixedHalf, allyFaction === 'ixians'); + }); + +for (const lead of ['ecaz', 'ally'] as const) + void test(`Public Basic mixed Ix with ${lead} lead owns its actual typed choice and Suboid substitution`, () => { + const fixture = basicEcazNativeFixture({ allyFaction: 'ixians', opponentFaction: 'choam', mixedIxians: true }); + originalBasic(fixture); + const result = resolveBasicEcazNative(fixture, lead, { losses: { normal: 0, elite: 1 }, substitute: true }); + nativeWin(result); + assert.ok(result.typedDecision?.decision?.kind === 'battleLosses'); + const decision = result.typedDecision.decision; + assert.equal(decision.player, result.actor); + assert.equal(decision.forceOwner, fixture.ally); + assert.ok(decision.options.some(loss => loss.normal === 4 && loss.elite === 0)); + assert.ok(decision.options.some(loss => loss.normal === 0 && loss.elite === 1)); + assert.ok(result.afterLosses && result.substitutionDecision && result.afterSubstitution); + assert.equal(player(result.afterLosses, fixture.ally).elites!.forces[fixture.location], 1); + assert.equal(player(result.afterLosses, fixture.ally).elites!.tanks - player(result.before, fixture.ally).elites!.tanks, 1); + assert.equal(result.substitutionDecision.decision?.player, fixture.ally); + assert.equal(player(result.afterSubstitution, fixture.ally).elites!.forces[fixture.location], 2); + assert.equal(player(result.game, fixture.ally).elites!.tanks, player(result.before, fixture.ally).elites!.tanks); + assert.equal(player(result.game, fixture.ally).forces[fixture.location], 7); + assert.equal(player(result.game, fixture.ally).tanks - player(result.before, fixture.ally).tanks, 1, + 'The actual surviving Suboid funds the recovered Cyborg without consuming Ecaz counters.'); + }); + +for (const marker of [0, 3] as const) for (const lead of ['ecaz', 'ally'] as const) + void test(`Public Basic concealed Richese ${marker} with ${lead} lead materializes only after both real seals`, () => { + const fixture = basicEcazNativeFixture({ marker, richeseReserves: 2 }); + originalBasic(fixture); + const result = resolveBasicEcazNative(fixture, lead); + nativeWin(result); + assert.ok(player(result.before, fixture.richese).noField!.deployed); + assert.deepEqual(player(result.firstSeal, fixture.richese).noField!.deployed, + player(result.before, fixture.richese).noField!.deployed); + assert.equal(player(result.firstSeal, fixture.richese).reserves, player(result.before, fixture.richese).reserves); + assert.equal(player(result.revealed, fixture.richese).noField!.deployed, null); + const materialized = Math.min(marker, 2); + assert.equal(player(result.before, fixture.richese).reserves - player(result.revealed, fixture.richese).reserves, + materialized); + assert.equal(player(result.revealed, fixture.richese).forces[fixture.location] ?? 0, materialized); + assert.equal(player(result.game, fixture.richese).tanks - player(result.before, fixture.richese).tanks, materialized); + assert.equal(player(result.game, fixture.richese).forces[fixture.location] ?? 0, 0); + assert.equal(result.profile.allyNoFieldPresence, true, 'A native zero marker remains real coalition presence.'); + }); + +void test('Public Basic Ecaz-led Ix total dial keeps a legal half-step from its native Suboid owner', () => { + const fixture = basicEcazNativeFixture({ allyFaction: 'ixians', opponentFaction: 'choam' }); + const result = resolveBasicEcazNative(fixture, 'ecaz', { variableDial: 0.5 }); + nativeWin(result); + assert.equal(result.plan.dial, 3.5); + assert.equal(result.profile.forces.normalFixedHalf, true); + assert.equal(player(result.game, fixture.ally).forces[fixture.location], 3); + assert.equal(player(result.game, fixture.ally).tanks - player(result.before, fixture.ally).tanks, 1); +}); + +void test('Public Basic canceled Ecaz-led mixed Ix uses only its own free ordinary pool', () => { + const fixture = basicEcazNativeFixture({ allyFaction: 'ixians', opponentFaction: 'choam', mixedIxians: true }); + const result = resolveBasicEcazNative(fixture, 'ecaz', { canceled: true }); + assert.equal(result.profile.canceled, true); + assert.equal(result.profile.fixedEcazDial, 0); + assert.equal(result.profile.forceOwner, fixture.ecaz); + assert.equal(result.profile.forces.normalFixedHalf, false); + assert.equal(result.profile.maxSupport, 0); + assert.equal(result.plan.dial, 2); + assert.equal(player(result.game, fixture.ecaz).forces[fixture.location], 3); + assert.equal(player(result.game, fixture.ecaz).tanks - player(result.before, fixture.ecaz).tanks, 2); + assert.deepEqual(player(result.game, fixture.ally).forces, player(result.before, fixture.ally).forces); + assert.deepEqual(player(result.game, fixture.ally).elites, player(result.before, fixture.ally).elites); + assert.equal(player(result.game, fixture.ally).tanks, player(result.before, fixture.ally).tanks); + assert.equal(player(result.game, fixture.ecaz).spice, player(result.before, fixture.ecaz).spice); + assert.equal(player(result.game, fixture.ally).spice, player(result.before, fixture.ally).spice); + assert.equal(result.substitutionDecision, null); + assert.equal(result.game.phase, 7); + conserved(result.game); +}); + +for (const lead of ['ecaz', 'ally'] as const) + void test(`Public Basic mixed Richese ordinary2 plus marker5 with ${lead} lead retains separate materialization and losses`, () => { + const fixture = basicEcazNativeFixture({ marker: 5, richeseReserves: 2, mixedRicheseForces: 2 }); + originalBasic(fixture); + const result = resolveBasicEcazNative(fixture, lead); + nativeWin(result); + assert.equal(player(result.before, fixture.richese).forces[fixture.location], 2); + assert.equal(player(result.firstSeal, fixture.richese).forces[fixture.location], 2); + assert.equal(player(result.firstSeal, fixture.richese).reserves, 2); + assert.ok(player(result.firstSeal, fixture.richese).noField!.deployed); + assert.equal(player(result.revealed, fixture.richese).noField!.deployed, null); + assert.equal(player(result.revealed, fixture.richese).reserves, 0); + assert.equal(player(result.revealed, fixture.richese).forces[fixture.location], 4, + 'Two original ordinary counters and two reserve-limited marker counters materialize at the real second seal.'); + assert.equal(result.profile.forces.normal, 4); + assert.equal(player(result.game, fixture.richese).forces[fixture.location], 2); + assert.equal(player(result.game, fixture.richese).tanks - player(result.before, fixture.richese).tanks, 2, + 'The variable dial loses two physical Richese counters independently of the three fixed Ecaz casualties.'); + }); + +void test('All four Basic policies select and seal a legal changed native coalition position through ordinary continuation', () => { + const fixture = basicEcazNativeFixture({ allyFaction: 'ixians', opponentFaction: 'choam', mixedIxians: true }); + for (const [index, difficulty] of DIFFICULTIES.entries()) { + const lead = index % 2 === 0 ? 'ecaz' : 'ally'; + const result = resolveBasicEcazNative(fixture, lead, + { difficulty, losses: { normal: 0, elite: 1 }, substitute: true }); + nativeWin(result); + assert.equal(result.selectedLead.lead, result.actor); + assert.equal(result.plan.type, 'battlePlan'); + assert.equal(Number(result.plan.support ?? 0), 0); + assert.ok(result.actions.some(step => step.action.type === 'battlePlan' && step.actor === result.actor)); + assert.ok(result.actions.some(step => step.action.type === 'decision' && step.action.sources)); + } +}); diff --git a/tests/fixture-basic-ecaz-native.ts b/tests/fixture-basic-ecaz-native.ts new file mode 100644 index 00000000..0bce5615 --- /dev/null +++ b/tests/fixture-basic-ecaz-native.ts @@ -0,0 +1,184 @@ +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import type { Difficulty } from '../game/bot-profiles'; +import { quoteEcazOccupyDial } from '../game/ecaz-occupy-battle'; +import { ecazOccupyRicheseFixture, occupyRicheseSeat, + type EcazOccupyRicheseFixture, type EcazOccupyRicheseOptions } from './fixture-ecaz-occupy-richese'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; + +export type BasicEcazNativeStep = { actor: string; action: Action }; +export type BasicEcazNativeLead = 'ecaz' | 'ally'; +export type BasicEcazNativeOptions = Omit & { + /** Labelled conserved eight-counter Ix army, including two original Cyborgs. */ + mixedIxians?: boolean; +}; +export type BasicEcazNativeFixture = EcazOccupyRicheseFixture & { source: Game }; +export const basicEcazNativeClean = (game: Game) => + !game.response && !game.phaseOpening && !game.decision; + +/** Live native windows only; plans remain an explicit action by their real owner. */ +export function nextBasicEcazNativeStep(game: Game): BasicEcazNativeStep | null { + if (!game.response && !game.phaseOpening && game.decision?.kind === 'richeseAllyOpportunity') + return { actor: game.decision.player, action: { type: 'decision', decline: true } }; + if (!game.response && !game.phaseOpening && game.decision?.kind === 'faceDance') + return { actor: game.decision.player, action: { type: 'decision', reveal: false } }; + return nextRicheseStrongholdsNativeStep(game); +} + +/** Genuine public Basic ready/start/setup, original native components, real Nexus + * alliance and actual No-Field shipment from the shared fixture. Its conserved + * board, thirty-spice budget and Karama staging are explicit controlled inputs; + * this is not an unstaged game or a played Advanced game with a changed flag. */ +export function basicEcazNativeFixture(options: BasicEcazNativeOptions = {}): BasicEcazNativeFixture { + const { mixedIxians, ...nativeOptions } = options; + const fixture = ecazOccupyRicheseFixture({ ...nativeOptions, advanced: false, publicStart: true }); + assert.equal(fixture.initial.advanced, false); + assert.equal(fixture.initial.status, 'setup'); + assert.equal(fixture.initial.ecazOccupyPreview, true); + assert.ok(!fixture.initial.ecazTreachery && !fixture.initial.homeworlds && + !fixture.initial.nexusCards && !fixture.initial.leaderSkills && + !fixture.initial.strongholdCards && !fixture.initial.techTokens && + !fixture.initial.discoveryEnabled && !fixture.initial.greatMaker); + if (mixedIxians) { + // Stage before admission, so the original chooseBattle sees actual native + // types. The first shared-fixture admission is discarded with its branch. + let game = structuredClone(fixture.beforeBattle); + const ix = occupyRicheseSeat(game, fixture.ally); + assert.equal(ix.faction, 'ixians'); + assert.ok(ix.elites && ix.elites.reserves >= 2 && ix.reserves >= 4); + assert.equal(ix.forces[fixture.location], 4); + ix.reserves -= 4; + ix.forces[fixture.location] += 4; + ix.elites.reserves -= 2; + ix.elites.forces[fixture.location] = 2; + fixture.staging.push('Before real battle admission: conserve eight actual Ixian counters at Carthag, including two original reserve Cyborgs; no typed casualty or substitution is installed.'); + fixture.beforeBattle = structuredClone(game); + const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === fixture.territory); + assert.ok(choice); + game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: fixture.territory, + target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }); + fixture.game = game; + } + return { ...fixture, source: fixture.initial }; +} + +export type BasicEcazNativeResolveOptions = { + /** Choose the live allocation; omitted cases consume its first legal option. */ + losses?: { normal: number; elite: number }; + substitute?: boolean; + canceled?: boolean; + variableDial?: number; + /** Uses the existing projected legal policy for the lead and plain plan. */ + difficulty?: Difficulty; +}; + +function leader(game: Game, actor: string, strongest: boolean): string { + const available = occupyRicheseSeat(game, actor).leaders.filter(l => !l.dead && !l.usedAt) + .sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength); + assert.ok(available[0]); + return available[0].id; +} +function policyActions(game: Game, actor: string, difficulty: Difficulty): Action[] { + const view = viewGame(game, actor); + view.players.find(p => p.id === actor)!.bot = difficulty; + return botActions(view); +} + +/** Select, seal, reveal and settle one original Basic native coalition battle. + * Captured stages and actions are available for bounded runtime/control proof. */ +export function resolveBasicEcazNative(fixture: BasicEcazNativeFixture, lead: BasicEcazNativeLead, + options: BasicEcazNativeResolveOptions = {}) { + const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally; + let game = structuredClone(fixture.game); + const actions: BasicEcazNativeStep[] = []; + const act = (player: string, action: Action) => { + game = applyAction(game, player, action); + actions.push({ actor: player, action: structuredClone(action) }); + }; + const next = () => { + const step = nextBasicEcazNativeStep(game); + assert.ok(step, 'The actual native continuation must retain a legal action.'); + act(step.actor, step.action); + }; + assert.equal(game.decision?.kind, 'ecazBattleLead'); + const selectedLead = options.difficulty + ? policyActions(game, fixture.ecaz, options.difficulty).find(a => a.type === 'decision' && a.lead === actor) + : { type: 'decision', event: game.decision!.event, lead: actor } satisfies Action; + assert.ok(selectedLead, 'The policy must retain this actual native lead choice.'); + act(fixture.ecaz, selectedLead); + if (options.canceled) { + assert.equal(game.response?.kind, 'ecazOccupy'); + const card = occupyRicheseSeat(game, fixture.opponent).hand.find(c => c.effect === 'karama'); + assert.ok(card); + act(fixture.opponent, { type: 'card', mode: 'cancel', card: card.id }); + } + for (let count = 0; count < 100; count++) { + if (game.battle && !game.battle.preparation && + (!game.battle.preLeader || game.battle.preLeader.closed) && basicEcazNativeClean(game)) break; + next(); + } + assert.ok(game.battle && basicEcazNativeClean(game)); + const before = structuredClone(game); + const profile = viewGame(game, actor).battle!.ecazOccupy!.profile!; + assert.ok(profile); + assert.equal(profile.advanced, false); + assert.equal(profile.maxSupport, 0); + const variable = options.variableDial ?? Math.min(2, profile.maxDial - profile.fixedEcazDial); + const dial = profile.fixedEcazDial + variable; + quoteEcazOccupyDial(profile, dial, 0); + const plan = options.difficulty + ? policyActions(game, actor, options.difficulty).find(a => a.type === 'battlePlan' && + a.dial === dial && Number(a.support ?? 0) === 0 && !a.weapon && !a.defense && + a.leader === leader(game, actor, true)) + : { type: 'battlePlan', dial, support: 0, leader: leader(game, actor, true) } satisfies Action; + assert.ok(plan, 'Every profile must offer a legal plain actual-owner plan.'); + const concealed = structuredClone(occupyRicheseSeat(game, fixture.richese).noField!.deployed); + act(actor, plan); + const firstSeal = structuredClone(game); + assert.equal(game.battle!.revealed, false); + assert.deepEqual(occupyRicheseSeat(game, fixture.richese).noField!.deployed, concealed); + assert.equal(viewGame(game, fixture.opponent).battle!.plans[actor], undefined); + act(fixture.opponent, { type: 'battlePlan', dial: 0, support: 0, + leader: leader(game, fixture.opponent, false) }); + assert.equal(game.battle!.revealed, true); + const revealed = structuredClone(game); + let typedDecision: Game | null = null; + let afterLosses: Game | null = null; + let substitutionDecision: Game | null = null; + let afterSubstitution: Game | null = null; + for (let count = 0; count < 200; count++) { + if (!game.battle && basicEcazNativeClean(game) && !game.pendingTreacheryDiscard) break; + // Actions replace this game inside the local closure. Read each current + // decision afresh instead of retaining the initial lead-choice narrowing. + const decision = (game as Game).decision; + if (!game.response && !game.phaseOpening && decision?.kind === 'battleLosses') { + typedDecision = structuredClone(game); + const choice = options.losses ? decision.options.findIndex(loss => + loss.normal === options.losses!.normal && loss.elite === options.losses!.elite) : 0; + assert.ok(choice >= 0, 'The original native decision must offer the requested typed loss.'); + act(decision.player, { type: 'decision', choice }); + afterLosses = structuredClone(game); + } else if (!game.response && !game.phaseOpening && decision?.kind === 'ixSubstitution') { + substitutionDecision = structuredClone(game); + assert.equal(decision.player, fixture.ally, 'The physical Ixian owns substitution.'); + if (options.substitute) { + const lost = decision.losses[fixture.location]; + assert.ok(lost > 0); + act(decision.player, { type: 'decision', sources: { [fixture.location]: lost }, + recover: { [fixture.location]: lost } }); + } else act(decision.player, { type: 'decision', decline: true }); + while (game.response) next(); + afterSubstitution = structuredClone(game); + } else next(); + } + assert.equal(game.battle, null); + assert.equal(game.decision, null); + assert.equal(game.lastBattleContext?.result, 'normal'); + assert.equal(game.lastBattleContext?.winner, actor); + assert.equal(game.phase, 7, 'The original coalition battle continues to native Basic Collection.'); + return { fixture, source: fixture.source, actor, selectedLead, before, firstSeal, revealed, + typedDecision, afterLosses, substitutionDecision, afterSubstitution, + profile, variable, plan, actions, staging: fixture.staging, result: game, game }; +} diff --git a/tests/fixture-ecaz-occupy-richese.ts b/tests/fixture-ecaz-occupy-richese.ts index cec834da..895313ff 100644 --- a/tests/fixture-ecaz-occupy-richese.ts +++ b/tests/fixture-ecaz-occupy-richese.ts @@ -8,7 +8,9 @@ import { finishEcazOccupySetup, stageEcazOccupyCard } from './fixture-ecaz-occup import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; export type EcazOccupyRicheseOptions = { - allyFaction?: 'richese' | 'ixians' | 'guild'; + allyFaction?: 'richese' | 'ixians' | 'choam' | 'guild'; + advanced?: boolean; + publicStart?: boolean; opponentFaction?: 'richese' | 'choam' | 'atreides' | 'guild'; marker?: NoFieldValue; richeseReserves?: number; @@ -72,11 +74,12 @@ function place(game: Game, owner: string, location: string, count: number): void if (count) player.forces[location] = (player.forces[location] ?? 0) + count; } -/** Real fresh Advanced family deal and natural Nexus alliance. Explicitly +/** Real fresh selected Basic/Advanced family deal and natural Nexus alliance. Explicitly * controlled conserved board/bank/card staging follows original setup; only * native shipment creates the concealed marker, and actual chooseBattle admits * the coalition. No phase, faction, alliance, plan or marker is assigned. */ export function ecazOccupyRicheseFixture(options: EcazOccupyRicheseOptions = {}): EcazOccupyRicheseFixture { + const advanced = options.advanced ?? true; const allyFaction = options.allyFaction ?? 'richese'; const opponentFaction = options.opponentFaction ?? 'guild'; assert.notEqual(allyFaction, opponentFaction); @@ -89,12 +92,12 @@ export function ecazOccupyRicheseFixture(options: EcazOccupyRicheseOptions = {}) game = structuredClone(options.initial); assert.equal(game.status, 'setup'); assert.equal(game.turn, 1); - assert.equal(game.advanced, true); + assert.equal(game.advanced, advanced); assert.equal(game.ecazOccupyPreview, true); assert.deepEqual(game.players.map(p => p.faction), roster); assert.deepEqual(game.expansions, expansions); } else { - game = createGame('ECAZOCCUPYRICHESE', newPlayer(roster[0], roster[0], roster[0]), true, expansions); + game = createGame('ECAZOCCUPYRICHESE', newPlayer(roster[0], roster[0], roster[0]), advanced, expansions); for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction)); for (let i = roster.length - 1; i >= 0; i--) { const position = i + 1; @@ -102,7 +105,9 @@ export function ecazOccupyRicheseFixture(options: EcazOccupyRicheseOptions = {}) game = applyAction(game, roster[i], { type: 'seatPosition', position }); } for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); - game = initializeEcazOccupyGameForAudit(game); + game = options.publicStart + ? applyAction(game, game.host, { type: 'start', ...(advanced ? { advancedPreview: true } : {}) }) + : initializeEcazOccupyGameForAudit(game); } const initial = structuredClone(game); game = finishEcazOccupySetup(game);