Compose concealed No-Field battle cards before reserve costs

This commit is contained in:
Quintine committed 2026-10-16 08:56:01 +11:00
1 parent f50dda6078
commit a58b8bc92c
18 files changed
+1064 -45

No files matched your search

+11 -3
View File
@@ -5354,12 +5354,20 @@ export function GameTable({
<p className="notice">
Reinforcements may occupy either card slot but is neither a weapon nor a defense.
It adds 2 to battle strength without changing the physical dial or spice support.
The cost is three reserve forces ({g.battle.reinforcements.normal} normal,{' '}
{g.battle.reinforcements.elite} elite) sent to the Tanks on any revealed outcome;
the card is discarded even after a traitor call or explosion.
The cost is three reserve forces sent to the Tanks on any revealed outcome.
Available for that quote: {g.battle.reinforcements.normal} normal,{' '}
{g.battle.reinforcements.elite} elite.
The card is discarded even after a traitor call or explosion.
{g.battle.reinforcements.blocked && ` ${g.battle.reinforcements.blocked}`}
</p>
)}
{g.battle.reinforcements?.noField && <p className="fine">
Provisional native order: your No-Field first materializes{' '}
{g.battle.reinforcements.noField.materializes} forces, leaving{' '}
{g.battle.reinforcements.noField.reservesAfterReveal} own reserves.
Three must remain after that reveal for Reinforcements;
a later Harass return cannot fund or change its original cost.
</p>}
{reinforcementsSelected &&
(selectedWeaponId === 'ecaz-harass-withdraw' || selectedDefenseId === 'ecaz-harass-withdraw') && (
<p className="fine">
+9
View File
@@ -51,6 +51,15 @@ export function HarassWithdrawGuide({
className="notice space-y-2"
>
<h4>Harass &amp; Withdraw</h4>
{preview.noField && <p>
Provisional materialization-first timing: your No-Field stays concealed
until both plans are sealed. Its native
reveal materializes {preview.noField.materializes} ordinary forces at
the marker’s sector, leaving {preview.noField.reservesAfterReveal} own
reserves before card effects. Harass returns only actual undialed
counters after that reveal; it never returns a concealed marker or
resets its used-token history.
</p>}
{preview.occupy && <p>
Occupy returns only the card user’s own undialed fighters, not their
ally’s army. {preview.occupy.cardUser === preview.occupy.ecaz && !preview.occupy.canceled
+24 -5
View File
@@ -29,6 +29,25 @@ 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
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,
before future Harass returns; token3/reserves3 now rejects the first fee plan
instead of sealing a battle that stalls at Traitor resolution. Harass-only stays
legal. Owner-only optional previews add no saved Game field or new mode gate.
This existing-order composition is explicitly provisional, not a new FAQ.
New genuine-shipment tests34/34; related17-file210/210, types/lint and build pass
with existing warnings. Sixteen fresh programs/all four policies accept48
continuation actions. Four actual390px journeys cover mixed-sector full return,
zero marker, blocked fee/legal fallback and funded native Occupy. Co-side cases
finish original CHOAM market into phase7 with original fixed/variable losses;
no overflow/browser/application error. Three guide topics import with all15
facets Partial; their25/16/28 evidence paths and three new anchors resolve.
Owned QA hosts/tabs/scaffolds removed; no human game/D1/deployment changed.
[Exact order, boundaries and scoped proof](HARASS_WITHDRAW_RUNTIME.md#own-no-field-composition--16-october-2026).
**Selected Richese weapon/card interaction wave — 16 October.**
- Clean selected CHOAM/Ix profiles now admit Stone/Mirror with native
Ixians/Tleilaxu in Basic/Advanced; Ecaz/Moritani stays Advanced-only.
@@ -45,8 +64,8 @@ Earlier questions remain unanswered; no new publisher ruling, state field,
public start or played-game conversion is implied. New Ix Tech/Stronghold,
other module bands and winner-changing allocation boundaries remain guarded.
Latest affected thirteen-file99/99, TypeScript, lint and build pass. Eight fresh
Node native programs/all four policies accept42 continuation actions; two
The preceding clean-Ix affected thirteen-file99/99, TypeScript, lint and build pass.
Eight fresh Node native programs/all four policies accept42 continuation actions; two
original47/10-cache programs also complete without independent Ecaz cards.
Two real390px control journeys prove Advanced copied Tooth activation versus
unused original (both deaths, actual cyborg losses, physical Mirror kept,
@@ -61,9 +80,9 @@ resolve; all ten facets stay Partial. Exact policies/earlier bounded proof:
[Harass/Stone](HARASS_WITHDRAW_RUNTIME.md#provisional-stone-burner-timing--16-october-2026),
[Harass/Reinforcements](REINFORCEMENTS_RUNTIME.md#combined-utility-plan--16-october-2026),
[Reinforcements/Stone](REINFORCEMENTS_RUNTIME.md#native-stone-composition--16-october-2026).
Own concealed No-Field Harass and other profile/module limits remain. No human
server, saved room, D1, deployment or new assurance campaign changed; final
assurance follows all rules.
Other profile/module limits remain. Own concealed No-Field Harass follows the
separate checkpoint above. No human server, saved room, D1, deployment or new
assurance campaign changed; final assurance follows all rules.
**Ixian Nexus purchased-card interaction wave — 16 October.**
Purchased-card replacement now composes:
+9
View File
@@ -258,6 +258,15 @@ Its +2 normal score adds no undialed counters; original reserve cost and fixed
versus variable on-board losses stay separate. Other optional modules and
Basic/unprofiled co-side card battles remain guarded.
Own concealed markers use the
[provisional materialization-first card contract](HARASS_WITHDRAW_RUNTIME.md#own-no-field-composition--16-october-2026).
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.
Native/pure67 cases, relevant284-case batch and27 actual native-program
cases pass, including all four legal policies. Two natural captured games
exposed an existing mandatory-discard callback comparing casualties to the
+45 -2
View File
@@ -90,6 +90,48 @@ the card adds no fictitious weapon or defense value. Stronger profiles value
saved forces when expecting defeat and discourage use when expecting to win.
This is a legal strategy path, not strength calibration.
## Own No-Field composition — 16 October 2026
Under standing choose-recommended, this prototype preserves the existing native
materialization-first order: both plans seal, the original No-Field reveals at
its actual sector and materializes `min(value, current reserves)`, then Harass
chooses/returns actual undialed ordinary counters. It never returns a concealed
marker or resets token/last-shipment history. This is an explicit composition
of reveal-time instructions, not a newly retrieved combined publisher FAQ.
Before commitment, the owner's private force and location quote includes those
prospective ordinary counters without mutating reserves, board or token. Marker0
still permits leader/cards; reserve shortage caps the actual materialization.
Mixed ordinary/marker sectors choose a complete return only after revelation.
An opposing successful Traitor cancels Harass, not the native marker reveal.
Original fixed Ecaz and variable ally pools remain owner-labelled.
In a supported same-plan Reinforcements profile, the original reserve fee must
remain funded **after native marker materialization and before Harass returns**.
Token3/reserves3 therefore cannot seal Reinforcements, even if a later return
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.
### Scoped native proof
The34 new genuine-shipment cases pass; the frozen17-file related union
passes210/210, with types/lint and application build. Zero and reserve-capped
markers, both slots, mixed-sector full allocations, real Traitors, original
fixed/variable owners, first-plan funding rejection and component history are
covered. Build retains existing module-register, chunk-size and route warnings.
Sixteen fresh native programs across all four policies accept48 continuation
actions. Four styled390px journeys pass without overflow or browser/application
errors: mixed Basic returns3 marker-sector plus1 other-sector counters and
loses2; Advanced zero plays the card without invented counters; deficient
Occupy keeps Reinforcements and legally returns3 with Harass; funded Occupy
materializes3, pays3, returns2 and loses its original1 variable counter.
The latter has Richese reserves2/Tanks4 and Ecaz Tanks3, preserving original
fixed losses. Both co-side journeys finish the actual CHOAM market and clear
battle/decision into phase7. Owned hosts/tabs/scaffolds were removed; no human
game, D1, deployment or new recovery campaign changed. Full acceptance remains.
## Current boundaries
- Basic and Advanced now support explicit ordinary/elite and sector allocation
@@ -104,8 +146,9 @@ This is a legal strategy path, not strength calibration.
The ordinary invariant-allocation guard remains. Clean selected Ix follows
the [separate priority contract](MIRROR_WEAPON_ENGINE_AUDIT.md#clean-selected-ix-composition--16-october-2026);
other profile/module limits, public starts and played-game retrofit stay gated.
A card user's own still-concealed No-Field remains guarded until its original
reveal; this wave does not certify Harass on a concealed native marker.
Own concealed No-Field use follows the
[materialization-first contract](#own-no-field-composition--16-october-2026).
Other profile/module boundaries remain unchanged.
- Co-present armies remain guarded in Basic or without the supported Advanced
Occupy profile. The [combined-army follow-up](ECAZ_OCCUPY_RULES.md#independent-cards-with-combined-armies--2-october-2026)
returns only actual card-user forces: original fixed Ecaz commitment or the
+31
View File
@@ -1,5 +1,36 @@
# Dune implementation status
## 16 October 2026 — Concealed No-Field Harass and original reserve funding
Preserve native materialization before effects. Private owner previews forecast
the actual reserve-capped ordinary counters at the marker sector, without an
early force/token transfer. Harass then selects/returns actual undialed counters,
never a virtual marker or reset history. Zero, caps, mixed sectors, original
fixed Ecaz/variable ally owners and Traitor cancellation remain connected.
Reproduced actual Advanced token3/reserves3 acceptance followed by native
materialization to0 and a failing first Traitor action. Reinforcements now
requires three own reserves after projected reveal, before any future return,
and rejects the first unfunded plan immutably while retaining legal Harass-only.
This materialize → original fee → successful return composition is explicitly
provisional, not a new publisher FAQ. No Basic Richese fee profile or other
optional module is admitted; no persisted Game field is added.
New genuine-shipment regressions34/34; frozen17-file210/210, types/lint and
build pass with existing module-register/chunk-size/route warnings. Sixteen
fresh native programs across four policies accept48 continuation actions.
Four real390px journeys cover mixed Basic complete-sector allocation, Advanced
zero/card legality, blocked native co-side fee/kept card and funded original
fee before return. The latter ends Richese reserves2/Tanks4 and Ecaz Tanks3;
both co-side controls finish original CHOAM market into phase7. No overflow,
browser/application error or original component-history loss was observed.
Actual generated guide import,25/16/28 topic evidence paths and three anchors
resolve; all15 facets remain Partial. Owned QA scaffolds/hosts/tabs removed.
No human game, D1, deployment or new broad/HTTP/recovery assurance changed.
Full rules, optional interactions and final acceptance remain unfinished.
[Canonical order and proof](HARASS_WITHDRAW_RUNTIME.md#own-no-field-composition--16-october-2026).
## 16 October 2026 — Clean selected Ix Stone and physical Mirror
Replaced the blanket Ix guard only for clean selected CHOAM/Ix profiles,
+20 -2
View File
@@ -35,8 +35,9 @@ This is an implementation interpretation, not a newly retrieved publisher FAQ
or an answer to the earlier reserve-payment question. The source audit's printed
effects and original provisional all-outcome/normal-first cost are unchanged.
Three reserves must exist at commitment. A future return cannot fund a deficient
plan or change its quoted normal/elite mix. For example, two original ordinary
Three original own reserves must remain funded at commitment after projecting
any own No-Field revelation. A future return cannot fund a deficient plan or
change its quoted normal/elite mix. For example, two original ordinary
and one elite reserve are spent even when Harass subsequently returns four
ordinary counters. Both cards discard once. The +2 remains normal-score-only,
not extra physical dial, support or winner casualties. Opposing successful
@@ -50,6 +51,23 @@ Basic/unprofiled co-side and other module gates stay; Stone uses the
[physical-token composition below](#native-stone-composition--16-october-2026).
No persisted state or fresh/public profile is added.
### Own concealed No-Field funding
The existing native battle path materializes a No-Field after both seals and
before card costs. Precommit availability now forecasts that same owner's
remaining reserves. Three must remain at that point, before any future Harass
return; another player's marker/reserves cannot affect or fund this card.
This is the explicitly provisional existing-order composition, not a new
publisher ruling or a waiver of the original fee.
Before-state token3/reserves3 accepted Reinforcements, revealed into reserves0
and then failed the first Traitor action. The new first-plan check rejects that
unfunded commitment without reserving cards or revealing a token; Harass alone
remains legal. Token0/reserves3 remains funded. Native actual revelation,
reserve-cap, token identity/history, support and fixed/variable losses stay.
No Basic Richese Reinforcements or other new mode is admitted.
### Scoped proof
The affected eleven-file offline union passes **134/134**, with types/lint and
+25 -1
View File
@@ -2,6 +2,30 @@
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.
## Concealed battle-card composition — 16 October 2026
Harass now composes with own concealed No-Fields in supported Basic/Advanced
and existing Advanced Occupy card profiles. Standing choose-recommended keeps
the native order: seal both plans, materialize the reserve-limited token at
its actual sector, then resolve original reserve cost and successful Harass
return before ordinary losses. This priority composition remains explicitly
provisional, not a new dedicated publisher FAQ.
Private planning forecasts actual ordinary counters and their marker-sector
location without moving them early. Zero remains a real marker with leader/
card legality; shortage caps by actual reserves. Mixed sectors select all
undialed physical counters only after reveal. Harass neither returns a virtual
marker nor clears its last-use/history fence. Opposing successful Traitor
cancels only the return, never the original component revelation.
Reinforcements requires three original own reserves **after projected native
materialization**, before any later return. A proven token3/reserves3 plan
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.
## Advanced first versions — 8 October 2026
Advanced ordinary mixed battles and discounted allied shipments now reuse
@@ -124,7 +148,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. Combined Occupy remains guarded.
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.
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.
+2 -1
View File
@@ -657,7 +657,8 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Basic Moritani / Leader Skills | Ordinary Terror death returns the assigned skill once; own revival offers the existing private optional replacement. Sabotage does not take skills, alliances do not transfer assignments, and mandatory Planetologist/Diplomat disposal excludes those cards from ally retention. This is composition of existing contracts, not a new ruling or resolution of unfinished skill effects. Advanced and other roster/module combinations remain gated. | [Integration contract](MORITANI_LEADER_SKILLS.md) |
| Ecaz Loyalty | Before initial Traitor dealing, randomly set aside one native Ecaz card face up permanently. No Karama effect; no change to its leader disc. Resolving after existing pre-Traitor setup is an explicit sequencing composition; native ordinary-disc Skills now precede this draw through public assignment. Older dealt saves are not retrofitted; wider Skills/assassination combinations remain gated. | [Official sources and runtime](ECAZ_LOYALTY.md), [native skill composition](LEADER_SKILLS_RUNTIME.md#native-ecaz-ordinary-disc-composition--4-october-2026) |
| 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 uniquely determined own undialed physical forces before weapon losses, 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) |
| 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) |
| 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) |
+3 -1
View File
@@ -31,7 +31,9 @@ The earlier question remains unanswered. This is an interpretation selected
under the standing choose-recommended priority, not newly retrieved publisher
authority. Clean Ix now follows the separate
[selected-deck priority contract](MIRROR_WEAPON_ENGINE_AUDIT.md#clean-selected-ix-composition--16-october-2026);
own concealed No-Field Harass and other profile/module boundaries remain.
own concealed No-Field Harass follows its
[materialization-first contract](HARASS_WITHDRAW_RUNTIME.md#own-no-field-composition--16-october-2026).
Other profile/module boundaries remain.
The 6 September evidence above
is historical; the current checkpoint has its own bounded proof.
+2 -2
View File
@@ -120,8 +120,8 @@ export function cardPresentation(card: VisibleCard): CardPresentation {
availability: ecaz.card.effect === 'recruits'
? 'Recruits has development controls during Revival. Earlier paid revivals, pending transactions, later free-rate changes and repeated use this turn remain guarded pending their rulings.'
: ecaz.card.effect === 'harassWithdraw'
? 'Harass & Withdraw has development controls in either battle-card slot, with physical allocation choices after reveal. Stone Burner provisionally compares undialed forces remaining after successful withdrawal; additional optional modules remain guarded.'
: 'Reinforcements has development controls in either battle-card slot, including the explicit Advanced Occupy card variant. It spends three of the card user’s own reserve forces and adds +2 normal battle score without extra support or physical tokens. Original or copied Stone Burner ignores that score bonus but still charges the reserve cost. All-outcome payment and paying before same-plan Harass returns are provisional; returned forces cannot fund or change the cost. Other profile and optional-module limits remain guarded.',
? 'Harass & Withdraw has controls in either battle-card slot, with physical allocation after reveal. Under provisional materialization-first order, an own concealed No-Field reveals natively before actual undialed counters return; token history is not reset. Stone Burner provisionally compares forces remaining after successful withdrawal. Other profile and optional-module limits remain guarded.'
: 'Reinforcements has controls in either battle-card slot, including the explicit Advanced Occupy card variant. It spends three own reserves and adds +2 normal score, not support or physical tokens. Own No-Field reveal must leave three original reserves before this cost; later Harass returns cannot fund it. Original/copy Stone ignores the score bonus but still charges reserves. All-outcome payment and native cost-before-return order are provisional. Other profile/module limits remain guarded.',
topics: RULE_TOPICS.filter((topic) => topic.id === (ecaz.card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : ecaz.card.effect === 'reinforcements' ? 'card-reinforcements' : 'card-recruits')),
};
const topicIds =
+31 -15
View File
@@ -8250,20 +8250,21 @@ function killTerritory(
n -= lost;
}
}
/** Private planning forecast; public alternatives remove the foreign marker
* before requesting a pool. Actual reveal remains the native component action. */
function prospectiveNoFieldForces(p: Player, territory: string): number {
const marker = p.noField?.deployed;
return marker?.location.territory === territory
? Math.min(p.reserves, p.noField!.tokens.find(token => token.id === marker.tokenId)!.value)
: 0;
}
function combatForces(
g: Game,
p: Player,
t: string,
opponent: Player,
): CombatForces {
const marker = p.noField?.deployed;
const prospective =
marker?.location.territory === t
? Math.min(
p.reserves,
p.noField!.tokens.find((token) => token.id === marker.tokenId)!.value,
)
: 0;
const prospective = prospectiveNoFieldForces(p, t);
const army = combatArmy(g, p.id, t);
const physical = army.normal + army.elite;
const elite = army.elite;
@@ -13753,7 +13754,8 @@ function currentReinforcementsCost(g: Game, p: Player) {
const combinedBlock = combinedCardBattleBlock(g);
requireRule(!combinedBlock, combinedBlock ?? 'This combined card battle is unavailable.');
try {
return quoteReinforcements(p.reserves - (p.elites?.reserves ?? 0), p.elites?.reserves ?? 0);
const materializes = prospectiveNoFieldForces(p, g.battle?.territory ?? '');
return quoteReinforcements(p.reserves - materializes - (p.elites?.reserves ?? 0), p.elites?.reserves ?? 0);
} catch (error) {
if (error instanceof ReinforcementsError) throw new RuleError(error.message);
throw error;
@@ -13763,7 +13765,9 @@ function reinforcementsPreview(g: Game, p: Player) {
const battle = g.battle;
if (!battle || ![battle.attacker, battle.defender].includes(p.id) ||
!p.hand.some(isReinforcements)) return null;
const normal = Math.min(3, Math.max(0, p.reserves - (p.elites?.reserves ?? 0)));
const materializes = prospectiveNoFieldForces(p, battle.territory);
const reservesAfterReveal = p.reserves - materializes;
const normal = Math.min(3, Math.max(0, reservesAfterReveal - (p.elites?.reserves ?? 0)));
const elite = Math.min(3 - normal, Math.max(0, p.elites?.reserves ?? 0));
const combinedBlock = combinedCardBattleBlock(g);
let blocked: string | null = null;
@@ -13774,8 +13778,12 @@ function reinforcementsPreview(g: Game, p: Player) {
else if (combinedBlock)
blocked = combinedBlock;
else if (normal + elite < 3)
blocked = 'Reinforcements needs three physical forces in your reserves.';
return { blocked, normal, elite };
blocked = materializes
? 'Your No-Field reveals before Reinforcements; three own reserves must remain after materialization.'
: 'Reinforcements needs three physical forces in your reserves.';
return { blocked, normal, elite,
...(p.noField?.deployed?.location.territory === battle.territory
? { noField: { materializes, reservesAfterReveal } } : {}) };
}
function recruitsInteractionPending(g: Game) {
@@ -20705,11 +20713,16 @@ function harassWithdrawContext(g: Game, p: Player): HarassWithdrawContext {
let blocked = harassModuleBlock(g);
if (!blocked && (g.status !== 'playing' || g.phase !== 6 || ![b.attacker, b.defender].includes(p.id)))
blocked = 'Use Harass & Withdraw in your own battle.';
if (!blocked && p.noField?.deployed?.location.territory === b.territory)
blocked = 'Reveal your No-Field before using Harass & Withdraw.';
const locations = Object.fromEntries(Object.entries(p.forces)
.filter(([key, amount]) => amount > 0 && splitLocation(key).territory === b.territory)
.map(([key, amount]) => [key, { normal: amount - (p.elites?.forces[key] ?? 0), elite: p.elites?.forces[key] ?? 0 }]));
const materializes = prospectiveNoFieldForces(p, b.territory);
if (materializes) {
const marker = p.noField!.deployed!;
const key = location(marker.location.territory, marker.location.sector);
if (locations[key]) locations[key].normal += materializes;
else locations[key] = { normal: materializes, elite: 0 };
}
const profile = ecazBattleProfile(g);
const forces = profile?.lead === p.id
? p.id === profile.ecaz ? profile.ecazForces : profile.allyForces
@@ -20744,8 +20757,11 @@ function harassWithdrawPreview(g: Game, p: Player): HarassWithdrawPreview | null
const b = g.battle;
if (!b || ![b.attacker, b.defender].includes(p.id) || !p.hand.some(isHarassWithdraw)) return null;
const context = harassWithdrawContext(g, p);
const materializes = prospectiveNoFieldForces(p, b.territory);
return { ...context, card: HARASS_WITHDRAW_CARD, allowAllocation: b.harassAllocationVersion === 1,
blocked: context.blocked ?? (b.revealed || b.plans[p.id] ? 'Your Battle Plan is already sealed.' : null) };
blocked: context.blocked ?? (b.revealed || b.plans[p.id] ? 'Your Battle Plan is already sealed.' : null),
...(p.noField?.deployed?.location.territory === b.territory
? { noField: { materializes, reservesAfterReveal: p.reserves - materializes } } : {}) };
}
function validateResidualPoison(
g: Game,
+2
View File
@@ -33,6 +33,8 @@ export type HarassWithdrawPreview = HarassWithdrawContext &
card: typeof HARASS_WITHDRAW_CARD;
/** New battle protocol allows the physical return to be chosen after reveal. */
allowAllocation?: boolean;
/** Owner-only forecast; actual marker reveal still precedes card effects. */
noField?: Readonly<{ materializes: number; reservesAfterReveal: number }>;
}>;
export type HarassWithdrawQuote = {
returned: { normal: number; elite: number };
+20 -13
View File
@@ -1034,9 +1034,10 @@ export const RULE_TOPICS: RuleTopic[] = [
'The map shows a concealed marker without its denomination to everyone. Richese privately sees its token values, last-use restriction and reveal control. Movement selection keeps the marker separate from physical forces. No hidden denomination or physical token identity is included in another player’s projected board state.',
'Marker-only battles use the owner’s private reserve-limited force pool. Both plans must be sealed before the token materializes. A zero-token battle still resolves with its leader, cards and traitor decisions. Ordinary Atreides prescience cannot request this opponent’s dial; other eligible plan elements remain available.',
'Basic and Advanced ordinary mixed battles have a provisional private pool: physical forces already here plus the token value capped by current reserves. The Battle Wheel labels that interpretation explicitly. Both plans seal before materialization, then native support, casualties and aftermath use actual forces. This first version does not adjudicate the FAQ mixed dial cap.',
'In supported card profiles, provisional materialization-first order forecasts the own marker privately without moving forces early. Both plans seal, the original token reveals and materializes its reserve-limited ordinary counters at its sector, then Harass selects/returns actual undialed counters. Reinforcements must leave three original reserves after that reveal and before any future Harass return; an unfunded card cannot seal a plan. Marker0, last-used identity and original reveal-event history remain native.',
'Concealed collection, occupancy and control use one-force presence. Exposed storm sectors and unprotected worm destruction reveal and remove the resulting actual forces. Storm shelter and other sectors remain separate. Trip to Gamont at a location with a marker reveals it, even if ordinary forces also occupy that sector, then returns one actual force if present. It is still used if zero forces remain. Canceling the CHOAM power leaves the marker concealed.',
'All four AI profiles choose from their own private token inventory, pay only the one-force shipment price, move the marker explicitly and reveal positive tokens when useful. They retain zero-token presence and use only public information to assess opponents. They also offer safe positive tokens from their own inventory during the allied opportunity, fund them with their own spice and accept or decline private offers using their own force and payment information. A human Richese player’s offer window holds an AI ally’s shipment. Pending responses, decisions and Truthtrance retain priority.',
'Combined Ecaz Occupy and entire-plan special Karama inspection against a No-Field remain guarded. Homeworld custody, broader Truthtrance and interrupted compositions remain unfinished. Mixed-pool and discounted-price prototypes in both modes are not publisher-adjudicated formulas. Richese starts remain disabled.',
'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.',
],
related: [
'richese-cards',
@@ -1050,35 +1051,35 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
'Existing own lifecycle, hazards and battle bounds remain. Native allied Guild rate cancellation bills one marker and preserves the existing provisional bank payee. Exact supplier approval cannot increase; release retains original token/army and a new full-price offer needs fresh consent. Engine, fresh lender controls and AI share authorized recipient pricing rather than inferring another player’s state from a personal view. Wider source combinations remain partial.',
'Native lifecycle, hazards and authorized allied pricing remain. Own concealed Harass now forecasts actual materialization and sector counters without moving them before both seals. Reinforcements checks original post-reveal reserves before allowing a plan, preventing the accepted-plan funding deadlock. Marker history, physical owners, fixed Ecaz commitment and other profile gates remain separate.',
evidence: ['game/shipment-price.ts', 'game/engine.ts'],
},
{
area: 'Player controls',
status: 'Partial',
detail:
'Existing private inventory, movement, reveal and battle controls remain. Actual390px controlled supplier loan cancels half1 without spending or revealing its prior three-marker, shows authoritative full2, makes a new five-token offer, obtains Guild consent and original interception, then spends supplier2/materializes Richese3 plus Guild5 once. No recipient wallet is exposed. Earlier saved-room evidence remains historical, not new assurance.',
evidence: ['components/richese-allied-no-field.tsx', 'components/richese-no-field.tsx'],
'Private inventory, movement/reveal and original loan controls remain. Owner-only Harass and reserve-cost previews explain projected materialization and post-reveal funding. Actual mixed-sector selection occurs after native revelation; invalid partial returns disable confirmation. Four mobile card journeys include zero marker, early blocked cost/legal fallback and funded native Occupy through original CHOAM market.',
evidence: ['components/richese-allied-no-field.tsx', 'components/richese-no-field.tsx', 'components/harass-withdraw.tsx', 'components/game-table.tsx'],
},
{
area: 'AI',
status: 'Partial',
detail:
'Own and allied legal participation remains. Fresh offer calculations consume the same authorized recipient quote as human controls; no inferred rival wallet or exact strategic first-candidate pin. Four controlled low-budget probes choose no optional reoffer here while the recipient executes a legal ordinary shipment. Full Richese strategy/calibration stays deferred.',
'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.',
evidence: ['game/bots.ts'],
},
{
area: 'Documentation',
status: 'Partial',
detail:
'Source distinctions, lifecycle, typed loan consent and current controls remain documented. Guild-recipient bank payee and one-marker price precedence stay provisional. Full2 supplier reapproval and the authoritative fresh-quote bridge are explicit; original versus controlled evidence and remaining sponsored/card combinations are not conflated.',
evidence: ['docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md', 'docs/GUILD_RATE_KARAMA_RULES.md'],
'Source, original loan consent/pricing and current controls remain documented. The provisional materialize-then-original-reserve-cost-then-Harass sequence is explicit, not a new publisher FAQ or user ruling. Token history and remaining mixed/inspection/module boundaries remain separate.',
evidence: ['docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md', 'docs/GUILD_RATE_KARAMA_RULES.md', 'docs/RICHESE_NO_FIELD_RULES.md'],
},
{
area: 'Verification',
status: 'Partial',
detail:
'Current affected25-file263/263 passes across frozen237+26 selections. Seven controlled allied source cases cover zero/five, recipient full price, supplier release/reapproval, poverty and independent price. Actual390px complete supplier reoffer/consent fixes observed fresh-quote drift; API and four controlled continuation probes pass. Earlier own/battle/persisted evidence remains historical. No new saved/deployed or complete-mode acceptance.',
'Earlier allied/native/recovery proof remains historical. New actual-shipment card regressions cover zero/caps, mixed sectors, original funding, typed physical custody, Traitor cancellation and marker history; mobile and four-profile continuations are bounded current evidence. No new saved/deployed or complete-mode assurance is claimed.',
evidence: [
'tests/richese-no-field.test.ts',
'tests/richese-no-field-engine.test.ts',
@@ -1091,6 +1092,10 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/richese-allied-no-field-bots.test.ts',
'tests/richese-allied-no-field-recovery.test.ts',
'tests/allied-marker-guild-rate.test.ts',
'tests/harass-no-field.test.ts',
'tests/fixture-harass-no-field.ts',
'tests/no-field-reinforcements.test.ts',
'tests/fixture-no-field-reinforcements.ts',
],
},
],
@@ -4974,6 +4979,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Have three own forces in reserves when sealing. Classic/paired Ecaz/Moritani Basic/Advanced and explicitly selected native Advanced Occupy prototypes spend ordinary reserves first and then elite reserves, moving exactly three to corresponding Tanks subpools at resolution. These are the actual card holder’s reserves, not the variable ally’s pool; no extra forces arrive.',
'The normal-outcome score gains 2 without adding to the physical dial, spice support or on-board casualties. The card and its reserve cost are consumed even on a Traitor victory, a mutual Traitor result or a Lasgun/Shield explosion; successful Traitor calls still decide the outcome directly.',
'All-outcome payment, normal-first selection and reserve transfer counting as battle losses remain provisional interpretations, not publisher rulings. Same-plan Harass pays the original three-reserve cost before returns; returned reserves cannot fund or change it. In native Advanced Occupy, original or copied Stone ignores +2 normal score but still charges the reserve cost. Basic/unprofiled co-side and other profile/module limits remain guarded.',
'With your own concealed No-Field, the provisional native order materializes its reserve-limited counters before this cost. At least three own reserves must remain after that reveal, before any future Harass return; otherwise Reinforcements is unavailable before the first plan seals. This checks only the actual card user, never its ally’s marker or reserves.',
],
related: ['battle-cards', 'battle', 'card-recruits', 'card-harass-withdraw', 'implementation-checklist'],
checklist: [
@@ -4981,7 +4987,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'Owner-only availability, exact cost guidance and both battle selectors support the card; private category answers remain None.', evidence: ['components/battle-preparation.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four profiles consider legal physical slot candidates and the normal score bonus; strategy and calibration remain unfinished.', evidence: ['game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'The printed effect and unresolved Traitor, explosion, type-selection and accounting interpretations are distinguished from this temporary prototype.', evidence: ['docs/ECAZ_TREACHERY_RULES.md', 'docs/REINFORCEMENTS_RUNTIME.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Original proof plus same-plan Harass and native Stone/Mirror cases cover original typed costs, physical-token victory, fixed/variable Occupy, Traitor/explosion and retention. Actual mobile controls and legal four-profile smoke are bounded evidence; complete modes, calibration and final assurance remain open.', evidence: ['tests/stone-reinforcements.test.ts', 'tests/harass-reinforcements.test.ts', 'tests/reinforcements.test.ts', 'tests/reinforcements-engine.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Original typed cost, Stone/Mirror and same-plan Harass proof now includes actual No-Field first-plan funding rejection, legal fallback, zero/funded costs, Traitor cancellation and actual Occupy owners. Mobile and four-profile continuations are bounded; complete modes/calibration/final assurance remain open.', evidence: ['tests/no-field-reinforcements.test.ts', 'tests/fixture-no-field-reinforcements.ts', 'tests/stone-reinforcements.test.ts', 'tests/harass-reinforcements.test.ts', 'tests/reinforcements.test.ts', 'tests/reinforcements-engine.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
],
},
{
@@ -4999,14 +5005,15 @@ export const RULE_TOPICS: RuleTopic[] = [
'For multiple legal physical allocations, choose all undialed ordinary and elite counters by sector after both plans reveal and before traitor declarations. Unique returns are automatic. The selected return is public; it is not an extra sealed plan element. Stone Burner provisionally compares the undialed forces remaining after successful withdrawal, including same-plan, opposing and copied Stone. Additional optional modules remain guarded. The printed card forbids use on your own Homeworld.',
'In explicitly selected Advanced Occupy, only the actual card user’s undialed forces return. An Ecaz user preserves its original mandatory fixed count at free full strength; an allied user uses its native variable commitment, while canceled Occupy uses its selected lead’s ordinary own pool. Returned reserves remain outside losses and Face Dance. Basic/unprofiled co-side, public starts and full acceptance remain gated.',
'In supported same-plan Reinforcements, the provisional order pays three original own reserves before Harass returns undialed counters. Future returns cannot fund or change that reserve quote; neither the +2 score bonus nor that cost adds to the physical commitment. Both cards discard after use. An opposing successful Traitor cancels Harass but not the provisional Reinforcements cost.',
'With an own concealed No-Field, this prototype provisionally uses the original materialization-first order. Before sealing, privately forecast its actual ordinary counters at the original marker sector; do not move them early. Both plans seal before native revelation, then any return allocation uses actual counters. Marker0 remains legal with a leader/cards, reserve shortage caps materialization, and withdrawal does not return/reset the token. Opposing successful Traitor cancels Harass, not that native reveal.',
],
related: ['battle-cards', 'battle', 'card-recruits', 'ecaz-modules', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Physical slot commitment, category inspection, revealed typed/sector returns, exact committed losses, opponent-Traitor cancellation, mandatory disposal and continuation are connected. Richese/Stone uses provisional post-withdrawal comparison with original fixed Occupy costs; other optional modules remain gated.', evidence: ['game/harass-withdraw.ts', 'game/stone-burner.ts', 'game/battle-card-slots.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Either slot, compatible pair selection, category-null commitment and card inspection are usable. Owners select typed/sector returns after reveal; Stone guidance and revealed counts visibly explain provisional post-withdrawal timing and Traitor cancellation.', evidence: ['components/battle-preparation.tsx', 'components/harass-withdraw.tsx', 'components/revealed-battle.tsx', 'components/game-table.tsx'] },
{ area: 'Implementation', status: 'Partial', detail: 'Physical slots, private category answers, typed/sector return, committed losses, Traitor cancellation and mandatory disposal remain. Own No-Field now projects reserve-limited sector counters before commitment and uses actual native revelation/history. Same-plan cost cannot be funded by future returns. Other module/profile boundaries remain.', evidence: ['game/harass-withdraw.ts', 'game/stone-burner.ts', 'game/battle-card-slots.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Either slot and after-reveal typed/sector choices remain. Owner-only No-Field forecasts and post-reveal reserve notices distinguish virtual marker from actual counters; zero marker, mixed-sector full return, blocked-cost fallback and funded card controls are usable.', evidence: ['components/battle-preparation.tsx', 'components/harass-withdraw.tsx', 'components/revealed-battle.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles have a legal shared-quote path and preserve inspection secrecy. Retreat strategy and combined-game strength remain uncalibrated.', evidence: ['game/bots.ts', 'tests/ecaz-battle-bots.test.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'The guide distinguishes printed effects, explicit inference, provisional post-withdrawal Stone timing and remaining module boundaries.', evidence: ['docs/HARASS_WITHDRAW_RUNTIME.md', 'docs/ECAZ_TREACHERY_RULES.md', 'docs/STONE_BURNER_ENGINE_AUDIT.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused Stone/Harass and Occupy quote/native regressions cover winner reversal, original costs, typed returns, Mirror retention and Traitor precedence. Two actual mobile native-control journeys and legal four-profile smoke are bounded evidence, not full combination or deployed acceptance. Historical recovery proof is separate.', evidence: ['tests/stone-harass-quote.test.ts', 'tests/stone-harass-runtime.test.ts', 'tests/fixture-stone-harass.ts', 'tests/ecaz-occupy-harass-quote.test.ts', 'tests/harass-allocation-engine.test.ts', 'tests/harass-allocation-recovery.test.ts', 'tests/harass-withdraw-choice-controls.test.tsx', 'tests/harass-withdraw.test.ts', 'tests/ecaz-battle-controls.test.tsx', 'tests/ecaz-battle-bots.test.ts', 'tests/ecaz-battle-recovery.test.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Printed effects, provisional materialization-first and Stone/withdrawal timing are distinguished from remaining profile/module limits and final assurance.', evidence: ['docs/HARASS_WITHDRAW_RUNTIME.md', 'docs/ECAZ_TREACHERY_RULES.md', 'docs/STONE_BURNER_ENGINE_AUDIT.md', 'docs/RICHESE_NO_FIELD_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Original Stone/Harass/Occupy proof plus actual No-Field shipment/reveal cases cover zero, caps, mixed sectors, original costs, history and Traitor cancellation. Current mobile/legal-policy proof remains bounded, not full combination/deployed or new recovery certification.', evidence: ['tests/harass-no-field.test.ts', 'tests/fixture-harass-no-field.ts', 'tests/no-field-reinforcements.test.ts', 'tests/stone-harass-quote.test.ts', 'tests/stone-harass-runtime.test.ts', 'tests/fixture-stone-harass.ts', 'tests/ecaz-occupy-harass-quote.test.ts', 'tests/harass-allocation-engine.test.ts', 'tests/harass-allocation-recovery.test.ts', 'tests/harass-withdraw-choice-controls.test.tsx', 'tests/harass-withdraw.test.ts', 'tests/ecaz-battle-controls.test.tsx', 'tests/ecaz-battle-bots.test.ts', 'tests/ecaz-battle-recovery.test.ts'] },
],
},
{
+241
View File
@@ -0,0 +1,241 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeEcazTreacheryGameForAudit, joinGame,
newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import type { NoFieldValue } from '../game/richese-no-field';
import { finishHarassPreparation, takeHarassCard } from './fixture-harass-withdraw';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
export type HarassNoFieldStep = { actor: string; action: Action };
export type HarassNoFieldOptions = {
advanced?: boolean;
marker?: NoFieldValue;
/** Physical own reserves immediately before the actual marker shipment. */
reserves?: number;
ordinaryAtMarker?: number;
ordinaryOtherSector?: number;
slot?: 'weapon' | 'defense';
dial?: number;
support?: number;
traitor?: 'owner' | 'opponent' | 'both';
seatIds?: [string, string, string];
/** Continue the original fresh independent-variant initializer, without redealing. */
initial?: Game;
};
export type HarassNoFieldFixture = {
initial: Game;
afterSetup: Game;
beforeShipment: Game;
shipmentStep: HarassNoFieldStep;
afterShipment: Game;
beforeBattle: Game;
battleStep: HarassNoFieldStep;
/** Open original battle, all native preparation completed, neither plan sealed. */
game: Game;
owner: string;
opponent: string;
observer: string;
territory: string;
location: string;
otherLocation: string;
token: { id: string; value: NoFieldValue };
materializes: number;
planActions: [HarassNoFieldStep, HarassNoFieldStep];
nativeSteps: HarassNoFieldStep[];
staging: string[];
};
export function harassNoFieldPlayer(game: Game, actor: string) {
const player = game.players.find(p => p.id === actor);
assert.ok(player, `Missing original seat ${actor}`);
return player;
}
const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening;
function initialize(game: Game): Game {
// Only the original initializer is deterministic; live token IDs/events remain native.
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return initializeEcazTreacheryGameForAudit(game); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function place(game: Game, actor: string, location: string, amount: number) {
const player = harassNoFieldPlayer(game, actor);
assert.ok(Number.isSafeInteger(amount) && amount >= 0 && amount <= player.reserves);
player.reserves -= amount;
if (amount) player.forces[location] = (player.forces[location] ?? 0) + amount;
}
function giveTraitor(game: Game, actor: string, identity: string, staging: string[]) {
const player = harassNoFieldPlayer(game, actor);
if (player.traitors.includes(identity)) return;
const returned = player.traitors[0];
assert.ok(returned);
const at = game.traitorReserve?.indexOf(identity) ?? -1;
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
assert.ok(at >= 0 || donor, 'Exchange an original physical traitor, never fabricate one');
if (at >= 0) game.traitorReserve!.splice(at, 1, returned);
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
player.traitors[0] = identity;
staging.push(`Conserved original traitor ${identity} exchanged for ${returned} into ${actor}'s hand.`);
}
/** Genuine Basic/Advanced ordinary Richese/classic setup: CHOAM's selected
* thirty-five-card deck plus the three independent cards, with the original
* separate Richese cache. Only labeled physical board/card/bank staging follows
* setup. No status, phase, marker, token, response or sealed plan is fabricated. */
export function createHarassNoFieldFixture(options: HarassNoFieldOptions = {}): HarassNoFieldFixture {
let game: Game;
if (options.initial) {
game = structuredClone(options.initial);
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.players.every(p => p.hand.length === 0));
assert.equal(game.advanced, options.advanced ?? game.advanced);
assert.deepEqual(game.expansions, ['choam']);
assert.deepEqual(game.players.map(p => p.faction).sort(), ['atreides', 'guild', 'richese']);
if (game.status === 'setup') assert.equal(game.ecazTreachery, true);
} else {
const ids = options.seatIds ?? ['richese', 'guild', 'atreides'];
game = createGame('HARASSNOFIELD', newPlayer(ids[0], 'Richese', 'richese'), options.advanced ?? false, ['choam']);
joinGame(game, newPlayer(ids[1], 'Guild', 'guild'));
joinGame(game, newPlayer(ids[2], 'Atreides', 'atreides'));
}
if (game.status === 'lobby') {
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
game = initialize(game);
}
const initial = structuredClone(game);
for (let count = 0; game.status === 'setup' && count < 200; count++) {
let step: HarassNoFieldStep | undefined;
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(p => p.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) { step = { actor: player.id, action }; break; }
}
assert.ok(step, `Original setup stalled at ${game.setupStage}`);
game = applyAction(game, step.actor, step.action);
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game);
const owner = game.players.find(p => p.faction === 'richese')!.id;
const opponent = game.players.find(p => p.faction === 'guild')!.id;
const observer = game.players.find(p => p.faction === 'atreides')!.id;
const territory = 'imperial_basin', sector = 9;
const location = `${territory}:${sector}`, otherLocation = `${territory}:10`;
const staging: string[] = [];
const nativeSteps: HarassNoFieldStep[] = [];
const act = (step: HarassNoFieldStep) => {
nativeSteps.push(structuredClone(step));
game = applyAction(game, step.actor, step.action);
};
const advance = (predicate: (state: Game) => boolean) => {
for (let count = 0; count < 1600; count++) {
if (predicate(game)) return;
const step = nextRicheseStrongholdsNativeStep(game);
assert.ok(step, 'Native continuation cannot invent an unsealed battle plan');
if (game.turn === 1 && step.action.type === 'stormDial') step.action.amount = 0;
act(step);
}
throw Error('Native Harass/No-Field continuation did not reach its boundary');
};
// Clear original starting positions before native phases, so no incidental battle is introduced.
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0);
player.forces = {};
player.spice = 30;
}
staging.push('After actual faction setup: return each original board counter to its own reserves and stage thirty-spice bank balances.');
advance(g => g.phase === 5 && g.active === owner && clean(g));
const mixed = (options.ordinaryAtMarker ?? 0) + (options.ordinaryOtherSector ?? 0);
const reserves = options.reserves ?? 20 - mixed;
assert.ok(Number.isSafeInteger(reserves) && reserves >= 0 && reserves + mixed <= 20);
place(game, owner, location, options.ordinaryAtMarker ?? 0);
place(game, owner, otherLocation, options.ordinaryOtherSector ?? 0);
place(game, owner, 'polar_sink:0', 20 - reserves - mixed);
place(game, opponent, location, 5);
staging.push(`Before actual shipment: conserve ${mixed} ordinary Richese battle counters, ${reserves} Richese reserves, the remainder in Polar Sink, and five opposing ordinary counters at ${location}.`);
const beforeShipment = structuredClone(game);
const token = structuredClone(harassNoFieldPlayer(game, owner).noField!.tokens.find(t => t.value === (options.marker ?? 3))!);
assert.ok(token);
const shipmentStep: HarassNoFieldStep = { actor: owner, action: {
type: 'ship', territory, sector, noField: token.id,
event: harassNoFieldPlayer(game, owner).noFieldEvent,
} };
act(shipmentStep);
advance(clean);
const afterShipment = structuredClone(game);
advance(g => g.phase === 6 && clean(g));
// Return unselected real hands before selecting H, keeping every hand below its printed limit.
for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; }
takeHarassCard(game, owner, 'ecaz-harass-withdraw');
staging.push('Before native battle admission: return every unselected original hand to the remaining deck, then move the one physical Harass & Withdraw into the Richese hand. Original cache custody remains untouched.');
const ownLeader = harassNoFieldPlayer(game, owner).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
const otherLeader = harassNoFieldPlayer(game, opponent).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
assert.ok(ownLeader && otherLeader);
if (options.traitor === 'owner' || options.traitor === 'both') giveTraitor(game, owner, otherLeader.id, staging);
if (options.traitor === 'opponent' || options.traitor === 'both') giveTraitor(game, opponent, ownLeader.id, staging);
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === territory);
assert.ok(choice, 'Even a concealed zero must enter an actual ordinary battle');
const battleStep: HarassNoFieldStep = { actor: choice.chooser, action: {
type: 'chooseBattle', territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker,
} };
act(battleStep);
for (let count = 0; count < 120; count++) {
const step = nextRicheseStrongholdsNativeStep(game);
if (!step) break;
act(step);
}
assert.ok(game.battle && !game.battle.revealed && clean(game));
assert.equal(Object.keys(game.battle.plans).length, 0);
const materializes = Math.min(token.value, reserves);
const dial = options.dial ?? Math.min(2, mixed + materializes);
const support = options.support ?? (game.advanced ? dial : 0);
const slot = options.slot ?? 'defense';
const planActions: [HarassNoFieldStep, HarassNoFieldStep] = [
{ actor: owner, action: { type: 'battlePlan', dial, support, leader: ownLeader.id,
weapon: slot === 'weapon' ? 'ecaz-harass-withdraw' : null,
defense: slot === 'defense' ? 'ecaz-harass-withdraw' : null } },
{ actor: opponent, action: { type: 'battlePlan', dial: 5, support: game.advanced ? 5 : 0, leader: otherLeader.id } },
];
return { initial, afterSetup, beforeShipment, shipmentStep, afterShipment,
beforeBattle, battleStep, game, owner, opponent, observer, territory, location,
otherLocation, token, materializes, planActions, nativeSteps, staging };
}
/** Seal a real plan and consume only the native response/preparation windows it opens. */
export function sealHarassNoFieldPlan(game: Game, step: HarassNoFieldStep): Game {
return finishHarassPreparation(applyAction(game, step.actor, step.action));
}
export function revealHarassNoFieldBattle(fixture: HarassNoFieldFixture): Game {
let game = structuredClone(fixture.game);
for (const step of fixture.planActions) game = sealHarassNoFieldPlan(game, step);
assert.ok(game.battle?.revealed);
return game;
}
/** Resolve original allocation, Traitor declarations, losses and cleanup without inventing plans. */
export function finishHarassNoFieldBattle(
game: Game,
options: { callers?: string[]; returns?: Record<string, { normal: number; elite: number }> } = {},
): Game {
for (let count = 0; count < 160; count++) {
if (!game.battle && clean(game) && !game.pendingTreacheryDiscard) return game;
let step: HarassNoFieldStep | null;
if (!game.response && !game.phaseOpening && game.decision?.kind === 'harassWithdraw') {
assert.ok(options.returns, 'Choose actual after-reveal physical return locations');
step = { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, returns: options.returns } };
} else step = nextRicheseStrongholdsNativeStep(game);
assert.ok(step, 'Native battle continuation requires both real plans');
if (step.action.type === 'traitorCall') step.action.call = options.callers?.includes(step.actor) ?? false;
game = applyAction(game, step.actor, step.action);
}
throw Error('Native Harass/No-Field battle did not finish');
}
+177
View File
@@ -0,0 +1,177 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeEcazOccupyGameForAudit, joinGame, newPlayer,
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 {
ecazOccupyRicheseFixture, openOccupyRichese, finishOccupyRichese,
occupyRicheseSeat, type EcazOccupyRicheseFixture,
} from './fixture-ecaz-occupy-richese';
export const NO_FIELD_REINFORCEMENTS = 'ecaz-reinforcements';
export const NO_FIELD_HARASS = 'ecaz-harass-withdraw';
export type NoFieldReinforcementsOptions = {
initial?: Game;
marker?: NoFieldValue;
richeseReserves?: number;
lead?: 'ecaz' | 'ally';
ecazReserves?: number;
opposingTraitor?: boolean;
omitPreview?: boolean;
};
export type NoFieldReinforcementsFixture = {
source: EcazOccupyRicheseFixture;
initial: Game;
afterSetup: Game;
game: Game;
owner: string;
ecaz: string;
ally: string;
richese: string;
opponent: string;
territory: string;
location: string;
marker: NoFieldValue;
noField: RicheseNoField;
materializes: number;
reservesBefore: Record<string, number>;
fixedEcazDial: number;
variableDial: number;
opposingTraitor: boolean;
plans: { both: Action; reinforcements: Action; harass: Action; plain: Action; opponent: Action };
staging: string[];
};
/** Select the independent physical cards BEFORE the original admitted deal. */
export function createNoFieldReinforcementsSetup(): Game {
const roster = ['ecaz', 'richese', 'guild', 'choam'] as const;
let game = createGame('NOFIELDREINFORCEMENTS', newPlayer(roster[0], roster[0], roster[0]),
true, ['ecaz', 'choam']);
for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
for (let index = roster.length - 1; index >= 0; index--) {
if (viewGame(game, roster[index]).playerPositions[roster[index]] !== index + 1)
game = applyAction(game, roster[index], { type: 'seatPosition', position: index + 1 });
}
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game.ecazTreachery = true;
// Only the original initializer is deterministic. Native setup decisions,
// shipment, marker identities and subsequent phase windows remain real.
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return initializeEcazOccupyGameForAudit(game); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Before either private plan: actual original setup, Nexus alliance, No-Field
* shipment, Occupy lead and native preleader readiness. No marker or window is
* assigned, cleared, removed/recreated or replaced with a synthetic receipt. */
export function noFieldReinforcementsFixture(options: NoFieldReinforcementsOptions = {}): NoFieldReinforcementsFixture {
const marker = options.marker ?? 3;
const source = ecazOccupyRicheseFixture({
initial: options.initial ?? createNoFieldReinforcementsSetup(),
allyFaction: 'richese', opponentFaction: 'guild', marker,
richeseReserves: options.richeseReserves ?? 6, omitPreview: options.omitPreview,
});
assert.equal(source.initial.ecazTreachery, true);
const owner = options.lead === 'ecaz' ? source.ecaz : source.richese;
// Return every unselected real face to the conserved remaining deck first;
// two staged utility cards never leave an over-limit native hand behind.
for (const player of source.game.players) {
source.game.deck.push(...player.hand);
player.hand = [];
}
source.staging.push('After native shipment/admission: return unselected original hands to the remaining deck, then stage the two original utility faces in the actual lead hand.');
stageEcazOccupyCard(source.game, owner, card => card.id === NO_FIELD_REINFORCEMENTS);
stageEcazOccupyCard(source.game, owner, card => card.id === NO_FIELD_HARASS);
if (options.ecazReserves !== undefined) {
const ecaz = occupyRicheseSeat(source.game, source.ecaz);
assert.ok(Number.isSafeInteger(options.ecazReserves) && options.ecazReserves >= 0 && options.ecazReserves <= ecaz.reserves);
const staged = ecaz.reserves - options.ecazReserves;
if (staged) ecaz.forces['polar_sink:0'] = (ecaz.forces['polar_sink:0'] ?? 0) + staged;
ecaz.reserves -= staged;
source.staging.push(`Conserve Ecaz counters: retain ${options.ecazReserves} own reserves and move ${staged} unused own reserves to Polar Sink.`);
}
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);
assert.equal(noField.deployed.tokenId, noField.lastShipped);
assert.deepEqual(noField.tokens, occupyRicheseSeat(source.afterSetup, source.richese).noField!.tokens);
const physicalTraitors = new Set([...(game.traitorReserve ?? []), ...game.players.flatMap(player => player.traitors)]);
const actorLeader = occupyRicheseSeat(game, owner).leaders
.filter(leader => !leader.dead && !leader.usedAt && (!options.opposingTraitor || physicalTraitors.has(leader.id)))
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
const enemyLeader = occupyRicheseSeat(game, source.opponent).leaders
.filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
assert.ok(actorLeader && enemyLeader);
if (options.opposingTraitor) {
holdOccupyCardsTraitor(game, source.opponent, actorLeader.id);
source.staging.push(`Conserved original Traitor Card exchange gives ${source.opponent} the selected real leader ${actorLeader.id}.`);
}
const variableDial = marker === 0 ? 0 : 1;
const plain: Action = { type: 'battlePlan', dial: profile.fixedEcazDial + variableDial,
support: variableDial, leader: actorLeader.id };
return {
source, initial: source.initial, afterSetup: source.afterSetup, game, owner,
ecaz: source.ecaz, ally: source.ally, richese: source.richese, opponent: source.opponent,
territory: source.territory, location: source.location, marker, noField,
materializes: Math.min(marker, occupyRicheseSeat(game, source.richese).reserves),
reservesBefore: Object.fromEntries(game.players.map(player => [player.id, player.reserves])),
fixedEcazDial: profile.fixedEcazDial, variableDial,
opposingTraitor: options.opposingTraitor ?? false,
plans: {
plain,
both: { ...plain, weapon: NO_FIELD_REINFORCEMENTS, defense: NO_FIELD_HARASS },
reinforcements: { ...plain, weapon: NO_FIELD_REINFORCEMENTS },
harass: { ...plain, defense: NO_FIELD_HARASS },
opponent: { type: 'battlePlan', dial: 0, support: 0, leader: enemyLeader.id },
},
staging: source.staging,
};
}
/** Actual second sealed plan invokes the original native marker revelation. */
export function sealNoFieldReinforcements(fixture: NoFieldReinforcementsFixture,
plan: 'both' | 'reinforcements' | 'harass' | 'plain' = 'both', state = fixture.game): Game {
let game = applyAction(state, fixture.owner, fixture.plans[plan]);
game = applyAction(game, fixture.opponent, fixture.plans.opponent);
return game;
}
/** Revealed physical allocation, native Traitor votes, then the shared native
* Occupy aftermath driver. Returns both the after-vote and completed controls. */
export function finishNoFieldReinforcements(fixture: NoFieldReinforcementsFixture, state: Game): {
afterVotes: Game; game: Game;
} {
let game = state;
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) });
}
for (let attempt = 0; attempt < 10 && game.battle; attempt++) {
const battle = viewGame(game, fixture.owner).battle;
assert.ok(battle);
const voter = battle.traitorVoters.find(id => !battle.traitorSubmitted.includes(id));
if (!voter) break;
game = applyAction(game, voter, { type: 'traitorCall',
call: fixture.opposingTraitor && voter === fixture.opponent });
}
const afterVotes = game;
return { afterVotes, game: finishOccupyRichese(game) };
}
+204
View File
@@ -0,0 +1,204 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import {
createHarassNoFieldFixture, finishHarassNoFieldBattle, harassNoFieldPlayer,
revealHarassNoFieldBattle, sealHarassNoFieldPlan, type HarassNoFieldFixture,
} from './fixture-harass-no-field';
function assertCustody(fixture: HarassNoFieldFixture, game: Game) {
const cards = (state: Game) => [
...state.deck, ...state.discard, ...(state.richeseCache ?? []), ...(state.richeseRemoved ?? []),
...state.players.flatMap(p => p.hand),
].map(c => c.id).sort();
assert.deepEqual(cards(game), cards(fixture.afterSetup));
const traitors = (state: Game) => [
...(state.traitorReserve ?? []), ...state.players.flatMap(p => p.traitors),
].sort();
assert.deepEqual(traitors(game), traitors(fixture.afterSetup));
for (const player of game.players) {
const counters = [player.reserves, player.tanks, ...Object.values(player.forces)];
assert.equal(counters.reduce((sum, n) => sum + n, 0), 20);
assert.ok(counters.every(n => Number.isSafeInteger(n) && n >= 0));
}
}
function assertOriginalMarkerHistory(fixture: HarassNoFieldFixture, revealed: Game, done: Game) {
const shipped = harassNoFieldPlayer(fixture.afterShipment, fixture.owner);
const exposed = harassNoFieldPlayer(revealed, fixture.owner);
const finished = harassNoFieldPlayer(done, fixture.owner);
assert.equal(shipped.noField!.deployed!.tokenId, fixture.token.id);
assert.equal(exposed.noField!.deployed, null);
assert.equal(finished.noField!.deployed, null);
assert.deepEqual(exposed.noField!.tokens, shipped.noField!.tokens);
assert.deepEqual(finished.noField!.tokens, shipped.noField!.tokens);
assert.equal(exposed.noField!.lastShipped, fixture.token.id);
assert.equal(finished.noField!.lastShipped, fixture.token.id);
assert.notEqual(exposed.noFieldEvent, shipped.noFieldEvent);
// Declaration, withdrawal, losses and cleanup cannot perform a second native reveal.
assert.equal(finished.noFieldEvent, exposed.noFieldEvent);
for (const actor of [fixture.owner, fixture.opponent, fixture.observer]) {
const before = viewGame(fixture.game, actor).players.find(p => p.id === fixture.owner)!.noField!;
const after = viewGame(done, actor).players.find(p => p.id === fixture.owner)!.noField!;
assert.ok(before.deployed);
assert.equal(before.lastUsed, null);
assert.equal(after.deployed, null);
assert.equal(after.lastUsed, fixture.token.value);
}
assert.equal(done.discard.filter(c => c.id === 'ecaz-harass-withdraw').length, 1);
assert.equal(done.players.flatMap(p => p.hand).filter(c => c.id === 'ecaz-harass-withdraw').length, 0);
assertCustody(fixture, done);
}
for (const advanced of [false, true]) {
for (const slot of ['weapon', 'defense'] as const) {
for (const marker of [3, 5] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} ${marker} No-Field: Harass in ${slot} privately forecasts the native pool and returns only undialed counters`, () => {
const fixture = createHarassNoFieldFixture({ advanced, marker, slot });
const original = harassNoFieldPlayer(fixture.game, fixture.owner);
const offer = viewGame(fixture.game, fixture.owner).battle!.harassWithdraw!;
assert.equal(offer.blocked, null);
assert.equal(offer.forces.normal, marker);
assert.equal(offer.locations[fixture.location].normal, marker);
assert.equal(offer.noField!.materializes, marker);
assert.equal(offer.noField!.reservesAfterReveal, original.reserves - marker);
const sealed = sealHarassNoFieldPlan(fixture.game, fixture.planActions[0]);
assert.equal(sealed.battle!.revealed, false);
assert.equal(sealed.battle!.harassAllocation, undefined);
const hidden = harassNoFieldPlayer(sealed, fixture.owner);
assert.deepEqual(hidden.noField, original.noField);
assert.equal(hidden.noFieldEvent, original.noFieldEvent);
assert.equal(hidden.reserves, original.reserves);
assert.equal(hidden.forces[fixture.location] ?? 0, 0);
assert.equal(viewGame(sealed, fixture.opponent).battle!.plans[fixture.owner], undefined);
const revealed = sealHarassNoFieldPlan(sealed, fixture.planActions[1]);
assert.equal(revealed.battle!.revealed, true);
const exposed = harassNoFieldPlayer(revealed, fixture.owner);
assert.equal(exposed.forces[fixture.location], offer.locations[fixture.location].normal);
assert.equal(exposed.reserves, offer.noField!.reservesAfterReveal);
const done = finishHarassNoFieldBattle(revealed);
const owner = harassNoFieldPlayer(done, fixture.owner);
assert.equal(done.lastBattleContext?.winner, fixture.opponent);
assert.equal(owner.reserves, original.reserves - 2);
assert.equal(owner.tanks, 2);
assert.equal(owner.forces[fixture.location] ?? 0, 0);
// Original paid support is charged once, not refunded with undialed counters.
assert.equal(owner.spice, original.spice - (advanced ? 2 : 0));
assertOriginalMarkerHistory(fixture, revealed, done);
});
}
void test(`${advanced ? 'Advanced' : 'Basic'} zero marker permits leader plus ${slot} Harass and is exposed/disposed only once`, () => {
const fixture = createHarassNoFieldFixture({ advanced, marker: 0, slot });
const original = harassNoFieldPlayer(fixture.game, fixture.owner);
const offer = viewGame(fixture.game, fixture.owner).battle!.harassWithdraw!;
assert.equal(offer.blocked, null);
assert.equal(offer.forces.normal, 0);
assert.equal(offer.noField!.materializes, 0);
const first = sealHarassNoFieldPlan(fixture.game, fixture.planActions[0]);
assert.ok(harassNoFieldPlayer(first, fixture.owner).noField!.deployed);
assert.equal(first.battle!.plans[fixture.owner].leader, fixture.planActions[0].action.leader);
const revealed = sealHarassNoFieldPlan(first, fixture.planActions[1]);
assert.equal(harassNoFieldPlayer(revealed, fixture.owner).reserves, original.reserves);
assert.equal(harassNoFieldPlayer(revealed, fixture.owner).forces[fixture.location] ?? 0, 0);
const done = finishHarassNoFieldBattle(revealed);
const owner = harassNoFieldPlayer(done, fixture.owner);
assert.equal(owner.reserves, 20);
assert.equal(owner.tanks, 0);
assert.equal(owner.leaders.find(l => l.id === fixture.planActions[0].action.leader)!.dead, false);
assertOriginalMarkerHistory(fixture, revealed, done);
});
}
void test(`${advanced ? 'Advanced' : 'Basic'} scarce reserves cap a five marker before Harass, conserving all twenty physical counters`, () => {
const fixture = createHarassNoFieldFixture({ advanced, marker: 5, reserves: 2, dial: 1 });
const offer = viewGame(fixture.game, fixture.owner).battle!.harassWithdraw!;
assert.equal(offer.blocked, null);
assert.equal(offer.forces.normal, 2);
assert.equal(offer.locations[fixture.location].normal, 2);
assert.equal(offer.noField!.materializes, 2);
assert.equal(offer.noField!.reservesAfterReveal, 0);
const revealed = revealHarassNoFieldBattle(fixture);
assert.equal(harassNoFieldPlayer(revealed, fixture.owner).reserves, 0);
assert.equal(harassNoFieldPlayer(revealed, fixture.owner).forces[fixture.location], 2);
assertCustody(fixture, revealed);
const done = finishHarassNoFieldBattle(revealed);
const owner = harassNoFieldPlayer(done, fixture.owner);
assert.equal(owner.reserves, 1);
assert.equal(owner.tanks, 1);
assert.equal(owner.forces['polar_sink:0'], 18);
assertOriginalMarkerHistory(fixture, revealed, done);
});
void test(`${advanced ? 'Advanced' : 'Basic'} mixed ordinary/marker sectors choose actual undialed locations only after reveal`, () => {
const fixture = createHarassNoFieldFixture({ advanced, marker: 3, ordinaryAtMarker: 1, ordinaryOtherSector: 2 });
const original = harassNoFieldPlayer(fixture.game, fixture.owner);
const offer = viewGame(fixture.game, fixture.owner).battle!.harassWithdraw!;
assert.equal(offer.blocked, null);
assert.equal(offer.forces.normal, 6);
assert.equal(offer.locations[fixture.location].normal, 4);
assert.equal(offer.locations[fixture.otherLocation].normal, 2);
// Opponent can seal first too; no marker counters or return choice exist yet.
const first = sealHarassNoFieldPlan(fixture.game, fixture.planActions[1]);
assert.ok(harassNoFieldPlayer(first, fixture.owner).noField!.deployed);
assert.equal(first.battle!.harassAllocation, undefined);
const revealed = sealHarassNoFieldPlan(first, fixture.planActions[0]);
assert.equal(revealed.decision!.kind, 'harassWithdraw');
assert.equal(revealed.decision!.player, fixture.owner);
const physical = harassNoFieldPlayer(revealed, fixture.owner);
assert.equal(physical.forces[fixture.location], 4);
assert.equal(physical.forces[fixture.otherLocation], 2);
assert.equal(physical.reserves, original.reserves - 3);
const returns = {
[fixture.location]: { normal: 2, elite: 0 },
[fixture.otherLocation]: { normal: 2, elite: 0 },
};
// A physically existing pool still cannot return the two dialed counters.
const snapshot = JSON.stringify(revealed);
assert.throws(() => applyAction(revealed, fixture.owner, { type: 'decision', event: revealed.battle!.harassAllocation!.event,
returns: { [fixture.location]: { normal: 4, elite: 0 }, [fixture.otherLocation]: { normal: 2, elite: 0 } } }));
assert.equal(JSON.stringify(revealed), snapshot);
const chosen = applyAction(revealed, fixture.owner, { type: 'decision', event: revealed.battle!.harassAllocation!.event, returns });
assert.equal(chosen.decision, null);
assert.deepEqual(harassNoFieldPlayer(chosen, fixture.owner).forces, physical.forces);
assert.equal(harassNoFieldPlayer(chosen, fixture.owner).reserves, physical.reserves);
assert.deepEqual(viewGame(chosen, fixture.opponent).battle!.harassAllocation!.selection, returns);
const done = finishHarassNoFieldBattle(chosen);
const owner = harassNoFieldPlayer(done, fixture.owner);
assert.equal(owner.reserves, 18);
assert.equal(owner.tanks, 2);
assert.equal(owner.forces[fixture.location] ?? 0, 0);
assert.equal(owner.forces[fixture.otherLocation] ?? 0, 0);
assert.equal(owner.spice, original.spice - (advanced ? 2 : 0));
assertOriginalMarkerHistory(fixture, revealed, done);
});
}
for (const traitor of ['owner', 'opponent', 'both'] as const) {
void test(`${traitor} successful Traitor: native reveal remains original and only an opposing call cancels Harass`, () => {
const fixture = createHarassNoFieldFixture({ advanced: true, marker: 5, traitor,
ordinaryAtMarker: 1, ordinaryOtherSector: 1 });
const revealed = revealHarassNoFieldBattle(fixture);
const original = harassNoFieldPlayer(fixture.game, fixture.owner);
const exposed = harassNoFieldPlayer(revealed, fixture.owner);
assert.equal(exposed.reserves, original.reserves - 5);
assert.equal(exposed.forces[fixture.location], 6);
assert.equal(exposed.forces[fixture.otherLocation], 1);
const callers = traitor === 'both' ? [fixture.owner, fixture.opponent]
: [traitor === 'owner' ? fixture.owner : fixture.opponent];
const done = finishHarassNoFieldBattle(revealed, { callers, returns: {
[fixture.location]: { normal: 4, elite: 0 },
[fixture.otherLocation]: { normal: 1, elite: 0 },
} });
const owner = harassNoFieldPlayer(done, fixture.owner);
if (traitor === 'owner') {
assert.equal(done.lastBattleContext?.winner, fixture.owner);
assert.equal(owner.reserves, exposed.reserves + 5);
assert.equal(owner.tanks, 0);
assert.equal(owner.forces[fixture.location], 2);
assert.equal(owner.forces[fixture.otherLocation] ?? 0, 0);
} else {
assert.equal(owner.reserves, exposed.reserves);
assert.equal(owner.tanks, 7);
assert.equal(owner.forces[fixture.location] ?? 0, 0);
assert.equal(owner.forces[fixture.otherLocation] ?? 0, 0);
}
assertOriginalMarkerHistory(fixture, revealed, done);
});
}
+208
View File
@@ -0,0 +1,208 @@
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 { stageEcazOccupyCard } from './fixture-ecaz-occupy';
import { createHarassNoFieldFixture, sealHarassNoFieldPlan } from './fixture-harass-no-field';
import {
noFieldReinforcementsFixture, sealNoFieldReinforcements, finishNoFieldReinforcements,
NO_FIELD_REINFORCEMENTS, NO_FIELD_HARASS, type NoFieldReinforcementsFixture,
} from './fixture-no-field-reinforcements';
function assertConserved(fixture: NoFieldReinforcementsFixture, 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 deck/cache face keeps physical 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) {
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20,
`${player.faction} retains its own original twenty counters.`);
assert.ok([player.reserves, player.tanks, ...Object.values(player.forces)]
.every(n => Number.isSafeInteger(n) && n >= 0));
}
}
function assertMarkerRevealed(fixture: NoFieldReinforcementsFixture, game: Game): void {
const richese = occupyRicheseSeat(game, fixture.richese);
assert.equal(richese.noField!.deployed, null);
assert.equal(richese.noField!.lastShipped, fixture.noField.lastShipped,
'Native revelation preserves the actual most-recent-shipment identity.');
assert.deepEqual(richese.noField!.tokens, fixture.noField.tokens);
assert.equal(richese.reserves, fixture.reservesBefore[fixture.richese] - fixture.materializes,
'Second seal materializes the physical marker before either utility card spends or returns counters.');
assert.equal(richese.forces[fixture.location] ?? 0, fixture.materializes);
assert.equal(richese.tanks, occupyRicheseSeat(fixture.game, fixture.richese).tanks);
}
function assertUtilitiesDiscarded(game: Game): void {
for (const card of [NO_FIELD_REINFORCEMENTS, NO_FIELD_HARASS]) {
assert.equal(game.discard.filter(face => face.id === card).length, 1);
assert.ok(game.players.every(player => !player.hand.some(face => face.id === card)));
}
}
for (const [reserves, marker] of [[3, 3], [7, 5]] as const)
void test(`No-Field ${marker} with reserves ${reserves} rejects the FIRST Reinforcements seal, but the same owner can seal Harass alone`, () => {
const fixture = noFieldReinforcementsFixture({ marker, richeseReserves: reserves });
const game = fixture.game;
const saved = structuredClone(game);
const preview = viewGame(game, fixture.owner).battle!.reinforcements!;
assert.ok(preview.blocked, 'Three current reserves do not imply three reserves after native materialization.');
assert.equal(preview.normal + preview.elite, reserves - marker);
assert.equal(preview.noField!.materializes, marker);
assert.equal(preview.noField!.reservesAfterReveal, reserves - marker);
assert.equal(viewGame(game, fixture.owner).battle!.harassWithdraw!.blocked, null);
for (const plan of [fixture.plans.reinforcements, fixture.plans.both]) {
assert.throws(() => applyAction(game, fixture.owner, plan), RuleError);
assert.deepEqual(game, saved, 'Rejection cannot debit resources, reveal the marker, reserve a card or hide a sealed plan.');
assert.equal(game.battle!.plans[fixture.owner], undefined);
assert.deepEqual(viewGame(game, fixture.opponent).battle!.plans, {});
}
const privatePlan = applyAction(game, fixture.owner, fixture.plans.harass);
assert.ok(privatePlan.battle!.plans[fixture.owner]);
assert.equal(privatePlan.battle!.plans[fixture.owner].weapon, null);
assert.deepEqual(occupyRicheseSeat(privatePlan, fixture.richese).noField, fixture.noField);
assert.equal(occupyRicheseSeat(privatePlan, fixture.richese).reserves, reserves);
const revealed = applyAction(privatePlan, fixture.opponent, fixture.plans.opponent);
assertMarkerRevealed(fixture, revealed);
const { game: done } = finishNoFieldReinforcements(fixture, revealed);
assert.equal(done.battle, null);
assert.equal(done.lastBattleContext!.winner, fixture.owner);
assert.equal(occupyRicheseSeat(done, fixture.richese).reserves, reserves - fixture.variableDial);
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(game, fixture.richese).tanks,
fixture.variableDial, 'Only the original one-counter variable commitment is lost.');
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(game, fixture.ecaz).tanks, 3);
assert.equal(occupyRicheseSeat(done, fixture.ecaz).forces[fixture.location], 2);
assert.equal(done.discard.filter(card => card.id === NO_FIELD_HARASS).length, 1);
assert.equal(done.discard.filter(card => card.id === NO_FIELD_REINFORCEMENTS).length, 0);
assert.ok(occupyRicheseSeat(done, fixture.owner).hand.some(card => card.id === NO_FIELD_REINFORCEMENTS));
assertConserved(fixture, done);
});
for (const [reserves, marker] of [[6, 3], [8, 5], [3, 0]] as const)
for (const slot of ['weapon', 'defense'] as const)
void test(`Funded No-Field ${marker}/reserves ${reserves}, Reinforcements in ${slot}: reveal, original cost, own undialed return, original losses`, () => {
const fixture = noFieldReinforcementsFixture({ marker, richeseReserves: reserves });
const before = structuredClone(fixture.game);
const preview = viewGame(before, fixture.owner).battle!.reinforcements!;
assert.equal(preview.blocked, null);
assert.equal(preview.normal, 3);
assert.equal(preview.elite, 0);
assert.equal(preview.noField!.materializes, marker);
assert.equal(preview.noField!.reservesAfterReveal, 3);
const plan = { ...fixture.plans.both,
weapon: slot === 'weapon' ? NO_FIELD_REINFORCEMENTS : NO_FIELD_HARASS,
defense: slot === 'defense' ? NO_FIELD_REINFORCEMENTS : NO_FIELD_HARASS };
const first = applyAction(before, fixture.owner, plan);
assert.equal(first.battle!.revealed, false);
assert.deepEqual(occupyRicheseSeat(first, fixture.richese).noField, fixture.noField);
assert.equal(occupyRicheseSeat(first, fixture.richese).reserves, reserves);
assert.equal(occupyRicheseSeat(first, fixture.richese).forces[fixture.location] ?? 0, 0);
assert.equal(occupyRicheseSeat(first, fixture.richese).tanks, occupyRicheseSeat(before, fixture.richese).tanks);
const revealed = applyAction(first, fixture.opponent, fixture.plans.opponent);
assert.equal(revealed.battle!.revealed, true);
assertMarkerRevealed(fixture, revealed);
const { afterVotes, game: done } = finishNoFieldReinforcements(fixture, revealed);
assert.equal(occupyRicheseSeat(afterVotes, fixture.richese).reserves, reserves - 3 - fixture.variableDial,
'The future undialed return neither funds nor changes the three-counter original reserve cost.');
assert.equal(done.lastBattleContext!.result, 'normal');
assert.equal(done.lastBattleContext!.winner, fixture.owner);
assert.equal(occupyRicheseSeat(done, fixture.richese).reserves, reserves - 3 - fixture.variableDial);
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(before, fixture.richese).tanks,
3 + fixture.variableDial);
assert.equal(occupyRicheseSeat(done, fixture.richese).forces[fixture.location] ?? 0, 0);
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, 3,
'Harass never recomputes the original mandatory ceil(5/2) Ecaz commitment.');
assert.equal(occupyRicheseSeat(done, fixture.ecaz).forces[fixture.location], 2);
assert.equal(occupyRicheseSeat(done, fixture.ecaz).reserves, fixture.reservesBefore[fixture.ecaz]);
assert.equal(occupyRicheseSeat(done, fixture.owner).spice, occupyRicheseSeat(before, fixture.owner).spice - fixture.variableDial,
'Only original variable support is paid; the withdrawn lead receives no Carthag income.');
assert.equal(occupyRicheseSeat(done, fixture.richese).noField!.lastShipped, fixture.noField.lastShipped);
assertUtilitiesDiscarded(done);
assertConserved(fixture, done);
assert.deepEqual(fixture.game, before, 'The before-commitment runtime control stays immutable.');
});
for (const [reserves, marker] of [[6, 3], [8, 5]] as const)
void test(`Opposing real Traitor with No-Field ${marker} cancels only Harass, never revelation or Reinforcements cost`, () => {
const fixture = noFieldReinforcementsFixture({ marker, richeseReserves: reserves, opposingTraitor: true });
const revealed = sealNoFieldReinforcements(fixture);
assertMarkerRevealed(fixture, revealed);
const { game: done } = finishNoFieldReinforcements(fixture, revealed);
assert.equal(done.lastBattleContext!.result, 'traitor');
assert.equal(done.lastBattleContext!.winner, fixture.opponent);
assert.equal(occupyRicheseSeat(done, fixture.richese).reserves, reserves - marker - 3,
'No concealed-marker counter returns after an opposing successful Traitor.');
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(fixture.game, fixture.richese).tanks, marker + 3);
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(fixture.game, fixture.ecaz).tanks, 5);
assert.equal(occupyRicheseSeat(done, fixture.richese).forces[fixture.location] ?? 0, 0);
assert.equal(occupyRicheseSeat(done, fixture.richese).noField!.deployed, null);
assert.equal(occupyRicheseSeat(done, fixture.richese).noField!.lastShipped, fixture.noField.lastShipped);
assertUtilitiesDiscarded(done);
assertConserved(fixture, done);
});
for (const [reserves, marker] of [[3, 3], [7, 5]] as const)
void test(`Ecaz-held same-plan utilities with Richese No-Field ${marker} use only Ecaz original reserves and fixed-undialed counters`, () => {
const fixture = noFieldReinforcementsFixture({ marker, richeseReserves: reserves, lead: 'ecaz' });
const preview = viewGame(fixture.game, fixture.owner).battle!.reinforcements!;
assert.equal(preview.blocked, null, 'The ally marker cannot consume the Ecaz card user reserve pool.');
assert.equal(preview.normal, 3);
assert.equal(preview.noField, undefined, 'An allied marker is not an own-marker funding forecast.');
const revealed = sealNoFieldReinforcements(fixture);
assertMarkerRevealed(fixture, revealed);
assert.equal(occupyRicheseSeat(revealed, fixture.ecaz).reserves, fixture.reservesBefore[fixture.ecaz]);
const { game: done } = finishNoFieldReinforcements(fixture, revealed);
assert.equal(done.lastBattleContext!.winner, fixture.ecaz);
assert.equal(occupyRicheseSeat(done, fixture.ecaz).reserves, fixture.reservesBefore[fixture.ecaz] - 3 + 2);
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(fixture.game, fixture.ecaz).tanks, 6);
assert.equal(occupyRicheseSeat(done, fixture.ecaz).forces[fixture.location] ?? 0, 0);
assert.equal(occupyRicheseSeat(done, fixture.richese).reserves, reserves - marker,
'The Richese ally neither funds Ecaz Reinforcements nor receives the Ecaz Harass return.');
assert.equal(occupyRicheseSeat(done, fixture.richese).forces[fixture.location], marker - 1);
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(fixture.game, fixture.richese).tanks, 1);
assertUtilitiesDiscarded(done);
assertConserved(fixture, done);
});
void test('An Ecaz card user cannot fund Reinforcements from its Richese ally after revelation or either future Harass return', () => {
const fixture = noFieldReinforcementsFixture({ marker: 5, richeseReserves: 8, lead: 'ecaz', ecazReserves: 2 });
const before = structuredClone(fixture.game);
assert.ok(viewGame(before, fixture.owner).battle!.reinforcements!.blocked);
assert.equal(viewGame(before, fixture.owner).battle!.reinforcements!.normal, 2);
assert.throws(() => applyAction(before, fixture.owner, fixture.plans.both), RuleError);
assert.deepEqual(before, fixture.game);
assertConserved(fixture, before);
});
void test('Original Advanced native inventory without the selected Occupy profile still cannot use Reinforcements', () => {
const fixture = noFieldReinforcementsFixture({ marker: 3, richeseReserves: 6, omitPreview: true });
const before = structuredClone(fixture.game);
assert.ok(viewGame(before, fixture.owner).battle!.reinforcements!.blocked);
assert.throws(() => applyAction(before, fixture.owner, fixture.plans.reinforcements), RuleError);
assert.deepEqual(before, fixture.game);
assertConserved(fixture, before);
});
void test('Ordinary Basic Richese Harass with a real concealed marker does not widen Reinforcements composition support', () => {
const fixture = createHarassNoFieldFixture({ advanced: false, marker: 3, reserves: 6 });
stageEcazOccupyCard(fixture.game, fixture.owner, card => card.id === NO_FIELD_REINFORCEMENTS);
const before = structuredClone(fixture.game);
const preview = viewGame(before, fixture.owner).battle!;
assert.equal(before.advanced, false);
assert.equal(preview.harassWithdraw!.blocked, null);
assert.ok(preview.reinforcements!.blocked);
assert.throws(() => applyAction(before, fixture.owner, {
...fixture.planActions[0].action, weapon: NO_FIELD_REINFORCEMENTS, defense: null,
}), RuleError);
assert.deepEqual(before, fixture.game);
const legal = sealHarassNoFieldPlan(before, fixture.planActions[0]);
assert.ok(legal.battle!.plans[fixture.owner]);
assert.equal(legal.battle!.revealed, false);
assert.equal(occupyRicheseSeat(legal, fixture.owner).reserves, 6);
assert.deepEqual(occupyRicheseSeat(legal, fixture.owner).noField,
occupyRicheseSeat(before, fixture.owner).noField);
});