Integrate Ecaz Duke revival and bounded Mirror Weapon battles
This commit is contained in:
1 parent
68d782ca82
commit
3b118dd845
39 files changed
+1377
-533
No files matched your search
@@ -5,6 +5,7 @@ import {
|
||||
battleCardLabel,
|
||||
isWeaponCard,
|
||||
isStoneBurner,
|
||||
isMirrorWeapon,
|
||||
isDefenseCard,
|
||||
VOICE_KINDS,
|
||||
} from '@/game/battle-cards';
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
type BattleCardSlot,
|
||||
} from '@/game/battle-card-slots';
|
||||
import { canUsePlanetologistBattleSpecial } from '@/game/leader-skill-combat';
|
||||
import { mirrorWeaponModeBlock } from '@/game/mirror-weapon-mode';
|
||||
|
||||
/** Both selectors evaluate the complete physical pair, in either selection order. */
|
||||
export function battlePlanCardOptions(game: GameView, slot: BattleCardSlot, leader: string, weapon: string, defense: string) {
|
||||
@@ -34,6 +36,7 @@ export function battlePlanCardOptions(game: GameView, slot: BattleCardSlot, lead
|
||||
});
|
||||
return (card.id !== 'ecaz-reinforcements' || (!!game.battle?.reinforcements && !game.battle.reinforcements.blocked)) &&
|
||||
(card.id !== 'ecaz-harass-withdraw' || (!!game.battle?.harassWithdraw && !game.battle.harassWithdraw.blocked)) &&
|
||||
(!isMirrorWeapon(card) || !mirrorWeaponModeBlock(game)) &&
|
||||
battleCardSlotEligible(slot, card, { planetologistWeapon }) &&
|
||||
(!fixed || fixedBattleInspectionMatches(slot, fixed.value, card.id, card)) &&
|
||||
validBattleSlotPair(w, d, planetologistWeapon);
|
||||
@@ -154,7 +157,7 @@ export function BattlePreparation({
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value)}
|
||||
>
|
||||
{VOICE_KINDS.map((k) => (
|
||||
{VOICE_KINDS.filter((name) => name !== 'mirrorWeapon' || !mirrorWeaponModeBlock(game)).map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{battleCardLabel(k)}
|
||||
</option>
|
||||
|
||||
+29
-18
@@ -141,9 +141,11 @@ import { CHEAP_HERO_TRAITOR, matchingTraitor } from '@/game/traitors';
|
||||
import {
|
||||
battleCardLabel,
|
||||
isStoneBurner,
|
||||
isMirrorWeapon,
|
||||
} from '@/game/battle-cards';
|
||||
import { validBattleSlotPair } from '@/game/battle-card-slots';
|
||||
import { stoneBurnerPlanBlock } from '@/game/stone-burner';
|
||||
import { mirrorWeaponModeBlock } from '@/game/mirror-weapon-mode';
|
||||
import { TECH_TOKENS, ownedTech } from '@/game/tech-tokens';
|
||||
import {
|
||||
canUseAsKaramaRole,
|
||||
@@ -518,15 +520,14 @@ export function GameTable({
|
||||
selectedWeaponId === 'ecaz-harass-withdraw' || selectedDefenseId === 'ecaz-harass-withdraw',
|
||||
committed.dial ? Number(committed.dial.value) : battleDial,
|
||||
me.faction === 'fremen' && !g.battle?.fremenSupportBlocked ? 0 : battleSupport);
|
||||
const selectedStone = me.hand?.find(
|
||||
(c) =>
|
||||
c.id === selectedWeaponId &&
|
||||
isStoneBurner(c),
|
||||
const selectedSpecialWeapon = me.hand?.find(
|
||||
(c) => c.id === selectedWeaponId && (isStoneBurner(c) || isMirrorWeapon(c)),
|
||||
);
|
||||
const mirrorSelected = isMirrorWeapon(selectedSpecialWeapon);
|
||||
const stoneContext = g.battle?.stoneBurnerContext;
|
||||
const stonePlanReason = !selectedStone
|
||||
const stonePlanReason = !selectedSpecialWeapon
|
||||
? null
|
||||
: (stoneContext?.blocked ??
|
||||
: ((mirrorSelected ? mirrorWeaponModeBlock(g) : null) ?? stoneContext?.blocked ??
|
||||
(!ownBattleForces || !stoneContext?.opponentPools.length
|
||||
? 'Stone Burner needs a supported physical force pool.'
|
||||
: (stoneContext.opponentPools
|
||||
@@ -2296,7 +2297,7 @@ export function GameTable({
|
||||
: g.decision.kind === 'ecazSpice'
|
||||
? 'Shared spice collection'
|
||||
: g.decision.kind === 'stoneBurner'
|
||||
? 'Stone Burner'
|
||||
? g.decision.copiedFrom ? 'Mirror Weapon copying Stone Burner' : 'Stone Burner'
|
||||
: g.decision.kind === 'nullentropy'
|
||||
? 'Private Nullentropy Box search'
|
||||
: g.decision.kind === 'richeseAllyOpportunity'
|
||||
@@ -2695,7 +2696,7 @@ export function GameTable({
|
||||
/>
|
||||
) : g.decision.kind === 'stoneBurner' ? (
|
||||
<>
|
||||
<h3>Choose Stone Burner’s effect</h3>
|
||||
<h3>Choose {g.decision.copiedFrom ? 'Mirror Weapon’s copied Stone Burner' : 'Stone Burner'} effect</h3>
|
||||
<p className="muted">
|
||||
Choose whether both leaders die or surviving leaders
|
||||
contribute no strength. Both choices compare undialed
|
||||
@@ -2703,6 +2704,7 @@ export function GameTable({
|
||||
comparison. Other weapon effects and traitor precedence
|
||||
still apply.
|
||||
</p>
|
||||
{g.decision.copiedFrom && <p className="fine">Your physical Mirror Weapon copied the opposing Stone Burner. Your choice is independent of its owner’s; winning retains Mirror as Mirror.</p>}
|
||||
<a href="/rules?topic=stone-burner">
|
||||
Read Stone Burner rules
|
||||
</a>
|
||||
@@ -2729,6 +2731,7 @@ export function GameTable({
|
||||
</>
|
||||
) : g.decision.kind === 'poisonTooth' ? (
|
||||
<>
|
||||
{g.decision.copiedFrom && <p className="fine">Your physical Mirror Weapon copied the opposing Poison Tooth. Choose its activation independently; a winning Mirror remains Mirror.</p>}
|
||||
<p className="muted">
|
||||
Choose after reviewing both plans. Activating attacks both
|
||||
leaders, including yours. Chemistry as a defense protects;
|
||||
@@ -2736,10 +2739,12 @@ export function GameTable({
|
||||
</p>
|
||||
{actionButton('Activate Poison Tooth', {
|
||||
type: 'decision',
|
||||
event: g.decision.event,
|
||||
activate: true,
|
||||
})}
|
||||
{actionButton('Leave it unused', {
|
||||
type: 'decision',
|
||||
event: g.decision.event,
|
||||
activate: false,
|
||||
})}
|
||||
</>
|
||||
@@ -3953,6 +3958,7 @@ export function GameTable({
|
||||
{g.revival.leaders.map((choice) => (
|
||||
<option value={choice.id} key={choice.id}>
|
||||
{choice.name} · {choice.cost} spice
|
||||
{choice.id === 'duke-vidal' ? ' · strength 6, set aside after revival' : ''}
|
||||
{choice.early ? ' · early revival' : ''}
|
||||
{!choice.affordable
|
||||
? ' · insufficient spice'
|
||||
@@ -3972,13 +3978,18 @@ export function GameTable({
|
||||
</>
|
||||
);
|
||||
})()
|
||||
: me.leaders.some(
|
||||
(l) => l.dead && l.faction === me.faction,
|
||||
) && (
|
||||
: (me.leaders.some((l) => l.dead && l.faction === me.faction) ||
|
||||
(me.faction === 'ecaz' && g.dukeVidal?.leader.dead)) && (
|
||||
<p className="fine">
|
||||
{me.leaderRevived && me.faction !== 'tleilaxu'
|
||||
? 'You have used your one leader revival for this phase.'
|
||||
: 'No native leader is currently eligible for normal revival.'}
|
||||
: g.revival.prevented
|
||||
? 'Tleilaxu prevented your normal revivals this turn.'
|
||||
: g.revival.dukeBlocked && me.faction === 'ecaz' && g.dukeVidal?.leader.dead
|
||||
? g.revival.dukeBlocked
|
||||
: g.revival.cycleBlock
|
||||
? g.revival.cycleBlock
|
||||
: 'No leader is currently eligible for normal revival.'}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
@@ -4849,14 +4860,14 @@ export function GameTable({
|
||||
Discard it after this battle, even if you win.
|
||||
</p>
|
||||
)}
|
||||
{selectedStone && (
|
||||
{selectedSpecialWeapon && (
|
||||
<div className="my-3 flex flex-col gap-3">
|
||||
<p>
|
||||
Stone Burner compares undialed physical
|
||||
tokens, not dialed strength. Its leader effect
|
||||
is chosen after both plans are revealed.
|
||||
{mirrorSelected
|
||||
? 'Mirror Weapon copies the opposing revealed weapon attack, not its physical card. If the copied attack is Stone Burner, it uses undialed physical tokens; any copied post-reveal choice is yours. A winning Mirror remains physical Mirror, even if it copies activated Poison Tooth or Artillery.'
|
||||
: 'Stone Burner compares undialed physical tokens, not dialed strength. Its leader effect is chosen after both plans are revealed.'}
|
||||
</p>
|
||||
<CardInspector card={selectedStone} />
|
||||
<CardInspector card={selectedSpecialWeapon} />
|
||||
{stonePlanReason && (
|
||||
<p className="notice" role="status">
|
||||
{stonePlanReason}
|
||||
@@ -5290,7 +5301,7 @@ export function GameTable({
|
||||
<option value="">Forces</option>
|
||||
{g.ghola.leaders.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
{l.name}{l.id === 'duke-vidal' ? ' · set aside after revival' : ''}
|
||||
</option>
|
||||
))}
|
||||
{g.ghola.kwisatz && (
|
||||
|
||||
+21
-1
@@ -63,6 +63,26 @@ tables, where the battle path cannot select Duke. Captured, dead and Ghola
|
||||
destinations and all combined modules remain gated. The Battle-only timing
|
||||
is a documented product boundary rather than printed phase text. This code
|
||||
is not yet in the live image.
|
||||
|
||||
The bounded [Ecaz-only Duke revival](ECAZ_REVIVAL_RULES.md#selected-product-interpretation-28-september-2026)
|
||||
returns the existing dead shared disc alive and set aside after ordinary paid
|
||||
revival or a Ghola card. The user selected this custody interpretation; it is
|
||||
not a publisher clarification. Five spice (three with the existing Tleilaxu
|
||||
alliance discount), stop/cancellation windows, first five-in-Tanks cohort,
|
||||
owner choices, legal AI and JSON/SQLite continuation are connected. Further
|
||||
six-disc native cycles, exceptional custody and Advanced Harkonnen remain
|
||||
guarded; no complete Ecaz start is certified.
|
||||
|
||||
The bounded [Mirror Weapon battle path](MIRROR_WEAPON_ENGINE_AUDIT.md#user-selected-cleanup-interpretation-28-september-2026)
|
||||
admits the canonical physical card in CHOAM/Richese-deck Basic/Advanced
|
||||
classic/CHOAM/Richese battles without combined optional modules. Effective
|
||||
copied attacks, copy-first Tooth/Stone choices, public Stone feasibility,
|
||||
saved custody, human controls and legal AI are connected. A victorious
|
||||
physical Mirror can be kept even after a copied activated Tooth or Artillery
|
||||
attack under the user's interpretation, not a GF9 clarification. Combined
|
||||
modes and full Richese starts remain gated. Neither new path is in the
|
||||
verified live `c9a3625` image.
|
||||
|
||||
The main page
|
||||
labels its build's Git commit in the lobby and active-table masthead; local
|
||||
Git HEAD does not represent uncommitted work. The main-branch container
|
||||
@@ -225,7 +245,7 @@ question was sent; ordinary HMS relocation and force entry/exit remain working.
|
||||
No extra collection is silently applied. The Bureaucrat/Emperor gift question
|
||||
and the other recorded skill boundaries remain pending.
|
||||
|
||||
The reviewed remaining native Ecaz/Moritani effects, Duke revival and Richese
|
||||
Remaining native Ecaz/Moritani effects, repeated six-disc Duke cycles and Richese
|
||||
empty-cache auction arithmetic still cross recorded source questions. Native
|
||||
Tleilaxu revival now creates its skill offer at successful commitment, including
|
||||
automatic response chains; the former dependency is resolved in its Basic profile.
|
||||
|
||||
@@ -64,3 +64,15 @@ The integrated896-unit suite,45 persisted/API tests, typecheck, lint and build p
|
||||
## Direct Ecaz acquisition follow-up
|
||||
|
||||
The direct self-acquisition feature was subsequently implemented and verified. See [Ecaz Duke acquisition](ECAZ_DUKE_ACQUISITION.md) for the integrated controls, AI, persistence and actual battle playtest. Alliance/loan alternatives, revival and exceptional capture remain separate unfinished work. Earlier readiness and integration statements above are historical.
|
||||
|
||||
## Ecaz revival follow-up (28 September 2026)
|
||||
|
||||
Ecaz alone can revive the canonical dead shared disc through ordinary paid
|
||||
revival or a real Ghola card. The user selected the living **set-aside** result
|
||||
after either return: no automatic Ecaz controller, source or acquisition turn.
|
||||
This is a product interpretation, **not** a published Duke-specific ruling.
|
||||
The printed five-spice normal price, existing allied discount/stop/payment
|
||||
accounting, ordinary slot and one physical death history remain separate from
|
||||
the Ghola card's free return. Native first-cohort five-in-Tanks eligibility is
|
||||
supported; repeated six-disc cycles and exceptional custody stay guarded.
|
||||
See [Ecaz revival](ECAZ_REVIVAL_RULES.md#selected-product-interpretation-28-september-2026).
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
Read-only review, 7 September 2026. Recommend a shared-disc revival contract before adding a selectable Duke to ordinary revival or Ghola. The printed permission and price are clear; post-revival active custody and subsequent six-disc cycles remain material interpretation boundaries already recorded in [ECAZ_REVIVAL_RULES.md](ECAZ_REVIVAL_RULES.md). This review does not silently resolve them, enable full Ecaz starts, or certify all expansion rules.
|
||||
|
||||
**Runtime follow-up, 28 September 2026:** The user selected the set-aside
|
||||
interpretation for a revived living Duke. Ecaz ordinary paid revival and Ghola
|
||||
now return the same physical disc alive and unclaimed, preserving death
|
||||
history; this is an explicit product decision, not an official clarification.
|
||||
The first five-disc ordinary cohort (including Duke) opens and stays recorded
|
||||
after a return. Repeated six-disc native cycles, exotic capture/Ghola custody
|
||||
and Advanced Harkonnen remain guarded. The gap table and in-memory probe below
|
||||
are historical pre-implementation observations, not present runtime status.
|
||||
|
||||
## Verified rule boundary
|
||||
|
||||
Fresh official indexed retrieval confirms Ecaz can revive Duke at a normal price of five spice without a dead-leader threshold. Five Ecaz leaders in the Tanks, counting Duke, permit ordinary revival even with a survivor. Only Ecaz may revive Duke, including through Ghola. His battle strength remains six and he has no Traitor card. These are Basic Ecaz rules as well as Advanced rules. [GF9 E3, printed pp.8–9](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=8)
|
||||
|
||||
@@ -2,6 +2,28 @@
|
||||
|
||||
Bounded primary-source review, 2026-09-06. This document supplements `DUKE_VIDAL_RULES.md` and separates the ordinary arithmetic and eligibility rules from unresolved shared-disc custody and cycle questions. No runtime edits were made.
|
||||
|
||||
## Selected product interpretation (28 September 2026)
|
||||
|
||||
The user chose **set-aside custody**: a successful ordinary Ecaz revival or Ecaz
|
||||
Ghola returns the same dead Duke disc alive to the shared area, with
|
||||
`controller`, `source` and `acquiredTurn` cleared. It is not automatically
|
||||
playable by Ecaz. This is an explicit product interpretation, **not** a
|
||||
publisher ruling; the Ambassador or another supported living-disc acquisition
|
||||
must subsequently claim him. Ordinary revival costs five spice (three with
|
||||
the existing Tleilaxu alliance discount), spends Ecaz's ordinary leader slot,
|
||||
and follows the normal stop, discount-cancellation and payment/income windows.
|
||||
Ghola spends the actual card instead of spice or that slot. Strength remains
|
||||
six; both paths preserve the physical death history.
|
||||
|
||||
Ecaz's first ordinary cohort opens with five dead discs counting Duke, or
|
||||
when its entire native five-disc roster is unavailable under the existing
|
||||
captured-leader exception. The opened first cohort persists across returns.
|
||||
Further native returns once any of the six histories enters a repeated cycle
|
||||
remain explicitly gated; Duke's independent Ecaz-only permission remains
|
||||
available, without making a repeated six-disc algorithm up. Captured, Ghola
|
||||
or concealed shared-disc custody and Advanced Harkonnen combinations retain
|
||||
their existing uniform boundary.
|
||||
|
||||
## Explicit expansion rules
|
||||
|
||||
Ecaz may revive Duke for **5 spice regardless of how many leaders are dead**. Its ordinary leaders become revivable with **five leaders in the Tanks, including Duke**. The clarification expressly permits this while Ecaz still holds one living leader, regardless of which leader that is. Only Ecaz may revive Duke, including through the Ghola Treachery Card. [GF9 Ecaz & Moritani, printed pp. 8–9](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf)
|
||||
@@ -14,7 +36,9 @@ The same p. 9 clarification restricts Ecaz’s acquisition of Duke to its Ambass
|
||||
|
||||
The ordinary base rule makes a revived leader playable. Applying that ordinary result to the special Ecaz-only revival strongly supports active Ecaz custody, treating the Ambassador sentence as acquisition of a living shared disc. The competing literal reading revives Duke to the shared set-aside area so that Ambassador-only acquisition remains universal. The retrieved expansion does not expressly choose between those readings. The Ghola FAQ permits a revived leader to fight in the same Battle phase, but does not specifically resolve Duke’s custody. [GF9 base rules, printed pp. 9, 22](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf)
|
||||
|
||||
**Implementation boundary:** do not certify either controller assignment as an explicit Duke FAQ result. Obtain a supported interpretation before activating revival, especially Ghola during Battle. Persisting `dead=false` while silently choosing `controller=null` or Ecaz changes whether the paid card/action is useful immediately. If Ecaz control is selected, it must be explicit shared custody rather than inserting Duke into the native five-leader array.
|
||||
Earlier implementation guidance below records the pre-decision boundary. The
|
||||
selected set-aside interpretation and limited first-cohort runtime above now
|
||||
supersede that boundary; it remains a source caveat, not a live blocker.
|
||||
|
||||
## Frequency, prices and Tleilaxu
|
||||
|
||||
@@ -42,4 +66,4 @@ The E3 Ecaz table lists Ambassador, Occupy, Loyalty and Collection; it contains
|
||||
|
||||
## Verification boundary
|
||||
|
||||
Checked official indexed GF9 p. 8/9 passages, the base revival text, Ixian/Tleilaxu alliance and early-revival text, and the November 2020 FAQ p. 6 against existing publisher-authored local extracts. Targeted official GF9 and designer-site searches returned no written Duke-after-revival or six-disc repeated-cycle example. This records a retrieval limit, not proof that no clarification exists. Existing direct publisher download restrictions and crawler dates were not treated as evidence of a new edition. Runtime integration, custody decisions and regression tests remain with the parent task.
|
||||
Checked official indexed GF9 p. 8/9 passages, the base revival text, Ixian/Tleilaxu alliance and early-revival text, and the November 2020 FAQ p. 6 against existing publisher-authored local extracts. Targeted official GF9 and designer-site searches returned no written Duke-after-revival or six-disc repeated-cycle example. This records a retrieval limit, not proof that no clarification exists. Existing direct publisher download restrictions and crawler dates were not treated as evidence of a new edition. The selected set-aside product interpretation and bounded runtime above do not resolve the repeated six-disc, exceptional custody or complete-expansion boundaries.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Physical cards and effective battle weapons
|
||||
|
||||
7 September 2026. This is a shared combat foundation for Mirror Weapon, not activation of its complete gameplay.
|
||||
The 7 September foundation now feeds a bounded, playable Mirror Weapon battle path (28 September 2026). The historical focused test counts below predate that integration.
|
||||
|
||||
## Contract and integration
|
||||
|
||||
@@ -10,24 +10,24 @@ Tooth and Stone copies each receive their own choice, with the copy before its s
|
||||
|
||||
`game/battle-cards.ts` consumes the descriptors for ordinary attacks, self-attacks, Artillery suppression/no-bounty behavior and a shared Lasgun explosion predicate. The authoritative engine and AI's fully known battle calculations use that predicate. Single-card knowledge remains single-card knowledge; no new hidden-hand access was added to AI evaluation. Actual Shield and Shield Snooper trigger explosions; defensive Weirding Way and Portable Snooper do not.
|
||||
|
||||
Physical plan IDs, Atreides inspection, Truthtrance commitments, custody, winning card retention and Moritani retention are unchanged. The engine still rejects Mirror as a Battle Plan weapon. Copy arithmetic must not be confused with completed controls, persisted copied choices, Stone outcome integration or cleanup.
|
||||
Physical plan IDs, Atreides inspection, Truthtrance commitments, custody, winning card retention and Moritani retention remain physical. The engine admits canonical Mirror only for CHOAM/Richese-deck classic/CHOAM/Richese rosters without combined optional modules; it rejects unsupported setups before commitment. It validates the original and copied Stone casualty possibilities using public force pools and checks reserved physical Mirror custody across saved zones.
|
||||
|
||||
## Evidence and boundaries
|
||||
|
||||
The current review in [MIRROR_WEAPON_ENGINE_AUDIT.md](MIRROR_WEAPON_ENGINE_AUDIT.md) remains the source contract. A fresh bounded publisher search again found the November FAQ's general winner-retention rule and named Tooth/Artillery exceptions, but no explicit ruling about the physical Mirror copying those exceptions. The earlier user clarification remains unanswered. The CHOAM package itself supplies Tooth and Artillery; excluding only Ix would not isolate this uncertainty. No source absence or elapsed silence was treated as an adjudication.
|
||||
The [Mirror audit](MIRROR_WEAPON_ENGINE_AUDIT.md#user-selected-cleanup-interpretation-28-september-2026) records the user's explicit choice: a victorious physical Mirror may be retained even after copying activated Tooth or Artillery. The November FAQ names the original Tooth/Artillery cards, but no publisher passage was found assigning their disposal to Mirror. This is a product interpretation, not a GF9 ruling. The revealed decision queue binds copied Tooth/Stone choices to the physical Mirror, copied source and battle event, presents the copy before its original, and saves each owner's independent answer across reload.
|
||||
|
||||
Portable Snooper and Stone Burner now have runtime foundations that were absent when the original audit was written. Their current event, defense, casualty-feasibility and public configuration restrictions remain in force. In particular, introducing a copy must not bypass the original Stone player's precommit feasibility guard or choose a hidden-plan-dependent acceptance boundary.
|
||||
|
||||
Remaining work includes canonical Mirror plan admission and named Voice role; physical prescience and promise checks; event-bound copy/source choice ownership and ordering; Stone's effective outcome; settled cleanup rules; controls, AI choice handling and persisted full battles. General expansion/module and Advanced acceptance remain incomplete.
|
||||
Combined expansion modules, leader skills, alternate card-role changes, complete Richese starts and full-mode release acceptance remain gated. The bounded path has human selectors, copy/retention guidance and legal AI plans/choices, without reading sealed opposing cards.
|
||||
|
||||
## Verification
|
||||
|
||||
`tests/effective-weapons.test.ts` contains ten independent helper tests: both orientations, all actual ordinary/Ix attack roles, canonical Stone, no-copy roles, original-versus-copied Chemistry, independent choice order, malformed/duplicate Mirror and frozen inputs. `tests/effective-battle-effects.test.ts` contains six integration tests using an explicit printed defense matrix, all Tooth activation pairs, Artillery defenses, Lasgun shield combinations, no-attack/Stone boundaries and continued blocked Mirror admission.
|
||||
`tests/effective-weapons.test.ts` exercises the pure physical/effective role matrix; `tests/effective-battle-effects.test.ts`, `tests/battle-resolution-quote.test.ts` and `tests/mirror-weapon-engine.test.ts` cover copied defenses, Carthag's effective poison, Stone outcomes, copy-first post-reveal ownership, JSON continuation, stale events, physical disposal and four-profile Basic/Advanced AI admission. Historical foundation counts below remain archived rather than substituted for end-to-end evidence.
|
||||
|
||||
The focused 53-test combat run passes, including existing authoritative Ix, Tooth/Artillery, Portable and Stone cases: `/tmp/dune-effective-weapons-target.log`. Type checking, lint and production build pass: `/tmp/dune-effective-weapons-type.log`, `/tmp/dune-effective-weapons-lint.log`, `/tmp/dune-effective-weapons-build.log`. No Mirror full-game or card-completion claim follows from these arithmetic tests.
|
||||
The earlier focused 53-test combat run passed before engine admission: `/tmp/dune-effective-weapons-target.log`. Its type, lint and build records (`/tmp/dune-effective-weapons-{type,lint,build}.log`) establish only the earlier foundation. Current integration checks belong to the newer checkpoint in `IMPLEMENTATION_STATUS.md`.
|
||||
|
||||
Full registered suites pass: **1,500 rules/client/component tests and 129 persistence/API tests**, 1,629 total. Logs: `/tmp/dune-effective-weapons-full.log` (67.15 seconds) and `/tmp/dune-effective-weapons-multiplayer.log` (18.64 seconds). The historical 648-game calibration was not rerun.
|
||||
|
||||
An independent differential script, `/tmp/dune-effective-comparison.ts`, compares the previous evaluator against the saved implementation using 57 canonical cards and 494 legal same-side pairs. All 865,580 effect comparisons, 216,395 explosion comparisons and 3,364 single-weapon comparisons agree. This is regression evidence for existing legal behavior, not independent proof of complete printed rules. Root reviewed the new helper, tests and integration; the independent review also checked that AI receives no new hidden-hand inputs.
|
||||
|
||||
Malformed Mirror-marked saved cards fail closed with a plain error from the pure evaluator; that can surface as a generic API failure rather than a recoverable rules message. Valid physical inventory does not produce this path. Complete corrupted battle recovery, including event/source validation for existing Poison Tooth answers, remains follow-up work and is not certified by this foundation.
|
||||
Malformed Mirror-marked saved cards fail closed in the pure evaluator; authoritative saved-battle integrity now also validates canonical physical Mirror custody and copied-choice source/event. The full historical-save compatibility audit remains final polish, not a claim made by this bounded runtime.
|
||||
@@ -1,5 +1,42 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 28 September 2026 — Ecaz Duke revival and bounded Mirror Weapon
|
||||
|
||||
The user chose two explicit product interpretations after bounded publisher
|
||||
source review: a successfully revived Duke returns alive to the shared area,
|
||||
unclaimed until later acquisition; a victorious physical Mirror may be kept
|
||||
even when its copied attack activates Poison Tooth or Artillery. Neither is
|
||||
presented as an official GF9 clarification. Ecaz's separate five-spice
|
||||
ordinary Duke revival and Ghola now use the same physical disc, with first
|
||||
five-in-Tanks cohort eligibility, frozen discount/payment and Tleilaxu
|
||||
stop/cancellation continuation, owner controls, minimal legal AI and JSON/
|
||||
isolated SQLite recovery. Repeated six-disc native cycles, exceptional
|
||||
shared-disc custody and Advanced Harkonnen remain guarded.
|
||||
|
||||
Canonical Mirror now occupies the actual weapon slot in bounded Basic/Advanced
|
||||
CHOAM/Richese-deck classic/CHOAM/Richese battles. Revealed opposing attacks,
|
||||
copy-first independently saved Tooth/Stone choices, Stone's public casualty
|
||||
feasibility, physical-card retention/custody, Carthag's effective-poison
|
||||
defense, named Voice, human controls and four-profile legal AI are connected.
|
||||
Combined modules, changing card roles, complete Richese starts and release
|
||||
acceptance remain gated. Read-only local desktop browser fixtures showed the
|
||||
selectable Duke at five spice with strength six/set-aside guidance and the
|
||||
Mirror weapon selector, copied-attack explanation and updated hand guide;
|
||||
no real room was written. The verified live image remains `c9a3625`.
|
||||
|
||||
Independent read-only reviews found and prompted fixes for concealed foreign-
|
||||
ghola death-count privacy, Mirror Voice compulsion deadlock, reordered saved
|
||||
copy decisions and incorrect Stone scoring of ordinary Mirror copies. The
|
||||
reviewed focused run passed **66/66**; the frozen full offline suite passed
|
||||
**5,643/5,643** across 714 files, live development-server HTTP integration
|
||||
passed **52/52**, scoped oxlint and the app build passed. Project TypeScript
|
||||
still reports only the unrelated in-progress Fremen Nexus draft's missing
|
||||
`Player.fremenNexusMovementBlockedTurn`; the required full `npm run check`
|
||||
cannot be claimed green until that draft is resolved. Browser proof also
|
||||
showed the actual copy-first revealed Tooth decision and physical Mirror/
|
||||
opponent Tooth card faces. Production deployment remains blocked by the
|
||||
OS-locked host Apps session, not by a local reset or game migration.
|
||||
|
||||
## 27 September 2026 — Bounded Emperor Nexus bank-auction purchase
|
||||
|
||||
[The separate Secret Ally purchase alternative](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md#bank-auction-purchase-alternative)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Mirror Weapon: source and engine integration audit
|
||||
|
||||
Audited 6 September 2026. This is a proposed implementation contract, not activation or verification of Mirror Weapon gameplay. No runtime file was changed by this audit. Richese and combined expansion starts remain gated.
|
||||
Audited 6 September 2026 as a proposed contract. The 28 September bounded runtime now admits canonical Mirror in CHOAM/Richese-deck classic/CHOAM/Richese battles without optional combined modules; the hook map and former blockers below describe the earlier audit, not current inactive status. Richese expansion starts remain gated.
|
||||
|
||||
**7 September follow-up:** Portable Snooper and Stone Burner now have runtime foundations, superseding the historical absence noted below. A shared physical/effective weapon resolver and ordinary attack/explosion integration are recorded in [EFFECTIVE_BATTLE_WEAPONS.md](EFFECTIVE_BATTLE_WEAPONS.md). Mirror plan admission remains disabled; copied choices, full outcome and cleanup integration are unfinished. Fresh bounded publisher retrieval did not settle the pending copied-disposal question.
|
||||
**28 September ruling and runtime:** the user selected ordinary winner retention for the physical Mirror even when its copy activates Tooth or Artillery. This is an explicit product interpretation, not a located GF9 clarification. `game/effective-weapons.ts` keeps physical identity apart from copied attacks; the engine now connects copied Tooth/Stone choices, Stone comparison, custody, battle controls and legal AI in the bounded configuration.
|
||||
|
||||
## Primary evidence and a retention correction
|
||||
|
||||
@@ -12,7 +12,7 @@ Mirror Weapon is one physical **Weapon / Special**, canonical ID `richese-mirror
|
||||
|
||||
The [CHOAM & Richese rulebook](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf), pp4–6, establishes replacement Tooth/Artillery cards, one separate ten-card cache, and normal Treachery discard routing. Local publisher-text mirror: `/tmp/dune-rules/choam-lelekan-mirror.pdf` and `.txt`. Direct GF9 retrieval returned 403; fresh official indexed excerpts corroborated p4. Bounded official-domain Mirror searches yielded no card-specific clarification.
|
||||
|
||||
**Do not infer compulsory winner disposal from that footer alone.** The [November 2020 FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf), p8, gives winning battle cards a general keep option even without printed retention wording. Page1 expressly makes used Tooth and Artillery exceptions, including when their player wins; an unactivated winning Tooth can be kept. The 2021 Mirror face adds no explicit winner exception. Recommended ordinary composition is that a winning Mirror can be retained; copying a disposable weapon raises a separate inheritance question below. This corrects the earlier inventory's overly definite mandatory-discard interpretation without claiming a new Mirror erratum. Losing and mutual-loss cleanup still discard played cards normally. A traitor-decided battle preempts weapon effects under the [base rules, pp10–11](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf).
|
||||
**Winner disposal interpretation.** The [November 2020 FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf), p8, gives winning battle cards a general keep option even without printed retention wording. Page1 names used Tooth and Artillery as exceptions, including when their player wins; an unactivated winning Tooth can be kept. The printed Mirror face says “Discard after use” without a winner exception. The user chose to treat the copied attack as an effect, not a change of the physical Mirror’s identity: a victorious Mirror may be retained even after copying activated Tooth or Artillery. This is a product interpretation, not a GF9 erratum. Losing and mutual-loss cleanup still discard the physical Mirror.
|
||||
|
||||
## Weapon matrix
|
||||
|
||||
@@ -27,9 +27,9 @@ Here “copy” means an effective attack belonging to the Mirror player, evalua
|
||||
| Weirding Way in the weapon slot | Copy its projectile weapon role. |
|
||||
| Chemistry in the weapon slot | Copy its poison weapon role. The opponent's pair must already be legal. Do not retroactively require the Mirror holder to possess the other defense that made the original Chemistry legal: Mirror is the committed weapon, and copies a card which already counts as a weapon. |
|
||||
| Weirding Way or Chemistry in the defense slot | Do not copy that defense. Inspect the opponent's actual weapon slot instead, which may contain another valid weapon or nothing. |
|
||||
| Poison Tooth | Copy the optional post-reveal attack, not the original owner's eventual answer. The Mirror owner chooses activation first, then the original owner chooses. Persist two separate choices. Reuse the established self-and-opponent attack evaluation; do not apply deaths between these declarations or count a leader death twice. Copy-related physical-card retention remains unresolved below. |
|
||||
| Artillery Strike | Copy the attack on both leaders, suppression of surviving leader strength, and no-bounty battle effect. Apply battle-wide suppression once, not twice. No extra mode choice is created. Copy-related physical-card retention remains unresolved below. |
|
||||
| Stone Burner | The copied effect has its own post-reveal choice, made before the original user's choice. Both effects replace ordinary leader/dial comparison with the same undialed-token comparison. A kill choice cannot be undone by the later ignore-strength choice. Underlying Stone Burner outcome/casualty implementation is not present, so do not silently reduce this to ordinary weapon kills. |
|
||||
| Poison Tooth | Copy the optional post-reveal attack, not the original owner's eventual answer. The Mirror owner chooses activation first, then the original owner chooses. Persist two separate choices. Reuse the established self-and-opponent attack evaluation; do not apply deaths between these declarations or count a leader death twice. An activated copy does not make the physical Mirror a Tooth for winner disposal. |
|
||||
| Artillery Strike | Copy the attack on both leaders, suppression of surviving leader strength, and no-bounty battle effect. Apply battle-wide suppression once, not twice. No extra mode choice is created. The physical Mirror retains the ordinary winner option. |
|
||||
| Stone Burner | The copied effect needs its own post-reveal choice before the original user's choice. Both effects use the existing undialed physical-token comparison. Either kill choice kills both leaders; one ignore choice cannot reverse that. Share the existing Stone casualty-feasibility and mode logic rather than substituting ordinary weapon kills. |
|
||||
| No weapon, or Worthless in place of a weapon | No effective attack. Mirror remains its physical committed Weapon / Special; an occupied slot alone is insufficient evidence of an opposing weapon. |
|
||||
| Reinforcements or Harass & Withdraw in place of a weapon | No effective attack from those cards. The [Ecaz/Moritani rulebook p11](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf) explicitly says they do not count as a weapon or defense. Their other effects are independent. |
|
||||
| A second Mirror | Not a reachable legal physical inventory: the verified collection has exactly one. Reject duplicated physical custody. A future variant adding another copy needs a cycle rule; do not invent an infinite recursion or pretend two copies become an ordinary attack. |
|
||||
@@ -52,8 +52,8 @@ The existing FAQ-backed defense distinctions must survive copying: Chemistry def
|
||||
| `game/battle-cards.ts`: kind arrays, pair/Voice helpers | Introduce strict identity-aware weapon admission and declared-role matching. Existing `WEAPON_KINDS.includes(card.kind)` cannot recognize a canonical Mirror transported as `special`. Avoid changing every Special's role. |
|
||||
| `engine.ts`: `validatePlan`, `findReachableBattlePlan`, `feasiblePrescience` | Use the same legal weapon admission. Preserve physical IDs and promised slots in all plan searches; a selector-only change would leave authoritative plans impossible. |
|
||||
| `nextRevealedDecision` | Currently detects physical `poisonTooth` and iterates storm order. It misses a copy entirely and orders it incorrectly. Replace or extend with a persisted effective-choice queue. |
|
||||
| `resolveBattle`: explosion and `battleCardEffects` | Explosion currently checks physical `kind === 'lasgun'`. Both explosion and normal effects need the same effective descriptor, including own-side attacks and global suppression. |
|
||||
| `canRetainBattleCard` and Moritani cleanup | Keep a shared physical cleanup policy. Do not turn the Mirror into a disposable physical Tooth by mutating its kind. Resolve the copy-disposal question before enabling affected pairings. |
|
||||
| `resolveBattle`: explosion and `battleCardEffects` | The existing effective descriptor already feeds explosion and ordinary attacks. Extend Stone comparison, copied mode ownership and full outcome through that same descriptor; never substitute a physical copied card. |
|
||||
| `canRetainBattleCard` and Moritani cleanup | Keep a shared physical cleanup policy. Do not turn the Mirror into a disposable physical Tooth: the user selected ordinary winner retention even for an activated Tooth or Artillery copy. |
|
||||
| `game/bots.ts`: plan candidates, `weaponKills`, `battleCardEffects`, Tooth decision | Use legal physical candidates and effective attacks only from legitimately known plans. A hidden opposing hand is not an AI input. Handle ordered post-reveal copy decisions with the same effect evaluator. |
|
||||
| Battle plan, revealed components and Rules presentation | Name and inspect the original Mirror. An additional public description can name the copied revealed weapon after both plans reveal. No extra acknowledgement is needed for a deterministic copy. |
|
||||
|
||||
@@ -65,23 +65,22 @@ Portable Snooper is concurrently being integrated by the parent. Consume its fin
|
||||
but cannot alone open a genuine setup. CHOAM's Treachery deck supplies Poison
|
||||
Tooth and Artillery even without Ix, while the full Richese cache supplies Stone
|
||||
Burner. A public configuration gate therefore cannot isolate ordinary copies.
|
||||
Rejecting according to a concealed opposing hand/plan would leak information;
|
||||
rejecting after commitments could strand the battle. Do not remove physical
|
||||
cards or manufacture a restricted deck to claim complete-game support. Mirror
|
||||
remains gated pending the existing copied-disposal ruling and copied-choice work.
|
||||
This qualifies the earlier ordinary-slice recommendation below; it does not
|
||||
invalidate the existing focused arithmetic evidence or answer the user question.
|
||||
Do not remove physical cards or manufacture a restricted deck to claim
|
||||
complete-game support. The user's copied-disposal interpretation removes that
|
||||
specific blocker. The bounded engine now admits Mirror with physical-card
|
||||
custody, public Stone feasibility and copied-choice event checks; it does not
|
||||
certify combined expansion modes or complete Richese starts.
|
||||
|
||||
- **Copied compulsory disposal:** the FAQ names physical Tooth/Artillery, while Mirror copies a weapon. The retrieved primary sources do not explicitly say whether their disposal exception also applies to the physical Mirror. Separate combat effects from cleanup and obtain a ruling before certifying those pairings. This does not justify blocking ordinary projectile/poison/Lasgun/Poison Blade/selected-role copies.
|
||||
- **Stone Burner foundation:** the full effect is presently unsupported. Basic undialed counts follow physical forces minus dialed losses; ties use ordinary aggressor priority unless overridden. Advanced mixed/support-dependent dials can permit different physical casualty allocations, which now affect who wins; the present engine chooses those only for an already determined winner. Stone's original choice and allocation timing must be implemented/audited before its copied form. This is an underlying special-outcome problem, not uncertainty over whether Mirror copies a valid weapon.
|
||||
- **Copied compulsory disposal:** the physical Mirror follows ordinary winner retention even if it copies activated Tooth or Artillery, by the user's selected product interpretation. Keep physical identity separate from the effective attack and do not present this as a publisher ruling.
|
||||
- **Stone Burner composition:** the original Stone effect has a runtime foundation with a public precommit casualty-feasibility guard and one saved choice. A copied Stone needs the same feasibility for the Mirror holder without inspecting the opposing sealed plan, plus separate copy-first choice ownership. This is an outcome-integration problem, not uncertainty over whether Mirror copies a valid weapon.
|
||||
- **Higher modules:** inactive leader skills may modify attacks, defenses or card roles. No combined leader-skill certification follows from the matrix. Two physical Mirrors are outside the verified inventory rather than a normal supported interaction.
|
||||
|
||||
## Required verification before claiming the slice
|
||||
|
||||
Tests should cover every matrix row in both attacker/defender positions; paired defenses; no-attack copies; Mirror plus defensive Weirding Way; named Voice and physical prescience/Truth commitments; Lasgun explosion including advisors/spice and traitor preemption; copied Tooth choice order independent of storm order; once-only global Artillery suppression; exact physical retention/discard policy after its ruling; Moritani eligible-card filtering; JSON restoration between reveal and choices; stale event rejection; and identical unauthorized projections with different opposing hands. Run all four AI profiles through a complete supported battle, including a Mirror with no useful copy, without an acknowledgement or deadlock.
|
||||
|
||||
Five facets remain partial: source audit and component text are present; authoritative behavior, player controls, AI and integrated verification have not been activated by this document. Existing tests of ordinary battle cards are useful regression evidence, not proof of Mirror behavior.
|
||||
The earlier five-facet audit is superseded only for the bounded CHOAM/Richese-deck battle path. Combined expansion modules, leader-skill role changes, every historical save and full Richese-game acceptance remain outside this checkpoint.
|
||||
|
||||
## Pending user clarification (2026-09-06)
|
||||
## User-selected cleanup interpretation (28 September 2026)
|
||||
|
||||
The coordinating agent asked whether a Mirror copying an activated Poison Tooth or Artillery must inherit that card’s mandatory discard on victory, or whether the physical Mirror uses ordinary winner retention. No answer has been received at this checkpoint. The question does not concern Portable Snooper, whose ordinary winner retention follows the general FAQ. Do not infer an answer from the first preselected UI option or elapsed time.
|
||||
After a bounded source search, no GF9 passage was found that expressly applies Tooth/Artillery's named winner-discard exception to the physical Mirror. The user selected retention of a victorious Mirror regardless of those copied attacks. The original Tooth/Artillery cards retain their own mandatory-discard rules. The bounded runtime implements this physical-card distinction; it is not a publisher clarification or complete combined-mode acceptance.
|
||||
@@ -42,6 +42,8 @@ Updated 14 September 2026. This is the authoritative navigation index for existi
|
||||
| Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
|
||||
| Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) |
|
||||
| Homeworld effects | Keep original payment/revival/placement identities and qualification observations; observations alone do not choose an occupied-benefit owner. | [Payments](HOMEWORLD_PAYMENT_INCOME_RULES.md), [card economy](HOMEWORLD_CARD_ECONOMY_RULES.md), [Collection](HOMEWORLD_COLLECTION_RULES.md), [revival deployment](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md), [victory reinforcement](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md), [replacement](HOMEWORLD_REPLACEMENT_RULES.md), [Tupile](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md) |
|
||||
| Duke Vidal revival custody | The user selected set-aside custody after Ecaz revives the shared disc: revival clears the Tanks but does not itself grant battle control. Ecaz still needs a separate Ambassador or Nexus acquisition; only Ecaz may revive Duke, including by Ghola. This resolves one post-revival product interpretation, **not** the separate six-disc repeated-cycle, exceptional capture or battle-used Ghola questions. No publisher clarification assigning a post-revival controller was located. | [Publisher Ecaz/Moritani pp. 7–9](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf#page=9), [revival readiness](ECAZ_DUKE_REVIVAL_READINESS.md) |
|
||||
| Mirror Weapon copied-card cleanup | The user selected ordinary winner retention for the physical Mirror even if its effective copy activates Tooth or Artillery. The original Tooth/Artillery cards retain their named mandatory-discard exceptions. This is a product interpretation, **not** a located publisher ruling. Independent copy-first Tooth/Stone choices and physical cleanup now have a bounded CHOAM/Richese-deck battle runtime; combined modules and full Richese games remain gated. | [Printed Mirror face and source audit](MIRROR_WEAPON_ENGINE_AUDIT.md#user-selected-cleanup-interpretation-28-september-2026) |
|
||||
|
||||
Shipment-only AND/OR now follows the FAQ's whole-question truth: Yes to AND
|
||||
requires all conditions, Yes to OR requires one, and No negates the whole
|
||||
@@ -69,7 +71,6 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
|
||||
| Sandmaster / native HMS relocation | No official source located establishes that passengers remaining inside the HMS enter outside territories for this skill. Extra collection eligibility is pending a user ruling; native sector collection and ordinary force entry/exit remain available. | [Source comparison and question](SANDMASTER_MOVEMENT.md#21-september-native-hms-relocation-eligibility) |
|
||||
| Ambassador/Terror arrival priority | Both optionally react to the same entry, but no publisher/designer priority was found. The order can change Fremen relocation versus Atomics losses. A user question has been sent: keep the overlap gated pending an official ruling, or use explicitly provisional storm order. No answer is assumed; Baliset remains a separate pre-entry prevention effect. | [Reproduction and source boundary](DEFERRED_MOVEMENT_ARRIVAL.md) |
|
||||
| Sapho remaining timing | First versus unspent Advanced Guild priority, partly completed combined turns, cyclic auction duration and later battle timing stay guarded. The existing pre-plan opportunity supports [early aggressor](SAPHO_AGGRESSOR.md). Prior questions remain unanswered. | [Source audit](JUICE_OF_SAPHO_SOURCE_UPDATE.md), [battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) |
|
||||
| Mirror Weapon | Whether copied Tooth/Artillery mandatory-discard rules attach to the physical Mirror remains unanswered. Every genuine Richese setup includes those cards and Stone Burner; an ordinary-copy-only admission gate cannot depend on concealed custody or alter the printed deck. | [Engine audit](MIRROR_WEAPON_ENGINE_AUDIT.md) |
|
||||
| Semuta Drug | Full-hand take-before-discard and the privacy-neutral immediate reaction protocol remain unresolved. Typed discard continuations already exist; they are not activation. | [Source update](SEMUTA_SOURCE_UPDATE_20260907.md), [privacy review](SEMUTA_PRIVACY_REVIEW.md) |
|
||||
| Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody, Smuggler modifier/allocation boundaries, Mentat empty-hand fallback, modified Diplomat defenses and simultaneous optional timing remain source gaps. After an online publisher/designer/tournament check found no explicit priority ruling, the bounded standalone Diplomat retreat prototype explicitly chooses printed/copied disc strength and a surviving-loser retreat before ordinary casualties; other combinations remain guarded. This is an implementation interpretation, not an official clarification. | [Material boundaries and selected retreat interpretation](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation), [Diplomat defense boundary](DIPLOMAT_DEFENSE.md#evidence-and-remaining-scope) |
|
||||
| Smuggler collection strength | A fresh primary-source check found no modifier ruling. The user was asked whether to keep affected combinations gated, use printed/copied disc value, or include leader bonuses with Stunner zero. No answer is assumed. The reveal-bound deferred receipt is supported as an explicit timing inference; unmodified lower collection is now [prototyped](SMUGGLER_BATTLE.md); modified strength, multiple positive piles and combined modules remain gated. | [Smuggler follow-up](LEADER_SKILLS_RULES.md#20-september-smuggler-follow-up) |
|
||||
|
||||
+10
-2
@@ -3,6 +3,7 @@ import { richeseCardDefinition } from './richese-cards';
|
||||
import {
|
||||
resolveBattleWeapons,
|
||||
type EffectiveWeapon,
|
||||
type BattleWeaponResolution,
|
||||
} from './effective-weapons';
|
||||
|
||||
/** Keep persisted physical card shapes stable while validating their printed role. */
|
||||
@@ -17,8 +18,11 @@ export function isDefenseCard(card: Card): boolean {
|
||||
export function isStoneBurner(card: Card | undefined): boolean {
|
||||
return !!card && richeseCardDefinition(card)?.card.effect === 'stoneBurner';
|
||||
}
|
||||
export function isMirrorWeapon(card: Card | undefined): boolean {
|
||||
return !!card && richeseCardDefinition(card)?.card.effect === 'mirrorWeapon';
|
||||
}
|
||||
export function isWeaponCard(card: Card): boolean {
|
||||
return WEAPON_KINDS.includes(card.kind) || isStoneBurner(card);
|
||||
return WEAPON_KINDS.includes(card.kind) || isStoneBurner(card) || isMirrorWeapon(card);
|
||||
}
|
||||
|
||||
export const WEAPON_KINDS = [
|
||||
@@ -42,6 +46,7 @@ export const DEFENSE_KINDS = [
|
||||
];
|
||||
export const VOICE_KINDS = [
|
||||
'stoneBurner',
|
||||
'mirrorWeapon',
|
||||
'poison',
|
||||
'projectile',
|
||||
'lasgun',
|
||||
@@ -84,6 +89,7 @@ export function validBattleCardPair(weapon?: Card, defense?: Card) {
|
||||
export function weaponTypes(card?: Card): string[] {
|
||||
if (!card) return [];
|
||||
if (isStoneBurner(card)) return ['stoneBurner'];
|
||||
if (isMirrorWeapon(card)) return ['mirrorWeapon'];
|
||||
if (card.kind === 'poisonBlade') return ['projectile', 'poison'];
|
||||
if (card.kind === 'weirdingWay') return ['projectile'];
|
||||
if (card.kind === 'chemistry' || card.kind === 'poisonTooth')
|
||||
@@ -164,6 +170,7 @@ export function battleCardLabel(kind: string) {
|
||||
poisonTooth: 'Poison Tooth',
|
||||
artillery: 'Artillery Strike',
|
||||
stoneBurner: 'Stone Burner',
|
||||
mirrorWeapon: 'Mirror Weapon',
|
||||
poisonBlade: 'Poison Blade',
|
||||
shieldSnooper: 'Shield Snooper',
|
||||
weirdingWay: 'Weirding Way',
|
||||
@@ -189,8 +196,9 @@ export function battleCardEffects(
|
||||
toothA = true,
|
||||
toothD = true,
|
||||
extraSnoopers: { attacker?: boolean; defender?: boolean } = {},
|
||||
resolved?: BattleWeaponResolution,
|
||||
) {
|
||||
const weapons = resolveBattleWeapons({
|
||||
const weapons = resolved ?? resolveBattleWeapons({
|
||||
attacker: { weapon: aw, defense: ad },
|
||||
defender: { weapon: dw, defense: dd },
|
||||
});
|
||||
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
type Leader,
|
||||
} from './cards';
|
||||
import type { FactionId } from './catalog';
|
||||
import { battleWeaponsExplode, isStoneBurner } from './battle-cards';
|
||||
import { battleWeaponsExplode } from './battle-cards';
|
||||
import { validBattleSlotPair } from './battle-card-slots';
|
||||
import { resolveBattleWeapons } from './effective-weapons';
|
||||
import {
|
||||
casualtyOptions,
|
||||
validCombatForces,
|
||||
@@ -376,6 +377,12 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
return copiedDiplomatDefense(quote, copy.card);
|
||||
};
|
||||
const effectiveAd = effectiveDefense(a, d, ad), effectiveDd = effectiveDefense(d, a, dd);
|
||||
const weapons = resolveBattleWeapons({
|
||||
attacker: { weapon: aw, defense: effectiveAd },
|
||||
defender: { weapon: dw, defense: effectiveDd },
|
||||
});
|
||||
requireQuote(!weapons.error, weapons.error ?? 'The revealed weapons are invalid.');
|
||||
const hasStone = weapons.attacker.kind === 'stoneBurner' || weapons.defender.kind === 'stoneBurner';
|
||||
const withdrawals = new Map<string, HarassWithdrawQuote>();
|
||||
const reinforcements: NonNullable<BattleResolutionQuote['reinforcements']> = [];
|
||||
for (const side of [a, d]) {
|
||||
@@ -393,7 +400,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
'Reinforcements needs a unique physical card and valid weapon and defense slot pair.');
|
||||
requireQuote(!input.homeworld && !(side.leaderSkills?.length || (side === a ? d : a).leaderSkills?.length) &&
|
||||
!slots.some(isHarassWithdraw) &&
|
||||
!isStoneBurner(aw) && !isStoneBurner(dw),
|
||||
!hasStone,
|
||||
'Reinforcements cannot combine with Homeworld, leader skills, own Harass & Withdraw or Stone Burner.');
|
||||
const reserves = side.reinforcementsReserves!;
|
||||
requireQuote(!!reserves && typeof reserves === 'object' && !Array.isArray(reserves),
|
||||
@@ -406,7 +413,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
if (harass) {
|
||||
requireQuote(validBattleSlotPair(slots[0], slots[1]),
|
||||
'The Harass & Withdraw plan has an invalid weapon and defense pair.');
|
||||
requireQuote(side.harassWithdraw && !input.homeworld && !isStoneBurner(aw) && !isStoneBurner(dw),
|
||||
requireQuote(side.harassWithdraw && !input.homeworld && !hasStone,
|
||||
'Harass & Withdraw needs its supported physical context; Homeworld and Stone Burner combinations remain unfinished.');
|
||||
requireQuote(JSON.stringify(side.harassWithdraw.forces) === JSON.stringify(side.forces),
|
||||
'The withdrawal context must match the current battle force roles.');
|
||||
@@ -416,29 +423,28 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
const tie = battleTieOwner(a.id, d.id, input.aggressor ?? a.id,
|
||||
[a, d].filter(side => side.stronghold === 'habbanya_ridge_sietch').map(side => side.id)) === a.id
|
||||
? 'attacker' : 'defender';
|
||||
const stone =
|
||||
isStoneBurner(aw) || isStoneBurner(dw)
|
||||
? stoneBurnerComparison(
|
||||
a.forces,
|
||||
a.plan.dial,
|
||||
a.plan.support,
|
||||
d.forces,
|
||||
d.plan.dial,
|
||||
d.plan.support,
|
||||
tie,
|
||||
)
|
||||
: null;
|
||||
const stone = hasStone
|
||||
? stoneBurnerComparison(
|
||||
a.forces,
|
||||
a.plan.dial,
|
||||
a.plan.support,
|
||||
d.forces,
|
||||
d.plan.dial,
|
||||
d.plan.support,
|
||||
tie,
|
||||
)
|
||||
: null;
|
||||
if (stone) {
|
||||
requireQuote(
|
||||
!!stone.winner,
|
||||
'Stone Burner has an unresolved physical casualty allocation. No battle resources were spent.',
|
||||
);
|
||||
for (const [side, weapon] of [
|
||||
[a, aw],
|
||||
[d, dw],
|
||||
[a, weapons.attacker],
|
||||
[d, weapons.defender],
|
||||
] as const)
|
||||
requireQuote(
|
||||
!isStoneBurner(weapon) ||
|
||||
weapon.kind !== 'stoneBurner' ||
|
||||
['kill', 'ignore'].includes(side.stoneMode ?? ''),
|
||||
'Choose Stone Burner’s revealed mode before resolving battle.',
|
||||
);
|
||||
|
||||
+6
-3
@@ -4,6 +4,7 @@ import type { Card } from './cards';
|
||||
import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, leaderSkillBattleBonus, usesSurvivingSkilledLeader } from './leader-skill-combat';
|
||||
import { smugglerBattleModeSupported, smugglerBattlePlanBlock } from './smuggler-battle';
|
||||
import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair, type BattlePlanInspectionField } from './battle-card-slots';
|
||||
import { mirrorWeaponModeBlock } from './mirror-weapon-mode';
|
||||
import { defaultHarassWithdrawAllocation, quoteHarassWithdraw } from './harass-withdraw';
|
||||
import { leaderSkillStrongholdCount } from './leader-skill-battle-board';
|
||||
import { discoveryFlightBotActions } from './discovery-flight-options';
|
||||
@@ -61,6 +62,7 @@ import {
|
||||
import {
|
||||
isWeaponCard,
|
||||
isStoneBurner,
|
||||
isMirrorWeapon,
|
||||
isDefenseCard,
|
||||
defaultVoiceMatch,
|
||||
playedVoiceMatch,
|
||||
@@ -701,6 +703,7 @@ function plans(g: GameView): Action[] {
|
||||
const specialForLeader = (card: Card | undefined, leader: string | null) =>
|
||||
canUsePlanetologistBattleSpecial({assignments:battleSkills, selectedLeader:leader, card});
|
||||
const prototypeCardAllowed = (card: Card) =>
|
||||
(!isMirrorWeapon(card) || !mirrorWeaponModeBlock(g)) &&
|
||||
(card.id !== 'ecaz-reinforcements' || (!!b.reinforcements && !b.reinforcements.blocked)) &&
|
||||
(card.id !== 'ecaz-harass-withdraw' || (!!b.harassWithdraw && !b.harassWithdraw.blocked));
|
||||
const weapons = [
|
||||
@@ -927,9 +930,8 @@ function plans(g: GameView): Action[] {
|
||||
: 0,
|
||||
);
|
||||
if (bankerSpice) action.bankerSpice = bankerSpice;
|
||||
const stonePlan = isStoneBurner(
|
||||
me.hand?.find((c) => c.id === action.weapon),
|
||||
);
|
||||
const stonePlan = isStoneBurner(me.hand?.find((c) => c.id === action.weapon)) ||
|
||||
isMirrorWeapon(me.hand?.find((c) => c.id === action.weapon));
|
||||
if (stonePlan && !action.leader) continue;
|
||||
if (
|
||||
stonePlan &&
|
||||
@@ -2384,6 +2386,7 @@ function policyActions(g: GameView): Action[] {
|
||||
return [
|
||||
{
|
||||
type: 'decision',
|
||||
...(d.event ? { event: d.event } : {}),
|
||||
activate:
|
||||
level === 0
|
||||
? variation(g, 'tooth') > 0.5
|
||||
|
||||
@@ -25,6 +25,8 @@ export function richeseCardActionBlock(card: VisibleCard): string | null {
|
||||
const definition = richeseCardDefinition(card);
|
||||
if (definition?.card.effect === 'stoneBurner')
|
||||
return 'Choose Stone Burner in your battle weapon slot, then choose its leader effect after both plans are revealed.';
|
||||
if (definition?.card.effect === 'mirrorWeapon')
|
||||
return 'Choose Mirror Weapon in your battle weapon slot. It copies the opposing revealed weapon attack, with your own first post-reveal choice if needed.';
|
||||
if (definition?.card.effect === 'portableSnooper')
|
||||
return 'Choose Portable Snooper as your battle defense, or use its late-defense panel after reveal before submitting your traitor decision.';
|
||||
if (definition?.card.effect === 'residualPoison')
|
||||
@@ -86,10 +88,12 @@ export function cardPresentation(card: VisibleCard): CardPresentation {
|
||||
gameplay: richese.gameplay,
|
||||
availability:
|
||||
richese.card.effect === 'juiceOfSapho'
|
||||
? 'Richese effect integration and verification are incomplete. Supported controls cover Once Around first before bidding begins, Once Around last before your bid, bounded movement and battle-choice ordering, and aggressor during the shared pre-plan opportunity. Later aggressor intervention and other phase or auction modes remain unfinished; expansion starts remain disabled.'
|
||||
? 'Richese effect integration and verification are incomplete. Supported controls cover Once Around first or last while your bid remains open, bounded movement and battle-choice ordering, and aggressor during the shared pre-plan opportunity. Earlier bids stay committed. Later aggressor intervention and other phase or auction modes remain unfinished; expansion starts remain disabled.'
|
||||
: richese.card.effect === 'mirrorWeapon'
|
||||
? 'Mirror Weapon works in bounded CHOAM/Richese-deck classic-faction battles; copied Tooth and Stone choices resolve separately, and a winning physical Mirror may be kept even after copying Tooth or Artillery. This winner-retention treatment is a user-selected interpretation, not an official clarification. Combined modes and full Richese starts remain gated.'
|
||||
: richese.card.effect === 'stoneBurner'
|
||||
? 'Stone Burner has development battle controls. Combined allocation timing and Ix timing remain guarded; full expansion starts stay disabled.'
|
||||
: 'Richese effect integration and verification are incomplete. Karama, Distrans, Nullentropy Box, Ornithopter, Residual Poison, Portable Snooper and Stone Burner have card handlers in development fixtures; full expansion starts remain disabled.',
|
||||
: 'Richese effect integration and verification are incomplete. Karama, Distrans, Nullentropy Box, Ornithopter, Residual Poison, Portable Snooper, Stone Burner and Mirror Weapon have bounded card handlers; full expansion starts remain disabled.',
|
||||
topics: RULE_TOPICS.filter(
|
||||
(topic) =>
|
||||
topic.id ===
|
||||
|
||||
+1
-1
@@ -113,7 +113,7 @@ export const DUKE_VIDAL_RULES = Object.freeze({
|
||||
implementation: Object.freeze({
|
||||
custody: 'implemented',
|
||||
capture: 'not-implemented',
|
||||
revival: 'not-implemented',
|
||||
revival: 'ecaz-only-set-aside',
|
||||
ambassadorAcquisition: 'ecaz-self-implemented',
|
||||
ambassadorAlliance: 'consent-implemented',
|
||||
ambassadorAllyAcquisition: 'unresolved',
|
||||
|
||||
@@ -128,7 +128,7 @@ export function resolveEcazDukeRevival(
|
||||
whole(duke.acquiredTurn) &&
|
||||
duke.acquiredTurn > 0 &&
|
||||
duke.acquiredTurn <= g.turn &&
|
||||
['ecaz', 'moritani', 'ally'].includes(duke.source!),
|
||||
['ecaz', 'ecazNexus', 'moritani', 'ally'].includes(duke.source!),
|
||||
);
|
||||
return {
|
||||
source: 'sharedDuke',
|
||||
|
||||
+84
-31
@@ -1,5 +1,6 @@
|
||||
import type { Card } from './cards';
|
||||
import { richeseCardDefinition } from './richese-cards';
|
||||
import { canRetainBattleCard } from './moritani-retention';
|
||||
|
||||
export type BattleWeaponSide = 'attacker' | 'defender';
|
||||
export type EffectiveBattleWeapon = {
|
||||
@@ -11,13 +12,17 @@ export type EffectiveBattleWeapon = {
|
||||
/** Public name used by effective attack/defense integration. */
|
||||
export type EffectiveWeapon = EffectiveBattleWeapon;
|
||||
type SelectedBattleCards = { weapon?: Card; defense?: Card };
|
||||
export type EffectiveWeaponChoice = {
|
||||
side: BattleWeaponSide;
|
||||
kind: 'poisonTooth' | 'stoneBurner';
|
||||
/** The committed card making this decision, never the copied source card. */
|
||||
physicalId: string;
|
||||
copiedFrom: string | null;
|
||||
};
|
||||
export type BattleWeaponResolution = {
|
||||
attacker: EffectiveBattleWeapon;
|
||||
defender: EffectiveBattleWeapon;
|
||||
choiceOrder: {
|
||||
side: BattleWeaponSide;
|
||||
kind: 'poisonTooth' | 'stoneBurner';
|
||||
}[];
|
||||
choiceOrder: EffectiveWeaponChoice[];
|
||||
error: string | null;
|
||||
};
|
||||
const mirrorId = 'richese-mirror-weapon';
|
||||
@@ -34,6 +39,17 @@ const empty = (card?: Card): EffectiveBattleWeapon => ({
|
||||
choice: null,
|
||||
});
|
||||
|
||||
function appendChoice(result: BattleWeaponResolution, side: BattleWeaponSide) {
|
||||
const weapon = result[side];
|
||||
if (weapon.choice && weapon.physicalId)
|
||||
result.choiceOrder.push({
|
||||
side,
|
||||
kind: weapon.choice,
|
||||
physicalId: weapon.physicalId,
|
||||
copiedFrom: weapon.copiedFrom,
|
||||
});
|
||||
}
|
||||
|
||||
/** The caller validates whole plans. Here Chemistry's selected weapon role must
|
||||
* have its original second defense; a copied effect does not require that card.
|
||||
*/
|
||||
@@ -88,6 +104,7 @@ function originalWeapon({
|
||||
* This establishes a copy-first dependency, not general battle turn order.
|
||||
* Without that dependency choiceOrder follows input attacker/defender order;
|
||||
* callers retain the existing ordinary storm-order choice policy as required.
|
||||
* Choice entries identify the physical owner of each independent decision.
|
||||
* This helper decides neither activation answers nor physical-card retention.
|
||||
*/
|
||||
export function resolveBattleWeapons(input: {
|
||||
@@ -100,43 +117,79 @@ export function resolveBattleWeapons(input: {
|
||||
choiceOrder: [],
|
||||
error: null,
|
||||
};
|
||||
const cards = [
|
||||
input.attacker.weapon,
|
||||
input.attacker.defense,
|
||||
input.defender.weapon,
|
||||
input.defender.defense,
|
||||
].filter((card): card is Card => !!card);
|
||||
if (cards.some((card) => claimsMirror(card) && !isMirror(card))) {
|
||||
const aw = input.attacker.weapon;
|
||||
const ad = input.attacker.defense;
|
||||
const dw = input.defender.weapon;
|
||||
const dd = input.defender.defense;
|
||||
const attackerMirror = isMirror(aw);
|
||||
const attackerDefenseMirror = isMirror(ad);
|
||||
const defenderMirror = isMirror(dw);
|
||||
const defenderDefenseMirror = isMirror(dd);
|
||||
if (
|
||||
(aw && claimsMirror(aw) && !attackerMirror) ||
|
||||
(ad && claimsMirror(ad) && !attackerDefenseMirror) ||
|
||||
(dw && claimsMirror(dw) && !defenderMirror) ||
|
||||
(dd && claimsMirror(dd) && !defenderDefenseMirror)
|
||||
) {
|
||||
result.error = 'Mirror Weapon must match its canonical physical identity.';
|
||||
return result;
|
||||
}
|
||||
if (cards.filter(isMirror).length > 1) {
|
||||
if (
|
||||
Number(attackerMirror) +
|
||||
Number(attackerDefenseMirror) +
|
||||
Number(defenderMirror) +
|
||||
Number(defenderDefenseMirror) >
|
||||
1
|
||||
) {
|
||||
result.error =
|
||||
'Only one physical Mirror Weapon exists; duplicated custody is invalid.';
|
||||
return result;
|
||||
}
|
||||
result.attacker = originalWeapon(input.attacker);
|
||||
result.defender = originalWeapon(input.defender);
|
||||
for (const side of ['attacker', 'defender'] as const) {
|
||||
if (!isMirror(input[side].weapon)) continue;
|
||||
const other = side === 'attacker' ? 'defender' : 'attacker';
|
||||
const original = result[other];
|
||||
if (original.kind !== null) {
|
||||
result[side] = {
|
||||
physicalId: input[side].weapon!.id,
|
||||
copiedFrom: original.physicalId,
|
||||
kind: original.kind,
|
||||
choice: original.choice,
|
||||
};
|
||||
}
|
||||
if (attackerMirror && result.defender.kind !== null) {
|
||||
result.attacker = {
|
||||
physicalId: aw!.id,
|
||||
copiedFrom: result.defender.physicalId,
|
||||
kind: result.defender.kind,
|
||||
choice: result.defender.choice,
|
||||
};
|
||||
} else if (defenderMirror && result.attacker.kind !== null) {
|
||||
result.defender = {
|
||||
physicalId: dw!.id,
|
||||
copiedFrom: result.attacker.physicalId,
|
||||
kind: result.attacker.kind,
|
||||
choice: result.attacker.choice,
|
||||
};
|
||||
}
|
||||
const order: BattleWeaponSide[] =
|
||||
result.defender.copiedFrom && result.defender.choice
|
||||
? ['defender', 'attacker']
|
||||
: ['attacker', 'defender'];
|
||||
for (const side of order) {
|
||||
const choice = result[side].choice;
|
||||
if (choice) result.choiceOrder.push({ side, kind: choice });
|
||||
if (result.defender.copiedFrom && result.defender.choice) {
|
||||
appendChoice(result, 'defender');
|
||||
appendChoice(result, 'attacker');
|
||||
} else {
|
||||
appendChoice(result, 'attacker');
|
||||
appendChoice(result, 'defender');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Ordinary battle cleanup quote for one committed physical card. The copy's
|
||||
* effective kind is deliberately not an input: mandatory Tooth/Artillery
|
||||
* disposal applies to those original physical cards, not to Mirror. Moritani's
|
||||
* separate losing-ally interception is outside ordinary winner/loser cleanup.
|
||||
*/
|
||||
export function quoteBattleWeaponRetention(input: {
|
||||
card: Card;
|
||||
outcome: 'winner' | 'loser';
|
||||
traitorDecided: boolean;
|
||||
toothUsed: boolean;
|
||||
}): { physicalId: string; mayRetain: boolean } {
|
||||
const { card, outcome, traitorDecided, toothUsed } = input;
|
||||
if (claimsMirror(card) && !isMirror(card))
|
||||
throw new Error('Mirror Weapon must match its canonical physical identity.');
|
||||
return {
|
||||
physicalId: card.id,
|
||||
mayRetain:
|
||||
outcome === 'winner' &&
|
||||
canRetainBattleCard(card, traitorDecided, toothUsed),
|
||||
};
|
||||
}
|
||||
+193
-118
@@ -438,7 +438,7 @@ import {
|
||||
type MoritaniRetention,
|
||||
} from './moritani-retention';
|
||||
import { ordinaryCardAvailability } from './card-availability';
|
||||
import { ecazDukeRevivalBlock } from './ecaz-duke-revival';
|
||||
import { ecazDukeRevivalBlock, quoteEcazDukeRevival } from './ecaz-duke-revival';
|
||||
import {
|
||||
reserveShipmentCost,
|
||||
guildShipmentCost,
|
||||
@@ -510,11 +510,13 @@ import { traitorDeck, matchingTraitor, CHEAP_HERO_TRAITOR } from './traitors';
|
||||
import { chooseEcazLoyalty, withoutEcazLoyalty, validateEcazLoyalty, type EcazLoyalty } from './ecaz-loyalty';
|
||||
import {
|
||||
isStoneBurner,
|
||||
isMirrorWeapon,
|
||||
isPortableSnooper,
|
||||
VOICE_KINDS,
|
||||
defaultVoiceMatch,
|
||||
playedVoiceMatch,
|
||||
} from './battle-cards';
|
||||
import { mirrorWeaponModeBlock } from './mirror-weapon-mode';
|
||||
import {
|
||||
TECH_TOKENS,
|
||||
createTechTokens,
|
||||
@@ -811,8 +813,8 @@ export type Decision =
|
||||
territory: string;
|
||||
}
|
||||
| { kind: 'techToken'; player: string; loser: string; choices: TechId[] }
|
||||
| { kind: 'poisonTooth'; player: string }
|
||||
| { kind: 'stoneBurner'; player: string; event: string }
|
||||
| { kind: 'poisonTooth'; player: string; event?: string; physicalId?: string; copiedFrom?: string | null }
|
||||
| { kind: 'stoneBurner'; player: string; event: string; physicalId?: string; copiedFrom?: string | null }
|
||||
| { kind: 'sukRescue'; player: string; event: string; territory: string; mode: 'normal' | 'skilled'; options: SukRescueOption[] }
|
||||
| { kind: 'rihani'; player: string; event: string; stage: 'offer' | 'return' }
|
||||
| { kind: 'fullPlanOffer'; player: string }
|
||||
@@ -4631,7 +4633,7 @@ function suspendedControlsIntegrity(
|
||||
saphoMovementIntegrity(context);
|
||||
ornithopterIntegrity(context);
|
||||
lateDefenseIntegrity(context);
|
||||
stoneBurnerIntegrity(context);
|
||||
battleSpecialWeaponIntegrity(context);
|
||||
strongholdIntegrity(context);
|
||||
auditorIntegrity(context);
|
||||
}
|
||||
@@ -15176,6 +15178,10 @@ function validatePlan(
|
||||
currentReinforcementsCost(g, p);
|
||||
}
|
||||
if ([w, d].some(isHarassWithdraw)) currentHarassWithdrawQuote(g, p, plan);
|
||||
if (isMirrorWeapon(w)) {
|
||||
const blocked = mirrorWeaponModeBlock(g) ?? stonePlanBlock(g, p, dial, support);
|
||||
requireRule(!blocked, blocked ?? 'Mirror Weapon cannot resolve this commitment.');
|
||||
}
|
||||
if (isStoneBurner(w)) {
|
||||
const blocked = stonePlanBlock(g, p, dial, support);
|
||||
requireRule(
|
||||
@@ -15332,13 +15338,12 @@ function normalizeBattle(g: Game) {
|
||||
/** Ghola follows current control for ordinary leaders, but Duke's printed
|
||||
* revival right belongs exclusively to Ecaz, regardless of temporary custody. */
|
||||
function gholaLeaders(g: Game, p: Player) {
|
||||
return controlledLeaders(g, p).filter(
|
||||
(l) =>
|
||||
l.dead &&
|
||||
!l.capturedBy &&
|
||||
(l.id !== DUKE_VIDAL_ID ||
|
||||
(p.faction === 'ecaz' && !ecazDukeRevivalBlock(g, p.id))),
|
||||
const native = controlledLeaders(g, p).filter(
|
||||
(l) => l.id !== DUKE_VIDAL_ID && l.dead && !l.capturedBy,
|
||||
);
|
||||
if (p.faction === 'ecaz' && !ecazDukeRevivalBlock(g, p.id))
|
||||
native.push(g.dukeVidal!.leader);
|
||||
return native;
|
||||
}
|
||||
function gholaOptions(g: Game, p: Player) {
|
||||
const cards = p.hand
|
||||
@@ -15512,16 +15517,16 @@ function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) {
|
||||
requireRule(l?.dead && !l.capturedBy, 'Choose a dead leader in your pool.');
|
||||
l.dead = false;
|
||||
delete l.concealed;
|
||||
// GF9 November 2020 FAQ p.9 permits a Ghola return in another battle
|
||||
// this turn. Death history remains; the earlier battle location does not.
|
||||
// Ghola permits a return in another battle this turn (GF9 FAQ p.9).
|
||||
delete l.usedAt;
|
||||
if (l.id === DUKE_VIDAL_ID) g.dukeVidal = consumeDuke(g.dukeVidal!);
|
||||
if (g.pendingChoamMarketGhola && g.leaderSkills && !isAuditorLeader(l) &&
|
||||
!l.gholaBy && p.leaders.some(leader => leader.id === l.id))
|
||||
g.leaderSkills = skillRule(() => offerRevivedLeaderSkill(
|
||||
g.leaderSkills!, p.id, l.id, choamGholaSkillOfferEvent(g.pendingChoamMarketGhola!, l.id)));
|
||||
log(
|
||||
g,
|
||||
`${p.name} revived ${l.name} with Ghola. The leader may fight again this turn; no spice or normal leader-revival allowance was spent.`,
|
||||
`${p.name} revived ${l.name} with Ghola.${l.id === DUKE_VIDAL_ID ? ' The living shared disc is set aside, unclaimed.' : ' The leader may fight again this turn;'} No spice or normal leader-revival allowance was spent.`,
|
||||
);
|
||||
} else {
|
||||
const n = integer(
|
||||
@@ -16329,49 +16334,100 @@ function portableSnooperView(g: Game, owner: Player) {
|
||||
}
|
||||
return { card, event: g.battle?.event ?? null, blocked };
|
||||
}
|
||||
function stoneBurnerIntegrity(g: Game) {
|
||||
function revealedWeaponChoices(g: Game) {
|
||||
const b = g.battle!;
|
||||
requireRule(b.revealed && !!b.plans[b.attacker] && !!b.plans[b.defender],
|
||||
'Revealed weapon choices need both exact battle plans.');
|
||||
const selected = (id: string) => {
|
||||
const p = getPlayer(g, id), plan = b.plans[id];
|
||||
return { weapon: cardOf(p, plan.weapon), defense: cardOf(p, b.lateDefense?.[id] ?? plan.defense) };
|
||||
};
|
||||
const resolved = resolveBattleWeapons({ attacker: selected(b.attacker), defender: selected(b.defender) });
|
||||
requireRule(!resolved.error, resolved.error ?? 'The revealed weapon choices are invalid.');
|
||||
return resolved.choiceOrder.map((choice) => ({
|
||||
...choice,
|
||||
player: choice.side === 'attacker' ? b.attacker : b.defender,
|
||||
}));
|
||||
}
|
||||
function orderedRevealedWeaponChoices(
|
||||
g: Game,
|
||||
choices: ReturnType<typeof revealedWeaponChoices>,
|
||||
) {
|
||||
if (choices.some((choice) => choice.copiedFrom !== null)) return choices;
|
||||
return [
|
||||
...choices.filter((choice) => choice.kind === 'stoneBurner'),
|
||||
...g.order.flatMap((id) => choices.filter((choice) =>
|
||||
choice.kind === 'poisonTooth' && choice.player === id)),
|
||||
];
|
||||
}
|
||||
function revealedWeaponAnswered(
|
||||
b: Battle,
|
||||
choice: ReturnType<typeof revealedWeaponChoices>[number],
|
||||
) {
|
||||
return choice.kind === 'stoneBurner'
|
||||
? b.stoneBurner?.[choice.player] !== undefined
|
||||
: b.poisonTooth?.[choice.player] !== undefined;
|
||||
}
|
||||
function battleSpecialWeaponIntegrity(g: Game) {
|
||||
const b = g.battle;
|
||||
if (!b) return;
|
||||
if (b.stoneBurner !== undefined) {
|
||||
requireRule(
|
||||
g.phase === 6 &&
|
||||
b.revealed &&
|
||||
!!b.event &&
|
||||
!!b.stoneBurner &&
|
||||
typeof b.stoneBurner === 'object' &&
|
||||
!Array.isArray(b.stoneBurner),
|
||||
'The saved Stone Burner choice context is invalid.',
|
||||
);
|
||||
for (const [id, mode] of Object.entries(b.stoneBurner))
|
||||
requireRule(
|
||||
[b.attacker, b.defender].includes(id) &&
|
||||
['kill', 'ignore'].includes(mode) &&
|
||||
isStoneBurner(cardOf(getPlayer(g, id), b.plans[id]?.weapon)),
|
||||
'The saved Stone Burner choice has an invalid owner, mode, or missing canonical weapon.',
|
||||
);
|
||||
}
|
||||
for (const id of [b.attacker, b.defender]) {
|
||||
if (b.plans[id]?.weapon !== 'richese-stone-burner') continue;
|
||||
const owner = getPlayer(g, id),
|
||||
card = cardOf(owner, b.plans[id].weapon);
|
||||
requireRule(
|
||||
isStoneBurner(card),
|
||||
'The committed canonical Stone Burner weapon is missing from its reserved hand.',
|
||||
);
|
||||
requireRule(
|
||||
g.players.reduce(
|
||||
(n, p) => n + p.hand.filter((c) => c.id === card!.id).length,
|
||||
0,
|
||||
) === 1 &&
|
||||
![
|
||||
...g.deck,
|
||||
...g.discard,
|
||||
...(g.richeseCache ?? []),
|
||||
...(g.auction?.cards ?? []),
|
||||
...(g.ixSetupCards ?? []),
|
||||
].some((c) => c.id === card!.id),
|
||||
'The committed Stone Burner physical card is duplicated.',
|
||||
);
|
||||
const cardId = b.plans[id]?.weapon;
|
||||
if (cardId !== 'richese-stone-burner' && cardId !== 'richese-mirror-weapon') continue;
|
||||
const owner = getPlayer(g, id), card = cardOf(owner, cardId);
|
||||
const name = cardId === 'richese-stone-burner' ? 'Stone Burner' : 'Mirror Weapon';
|
||||
if (cardId === 'richese-mirror-weapon') {
|
||||
const blocked = mirrorWeaponModeBlock(g);
|
||||
requireRule(!blocked, blocked ?? 'The committed Mirror Weapon mode is invalid.');
|
||||
}
|
||||
requireRule(cardId === 'richese-stone-burner' ? isStoneBurner(card) : isMirrorWeapon(card),
|
||||
`The committed canonical ${name} weapon is missing from its reserved hand.`);
|
||||
requireRule(g.players.reduce((n, p) => n + p.hand.filter((c) => c.id === cardId).length, 0) === 1 &&
|
||||
![...g.deck, ...g.discard, ...(g.richeseCache ?? []),
|
||||
...(g.auction?.cards ?? []), ...(g.ixSetupCards ?? [])].some((c) => c.id === cardId),
|
||||
`The committed ${name} physical card is duplicated.`);
|
||||
}
|
||||
const choices = b.revealed ? revealedWeaponChoices(g) : [];
|
||||
if (b.stoneBurner !== undefined) {
|
||||
requireRule(g.phase === 6 && b.revealed && !!b.event && !!b.stoneBurner &&
|
||||
typeof b.stoneBurner === 'object' && !Array.isArray(b.stoneBurner),
|
||||
'The saved Stone Burner choice context is invalid.');
|
||||
for (const [id, mode] of Object.entries(b.stoneBurner))
|
||||
requireRule(['kill', 'ignore'].includes(mode) &&
|
||||
choices.some((choice) => choice.player === id && choice.kind === 'stoneBurner' &&
|
||||
choice.physicalId === b.plans[id]?.weapon),
|
||||
'The saved Stone Burner choice has an invalid owner, mode, or physical weapon.');
|
||||
}
|
||||
if (b.poisonTooth !== undefined) {
|
||||
requireRule(g.phase === 6 && b.revealed && !!b.poisonTooth &&
|
||||
typeof b.poisonTooth === 'object' && !Array.isArray(b.poisonTooth),
|
||||
'The saved Poison Tooth choice context is invalid.');
|
||||
for (const [id, activated] of Object.entries(b.poisonTooth))
|
||||
requireRule(typeof activated === 'boolean' &&
|
||||
choices.some((choice) => choice.player === id && choice.kind === 'poisonTooth' &&
|
||||
choice.physicalId === b.plans[id]?.weapon &&
|
||||
(choice.copiedFrom === null || !!b.event)),
|
||||
'The saved Poison Tooth choice has an invalid owner, answer, or physical weapon.');
|
||||
}
|
||||
const ordered = orderedRevealedWeaponChoices(g, choices);
|
||||
let pendingSeen = false;
|
||||
for (const choice of ordered) {
|
||||
if (revealedWeaponAnswered(b, choice))
|
||||
requireRule(!pendingSeen, 'A revealed weapon choice cannot precede its copied source.');
|
||||
else pendingSeen = true;
|
||||
}
|
||||
if (g.decision?.kind === 'stoneBurner' || g.decision?.kind === 'poisonTooth') {
|
||||
const decision = g.decision;
|
||||
const choice = ordered.find((candidate) => !revealedWeaponAnswered(b, candidate));
|
||||
requireRule(!!choice && choice.player === decision.player &&
|
||||
choice.kind === decision.kind &&
|
||||
choice.physicalId === b.plans[decision.player]?.weapon &&
|
||||
(decision.event === undefined || decision.event === b.event) &&
|
||||
(decision.physicalId === undefined || decision.physicalId === choice.physicalId) &&
|
||||
(decision.copiedFrom === undefined || decision.copiedFrom === choice.copiedFrom) &&
|
||||
(choice.copiedFrom === null || (decision.event === b.event &&
|
||||
decision.physicalId === choice.physicalId && decision.copiedFrom === choice.copiedFrom)),
|
||||
'The saved revealed weapon choice lost its physical card or battle event.');
|
||||
}
|
||||
}
|
||||
function diplomatDefenseOffer(g: Game): (DiplomatDefenseQuote & { player: string }) | null {
|
||||
@@ -16531,23 +16587,17 @@ function nextRevealedDecision(g: Game) {
|
||||
const b = g.battle!;
|
||||
if (offerHarassAllocation(g)) return;
|
||||
if (offerDiplomatDefense(g)) return;
|
||||
const stoneOwner = [b.attacker, b.defender].find(
|
||||
(id) =>
|
||||
isStoneBurner(cardOf(getPlayer(g, id), b.plans[id].weapon)) &&
|
||||
!b.stoneBurner?.[id],
|
||||
);
|
||||
if (stoneOwner) {
|
||||
requireRule(!!b.event, 'The Stone Burner battle event is missing.');
|
||||
g.decision = { kind: 'stoneBurner', player: stoneOwner, event: b.event };
|
||||
return;
|
||||
const ordered = orderedRevealedWeaponChoices(g, revealedWeaponChoices(g));
|
||||
const choice = ordered.find((candidate) => !revealedWeaponAnswered(b, candidate));
|
||||
if (choice) {
|
||||
if (choice.kind === 'stoneBurner' || choice.copiedFrom)
|
||||
requireRule(!!b.event, 'The revealed weapon battle event is missing.');
|
||||
g.decision = choice.kind === 'stoneBurner'
|
||||
? { kind: 'stoneBurner', player: choice.player, event: b.event!,
|
||||
physicalId: choice.physicalId, copiedFrom: choice.copiedFrom }
|
||||
: { kind: 'poisonTooth', player: choice.player, event: b.event,
|
||||
physicalId: choice.physicalId, copiedFrom: choice.copiedFrom };
|
||||
}
|
||||
const player = g.order.find(
|
||||
(id) =>
|
||||
[b.attacker, b.defender].includes(id) &&
|
||||
cardOf(getPlayer(g, id), b.plans[id].weapon)?.kind === 'poisonTooth' &&
|
||||
b.poisonTooth?.[id] === undefined,
|
||||
);
|
||||
if (player) g.decision = { kind: 'poisonTooth', player };
|
||||
}
|
||||
/** Homeworld invaders have no traitor vote to hold their late-defense window.
|
||||
* Offer only a real held legal defense; empty windows resolve automatically. */
|
||||
@@ -18477,15 +18527,16 @@ function finishRevival(
|
||||
Math.max(p.revivalCycle, p.kwisatz!.revivalCycle ?? 1) + 1;
|
||||
log(g, `${p.name} revived Kwisatz Haderach.`, { faction: p.faction, name: 'Revival' });
|
||||
} else {
|
||||
const leader = g.players
|
||||
.flatMap((owner) => owner.leaders)
|
||||
.find((l) => l.id === revival.leader)!;
|
||||
const sharedDuke = revival.kind === 'leader' && revival.leader === DUKE_VIDAL_ID;
|
||||
const leader = sharedDuke
|
||||
? g.dukeVidal!.leader
|
||||
: g.players.flatMap((owner) => owner.leaders).find((l) => l.id === revival.leader)!;
|
||||
if (revival.kind === 'foreignGhola') leader.gholaBy = p.id;
|
||||
else delete leader.gholaBy;
|
||||
leader.dead = false;
|
||||
delete leader.concealed;
|
||||
if (sharedDuke) g.dukeVidal = consumeDuke(g.dukeVidal!);
|
||||
// Automatic Karama response chains may commit after the action's before/after
|
||||
// observer. Record the own-leader offer at the successful revival itself.
|
||||
if (revival.kind === 'leader' && g.leaderSkills && !isAuditorLeader(leader) &&
|
||||
!leader.capturedBy && p.leaders.some(own => own.id === leader.id)) {
|
||||
requireLeaderSkillRevival(g, p.id);
|
||||
@@ -18494,7 +18545,7 @@ function finishRevival(
|
||||
}
|
||||
log(
|
||||
g,
|
||||
`${p.name} revived ${leader.name}${revival.kind === 'foreignGhola' ? ' as a ghola' : ''}.`,
|
||||
`${p.name} revived ${leader.name}${revival.kind === 'foreignGhola' ? ' as a ghola' : ''}.${sharedDuke ? ' The living shared disc is set aside, unclaimed.' : ''}`,
|
||||
{ faction: p.faction, name: 'Revival' },
|
||||
);
|
||||
}
|
||||
@@ -22095,7 +22146,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
karamaConversionIntegrity(g);
|
||||
ornithopterIntegrity(g);
|
||||
lateDefenseIntegrity(g);
|
||||
stoneBurnerIntegrity(g);
|
||||
battleSpecialWeaponIntegrity(g);
|
||||
saphoAggressorIntegrity(g);
|
||||
strongholdIntegrity(g);
|
||||
auditorIntegrity(g);
|
||||
@@ -22326,7 +22377,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
const g = structuredClone(state);
|
||||
ornithopterIntegrity(g);
|
||||
lateDefenseIntegrity(g);
|
||||
stoneBurnerIntegrity(g);
|
||||
battleSpecialWeaponIntegrity(g);
|
||||
strongholdIntegrity(g);
|
||||
auditorIntegrity(g);
|
||||
if (g.pendingNullentropy) return g;
|
||||
@@ -22370,7 +22421,7 @@ function applyActionInner(
|
||||
g.biddingEnd.ready = [];
|
||||
ornithopterIntegrity(g);
|
||||
lateDefenseIntegrity(g);
|
||||
stoneBurnerIntegrity(g);
|
||||
battleSpecialWeaponIntegrity(g);
|
||||
strongholdIntegrity(g);
|
||||
auditorIntegrity(g);
|
||||
if (g.pendingNullentropy && !['nexusTraitorDraw', 'nexusTraitorReturn', 'nexusFaceDancers'].includes(action?.type)) {
|
||||
@@ -23545,43 +23596,41 @@ function applyActionInner(
|
||||
nextRevealedDecision(g);
|
||||
} else if (decision.kind === 'stoneBurner') {
|
||||
requireRule(
|
||||
Object.keys(action).every((key) =>
|
||||
['type', 'event', 'mode'].includes(key),
|
||||
),
|
||||
'Stone Burner needs only this battle event and a mode.',
|
||||
);
|
||||
requireRule(
|
||||
g.battle?.revealed &&
|
||||
action.event === decision.event &&
|
||||
Object.keys(action).every((key) => ['type', 'event', 'mode'].includes(key)) &&
|
||||
(action.mode === 'kill' || action.mode === 'ignore') &&
|
||||
g.battle?.revealed && action.event === decision.event &&
|
||||
action.event === g.battle.event,
|
||||
'Choose Stone Burner’s mode for this exact revealed battle.',
|
||||
);
|
||||
requireRule(
|
||||
action.mode === 'kill' || action.mode === 'ignore',
|
||||
'Choose to kill both leaders or ignore surviving leader strength.',
|
||||
);
|
||||
requireRule(
|
||||
isStoneBurner(cardOf(p, g.battle.plans[id]?.weapon)) &&
|
||||
!g.battle.stoneBurner?.[id],
|
||||
'Stone Burner’s mode is already chosen or its weapon is missing.',
|
||||
);
|
||||
const choice = revealedWeaponChoices(g).find((candidate) =>
|
||||
candidate.kind === 'stoneBurner' && candidate.player === id);
|
||||
requireRule(!!choice && choice.physicalId === (decision.physicalId ?? choice.physicalId) &&
|
||||
choice.copiedFrom === (decision.copiedFrom ?? null) &&
|
||||
g.battle.stoneBurner?.[id] === undefined,
|
||||
'Stone Burner’s mode is already chosen or its physical weapon changed.');
|
||||
(g.battle.stoneBurner ??= {})[id] = action.mode;
|
||||
log(
|
||||
g,
|
||||
`${p.name} chose Stone Burner: ${action.mode === 'kill' ? 'both participating leaders will be killed' : 'surviving leaders add no strength; other weapon attacks still apply'}. Undialed physical force tokens decide the ordinary battle; traitors and explosions retain precedence.`,
|
||||
);
|
||||
log(g, `${p.name} chose ${choice.copiedFrom ? 'Mirror Weapon copying ' : ''}Stone Burner: ${
|
||||
action.mode === 'kill' ? 'both participating leaders will be killed' :
|
||||
'surviving leaders add no strength; other weapon attacks still apply'
|
||||
}. Undialed physical force tokens decide the ordinary battle; traitors and explosions retain precedence.`);
|
||||
nextRevealedDecision(g);
|
||||
} else if (decision.kind === 'poisonTooth') {
|
||||
requireRule(
|
||||
g.battle?.revealed && typeof action.activate === 'boolean',
|
||||
'Choose whether to activate Poison Tooth.',
|
||||
);
|
||||
g.battle.poisonTooth ??= {};
|
||||
g.battle.poisonTooth[id] = action.activate;
|
||||
log(
|
||||
g,
|
||||
`${p.name} ${action.activate ? 'activated' : 'declined to activate'} Poison Tooth.`,
|
||||
);
|
||||
requireRule(Object.keys(action).every((key) => ['type', 'event', 'activate'].includes(key)) &&
|
||||
g.battle?.revealed && typeof action.activate === 'boolean' &&
|
||||
(decision.copiedFrom === null || decision.copiedFrom === undefined ||
|
||||
action.event === decision.event) &&
|
||||
(action.event === undefined || action.event === decision.event) &&
|
||||
(decision.event === undefined || decision.event === g.battle.event),
|
||||
'Choose whether to activate Poison Tooth for this exact revealed battle.');
|
||||
const choice = revealedWeaponChoices(g).find((candidate) =>
|
||||
candidate.kind === 'poisonTooth' && candidate.player === id);
|
||||
requireRule(!!choice && choice.physicalId === (decision.physicalId ?? choice.physicalId) &&
|
||||
choice.copiedFrom === (decision.copiedFrom ?? null) &&
|
||||
g.battle.poisonTooth?.[id] === undefined,
|
||||
'Poison Tooth is already chosen or its physical weapon changed.');
|
||||
(g.battle.poisonTooth ??= {})[id] = action.activate;
|
||||
log(g, `${p.name} ${action.activate ? 'activated' : 'declined to activate'} ${
|
||||
choice.copiedFrom ? 'Mirror Weapon copying ' : ''}Poison Tooth.`);
|
||||
nextRevealedDecision(g);
|
||||
} else if (decision.kind === 'techToken') {
|
||||
requireRule(
|
||||
@@ -24908,6 +24957,8 @@ function applyActionInner(
|
||||
g.advanced && g.phase === 4 && p.faction === 'tleilaxu',
|
||||
'Foreign gholas require advanced Tleilaxu during Revival.',
|
||||
);
|
||||
requireRule(action.leader !== DUKE_VIDAL_ID,
|
||||
'Only Ecaz may revive Duke Vidal, including with Ghola.');
|
||||
const leader = g.players
|
||||
.flatMap((owner) => owner.leaders)
|
||||
.find((l) => l.id === action.leader);
|
||||
@@ -24997,11 +25048,16 @@ function applyActionInner(
|
||||
'Tleilaxu prevented this faction’s normal revivals for this turn.',
|
||||
);
|
||||
const options = leaderRevivalOptions(g, p);
|
||||
p.revivalCycle = options.cycle;
|
||||
const l =
|
||||
t === 'reviveLeader'
|
||||
? p.leaders.find((l) => l.id === action.leader)
|
||||
: undefined;
|
||||
const duke = t === 'reviveLeader' && action.leader === DUKE_VIDAL_ID;
|
||||
if (duke) {
|
||||
requireRule(p.faction === 'ecaz', 'Only Ecaz may revive Duke Vidal.');
|
||||
const blocked = ecazDukeRevivalBlock(g, p.id);
|
||||
requireRule(!blocked, blocked ?? 'Duke Vidal is unavailable for revival.');
|
||||
}
|
||||
const dukeQuote = duke ? quoteEcazDukeRevival(g, id, !!revivalDiscount(g, p)) : null;
|
||||
const l = t === 'reviveLeader'
|
||||
? duke ? g.dukeVidal!.leader : p.leaders.find((l) => l.id === action.leader)
|
||||
: undefined;
|
||||
if (t === 'reviveKwisatz')
|
||||
requireRule(
|
||||
g.advanced && p.faction === 'atreides' && p.kwisatz?.dead,
|
||||
@@ -25031,10 +25087,14 @@ function applyActionInner(
|
||||
t === 'reviveKwisatz' ? !!options.kwisatz : !!option,
|
||||
'That leader is unavailable for revival this phase.',
|
||||
);
|
||||
const normalCost = l?.strength ?? 2;
|
||||
const cost = revivalDiscount(g, p) ? Math.ceil(normalCost / 2) : normalCost;
|
||||
const normalCost = dukeQuote?.normalCost ?? l?.strength ?? 2;
|
||||
const cost = dukeQuote?.cost ?? (revivalDiscount(g, p) ? Math.ceil(normalCost / 2) : normalCost);
|
||||
requireRule(p.spice >= cost, 'Not enough spice.');
|
||||
if (cost < normalCost) checks.push('revivalDiscount');
|
||||
// Duke may be revived independently even while later six-disc native
|
||||
// cycles are gated; that request must not erase an opened first cohort.
|
||||
if (!duke || options.cycle > p.revivalCycle)
|
||||
p.revivalCycle = options.cycle;
|
||||
beginRevival(g, {
|
||||
player: id,
|
||||
kind: t === 'reviveKwisatz' ? 'kwisatz' : 'leader',
|
||||
@@ -25857,8 +25917,12 @@ function applyActionInner(
|
||||
VOICE_KINDS.includes(String(action.kind)),
|
||||
'Choose a card type.',
|
||||
);
|
||||
if (action.kind === 'mirrorWeapon') {
|
||||
const blocked = mirrorWeaponModeBlock(g);
|
||||
requireRule(!blocked, blocked ?? 'Mirror Weapon is unavailable in this battle configuration.');
|
||||
}
|
||||
requireRule(typeof action.must === 'boolean', 'Choose require or forbid.');
|
||||
if (action.kind === 'stoneBurner' && action.must) {
|
||||
if ((action.kind === 'stoneBurner' || action.kind === 'mirrorWeapon') && action.must) {
|
||||
const target = getPlayer(
|
||||
g,
|
||||
b.attacker === b.preparation.beneficiary ? b.defender : b.attacker,
|
||||
@@ -25866,7 +25930,7 @@ function applyActionInner(
|
||||
const blocked = stoneCompulsionBlock(g, target);
|
||||
requireRule(
|
||||
!blocked,
|
||||
blocked ?? 'Stone Burner cannot be compelled in this battle.',
|
||||
blocked ?? `${action.kind === 'mirrorWeapon' ? 'Mirror Weapon' : 'Stone Burner'} cannot be compelled in this battle.`,
|
||||
);
|
||||
}
|
||||
b.voice = {
|
||||
@@ -26148,6 +26212,7 @@ export function viewGame(state: Game, id: string) {
|
||||
ambassadorRelocationIntegrity(state);
|
||||
ecazCollectionIntegrity(state);
|
||||
ecazAllianceIntegrity(state);
|
||||
battleSpecialWeaponIntegrity(state);
|
||||
const g = structuredClone(state);
|
||||
normalizeCardNames(g);
|
||||
settleAdvisors(g);
|
||||
@@ -26165,7 +26230,15 @@ export function viewGame(state: Game, id: string) {
|
||||
if (!(error instanceof VictoryProgressError)) throw error;
|
||||
}
|
||||
}
|
||||
const leaderRevivals = leaderRevivalOptions(g, me);
|
||||
// A concealed capture may change the real death count without revealing it
|
||||
// to the native owner. Derive guidance from the same projected discs that
|
||||
// the seat can inspect; action validation still uses authoritative custody.
|
||||
const visibleRevivalGame = me.faction === 'ecaz' && g.dukeVidal
|
||||
? { ...g, dukeVidal: { ...g.dukeVidal,
|
||||
leader: projectLeader(g, g.dukeVidal.leader, id) } }
|
||||
: g;
|
||||
const leaderRevivals = leaderRevivalOptions(visibleRevivalGame,
|
||||
{ ...me, leaders: me.leaders.map((leader) => projectLeader(g, leader, id)) });
|
||||
const b = g.battle;
|
||||
const completion =
|
||||
!g.pendingTreacheryDiscard &&
|
||||
@@ -26767,6 +26840,8 @@ export function viewGame(state: Game, id: string) {
|
||||
: null,
|
||||
leaders: leaderRevivals.leaders,
|
||||
kwisatz: leaderRevivals.kwisatz,
|
||||
cycleBlock: leaderRevivals.cycleBlock,
|
||||
dukeBlocked: leaderRevivals.dukeBlocked,
|
||||
prevented: revivalPrevented(g, me.id),
|
||||
eliteRemaining: eliteRevivalRemaining(me, g.advanced),
|
||||
limit: normalForceRevivalLimit(g, me),
|
||||
|
||||
@@ -144,6 +144,7 @@ function practiceLeaders(choice: IntroductionResourceChoice): Leader[] {
|
||||
export function introductionRevival(choice: IntroductionResourceChoice) {
|
||||
validateChoice(choice);
|
||||
const context = {
|
||||
status: 'playing' as const,
|
||||
advanced: false,
|
||||
phase: 4,
|
||||
turn: 1,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { FactionId } from './catalog';
|
||||
|
||||
const supported = new Set<FactionId>([
|
||||
'atreides', 'harkonnen', 'emperor', 'guild', 'beneGesserit', 'fremen',
|
||||
'choam', 'richese',
|
||||
]);
|
||||
|
||||
/** Public configuration only: never inspect an opponent's hand or sealed plan. */
|
||||
export function mirrorWeaponModeBlock(g: {
|
||||
expansions: readonly string[];
|
||||
players: readonly { faction: FactionId }[];
|
||||
homeworlds?: unknown;
|
||||
nexusCards?: unknown;
|
||||
leaderSkills?: unknown;
|
||||
discoveries?: unknown;
|
||||
sandtrout?: boolean;
|
||||
}): string | null {
|
||||
if (g.expansions.length !== 1 || g.expansions[0] !== 'choam' ||
|
||||
g.players.some((p) => !supported.has(p.faction)))
|
||||
return 'Mirror Weapon currently needs the CHOAM/Richese expansion with classic or CHOAM/Richese factions.';
|
||||
if (g.homeworlds || g.nexusCards || g.leaderSkills || g.discoveries || g.sandtrout)
|
||||
return 'Mirror Weapon with combined optional modules is still being integrated.';
|
||||
return null;
|
||||
}
|
||||
+7
-3
@@ -413,7 +413,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'A player whose hand becomes full is automatically passed, or submits zero in an unfinished Silent lot. A full-hand owner’s unbid cache card is removed automatically. If every hand is full, preparing the normal pool does not draw and lose unseen cards.',
|
||||
'AI supports cache choices and all three offered bidding methods using its own projected information. It currently skips the optional Black Market prelude. A seller in an already offered Black Market lot passes or submits zero.',
|
||||
'Some unresolved rules are explicitly blocked: changing the normal card count after cache-auction cancellation, exhausted-cache arithmetic, positive Black Market self-bids, and advanced Ixian Technology substitution on special lots. These are implementation guards while the rules are resolved, not claims of official prohibitions.',
|
||||
'Juice of Sapho now supports bounded first/last Once Around, movement and remaining battle-choice ordering; see its timing guide for current limits. Richese’s Karama uses the existing generic handler, Distrans supports a separate private hand transfer, Nullentropy Box supports a paid private discard search, Ornithopter supports its two movement modes, and Residual Poison supports a random opposing leader death before leader commitment in development fixtures. Portable Snooper supports ordinary and late poison defense. Stone Burner supports a guarded weapon commitment and revealed leader-effect choice. Mirror Weapon and Semuta Drug remain unfinished, as do the other Sapho timing modes; this does not certify every Karama interaction. Richese expansion starts remain disabled. Each component guide identifies its currently available action. Independent continuation and persisted-room checks pass. Desktop checks confirmed all ten enlarged guides, direct topic links, Once Around payment and restored private Silent bidding. Mobile and full Richese-game verification remain unfinished.',
|
||||
'Juice of Sapho now supports bounded first/last Once Around, movement and remaining battle-choice ordering; see its timing guide for current limits. Richese’s Karama uses the existing generic handler, Distrans supports a separate private hand transfer, Nullentropy Box supports a paid private discard search, Ornithopter supports its two movement modes, and Residual Poison supports a random opposing leader death before leader commitment in development fixtures. Portable Snooper supports ordinary and late poison defense. Stone Burner supports a guarded weapon commitment and revealed leader-effect choice. Mirror Weapon copies opposing revealed attacks, with independent copy-first choices for Tooth and Stone in bounded CHOAM/Richese-deck classic-faction battles. A winning physical Mirror may be kept even after copying activated Tooth or Artillery under the user-selected interpretation, not a publisher clarification. Semuta Drug and other Sapho timing modes remain unfinished; this does not certify every Karama interaction. Richese expansion starts remain disabled. Each component guide identifies its currently available action. Independent continuation and persisted-room checks pass. Desktop checks confirmed all ten enlarged guides, direct topic links, Once Around payment and restored private Silent bidding. Mobile and full Richese-game verification remain unfinished.',
|
||||
],
|
||||
related: [
|
||||
'choam-modules',
|
||||
@@ -624,7 +624,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'The server checks alliance, exact card ownership, capacity and commitments again before transfer. An obsolete pending gift aborts without a partial transfer and restores its interrupted flow. Unrelated players see the gift power, not its private card identity. A pending card cannot also be spent, exchanged or randomly extracted.',
|
||||
'After cancellation, trying the same card again in the same phase is currently blocked pending a ruling on repeated use. This is an explicit implementation guard, not a printed once-per-phase limit on all gifts. Other legally available cards are assessed separately.',
|
||||
'All four AI profiles conservatively give only the canonical Richese Karama during Bidding to free a full hand when the ally’s public card count is lower and there is room. They do not inspect ally hand contents or infer an unknown need for a card. Pending responses, decisions and Truthtrance take priority.',
|
||||
'All ten canonical Richese cards may be transferred when legal, and Karama has its existing card-effect handler. Distrans supports its separate card-transfer effect, Nullentropy Box supports paid private discard search, Ornithopter supports its two movement modes, and Residual Poison supports a random opposing leader death before leader commitment. Portable Snooper supports ordinary and late poison defense. Stone Burner supports a guarded weapon commitment and revealed leader-effect choice. Juice of Sapho supports bounded Once Around and movement ordering. Mirror Weapon and Semuta Drug remain unfinished, as do the other Sapho timing modes. Desktop gift transfer and fresh-tab pending recovery have been checked in a synthetic table. Mobile, broader interactions and full Richese games remain pending; Richese starts remain disabled.',
|
||||
'All ten canonical Richese cards may be transferred when legal, and Karama has its existing card-effect handler. Distrans supports its separate card-transfer effect, Nullentropy Box supports paid private discard search, Ornithopter supports its two movement modes, and Residual Poison supports a random opposing leader death before leader commitment. Portable Snooper supports ordinary and late poison defense. Stone Burner supports a guarded weapon commitment and revealed leader-effect choice. Mirror Weapon copies an opposing revealed weapon in bounded CHOAM/Richese-deck classic-faction battles, with copy-first Tooth/Stone choices; physical-Mirror winner retention even after copied activated Tooth/Artillery is a user-selected interpretation. Juice of Sapho supports bounded Once Around and movement ordering. Semuta Drug and other Sapho timing modes remain unfinished. Desktop gift transfer and fresh-tab pending recovery have been checked in a synthetic table. Mobile, broader interactions and full Richese games remain pending; Richese starts remain disabled.',
|
||||
],
|
||||
related: [
|
||||
'richese-cards',
|
||||
@@ -940,7 +940,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'An active Truthtrance question and exact card or transaction commitments must resolve as required. A full hand is currently guarded: the printed sequence adds the recovered card before discarding the Box, and the temporary capacity question is unresolved. The current implementation requires a pre-existing free hand slot. This is not a verified prohibition on every net-zero exchange.',
|
||||
'A pending Guild refund claim on a discarded shipping Karama also blocks starting the search while that provisional refund policy is unresolved. The guard protects the competing claim to that card; it is not a general ban on Box during Shipment. Empty or only-Box searches are rejected before payment under the current unsupported-empty-search policy; there is no fallback deck draw.',
|
||||
'All four AI profiles start only when their own projected availability and spice reserve allow it. They do not inspect unpaid discard contents. Once paid, they choose from their entitled cards, prefer existing functional effects over unfinished Richese faces and complete a legal selection even if only unfinished effects remain. A paid search takes priority over optional actions.',
|
||||
'This bounded search, its controls and recovery are integrated in development fixtures. Full-hand activation, the provisional Guild refund interaction and future Semuta discard reactions remain unresolved or unfinished. Mirror Weapon and Semuta Drug remain inactive, and full Richese starts remain disabled. Complete browser, mobile and full-game acceptance is not claimed here.',
|
||||
'This bounded search, its controls and recovery are integrated in development fixtures. Full-hand activation, the provisional Guild refund interaction and future Semuta discard reactions remain unresolved or unfinished. Mirror Weapon has a separate bounded CHOAM/Richese-deck classic-faction battle path; Semuta Drug remains inactive, and full Richese starts remain disabled. Complete browser, mobile and full-game acceptance is not claimed here.',
|
||||
],
|
||||
related: [
|
||||
'card-richese-nullentropy-box',
|
||||
@@ -1149,6 +1149,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
? 'Use the Residual Poison panel before either combatant commits a leader. A shared preparation step provides an opportunity regardless of hand contents; advanced Harkonnen tables remain guarded pending the secret-captive ruling.'
|
||||
: definition.card.effect === 'stoneBurner'
|
||||
? 'Use Stone Burner in the battle weapon selector and review the chosen dial/support guard before sealing. Choose the leader effect after revelation; combined timing remains guarded.'
|
||||
: definition.card.effect === 'mirrorWeapon'
|
||||
? 'Choose Mirror Weapon in the battle weapon slot in a supported CHOAM/Richese-deck table. Its attack copies the opposing revealed weapon; copied Tooth or Stone choices resolve first and independently. Under the user-selected interpretation, a winning physical Mirror may be retained even after a copied Tooth or Artillery attack. Full Richese starts and combined modules remain guarded.'
|
||||
: definition.card.effect === 'portableSnooper'
|
||||
? 'Choose Portable Snooper as your ordinary poison defense, or use the late-defense panel after reveal before your own traitor decision. It uses ordinary winner retention and cannot stop Poison Tooth.'
|
||||
: 'Reference component only: this Richese card’s game actions are not enabled.',
|
||||
@@ -1224,6 +1226,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
? ['residual-poison']
|
||||
: definition.card.effect === 'stoneBurner'
|
||||
? ['stone-burner']
|
||||
: definition.card.effect === 'mirrorWeapon'
|
||||
? ['battle', 'stone-burner']
|
||||
: definition.card.effect === 'portableSnooper'
|
||||
? ['portable-snooper']
|
||||
: []),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Game, ResponseWindow, Player } from './engine';
|
||||
import { DUKE_VIDAL_ID } from './duke-vidal';
|
||||
import { resolveEcazDukeRevival } from './ecaz-duke-revival';
|
||||
import { isAuditorLeader } from './choam-auditor';
|
||||
import { controlsLeader } from './leader-control';
|
||||
import {
|
||||
@@ -28,6 +30,7 @@ export type RevivalCancellationContext = Pick<
|
||||
| 'players'
|
||||
| 'revivalRules'
|
||||
| 'freeRevival'
|
||||
| 'dukeVidal'
|
||||
| 'pendingRevival'
|
||||
| 'emperorExtra'
|
||||
>;
|
||||
@@ -118,6 +121,20 @@ export function continuationCustody(
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (pending.leader === DUKE_VIDAL_ID) {
|
||||
requireRevival(pending.kind === 'leader' && p.faction === 'ecaz' &&
|
||||
pending.normalCost === 5 && (pending.cost === 3 || pending.cost === 5) &&
|
||||
!pending.checks.includes('earlyRevival'),
|
||||
'The pending shared leader has invalid revival costs or checks.');
|
||||
try {
|
||||
resolveEcazDukeRevival(g, p.id);
|
||||
} catch {
|
||||
throw new RevivalCancellationError('The pending shared leader is unavailable for revival.');
|
||||
}
|
||||
requireRevival(!p.leaderRevived,
|
||||
'The pending shared leader has already used this phase’s revival slot.');
|
||||
return;
|
||||
}
|
||||
const matches = g.players
|
||||
.flatMap((owner) => owner.leaders.map((leader) => ({ owner, leader })))
|
||||
.filter(({ leader }) => leader.id === pending.leader);
|
||||
@@ -241,6 +258,16 @@ export function quoteRevivalCancellation(
|
||||
g.advanced && p.faction === 'atreides' && !!p.kwisatz,
|
||||
'The pending Kwisatz Haderach belongs to no valid revival pool.',
|
||||
);
|
||||
} else if (original.leader === DUKE_VIDAL_ID) {
|
||||
requireRevival(original.kind === 'leader' && p.faction === 'ecaz' &&
|
||||
original.normalCost === 5 && (original.cost === 3 || original.cost === 5) &&
|
||||
kind !== 'earlyRevival',
|
||||
'The pending shared leader has invalid revival costs or checks.');
|
||||
try {
|
||||
resolveEcazDukeRevival(g, p.id);
|
||||
} catch {
|
||||
throw new RevivalCancellationError('The pending shared leader is unavailable for revival.');
|
||||
}
|
||||
} else {
|
||||
const matches = g.players
|
||||
.flatMap((seat) => seat.leaders.map((leader) => ({ seat, leader })))
|
||||
|
||||
+54
-40
@@ -9,6 +9,8 @@ import { isAuditorLeader } from './choam-auditor';
|
||||
import { controlsLeader } from './leader-control';
|
||||
import { TECH_TOKENS, ownedTech } from './tech-tokens';
|
||||
import { tleilaxuHomeworldFreeIncomeBlocked } from './homeworld-benefits';
|
||||
import { DUKE_VIDAL_ID } from './duke-vidal';
|
||||
import { resolveEcazDukeRevival } from './ecaz-duke-revival';
|
||||
|
||||
export type RevivalResumeContext = Pick<
|
||||
Game,
|
||||
@@ -247,49 +249,61 @@ export function quoteRevivalResume(
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
const matches = g.players
|
||||
.flatMap((owner) => owner.leaders.map((leader) => ({ owner, leader })))
|
||||
.filter(({ leader }) => leader.id === pending.leader);
|
||||
requireResume(
|
||||
matches.length === 1 &&
|
||||
matches[0].leader.dead &&
|
||||
!matches[0].leader.capturedBy,
|
||||
'The pending revival needs one dead, uncaptured physical leader.',
|
||||
);
|
||||
const { owner, leader } = matches[0];
|
||||
if (pending.kind === 'foreignGhola') {
|
||||
const living =
|
||||
g.players
|
||||
.flatMap((seat) => seat.leaders)
|
||||
.filter((leader) => controlsLeader(p, leader) && !leader.dead)
|
||||
.length +
|
||||
(g.dukeVidal?.controller === p.id &&
|
||||
!g.dukeVidal.leader.dead &&
|
||||
!g.dukeVidal.leader.capturedBy &&
|
||||
!g.dukeVidal.leader.gholaBy &&
|
||||
!(g.advanced && g.players.some((seat) => seat.faction === 'harkonnen'))
|
||||
? 1
|
||||
: 0);
|
||||
requireResume(
|
||||
leader.faction !== p.faction && !isAuditorLeader(leader) && living < 5,
|
||||
'The pending foreign ghola has invalid leader identity or active pool capacity.',
|
||||
);
|
||||
if (pending.leader === DUKE_VIDAL_ID) {
|
||||
requireResume(pending.kind === 'leader' && p.faction === 'ecaz' &&
|
||||
pending.normalCost === 5 && (pending.cost === 3 || pending.cost === 5) &&
|
||||
!pending.checks.includes('earlyRevival') && !p.leaderRevived,
|
||||
'The pending shared leader has invalid ownership, cost, or revival slot.');
|
||||
try {
|
||||
resolveEcazDukeRevival(g, p.id);
|
||||
} catch {
|
||||
throw new RevivalResumeError('The pending shared leader is unavailable for revival.');
|
||||
}
|
||||
} else {
|
||||
// A negotiated native buyback may still carry the Tleilaxu ghola marker.
|
||||
// The original approved request survives while benefit checks are answered.
|
||||
const request = g.revivalRequests?.[p.id];
|
||||
const buyback =
|
||||
!!tleilaxu &&
|
||||
leader.gholaBy === tleilaxu.id &&
|
||||
request?.leader === leader.id &&
|
||||
request.price === pending.cost &&
|
||||
!request.declined;
|
||||
const matches = g.players
|
||||
.flatMap((owner) => owner.leaders.map((leader) => ({ owner, leader })))
|
||||
.filter(({ leader }) => leader.id === pending.leader);
|
||||
requireResume(
|
||||
owner.id === p.id &&
|
||||
(!leader.gholaBy || buyback) &&
|
||||
(!p.leaderRevived || p.faction === 'tleilaxu'),
|
||||
'The pending native leader no longer belongs to this revival request.',
|
||||
matches.length === 1 &&
|
||||
matches[0].leader.dead &&
|
||||
!matches[0].leader.capturedBy,
|
||||
'The pending revival needs one dead, uncaptured physical leader.',
|
||||
);
|
||||
const { owner, leader } = matches[0];
|
||||
if (pending.kind === 'foreignGhola') {
|
||||
const living =
|
||||
g.players
|
||||
.flatMap((seat) => seat.leaders)
|
||||
.filter((leader) => controlsLeader(p, leader) && !leader.dead)
|
||||
.length +
|
||||
(g.dukeVidal?.controller === p.id &&
|
||||
!g.dukeVidal.leader.dead &&
|
||||
!g.dukeVidal.leader.capturedBy &&
|
||||
!g.dukeVidal.leader.gholaBy &&
|
||||
!(g.advanced && g.players.some((seat) => seat.faction === 'harkonnen'))
|
||||
? 1
|
||||
: 0);
|
||||
requireResume(
|
||||
leader.faction !== p.faction && !isAuditorLeader(leader) && living < 5,
|
||||
'The pending foreign ghola has invalid leader identity or active pool capacity.',
|
||||
);
|
||||
} else {
|
||||
// A negotiated native buyback may still carry the Tleilaxu ghola marker.
|
||||
// The original approved request survives while benefit checks are answered.
|
||||
const request = g.revivalRequests?.[p.id];
|
||||
const buyback =
|
||||
!!tleilaxu &&
|
||||
leader.gholaBy === tleilaxu.id &&
|
||||
request?.leader === leader.id &&
|
||||
request.price === pending.cost &&
|
||||
!request.declined;
|
||||
requireResume(
|
||||
owner.id === p.id &&
|
||||
(!leader.gholaBy || buyback) &&
|
||||
(!p.leaderRevived || p.faction === 'tleilaxu'),
|
||||
'The pending native leader no longer belongs to this revival request.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stop)
|
||||
|
||||
+35
-6
@@ -3,6 +3,8 @@ import { faction, type FactionId } from './catalog';
|
||||
import { isAuditorLeader } from './choam-auditor';
|
||||
import { homeworldLowBonus } from './homeworld-benefits';
|
||||
import { recruitsRevivalAllowance } from './recruits';
|
||||
import { DUKE_VIDAL_ID } from './duke-vidal';
|
||||
import { ecazDukeRevivalBlock, quoteEcazDukeRevival } from './ecaz-duke-revival';
|
||||
type RevivalRateContext = Pick<
|
||||
Game,
|
||||
| 'advanced'
|
||||
@@ -136,10 +138,9 @@ export function normalRevivalCycle(
|
||||
: p.revivalCycle;
|
||||
}
|
||||
|
||||
/** Native leader options for the existing ordinary cycle and Tleilaxu self-service.
|
||||
* Shared discs are deliberately separate: Ecaz's six-disc cycle is not implemented here.
|
||||
* This is a pure quote; committing an action, not reading a view, advances the cycle.
|
||||
*/
|
||||
/** The five-disc Ecaz opening is recorded in revivalCycle on the first
|
||||
* accepted ordinary return. Later six-disc cohorts are not established by E3:
|
||||
* keep their native returns gated rather than guessing from death counters. */
|
||||
export function leaderRevivalOptions(
|
||||
g: Pick<
|
||||
Game,
|
||||
@@ -149,6 +150,8 @@ export function leaderRevivalOptions(
|
||||
| 'revivalPrevention'
|
||||
| 'players'
|
||||
| 'revivalRules'
|
||||
| 'status'
|
||||
| 'dukeVidal'
|
||||
>,
|
||||
p: Pick<
|
||||
Player,
|
||||
@@ -161,7 +164,21 @@ export function leaderRevivalOptions(
|
||||
| 'kwisatz'
|
||||
>,
|
||||
) {
|
||||
const cycle = normalRevivalCycle(p);
|
||||
const shared = p.faction === 'ecaz' && g.dukeVidal;
|
||||
const dukeContext = shared && g.status === 'playing' ? g : null;
|
||||
const dukeBlocked = dukeContext ? ecazDukeRevivalBlock(dukeContext, p.id) : null;
|
||||
const dukeAvailable = !!dukeContext && !dukeBlocked;
|
||||
const repeatHistory = !!shared &&
|
||||
(g.dukeVidal!.leader.deaths > 1 || p.leaders.some((l) => l.deaths > 1) ||
|
||||
p.revivalCycle > 1);
|
||||
const firstOpening = !!shared && !repeatHistory &&
|
||||
(p.leaders.filter((l) => l.dead && !l.capturedBy && !l.gholaBy && l.deaths === 1).length +
|
||||
(g.dukeVidal!.leader.dead && dukeAvailable &&
|
||||
g.dukeVidal!.leader.deaths === 1 ? 1 : 0) >= 5 ||
|
||||
p.leaders.every((l) => l.dead || l.capturedBy || l.gholaBy));
|
||||
const cycle = shared
|
||||
? (!repeatHistory && (p.revivalCycle === 1 || firstOpening) ? 1 : 0)
|
||||
: normalRevivalCycle(p);
|
||||
const enabled = g.phase === 4 && !revivalPrevented(g, p.id);
|
||||
const discount = revivalDiscount(g, p);
|
||||
const leaders = p.leaders.flatMap((leader) => {
|
||||
@@ -195,6 +212,17 @@ export function leaderRevivalOptions(
|
||||
},
|
||||
];
|
||||
});
|
||||
if (dukeAvailable && enabled && !p.leaderRevived) {
|
||||
const quote = quoteEcazDukeRevival(dukeContext!, p.id, !!discount);
|
||||
leaders.push({
|
||||
id: DUKE_VIDAL_ID,
|
||||
name: quote.duke.leader.name,
|
||||
normalCost: quote.normalCost,
|
||||
cost: quote.cost,
|
||||
early: false,
|
||||
affordable: p.spice >= quote.cost,
|
||||
});
|
||||
}
|
||||
const kwisatzCost = discount ? 1 : 2;
|
||||
const kwisatz =
|
||||
enabled &&
|
||||
@@ -205,7 +233,8 @@ export function leaderRevivalOptions(
|
||||
cycle >= (p.kwisatz.revivalCycle ?? 1)
|
||||
? { cost: kwisatzCost, affordable: p.spice >= kwisatzCost }
|
||||
: null;
|
||||
return { cycle, leaders, kwisatz };
|
||||
return { cycle, leaders, kwisatz, dukeBlocked, cycleBlock: repeatHistory
|
||||
? 'Ecaz six-disc repeated revival cycles are not yet resolved.' : null };
|
||||
}
|
||||
export type PendingRevival = {
|
||||
player: string;
|
||||
|
||||
@@ -194,11 +194,10 @@ export const RICHESE_CARD_DEFINITIONS: readonly RicheseCardDefinition[] =
|
||||
],
|
||||
[
|
||||
'Determine the copied weapon from the revealed opposing plan, not the opponent’s hand.',
|
||||
'Physical disposal follows the general official winner-retention rule unless a specific exception applies; copied special-weapon disposal remains under audit.',
|
||||
'The user-selected interpretation keeps a victorious physical Mirror eligible for normal winner retention even if it copied activated Poison Tooth or Artillery; this is not a publisher clarification.',
|
||||
],
|
||||
[
|
||||
'Two Mirror Weapons, no opposing weapon, mode-dependent cards, Poison Tooth, Artillery Strike, Stone Burner and weapon-order effects.',
|
||||
'Copied card disposal and effects that cancel or replace weapons.',
|
||||
'Further combined-module card-role changes and effects that cancel or replace weapons.',
|
||||
],
|
||||
),
|
||||
define(
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
weaponTypes,
|
||||
defenseTypes,
|
||||
} from './battle-cards';
|
||||
import { resolveBattleWeapons, type EffectiveWeapon } from './effective-weapons';
|
||||
import type { StrongholdId } from './stronghold-cards';
|
||||
|
||||
/** Carthag adds a Snooper property, not a second physical card or Chemistry. */
|
||||
@@ -12,6 +13,7 @@ export function strongholdSnooper(
|
||||
effect: StrongholdId | null | undefined,
|
||||
weapon?: Card,
|
||||
defense?: Card,
|
||||
effectiveKind?: EffectiveWeapon['kind'],
|
||||
) {
|
||||
return (
|
||||
effect === 'carthag' &&
|
||||
@@ -19,7 +21,8 @@ export function strongholdSnooper(
|
||||
defense.kind !== 'worthless' &&
|
||||
isDefenseCard(defense) &&
|
||||
!defenseTypes(defense).includes('snooper') &&
|
||||
!weaponTypes(weapon).includes('poison')
|
||||
!(effectiveKind === undefined ? weaponTypes(weapon).includes('poison') :
|
||||
['poison', 'poisonBlade', 'poisonTooth', 'chemistry'].includes(effectiveKind ?? ''))
|
||||
);
|
||||
}
|
||||
export function strongholdBattleEffects(
|
||||
@@ -32,8 +35,12 @@ export function strongholdBattleEffects(
|
||||
aEffect?: StrongholdId | null,
|
||||
dEffect?: StrongholdId | null,
|
||||
) {
|
||||
return battleCardEffects(aw, ad, dw, dd, toothA, toothD, {
|
||||
attacker: strongholdSnooper(aEffect, aw, ad),
|
||||
defender: strongholdSnooper(dEffect, dw, dd),
|
||||
const weapons = resolveBattleWeapons({
|
||||
attacker: { weapon: aw, defense: ad },
|
||||
defender: { weapon: dw, defense: dd },
|
||||
});
|
||||
return battleCardEffects(aw, ad, dw, dd, toothA, toothD, {
|
||||
attacker: strongholdSnooper(aEffect, aw, ad, weapons.attacker.kind),
|
||||
defender: strongholdSnooper(dEffect, dw, dd, weapons.defender.kind),
|
||||
}, weapons);
|
||||
}
|
||||
@@ -249,6 +249,43 @@ void test('Stone validates physical ambiguity and mode before traitors, and uses
|
||||
g.defender.plan.support = 1;
|
||||
reject(g, /unresolved physical casualty/);
|
||||
});
|
||||
void test('copied Stone needs each revealed mode and keeps physical Mirror custody on victory', () => {
|
||||
const g = input();
|
||||
const mirror = play(g, 'attacker', 'weapon', (card) => card.effect === 'mirrorWeapon');
|
||||
const original = play(g, 'defender', 'weapon', (card) => card.effect === 'stoneBurner');
|
||||
g.defender.plan.dial = g.defender.plan.support = 4;
|
||||
reject(g, /revealed mode/);
|
||||
g.attacker.stoneMode = 'ignore';
|
||||
reject(g, /revealed mode/);
|
||||
g.defender.stoneMode = 'ignore';
|
||||
const quote = quoteBattleResolution(g);
|
||||
assert.equal(quote.stone?.winner, 'attacker');
|
||||
assert.equal(quote.winner, 'a');
|
||||
assert.ok(quote.winnerCards.includes(mirror.id));
|
||||
assert.ok(quote.discarded.some((entry) => entry.card === original.id));
|
||||
assert.ok(!quote.discarded.some((entry) => entry.card === mirror.id));
|
||||
g.attacker.stoneMode = 'kill';
|
||||
assert.deepEqual(quoteBattleResolution(g).leaderDeaths, {
|
||||
attacker: true,
|
||||
defender: true,
|
||||
});
|
||||
});
|
||||
void test('victorious physical Mirror retains after copying activated Tooth or Artillery', () => {
|
||||
for (const copiedKind of ['poisonTooth', 'artillery'] as const) {
|
||||
const g = input();
|
||||
const mirror = play(g, 'attacker', 'weapon', (card) => card.effect === 'mirrorWeapon');
|
||||
const original = play(g, 'defender', 'weapon', (card) => card.kind === copiedKind);
|
||||
if (copiedKind === 'poisonTooth') {
|
||||
g.attacker.poisonTooth = true;
|
||||
g.defender.poisonTooth = true;
|
||||
}
|
||||
const quote = quoteBattleResolution(g);
|
||||
assert.equal(quote.winner, 'a', copiedKind);
|
||||
assert.ok(quote.winnerCards.includes(mirror.id), copiedKind);
|
||||
assert.ok(!quote.discarded.some((entry) => entry.card === mirror.id), copiedKind);
|
||||
assert.ok(quote.discarded.some((entry) => entry.card === original.id), copiedKind);
|
||||
}
|
||||
});
|
||||
void test('Habbanya selects ordinary and Stone ties; canceled free-support and elite bonuses remain captured in casualty choices', () => {
|
||||
const g = input();
|
||||
g.defender.leader!.strength = 3;
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
consumeDuke,
|
||||
createDukeVidal,
|
||||
DUKE_VIDAL_ID,
|
||||
DUKE_VIDAL_RULES,
|
||||
expireDuke,
|
||||
type DukeSource,
|
||||
} from '../game/duke-vidal';
|
||||
@@ -170,34 +169,3 @@ void test('custody survives JSON persistence and permits a later eligible acquis
|
||||
assert.equal(released.controller, null);
|
||||
assert.equal(held.controller, 'm');
|
||||
});
|
||||
|
||||
void test('immutable original gameplay guidance separates strength, revival cost and unfinished integrations', () => {
|
||||
assert.equal(DUKE_VIDAL_RULES.traitor, false);
|
||||
assert.equal(DUKE_VIDAL_RULES.strength, 6);
|
||||
assert.equal(DUKE_VIDAL_RULES.faction, 'ecaz');
|
||||
assert.ok(
|
||||
DUKE_VIDAL_RULES.gameplay.some((paragraph) =>
|
||||
paragraph.includes('five spice'),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
DUKE_VIDAL_RULES.gameplay.some((paragraph) =>
|
||||
paragraph.includes('Only Ecaz'),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
DUKE_VIDAL_RULES.gameplay.some((paragraph) => paragraph.includes('Karama')),
|
||||
);
|
||||
assert.ok(
|
||||
DUKE_VIDAL_RULES.gameplay.every(
|
||||
(paragraph) => !/https?:\/\//.test(paragraph),
|
||||
),
|
||||
);
|
||||
assert.equal(DUKE_VIDAL_RULES.implementation.custody, 'implemented');
|
||||
assert.equal(DUKE_VIDAL_RULES.implementation.capture, 'not-implemented');
|
||||
assert.equal(DUKE_VIDAL_RULES.implementation.revival, 'not-implemented');
|
||||
assert.equal(DUKE_VIDAL_RULES.implementation.allyLoanReturn, 'unresolved');
|
||||
assert.ok(Object.isFrozen(DUKE_VIDAL_RULES));
|
||||
assert.ok(Object.isFrozen(DUKE_VIDAL_RULES.gameplay));
|
||||
assert.ok(Object.isFrozen(DUKE_VIDAL_RULES.implementation));
|
||||
});
|
||||
@@ -390,7 +390,7 @@ function completeMovement(g: Game) {
|
||||
assert.equal(g.phase, 6);
|
||||
return g;
|
||||
}
|
||||
void test('an acquired Duke is usable in a genuine following battle, then the same disc is consumed after death', () => {
|
||||
function deadFromBattle() {
|
||||
const initial = fixture();
|
||||
p(initial, 'ec').forces = { 'arrakeen:10': 1 };
|
||||
p(initial, 'ec').reserves = 19;
|
||||
@@ -429,6 +429,72 @@ void test('an acquired Duke is usable in a genuine following battle, then the sa
|
||||
assert.equal(g.dukeVidal!.leader.id, DUKE_VIDAL_ID);
|
||||
assert.equal(p(g, 'in').spice, 24);
|
||||
assert.ok(p(g, 'ec').leaders.every((l) => !l.dead));
|
||||
g = applyAction(g, 'in', { type: 'decision', discard: [] });
|
||||
assert.equal(g.decision, null);
|
||||
return g;
|
||||
}
|
||||
void test('an acquired Duke is usable in a genuine following battle, then the same disc is consumed after death', () => {
|
||||
const g = deadFromBattle();
|
||||
assert.equal(viewGame(g, 'ec').dukeVidal?.controller, null);
|
||||
assert.ok(g.players.every((seat) => seat.leaders.every((l) => l.id !== DUKE_VIDAL_ID)));
|
||||
});
|
||||
void test('genuine acquisition and battle death lead to one paid Ecaz return of the shared disc at five, unclaimed', () => {
|
||||
let g = deadFromBattle();
|
||||
g.phase = 4;
|
||||
g.turn = 3;
|
||||
g.battle = null;
|
||||
g.ready = [];
|
||||
const ec = p(g, 'ec');
|
||||
ec.spice = 9;
|
||||
const option = viewGame(g, 'ec').revival.leaders.find((l) => l.id === DUKE_VIDAL_ID);
|
||||
assert.deepEqual(option, {
|
||||
id: DUKE_VIDAL_ID, name: 'Duke Prad Vidal',
|
||||
normalCost: 5, cost: 5, early: false, affordable: true,
|
||||
});
|
||||
assert.equal(viewGame(g, 'in').revival.leaders.some((l) => l.id === DUKE_VIDAL_ID), false);
|
||||
const before = structuredClone(g);
|
||||
reject(g, 'in', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
|
||||
assert.deepEqual(g, before);
|
||||
g = applyAction(reload(g), 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
|
||||
assert.equal(g.pendingRevival, null);
|
||||
assert.equal(p(g, 'ec').spice, 4);
|
||||
assert.equal(p(g, 'ec').leaderRevived, true);
|
||||
assert.equal(g.dukeVidal!.leader.dead, false);
|
||||
assert.equal(g.dukeVidal!.leader.deaths, 1);
|
||||
assert.equal(g.dukeVidal!.leader.strength, 6);
|
||||
assert.deepEqual(
|
||||
[g.dukeVidal!.controller, g.dukeVidal!.source, g.dukeVidal!.acquiredTurn],
|
||||
[null, null, null],
|
||||
);
|
||||
assert.equal(p(g, 'ec').leaders.length, 5);
|
||||
assert.equal(viewGame(g, 'ec').players.find((seat) => seat.id === 'ec')!.leaders.some((l) => l.id === DUKE_VIDAL_ID), false);
|
||||
assert.equal(viewGame(g, 'ec').revival.leaders.some((l) => l.id === DUKE_VIDAL_ID), false);
|
||||
reject(g, 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
|
||||
assert.deepEqual(reload(g).dukeVidal, g.dukeVidal);
|
||||
});
|
||||
void test('Ghola revives the genuine dead Ecaz Duke as an unclaimed disc without spice or ordinary slot', () => {
|
||||
let g = deadFromBattle();
|
||||
const cardIndex = g.deck.findIndex((c) => c.effect === 'ghola');
|
||||
assert.ok(cardIndex >= 0);
|
||||
const card = g.deck.splice(cardIndex, 1)[0];
|
||||
p(g, 'ec').hand.push(card);
|
||||
const beforeSpice = p(g, 'ec').spice;
|
||||
assert.equal(viewGame(g, 'ec').ghola.leaders.some((l) => l.id === DUKE_VIDAL_ID), true);
|
||||
assert.equal(viewGame(g, 'in').ghola.leaders.some((l) => l.id === DUKE_VIDAL_ID), false);
|
||||
reject(g, 'in', { type: 'card', card: card.id, leader: DUKE_VIDAL_ID });
|
||||
g = applyAction(reload(g), 'ec', { type: 'card', card: card.id, leader: DUKE_VIDAL_ID });
|
||||
assert.equal(p(g, 'ec').spice, beforeSpice);
|
||||
assert.equal(p(g, 'ec').leaderRevived, false);
|
||||
assert.equal(g.dukeVidal!.leader.dead, false);
|
||||
assert.equal(g.dukeVidal!.leader.deaths, 1);
|
||||
assert.equal(g.dukeVidal!.controller, null);
|
||||
assert.equal(g.dukeVidal!.source, null);
|
||||
assert.equal(g.dukeVidal!.leader.usedAt, undefined);
|
||||
assert.equal(g.dukeVidal!.acquiredTurn, null);
|
||||
assert.equal(p(g, 'ec').leaders.length, 5);
|
||||
assert.equal(p(g, 'ec').hand.some((c) => c.id === card.id), false);
|
||||
assert.deepEqual(reload(g).dukeVidal, g.dukeVidal);
|
||||
assert.equal(viewGame(reload(g), 'ec').ghola.leaders.some((l) => l.id === DUKE_VIDAL_ID), false);
|
||||
});
|
||||
void test('unused source-Ecaz custody persists through a genuine end of turn without manufacturing another disc', () => {
|
||||
let g = acquire(enter());
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { createDukeVidal, DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { newRevivalRules } from '../game/revival';
|
||||
import type { Action } from '../game/engine';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
|
||||
async function room(t: test.TestContext, advanced: boolean) {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
const made = await store.rooms.createRoom('Ecaz revival', 'ecaz', advanced, ['ecaz', 'ix']);
|
||||
const code = made.view.code;
|
||||
const tokens = [made.token];
|
||||
for (const faction of ['moritani', 'tleilaxu', 'emperor'] as const)
|
||||
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
|
||||
const auths = await Promise.all(tokens.map((token) => store.rooms.authenticate(code, token)));
|
||||
const ids = auths.map((auth) => auth.playerId);
|
||||
const game = await store.rooms.readRoom(code);
|
||||
Object.assign(game, {
|
||||
status: 'playing', phase: 4, turn: 3, order: ids,
|
||||
dukeVidal: createDukeVidal(), revivalRules: newRevivalRules(),
|
||||
deck: baseDeck(), discard: [],
|
||||
});
|
||||
game.dukeVidal!.leader.dead = true;
|
||||
game.dukeVidal!.leader.deaths = 1;
|
||||
game.players[0].spice = 10;
|
||||
return {
|
||||
...store, code, tokens, auths, ids, game,
|
||||
save() {
|
||||
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(game), game.version, code);
|
||||
},
|
||||
async act(index: number, action: Action) {
|
||||
const rooms = store.restart();
|
||||
const before = await rooms.readRoom(code);
|
||||
await rooms.act(code, auths[index], before.version, action, clock);
|
||||
return store.restart().readRoom(code);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
void test('a discounted pending Ecaz Duke return survives SQLite restart and cancellation without double payment', async (t) => {
|
||||
const f = await room(t, true);
|
||||
const [ecaz, , tleilaxu] = f.game.players;
|
||||
ecaz.ally = tleilaxu.id;
|
||||
tleilaxu.ally = ecaz.id;
|
||||
tleilaxu.specialKaramaUsed = true;
|
||||
f.game.revivalRules!.allyDiscount = ecaz.id;
|
||||
const at = f.game.deck.findIndex((card) => card.effect === 'karama');
|
||||
assert.ok(at >= 0);
|
||||
const card = f.game.deck.splice(at, 1)[0];
|
||||
f.game.players[3].hand.push(card);
|
||||
f.save();
|
||||
const ownerView = await f.restart().readSeatView(f.code, f.auths[0]);
|
||||
assert.equal(ownerView.revival.leaders.find((l) => l.id === DUKE_VIDAL_ID)?.cost, 3);
|
||||
assert.equal((await f.restart().readSeatView(f.code, f.auths[1])).revival.leaders.some(
|
||||
(l) => l.id === DUKE_VIDAL_ID), false);
|
||||
const pending = await f.act(0, { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
|
||||
assert.equal(pending.response?.kind, 'revivalDiscount');
|
||||
assert.equal(pending.pendingRevival?.normalCost, 5);
|
||||
assert.equal(pending.pendingRevival?.cost, 3);
|
||||
assert.equal(pending.players[0].spice, 10);
|
||||
assert.equal(pending.dukeVidal!.leader.dead, true);
|
||||
const done = await f.act(3, { type: 'card', card: card.id, mode: 'cancel' });
|
||||
assert.equal(done.dukeVidal!.leader.dead, false);
|
||||
assert.equal(done.dukeVidal!.leader.deaths, 1);
|
||||
assert.deepEqual([done.dukeVidal!.controller, done.dukeVidal!.source, done.dukeVidal!.acquiredTurn],
|
||||
[null, null, null]);
|
||||
assert.equal(done.players[0].spice, 5);
|
||||
assert.equal(done.players[2].spice, 5);
|
||||
assert.equal(done.players[0].leaderRevived, true);
|
||||
assert.equal(done.players[0].leaders.some((l) => l.id === DUKE_VIDAL_ID), false);
|
||||
assert.equal(done.discard.filter((c) => c.id === card.id).length, 1);
|
||||
await assert.rejects(f.restart().act(f.code, f.auths[0], pending.version,
|
||||
{ type: 'reviveLeader', leader: DUKE_VIDAL_ID }, clock));
|
||||
assert.deepEqual(await f.restart().readRoom(f.code), done);
|
||||
});
|
||||
|
||||
void test('a physical Ecaz Ghola returns the saved shared Duke once through SQLite, without a leader slot or spice', async (t) => {
|
||||
const f = await room(t, false);
|
||||
f.game.phase = 7;
|
||||
const at = f.game.deck.findIndex((card) => card.effect === 'ghola');
|
||||
assert.ok(at >= 0);
|
||||
const card = f.game.deck.splice(at, 1)[0];
|
||||
f.game.players[0].hand.push(card);
|
||||
f.game.dukeVidal!.leader.usedAt = 'arrakeen';
|
||||
f.save();
|
||||
const own = await f.restart().readSeatView(f.code, f.auths[0]);
|
||||
const rival = await f.restart().readSeatView(f.code, f.auths[1]);
|
||||
assert.equal(own.ghola.leaders.some((l) => l.id === DUKE_VIDAL_ID), true);
|
||||
assert.equal(rival.ghola.leaders.some((l) => l.id === DUKE_VIDAL_ID), false);
|
||||
const before = await f.restart().readRoom(f.code);
|
||||
await assert.rejects(f.restart().act(f.code, f.auths[1], before.version,
|
||||
{ type: 'card', card: card.id, leader: DUKE_VIDAL_ID }, clock));
|
||||
assert.deepEqual(await f.restart().readRoom(f.code), before);
|
||||
const done = await f.act(0, { type: 'card', card: card.id, leader: DUKE_VIDAL_ID });
|
||||
assert.equal(done.dukeVidal!.leader.dead, false);
|
||||
assert.equal(done.dukeVidal!.leader.deaths, 1);
|
||||
assert.equal(done.dukeVidal!.leader.usedAt, undefined);
|
||||
assert.deepEqual([done.dukeVidal!.controller, done.dukeVidal!.source, done.dukeVidal!.acquiredTurn],
|
||||
[null, null, null]);
|
||||
assert.equal(done.players[0].spice, 10);
|
||||
assert.equal(done.players[0].leaderRevived, false);
|
||||
assert.equal(done.players[0].leaders.some((l) => l.id === DUKE_VIDAL_ID), false);
|
||||
assert.equal(done.discard.filter((c) => c.id === card.id).length, 1);
|
||||
await assert.rejects(f.restart().act(f.code, f.auths[0], before.version,
|
||||
{ type: 'card', card: card.id, leader: DUKE_VIDAL_ID }, clock));
|
||||
assert.deepEqual(await f.restart().readRoom(f.code), done);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, newPlayer, type Game } from '../game/engine';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { FACTIONS, type FactionId } from '../game/catalog';
|
||||
import {
|
||||
createDukeVidal,
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
EcazDukeRevivalError,
|
||||
DUKE_REVIVAL_NORMAL_COST,
|
||||
} from '../game/ecaz-duke-revival';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { quoteRevivalCancellation } from '../game/revival-cancellation';
|
||||
import { botActions } from '../game/bots';
|
||||
const p = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
|
||||
function fixture(advanced = false) {
|
||||
const g = createGame(
|
||||
@@ -360,6 +363,15 @@ void test('set-aside battle-death identity and valid retained legacy custody res
|
||||
released,
|
||||
);
|
||||
});
|
||||
void test('a dead Duke with prior Ecaz Nexus custody resolves to the same physical return', () => {
|
||||
const g = fixture();
|
||||
g.dukeVidal = acquireDuke(createDukeVidal(), 'ec', 2, 'ecazNexus');
|
||||
g.dukeVidal.leader.dead = true;
|
||||
g.dukeVidal.leader.deaths = 1;
|
||||
const quoted = resolveEcazDukeRevival(g, 'ec');
|
||||
assert.equal(quoted.duke.source, 'ecazNexus');
|
||||
assert.equal(quoted.normalCost, 5);
|
||||
});
|
||||
void test('invalid source context and duplicate Ecaz owners fail independently from paid revival timing', () => {
|
||||
for (const change of [
|
||||
(g: Game) => {
|
||||
@@ -388,3 +400,165 @@ void test('invalid source context and duplicate Ecaz owners fail independently f
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
});
|
||||
void test('four native deaths plus Duke open Ecaz first cohort, which stays open after a paid return', () => {
|
||||
let g = fixture();
|
||||
p(g, 'ec').leaders.slice(0, 4).forEach((l) => {
|
||||
l.dead = true;
|
||||
l.deaths = 1;
|
||||
});
|
||||
p(g, 'ec').spice = 20;
|
||||
const initial = viewGame(g, 'ec').revival.leaders;
|
||||
assert.equal(initial.filter((l) => l.id !== DUKE_VIDAL_ID).length, 4);
|
||||
assert.equal(initial.find((l) => l.id === DUKE_VIDAL_ID)?.early, false);
|
||||
g = applyAction(g, 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
|
||||
assert.equal(p(g, 'ec').revivalCycle, 1);
|
||||
assert.equal(p(g, 'ec').spice, 15);
|
||||
p(g, 'ec').leaderRevived = false;
|
||||
g.turn++;
|
||||
assert.equal(viewGame(g, 'ec').revival.leaders.length, 4);
|
||||
const native = p(g, 'ec').leaders[0].id;
|
||||
g = applyAction(g, 'ec', { type: 'reviveLeader', leader: native });
|
||||
assert.equal(p(g, 'ec').spice, 15 - p(g, 'ec').leaders[0].strength);
|
||||
assert.equal(p(g, 'ec').leaders[4].dead, false);
|
||||
assert.equal(g.dukeVidal!.controller, null);
|
||||
});
|
||||
void test('Ecaz native repeated six-disc cycles stay gated while Duke remains independently revivable', () => {
|
||||
const g = fixture();
|
||||
p(g, 'ec').leaders.forEach((l) => { l.dead = true; l.deaths = 1; });
|
||||
p(g, 'ec').leaders[0].deaths = 2;
|
||||
p(g, 'ec').spice = 10;
|
||||
p(g, 'ec').revivalCycle = 1;
|
||||
const options = viewGame(g, 'ec').revival;
|
||||
assert.match(options.cycleBlock!, /six-disc repeated/);
|
||||
assert.deepEqual(options.leaders.map((l) => l.id), [DUKE_VIDAL_ID]);
|
||||
assert.equal(options.leaders[0].cost, 5);
|
||||
const returned = applyAction(g, 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
|
||||
assert.equal(p(returned, 'ec').revivalCycle, 1);
|
||||
assert.equal(returned.dukeVidal!.leader.deaths, 1);
|
||||
});
|
||||
void test('saved discounted Duke request can be canceled back to five and paid once with income', () => {
|
||||
let g = fixture(true);
|
||||
g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
|
||||
g.players.push(newPlayer('k', 'Karama', 'emperor'));
|
||||
g.order = g.players.map((seat) => seat.id);
|
||||
g.deck = baseDeck();
|
||||
const karamaIndex = g.deck.findIndex((card) => card.effect === 'karama');
|
||||
p(g, 'k').hand.push(g.deck.splice(karamaIndex, 1)[0]);
|
||||
p(g, 'ec').spice = 8;
|
||||
p(g, 't').ally = 'ec';
|
||||
p(g, 'ec').ally = 't';
|
||||
p(g, 't').specialKaramaUsed = true;
|
||||
g = applyAction(g, 't', { type: 'tleilaxuAllyDiscount' });
|
||||
assert.equal(viewGame(g, 'ec').revival.leaders.find((l) => l.id === DUKE_VIDAL_ID)?.cost, 3);
|
||||
g = applyAction(g, 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
|
||||
assert.deepEqual(g.pendingRevival?.checks, []);
|
||||
assert.equal(g.response?.kind, 'revivalDiscount');
|
||||
assert.equal(g.pendingRevival?.normalCost, 5);
|
||||
assert.equal(g.pendingRevival?.cost, 3);
|
||||
const saved = JSON.parse(JSON.stringify(g)) as Game;
|
||||
const before = structuredClone(saved);
|
||||
const quote = quoteRevivalCancellation(saved, saved.response!);
|
||||
assert.equal(quote.pending?.cost, 5);
|
||||
assert.deepEqual(saved, before);
|
||||
g = applyAction(saved, 'k', {
|
||||
type: 'card', card: p(saved, 'k').hand[0].id, mode: 'cancel',
|
||||
});
|
||||
assert.equal(p(g, 'ec').spice, 3);
|
||||
assert.equal(g.dukeVidal!.leader.dead, false);
|
||||
assert.equal(g.dukeVidal!.controller, null);
|
||||
assert.equal(p(g, 'ec').leaderRevived, true);
|
||||
assert.equal(g.pendingRevival, null);
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(p(g, 't').spice, 5, 'the automatic paid revival income settles once');
|
||||
});
|
||||
void test('Advanced Tleilaxu stop decision survives JSON and cannot charge Duke before approval', () => {
|
||||
let g = fixture(true);
|
||||
g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
|
||||
g.order = g.players.map((seat) => seat.id);
|
||||
p(g, 'ec').spice = 7;
|
||||
g = applyAction(g, 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
|
||||
assert.equal(g.decision?.kind, 'revivalStop');
|
||||
assert.equal(g.pendingRevival?.normalCost, 5);
|
||||
assert.equal(p(g, 'ec').spice, 7);
|
||||
assert.equal(g.dukeVidal!.leader.dead, true);
|
||||
g = JSON.parse(JSON.stringify(g)) as Game;
|
||||
g = applyAction(g, 't', { type: 'decision', decline: true });
|
||||
assert.equal(p(g, 'ec').spice, 2);
|
||||
assert.equal(p(g, 't').spice, 5);
|
||||
assert.equal(g.dukeVidal!.leader.dead, false);
|
||||
assert.equal(g.dukeVidal!.controller, null);
|
||||
assert.equal(g.pendingRevival, null);
|
||||
});
|
||||
void test('all four Ecaz AI profiles can choose the authoritative five-spice Duke return', () => {
|
||||
const g = fixture();
|
||||
p(g, 'ec').spice = 10;
|
||||
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
p(g, 'ec').bot = profile;
|
||||
const view = viewGame(g, 'ec');
|
||||
const selected = botActions(view).find(
|
||||
(action) => action.type === 'reviveLeader' && action.leader === DUKE_VIDAL_ID,
|
||||
);
|
||||
assert.ok(selected, profile);
|
||||
const done = applyAction(g, 'ec', selected);
|
||||
assert.equal(done.dukeVidal!.leader.dead, false);
|
||||
assert.equal(done.dukeVidal!.controller, null);
|
||||
assert.equal(done.players.find((seat) => seat.id === 'ec')!.spice, 5);
|
||||
}
|
||||
});
|
||||
void test('corrupt saved pending Duke custody rejects continuation without changing spice or the request', () => {
|
||||
let g = fixture(true);
|
||||
g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
|
||||
g.order = g.players.map((seat) => seat.id);
|
||||
p(g, 'ec').spice = 9;
|
||||
g = applyAction(g, 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
|
||||
assert.equal(g.decision?.kind, 'revivalStop');
|
||||
g = JSON.parse(JSON.stringify(g)) as Game;
|
||||
p(g, 'ec').leaders.push(structuredClone(g.dukeVidal!.leader));
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, 't', { type: 'decision', decline: true }));
|
||||
assert.deepEqual(g, before);
|
||||
assert.equal(p(g, 'ec').spice, 9);
|
||||
});
|
||||
void test('five native Ecaz leaders in Tanks open the ordinary first cohort even while Duke lives', () => {
|
||||
const g = fixture();
|
||||
g.dukeVidal!.leader.dead = false;
|
||||
g.dukeVidal!.leader.deaths = 0;
|
||||
p(g, 'ec').leaders.forEach((l) => { l.dead = true; l.deaths = 1; });
|
||||
p(g, 'ec').spice = 20;
|
||||
const options = viewGame(g, 'ec').revival.leaders;
|
||||
assert.equal(options.length, 5);
|
||||
assert.equal(options.some((l) => l.id === DUKE_VIDAL_ID), false);
|
||||
});
|
||||
void test('unavailable captured native leaders retain the ordinary no-survivor exception', () => {
|
||||
const g = fixture();
|
||||
g.dukeVidal!.leader.dead = false;
|
||||
g.dukeVidal!.leader.deaths = 0;
|
||||
p(g, 'ec').leaders.forEach((l, i) => {
|
||||
l.dead = i === 0;
|
||||
l.deaths = i === 0 ? 1 : 0;
|
||||
if (i !== 0) l.capturedBy = 'm';
|
||||
});
|
||||
p(g, 'ec').spice = 20;
|
||||
assert.deepEqual(viewGame(g, 'ec').revival.leaders.map((l) => l.id),
|
||||
[p(g, 'ec').leaders[0].id]);
|
||||
});
|
||||
void test('concealed foreign-ghola execution does not reveal Ecaz revival cohort history', () => {
|
||||
const g = fixture(true);
|
||||
g.expansions = ['ecaz', 'ix'];
|
||||
g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
|
||||
g.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
const leader = p(g, 'ec').leaders[0];
|
||||
leader.dead = false;
|
||||
leader.deaths = 1;
|
||||
leader.gholaBy = 't';
|
||||
leader.capturedBy = 'h';
|
||||
leader.concealed = { captor: 'h', controller: 't', dead: false, deaths: 1 };
|
||||
const before = viewGame(g, 'ec');
|
||||
leader.dead = true;
|
||||
leader.deaths = 2;
|
||||
delete leader.capturedBy;
|
||||
const after = viewGame(g, 'ec');
|
||||
assert.deepEqual(after.players.find((seat) => seat.id === 'ec')?.leaders,
|
||||
before.players.find((seat) => seat.id === 'ec')?.leaders);
|
||||
assert.deepEqual(after.revival, before.revival);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
newPlayer,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
RuleError,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { baseDeck, treacheryDeck, spiceDeck, type Card } from '../game/cards';
|
||||
@@ -314,10 +315,7 @@ void test('Mirror remains gated at actual plan selection and cannot award income
|
||||
const defense = hold(g, 'owner', (card) => card.kind === 'snooper');
|
||||
const poison = hold(g, 'other', (card) => card.kind === 'poison');
|
||||
const before = reload(g);
|
||||
assert.throws(
|
||||
() => readyBattle(g, mirror.id, defense.id, poison.id),
|
||||
/Choose a weapon/,
|
||||
);
|
||||
assert.throws(() => readyBattle(g, mirror.id, defense.id, poison.id), RuleError);
|
||||
assert.deepEqual(g, before);
|
||||
assert.equal(player(g, 'ec').spice, 20);
|
||||
assert.deepEqual(viewGame(g, 'ec').ecazPoisonIncome, []);
|
||||
|
||||
@@ -5,9 +5,13 @@ import { richeseCards } from '../game/richese-cards';
|
||||
import {
|
||||
battleCardEffects,
|
||||
battleWeaponsExplode,
|
||||
isMirrorWeapon,
|
||||
isWeaponCard,
|
||||
validBattleCardPair,
|
||||
defaultVoiceMatch,
|
||||
playedVoiceMatch,
|
||||
} from '../game/battle-cards';
|
||||
import { strongholdBattleEffects } from '../game/stronghold-battle';
|
||||
|
||||
const cards = [...baseDeck(), ...ixBattleCards(), ...richeseCards()];
|
||||
const get = (kind: Card['kind']) => cards.find((card) => card.kind === kind)!;
|
||||
@@ -137,6 +141,15 @@ void test('copied Lasgun uses the same explosion predicate and only actual shiel
|
||||
assert.equal(battleWeaponsExplode(get('lasgun'), get('shield')), true);
|
||||
});
|
||||
|
||||
void test('Carthag does not grant free Snooper when physical Mirror copies a poison attack', () => {
|
||||
const shield = get('shield');
|
||||
const poison = get('poison');
|
||||
const copied = strongholdBattleEffects(mirror, shield, poison, undefined, true, true, 'carthag');
|
||||
assert.equal(copied.attackerDead, true);
|
||||
const ordinary = strongholdBattleEffects(get('projectile'), shield, poison, undefined, true, true, 'carthag');
|
||||
assert.equal(ordinary.attackerDead, false);
|
||||
});
|
||||
|
||||
void test('empty and Worthless copies have no attack; Stone remains a separate post-reveal outcome', () => {
|
||||
for (const weapon of [undefined, get('worthless'), stone]) {
|
||||
const before = structuredClone([mirror, weapon]);
|
||||
@@ -151,11 +164,16 @@ void test('empty and Worthless copies have no attack; Stone remains a separate p
|
||||
}
|
||||
});
|
||||
|
||||
void test('effective attack support does not activate Mirror plan admission or invent physical cleanup rules', () => {
|
||||
assert.equal(isWeaponCard(mirror), false);
|
||||
assert.equal(validBattleCardPair(mirror, get('weirdingWay')), false);
|
||||
assert.equal(mirror.id, 'richese-mirror-weapon');
|
||||
assert.equal(mirror.kind, 'special');
|
||||
void test('only the canonical physical Mirror is a named weapon in plans and Voice', () => {
|
||||
assert.equal(isMirrorWeapon(mirror), true);
|
||||
assert.equal(isWeaponCard(mirror), true);
|
||||
assert.equal(validBattleCardPair(mirror, get('weirdingWay')), true);
|
||||
assert.equal(defaultVoiceMatch(mirror, 'mirrorWeapon'), true);
|
||||
assert.equal(playedVoiceMatch(mirror, 'weapon', 'mirrorWeapon'), true);
|
||||
const counterfeit = { ...mirror, id: 'forged-mirror' };
|
||||
assert.equal(isMirrorWeapon(counterfeit), false);
|
||||
assert.equal(isWeaponCard(counterfeit), false);
|
||||
assert.equal(validBattleCardPair(counterfeit), false);
|
||||
assert.throws(() => battleCardEffects(mirror, undefined, mirror));
|
||||
assert.throws(() => battleWeaponsExplode(mirror, undefined, mirror));
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import {
|
||||
quoteBattleWeaponRetention,
|
||||
resolveBattleWeapons,
|
||||
type BattleWeaponSide,
|
||||
type EffectiveBattleWeapon,
|
||||
@@ -75,8 +76,8 @@ void test('Stone Burner and Poison Tooth copies require separate choices before
|
||||
const kind = weapon === stone ? 'stoneBurner' : 'poisonTooth';
|
||||
const { result, other } = copied(side, weapon);
|
||||
assert.deepEqual(result.choiceOrder, [
|
||||
{ side, kind },
|
||||
{ side: other, kind },
|
||||
{ side, kind, physicalId: mirror.id, copiedFrom: weapon.id },
|
||||
{ side: other, kind, physicalId: weapon.id, copiedFrom: null },
|
||||
]);
|
||||
assert.equal(result[side].choice, kind);
|
||||
assert.equal(result[other].choice, kind);
|
||||
@@ -102,10 +103,14 @@ void test('ordinary choices retain input order when no copy dependency exists an
|
||||
{
|
||||
side: 'attacker',
|
||||
kind: attacker === stone ? 'stoneBurner' : 'poisonTooth',
|
||||
physicalId: attacker.id,
|
||||
copiedFrom: null,
|
||||
},
|
||||
{
|
||||
side: 'defender',
|
||||
kind: defender === stone ? 'stoneBurner' : 'poisonTooth',
|
||||
physicalId: defender.id,
|
||||
copiedFrom: null,
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -285,3 +290,82 @@ void test('resolution accepts frozen physical inputs and returned descriptors ne
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
void test('physical winner retention ignores the copied attack, including activated Tooth and Artillery', () => {
|
||||
for (const weapon of [
|
||||
card('poisonTooth'),
|
||||
card('artillery'),
|
||||
stone,
|
||||
card('projectile'),
|
||||
undefined,
|
||||
]) {
|
||||
for (const side of sides) {
|
||||
const { result } = copied(side, weapon);
|
||||
assert.equal(result[side].physicalId, mirror.id);
|
||||
assert.equal(result[side].copiedFrom, weapon?.id ?? null);
|
||||
const winner = quoteBattleWeaponRetention({
|
||||
card: mirror,
|
||||
outcome: 'winner',
|
||||
traitorDecided: false,
|
||||
toothUsed: true,
|
||||
});
|
||||
assert.deepEqual(winner, { physicalId: mirror.id, mayRetain: true });
|
||||
assert.deepEqual(
|
||||
quoteBattleWeaponRetention({
|
||||
card: mirror,
|
||||
outcome: 'loser',
|
||||
traitorDecided: false,
|
||||
toothUsed: true,
|
||||
}),
|
||||
{ physicalId: mirror.id, mayRetain: false },
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('original physical Tooth and Artillery retain their existing mandatory disposal exceptions', () => {
|
||||
const quote = (
|
||||
physical: Card,
|
||||
outcome: 'winner' | 'loser',
|
||||
toothUsed: boolean,
|
||||
traitorDecided = false,
|
||||
) =>
|
||||
quoteBattleWeaponRetention({
|
||||
card: physical,
|
||||
outcome,
|
||||
toothUsed,
|
||||
traitorDecided,
|
||||
});
|
||||
const tooth = card('poisonTooth');
|
||||
const artillery = card('artillery');
|
||||
assert.deepEqual(quote(tooth, 'winner', true), {
|
||||
physicalId: tooth.id,
|
||||
mayRetain: false,
|
||||
});
|
||||
assert.equal(quote(tooth, 'winner', false).mayRetain, true);
|
||||
assert.equal(quote(artillery, 'winner', false).mayRetain, false);
|
||||
assert.equal(quote(artillery, 'winner', true, true).mayRetain, true);
|
||||
assert.equal(quote(tooth, 'winner', true, true).mayRetain, true);
|
||||
assert.equal(quote(tooth, 'loser', false, true).mayRetain, false);
|
||||
assert.equal(quote(card('projectile'), 'winner', false).mayRetain, true);
|
||||
assert.equal(quote(card('hero'), 'winner', false, true).mayRetain, false);
|
||||
});
|
||||
|
||||
void test('retention quote refuses forged Mirror identities instead of treating them as generic Specials', () => {
|
||||
for (const forged of [
|
||||
{ ...mirror, id: 'forged-mirror' },
|
||||
{ ...mirror, effect: undefined },
|
||||
{ ...mirror, kind: 'artillery' as const },
|
||||
]) {
|
||||
assert.throws(
|
||||
() =>
|
||||
quoteBattleWeaponRetention({
|
||||
card: forged,
|
||||
outcome: 'winner',
|
||||
traitorDecided: false,
|
||||
toothUsed: false,
|
||||
}),
|
||||
/canonical physical identity/,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { treacheryDeck, type Card } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
|
||||
const mirror = richeseCards().find((card) => card.effect === 'mirrorWeapon')!;
|
||||
function fixture(advanced = false) {
|
||||
let g = createGame('MIRROR', newPlayer('p', 'Mirror holder', 'emperor'), advanced, ['choam']);
|
||||
g.players.push(newPlayer('q', 'Opponent', 'guild'), newPlayer('r', 'Richese', 'richese'));
|
||||
g.status = 'playing';
|
||||
g.phase = 6;
|
||||
g.turn = 2;
|
||||
g.active = 'p';
|
||||
g.order = ['p', 'q', 'r'];
|
||||
g.storm = 18;
|
||||
g.deck = treacheryDeck(['choam']);
|
||||
g.richeseCache = richeseCards().filter((card) => card.id !== mirror.id);
|
||||
for (const player of g.players) {
|
||||
player.hand = [];
|
||||
player.forces = {};
|
||||
player.reserves = 20;
|
||||
player.spice = 20;
|
||||
player.traitors = [];
|
||||
}
|
||||
for (const player of g.players.slice(0, 2)) {
|
||||
player.forces = { 'pasty_mesa:5': 5 };
|
||||
player.reserves = 15;
|
||||
}
|
||||
g.players[0].hand = [mirror];
|
||||
g = applyAction(g, 'p', { type: 'chooseBattle', territory: 'pasty_mesa', target: 'q' });
|
||||
for (const id of ['p', 'q'])
|
||||
g = applyAction(g, id, { type: 'battlePreparationReady', event: g.battle!.event });
|
||||
while (g.battle?.preparation)
|
||||
g = applyAction(g, g.battle.preparation.owner, { type: 'declineBattlePower' });
|
||||
if (g.decision?.kind === 'fullPlanOffer')
|
||||
g = applyAction(g, g.decision.player, { type: 'decision', decline: true });
|
||||
return g;
|
||||
}
|
||||
function give(g: Game, kind: Card['kind'] | 'stoneBurner') {
|
||||
const card = kind === 'stoneBurner'
|
||||
? richeseCards().find((item) => item.effect === 'stoneBurner')!
|
||||
: g.deck.find((item) => item.kind === kind)!;
|
||||
g.players[1].hand.push(card);
|
||||
g.deck = g.deck.filter((item) => item.id !== card.id);
|
||||
g.richeseCache = g.richeseCache?.filter((item) => item.id !== card.id);
|
||||
return card;
|
||||
}
|
||||
function reveal(g: Game, source?: Card, ownDial = 2, otherDial = 2) {
|
||||
g = applyAction(g, 'p', {
|
||||
type: 'battlePlan', dial: ownDial, support: g.advanced ? ownDial : 0,
|
||||
leader: g.players[0].leaders[0].id, weapon: mirror.id,
|
||||
});
|
||||
return applyAction(g, 'q', {
|
||||
type: 'battlePlan', dial: otherDial, support: g.advanced ? otherDial : 0,
|
||||
leader: g.players[1].leaders[0].id, weapon: source?.id,
|
||||
});
|
||||
}
|
||||
function finish(g: Game) {
|
||||
g = applyAction(g, 'q', { type: 'traitorCall', call: false });
|
||||
return applyAction(g, 'p', { type: 'traitorCall', call: false });
|
||||
}
|
||||
|
||||
void test('copied Tooth chooses before its original despite reverse storm order; restored choices keep physical custody', () => {
|
||||
let g = fixture();
|
||||
const tooth = give(g, 'poisonTooth');
|
||||
g.order = ['q', 'p', 'r'];
|
||||
g = reveal(g, tooth, 4, 2);
|
||||
assert.equal(g.decision?.kind, 'poisonTooth');
|
||||
assert.equal(g.decision.player, 'p');
|
||||
const reordered = structuredClone(g);
|
||||
reordered.decision = { kind: 'poisonTooth', player: 'q', event: g.battle!.event,
|
||||
physicalId: tooth.id, copiedFrom: null };
|
||||
assert.throws(() => viewGame(reordered, 'q'));
|
||||
assert.throws(() => applyAction(reordered, 'q', {
|
||||
type: 'decision', event: g.battle!.event, activate: false,
|
||||
}));
|
||||
const preanswered = structuredClone(g);
|
||||
preanswered.battle!.poisonTooth = { q: true };
|
||||
assert.throws(() => viewGame(preanswered, 'p'));
|
||||
assert.deepEqual(reordered.battle, g.battle);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, 'r', { type: 'decision', event: g.battle!.event, activate: true }));
|
||||
assert.deepEqual(g, before);
|
||||
g = JSON.parse(JSON.stringify(g));
|
||||
g = applyAction(g, 'p', { type: 'decision', event: g.battle!.event, activate: true });
|
||||
assert.equal(g.decision?.kind, 'poisonTooth');
|
||||
assert.equal(g.decision.player, 'q');
|
||||
assert.equal(g.battle!.poisonTooth?.p, true);
|
||||
const wrong = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, 'q', { type: 'decision', event: 'stale', activate: true }));
|
||||
assert.deepEqual(g, wrong);
|
||||
g = JSON.parse(JSON.stringify(g));
|
||||
g = applyAction(g, 'q', { type: 'decision', event: g.battle!.event, activate: false });
|
||||
g = finish(g);
|
||||
assert.equal(g.lastBattleContext?.winner, 'p');
|
||||
assert.equal(g.players[0].leaders[0].dead, true);
|
||||
assert.equal(g.players[1].leaders[0].dead, true);
|
||||
assert.equal(g.decision?.kind, 'battleCards');
|
||||
g = applyAction(g, 'p', { type: 'decision', discard: [] });
|
||||
assert.equal(g.players[0].hand.filter((card) => card.id === mirror.id).length, 1);
|
||||
assert.equal(g.discard.filter((card) => card.id === tooth.id).length, 1);
|
||||
});
|
||||
|
||||
void test('copied Stone has two independent modes in copy-first order and the original never becomes a second Mirror', () => {
|
||||
let g = fixture();
|
||||
const stone = give(g, 'stoneBurner');
|
||||
g = reveal(g, stone, 2, 4);
|
||||
assert.equal(g.decision?.kind, 'stoneBurner');
|
||||
assert.equal(g.decision.player, 'p');
|
||||
const reordered = structuredClone(g);
|
||||
reordered.decision = { kind: 'stoneBurner', player: 'q', event: g.battle!.event!,
|
||||
physicalId: stone.id, copiedFrom: null };
|
||||
assert.throws(() => viewGame(reordered, 'q'));
|
||||
const preanswered = structuredClone(g);
|
||||
preanswered.battle!.stoneBurner = { q: 'kill' };
|
||||
assert.throws(() => viewGame(preanswered, 'p'));
|
||||
g = applyAction(g, 'p', { type: 'decision', event: g.battle!.event, mode: 'ignore' });
|
||||
assert.equal(g.decision?.kind, 'stoneBurner');
|
||||
assert.equal(g.decision.player, 'q');
|
||||
g = JSON.parse(JSON.stringify(g));
|
||||
g = applyAction(g, 'q', { type: 'decision', event: g.battle!.event, mode: 'kill' });
|
||||
g = finish(g);
|
||||
assert.equal(g.lastBattleContext?.winner, 'p');
|
||||
assert.equal(g.players[0].leaders[0].dead, true);
|
||||
assert.equal(g.players[1].leaders[0].dead, true);
|
||||
g = applyAction(g, 'p', { type: 'decision', discard: [] });
|
||||
assert.equal(g.players[0].hand.filter((card) => card.id === mirror.id).length, 1);
|
||||
assert.equal(g.discard.filter((card) => card.id === stone.id).length, 1);
|
||||
});
|
||||
|
||||
void test('all bot profiles can commit legal Mirror plans in Basic and Advanced without inspecting hidden weapons', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const g = fixture(advanced);
|
||||
const view = viewGame(g, 'p');
|
||||
view.players[0].bot = difficulty;
|
||||
const before = structuredClone(view);
|
||||
const plans = botActions(view).filter((action) => action.type === 'battlePlan' && action.weapon === mirror.id);
|
||||
assert.ok(plans.length, `${difficulty}/${advanced}`);
|
||||
for (const plan of plans) assert.doesNotThrow(() => applyAction(g, 'p', plan));
|
||||
assert.deepEqual(view, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('inspected ordinary copy is scored as its attack rather than as undialed Stone', () => {
|
||||
const g = fixture(true);
|
||||
const projectile = give(g, 'projectile');
|
||||
const view = viewGame(g, 'p');
|
||||
view.battle!.fullPlanInsight = {
|
||||
target: 'q',
|
||||
plan: { dial: 3, support: 3, leader: g.players[1].leaders[0].id,
|
||||
weapon: projectile.id, defense: null },
|
||||
cards: [projectile],
|
||||
};
|
||||
view.players[0].bot = 'Hard';
|
||||
const candidate = botActions(view).find((action) =>
|
||||
action.type === 'battlePlan' && action.weapon === mirror.id);
|
||||
assert.ok(candidate);
|
||||
assert.ok(Number(candidate.dial) >= 3,
|
||||
'the copied projectile leaves both leaders dead, so the aggressor must dial for the tie');
|
||||
assert.doesNotThrow(() => applyAction(g, 'p', candidate));
|
||||
});
|
||||
|
||||
void test('a saved Mirror commitment rejects a changed public module configuration', () => {
|
||||
const initial = fixture();
|
||||
const sealed = applyAction(initial, 'p', {
|
||||
type: 'battlePlan', dial: 1, leader: initial.players[0].leaders[0].id,
|
||||
weapon: mirror.id,
|
||||
});
|
||||
const altered = structuredClone(sealed);
|
||||
altered.expansions = ['ix'];
|
||||
const before = structuredClone(altered);
|
||||
assert.throws(() => viewGame(altered, 'p'));
|
||||
assert.throws(() => applyAction(altered, 'q', {
|
||||
type: 'battlePlan', dial: 1, leader: altered.players[1].leaders[0].id,
|
||||
}));
|
||||
assert.deepEqual(altered, before);
|
||||
});
|
||||
@@ -1026,11 +1026,11 @@ void test('a dead-leader preparation witness stays private and its Ghola cannot
|
||||
);
|
||||
});
|
||||
|
||||
void test('an admitted shared Duke Ghola outcome binds the shared record outside all native leader arrays', () => {
|
||||
void test('a saved Ecaz Duke Ghola return binds one unclaimed shared record outside native arrays', () => {
|
||||
const g = fixture(4, true, ['ecaz', 'emperor', 'atreides']),
|
||||
card = hold(g, 'p', 'ghola');
|
||||
// Isolated saved-state precondition for the existing controlledLeaders path.
|
||||
// This does not assert how a controlled dead Duke arises in normal battle play.
|
||||
// A legacy retained controller is intentionally present: revival must
|
||||
// release it, even when a consumed-card save interrupts disposal.
|
||||
g.dukeVidal = acquireDuke(createDukeVidal(), 'p', g.turn, 'ecaz');
|
||||
g.dukeVidal.leader.dead = true;
|
||||
g.dukeVidal.leader.deaths = 1;
|
||||
@@ -1040,6 +1040,9 @@ void test('an admitted shared Duke Ghola outcome binds the shared record outside
|
||||
leader: DUKE_VIDAL_ID,
|
||||
});
|
||||
assert.equal(pending.dukeVidal!.leader.dead, false);
|
||||
assert.equal(pending.dukeVidal!.controller, null);
|
||||
assert.equal(pending.dukeVidal!.source, null);
|
||||
assert.equal(pending.dukeVidal!.acquiredTurn, null);
|
||||
assert.deepEqual(done.dukeVidal, pending.dukeVidal);
|
||||
assert.equal(
|
||||
done.players.some((p) => p.leaders.some((l) => l.id === DUKE_VIDAL_ID)),
|
||||
|
||||
@@ -407,32 +407,3 @@ void test('Nullentropy checklist preserves paid-only privacy, real choices, reco
|
||||
);
|
||||
assert.doesNotMatch(JSON.stringify(topic), /https?:\/\//);
|
||||
});
|
||||
|
||||
void test('Richese generated guides distinguish the eight bounded handlers from two inactive faces', () => {
|
||||
const cards = RULE_TOPICS.filter((topic) =>
|
||||
topic.id.startsWith('card-richese-'),
|
||||
);
|
||||
assert.equal(cards.length, 10);
|
||||
const active = new Set([
|
||||
'card-richese-karama',
|
||||
'card-richese-distrans',
|
||||
'card-richese-nullentropy-box',
|
||||
'card-richese-ornithopter',
|
||||
'card-richese-residual-poison',
|
||||
'card-richese-portable-snooper',
|
||||
'card-richese-stone-burner',
|
||||
'card-richese-juice-of-sapho',
|
||||
]);
|
||||
for (const card of cards) {
|
||||
const text = card.steps.join(' ');
|
||||
if (active.has(card.id))
|
||||
assert.doesNotMatch(text, /game actions are not enabled/);
|
||||
else assert.match(text, /game actions are not enabled/);
|
||||
assert.equal(card.coverage, 'Partial');
|
||||
}
|
||||
for (const id of ['richese-cards', 'richese-gift'])
|
||||
assert.match(
|
||||
RULE_TOPICS.find((topic) => topic.id === id)!.steps.join(' '),
|
||||
/Mirror Weapon and Semuta Drug remain unfinished/,
|
||||
);
|
||||
});
|
||||
@@ -1,52 +1,8 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
cardPresentation,
|
||||
richeseCardActionBlock,
|
||||
} from '../game/card-presentation';
|
||||
import { baseDeck, ixDeck, treacheryDeck, type Card } from '../game/cards';
|
||||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||
import { cardPresentation } from '../game/card-presentation';
|
||||
import type { Card } from '../game/cards';
|
||||
import { RICHESE_CARD_DEFINITIONS, richeseCards } from '../game/richese-cards';
|
||||
import { RULE_TOPICS, RULE_CHECKLIST_AREAS } from '../game/reference';
|
||||
|
||||
void test('every Richese physical card presents its complete guide and actual printed category without changing its runtime kind', () => {
|
||||
for (const definition of RICHESE_CARD_DEFINITIONS) {
|
||||
const supplied = { ...definition.card };
|
||||
const original = structuredClone(supplied);
|
||||
const presentation = cardPresentation(supplied);
|
||||
assert.deepEqual(supplied, original);
|
||||
assert.equal(presentation.category, definition.printedType);
|
||||
assert.equal(presentation.guidance, definition.summary);
|
||||
assert.deepEqual(presentation.gameplay, definition.gameplay);
|
||||
assert.equal(
|
||||
presentation.role,
|
||||
definition.behavior.battleCategory === 'weapon'
|
||||
? 'weapon'
|
||||
: definition.behavior.battleCategory === 'poison-defense'
|
||||
? 'defense'
|
||||
: 'utility',
|
||||
);
|
||||
assert.match(
|
||||
presentation.availability!,
|
||||
definition.card.effect === 'stoneBurner'
|
||||
? /Combined allocation timing and Ix timing remain guarded/
|
||||
: /effect integration and verification are incomplete/,
|
||||
);
|
||||
assert.deepEqual(
|
||||
presentation.topics.map((topic) => topic.id),
|
||||
[
|
||||
definition.card.effect === 'juiceOfSapho'
|
||||
? 'juice-of-sapho'
|
||||
: definition.card.effect === 'stoneBurner'
|
||||
? 'stone-burner'
|
||||
: definition.card.effect === 'portableSnooper'
|
||||
? 'portable-snooper'
|
||||
: 'richese-cards',
|
||||
],
|
||||
);
|
||||
assert.equal(supplied.kind, 'special');
|
||||
}
|
||||
});
|
||||
|
||||
void test('presentation does not export source-verification metadata or unverified interaction guesses', () => {
|
||||
for (const definition of RICHESE_CARD_DEFINITIONS) {
|
||||
@@ -93,132 +49,3 @@ void test('missing, altered and merely similar Richese identities receive no can
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('the public collection and ten full card topics are searchable, linked and identify implemented handlers', () => {
|
||||
const ids = new Set(RULE_TOPICS.map((topic) => topic.id));
|
||||
assert.equal(ids.size, RULE_TOPICS.length);
|
||||
const hub = RULE_TOPICS.find((topic) => topic.id === 'richese-cards')!;
|
||||
assert.deepEqual(
|
||||
hub.checklist!.map((item) => item.area),
|
||||
[...RULE_CHECKLIST_AREAS],
|
||||
);
|
||||
assert.equal(hub.coverage, 'Partial');
|
||||
assert.equal(
|
||||
hub.checklist!.find((item) => item.area === 'AI')!.status,
|
||||
'Partial',
|
||||
);
|
||||
assert.match(
|
||||
hub.steps.join(' '),
|
||||
/not the contents of any player’s current cache or hand/,
|
||||
);
|
||||
assert.match(
|
||||
hub.steps.join(' '),
|
||||
/Mirror Weapon and Semuta Drug remain unfinished/,
|
||||
);
|
||||
assert.match(hub.steps.join(' '), /Discard contents remain private/);
|
||||
for (const definition of RICHESE_CARD_DEFINITIONS) {
|
||||
const id = `card-${definition.card.id}`;
|
||||
const topic = RULE_TOPICS.find((candidate) => candidate.id === id)!;
|
||||
assert.ok(hub.related!.includes(id));
|
||||
assert.equal(topic.coverage, 'Partial');
|
||||
assert.ok(topic.summary.includes(definition.printedType));
|
||||
assert.deepEqual(topic.steps.slice(0, -1), definition.gameplay);
|
||||
assert.match(
|
||||
topic.steps.at(-1)!,
|
||||
definition.card.effect === 'juiceOfSapho'
|
||||
? /Juice of Sapho panel/
|
||||
: definition.card.effect === 'karama'
|
||||
? /existing generic handler/
|
||||
: definition.card.effect === 'distrans'
|
||||
? /Distrans transfer panel/
|
||||
: definition.card.effect === 'nullentropyBox'
|
||||
? /Nullentropy Box panel/
|
||||
: definition.card.effect === 'ornithopter'
|
||||
? /Ornithopter movement controls/
|
||||
: definition.card.effect === 'residualPoison'
|
||||
? /Residual Poison panel/
|
||||
: definition.card.effect === 'portableSnooper'
|
||||
? /Portable Snooper/
|
||||
: definition.card.effect === 'stoneBurner'
|
||||
? /Stone Burner/
|
||||
: /actions are not enabled/,
|
||||
);
|
||||
for (const related of topic.related!) assert.ok(ids.has(related));
|
||||
assert.doesNotMatch(JSON.stringify(topic), /https?:\/\//);
|
||||
}
|
||||
for (const id of ['implementation-checklist', 'choam-modules'])
|
||||
assert.ok(
|
||||
RULE_TOPICS.find((topic) => topic.id === id)!.related!.includes(
|
||||
'richese-cards',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
void test('base, Ix and Ecaz guides remain separate from Richese replacement-card guidance', () => {
|
||||
for (const card of [...baseDeck(), ...ixDeck()]) {
|
||||
const presentation = cardPresentation(card);
|
||||
assert.equal(presentation.availability, undefined);
|
||||
assert.equal(presentation.gameplay, undefined);
|
||||
assert.equal(
|
||||
presentation.topics.some((topic) => topic.id === 'richese-cards'),
|
||||
false,
|
||||
);
|
||||
}
|
||||
for (const card of ecazTreacheryCards()) {
|
||||
const presentation = cardPresentation(card);
|
||||
assert.equal(presentation.category, 'Special treachery');
|
||||
assert.ok(presentation.gameplay!.length >= 4);
|
||||
assert.match(presentation.availability ?? '', card.effect === 'recruits'
|
||||
? /development controls during Revival/ : card.effect === 'harassWithdraw'
|
||||
? /development controls in either battle-card slot/ : /battle effect is still being implemented/);
|
||||
assert.deepEqual(presentation.topics.map((topic) => topic.id), [card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : 'card-recruits']);
|
||||
}
|
||||
assert.equal(treacheryDeck().length, 33);
|
||||
assert.equal(treacheryDeck(['ix']).length, 47);
|
||||
assert.equal(treacheryDeck(['choam']).length, 35);
|
||||
assert.throws(() => treacheryDeck(['richese']), /not implemented/);
|
||||
});
|
||||
|
||||
void test('unsupported canonical Richese effects have an explicit action block while Karama and unrelated cards retain their validators', () => {
|
||||
for (const card of richeseCards()) {
|
||||
if (card.effect === 'karama')
|
||||
assert.equal(richeseCardActionBlock(card), null);
|
||||
else if (card.effect === 'portableSnooper')
|
||||
assert.match(richeseCardActionBlock(card)!, /late-defense panel/);
|
||||
else if (card.effect === 'stoneBurner')
|
||||
assert.match(richeseCardActionBlock(card)!, /battle weapon slot/);
|
||||
else if (card.effect === 'residualPoison')
|
||||
assert.match(richeseCardActionBlock(card)!, /Residual Poison panel/);
|
||||
else if (card.effect === 'ornithopter')
|
||||
assert.match(
|
||||
richeseCardActionBlock(card)!,
|
||||
/Ornithopter movement controls/,
|
||||
);
|
||||
else if (card.effect === 'nullentropyBox')
|
||||
assert.match(richeseCardActionBlock(card)!, /Nullentropy Box panel/);
|
||||
else if (card.effect === 'distrans')
|
||||
assert.match(richeseCardActionBlock(card)!, /Distrans transfer panel/);
|
||||
else if (card.effect === 'juiceOfSapho')
|
||||
assert.match(richeseCardActionBlock(card)!, /Juice of Sapho panel/);
|
||||
else
|
||||
assert.match(
|
||||
richeseCardActionBlock(card)!,
|
||||
/effect is not implemented yet/,
|
||||
);
|
||||
assert.equal(
|
||||
richeseCardActionBlock({ ...card, id: 'another-physical-card' }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
richeseCardActionBlock({ ...card, name: 'Another name' }),
|
||||
null,
|
||||
);
|
||||
assert.equal(richeseCardActionBlock({ ...card, kind: 'worthless' }), null);
|
||||
assert.equal(
|
||||
richeseCardActionBlock({ ...card, effect: 'another-effect' }),
|
||||
null,
|
||||
);
|
||||
}
|
||||
for (const card of [...baseDeck(), ...ixDeck(), ...ecazTreacheryCards()])
|
||||
assert.equal(richeseCardActionBlock(card), null);
|
||||
});
|
||||
@@ -225,6 +225,40 @@ void test('public zero-spice compulsion guard is independent of Stone possession
|
||||
assert.ok(plan(g, 'r', { weapon: null, dial: 0 }).battle!.plans.r);
|
||||
});
|
||||
|
||||
void test('required Mirror Voice rejects unfinishable public zero-spice plans without testing card possession', () => {
|
||||
let g = fixture();
|
||||
g.advanced = true;
|
||||
g.richeseCache!.push(g.players[0].hand.pop()!);
|
||||
hold(g, 'r', 'Mirror Weapon');
|
||||
g.players[0].forces = { 'arrakeen:10': 1 };
|
||||
g.players[0].reserves = 19;
|
||||
g.players[0].spice = 0;
|
||||
g.players[1].faction = 'emperor';
|
||||
g.players[1].leaders = leaders('emperor');
|
||||
g.players[1].forces = { 'arrakeen:10': 6 };
|
||||
g.players[1].reserves = 14;
|
||||
g.players[1].elites = {
|
||||
forces: { 'arrakeen:10': 1 }, reserves: 4, tanks: 0, revived: 0,
|
||||
};
|
||||
g.players[2] = newPlayer('e', 'Bene Gesserit', 'beneGesserit');
|
||||
g.players[2].ally = 'g';
|
||||
g.players[1].ally = 'e';
|
||||
g = start(g);
|
||||
const withoutMirror = reload(g);
|
||||
withoutMirror.richeseCache!.push(withoutMirror.players[0].hand.pop()!);
|
||||
for (const state of [g, withoutMirror]) {
|
||||
const before = structuredClone(state);
|
||||
assert.throws(() => send(state, 'e', {
|
||||
type: 'voice', kind: 'mirrorWeapon', must: true,
|
||||
}));
|
||||
assert.deepEqual(state, before);
|
||||
}
|
||||
g = send(g, 'e', { type: 'declineBattlePower' });
|
||||
for (const id of ['r', 'g'])
|
||||
g = send(g, id, { type: 'battlePreparationReady', event: g.battle!.event });
|
||||
assert.ok(plan(g, 'r', { weapon: null, dial: 0 }).battle!.plans.r);
|
||||
});
|
||||
|
||||
void test('foreign No-Field values produce identical public Stone preflight context and accept the same own commitment', () => {
|
||||
const contexts: unknown[] = [];
|
||||
for (const value of [0, 3, 5]) {
|
||||
|
||||
Reference in new issue
Block a user