Connect mixed No-Field armies to native Occupy battles

This commit is contained in:
Quintine committed 2026-10-16 09:12:57 +11:00
1 parent a58b8bc92c
commit 0dfdeb9578
14 files changed
+778 -43

No files matched your search

+3 -4
View File
@@ -5186,11 +5186,10 @@ export function GameTable({
dial={committed.dial ? Number(committed.dial.value) : battleDial}
support={battleFreeSupport ? 0 : battleSupport} />
<p className="fine">
{ownNoField &&
g.battle.noFieldPlayers.includes(me.id)
{g.battle.noFieldPlayers.includes(me.id)
? g.battle.mixedNoField
? `Provisional ${g.advanced ? 'Advanced' : 'Basic'} mixed No-Field pool: ${battleForces} fighters, combining forces already here with the token's reserve-limited materialization. This estimate is private; the mixed-pool formula is not an adjudicated publisher ruling.`
: `Your concealed No-Field can reveal ${battleForces} physical forces from your current reserves. This estimate is private.`
? `Provisional ${g.advanced ? 'Advanced' : 'Basic'} mixed No-Field ${occupyProfile ? 'variable ' : ''}pool: ${battleForces} fighters, combining forces already here with the token's reserve-limited materialization. ${occupyProfile ? 'Fixed Ecaz strength is separate. ' : ''}This estimate is private; the mixed-pool formula is not an adjudicated publisher ruling.`
: `${battleForceOwnerName}’s concealed No-Field can materialize ${battleForces} physical forces from current reserves. This estimate is private.`
: occupyProfile
? `${battleForceOwnerName} supplies ${battleForces} physical fighters for the variable dial.`
: `You have ${battleForces} fighting forces here.`}
+22 -1
View File
@@ -29,7 +29,28 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
**Concealed No-Field battle cards — 16 October.** Own Harass privately forecasts
**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
Ecaz plan now receives its Richese variable-owner mixed warning; prevention
restores only the selected lead's own pool. Both seals still precede actual
reserve-capped revelation/history. No new saved field, Basic mixed-co-side,
public start or optional-module gate opens; the FAQ mixed cap stays unresolved.
Twenty-eight new native cases and the26-file358/358 related union pass, as do
types/lint and build with existing warnings. Four actual390px journeys finish
original CHOAM market into phase7: capped Ecaz lead, funded H/R Richese lead,
opposing zero/ordinary army and actual Karama prevention/redial. First seals
stay concealed; physical losses, original reserve cost and history hold.
Sixteen fresh native programs/all four policies accept80 policy actions through
Collection. Two generated guide topics retain Partial coverage;28/22 evidence
paths and the new source anchor resolve. One fixture's incidental tie was
repaired by a real three-counter commitment, not weakening the winner check.
Owned native-image mount prerequisite fixed, then hosts/tabs/scaffolds removed;
no human game, D1, deployment or new assurance campaign changed.
[Exact provisional pool and scoped proof](RICHESE_NO_FIELD_RULES.md#mixed-native-occupy-pool--16-october-2026).
**Preceding concealed No-Field battle-card checkpoint — 16 October.** Own Harass privately forecasts
native reserve-limited ordinary counters at their actual sector, without moving
them before both plans seal. Original reveal/history precedes card effects.
Reinforcements requires three own reserves after projected materialization,
+6 -3
View File
@@ -264,8 +264,9 @@ Actual reveal precedes original reserve cost and successful return. Richese-held
Harass returns only its actual variable ordinary counters; Ecaz-held Harass
returns only its original fixed-pool undialed counters. Neither changes the
original mandatory Ecaz commitment. Reinforcements checks only its card user's
post-reveal reserves, not the allied marker's owner's supply. Marker-only
co-side does not admit same-owner mixed ordinary/marker Occupy.
post-reveal reserves, not the allied marker's owner's supply. The
[selected Advanced mixed-pool extension](RICHESE_NO_FIELD_RULES.md#mixed-native-occupy-pool--16-october-2026)
retains that order, physical owners and fixed Ecaz commitment.
Native/pure67 cases, relevant284-case batch and27 actual native-program
cases pass, including all four legal policies. Two natural captured games
@@ -305,7 +306,9 @@ The physical Richese owner reveals once after both seals. Dial prescience and
whole-plan 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 own ordinary-plus-marker dialing and broader disclosure rulings remain.
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.
Stone Burner compares **floor(E/2) + native ally physical undialed tokens** for
active Occupy, or the **selected lead's own pool only** after prevention.
+6
View File
@@ -113,6 +113,12 @@ would refill reserves. The same owner may still use Harass alone. Token3/
reserves6 can materialize3, pay original3, then return its undialed counters.
Preview facts are owner-only and add no persisted Game field.
The [Advanced mixed Occupy extension](RICHESE_NO_FIELD_RULES.md#mixed-native-occupy-pool--16-october-2026)
also combines original ordinary counters with actual marker materialization.
Selected Ecaz still returns only its own fixed-pool undialed counters; selected
Richese returns only its actual ordinary-plus-revealed variable counters.
Original mandatory Ecaz losses and post-reveal reserve funding do not change.
### Scoped native proof
The34 new genuine-shipment cases pass; the frozen17-file related union
+33
View File
@@ -1,5 +1,38 @@
# Dune implementation status
## 16 October 2026 — Mixed ordinary/No-Field native Occupy
Actual native admission previously rejected both allied and opposing Richese
with one ordinary counter plus marker5/reserves2. Extend the existing explicitly
provisional additive pool into selected Advanced Occupy. Fixed Ecaz commitment,
native payer/support/counter owners, second-seal materialization/history and
post-reveal reserve-cost-before-return stay. Private mixed metadata follows
the selected plan's physical force owner; Ecaz gets the actual Richese variable
pool, while prevention restores only its own army. No new persisted field,
Basic mixed-co-side, optional-module or public start gate is opened.
Twenty-eight new native cases and26-file358/358 pass, with types/lint/build.
Existing module-register/chunk-size/route warnings remain. One opposing
prevention fixture accidentally tied; an actual third supported Ecaz counter
now guarantees its asserted ordinary win and exact three-counter loss. Old
blanket-rejection wording tests are deleted, not re-pinned.
Four actual390px journeys clear original CHOAM market into phase7: capped
Ecaz lead (Richese1/Tanks2, Ecaz2/Tanks3), funded mixed H/R Richese lead
(Richese reserves4/Tanks4, Ecaz2/Tanks3), opposing zero/ordinary Richese
victory (own2 committed losses; combined Ecaz5/Guild4 destroyed), and original
Karama prevention (Ecaz2 losses, all3 real Richese counters stay). All first
seals retain native concealment. No overflow or post-configuration browser/
application errors; the owned harness initially needed the existing native
Vinext image alias/environment, corrected before the actual journeys.
Sixteen fresh native programs/four policies accept80 policy actions through
Collection. Actual guide import resolves28/22 evidence paths and the new anchor;
both topic coverages remain Partial. Owned hosts/tabs/scaffolds removed.
No human game, D1, deployment or broad/HTTP/recovery assurance changed.
Mixed-cap source ambiguity and complete-mode/final acceptance remain open.
[Exact interpretation and scope](RICHESE_NO_FIELD_RULES.md#mixed-native-occupy-pool--16-october-2026).
## 16 October 2026 — Concealed No-Field Harass and original reserve funding
Preserve native materialization before effects. Private owner previews forecast
+65 -5
View File
@@ -2,6 +2,65 @@
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.
## Mixed native Occupy pool — 16 October 2026
Standing choose-recommended extends the existing explicitly provisional
ordinary additive pool into the selected Advanced Occupy composition:
`Richese ordinary counters here + min(token value, current Richese reserves)`.
Active coalition total adds the original mandatory `ceil(Ecaz/2)` separately;
only native variable commitment spends support or creates variable losses.
This composes the earlier mixed-pool interpretation with native coalition
arithmetic; it does **not** adjudicate the FAQ's ambiguous mixed reserve cap.
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.
Original Karama prevention preserves the chosen lead but removes the fixed
contribution and all unselected ally strength. Selected Ecaz uses only Ecaz
counters; selected Richese uses its own mixed pool. The original Richese
marker still reveals once after both seals, even when it no longer supplies
the plan. Zero creates no new counters; shortage remains reserve-capped.
Native support, Traitors, original physical losses and token history follow.
Harass/R uses the materialization-first cost contract below, not a new fee
order. No persisted field, new Basic coalition or optional-module gate opens.
### Scoped mixed runtime proof
Before-state actual native admission rejected one ordinary counter plus
marker5/reserves2 for both allied and opposing Richese. Four actual390px
journeys now finish the original CHOAM market into phase7:
- Ecaz lead: one ordinary plus capped2 reveals to3; total5/support2 leaves
Richese1/Tanks2 and Ecaz2/Tanks3. First seal kept the marker/reserves hidden.
- Richese lead: ordinary2 plus marker3, original reserves6, total4/support1,
H/R together; materialize3, spend original3, return4, lose original variable1.
End Richese reserves4/Tanks4, Ecaz2/Tanks3; both actual cards discard once.
- Opposing Richese: ordinary2 plus marker0 retains original reserves2, wins
with its own supported2/real leader, loses only those2 committed counters;
the actual combined Ecaz5/Guild4 side is destroyed and zero history remains.
- Original opposing Karama prevents Ecaz lead. The wheel redials only Ecaz5
with fixed0; total2/support2 loses Ecaz2, leaves all3 actual revealed Richese
counters untouched and consumes the native Karama once.
Each native marker retains its original last-shipped identity. All surfaces
have no overflow, post-configuration browser/application error or stalled
decision. The owned Vite mount initially lacked the native Vinext image alias/
environment; configuring that existing adapter fixed the harness prerequisite,
not product code. Owned host/tabs/scaffolds were removed after proof.
Sixteen fresh native programs accept80 policy actions across Easy/Medium/Hard/
Brutal, active/prevented allied/opposing roles, through original Collection.
This is explicit conserved battle staging after genuine setup/Nexus/shipment,
not unstaged complete-game or new save/deployed acceptance.
The28 new native cases and frozen26-file358/358 related union pass, with
types/lint/build and existing warnings. The two generated guide topics retain
Partial coverage;28/22 evidence paths and this source anchor resolve. One
prevented-opponent fixture needed three real supported Ecaz counters to avoid
an unintended tied precondition; the exact winner/loss assertion remains.
## Concealed battle-card composition — 16 October 2026
Harass now composes with own concealed No-Fields in supported Basic/Advanced
@@ -24,7 +83,8 @@ previously sealed, materialized to0 and stalled on Traitor resolution; the
new first-plan guard rejects that commitment and retains a legal Harass-only
alternative. Other owners' reserves/markers are not shared. Owner-only preview
facts are view data, not new saved Game fields or a historical-save migration.
Existing mode, mixed-co-side, full-plan and optional-module gates remain.
Existing mode, full-plan and optional-module gates remain; the selected
Advanced mixed-co-side extension is defined above.
## Advanced first versions — 8 October 2026
@@ -148,7 +208,7 @@ This table records the original adapter contract. The implemented subset and cur
## Unresolved or compositional boundaries
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. A territory can also contain ordinary forces (the Smuggler answer demonstrates mixed presence), but no retrieved answer expressly defines that limit with existing forces, multiple sectors, typed units or independently committed reserve uses. Basic and Advanced prototypes use an explicitly provisional additive pool; this does not settle the source question. Same-owner mixed ordinary/marker Occupy remains guarded; the existing marker-only Advanced Occupy profile supports the owner-specific card composition above.
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.
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.
@@ -173,13 +233,13 @@ Root integration review also corrected the Polar Sink address: its ordinary boar
`game/force-presence.ts` distinguishes presence from actual units, and map/occupancy, battle discovery, collection and control use that distinction. A marker is one collector even at zero. Storm checks the exact exposed marker sector before materialization/casualties; sheltered Imperial Basin and other sectors are preserved in focused checks. Unprotected worm destruction runs the actual reveal/casualty path, including a real Thumper trigger. Protected-worm and additional removal effects remain bounded by the source interpretations above.
Marker-only battle admission computes the owner's prospective pool from denomination and remaining reserves. The view exposes that pool only to its owning combatant; another player sees its own pool or none. Both plans seal before public reveal materializes the marker. The battle's `noFieldPlayers` flag persists, so zero still has its leader/cards/traitor resolution. Ordinary Atreides dial prescience is rejected on the server and avoided by controls/AI. Basic and Advanced physical-plus-marker admission use the provisional prototype above; combined mixed admission and special whole-plan inspection remain guarded. Broader Truthtrance remains an audit boundary.
Battle admission computes the reserve-capped prospective counters alongside actual ordinary units. Both plans seal before the original marker materializes. The selected active Occupy plan actor receives its own variable pool/provisional notice; rivals retain no concealed maximum. Prevented Ecaz uses only its own pool, while the marker retains native revelation. Zero keeps leader/cards/Traitor legality and original history. Dial prescience, special whole-plan inspection, broader Truthtrance and profile/module gates remain separate.
**Player controls — Partial.** `components/richese-no-field.tsx` provides concealed board markers plus owner-only denomination inventory, last-use status, token selection, ordinary one-force price/ally-share feedback and authoritative unavailable reasons. The parent table adds explicit marker movement selection and private prospective battle bounds. Token identifiers and hidden values are absent from rival board projections. The separate `components/richese-allied-no-field.tsx` supplies owner opportunity/proposal/payment controls and private recipient accept/decline/typed-force controls. Own shipment, marker movement and invitation/reveal have passed browser checks; an allied owner proposal was checked, but recipient browser acceptance and mobile remain pending.
**AI — Partial.** `game/bots.ts` selects eligible private own denominations, emits one-force shipments with exact funding, moves marker-only groups as `forces: {}` with explicit token/event, and reveals positive materializable tokens before a new shipment or after movement. It keeps a zero or exhausted-reserve marker concealed. Ordinary group candidates avoid combining with the owner's concealed marker while mixed battles are guarded. The battle policy uses the projected private force pool; opponent valuation uses public one-force presence. Responses, decisions and Truthtrance take priority. All four profiles also handle allied opportunity and acceptance from their entitled views, prefer positive safe owner-funded offers, decline unusable offers and avoid repeated automatic offers. Actual scheduling tests cover both seating orders, autopilot and a human supplier holding an AI ally’s shipment until explicit choice. Optimal strategy and complete Richese games are not claimed.
**AI — Partial.** Existing private own/allied shipment and explicit marker movement policies remain. Ordinary movement candidates still prefer separate physical and marker groups; current native battle policy consumes the private additive own/selected variable quote without exposing its prospective increment to rivals. Sixteen fresh programs across all four policies complete active/prevented allied/opposing mixed battles with80 accepted policy actions through native Collection. This is minimal legal participation, not Richese strategy or complete-game calibration.
**Documentation — Partial.** The in-app `richese-no-field` topic now documents the above controls and five facets. `RICHESE_NO_FIELD_ENGINE_AUDIT.md` retains the hook audit and current resolution notes. Source-confirmed Gamont requires reveal whenever its selected location contains the marker, then returns one actual force if present; the card is still used when zero results. Gamont engine/UI integration now follows that rule even when ordinary units also occupy the selected marker sector; its options label the reveal consequence without a separate target mode. Canceling the CHOAM power keeps the token concealed. Root reports 29 passing focused scenarios: 19 existing Gamont regressions plus the 10-case No-Field engine file, including two new Gamont scenarios covering 0/3/5, colocated zero/two ordinary forces, scarce reserves, cancellation and stale restored events. Allied shipment and typed allocations are integrated; mixed battles, Guild/Karama allied pricing and broader power interactions remain explicit unfinished work. No game-mode gate was opened.
**Documentation — Partial.** Current source, native lifecycle, allied price/consent and concealed-card/mixed-coalition compositions are recorded above and in the internal reference. Additive mixed caps and effect priority remain explicit provisional interpretations; Homeworld custody, full-plan/Truthtrance, unrelated overlays and complete-mode/final acceptance retain their separate gates. Earlier Gamont/allied proof remains historical, not a freshly repeated assurance campaign.
**Verification — Partial.** Current named focused results: `tests/richese-no-field.test.ts` (13), `tests/richese-no-field-engine.test.ts` (10), `tests/richese-no-field-review.test.ts` (8), `tests/richese-no-field-bots.test.ts` (9). The independent review compares complete opponent projections across 0/3/5 both before and after shipment, and after private plan sealing; checks payment/rates, storm shelter/sectors, actual worm activation, voluntary timing, later physical-token reuse with stale event, atomic BG/Terror entry rejection, stale CHOAM continuation and synthetic inventory validation. AI tests run every profile through Basic/Advanced marker-only zero/3/5 battles, including scarce reserves and repeated JSON restoration, to finite battle completion with conservation. Seven ordinary shipment-budget and four special-acquisition AI regressions also pass, with targeted lint and TypeScript. These focused checks are supplemented by the integrated acceptance updates below; complete Richese-game acceptance is not certified.
+1
View File
@@ -659,6 +659,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) |
| 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) |
+11 -9
View File
@@ -8305,16 +8305,18 @@ function occupyArmiesConnected(g: Game, ecaz: Player, ally: Player, t: string):
}
return false;
}
/** Active Occupy's plan actor controls its ally's variable physical pool. */
function battlePhysicalForceOwner(g: Game, id: string): string {
const occupy = g.battle?.ecazOccupy;
return occupy?.lead === id && !occupy.canceled ? occupy.ally : id;
}
/** Inspection follows the selected plan actor; reveal still belongs to the physical marker owner. */
function battleNoFieldPlan(g: Game, id: string): boolean {
const b = g.battle, occupy = b?.ecazOccupy;
const owner = occupy?.lead === id ? occupy.canceled ? id : occupy.ally : id;
return !!b?.noFieldPlayers?.includes(owner);
return !!g.battle?.noFieldPlayers?.includes(battlePhysicalForceOwner(g, id));
}
function battleConcealedForces(g: Game, id: string): boolean {
const b = g.battle, occupy = b?.ecazOccupy;
const owner = occupy?.lead === id ? occupy.canceled ? id : occupy.ally : id;
return !!b && getPlayer(g, owner).noField?.deployed?.location.territory === b.territory;
const b = g.battle;
return !!b && getPlayer(g, battlePhysicalForceOwner(g, id)).noField?.deployed?.location.territory === b.territory;
}
function ecazBattleProfile(g: Game): EcazOccupyBattleProfile | null {
const b = g.battle, occupy = b?.ecazOccupy;
@@ -33663,8 +33665,8 @@ function applyActionInner(
([key, n]) => n > 0 && splitLocation(key).territory === choice.territory,
);
if (!mixed) continue;
requireRule(!combined,
'Combined Occupy No-Field battle dialing awaits a ruling. Ordinary mixed battles use the visible provisional pool.');
requireRule(!combined || g.advanced,
'Combined mixed No-Field dialing requires the selected Advanced Occupy composition.');
(mixedNoFieldPlayers ??= []).push(playerId);
}
if (combined) {
@@ -35185,7 +35187,7 @@ export function viewGame(state: Game, id: string) {
) as Record<string, StrongholdId | null>,
tieWinner: battleTieWinner(g),
noFieldPlayers: [b.attacker, b.defender].filter(player => battleNoFieldPlan(g, player)),
...(b.mixedNoFieldPlayers?.includes(id) ? { mixedNoField: true as const } : {}),
...(b.mixedNoFieldPlayers?.includes(battlePhysicalForceOwner(g, id)) ? { mixedNoField: true as const } : {}),
ownForces: [b.attacker, b.defender].includes(id)
? planCombatForces(g, me, getPlayer(g, b.attacker === id ? b.defender : b.attacker))
: null,
+10 -7
View File
@@ -1037,7 +1037,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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 No-Fields compose with the existing Advanced Occupy profile, including card-user-specific Harass and reserve costs. Same-owner mixed ordinary/marker Occupy and entire-plan special Karama inspection remain guarded. Homeworld custody, wider Truthtrance, other modules and complete Richese starts remain unfinished; mixed-pool, price and card-effect sequencing are explicit prototype interpretations.',
'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.',
],
related: [
'richese-cards',
@@ -1065,7 +1065,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'AI',
status: 'Partial',
detail:
'Own/allied pricing participation remains. All four profiles use private own materialization, original reserve funding and physical return quotes; unfunded Reinforcements is excluded while Harass remains legal. Sixteen native programs exercise ordinary, funded and fallback paths without strategic tuning or calibration.',
'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.',
evidence: ['game/bots.ts'],
},
{
@@ -1096,6 +1096,9 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/fixture-harass-no-field.ts',
'tests/no-field-reinforcements.test.ts',
'tests/fixture-no-field-reinforcements.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',
],
},
],
@@ -1388,18 +1391,18 @@ 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 or its concealed No-Field alone. A zero or reserve-limited marker remains battle presence; the original Richese owner materializes it only after both plans seal. The selected lead receives the private variable pool needed for its plan; rivals do not. Dial prescience and whole-plan restrictions follow the selected plan’s actual force owner, while cancellation to Ecaz’s own pool removes that concealed-dial restriction. Richese’s own ordinary-plus-marker dialing remains guarded.',
'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.',
'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 score without extra support or on-board arrivals. Its existing provisional cost/disposal on every revealed outcome is unchanged, not a new publisher ruling. Own same-plan Harass/Reinforcements and Stone remain guarded; Basic/unprofiled co-side cards remain unsupported.',
'Advanced numbers come from the user-authorized revised PDF, physical pages 32 and 19, not a newly located publisher erratum. Basic follows the main printed paragraph provisionally. Existing faction/deck prerequisites remain:33 Ecaz alone,47 Ix,35 CHOAM,47 both; Richese retains its separate ten-card cache. Explicit --ecaz-treachery independently adds all three cards. Whole-plan disclosure, mixed No-Field and optional-overlay interactions remain for refinement; public starts remain gated.',
'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.',
'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. Basic mixed-co-side, whole-plan/optional overlays and public starts remain separate gates.',
],
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 390px controls cover mandatory/free dials, native Karama redial, Ix substitutions and whole-side Face Dance. Richese human T78EFNZK chose Ecaz lead, sealed total5/support2 with its real leader, revealed the ally’s original No-Field5 into only two available reserves, and reached Collection with Ecaz2 and two Richese losses. Basic human FEJ6SBFB shows the correct even-force labels, a total-dial slider bounded2..6 with no support control, and real Ecaz/guild casualties after an8–6 win. Other combinations remain open.', evidence: ['components/ecaz-occupy.tsx', 'components/face-dance-decision.tsx', 'components/game-table.tsx', 'game/ecaz-occupy-options.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: '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: 'Development Verified: affected363 rules cases,26 actual native programs including all four legal policies, and original390px Richese/No-Field controls. Source-band Basic adds focused even/odd/canceled quote cases and native runtime cases with all four policies and original Collection/victory. Five genuine2..6-seat Basic games: four complete2090 accepted actions/54 JSON continuations/no rejection; four-seat seed20261299 is captured at the preserved odd-count conflict. Earlier46 ordinary/variant games and source-specific card evidence remain historical; complete factions/optional combinations and comprehensive assurance follow all rules.', evidence: ['tests/ecaz-occupy-battle.test.ts', 'tests/basic-ecaz-occupy-runtime.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 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/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'],
},
@@ -0,0 +1,195 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { ecazOccupyRicheseFixture, occupyRicheseSeat, openOccupyRichese,
finishOccupyRichese, type EcazOccupyRicheseFixture } from './fixture-ecaz-occupy-richese';
type Lead = 'ecaz' | 'ally';
const seat = occupyRicheseSeat;
const at = (game: Game, actor: string, fixture: EcazOccupyRicheseFixture) =>
seat(game, actor).forces[fixture.location] ?? 0;
const income = (game: Game, actor: string, fixture: EcazOccupyRicheseFixture) =>
at(game, actor, fixture) > 0 ? 2 : 0;
function conserved(game: Game): void {
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20,
`${player.faction} retains twenty counters with its original physical owner.`);
}
}
function leader(game: Game, actor: string, strongest: boolean) {
const available = seat(game, actor).leaders.filter(candidate => !candidate.dead && !candidate.usedAt)
.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength);
assert.ok(available[0]);
return available[0];
}
/** Both plans use native leaders without weapons; native aftermath declines
* Traitor calls rather than altering the original deal's card custody. */
function settle(fixture: EcazOccupyRicheseFixture, lead: Lead, cancel: boolean, enemyFirst: boolean) {
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
const originalMarker = structuredClone(seat(fixture.game, fixture.richese).noField!);
assert.ok(originalMarker.deployed, 'The fixture shipped an actual original No-Field token.');
assert.equal(originalMarker.lastShipped, originalMarker.deployed.tokenId);
const karama = seat(fixture.game, fixture.opponent).hand.find(card => card.effect === 'karama');
assert.ok(karama);
let game = openOccupyRichese(fixture, lead, cancel);
const before = structuredClone(game);
const profile = viewGame(game, actor).battle!.ecazOccupy!.profile!;
assert.ok(profile);
assert.equal(profile.canceled, cancel);
assert.equal(profile.payer, actor);
assert.equal(profile.planOwner, actor);
assert.equal(profile.forceOwner, cancel ? actor : fixture.ally);
assert.equal(profile.fixedEcazDial, cancel ? 0 : 3,
'The original five Ecaz counters contribute their fixed ceiling, independent of the selected lead.');
assert.deepEqual(seat(game, fixture.richese).noField, originalMarker);
if (cancel) {
assert.ok(!seat(game, fixture.opponent).hand.some(card => card.id === karama.id));
assert.ok(game.discard.some(card => card.id === karama.id),
'Prevention spends the opposing seat\'s actual original Karama in the native response.');
}
const variable = cancel && actor === fixture.richese ? profile.maxDial
: cancel && fixture.opponent === fixture.richese && lead === 'ecaz' ? 3 : 2;
const support = variable;
const enemyPool = viewGame(game, fixture.opponent).battle!.ownForces!.normal;
const enemyDial = fixture.opponent === fixture.richese ? enemyPool : 0;
const enemySupport = enemyDial;
const ownLeader = leader(game, actor, true);
const enemyLeader = leader(game, fixture.opponent, false);
const dial = profile.fixedEcazDial + variable;
assert.ok(dial + ownLeader.strength > enemyDial + enemyLeader.strength,
'The original native leaders make this controlled battle a normal coalition victory.');
const ownPlan = { type: 'battlePlan' as const, dial, support, leader: ownLeader.id };
const enemyPlan = { type: 'battlePlan' as const, dial: enemyDial, support: enemySupport, leader: enemyLeader.id };
const first = enemyFirst ? fixture.opponent : actor;
const second = enemyFirst ? actor : fixture.opponent;
game = applyAction(game, first, enemyFirst ? enemyPlan : ownPlan);
assert.equal(game.battle!.revealed, false);
assert.equal(viewGame(game, second).battle!.plans[first], undefined,
'The first native seal does not expose the other side\'s plan.');
assert.deepEqual(seat(game, fixture.richese).noField, originalMarker);
assert.equal(seat(game, fixture.richese).reserves, seat(before, fixture.richese).reserves);
assert.equal(at(game, fixture.richese, fixture), at(before, fixture.richese, fixture),
'No prospective counters are placed after only one seal.');
if (fixture.opponent === fixture.richese) {
assert.equal(viewGame(game, actor).battle!.opponentForces, null);
assert.equal(viewGame(game, actor).richeseNoField!.private, null);
} else if (lead === 'ally') {
assert.equal(viewGame(game, fixture.opponent).battle!.opponentForces, null);
assert.equal(viewGame(game, fixture.opponent).battle!.ecazOccupy!.profile, null,
'A selected Richese plan cannot expose its mixed private maximum before native reveal.');
}
conserved(game);
game = applyAction(game, second, enemyFirst ? ownPlan : enemyPlan);
assert.equal(game.battle!.revealed, true);
assert.equal(game.battle!.plans[actor].leader, ownLeader.id);
assert.equal(game.battle!.plans[fixture.opponent].dial, enemyDial);
assert.equal(seat(game, fixture.richese).noField!.deployed, null);
assert.deepEqual(seat(game, fixture.richese).noField!.tokens, originalMarker.tokens);
assert.equal(seat(game, fixture.richese).noField!.lastShipped, originalMarker.lastShipped);
assert.equal(at(game, fixture.richese, fixture), at(before, fixture.richese, fixture) + fixture.materialized!,
'Only the second original seal adds the reserve-capped marker to the existing ordinary army.');
assert.equal(seat(before, fixture.richese).reserves - seat(game, fixture.richese).reserves, fixture.materialized);
if (fixture.opponent === fixture.richese) {
assert.equal(viewGame(game, actor).battle!.opponentForces!.normal, enemyPool,
'The opposing mixed total becomes public at its original reveal, not before.');
}
const revealed = structuredClone(game);
conserved(revealed);
game = finishOccupyRichese(game);
assert.equal(game.battle, null);
assert.equal(game.lastBattleContext!.result, 'normal');
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(seat(game, actor).leaders.find(candidate => candidate.id === ownLeader.id)!.usedAt, fixture.territory);
assert.deepEqual(seat(game, fixture.richese).noField, seat(revealed, fixture.richese).noField,
'Native settlement cannot restore the marker or reset its no-repeat shipment history.');
assert.equal(seat(before, actor).spice - seat(game, actor).spice, support - income(game, actor, fixture),
'Only the selected lead funds the variable support; the fixed Ecaz contribution is free.');
const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
assert.equal(seat(game, nonLead).spice, seat(before, nonLead).spice + income(game, nonLead, fixture));
assert.equal(seat(before, fixture.opponent).spice - seat(game, fixture.opponent).spice, enemySupport);
conserved(game);
return { game, before, profile, actor };
}
const opposingPools = [
{ marker: 0, richeseReserves: 4, mixedRicheseForces: 2 },
{ marker: 5, richeseReserves: 2, mixedRicheseForces: 2 },
{ marker: 3, richeseReserves: 0, mixedRicheseForces: 2 },
{ marker: 3, richeseReserves: 4, mixedRicheseForces: 1 },
] as const;
for (const lead of ['ecaz', 'ally'] as const) for (const pool of opposingPools)
void test(`Mixed opposing Richese ${pool.mixedRicheseForces}+No-Field ${pool.marker}, reserves ${pool.richeseReserves}, ${lead} lead settles natively`, () => {
const fixture = ecazOccupyRicheseFixture({ allyFaction: 'guild', opponentFaction: 'richese', ...pool });
assert.equal(fixture.game.decision?.kind, 'ecazBattleLead', 'Actual chooseBattle admits the mixed opposing army.');
conserved(fixture.game);
const prepared = openOccupyRichese(fixture, lead);
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
const total = pool.mixedRicheseForces + Math.min(pool.marker, pool.richeseReserves);
assert.equal(viewGame(prepared, fixture.richese).battle!.ownForces!.normal, total);
assert.equal(viewGame(prepared, actor).battle!.opponentForces, null,
'Visible ordinary counters do not authorize disclosure of the hidden opposing maximum.');
assert.equal(viewGame(prepared, actor).battle!.ecazOccupy!.profile!.maxDial, 7,
'The ordinary Guild ally, not the opposing prospective army, supplies the variable Occupy pool.');
const { game, before } = settle(fixture, lead, false, pool.marker !== 0);
assert.equal(at(game, fixture.ecaz, fixture), 2);
assert.equal(seat(game, fixture.ecaz).tanks - seat(before, fixture.ecaz).tanks, 3);
assert.equal(at(game, fixture.ally, fixture), 2);
assert.equal(seat(game, fixture.ally).tanks - seat(before, fixture.ally).tanks, 2);
assert.equal(at(game, fixture.richese, fixture), 0);
assert.equal(seat(game, fixture.richese).tanks - seat(before, fixture.richese).tanks, total,
'Normal defeat destroys both the original ordinary counters and the actual reserve-limited reveal.');
assert.equal(seat(game, fixture.richese).reserves, pool.richeseReserves - fixture.materialized!);
});
for (const lead of ['ecaz', 'ally'] as const)
void test(`Prevented Occupy against mixed opposing Richese leaves only selected ${lead} losses and payment`, () => {
const fixture = ecazOccupyRicheseFixture({ allyFaction: 'guild', opponentFaction: 'richese',
marker: 5, richeseReserves: 2, mixedRicheseForces: 2 });
const { game, before, profile, actor } = settle(fixture, lead, true, true);
assert.equal(profile.maxDial, lead === 'ecaz' ? 5 : 4);
assert.equal(profile.forces.normal, lead === 'ecaz' ? 5 : 4);
const ecazLoss = lead === 'ecaz' ? 3 : 0;
const guildLoss = lead === 'ally' ? 2 : 0;
assert.equal(at(game, fixture.ecaz, fixture), 5 - ecazLoss);
assert.equal(seat(game, fixture.ecaz).tanks - seat(before, fixture.ecaz).tanks, ecazLoss);
assert.equal(at(game, fixture.ally, fixture), 4 - guildLoss);
assert.equal(seat(game, fixture.ally).tanks - seat(before, fixture.ally).tanks, guildLoss);
assert.equal(at(game, fixture.richese, fixture), 0);
assert.equal(seat(game, fixture.richese).tanks - seat(before, fixture.richese).tanks, 4);
assert.equal(profile.payer, actor);
});
for (const lead of ['ecaz', 'ally'] as const) for (const marker of [0, 5] as const)
void test(`Prevented Occupy with mixed allied Richese No-Field ${marker} preserves selected ${lead}'s own army`, () => {
const fixture = ecazOccupyRicheseFixture({ allyFaction: 'richese', opponentFaction: 'guild',
marker, richeseReserves: 2, mixedRicheseForces: 2 });
const prepared = openOccupyRichese(fixture, lead, true);
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
const total = 2 + Math.min(marker, 2);
assert.equal(viewGame(prepared, actor).battle!.ownForces!.normal, lead === 'ecaz' ? 5 : total);
if (lead === 'ecaz') {
assert.equal(viewGame(prepared, fixture.opponent).battle!.opponentForces!.normal, 5,
'Prevention restores Ecaz\'s own inspectable army without borrowing any Richese counters.');
} else {
assert.equal(viewGame(prepared, fixture.opponent).battle!.ecazOccupy!.profile, null);
assert.equal(viewGame(prepared, fixture.opponent).battle!.opponentForces, null);
}
const { game, before, profile } = settle(fixture, lead, true, lead === 'ecaz');
assert.equal(profile.maxDial, lead === 'ecaz' ? 5 : total);
assert.equal(profile.forces.normal, lead === 'ecaz' ? 5 : total);
const ecazLoss = lead === 'ecaz' ? 2 : 0;
const richeseLoss = lead === 'ally' ? total : 0;
assert.equal(at(game, fixture.ecaz, fixture), 5 - ecazLoss);
assert.equal(seat(game, fixture.ecaz).tanks - seat(before, fixture.ecaz).tanks, ecazLoss);
assert.equal(at(game, fixture.richese, fixture), total - richeseLoss,
'An unselected Richese army still reveals natively but loses no counters for Ecaz\'s dial.');
assert.equal(seat(game, fixture.richese).tanks - seat(before, fixture.richese).tanks, richeseLoss);
assert.equal(seat(game, fixture.richese).reserves, 2 - Math.min(marker, 2));
assert.equal(seat(game, fixture.opponent).tanks - seat(before, fixture.opponent).tanks, 8);
});
+267
View File
@@ -0,0 +1,267 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, RuleError, type Game } from '../game/engine';
import { occupyRicheseSeat } from './fixture-ecaz-occupy-richese';
import { NO_FIELD_HARASS, NO_FIELD_REINFORCEMENTS } from './fixture-no-field-reinforcements';
import {
ecazOccupyMixedNoFieldFixture, sealEcazOccupyMixedNoField, finishEcazOccupyMixedNoField,
type EcazOccupyMixedNoFieldFixture,
} from './fixture-ecaz-occupy-mixed-no-field';
function assertCustody(fixture: EcazOccupyMixedNoFieldFixture, 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 physical card 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} keeps its own twenty physical counters.`);
assert.ok(counters.every(count => Number.isSafeInteger(count) && count >= 0));
assert.ok(player.hand.length <= 4, 'Conserved staging must not leave a native hand over its limit.');
}
}
function assertFirstSeal(fixture: EcazOccupyMixedNoFieldFixture, first: Game): void {
const before = occupyRicheseSeat(fixture.preplan, fixture.richese);
const sealed = occupyRicheseSeat(first, fixture.richese);
assert.equal(first.battle!.revealed, false);
assert.deepEqual(sealed.noField, fixture.noField);
assert.equal(sealed.noFieldEvent, before.noFieldEvent);
assert.equal(sealed.reserves, before.reserves);
assert.equal(sealed.forces[fixture.location], fixture.ordinary);
assert.equal(sealed.tanks, before.tanks);
assert.equal(viewGame(first, fixture.opponent).battle!.plans[fixture.owner], undefined);
assertCustody(fixture, first);
}
function assertReveal(fixture: EcazOccupyMixedNoFieldFixture, revealed: Game): void {
const richese = occupyRicheseSeat(revealed, fixture.richese);
const original = occupyRicheseSeat(fixture.preplan, fixture.richese);
assert.equal(revealed.battle!.revealed, true);
assert.equal(richese.noField!.deployed, null);
assert.equal(richese.noField!.lastShipped, fixture.noField.deployed!.tokenId);
assert.deepEqual(richese.noField!.tokens, fixture.noField.tokens);
assert.notEqual(richese.noFieldEvent, original.noFieldEvent);
assert.equal(richese.forces[fixture.location], fixture.ordinary + fixture.materializes,
'Both actual seals add native marker counters to, rather than replace, ordinary counters.');
assert.equal(richese.reserves, original.reserves - fixture.materializes);
assert.equal(richese.tanks, original.tanks);
assertCustody(fixture, revealed);
}
function assertHistory(fixture: EcazOccupyMixedNoFieldFixture, revealed: Game, done: Game): void {
const richese = occupyRicheseSeat(done, fixture.richese);
assert.equal(richese.noField!.deployed, null);
assert.deepEqual(richese.noField!.tokens, fixture.noField.tokens);
assert.equal(richese.noField!.lastShipped, fixture.noField.lastShipped);
assert.equal(richese.noFieldEvent, occupyRicheseSeat(revealed, fixture.richese).noFieldEvent,
'Withdrawal, losses and aftermath must not perform a second marker revelation.');
for (const actor of [fixture.ecaz, fixture.richese, fixture.opponent]) {
const history = viewGame(done, actor).players.find(player => player.id === fixture.richese)!.noField!;
assert.equal(history.deployed, null);
assert.equal(history.lastUsed, fixture.marker, 'The actual shipped denomination becomes native public history.');
}
assert.equal(done.battle, null);
assert.equal(done.decision, null);
assertCustody(fixture, done);
}
function assertUtilities(game: Game, used: readonly string[]): void {
for (const id of [NO_FIELD_HARASS, NO_FIELD_REINFORCEMENTS]) {
assert.equal(game.discard.filter(card => card.id === id).length, used.includes(id) ? 1 : 0);
assert.equal(game.players.flatMap(player => player.hand).filter(card => card.id === id).length,
used.includes(id) ? 0 : 1);
}
}
// Three denominations, one reserve-capped positive marker, and both selected
// plan actors. Every case retains positive ordinary Richese physical presence.
for (const lead of ['ecaz', 'ally'] as const)
for (const [marker, reserves] of [[0, 0], [3, 6], [5, 2]] as const)
void test(`Mixed Richese variable ally: marker ${marker}, reserves ${reserves}, ${lead} lead seals and resolves native counters`, () => {
const fixture = ecazOccupyMixedNoFieldFixture({ lead, marker, richeseReserves: reserves });
const actorView = viewGame(fixture.preplan, fixture.owner).battle!;
const profile = actorView.ecazOccupy!.profile!;
assert.equal(profile.forceOwner, fixture.richese);
assert.equal(profile.payer, fixture.owner);
assert.equal(profile.fixedEcazDial, 3);
assert.equal(profile.maxDial, 3 + fixture.ordinary + fixture.materializes);
assert.equal(actorView.ownForces!.normal, fixture.ordinary + fixture.materializes);
assert.equal(actorView.mixedNoField, true,
'The selected Ecaz plan also uses its physical Richese variable owner, not the Ecaz counter pool.');
const rivalView = viewGame(fixture.preplan, fixture.opponent).battle!;
assert.equal(rivalView.opponentForces, null);
assert.equal(rivalView.ecazOccupy!.profile, null,
'Ordinary presence must not disclose the prospective concealed increment.');
assert.equal(rivalView.mixedNoField, undefined);
const first = applyAction(fixture.preplan, fixture.owner, fixture.plans.plain);
assertFirstSeal(fixture, first);
const revealed = applyAction(first, fixture.opponent, fixture.plans.opponent);
assertReveal(fixture, revealed);
const { game: done } = finishEcazOccupyMixedNoField(fixture, revealed);
assert.equal(done.lastBattleContext!.result, 'normal');
assert.equal(done.lastBattleContext!.winner, fixture.owner);
const richese = occupyRicheseSeat(done, fixture.richese);
assert.equal(richese.forces[fixture.location] ?? 0, fixture.ordinary + fixture.materializes - fixture.variableDial);
assert.equal(richese.tanks - occupyRicheseSeat(fixture.preplan, fixture.richese).tanks, fixture.variableDial);
assert.equal(richese.reserves, reserves - fixture.materializes);
assert.equal(occupyRicheseSeat(done, fixture.ecaz).forces[fixture.location], 2);
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(fixture.preplan, fixture.ecaz).tanks, 3);
assert.equal(occupyRicheseSeat(done, fixture.opponent).tanks - occupyRicheseSeat(fixture.preplan, fixture.opponent).tanks, 8);
for (const actor of [fixture.ecaz, fixture.richese])
assert.equal(occupyRicheseSeat(done, actor).spice,
occupyRicheseSeat(fixture.preplan, actor).spice - (actor === fixture.owner ? fixture.support : 0) +
((occupyRicheseSeat(done, actor).forces[fixture.location] ?? 0) > 0 ? 2 : 0));
assertUtilities(done, []);
assertHistory(fixture, revealed, done);
});
void test('Mixed capped ally uses actual unsupported physical losses, while the original Ecaz fixed commitment stays free', () => {
const fixture = ecazOccupyMixedNoFieldFixture({ lead: 'ecaz', marker: 5, richeseReserves: 2,
variableDial: 2, support: 0 });
const revealed = sealEcazOccupyMixedNoField(fixture);
assertReveal(fixture, revealed);
const { game: done } = finishEcazOccupyMixedNoField(fixture, revealed);
assert.equal(done.lastBattleContext!.winner, fixture.ecaz);
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(fixture.preplan, fixture.richese).tanks, 4,
'Two unsupported strength consumes all four real ordinary-plus-revealed counters.');
assert.equal(occupyRicheseSeat(done, fixture.richese).forces[fixture.location] ?? 0, 0);
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(fixture.preplan, fixture.ecaz).tanks, 3);
assert.equal(occupyRicheseSeat(done, fixture.ecaz).spice,
occupyRicheseSeat(fixture.preplan, fixture.ecaz).spice + 2);
assertHistory(fixture, revealed, done);
});
for (const lead of ['ecaz', 'ally'] as const)
void test(`Mixed positive marker with ${lead}-held H/R returns only that card user's actual undialed army`, () => {
const fixture = ecazOccupyMixedNoFieldFixture({ lead });
const before = fixture.preplan;
const offer = viewGame(before, fixture.owner).battle!;
assert.equal(offer.harassWithdraw!.blocked, null);
assert.equal(offer.reinforcements!.blocked, null);
assert.equal(offer.reinforcements!.normal, 3);
if (lead === 'ally') {
assert.equal(offer.harassWithdraw!.forces.normal, fixture.ordinary + fixture.materializes);
assert.equal(offer.harassWithdraw!.locations[fixture.location].normal, fixture.ordinary + fixture.materializes);
assert.equal(offer.reinforcements!.noField!.reservesAfterReveal, 3);
} else assert.equal(offer.reinforcements!.noField, undefined);
const first = applyAction(before, fixture.owner, fixture.plans.both);
assertFirstSeal(fixture, first);
const revealed = applyAction(first, fixture.opponent, fixture.plans.opponent);
assertReveal(fixture, revealed);
const { game: done } = finishEcazOccupyMixedNoField(fixture, revealed);
assert.equal(done.lastBattleContext!.result, 'normal');
assert.equal(done.lastBattleContext!.winner, fixture.owner);
const ecaz = occupyRicheseSeat(done, fixture.ecaz);
const richese = occupyRicheseSeat(done, fixture.richese);
const returned = lead === 'ecaz' ? 2 : fixture.ordinary + fixture.materializes - fixture.variableDial;
assert.ok(returned > 0);
assert.equal(occupyRicheseSeat(done, fixture.owner).reserves,
occupyRicheseSeat(before, fixture.owner).reserves - (lead === 'ally' ? fixture.materializes : 0) - 3 + returned,
'Three original reserves are spent before, and cannot be funded by, the future H return.');
assert.equal(ecaz.tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, 3 + (lead === 'ecaz' ? 3 : 0),
'Returning Ecaz undialed counters never reduces or recalculates ceil(original five / two).');
assert.equal(richese.tanks - occupyRicheseSeat(before, fixture.richese).tanks,
fixture.variableDial + (lead === 'ally' ? 3 : 0));
assert.equal(ecaz.forces[fixture.location] ?? 0, lead === 'ecaz' ? 0 : 2);
assert.equal(richese.forces[fixture.location] ?? 0, lead === 'ally' ? 0 : fixture.ordinary + fixture.materializes - fixture.variableDial);
const other = lead === 'ecaz' ? fixture.richese : fixture.ecaz;
assert.equal(occupyRicheseSeat(done, other).reserves,
occupyRicheseSeat(before, other).reserves - (other === fixture.richese ? fixture.materializes : 0));
assert.equal(occupyRicheseSeat(done, fixture.owner).spice,
occupyRicheseSeat(before, fixture.owner).spice - fixture.support);
assertUtilities(done, [NO_FIELD_HARASS, NO_FIELD_REINFORCEMENTS]);
assertHistory(fixture, revealed, done);
});
void test('Mixed Richese Reinforcements alone spends post-reveal reserves without returning undialed board counters', () => {
const fixture = ecazOccupyMixedNoFieldFixture();
const revealed = sealEcazOccupyMixedNoField(fixture, 'reinforcements');
assertReveal(fixture, revealed);
const { game: done } = finishEcazOccupyMixedNoField(fixture, revealed);
assert.equal(done.lastBattleContext!.winner, fixture.richese);
assert.equal(occupyRicheseSeat(done, fixture.richese).reserves, 0);
assert.equal(occupyRicheseSeat(done, fixture.richese).forces[fixture.location], 2);
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(fixture.preplan, fixture.richese).tanks, 6);
assertUtilities(done, [NO_FIELD_REINFORCEMENTS]);
assertHistory(fixture, revealed, done);
});
void test('Mixed Richese unfunded FIRST R and H/R plans reject immutably; a real H-only fallback remains legal', () => {
const fixture = ecazOccupyMixedNoFieldFixture({ marker: 5, richeseReserves: 7 });
const before = fixture.preplan;
const saved = structuredClone(before);
const offer = viewGame(before, fixture.owner).battle!;
assert.equal(offer.reinforcements!.normal, 2);
assert.equal(offer.reinforcements!.noField!.reservesAfterReveal, 2);
assert.ok(offer.reinforcements!.blocked);
assert.equal(offer.harassWithdraw!.blocked, null);
for (const plan of [fixture.plans.reinforcements, fixture.plans.both]) {
assert.throws(() => applyAction(before, fixture.owner, plan), RuleError);
assert.deepEqual(before, saved, 'No first seal, card custody, reserve debit or marker reveal survives rejection.');
assert.equal(before.battle!.plans[fixture.owner], undefined);
}
const first = applyAction(before, fixture.owner, fixture.plans.harass);
assertFirstSeal(fixture, first);
const revealed = applyAction(first, fixture.opponent, fixture.plans.opponent);
assertReveal(fixture, revealed);
const { game: done } = finishEcazOccupyMixedNoField(fixture, revealed);
assert.equal(done.lastBattleContext!.winner, fixture.richese);
assert.equal(occupyRicheseSeat(done, fixture.richese).reserves, 7 + fixture.ordinary - fixture.variableDial);
assert.equal(occupyRicheseSeat(done, fixture.richese).forces[fixture.location] ?? 0, 0);
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(before, fixture.richese).tanks, fixture.variableDial);
assert.equal(occupyRicheseSeat(done, fixture.ecaz).forces[fixture.location], 2);
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, 3);
assertUtilities(done, [NO_FIELD_HARASS]);
assertHistory(fixture, revealed, done);
});
void test('Ecaz-held mixed H/R cannot spend its ally reserves or its own future Harass return', () => {
const fixture = ecazOccupyMixedNoFieldFixture({ lead: 'ecaz', richeseReserves: 8, ecazReserves: 2 });
const before = fixture.preplan;
const saved = structuredClone(before);
const offer = viewGame(before, fixture.ecaz).battle!.reinforcements!;
assert.equal(offer.normal, 2);
assert.equal(offer.noField, undefined);
assert.ok(offer.blocked);
assert.throws(() => applyAction(before, fixture.ecaz, fixture.plans.both), RuleError);
assert.deepEqual(before, saved);
const revealed = sealEcazOccupyMixedNoField(fixture, 'harass');
assertReveal(fixture, revealed);
const { game: done } = finishEcazOccupyMixedNoField(fixture, revealed);
assert.equal(done.lastBattleContext!.winner, fixture.ecaz);
assert.equal(occupyRicheseSeat(done, fixture.ecaz).reserves, 4);
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, 3);
assert.equal(occupyRicheseSeat(done, fixture.richese).reserves, 5);
assertUtilities(done, [NO_FIELD_HARASS]);
assertHistory(fixture, revealed, done);
});
for (const lead of ['ecaz', 'ally'] as const)
void test(`Real opposing Traitor cancels ${lead}-held mixed Harass, but preserves native reveal and the original R cost`, () => {
const fixture = ecazOccupyMixedNoFieldFixture({ lead, opposingTraitor: true });
const before = fixture.preplan;
const leader = fixture.plans.both.leader;
assert.ok(typeof leader === 'string');
assert.ok(occupyRicheseSeat(before, fixture.opponent).traitors.includes(leader));
const revealed = sealEcazOccupyMixedNoField(fixture, 'both');
assertReveal(fixture, revealed);
const { game: done } = finishEcazOccupyMixedNoField(fixture, revealed);
assert.equal(done.lastBattleContext!.result, 'traitor');
assert.equal(done.lastBattleContext!.winner, fixture.opponent);
assert.equal(occupyRicheseSeat(done, fixture.owner).leaders.find(face => face.id === leader)!.dead, true);
for (const actor of [fixture.ecaz, fixture.richese]) {
const original = occupyRicheseSeat(before, actor);
const player = occupyRicheseSeat(done, actor);
const materialized = actor === fixture.richese ? fixture.materializes : 0;
const reserveCost = actor === fixture.owner ? 3 : 0;
const destroyed = actor === fixture.ecaz ? 5 : fixture.ordinary + materialized;
assert.equal(player.forces[fixture.location] ?? 0, 0);
assert.equal(player.reserves, original.reserves - materialized - reserveCost,
'An opposing real Traitor prevents every promised H return, including ordinary counters.');
assert.equal(player.tanks - original.tanks, destroyed + reserveCost);
}
assertUtilities(done, [NO_FIELD_HARASS, NO_FIELD_REINFORCEMENTS]);
assertHistory(fixture, revealed, done);
});
-12
View File
@@ -146,18 +146,6 @@ void test('Original concealed Richese presence permits a zero prospective pool,
assert.deepEqual(input, saved);
});
for (const role of ['ally', 'opponent'] as const) void test(`Combined Occupy ordinary-plus-marker ${role} battle is rejected immutably`, () => {
const fixture = ecazOccupyRicheseFixture({ allyFaction: role === 'ally' ? 'richese' : 'guild',
opponentFaction: role === 'opponent' ? 'richese' : 'guild', marker: 5,
richeseReserves: 2, mixedRicheseForces: 1 });
const game = fixture.beforeBattle;
const saved = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === fixture.territory)!;
assert.throws(() => applyAction(game, choice.chooser, { type: 'chooseBattle', territory: fixture.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }),
{ message: 'Combined Occupy No-Field battle dialing awaits a ruling. Ordinary mixed battles use the visible provisional pool.' });
assert.deepEqual(game, saved);
});
for (const lead of ['ecaz', 'ally'] as const) void test(`Atreides cannot inspect concealed partner's ${lead} plan dial or whole plan`, () => {
const fixture = ecazOccupyRicheseFixture({ opponentFaction: 'atreides', marker: 5, richeseReserves: 2 });
+158
View File
@@ -0,0 +1,158 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import type { NoFieldValue, RicheseNoField } 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 {
createNoFieldReinforcementsSetup, NO_FIELD_HARASS, NO_FIELD_REINFORCEMENTS,
} from './fixture-no-field-reinforcements';
import {
ecazOccupyRicheseFixture, openOccupyRichese, finishOccupyRichese, nextOccupyRichese,
occupyRicheseSeat, type EcazOccupyRicheseFixture,
} from './fixture-ecaz-occupy-richese';
export type EcazOccupyMixedNoFieldOptions = {
/** Original fresh Advanced setup; the independent cards must already be selected. */
initial?: Game;
lead?: 'ecaz' | 'ally';
marker?: NoFieldValue;
ordinary?: number;
richeseReserves?: number;
ecazReserves?: number;
variableDial?: number;
support?: number;
opposingTraitor?: boolean;
};
export type EcazOccupyMixedNoFieldPlan = 'plain' | 'harass' | 'reinforcements' | 'both';
export type EcazOccupyMixedNoFieldFixture = {
source: EcazOccupyRicheseFixture;
initial: Game;
afterSetup: Game;
/** Admitted coalition, before choosing the lead. */
beforeLead: Game;
/** Native preparation complete, before either sealed plan; game is the same control. */
preplan: Game;
game: Game;
owner: string;
ecaz: string;
ally: string;
richese: string;
opponent: string;
territory: string;
location: string;
ordinary: number;
marker: NoFieldValue;
materializes: number;
noField: RicheseNoField;
fixedEcazDial: number;
variableDial: number;
support: number;
opposingTraitor: boolean;
plans: Record<EcazOccupyMixedNoFieldPlan | 'opponent', Action>;
staging: string[];
};
/** Native Advanced variable ALLY with ordinary counters plus its own concealed
* marker. The shared source performs setup, Nexus, conserved board staging,
* actual No-Field shipment and battle admission. No phase/window is replaced. */
export function ecazOccupyMixedNoFieldFixture(
options: EcazOccupyMixedNoFieldOptions = {},
): EcazOccupyMixedNoFieldFixture {
const marker = options.marker ?? 3;
const ordinary = options.ordinary ?? 2;
assert.ok(Number.isSafeInteger(ordinary) && ordinary > 0);
const source = ecazOccupyRicheseFixture({
initial: options.initial ?? createNoFieldReinforcementsSetup(),
allyFaction: 'richese', opponentFaction: 'guild', marker,
mixedRicheseForces: ordinary, richeseReserves: options.richeseReserves ?? 6,
});
assert.equal(source.initial.ecazTreachery, true,
'Select the original independent H/R inventory before native setup, not afterward.');
const owner = options.lead === 'ecaz' ? source.ecaz : source.richese;
// Preserve every original physical face, including auction acquisitions,
// before staging two utility faces in a hand below the native four-card cap.
for (const player of source.game.players) {
source.game.deck.push(...player.hand);
player.hand = [];
}
stageEcazOccupyCard(source.game, owner, card => card.id === NO_FIELD_HARASS);
stageEcazOccupyCard(source.game, owner, card => card.id === NO_FIELD_REINFORCEMENTS);
source.staging.push('Return all unselected original hands to the actual remaining deck; stage only the original Harass & Withdraw and Reinforcements identities in the selected lead hand.');
if (options.ecazReserves !== undefined) {
const player = occupyRicheseSeat(source.game, source.ecaz);
assert.ok(Number.isSafeInteger(options.ecazReserves) && options.ecazReserves >= 0 && options.ecazReserves <= player.reserves);
const relocated = player.reserves - options.ecazReserves;
if (relocated) player.forces['polar_sink:0'] = (player.forces['polar_sink:0'] ?? 0) + relocated;
player.reserves -= relocated;
source.staging.push(`Conserve Ecaz counters: move ${relocated} unused reserves to Polar Sink, leaving ${options.ecazReserves} own reserves.`);
}
const beforeLead = structuredClone(source.game);
const game = openOccupyRichese(source, options.lead ?? 'ally');
const profile = viewGame(game, owner).battle?.ecazOccupy?.profile;
assert.ok(profile);
const noField = structuredClone(occupyRicheseSeat(game, source.richese).noField!);
assert.ok(noField.deployed);
const materializes = Math.min(marker, occupyRicheseSeat(game, source.richese).reserves);
const physicalTraitors = new Set([...(game.traitorReserve ?? []), ...game.players.flatMap(player => player.traitors)]);
const leader = occupyRicheseSeat(game, owner).leaders
.filter(face => !face.dead && !face.usedAt && (!options.opposingTraitor || physicalTraitors.has(face.id)))
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
const enemyLeader = occupyRicheseSeat(game, source.opponent).leaders
.filter(face => !face.dead && !face.usedAt)
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
assert.ok(leader && enemyLeader);
if (options.opposingTraitor) {
holdOccupyCardsTraitor(game, source.opponent, leader.id);
source.staging.push(`Exchange an actual original Traitor Card for ${leader.id} in the opposing hand.`);
}
const variableDial = options.variableDial ?? Math.min(3, ordinary + materializes);
const support = options.support ?? variableDial;
const plain: Action = { type: 'battlePlan', dial: profile.fixedEcazDial + variableDial,
support, leader: leader.id };
return {
source, initial: source.initial, afterSetup: source.afterSetup, beforeLead,
preplan: game, game, owner, ecaz: source.ecaz, ally: source.ally, richese: source.richese,
opponent: source.opponent, territory: source.territory, location: source.location,
ordinary, marker, materializes, noField, fixedEcazDial: profile.fixedEcazDial,
variableDial, support, opposingTraitor: options.opposingTraitor ?? false,
plans: { plain, harass: { ...plain, defense: NO_FIELD_HARASS },
reinforcements: { ...plain, weapon: NO_FIELD_REINFORCEMENTS },
both: { ...plain, weapon: NO_FIELD_REINFORCEMENTS, defense: NO_FIELD_HARASS },
opponent: { type: 'battlePlan', dial: 0, support: 0, leader: enemyLeader.id } },
staging: source.staging,
};
}
/** The second actual seal, not this helper, produces the native reveal receipt. */
export function sealEcazOccupyMixedNoField(fixture: EcazOccupyMixedNoFieldFixture,
plan: EcazOccupyMixedNoFieldPlan = 'plain', state = fixture.game): Game {
const first = applyAction(state, fixture.owner, fixture.plans[plan]);
return applyAction(first, fixture.opponent, fixture.plans.opponent);
}
/** Consume real allocation/response/Traitor decisions, then native aftermath,
* including any original CHOAM market. Never clear a live decision or window. */
export function finishEcazOccupyMixedNoField(fixture: EcazOccupyMixedNoFieldFixture,
state: Game): { afterVotes: Game; game: Game } {
let game = state;
for (let attempt = 0; attempt < 80; 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 { afterVotes: game, game: finishOccupyRichese(game) };
game = applyAction(game, voter, { type: 'traitorCall', call: fixture.opposingTraitor && voter === fixture.opponent });
}
throw Error('Mixed Occupy native declaration windows did not finish.');
}
+1 -2
View File
@@ -172,8 +172,7 @@ export function ecazOccupyRicheseFixture(options: EcazOccupyRicheseOptions = {})
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === battleTerritory);
assert.ok(choice, 'Concealed zero remains actual native battle presence.');
// Mixed ownership fixtures stop before admission so rejection is testable.
if (!mixed) game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: battleTerritory,
game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: battleTerritory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
return { initial, afterSetup, beforeBattle, game, ecaz, ally, opponent, richese,
territory: battleTerritory, location, ecazForces,