Compose native Richese No-Field and Stone with Advanced Occupy
This commit is contained in:
1 parent
03c77604de
commit
113667da55
23 files changed
+985
-246
No files matched your search
@@ -578,6 +578,7 @@ export function GameTable({
|
||||
g.battle!.tieWinner === g.battle!.attacker
|
||||
? 'attacker'
|
||||
: 'defender',
|
||||
stoneContext.occupy,
|
||||
),
|
||||
)
|
||||
.find((reason) => reason !== null) ?? null)));
|
||||
|
||||
@@ -45,14 +45,25 @@ support this composition; it is not a new expansion or timing ruling. Earlier
|
||||
phases, other future actions, expansion shipment modes and arbitrary prose
|
||||
enforcement remain outside the bounded implementation.
|
||||
|
||||
Twelve actual programs cover mixed truth/skip/shipment/changed-spice, all four
|
||||
legal policies and a second joint pure+mixed promise. The affected
|
||||
truth/ship/discard rule batch passes88/88; mixed/reference23/23, types/lint and
|
||||
Eight actual programs cover the four mixed truth/skip/shipment/changed-spice
|
||||
paths and all four legal policies. Nested and joint promises have rule
|
||||
regressions. The affected truth/ship/discard rule batch passes88/88;
|
||||
mixed/reference23/23, types/lint and
|
||||
build pass. Human390px **4PVKRRWB v10** authored and answered the actual OR
|
||||
claim, then spent all20 spice as a bribe, skipped shipment and fulfilled the
|
||||
promise while the public answer stayed Yes. This is bounded local evidence, not
|
||||
complete Truthtrance, module or deployed acceptance.
|
||||
|
||||
The later390px **FCGZTEGV v11** control check authored an outer OR around
|
||||
`(current spice >=10 AND ship4 to Arrakeen)`, with an alternative shipment6
|
||||
to Carthag. The respondent answered Yes at20 spice, paid a real12-spice bribe,
|
||||
then shipped4 to Arrakeen at a four-spice cost. The frozen nested branch
|
||||
fulfilled the whole answer with only4 current spice; native end-movement
|
||||
continued the original turn and the exact grouped question/Yes survived refresh.
|
||||
This is additional human control evidence, not another program case or an
|
||||
expanded timing/module claim.
|
||||
|
||||
|
||||
|
||||
## Meaning and authority
|
||||
|
||||
|
||||
+30
-8
@@ -16,6 +16,27 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Native Richese Occupy — bounded Development Verified:**
|
||||
Richese-present Advanced tables now resolve their mandatory Ecaz coalitions
|
||||
instead of rejecting unrelated native factions. Ordinary or marker-only Richese
|
||||
pools retain the selected plan owner, original force owners and concealed
|
||||
0/3/5 reveal timing. Stone/Mirror compare actual undialed physical counters,
|
||||
including the fixed Ecaz remainder; cancellation uses only the selected own pool.
|
||||
The original same-owner mixed No-Field, whole-plan disclosure, Ix Stone/Tooth,
|
||||
Harass/Stone and optional-module guards remain. Basic/public starts are unchanged.
|
||||
Affected363/363, types/lint, build and26 actual native programs pass.
|
||||
The previously failed genuine six-native Advanced sample finishes2424 accepted
|
||||
actions/no rejection/65 JSON continuations. Fresh CLI **GQ4FEWNW v8** and
|
||||
human390px **T78EFNZK v22** exercise original setup, private combined total5/
|
||||
support2, No-Field5 materializing only two reserves, actual owner losses and
|
||||
native CHOAM income/Collection. See
|
||||
[native Richese composition](ECAZ_OCCUPY_RULES.md#native-richese-no-field-and-stone-composition--3-october-2026).
|
||||
The final guide is readable at390px; the original human v22 state remains at
|
||||
Collection after the required isolated worker reload. A stale prior-build guide
|
||||
chunk required a cache-free local navigation; deployed cache-upgrade acceptance
|
||||
is not claimed.
|
||||
|
||||
|
||||
**Mixed current-fact/shipment Truthtrance — bounded Development Verified:**
|
||||
structured questions now combine supported current facts with this-turn reserve
|
||||
shipments in bounded nested AND/OR trees. Current facts freeze privately at a
|
||||
@@ -26,10 +47,10 @@ only the respondent receives the compiled fixed answers.
|
||||
Two bounded owners supplied the pure expression logic and nested authoring
|
||||
controls; one integration owner wired the question, private projection,
|
||||
promise receipt, public redaction, policies and existing continuation.
|
||||
Types/lint, affected88/88, mixed/reference23/23 and build pass. Twelve actual
|
||||
program cases cover truth/skip/shipment/changed-spice, all four policies and a
|
||||
second joint promise. Human390px **4PVKRRWB v10** authored and answered the
|
||||
actual OR question, then spent all20 spice as a bribe, skipped shipment and
|
||||
Types/lint, affected88/88, mixed/reference23/23 and build pass. Eight actual
|
||||
program cases cover the four truth/skip/shipment/changed-spice paths and all four
|
||||
policies; nested and joint promises have rule regressions. Human390px **4PVKRRWB v10**
|
||||
authored and answered the actual OR question, then spent all20 spice as a bribe, skipped shipment and
|
||||
fulfilled the promise while its public answer stayed Yes. Earlier phases, other
|
||||
future actions and existing module gates remain unfinished. See
|
||||
[mixed compounds](COMPOUND_SHIPMENT_PROMISES.md#mixed-current-facts-and-future-shipment--3-october-2026).
|
||||
@@ -38,8 +59,8 @@ Code **cd985c7c34a05057ed98b34838effdbcddb7697d** is pushed.
|
||||
job111031663449, completed/success. Existing isolated storage/HTTP verification
|
||||
and verified-image publication pass:
|
||||
`ghcr.io/quintine/dune:sha-cd985c7c34a05057ed98b34838effdbcddb7697d`.
|
||||
The full offline suite also keeps one pre-existing combined-Occupy sample
|
||||
failure (reproduced at 0ceb28b), documented as an open bot-offer defect.
|
||||
The prior full offline run exposed a pre-existing native combined-Occupy
|
||||
integration failure, not an illegal bot offer; native Richese composition now repairs it.
|
||||
No deployment or new assurance campaign; live **ab5c782** remains last observed.
|
||||
|
||||
**Requested priority — 2 October 2026:** throughput improvements are verified
|
||||
@@ -978,8 +999,9 @@ 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,
|
||||
classic/CHOAM/Richese battles and source-clear Advanced Ecaz/Moritani
|
||||
composition without optional overlays. 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
|
||||
|
||||
@@ -153,6 +153,56 @@ returned Ecaz2 reserves, sent original fixed3 to Tanks and left Guild2.
|
||||
real equal-leader comparison, paid three Ecaz reserves, left Ecaz2/Guild2 and
|
||||
sent six total Ecaz counters to Tanks. Both reach original Collection.
|
||||
|
||||
### Native Richese, No-Field and Stone composition — 3 October 2026
|
||||
|
||||
Advanced native tables now admit Richese alongside the already supported
|
||||
families. The actual six-native failure was Ecaz+Ixians versus CHOAM at
|
||||
Carthag with Richese elsewhere, not a Richese opponent or an illegal bot offer.
|
||||
The roster-wide guard and fresh-label-only runtime restriction prevented a
|
||||
mandatory source-clear battle. Native composition now selects the real lead
|
||||
and original force owners; it does not suppress that battle or fall back to
|
||||
an ordinary one-army plan. Optional overlays remain guarded.
|
||||
|
||||
The supplied Advanced PDF **physical p36, Richese No-Field B** says a concealed
|
||||
0/3/5 marker counts as one force for all effects until reveal, materializes
|
||||
only available reserves, and reveals with Battle Plans. A marker-only Richese
|
||||
ally therefore remains original battle presence even with a zero prospective
|
||||
pool. The selected plan actor receives its private variable pool; rivals do
|
||||
not receive that pool or an Occupy profile containing the secret denomination.
|
||||
The physical Richese owner reveals once after both seals. Dial prescience and
|
||||
whole-plan restrictions follow the actual variable-force owner rather than the
|
||||
original chooser. Preventing Occupy with Ecaz lead makes only Ecaz's own pool
|
||||
inspectable; the unrelated marker stays concealed until original reveal timing.
|
||||
Richese's own ordinary-plus-marker dialing and broader disclosure rulings remain.
|
||||
|
||||
Stone Burner compares **floor(E/2) + native ally physical undialed tokens** for
|
||||
active Occupy, or the **selected lead's own pool only** after prevention.
|
||||
Its total dial offset is removed before native typed casualty enumeration.
|
||||
Mirror copies use the same comparison and original physical-card retention.
|
||||
Public opposing No-Field preflight enumerates possible native pools, never the
|
||||
actual secret denomination. Ix Stone/Poison Tooth timing, Harass/Stone and
|
||||
genuinely winner-changing typed allocations retain their existing guards.
|
||||
Native Ecaz/Moritani Mirror admission is Advanced only; Basic is unchanged.
|
||||
|
||||
The affected rule batch passes **363/363**, with types/lint and build passing.
|
||||
**26 actual native programs** cover ten ordinary/marker owner cases, eight
|
||||
Stone/Mirror active/prevented lead cases and eight four-policy concealed-pool
|
||||
cases, with original Collection and JSON continuation. These are genuine
|
||||
setup followed by explicitly conserved board/card/bank staging, not unstaged
|
||||
games. All four policies remain minimal legal participants; strategy is deferred.
|
||||
The formerly stalled genuine Advanced combined seed20260932 now completes
|
||||
**2,424 accepted actions, no rejection and65 JSON continuations**, at turn9.
|
||||
|
||||
Original CLI **GQ4FEWNW v8** and human **T78EFNZK v8** admitted fresh native
|
||||
setups with real authenticated seats. Human390px **T78EFNZK v22** selected
|
||||
Ecaz lead and sealed total5/support2 with Sanya Ecaz. Both seals revealed the
|
||||
original No-Field5 into only two available Richese reserves; settlement left
|
||||
Ecaz2, Ecaz Tanks3 and Richese Tanks2. Native CHOAM received its original
|
||||
one-spice support income, and original Collection returned Ecaz's wallet to30.
|
||||
Refreshing retained the actual continuation. No reset, re-deal, public start,
|
||||
deployment or new comprehensive assurance campaign.
|
||||
|
||||
|
||||
### Encounter evidence correction — 2 October 2026
|
||||
|
||||
Earlier “no combined lead choice” statements were incorrect: the compact
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 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.
|
||||
Physical plan IDs, Atreides inspection, Truthtrance commitments, custody, winning card retention and Moritani retention remain physical. Canonical Mirror supports CHOAM/Richese-deck classic/CHOAM/Richese Basic/Advanced rosters and source-clear Advanced native Ecaz/Moritani composition without optional overlays. It rejects unsupported setups before commitment. Original and copied Stone compare native physical undialed counters, including the separate fixed Ecaz remainder; public hypothetical No-Field pools never become the actual secret denomination. Ix Stone/Tooth timing and genuinely winner-changing typed allocations remain guarded.
|
||||
|
||||
## Evidence and boundaries
|
||||
|
||||
|
||||
@@ -1,5 +1,59 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 3 October 2026 — native Richese, No-Field and Stone Occupy composition
|
||||
|
||||
Two bounded owners implemented physical Stone normalization and native Richese
|
||||
fixtures/zero-marker metadata. One integration owner composed admission,
|
||||
selected-plan inspection, original physical revelation, private legal pools,
|
||||
controls, four-policy filtering and internal guidance.
|
||||
|
||||
The pre-existing combined sample failure was a mandatory Ecaz+Ixians versus
|
||||
CHOAM Carthag battle rejected because Richese was elsewhere at the table.
|
||||
Native composition replaces that blanket guard, not the battle itself. Original
|
||||
Richese reveal ownership persists even when Ecaz supplies the plan; zero markers
|
||||
remain original presence through response/Traitor validation and public reveal.
|
||||
Stone preflight/resolution subtract the fixed dial offset and count the separate
|
||||
physical Ecaz remainder; public hypothetical pools remain authoritative.
|
||||
Advanced native Mirror uses the same comparison and original physical cleanup.
|
||||
Basic and existing unresolved timing/disclosure/optional-module guards remain.
|
||||
|
||||
Affected363/363 and types/lint/build pass. Twenty-six actual native programs
|
||||
cover ten owner/marker cases, eight Stone/Mirror lead/cancellation cases and eight
|
||||
four-policy private-pool cases, reaching original Collection after JSON reload.
|
||||
Fixtures are explicitly controlled conserved board/card/bank positions after
|
||||
genuine setup. The actual combined seed20260932 completes2424 accepted actions,
|
||||
zero rejection and65 JSON continuations at turn9, versus the prior2353-action
|
||||
stall. No new whole-suite/HTTP/storage assurance wave was run.
|
||||
|
||||
Original CLI **GQ4FEWNW v8** admits a fresh ready four-seat Advanced
|
||||
Ecaz/Richese/Guild/CHOAM setup. Human390px **T78EFNZK v22** uses the same original seat identities and
|
||||
fresh admitted setup before a conserved Carthag position. Actual Ecaz lead/
|
||||
Sanya Ecaz/total5/support2 controls seal against Guild Representative. Both
|
||||
plans reveal the original No-Field5 into only two Richese reserves. Native
|
||||
Traitor decline, CHOAM one-spice support income and closing-market continuation
|
||||
leave Ecaz2/Tanks3, Richese Tanks2 and original Collection/Ecaz wallet30 after
|
||||
refresh. The physical field, variable losses and payer remain distinct.
|
||||
|
||||
Corrected the previous mixed Truthtrance log: eight actual programs, not twelve;
|
||||
nested/joint cases are regressions, and its phase/positions were explicitly
|
||||
staged after genuine setup. Deleted obsolete presentation/wording/wiring pins
|
||||
instead of re-pinning changed labels. No reset, public start or deployed claim.
|
||||
|
||||
Final native/reference76/76 passes. The new fixture now explicitly declines
|
||||
an unrelated optional Face Dance rather than depending on randomized native
|
||||
identity draws. Final guide paragraphs are readable at390px after the required
|
||||
isolated worker reload; original **T78EFNZK v22** remains at Collection with
|
||||
the same relevant owner losses. The old guide chunk was cached in the managed
|
||||
browser, so final guide navigation disabled that prior-build cache; production
|
||||
cache-upgrade acceptance remains open.
|
||||
|
||||
Additional mixed controls **FCGZTEGV v11** actually authored outer OR with an
|
||||
inner current-spice>=10/Arrakeen4 AND and alternative Carthag6. Yes at20 spice
|
||||
survived a real12-spice bribe, four-force/four-spice Arrakeen shipment and native
|
||||
end-movement with wallet4. The promise is fulfilled and exact grouped Yes history
|
||||
survives refresh. This is a second human journey, not another program case.
|
||||
|
||||
|
||||
## 3 October 2026 — mixed current-fact/shipment Truthtrance
|
||||
|
||||
Two bounded owners supplied the public mixed tree/compiler/matcher (including
|
||||
@@ -23,11 +77,12 @@ mixed promise as if it must ship; and two workspace type errors
|
||||
shipment-only promises retain their original behavior.
|
||||
|
||||
Types/lint, the affected truth/ship/discard batch88/88, mixed/reference23/23 and
|
||||
build pass. Twelve actual programs cover OR-true-skip, AND-false-ship,
|
||||
AND-true-required, OR-false-ship, a nested branch after spending, all four legal
|
||||
policies and a second joint pure+mixed promise. Fixtures use genuine base setup
|
||||
and conserved rule-unit movement positions; no staged phase or promise was
|
||||
fabricated.
|
||||
build pass. Eight actual programs cover OR-true-skip, AND-false-ship,
|
||||
AND-true-required, OR-false-ship and all four legal policies. Nested branches
|
||||
after spending and a second joint pure+mixed promise have rule regressions.
|
||||
Fixtures use genuine base setup followed by explicitly staged turn-two
|
||||
Shipment and Movement, conserved positions and wallets; accepted native
|
||||
Truthtrance actions create the promises.
|
||||
|
||||
Human390px **4PVKRRWB v10** authored the actual "hold at least10 spice OR ship4
|
||||
to Arrakeen" question, answered Yes, spent all20 spice as a public bribe,
|
||||
@@ -36,12 +91,13 @@ refresh retained it. Earlier phases, Advanced Guild/other modules, expansion
|
||||
shipment modes, other future actions and arbitrary prose enforcement remain
|
||||
unfinished. No new assurance campaign, public start or old-game retrofit.
|
||||
|
||||
The full offline suite still reports one pre-existing failure unrelated to this
|
||||
wave: the `combined` Advanced CLI sample stalls at phase6 when a bot offers an
|
||||
Advanced combined-Occupy battle against a Richese/No-Field seat that the engine
|
||||
correctly rejects. The same 2353-action failure reproduces at the prior pushed
|
||||
checkpoint 0ceb28b, so this wave neither introduced nor hid it. It remains an
|
||||
open sample/bot-offer defect for a later combined-Occupy slice.
|
||||
The prior full offline run exposed a pre-existing failure unrelated to this
|
||||
wave: the `combined` Advanced CLI sample stalled at phase6 because the engine's
|
||||
roster guard rejected Ecaz+Ixians versus CHOAM at Carthag merely with Richese
|
||||
elsewhere at the table. The same2353-action failure reproduced at0ceb28b.
|
||||
This was missing native Occupy composition, not an illegal bot offer, a
|
||||
Richese opponent or a new ruling question. The later Richese checkpoint
|
||||
repairs it without suppressing the mandatory battle.
|
||||
|
||||
Code **cd985c7c34a05057ed98b34838effdbcddb7697d** is pushed.
|
||||
[Exact CI37065338944](https://github.com/Quintine/Dune/actions/runs/37065338944)
|
||||
|
||||
@@ -444,6 +444,10 @@ 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 stoneOccupy = hasStone && input.ecazOccupy ? {
|
||||
fixedEcazDial: input.ecazOccupy.fixedEcazDial,
|
||||
ecazUndialed: input.ecazOccupy.canceled ? 0 : Math.floor(input.ecazOccupy.ecazForces.normal / 2),
|
||||
} : undefined;
|
||||
const stone = hasStone
|
||||
? stoneBurnerComparison(
|
||||
a.forces,
|
||||
@@ -453,6 +457,10 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
d.plan.dial,
|
||||
d.plan.support,
|
||||
tie,
|
||||
input.ecazOccupy ? {
|
||||
attacker: input.ecazOccupy.lead === a.id ? stoneOccupy : undefined,
|
||||
defender: input.ecazOccupy.lead === d.id ? stoneOccupy : undefined,
|
||||
} : undefined,
|
||||
)
|
||||
: null;
|
||||
if (stone) {
|
||||
|
||||
@@ -963,6 +963,7 @@ function plans(g: GameView): Action[] {
|
||||
pool,
|
||||
b.attacker === me.id ? 'attacker' : 'defender',
|
||||
b.tieWinner === b.attacker ? 'attacker' : 'defender',
|
||||
b.stoneBurnerContext?.occupy,
|
||||
),
|
||||
))
|
||||
)
|
||||
|
||||
@@ -143,7 +143,8 @@ function calculate(
|
||||
const profile = quoteEcazOccupyBattle({
|
||||
battleOrderActor: occupy.battleOrderActor,
|
||||
ecaz: { id: ecaz.id, faction: 'ecaz', ally: ecaz.ally, forces: occupy.ecazForces },
|
||||
ally: { id: ally.id, faction: ally.faction, ally: ally.ally, forces: occupy.allyForces },
|
||||
ally: { id: ally.id, faction: ally.faction, ally: ally.ally, forces: occupy.allyForces,
|
||||
...(occupy.allyNoFieldPresence ? { noFieldPresence: true as const } : {}) },
|
||||
lead: occupy.lead, canceled: occupy.canceled,
|
||||
});
|
||||
requireCombat(profile.forceOwner === occupy.forceOwner, 'Occupy lost its actual physical force owner.');
|
||||
|
||||
@@ -22,6 +22,9 @@ export type EcazOccupyBattleSeat = Readonly<{
|
||||
faction: FactionId;
|
||||
ally?: string | null;
|
||||
forces: CombatForces;
|
||||
/** Original concealed Richese marker: one battle presence even when the
|
||||
* reserve-limited prospective pool is zero. Never ordinary zero fighters. */
|
||||
noFieldPresence?: true;
|
||||
}>;
|
||||
export type EcazOccupyBattleInput = Readonly<{
|
||||
/** Original battle chooser, before Ecaz selects the coalition's plan actor. */
|
||||
@@ -42,6 +45,7 @@ export type EcazOccupyBattleProfile = Readonly<{
|
||||
canceled: boolean;
|
||||
ecazForces: CombatForces;
|
||||
allyForces: CombatForces;
|
||||
allyNoFieldPresence?: true;
|
||||
/** Only the variable dial's physical counters, never a merged army. */
|
||||
forces: CombatForces;
|
||||
fixedEcazDial: number;
|
||||
@@ -103,10 +107,16 @@ export function quoteEcazOccupyBattle(
|
||||
validCombatForces(ecaz.forces) && validCombatForces(ally.forces),
|
||||
'An Occupy battle needs valid native physical fighter pools.',
|
||||
);
|
||||
requireBattle(
|
||||
ecaz.noFieldPresence === undefined &&
|
||||
(ally.noFieldPresence === undefined ||
|
||||
ally.noFieldPresence === true && ally.faction === 'richese'),
|
||||
'Only an original concealed Richese ally may supply No-Field battle presence.',
|
||||
);
|
||||
requireBattle(
|
||||
ecaz.forces.elite === 0 && !ecaz.forces.temporaryElite &&
|
||||
!ecaz.forces.normalFixedHalf && ecaz.forces.normal > 0 &&
|
||||
ally.forces.normal + ally.forces.elite > 0,
|
||||
(ally.forces.normal + ally.forces.elite > 0 || ally.noFieldPresence === true),
|
||||
'Both Occupy members need storm-connected fighters; Ecaz uses ordinary counters, not advisors or an elite pool.',
|
||||
);
|
||||
requireBattle(
|
||||
@@ -135,6 +145,7 @@ export function quoteEcazOccupyBattle(
|
||||
canceled,
|
||||
ecazForces: ecaz.forces,
|
||||
allyForces: ally.forces,
|
||||
...(ally.noFieldPresence ? { allyNoFieldPresence: true as const } : {}),
|
||||
forces,
|
||||
fixedEcazDial,
|
||||
maxDial: fixedEcazDial + maxCombatDial(forces),
|
||||
|
||||
+72
-41
@@ -357,7 +357,7 @@ import {
|
||||
auditCount,
|
||||
sampleAuditCards,
|
||||
} from './choam-auditor';
|
||||
import { stoneBurnerPlanBlock } from './stone-burner';
|
||||
import { stoneBurnerPlanBlock, type StoneBurnerContext } from './stone-burner';
|
||||
import {
|
||||
STRONGHOLD_CARDS,
|
||||
createStrongholdCards,
|
||||
@@ -3598,7 +3598,7 @@ function nexusAtreidesOffer(g: Game, id: string) {
|
||||
const target = b && (b.attacker === id ? b.defender : b.attacker);
|
||||
const fields = mode === 'betrayal' ? [] : (['leader', 'weapon', 'defense', 'dial'] as PlanField[])
|
||||
.filter(field => !(mode === 'cunning' && (field === b?.prescience?.field ||
|
||||
(field === 'dial' && target && b?.noFieldPlayers?.includes(target)))));
|
||||
(field === 'dial' && target && battleNoFieldPlan(g, target)))));
|
||||
return { event: b?.event ?? '', mode, fields, blocked };
|
||||
}
|
||||
function finishInspectionAnswers(g: Game) {
|
||||
@@ -7307,6 +7307,29 @@ function combatForces(
|
||||
(p.faction === 'fremen' && !g.battle?.fremenSupportBlocked),
|
||||
};
|
||||
}
|
||||
function occupyArmiesConnected(g: Game, ecaz: Player, ally: Player, t: string): boolean {
|
||||
const marker = ally.noField?.deployed;
|
||||
for (const x in ecaz.forces) {
|
||||
if (ecaz.forces[x] <= 0 || splitLocation(x).territory !== t) continue;
|
||||
for (const y in ally.forces)
|
||||
if (ally.forces[y] > 0 && splitLocation(y).territory === t &&
|
||||
gameDistance(g, x, y, key => splitLocation(key).sector === g.storm) === 0) return true;
|
||||
if (marker?.location.territory === t &&
|
||||
gameDistance(g, x, location(t, marker.location.sector), key => splitLocation(key).sector === g.storm) === 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** Inspection follows the selected plan actor; reveal still belongs to the physical marker owner. */
|
||||
function battleNoFieldPlan(g: Game, id: string): boolean {
|
||||
const b = g.battle, occupy = b?.ecazOccupy;
|
||||
const owner = occupy?.lead === id ? occupy.canceled ? id : occupy.ally : id;
|
||||
return !!b?.noFieldPlayers?.includes(owner);
|
||||
}
|
||||
function battleConcealedForces(g: Game, id: string): boolean {
|
||||
const b = g.battle, occupy = b?.ecazOccupy;
|
||||
const owner = occupy?.lead === id ? occupy.canceled ? id : occupy.ally : id;
|
||||
return !!b && getPlayer(g, owner).noField?.deployed?.location.territory === b.territory;
|
||||
}
|
||||
function ecazBattleProfile(g: Game): EcazOccupyBattleProfile | null {
|
||||
const b = g.battle, occupy = b?.ecazOccupy;
|
||||
if (!b || !occupy?.lead) return null;
|
||||
@@ -7315,15 +7338,17 @@ function ecazBattleProfile(g: Game): EcazOccupyBattleProfile | null {
|
||||
requireRule(g.advanced && occupy.event === b.event && ecaz.faction === 'ecaz' &&
|
||||
ecaz.ally === ally.id && ally.ally === ecaz.id && [b.attacker, b.defender].includes(occupy.lead) &&
|
||||
!isAdvisor(ecaz, b.territory) && !isAdvisor(ally, b.territory) &&
|
||||
Object.entries(ecaz.forces).some(([x, n]) => n > 0 && splitLocation(x).territory === b.territory &&
|
||||
Object.entries(ally.forces).some(([y, m]) => m > 0 && splitLocation(y).territory === b.territory &&
|
||||
gameDistance(g, x, y, key => splitLocation(key).sector === g.storm) === 0)),
|
||||
(occupyArmiesConnected(g, ecaz, ally, b.territory) ||
|
||||
(b.revealed && b.noFieldPlayers?.includes(ally.id) && fighterCount(ecaz, b.territory) > 0 &&
|
||||
fighterCount(ally, b.territory) === 0)),
|
||||
'Occupy needs the original battle and physically connected allied fighters.');
|
||||
const opponent = getPlayer(g, b.attacker === occupy.lead ? b.defender : b.attacker);
|
||||
return nexusRule(() => quoteEcazOccupyBattle({
|
||||
battleOrderActor: b.chooser ?? b.attacker,
|
||||
ecaz: { id: ecaz.id, faction: 'ecaz', ally: ecaz.ally, forces: combatForces(g, ecaz, b.territory, opponent) },
|
||||
ally: { id: ally.id, faction: ally.faction, ally: ally.ally, forces: combatForces(g, ally, b.territory, opponent) },
|
||||
ally: { id: ally.id, faction: ally.faction, ally: ally.ally,
|
||||
...(b.noFieldPlayers?.includes(ally.id) ? { noFieldPresence: true as const } : {}),
|
||||
forces: combatForces(g, occupy.canceled && lead === ecaz.id ? { ...ally, noField: undefined } : ally, b.territory, opponent) },
|
||||
lead, canceled: occupy.canceled,
|
||||
}));
|
||||
}
|
||||
@@ -9158,7 +9183,7 @@ function ecazOccupyCompositionSupported(g: Game): boolean {
|
||||
g.players.length >= 2 && g.players.length <= 6 &&
|
||||
g.players.some(p => p.faction === 'ecaz') &&
|
||||
g.players.every(p => faction(p.faction).expansion === 'base' ||
|
||||
(['ecaz', 'moritani', 'ixians', 'tleilaxu', 'choam'].includes(p.faction) &&
|
||||
(['ecaz', 'moritani', 'ixians', 'tleilaxu', 'choam', 'richese'].includes(p.faction) &&
|
||||
g.expansions.includes(faction(p.faction).expansion))) &&
|
||||
!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.strongholdCards &&
|
||||
!g.techTokens && !g.discoveryEnabled && !g.discoveries &&
|
||||
@@ -9168,7 +9193,7 @@ function ecazOccupyCompositionSupported(g: Game): boolean {
|
||||
/** Fresh native Advanced Occupy profile; public starts and Basic are unchanged. */
|
||||
export function initializeEcazOccupyGameForAudit(state: Game): Game {
|
||||
requireRule(ecazOccupyCompositionSupported(state),
|
||||
'Advanced Occupy requires native Ecaz, classic/Moritani/Ixian/Tleilaxu/CHOAM factions and their distinct selected family decks, without Richese or optional overlays.');
|
||||
'Advanced Occupy requires native Ecaz, classic/Moritani/Ixian/Tleilaxu/CHOAM/Richese factions and their distinct selected family decks, without optional overlays.');
|
||||
requireFreshBaseRuntime(state);
|
||||
requireFreshFactionInventory(state);
|
||||
const g = initializeSetupGameForAudit(state, false, false, false, false, false, false, true, state.ecazTreachery === true);
|
||||
@@ -18432,14 +18457,11 @@ function stonePublicPools(
|
||||
p: Player,
|
||||
opponent: Player,
|
||||
): CombatForces[] {
|
||||
const t = g.battle!.territory;
|
||||
const hiddenMarker = p.noField?.deployed?.location.territory === t;
|
||||
const pool = combatForces(
|
||||
g,
|
||||
hiddenMarker ? { ...p, noField: undefined } : p,
|
||||
t,
|
||||
opponent,
|
||||
);
|
||||
const b = g.battle!, t = b.territory, occupy = b.ecazOccupy;
|
||||
const owner = occupy?.lead === p.id && !occupy.canceled ? getPlayer(g, occupy.ally) : p;
|
||||
const enemy = occupy?.lead === opponent.id && !occupy.canceled ? getPlayer(g, occupy.ally) : opponent;
|
||||
const hiddenMarker = owner.noField?.deployed?.location.territory === t;
|
||||
const pool = combatForces(g, hiddenMarker ? { ...owner, noField: undefined } : owner, t, enemy);
|
||||
return hiddenMarker
|
||||
? Array.from({ length: 6 }, (_, n) => ({
|
||||
...pool,
|
||||
@@ -18452,6 +18474,12 @@ function stoneTimingBlock(g: Game): string | null {
|
||||
? 'Stone Burner with the Ix expansion is awaiting a ruling on its simultaneous revealed choice with Poison Tooth. This implementation boundary applies before either hidden plan is submitted.'
|
||||
: null;
|
||||
}
|
||||
function stoneOccupyContext(g: Game): StoneBurnerContext | undefined {
|
||||
const b = g.battle!, profile = ecazBattleProfile(g);
|
||||
if (!profile || profile.canceled) return undefined;
|
||||
const side = { fixedEcazDial: profile.fixedEcazDial, ecazUndialed: Math.floor(profile.ecazForces.normal / 2) };
|
||||
return profile.lead === b.attacker ? { attacker: side } : { defender: side };
|
||||
}
|
||||
function stonePlanBlock(
|
||||
g: Game,
|
||||
p: Player,
|
||||
@@ -18460,17 +18488,19 @@ function stonePlanBlock(
|
||||
): string | null {
|
||||
const b = g.battle!;
|
||||
const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
|
||||
const context = stoneOccupyContext(g);
|
||||
return (
|
||||
stoneTimingBlock(g) ??
|
||||
stonePublicPools(g, opponent, p)
|
||||
.map((pool) =>
|
||||
stoneBurnerPlanBlock(
|
||||
combatForces(g, p, b.territory, opponent),
|
||||
planCombatForces(g, p, opponent),
|
||||
dial,
|
||||
support,
|
||||
pool,
|
||||
b.attacker === p.id ? 'attacker' : 'defender',
|
||||
battleTieWinner(g) === b.attacker ? 'attacker' : 'defender',
|
||||
context,
|
||||
),
|
||||
)
|
||||
.find(Boolean) ??
|
||||
@@ -18486,13 +18516,15 @@ function stoneCompulsionBlock(g: Game, target: Player): string | null {
|
||||
const timing = stoneTimingBlock(g);
|
||||
if (timing) return timing;
|
||||
const opponents = stonePublicPools(g, other, target);
|
||||
const context = stoneOccupyContext(g);
|
||||
const fixedDial = (b.attacker === target.id ? context?.attacker : context?.defender)?.fixedEcazDial ?? 0;
|
||||
for (const own of stonePublicPools(g, target, other)) {
|
||||
const supported = Array.from(
|
||||
{ length: maxCombatDial(own) * 2 + 1 },
|
||||
(_, n) => n / 2,
|
||||
(_, n) => fixedDial + n / 2,
|
||||
).some(
|
||||
(dial) =>
|
||||
casualtyOptions(own, dial, 0).length &&
|
||||
casualtyOptions(own, dial - fixedDial, 0).length &&
|
||||
opponents.every(
|
||||
(opponent) =>
|
||||
!stoneBurnerPlanBlock(
|
||||
@@ -18502,6 +18534,7 @@ function stoneCompulsionBlock(g: Game, target: Player): string | null {
|
||||
opponent,
|
||||
b.attacker === target.id ? 'attacker' : 'defender',
|
||||
battleTieWinner(g) === b.attacker ? 'attacker' : 'defender',
|
||||
context,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -25570,7 +25603,7 @@ export function prepareSpecialKaramaIntent(
|
||||
'Choose a player in this battle.',
|
||||
);
|
||||
requireRule(
|
||||
!b.noFieldPlayers?.includes(target),
|
||||
!battleNoFieldPlan(g, target),
|
||||
'Whole-plan special prescience against a No-Field awaits its disclosure ruling. Ordinary permitted elements remain available.',
|
||||
);
|
||||
return {
|
||||
@@ -29984,11 +30017,14 @@ function applyActionInner(
|
||||
requireRule(choice, 'Choose one of your unresolved battles.');
|
||||
g.auditorInsight = null;
|
||||
retireNexusChoamInsight(g);
|
||||
const noFieldPlayers = [choice.attacker, choice.defender].filter(
|
||||
(playerId) =>
|
||||
getPlayer(g, playerId).noField?.deployed?.location.territory ===
|
||||
choice.territory,
|
||||
);
|
||||
const ecaz = byFaction(g, 'ecaz');
|
||||
const ally = ecaz?.ally ? getPlayer(g, ecaz.ally) : null;
|
||||
const connected = ecaz && ally && occupyArmiesConnected(g, ecaz, ally, choice.territory);
|
||||
const noFieldPlayers = g.players.filter(player =>
|
||||
player.noField?.deployed?.location.territory === choice.territory &&
|
||||
(player.id === choice.attacker || player.id === choice.defender ||
|
||||
(g.advanced && connected && ecaz && ally?.ally === ecaz.id && player.id === ally.id &&
|
||||
(choice.attacker === ecaz.id || choice.defender === ecaz.id)))).map(player => player.id);
|
||||
for (const playerId of noFieldPlayers)
|
||||
requireRule(
|
||||
Object.entries(getPlayer(g, playerId).forces).every(
|
||||
@@ -30015,18 +30051,10 @@ function applyActionInner(
|
||||
revealed: false,
|
||||
traitorCalls: {},
|
||||
};
|
||||
const ecaz = byFaction(g, 'ecaz');
|
||||
const ally = ecaz?.ally ? getPlayer(g, ecaz.ally) : null;
|
||||
const connected = ecaz && ally && Object.entries(ecaz.forces).some(([x, n]) =>
|
||||
n > 0 && splitLocation(x).territory === choice.territory && Object.entries(ally.forces).some(([y, m]) =>
|
||||
m > 0 && splitLocation(y).territory === choice.territory &&
|
||||
gameDistance(g, x, y, key => splitLocation(key).sector === g.storm) === 0));
|
||||
if (g.advanced && ecaz && ally?.ally === ecaz.id && connected &&
|
||||
!isAdvisor(ally, choice.territory) && [choice.attacker, choice.defender].some(seat => seat === ecaz.id || seat === ally.id)) {
|
||||
requireRule(ecazOccupyCompositionSupported(g) &&
|
||||
(g.ecazOccupyPreview === true || g.players.every(player =>
|
||||
faction(player.faction).expansion === 'base' || ['ecaz', 'moritani'].includes(player.faction))),
|
||||
'Advanced combined Occupy supports source-selected native factions only in the fresh Occupy profile; Richese/No-Field and optional overlays remain gated.');
|
||||
requireRule(ecazOccupyCompositionSupported(g),
|
||||
'Advanced combined Occupy requires source-selected native factions; optional overlays remain gated.');
|
||||
g.battle.ecazOccupy = { event: battleEvent, ecaz: ecaz.id, ally: ally.id, lead: null, canceled: false };
|
||||
g.decision = { kind: 'ecazBattleLead', player: ecaz.id, event: battleEvent, choices: [ecaz.id, ally.id] };
|
||||
} else {
|
||||
@@ -30067,7 +30095,7 @@ function applyActionInner(
|
||||
const beneficiary = b.preparation.beneficiary;
|
||||
const target = beneficiary === b.attacker ? b.defender : b.attacker;
|
||||
requireRule(
|
||||
action.field !== 'dial' || !b.noFieldPlayers?.includes(target),
|
||||
action.field !== 'dial' || !battleNoFieldPlan(g, target),
|
||||
'Atreides may not inspect the number dialed in a No-Field battle.',
|
||||
);
|
||||
b.prescience = { player: beneficiary, field: action.field as PlanField };
|
||||
@@ -30085,7 +30113,7 @@ function applyActionInner(
|
||||
b.preparation.owner === id && record?.stage === 'answer' && record.target === id &&
|
||||
action.event === record.event && Object.keys(action).sort().join(',') === 'event,type,value',
|
||||
'This Nexus answer does not match your current inspection.');
|
||||
requireRule(record.mode !== 'cunning' || record.field !== 'dial' || !b.noFieldPlayers?.includes(id),
|
||||
requireRule(record.mode !== 'cunning' || record.field !== 'dial' || !battleNoFieldPlan(g, id),
|
||||
'Atreides may not inspect the number dialed in a No-Field battle.');
|
||||
const value = record.field === 'dial'
|
||||
? typeof action.value === 'number' ? action.value : NaN
|
||||
@@ -30107,7 +30135,7 @@ function applyActionInner(
|
||||
);
|
||||
const field = b.prescience.field;
|
||||
requireRule(
|
||||
field !== 'dial' || !b.noFieldPlayers?.includes(id),
|
||||
field !== 'dial' || !battleNoFieldPlan(g, id),
|
||||
'Atreides may not inspect the number dialed in a No-Field battle.',
|
||||
);
|
||||
const value =
|
||||
@@ -31453,11 +31481,13 @@ export function viewGame(state: Game, id: string) {
|
||||
battle: b
|
||||
? {
|
||||
locationName: combatLocationName(g, b.territory),
|
||||
ecazOccupy: b.ecazOccupy ? { ...b.ecazOccupy, profile: ecazBattleProfile(g) } : null,
|
||||
ecazOccupy: b.ecazOccupy ? { ...b.ecazOccupy,
|
||||
profile: !b.revealed && b.ecazOccupy.lead && battleConcealedForces(g, b.ecazOccupy.lead) &&
|
||||
id !== b.ecazOccupy.lead ? null : ecazBattleProfile(g) } : null,
|
||||
native: homeworldBattleLocation(g, b.territory)?.native ?? null,
|
||||
nativeBattleStrength: homeworldBattleLocation(g, b.territory)?.nativeBattleStrength ?? 0,
|
||||
opponentForces: [b.attacker, b.defender].includes(id) &&
|
||||
!getPlayer(g, b.attacker === id ? b.defender : b.attacker).noField?.deployed
|
||||
!battleConcealedForces(g, b.attacker === id ? b.defender : b.attacker)
|
||||
? planCombatForces(g, getPlayer(g, b.attacker === id ? b.defender : b.attacker), me)
|
||||
: null,
|
||||
strongholdCopy: b.strongholdCopy ?? null,
|
||||
@@ -31468,7 +31498,7 @@ export function viewGame(state: Game, id: string) {
|
||||
]),
|
||||
) as Record<string, StrongholdId | null>,
|
||||
tieWinner: battleTieWinner(g),
|
||||
noFieldPlayers: b.noFieldPlayers ?? [],
|
||||
noFieldPlayers: [b.attacker, b.defender].filter(player => battleNoFieldPlan(g, player)),
|
||||
ownForces: [b.attacker, b.defender].includes(id)
|
||||
? planCombatForces(g, me, getPlayer(g, b.attacker === id ? b.defender : b.attacker))
|
||||
: null,
|
||||
@@ -31489,6 +31519,7 @@ export function viewGame(state: Game, id: string) {
|
||||
stoneBurnerContext: [b.attacker, b.defender].includes(id)
|
||||
? {
|
||||
blocked: stoneTimingBlock(g),
|
||||
occupy: stoneOccupyContext(g),
|
||||
opponentPools: stonePublicPools(
|
||||
g,
|
||||
getPlayer(g, b.attacker === id ? b.defender : b.attacker),
|
||||
|
||||
@@ -2,12 +2,13 @@ import type { FactionId } from './catalog';
|
||||
|
||||
const supported = new Set<FactionId>([
|
||||
'atreides', 'harkonnen', 'emperor', 'guild', 'beneGesserit', 'fremen',
|
||||
'choam', 'richese',
|
||||
'choam', 'richese', 'ecaz', 'moritani',
|
||||
]);
|
||||
|
||||
/** Public configuration only: never inspect an opponent's hand or sealed plan. */
|
||||
export function mirrorWeaponModeBlock(g: {
|
||||
expansions: readonly string[];
|
||||
advanced?: boolean;
|
||||
players: readonly { faction: FactionId }[];
|
||||
homeworlds?: unknown;
|
||||
nexusCards?: unknown;
|
||||
@@ -15,9 +16,11 @@ export function mirrorWeaponModeBlock(g: {
|
||||
discoveries?: unknown;
|
||||
sandtrout?: boolean;
|
||||
}): string | null {
|
||||
if (g.expansions.length !== 1 || g.expansions[0] !== 'choam' ||
|
||||
if (!g.expansions.includes('choam') ||
|
||||
g.expansions.some(id => id !== 'choam' && id !== 'ecaz') ||
|
||||
(g.expansions.includes('ecaz') && g.advanced !== true) ||
|
||||
g.players.some((p) => !supported.has(p.faction)))
|
||||
return 'Mirror Weapon currently needs the CHOAM/Richese expansion with classic or CHOAM/Richese factions.';
|
||||
return 'Mirror Weapon currently needs CHOAM/Richese with classic or native Ecaz/Moritani factions; Ix timing remains unresolved.';
|
||||
if (g.homeworlds || g.nexusCards || g.leaderSkills || g.discoveries || g.sandtrout)
|
||||
return 'Mirror Weapon with combined optional modules is still being integrated.';
|
||||
return null;
|
||||
|
||||
+5
-3
@@ -1265,16 +1265,18 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Eligible Karama can prevent Occupy after the lead choice and before powers/plans. The chosen lead remains lead and dials its own army; the other ally contributes zero. The plan editor then uses the new force pool and no fixed Ecaz strength.',
|
||||
'An Ixian variable army takes its own typed losses and chooses native surviving-Suboid substitution, including when Ecaz formed the plan. Actual Ixians own the exchange/counter; the selected lead still owns winner cards and continuation. CHOAM support income uses original eligible payer/donor shares, not the free fixed contribution.',
|
||||
'A Tleilaxu winning co-side cannot Face Dance itself. External Face Dance returns both actual winning allied armies with a combined survivor maximum, while the selected winner keeps original rewards and card cleanup. Reserve/board zero/partial/full replacements remain native choices, not a copied player.',
|
||||
'A native Richese ally may supply ordinary counters or its concealed No-Field alone. A zero or reserve-limited marker remains battle presence; the original Richese owner materializes it only after both plans seal. The selected lead receives the private variable pool needed for its plan; rivals do not. Dial prescience and whole-plan restrictions follow the selected plan’s actual force owner, while cancellation to Ecaz’s own pool removes that concealed-dial restriction. Richese’s own ordinary-plus-marker dialing remains guarded.',
|
||||
'Stone Burner compares physical undialed counters, not total dial or paid strength: active Occupy contributes floor(Ecaz forces / 2) plus the ally’s native undialed tokens. Canceled Occupy compares only the selected lead’s own pool. Physical Mirror copies use the same comparison and retain their original cleanup. Hidden opposing No-Field preflight uses public possible pools, not its actual denomination; genuinely winner-changing typed allocations remain blocked.',
|
||||
'With the independent variant, Harass returns only the actual card user’s undialed forces. An Ecaz user keeps the original mandatory ceil contribution committed at free full strength while its floor count returns; ally support is separate. An allied user subtracts fixed strength before choosing its native typed commitment. Canceled Occupy uses its selected lead’s own pool. Returned reserves are outside later casualties/explosion/Face Dance.',
|
||||
'Reinforcements consumes three card-user reserves normal-first and adds +2 score without extra support or on-board arrivals. Its existing provisional cost/disposal on every revealed outcome is unchanged, not a new publisher ruling. Own same-plan Harass/Reinforcements and Stone remain guarded; Basic/unprofiled co-side cards remain unsupported.',
|
||||
'These numbers come from the user-authorized revised Advanced PDF, physical pages 32 and 19, not a newly located publisher erratum. Basic and the specific Ecaz-only Duke/Ghola decision remain. Fresh ecaz-occupy admits Ecaz/classic/optional Moritani/Ixians/Tleilaxu/CHOAM two-to-six-seat Advanced with required family decks:33 Ecaz alone,47 Ix,35 CHOAM,47 both. Explicit --ecaz-treachery independently adds all three cards for36/50/38; Richese mixed planning and other overlays remain open. Full factions/public starts stay gated.',
|
||||
'These numbers come from the user-authorized revised Advanced PDF, physical pages 32 and 19, not a newly located publisher erratum. Basic and the specific Ecaz-only Duke/Ghola decision remain. Fresh ecaz-occupy admits Ecaz/classic/optional Moritani/Ixians/Tleilaxu/CHOAM/Richese two-to-six-seat Advanced with required family decks:33 Ecaz alone,47 Ix,35 CHOAM,47 both; Richese retains its separate ten-card cache. Explicit --ecaz-treachery independently adds all three cards for36/50/38. Own mixed No-Field dialing, whole-plan disclosure, Ix Stone/Tooth timing, Harass/Stone and optional overlays remain open. Full factions/public starts stay gated.',
|
||||
],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Combined-side enumeration, explicit lead choice, native Occupy response and owner-labelled physical dial/outcome integration are implemented. Optional/exotic compositions remain open.', evidence: ['game/ecaz-occupy-battle.ts', 'game/board-resolution-quote.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Original human390px rooms exercise mandatory/free total dials, native Karama own-army redial, physical Ix two-Suboid/two-Cyborg exchange after an Ecaz plan, and full three-force FaceDance replacement across both winning armies. Selected lead retains its cards and native Collection continuation.', evidence: ['components/ecaz-occupy.tsx', 'components/face-dance-decision.tsx', 'components/game-table.tsx', 'game/ecaz-occupy-options.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Original 390px controls cover mandatory/free dials, native Karama redial, Ix substitutions and whole-side Face Dance. Richese human T78EFNZK chose Ecaz lead, sealed total5/support2 with its real leader, revealed the ally’s original No-Field5 into only two available reserves, and reached Collection with Ecaz2 and two Richese losses. Other combinations remain open.', evidence: ['components/ecaz-occupy.tsx', 'components/face-dance-decision.tsx', 'components/game-table.tsx', 'game/ecaz-occupy-options.ts'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four native policies actually seal legal total plans for either lead, active or canceled, using native leaders/cards and physical support. Strategy remains deferred.', evidence: ['game/ecaz-occupy-options.ts', 'game/bots.ts', 'tests/ecaz-occupy-options.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'Authorized Advanced arithmetic/cancellation, unchanged Basic and specific Duke override are separated from historical source gaps and optional-module gates.', evidence: ['docs/ECAZ_OCCUPY_RULES.md', 'docs/RULE_DECISIONS.md', 'game/reference.ts'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Development Verified: original arithmetic/native force integration, independent-card native/pure67 cases, relevant284-case batch,27 actual programs/four policies and original390px human Harass/Reinforcements. Current46 ordinary/variant2..6 games complete26619 accepted actions/no rejection with nine actual combined lead choices in full results. Prior five/23-game full counters record one/three, correcting compact-summary misreading. Full factions/other combinations and comprehensive assurance remain after all rules.', evidence: ['tests/ecaz-occupy-cards-runtime.test.ts', 'tests/ecaz-occupy-harass-quote.test.ts', 'tests/ecaz-occupy-ix-choam-runtime.test.ts', 'tests/ecaz-occupy-tleilaxu-runtime.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Development Verified: affected363 rules cases,26 actual native programs including all four legal policies, and original390px Richese/No-Field controls. The previously stalled six-native Advanced game completes2424 accepted actions, no rejections and65JSON continuations. Earlier46 ordinary/variant games and source-specific card evidence remain historical; complete factions/optional combinations and comprehensive assurance follow all rules.', evidence: ['tests/ecaz-occupy-richese.test.ts', 'tests/ecaz-occupy-stone.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
|
||||
],
|
||||
related: ['faction-ecaz', 'advanced-ecaz', 'ecaz-ambassadors', 'battle', 'victory', 'implementation-checklist'],
|
||||
},
|
||||
|
||||
+43
-11
@@ -12,6 +12,16 @@ export type StoneBurnerComparison = {
|
||||
attacker: number[];
|
||||
defender: number[];
|
||||
};
|
||||
/** Public fixed contributions only; callers retain their own native force pools. */
|
||||
export type StoneBurnerOccupyContext = {
|
||||
fixedEcazDial: number;
|
||||
ecazUndialed: number;
|
||||
};
|
||||
/** Context belongs to physical battle slots, not the card holder or aggressor. */
|
||||
export type StoneBurnerContext = {
|
||||
attacker?: StoneBurnerOccupyContext;
|
||||
defender?: StoneBurnerOccupyContext;
|
||||
};
|
||||
|
||||
const opposingChoices = new Map<string, readonly (readonly number[])[]>();
|
||||
const MAX_CACHED_POOLS = 128;
|
||||
@@ -22,7 +32,14 @@ function validSide(side: StoneBurnerSide): boolean {
|
||||
function sorted(values: Iterable<number>): number[] {
|
||||
return [...new Set(values)].sort((a, b) => a - b);
|
||||
}
|
||||
function totals(forces: CombatForces, dial: number, support: number): number[] {
|
||||
function totals(
|
||||
forces: CombatForces,
|
||||
dial: number,
|
||||
support: number,
|
||||
occupy?: StoneBurnerOccupyContext,
|
||||
): number[] {
|
||||
dial -= occupy?.fixedEcazDial ?? 0;
|
||||
const fixedUndialed = occupy?.ecazUndialed ?? 0;
|
||||
if (
|
||||
!validCombatForces(forces) ||
|
||||
!Number.isFinite(dial) ||
|
||||
@@ -35,7 +52,7 @@ function totals(forces: CombatForces, dial: number, support: number): number[] {
|
||||
return [];
|
||||
return sorted(
|
||||
casualtyOptions(forces, dial, support).map(
|
||||
(loss) => forces.normal + forces.elite - loss.normal - loss.elite,
|
||||
(loss) => fixedUndialed + forces.normal + forces.elite - loss.normal - loss.elite,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -68,9 +85,10 @@ export function stoneBurnerComparison(
|
||||
dDial: number,
|
||||
dSupport: number,
|
||||
aggressor: StoneBurnerSide = 'attacker',
|
||||
context?: StoneBurnerContext,
|
||||
): StoneBurnerComparison {
|
||||
const attacker = totals(a, aDial, aSupport),
|
||||
defender = totals(d, dDial, dSupport);
|
||||
const attacker = totals(a, aDial, aSupport, context?.attacker),
|
||||
defender = totals(d, dDial, dSupport, context?.defender);
|
||||
return {
|
||||
winner: invariantWinner(attacker, defender, aggressor),
|
||||
attacker,
|
||||
@@ -85,7 +103,9 @@ export function stoneBurnerComparison(
|
||||
*/
|
||||
function allOpposingTotals(
|
||||
forces: CombatForces,
|
||||
occupy?: StoneBurnerOccupyContext,
|
||||
): readonly (readonly number[])[] {
|
||||
const fixedUndialed = occupy?.ecazUndialed ?? 0;
|
||||
const key = [
|
||||
forces.normal,
|
||||
forces.elite,
|
||||
@@ -95,6 +115,7 @@ function allOpposingTotals(
|
||||
!!forces.normalFreeSupport,
|
||||
forces.freeSupport,
|
||||
!!forces.eliteFreeSupport,
|
||||
fixedUndialed,
|
||||
].join(':');
|
||||
const cached = opposingChoices.get(key);
|
||||
if (cached) return cached;
|
||||
@@ -106,7 +127,12 @@ function allOpposingTotals(
|
||||
for (let halfDial = 0; halfDial <= maximumHalfDial; halfDial++)
|
||||
for (let support = 0; support <= maximumSupport; support++) {
|
||||
const choices = totals(forces, halfDial / 2, support);
|
||||
if (choices.length) result.push(choices);
|
||||
if (choices.length) {
|
||||
if (fixedUndialed)
|
||||
for (let index = 0; index < choices.length; index++)
|
||||
choices[index] += fixedUndialed;
|
||||
result.push(choices);
|
||||
}
|
||||
}
|
||||
if (opposingChoices.size >= MAX_CACHED_POOLS)
|
||||
opposingChoices.delete(opposingChoices.keys().next().value!);
|
||||
@@ -125,15 +151,18 @@ export function stoneBurnerPlanBlock(
|
||||
opponent: CombatForces,
|
||||
side: StoneBurnerSide,
|
||||
aggressor: StoneBurnerSide = 'attacker',
|
||||
context?: StoneBurnerContext,
|
||||
): string | null {
|
||||
if (!validSide(side) || !validSide(aggressor))
|
||||
return 'Choose valid Stone Burner combatant roles.';
|
||||
const ownOccupy = context?.[side];
|
||||
const opponentOccupy = context?.[side === 'attacker' ? 'defender' : 'attacker'];
|
||||
if (!validCombatForces(own) || !validCombatForces(opponent))
|
||||
return 'Stone Burner needs valid supported physical force pools of at most 20 tokens.';
|
||||
const ownTotals = totals(own, dial, support);
|
||||
const ownTotals = totals(own, dial, support, ownOccupy);
|
||||
if (!ownTotals.length)
|
||||
return 'Your dial and support do not permit a legal Stone Burner force allocation.';
|
||||
for (const otherTotals of allOpposingTotals(opponent)) {
|
||||
for (const otherTotals of allOpposingTotals(opponent, opponentOccupy)) {
|
||||
const winner =
|
||||
side === 'attacker'
|
||||
? invariantWinner(ownTotals, otherTotals, aggressor)
|
||||
@@ -150,16 +179,19 @@ export function stoneBurnerCompulsionBlock(
|
||||
opponent: CombatForces,
|
||||
side: StoneBurnerSide,
|
||||
aggressor: StoneBurnerSide = 'attacker',
|
||||
context?: StoneBurnerContext,
|
||||
): string | null {
|
||||
if (!validSide(side) || !validSide(aggressor))
|
||||
return 'Choose valid Stone Burner combatant roles.';
|
||||
const ownOccupy = context?.[side];
|
||||
if (!validCombatForces(own) || !validCombatForces(opponent))
|
||||
return 'Stone Burner needs valid supported physical force pools of at most 20 tokens.';
|
||||
const maximumStrength = maxCombatDial(own);
|
||||
for (let halfDial = 0; halfDial <= maximumStrength * 2; halfDial++) {
|
||||
if (!totals(own, halfDial / 2, 0).length) continue;
|
||||
const fixedDial = ownOccupy?.fixedEcazDial ?? 0;
|
||||
const maximumStrength = fixedDial + maxCombatDial(own);
|
||||
for (let halfDial = fixedDial * 2; halfDial <= maximumStrength * 2; halfDial++) {
|
||||
if (!totals(own, halfDial / 2, 0, ownOccupy).length) continue;
|
||||
if (
|
||||
stoneBurnerPlanBlock(own, halfDial / 2, 0, opponent, side, aggressor) ===
|
||||
stoneBurnerPlanBlock(own, halfDial / 2, 0, opponent, side, aggressor, context) ===
|
||||
null
|
||||
)
|
||||
return null;
|
||||
|
||||
@@ -84,7 +84,8 @@ function validateContext(context: TraitorDeclarationContext): void {
|
||||
quoteEcazOccupyBattle({
|
||||
battleOrderActor: occupy.battleOrderActor,
|
||||
ecaz: { id: ecaz.id, faction: 'ecaz', ally: ecaz.ally, forces: occupy.ecazForces },
|
||||
ally: { id: ally.id, faction: ally.faction, ally: ally.ally, forces: occupy.allyForces },
|
||||
ally: { id: ally.id, faction: ally.faction, ally: ally.ally, forces: occupy.allyForces,
|
||||
...(occupy.allyNoFieldPresence ? { noFieldPresence: true as const } : {}) },
|
||||
lead: occupy.lead, canceled: occupy.canceled,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,83 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { ShipmentClaimFields, invalidShipmentClause } from '../components/shipment-claim-fields';
|
||||
import { truthQuestionText, type TruthQuestion } from '../game/truthtrance';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { ShipmentPromises } from '../components/shipment-promises';
|
||||
import { compoundShipmentGame, askCompoundShipment } from './fixture-compound-shipment';
|
||||
|
||||
const aliases = registerHooks({ resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
} });
|
||||
const { Truthtrance } = await import('../components/truthtrance');
|
||||
aliases.deregister();
|
||||
|
||||
const clauses = [{ territory: 'carthag', minimum: 6 }, { territory: 'arrakeen', minimum: 4 }];
|
||||
function markup(join: 'single' | 'and' | 'or', minimum = 4) {
|
||||
return renderToStaticMarkup(createElement(ShipmentClaimFields, {
|
||||
id: 'shipment-test', join, clauses: [clauses[0], { ...clauses[1], minimum }],
|
||||
onJoin() {}, onClauses() {},
|
||||
}));
|
||||
}
|
||||
void test('compound shipment fields distinguish one shipment from two and retain accessible per-clause labels', () => {
|
||||
assert.equal((markup('single').match(/<fieldset/g) ?? []).length, 1);
|
||||
for (const join of ['and', 'or'] as const) {
|
||||
const html = markup(join);
|
||||
assert.equal((html.match(/<fieldset/g) ?? []).length, 2);
|
||||
assert.match(html, /All conditions describe the same shipment/);
|
||||
assert.match(html, /A No answer requires the whole statement to be false/);
|
||||
assert.equal((html.match(/This-turn shipment destination/g) ?? []).length, 2);
|
||||
assert.equal((html.match(/Minimum physical forces from reserves/g) ?? []).length, 2);
|
||||
const question: TruthQuestion = { kind: 'shipment', target: 'p', claim: { op: join, terms: clauses } };
|
||||
const text = truthQuestionText(question, id => id);
|
||||
assert.match(text, /Carthag/);
|
||||
assert.match(text, /Arrakeen/);
|
||||
assert.match(text, new RegExp(join.toUpperCase()));
|
||||
}
|
||||
});
|
||||
import { invalidShipmentClause } from '../components/shipment-claim-fields';
|
||||
|
||||
void test('empty, fractional and out-of-range compound counts stay invalid rather than coercing to a different promise', () => {
|
||||
for (const minimum of [NaN, 0, 2.5, 21]) {
|
||||
assert.equal(invalidShipmentClause({ territory: 'arrakeen', minimum }), true);
|
||||
assert.equal((markup('or', minimum).match(/aria-invalid="true"/g) ?? []).length, 1);
|
||||
}
|
||||
for (const minimum of [1, 20]) assert.equal(invalidShipmentClause({ territory: 'arrakeen', minimum }), false);
|
||||
});
|
||||
|
||||
void test('the real answer panel explains whole-expression No and offers answers only to the respondent', () => {
|
||||
const g = askCompoundShipment(compoundShipmentGame(true), { op: 'or', terms: clauses });
|
||||
const render = (me: string, busy = false) => renderToStaticMarkup(createElement(Truthtrance, {
|
||||
game: viewGame(g, me), act() {}, busy,
|
||||
}));
|
||||
const own = render('p');
|
||||
assert.match(own, /Answer Yes/);
|
||||
assert.match(own, /Answer No/);
|
||||
assert.match(own, /AND requires all conditions; OR requires at least one/);
|
||||
assert.match(own, /No makes the whole claim false/);
|
||||
assert.match(render('p', true), /<button[^>]*disabled=""[^>]*>Answer Yes/);
|
||||
for (const other of ['a', 'o']) {
|
||||
assert.doesNotMatch(render(other), />Answer (Yes|No)</);
|
||||
assert.match(render(other), /Waiting for Shipper to answer/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('accepted compound promises render grouped private guidance and preserve the server completion', () => {
|
||||
for (const op of ['and', 'or'] as const) for (const answer of ['yes', 'no'] as const) {
|
||||
const terms = op === 'and' ? [clauses[0], { territory: 'carthag', minimum: 4 }] : clauses;
|
||||
const asked = askCompoundShipment(compoundShipmentGame(true), { op, terms });
|
||||
const promised = applyAction(asked, 'p', { type: 'truthAnswer', answer });
|
||||
const view = viewGame(promised, 'p');
|
||||
const html = renderToStaticMarkup(createElement(ShipmentPromises, { game: view, act() {}, busy: false }));
|
||||
assert.match(html, new RegExp(`You answered ${answer === 'yes' ? 'Yes' : 'No'}`));
|
||||
assert.match(html, new RegExp(op.toUpperCase()));
|
||||
assert.match(html, /Carthag/);
|
||||
assert.doesNotMatch(html, /undefined/);
|
||||
if (answer === 'yes') assert.match(html, /Suggested next step/);
|
||||
for (const rival of ['a', 'o']) assert.equal(renderToStaticMarkup(createElement(ShipmentPromises, {
|
||||
game: viewGame(promised, rival), act() {}, busy: false,
|
||||
})), '');
|
||||
}
|
||||
});
|
||||
@@ -73,14 +73,6 @@ void test('factory instances and their mutable cards are independent of canonica
|
||||
assert.equal(second.length, 3);
|
||||
assert.equal(second[0].name, 'Recruits');
|
||||
assert.equal(ECAZ_TREACHERY_DEFINITIONS[0].card.name, 'Recruits');
|
||||
assert.ok(Object.isFrozen(ECAZ_TREACHERY_DEFINITIONS));
|
||||
for (const definition of ECAZ_TREACHERY_DEFINITIONS) {
|
||||
assert.ok(Object.isFrozen(definition));
|
||||
assert.ok(Object.isFrozen(definition.card));
|
||||
assert.ok(Object.isFrozen(definition.gameplay));
|
||||
assert.ok(Object.isFrozen(definition.battleSlots));
|
||||
assert.ok(Object.isFrozen(definition.verification.unresolved));
|
||||
}
|
||||
});
|
||||
|
||||
void test('slot occupancy never reclassifies the new special cards as weapons or defenses', () => {
|
||||
@@ -116,50 +108,6 @@ void test('presentation lookup requires the canonical physical ID, kind and effe
|
||||
assert.equal(ecazTreacheryDefinition(baseDeck()[0]), undefined);
|
||||
});
|
||||
|
||||
void test('verified inventory and source rules remain distinct from unimplemented runtime and unresolved interactions', () => {
|
||||
for (const definition of ECAZ_TREACHERY_DEFINITIONS) {
|
||||
assert.equal(definition.quantity, 1);
|
||||
assert.equal(definition.verification.inventory, 'verified');
|
||||
assert.equal(definition.verification.sourceRules, 'verified');
|
||||
assert.equal(
|
||||
definition.verification.runtime,
|
||||
definition.card.effect === 'reinforcements' ? 'not-implemented' : 'prototype',
|
||||
);
|
||||
assert.equal(definition.verification.combinedInteractions, 'incomplete');
|
||||
assert.ok(definition.verification.unresolved.length > 0);
|
||||
assert.ok(definition.summary.length > 20);
|
||||
assert.ok(definition.gameplay.length >= 4);
|
||||
assert.ok(definition.gameplay.every((paragraph) => paragraph.length > 15));
|
||||
}
|
||||
});
|
||||
|
||||
void test('every canonical Ecaz face receives its full original gameplay guide and a concise normal-card summary', () => {
|
||||
for (const card of ecazTreacheryCards()) {
|
||||
const definition = ecazTreacheryDefinition(card)!;
|
||||
const presentation = cardPresentation(card);
|
||||
assert.equal(presentation.guidance, definition.summary);
|
||||
assert.deepEqual(presentation.gameplay, definition.gameplay);
|
||||
assert.equal(presentation.category, 'Special treachery');
|
||||
assert.equal(presentation.role, 'utility');
|
||||
assert.deepEqual(Object.keys(presentation).sort(), [
|
||||
'availability',
|
||||
'category',
|
||||
'gameplay',
|
||||
'guidance',
|
||||
'role',
|
||||
'topics',
|
||||
]);
|
||||
const serialized = JSON.stringify(presentation);
|
||||
for (const note of definition.verification.unresolved)
|
||||
assert.equal(serialized.includes(note), false);
|
||||
assert.equal(serialized.includes('sourceRules'), false);
|
||||
assert.equal(serialized.includes('not-implemented'), false);
|
||||
assert.deepEqual(presentation.topics.map((topic) => topic.id), [card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : card.effect === 'reinforcements' ? 'card-reinforcements' : 'card-recruits']);
|
||||
assert.match(presentation.availability ?? '', card.effect === 'recruits'
|
||||
? /development controls during Revival/ : card.effect === 'harassWithdraw'
|
||||
? /development controls in either battle-card slot/ : /development controls in either battle-card slot/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('missing, mismatched and merely similar Ecaz identities do not receive a canonical gameplay guide', () => {
|
||||
for (const card of ecazTreacheryCards()) {
|
||||
@@ -184,31 +132,3 @@ void test('missing, mismatched and merely similar Ecaz identities do not receive
|
||||
}
|
||||
});
|
||||
|
||||
void test('base and Ix card guides retain their role, summary and reference topics without Ecaz enrichment', () => {
|
||||
for (const card of [...baseDeck(), ...ixDeck()]) {
|
||||
const presentation = cardPresentation(card);
|
||||
assert.equal(presentation.gameplay, undefined);
|
||||
assert.ok(presentation.guidance.length > 20);
|
||||
}
|
||||
const weapon = cardPresentation(baseDeck()[0]);
|
||||
assert.equal(weapon.role, 'weapon');
|
||||
assert.equal(
|
||||
weapon.guidance,
|
||||
'Play in the weapon slot of your battle plan. A projectile weapon kills an opposing leader who has no projectile defense.',
|
||||
);
|
||||
assert.ok(weapon.topics.some((topic) => topic.id === 'battle-cards'));
|
||||
const poisonTooth = cardPresentation(
|
||||
ixDeck().find((card) => card.kind === 'poisonTooth')!,
|
||||
);
|
||||
assert.equal(poisonTooth.role, 'weapon');
|
||||
assert.ok(
|
||||
poisonTooth.topics.some((topic) => topic.id === 'card-poison-tooth'),
|
||||
);
|
||||
const hero = cardPresentation(
|
||||
baseDeck().find((card) => card.kind === 'hero')!,
|
||||
);
|
||||
assert.equal(hero.role, 'leader');
|
||||
assert.ok(hero.topics.some((topic) => topic.id === 'cheap-hero-traitor'));
|
||||
assert.equal(treacheryDeck().length, 33);
|
||||
assert.equal(treacheryDeck(['ix']).length, 47);
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { quoteEcazOccupyBattle, quoteEcazOccupyDial, type EcazOccupyBattleInput } from '../game/ecaz-occupy-battle';
|
||||
import { ecazOccupyRicheseFixture, occupyRicheseSeat, openOccupyRichese,
|
||||
nextOccupyRichese, finishOccupyRichese, type EcazOccupyRicheseFixture } from './fixture-ecaz-occupy-richese';
|
||||
import { stageEcazOccupyCard } from './fixture-ecaz-occupy';
|
||||
|
||||
function conserve(game: Game): void {
|
||||
for (const player of game.players) {
|
||||
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20,
|
||||
`${player.faction} counters retain their original physical owner.`);
|
||||
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
|
||||
Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0), player.faction === 'ixians' ? 7 : 5);
|
||||
}
|
||||
}
|
||||
function resolve(fixture: EcazOccupyRicheseFixture, lead: 'ecaz' | 'ally', cancel = false) {
|
||||
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||||
let game = openOccupyRichese(fixture, lead, cancel);
|
||||
const before = structuredClone(game);
|
||||
const profile = viewGame(game, actor).battle!.ecazOccupy!.profile!;
|
||||
const variable = Math.min(2, profile.maxDial - profile.fixedEcazDial);
|
||||
const support = profile.forces.normalFixedHalf ? 0 : variable;
|
||||
const leader = occupyRicheseSeat(game, actor).leaders.filter(l => !l.dead && !l.usedAt)
|
||||
.sort((a, b) => b.strength - a.strength)[0];
|
||||
const enemyLeader = occupyRicheseSeat(game, fixture.opponent).leaders.filter(l => !l.dead && !l.usedAt)
|
||||
.sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(leader && enemyLeader);
|
||||
const concealed = structuredClone(occupyRicheseSeat(game, fixture.richese).noField!.deployed);
|
||||
game = applyAction(game, actor, { type: 'battlePlan', dial: profile.fixedEcazDial + variable, support, leader: leader.id });
|
||||
assert.equal(game.battle!.plans[actor].leader, leader.id, 'The selected faction commits its real native leader.');
|
||||
assert.equal(game.battle!.revealed, false);
|
||||
assert.deepEqual(occupyRicheseSeat(game, fixture.richese).noField!.deployed, concealed,
|
||||
'The first sealed plan cannot reveal its physical partner marker.');
|
||||
assert.equal(occupyRicheseSeat(game, fixture.richese).reserves, occupyRicheseSeat(before, fixture.richese).reserves);
|
||||
assert.equal(viewGame(game, fixture.opponent).battle!.plans[actor], undefined);
|
||||
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 0, support: 0, leader: enemyLeader.id });
|
||||
assert.equal(game.battle!.revealed, true);
|
||||
assert.equal(game.battle!.plans[actor].leader, leader.id);
|
||||
const revealed = structuredClone(game);
|
||||
if (fixture.materialized !== null) {
|
||||
assert.equal(occupyRicheseSeat(game, fixture.richese).noField!.deployed, null);
|
||||
assert.equal(occupyRicheseSeat(before, fixture.richese).reserves - occupyRicheseSeat(game, fixture.richese).reserves,
|
||||
fixture.materialized, 'Both sealed plans materialize only available owner reserves, once.');
|
||||
assert.equal(occupyRicheseSeat(game, fixture.richese).forces[fixture.location] ?? 0, fixture.materialized);
|
||||
}
|
||||
game = finishOccupyRichese(game);
|
||||
assert.equal(game.battle, null);
|
||||
assert.equal(game.decision, null);
|
||||
assert.equal(game.lastBattleContext!.result, 'normal');
|
||||
assert.equal(game.lastBattleContext!.winner, actor);
|
||||
assert.equal(occupyRicheseSeat(game, actor).leaders.find(l => l.id === leader.id)!.usedAt, fixture.territory);
|
||||
assert.ok(game.phase >= 7, 'The mandatory coalition battle proceeds to actual collection.');
|
||||
conserve(game);
|
||||
return { game, before, revealed, actor, profile, variable, support };
|
||||
}
|
||||
|
||||
const cityIncome = (game: Game, actor: string, location: string) =>
|
||||
(occupyRicheseSeat(game, actor).forces[location] ?? 0) > 0 ? 2 : 0;
|
||||
for (const lead of ['ecaz', 'ally'] as const) void test(`Six-native Richese-present Carthag: Ecaz+Ix vs CHOAM with ${lead} lead resolves`, () => {
|
||||
const fixture = ecazOccupyRicheseFixture({ allyFaction: 'ixians', opponentFaction: 'choam', omitPreview: true });
|
||||
assert.deepEqual(fixture.initial.players.map(p => p.faction), ['ecaz', 'ixians', 'choam', 'richese', 'moritani', 'tleilaxu']);
|
||||
assert.equal(fixture.initial.status, 'setup');
|
||||
assert.deepEqual(fixture.initial.richeseCache, richeseCards(), 'The fresh initializer creates the canonical native cache.');
|
||||
assert.deepEqual([...fixture.initial.deck, ...(fixture.initial.ixSetupCards ?? []), ...fixture.initial.players.flatMap(p => p.hand)]
|
||||
.map(c => c.id).sort(), treacheryDeck(fixture.initial.expansions).map(c => c.id).sort());
|
||||
assert.ok(fixture.afterSetup.players.every(p => p.leaders.length > 0 &&
|
||||
(p.faction === 'tleilaxu' ? p.faceDancers!.length > 0 : p.traitors.length > 0)));
|
||||
assert.equal(fixture.beforeBattle.ecazOccupyPreview, undefined);
|
||||
const { game, before, actor } = resolve(fixture, lead);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ecaz).forces[fixture.location], 2);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ecaz).tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, 3);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ally).forces[fixture.location] ?? 0, 0,
|
||||
'Four unsupported original Suboids contribute two strength and all four are lost.');
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ally).tanks - occupyRicheseSeat(before, fixture.ally).tanks, 4);
|
||||
assert.equal(occupyRicheseSeat(game, actor).spice,
|
||||
occupyRicheseSeat(before, actor).spice + cityIncome(game, actor, fixture.location));
|
||||
assert.equal(occupyRicheseSeat(game, fixture.opponent).tanks - occupyRicheseSeat(before, fixture.opponent).tanks, 8);
|
||||
});
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const) for (const cancel of [false, true])
|
||||
void test(`Ordinary Richese variable pool, ${lead} lead, Occupy ${cancel ? 'prevented' : 'active'} preserves native owners`, () => {
|
||||
const fixture = ecazOccupyRicheseFixture();
|
||||
const { game, before, actor } = resolve(fixture, lead, cancel);
|
||||
const ecazLoss = cancel ? lead === 'ecaz' ? 2 : 0 : 3;
|
||||
const allyLoss = cancel && lead === 'ecaz' ? 0 : 2;
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ecaz).forces[fixture.location], 5 - ecazLoss);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ecaz).tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, ecazLoss);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ally).forces[fixture.location], 4 - allyLoss);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ally).tanks - occupyRicheseSeat(before, fixture.ally).tanks, allyLoss);
|
||||
assert.equal(occupyRicheseSeat(before, actor).spice - occupyRicheseSeat(game, actor).spice,
|
||||
2 - cityIncome(game, actor, fixture.location));
|
||||
const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
|
||||
assert.equal(occupyRicheseSeat(game, nonLead).spice,
|
||||
occupyRicheseSeat(before, nonLead).spice + cityIncome(game, nonLead, fixture.location));
|
||||
assert.equal(occupyRicheseSeat(game, fixture.opponent).forces[fixture.location] ?? 0, 0);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.opponent).tanks - occupyRicheseSeat(before, fixture.opponent).tanks, 8);
|
||||
});
|
||||
|
||||
for (const role of ['ally', 'opponent'] as const) for (const marker of [0, 3, 5] as const)
|
||||
for (const reserves of [0, 2, 20]) for (const lead of ['ecaz', 'ally'] as const)
|
||||
void test(`Concealed Richese ${role} ${marker}, reserves ${reserves}, ${lead} lead resolves only after both seals`, () => {
|
||||
const fixture = ecazOccupyRicheseFixture({ allyFaction: role === 'ally' ? 'richese' : 'guild',
|
||||
opponentFaction: role === 'opponent' ? 'richese' : 'guild', marker, richeseReserves: reserves });
|
||||
conserve(fixture.game);
|
||||
const prepared = openOccupyRichese(fixture, lead);
|
||||
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||||
const rival = role === 'ally' ? fixture.opponent : actor;
|
||||
assert.equal(viewGame(prepared, rival).battle!.opponentForces, null,
|
||||
'The rival cannot receive a prospective denomination through its opponent pool.');
|
||||
if (role === 'ally') {
|
||||
assert.equal(viewGame(prepared, fixture.opponent).battle!.ecazOccupy!.profile, null,
|
||||
'Rival max dial and physical ally counters must not disclose the marker.');
|
||||
assert.equal(viewGame(prepared, actor).battle!.ownForces!.normal, Math.min(marker, reserves));
|
||||
}
|
||||
const { game, before, actor: selected, support } = resolve(fixture, lead);
|
||||
const materialized = Math.min(marker, reserves);
|
||||
const richeseLoss = role === 'opponent' ? materialized : Math.min(2, materialized);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.richese).forces[fixture.location] ?? 0, materialized - richeseLoss);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.richese).tanks - occupyRicheseSeat(before, fixture.richese).tanks, richeseLoss);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.richese).reserves, reserves - materialized);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ecaz).forces[fixture.location], 2);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ecaz).tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, 3);
|
||||
assert.equal(occupyRicheseSeat(before, selected).spice - occupyRicheseSeat(game, selected).spice,
|
||||
support - cityIncome(game, selected, fixture.location));
|
||||
assert.equal(occupyRicheseSeat(game, fixture.opponent).forces[fixture.location] ?? 0, 0);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.opponent).tanks - occupyRicheseSeat(before, fixture.opponent).tanks,
|
||||
role === 'opponent' ? materialized : 8);
|
||||
});
|
||||
|
||||
void test('Original concealed Richese presence permits a zero prospective pool, never an ordinary empty ally', () => {
|
||||
const forces = { normal: 0, elite: 0, eliteStrength: 2 as const, freeSupport: false };
|
||||
const input: EcazOccupyBattleInput = { battleOrderActor: 'ecaz', lead: 'ecaz', canceled: false,
|
||||
ecaz: { id: 'ecaz', faction: 'ecaz', ally: 'richese', forces: { ...forces, normal: 5 } },
|
||||
ally: { id: 'richese', faction: 'richese', ally: 'ecaz', forces } };
|
||||
const saved = structuredClone(input);
|
||||
assert.throws(() => quoteEcazOccupyBattle(input), /storm-connected fighters/);
|
||||
const profile = quoteEcazOccupyBattle({ ...input, ally: { ...input.ally, noFieldPresence: true } });
|
||||
assert.equal(profile.maxDial, 3);
|
||||
assert.equal(profile.maxSupport, 0);
|
||||
assert.deepEqual(quoteEcazOccupyDial(profile, 3, 0).options, [{ normal: 0, elite: 0, paidNormal: 0, paidElite: 0 }]);
|
||||
assert.throws(() => quoteEcazOccupyBattle({ ...input, ally: { ...input.ally, faction: 'guild', noFieldPresence: true } }),
|
||||
/Only an original concealed Richese ally/);
|
||||
assert.deepEqual(input, saved);
|
||||
});
|
||||
|
||||
for (const role of ['ally', 'opponent'] as const) void test(`Mixed ordinary-plus-marker ${role} battle is rejected immutably`, () => {
|
||||
const fixture = ecazOccupyRicheseFixture({ allyFaction: role === 'ally' ? 'richese' : 'guild',
|
||||
opponentFaction: role === 'opponent' ? 'richese' : 'guild', marker: 5,
|
||||
richeseReserves: 2, mixedRicheseForces: 1 });
|
||||
const game = fixture.beforeBattle;
|
||||
const saved = structuredClone(game);
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === fixture.territory)!;
|
||||
assert.throws(() => applyAction(game, choice.chooser, { type: 'chooseBattle', territory: fixture.territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }), /Mixed ordinary-force and No-Field/);
|
||||
assert.deepEqual(game, saved);
|
||||
});
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const) void test(`Atreides cannot inspect concealed partner's ${lead} plan dial or whole plan`, () => {
|
||||
const fixture = ecazOccupyRicheseFixture({ opponentFaction: 'atreides', marker: 5, richeseReserves: 2 });
|
||||
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||||
assert.ok(fixture.game.decision?.kind === 'ecazBattleLead');
|
||||
const event = fixture.game.decision.event;
|
||||
let game = applyAction(fixture.game, fixture.ecaz, { type: 'decision', event, lead: actor });
|
||||
for (let attempt = 0; attempt < 100 && game.battle?.preparation?.kind !== 'prescience'; attempt++)
|
||||
game = nextOccupyRichese(game);
|
||||
assert.equal(game.battle!.preparation!.kind, 'prescience');
|
||||
let saved = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, fixture.opponent, { type: 'prescience', field: 'dial' }), /may not inspect/);
|
||||
assert.deepEqual(game, saved);
|
||||
game = applyAction(game, fixture.opponent, { type: 'declineBattlePower' });
|
||||
for (let attempt = 0; attempt < 100 && game.decision?.kind !== 'fullPlanOffer'; attempt++) game = nextOccupyRichese(game);
|
||||
assert.equal(game.decision!.kind, 'fullPlanOffer');
|
||||
const karama = occupyRicheseSeat(game, fixture.opponent).hand.find(c => c.effect === 'karama')!;
|
||||
saved = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, fixture.opponent, { type: 'card', mode: 'special', card: karama.id, target: actor }),
|
||||
/Whole-plan special prescience against a No-Field/);
|
||||
assert.deepEqual(game, saved);
|
||||
assert.ok(occupyRicheseSeat(game, fixture.richese).noField!.deployed);
|
||||
});
|
||||
|
||||
for (const marker of [0, 5] as const) for (const lead of ['ecaz', 'ally'] as const)
|
||||
void test(`Prevented Occupy with marker ${marker} retains ${lead}'s own pool and original physical reveal`, () => {
|
||||
const fixture = ecazOccupyRicheseFixture({ marker, richeseReserves: 2 });
|
||||
const { game, before, actor, support } = resolve(fixture, lead, true);
|
||||
const materialized = Math.min(marker, 2);
|
||||
const ecazLoss = lead === 'ecaz' ? 2 : 0;
|
||||
const richeseLoss = lead === 'ally' ? materialized : 0;
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ecaz).forces[fixture.location], 5 - ecazLoss);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ecaz).tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, ecazLoss);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.richese).forces[fixture.location] ?? 0, materialized - richeseLoss);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.richese).tanks - occupyRicheseSeat(before, fixture.richese).tanks, richeseLoss);
|
||||
assert.equal(occupyRicheseSeat(before, actor).spice - occupyRicheseSeat(game, actor).spice,
|
||||
support - cityIncome(game, actor, fixture.location));
|
||||
});
|
||||
|
||||
void test('Prevented Occupy makes selected Ecaz own dial and whole plan inspectable without revealing Richese', () => {
|
||||
const fixture = ecazOccupyRicheseFixture({ opponentFaction: 'atreides', marker: 5, richeseReserves: 2 });
|
||||
const counter = 'choam';
|
||||
const karama = stageEcazOccupyCard(fixture.game, counter, card => card.effect === 'karama');
|
||||
assert.ok(fixture.game.decision?.kind === 'ecazBattleLead');
|
||||
const event = fixture.game.decision.event;
|
||||
let game = applyAction(fixture.game, fixture.ecaz, { type: 'decision', event, lead: fixture.ecaz });
|
||||
game = applyAction(game, counter, { type: 'card', mode: 'cancel', card: karama });
|
||||
for (let attempt = 0; attempt < 100 && game.battle?.preparation?.kind !== 'prescience'; attempt++)
|
||||
game = nextOccupyRichese(game);
|
||||
assert.equal(game.battle!.preparation!.kind, 'prescience');
|
||||
const ownProfile = viewGame(game, fixture.ecaz).battle!.ecazOccupy!.profile!;
|
||||
assert.equal(ownProfile.forceOwner, fixture.ecaz);
|
||||
assert.equal(ownProfile.forces.normal, 5);
|
||||
const asked = applyAction(game, fixture.opponent, { type: 'prescience', field: 'dial' });
|
||||
assert.equal(asked.battle!.prescience!.field, 'dial');
|
||||
assert.equal(asked.battle!.preparation!.owner, fixture.ecaz);
|
||||
assert.ok(occupyRicheseSeat(asked, fixture.richese).noField!.deployed);
|
||||
game = applyAction(game, fixture.opponent, { type: 'declineBattlePower' });
|
||||
for (let attempt = 0; attempt < 100 && game.decision?.kind !== 'fullPlanOffer'; attempt++) game = nextOccupyRichese(game);
|
||||
assert.equal(game.decision!.kind, 'fullPlanOffer');
|
||||
const inspectionCard = occupyRicheseSeat(game, fixture.opponent).hand.find(c => c.effect === 'karama')!;
|
||||
game = applyAction(game, fixture.opponent, { type: 'card', mode: 'special', card: inspectionCard.id, target: fixture.ecaz });
|
||||
assert.deepEqual(game.battle!.fullPlan, { owner: fixture.opponent, target: fixture.ecaz });
|
||||
assert.ok(occupyRicheseSeat(game, fixture.richese).noField!.deployed);
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { baseDeck, ixBattleCards, leaders, type Card } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from '../game/combat';
|
||||
import { quoteEcazOccupyBattle, type EcazOccupyBattleProfile } from '../game/ecaz-occupy-battle';
|
||||
import { quoteBattleResolution, BattleResolutionQuoteError, type BattleResolutionInput, type ResolutionCombatant } from '../game/battle-resolution-quote';
|
||||
import { stoneBurnerComparison, stoneBurnerPlanBlock, stoneBurnerCompulsionBlock, type StoneBurnerContext, type StoneBurnerOccupyContext, type StoneBurnerSide } from '../game/stone-burner';
|
||||
|
||||
const ordinary = (normal: number): CombatForces => ({
|
||||
normal, elite: 0, eliteStrength: 1, freeSupport: false,
|
||||
});
|
||||
const ix: CombatForces = {
|
||||
normal: 3, elite: 2, eliteStrength: 2,
|
||||
normalFixedHalf: true, freeSupport: false,
|
||||
};
|
||||
function profile(ecazNormal = 5, lead = 'ecaz-user', canceled = false, allyForces = ix): EcazOccupyBattleProfile {
|
||||
return quoteEcazOccupyBattle({
|
||||
battleOrderActor: 'ecaz-user',
|
||||
ecaz: { id: 'ecaz-user', faction: 'ecaz', ally: 'ally-user', forces: ordinary(ecazNormal) },
|
||||
ally: { id: 'ally-user', faction: 'ixians', ally: 'ecaz-user', forces: allyForces },
|
||||
lead, canceled,
|
||||
});
|
||||
}
|
||||
function occupyContext(p: EcazOccupyBattleProfile): StoneBurnerOccupyContext {
|
||||
return { fixedEcazDial: p.fixedEcazDial, ecazUndialed: p.canceled ? 0 : Math.floor(p.ecazForces.normal / 2) };
|
||||
}
|
||||
const opposite = (side: StoneBurnerSide): StoneBurnerSide => side === 'attacker' ? 'defender' : 'attacker';
|
||||
|
||||
void test('active Occupy compares odd/even Ecaz physical remainder plus native half-normal and double-elite counters in either slot', () => {
|
||||
for (const ecazNormal of [5, 6])
|
||||
for (const lead of ['ecaz-user', 'ally-user'])
|
||||
for (const slot of ['attacker', 'defender'] as const)
|
||||
for (const aggressor of ['attacker', 'defender'] as const) {
|
||||
const p = profile(ecazNormal, lead);
|
||||
const ownUndialed = Math.floor(ecazNormal / 2) + 3;
|
||||
const own = [p.forces, p.fixedEcazDial + 2.5, 1] as const;
|
||||
const other = [ordinary(ownUndialed + 1), 1, 1] as const;
|
||||
const result = slot === 'attacker'
|
||||
? stoneBurnerComparison(...own, ...other, aggressor, { [slot]: occupyContext(p) })
|
||||
: stoneBurnerComparison(...other, ...own, aggressor, { [slot]: occupyContext(p) });
|
||||
assert.deepEqual(result, { winner: aggressor, attacker: [ownUndialed], defender: [ownUndialed] });
|
||||
// The fixed commitment is mandatory even when the native variable pool could dial zero.
|
||||
assert.notEqual(stoneBurnerPlanBlock(p.forces, p.fixedEcazDial - 0.5, 0, other[0], slot, aggressor, { [slot]: occupyContext(p) }), null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('canceled Occupy compares only the selected lead own native pool, never the other member undialed army', () => {
|
||||
for (const lead of ['ecaz-user', 'ally-user']) {
|
||||
const p = profile(9, lead, true, ordinary(3));
|
||||
const expected = lead === p.ecaz ? 7 : 1;
|
||||
const result = stoneBurnerComparison(p.forces, 1, 0, ordinary(4), 0, 0, 'attacker', { attacker: occupyContext(p) });
|
||||
assert.deepEqual(result, { winner: expected > 4 ? 'attacker' : 'defender', attacker: [expected], defender: [4] });
|
||||
}
|
||||
});
|
||||
|
||||
void test('active mandatory Ecaz survivors can make a typed native allocation finishable without selecting its casualties', () => {
|
||||
const emperor: CombatForces = { normal: 5, elite: 1, eliteStrength: 2, freeSupport: false };
|
||||
const active = profile(5, 'ecaz-user', false, emperor);
|
||||
const canceled = profile(5, 'ally-user', true, emperor);
|
||||
assert.deepEqual(stoneBurnerComparison(active.forces, 6, 1, ordinary(2), 0, 0, 'attacker', { attacker: occupyContext(active) }), {
|
||||
winner: 'attacker', attacker: [3, 4, 5], defender: [2],
|
||||
});
|
||||
assert.equal(stoneBurnerPlanBlock(active.forces, 6, 1, ordinary(2), 'attacker', 'attacker', { attacker: occupyContext(active) }), null);
|
||||
assert.deepEqual(stoneBurnerComparison(canceled.forces, 3, 1, ordinary(2), 0, 0, 'attacker', { attacker: occupyContext(canceled) }), {
|
||||
winner: null, attacker: [1, 2, 3], defender: [2],
|
||||
});
|
||||
assert.notEqual(stoneBurnerPlanBlock(canceled.forces, 3, 1, ordinary(2), 'attacker', 'attacker', { attacker: occupyContext(canceled) }), null);
|
||||
});
|
||||
|
||||
void test('public hypothetical native pools remain authoritative under the same fixed Occupy contribution', () => {
|
||||
const context: StoneBurnerContext = { defender: { fixedEcazDial: 3, ecazUndialed: 2 } };
|
||||
const elitesOnly = { ...ix, normal: 0 };
|
||||
const concealedNormals = { ...ix, normal: 5 };
|
||||
assert.equal(stoneBurnerPlanBlock(ordinary(4), 0, 0, elitesOnly, 'attacker', 'attacker', context), null);
|
||||
assert.notEqual(stoneBurnerPlanBlock(ordinary(4), 0, 0, concealedNormals, 'attacker', 'attacker', context), null);
|
||||
assert.deepEqual(stoneBurnerComparison(ordinary(4), 0, 0, elitesOnly, 3, 0, 'attacker', context).defender, [4]);
|
||||
assert.deepEqual(stoneBurnerComparison(ordinary(4), 0, 0, concealedNormals, 3, 0, 'attacker', context).defender, [9]);
|
||||
});
|
||||
|
||||
function physicalChoices(pool: CombatForces, dial: number, support: number, p?: StoneBurnerOccupyContext): number[] {
|
||||
const offset = p?.ecazUndialed ?? 0;
|
||||
return casualtyOptions(pool, dial - (p?.fixedEcazDial ?? 0), support)
|
||||
.map(loss => offset + pool.normal + pool.elite - loss.normal - loss.elite);
|
||||
}
|
||||
function publicPlans(pool: CombatForces, p?: StoneBurnerOccupyContext): { dial: number; support: number; totals: number[] }[] {
|
||||
const plans: { dial: number; support: number; totals: number[] }[] = [];
|
||||
for (let half = 0; half <= maxCombatDial(pool) * 2; half++)
|
||||
for (let support = 0; support <= maxCombatSupport(pool); support++) {
|
||||
const dial = half / 2 + (p?.fixedEcazDial ?? 0);
|
||||
const totals = physicalChoices(pool, dial, support, p);
|
||||
if (totals.length) plans.push({ dial, support, totals });
|
||||
}
|
||||
return plans;
|
||||
}
|
||||
function canFinish(own: number[], otherPlans: { totals: number[] }[], side: StoneBurnerSide, aggressor: StoneBurnerSide): boolean {
|
||||
return otherPlans.every(other => {
|
||||
const winners = new Set(own.flatMap(a => other.totals.map(d => a === d ? aggressor : a > d ? side : opposite(side))));
|
||||
return winners.size === 1;
|
||||
});
|
||||
}
|
||||
|
||||
void test('Occupy precommit and Voice finishability match exhaustive physical-allocation outcomes for own and opposing active/canceled profiles', () => {
|
||||
for (const canceled of [false, true])
|
||||
for (const lead of ['ecaz-user', 'ally-user'])
|
||||
for (const slot of ['attacker', 'defender'] as const)
|
||||
for (const side of ['attacker', 'defender'] as const)
|
||||
for (const aggressor of ['attacker', 'defender'] as const) {
|
||||
const p = profile(5, lead, canceled);
|
||||
const context: StoneBurnerContext = { [slot]: occupyContext(p) };
|
||||
const own = side === slot ? p.forces : ordinary(4);
|
||||
const other = side === slot ? ordinary(4) : p.forces;
|
||||
const ownProfile = context[side];
|
||||
const otherPlans = publicPlans(other, context[opposite(side)]);
|
||||
const ownPlans = publicPlans(own, ownProfile);
|
||||
for (const plan of ownPlans) {
|
||||
assert.equal(stoneBurnerPlanBlock(own, plan.dial, plan.support, other, side, aggressor, context) === null,
|
||||
canFinish(plan.totals, otherPlans, side, aggressor),
|
||||
JSON.stringify({ canceled, lead, slot, side, aggressor, plan }));
|
||||
}
|
||||
const zeroSpiceCompletion = ownPlans.some(plan => plan.support === 0 && canFinish(plan.totals, otherPlans, side, aggressor));
|
||||
assert.equal(stoneBurnerCompulsionBlock(own, other, side, aggressor, context) === null, zeroSpiceCompletion);
|
||||
}
|
||||
});
|
||||
|
||||
const catalog = (): Card[] => [...baseDeck(), ...ixBattleCards(), ...richeseCards()];
|
||||
function resolution(p: EcazOccupyBattleProfile, slot: StoneBurnerSide, dial = p.fixedEcazDial + 2.5, support = 1, otherUndialed = 5): BattleResolutionInput {
|
||||
const combatant = (id: string, faction: 'ecaz' | 'ixians' | 'guild', forces: CombatForces, dial: number, support: number): ResolutionCombatant => {
|
||||
const leader = { ...leaders(faction)[0] };
|
||||
return { id, faction, spice: 10, hand: [], leader, forces,
|
||||
plan: { dial, support, leader: leader.id, weapon: null, defense: null } };
|
||||
};
|
||||
const own = combatant(p.lead, p.lead === p.ecaz ? 'ecaz' : 'ixians', p.forces, dial, support);
|
||||
own.ally = p.lead === p.ecaz ? p.ally : p.ecaz;
|
||||
const other = combatant('opponent', 'guild', ordinary(otherUndialed + 1), 1, 1);
|
||||
const cards = catalog();
|
||||
const stone = cards.find(card => card.effect === 'stoneBurner')!;
|
||||
own.hand = [stone];
|
||||
own.plan.weapon = stone.id;
|
||||
own.stoneMode = 'ignore';
|
||||
return {
|
||||
ecazOccupy: p, advanced: true, turn: 2, territory: 'carthag',
|
||||
attacker: slot === 'attacker' ? own : other,
|
||||
defender: slot === 'defender' ? own : other,
|
||||
participants: [
|
||||
{ id: p.ecaz, faction: 'ecaz', ally: p.ally },
|
||||
{ id: p.ally, faction: 'ixians', ally: p.ecaz },
|
||||
{ id: other.id, faction: other.faction },
|
||||
],
|
||||
voters: [p.ecaz, p.ally].map(id => ({ id, beneficiary: p.lead, called: false, traitors: [] }))
|
||||
.concat([{ id: other.id, beneficiary: other.id, called: false, traitors: [] }]),
|
||||
physicalCards: cards, pendingAuditorPresent: false, pendingRetentionPresent: false,
|
||||
};
|
||||
}
|
||||
|
||||
void test('normal Stone Occupy resolution honors either lead/slot/aggressor and settles original physical owners and card custody', () => {
|
||||
for (const ecazNormal of [5, 6])
|
||||
for (const lead of ['ecaz-user', 'ally-user'])
|
||||
for (const slot of ['attacker', 'defender'] as const)
|
||||
for (const aggressor of ['attacker', 'defender'] as const) {
|
||||
const p = profile(ecazNormal, lead);
|
||||
const count = Math.floor(ecazNormal / 2) + 3;
|
||||
const input = resolution(p, slot, p.fixedEcazDial + 2.5, 1, count);
|
||||
input.aggressor = input[aggressor].id;
|
||||
const before = structuredClone(input);
|
||||
const quote = quoteBattleResolution(input);
|
||||
assert.equal(quote.result, 'normal');
|
||||
assert.equal(quote.winner, input[aggressor].id);
|
||||
assert.deepEqual(quote.stone, { winner: aggressor, attacker: [count], defender: [count] });
|
||||
assert.deepEqual(quote.leaderDeaths, { attacker: false, defender: false });
|
||||
assert.deepEqual(quote.payments.map(payment => payment.ownPayment), [1, 1]);
|
||||
if (aggressor === slot) {
|
||||
assert.deepEqual(quote.fixedLosses, [{ owner: p.ecaz, normal: Math.ceil(ecazNormal / 2), elite: 0 }]);
|
||||
assert.equal(quote.casualties?.owner, p.ally);
|
||||
assert.deepEqual(quote.casualties?.options, [{ normal: 1, elite: 1, paidNormal: 0, paidElite: 1 }]);
|
||||
assert.deepEqual(quote.destroyedArmies, ['opponent']);
|
||||
assert.deepEqual(quote.winnerCards, [input[slot].plan.weapon]);
|
||||
} else {
|
||||
assert.deepEqual(quote.destroyedArmies, [p.ecaz, p.ally]);
|
||||
assert.deepEqual(quote.discarded, [{ player: p.lead, card: input[slot].plan.weapon }]);
|
||||
}
|
||||
assert.deepEqual(input, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('canceled Stone resolution uses own pool and leaves the winning nonparticipating member untouched', () => {
|
||||
for (const lead of ['ecaz-user', 'ally-user']) {
|
||||
const p = profile(9, lead, true, ordinary(3));
|
||||
const input = resolution(p, 'attacker', 1, 0, 4);
|
||||
const quote = quoteBattleResolution(input);
|
||||
assert.equal(quote.winner, lead === p.ecaz ? p.ecaz : 'opponent');
|
||||
assert.deepEqual(quote.stone?.attacker, [lead === p.ecaz ? 7 : 1]);
|
||||
if (lead === p.ecaz) {
|
||||
assert.equal(quote.casualties?.owner, p.ecaz);
|
||||
assert.deepEqual(quote.casualties?.options, [{ normal: 2, elite: 0, paidNormal: 0, paidElite: 0 }]);
|
||||
assert.deepEqual(quote.fixedLosses, []);
|
||||
assert.deepEqual(quote.destroyedArmies, ['opponent']);
|
||||
} else assert.deepEqual(quote.destroyedArmies, [p.ecaz, p.ally]);
|
||||
}
|
||||
});
|
||||
|
||||
void test('copied Stone uses the same Occupy physical comparison but retains the winning physical Mirror', () => {
|
||||
const p = profile();
|
||||
const input = resolution(p, 'defender', 5.5, 1, 5);
|
||||
const original = input.defender.hand[0];
|
||||
const mirror = catalog().find(card => card.effect === 'mirrorWeapon')!;
|
||||
input.defender.hand = [mirror];
|
||||
input.defender.plan.weapon = mirror.id;
|
||||
input.attacker.hand = [original];
|
||||
input.attacker.plan.weapon = original.id;
|
||||
input.attacker.stoneMode = 'ignore';
|
||||
input.aggressor = p.lead;
|
||||
const quote = quoteBattleResolution(input);
|
||||
assert.equal(quote.winner, p.lead);
|
||||
assert.deepEqual(quote.stone, { winner: 'defender', attacker: [5], defender: [5] });
|
||||
assert.deepEqual(quote.winnerCards, [mirror.id]);
|
||||
assert.deepEqual(quote.discarded, [{ player: 'opponent', card: original.id }]);
|
||||
input.defender.stoneMode = 'kill';
|
||||
assert.deepEqual(quoteBattleResolution(input).leaderDeaths, { attacker: true, defender: true });
|
||||
});
|
||||
|
||||
void test('true native typed ambiguity after the Ecaz offset remains unresolved and never mutates the sealed inputs', () => {
|
||||
const emperor: CombatForces = { normal: 5, elite: 1, eliteStrength: 2, freeSupport: false };
|
||||
const p = profile(5, 'ecaz-user', false, emperor);
|
||||
const input = resolution(p, 'attacker', 6, 1, 4);
|
||||
const before = structuredClone(input);
|
||||
assert.deepEqual(stoneBurnerComparison(p.forces, 6, 1, input.defender.forces, 1, 1, 'attacker', { attacker: occupyContext(p) }), {
|
||||
winner: null, attacker: [3, 4, 5], defender: [4],
|
||||
});
|
||||
assert.throws(() => quoteBattleResolution(input), BattleResolutionQuoteError);
|
||||
assert.deepEqual(input, before);
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeEcazOccupyGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game } from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import type { NoFieldValue } from '../game/richese-no-field';
|
||||
import { finishEcazOccupySetup, stageEcazOccupyCard } from './fixture-ecaz-occupy';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
|
||||
export type EcazOccupyRicheseOptions = {
|
||||
allyFaction?: 'richese' | 'ixians' | 'guild';
|
||||
opponentFaction?: 'richese' | 'choam' | 'atreides' | 'guild';
|
||||
marker?: NoFieldValue;
|
||||
richeseReserves?: number;
|
||||
mixedRicheseForces?: number;
|
||||
ecazForces?: number;
|
||||
omitPreview?: boolean;
|
||||
/** Original admitted fresh setup with its real authenticated seat identities. */
|
||||
initial?: Game;
|
||||
};
|
||||
export type EcazOccupyRicheseFixture = {
|
||||
initial: Game; afterSetup: Game; beforeBattle: Game; game: Game;
|
||||
ecaz: string; ally: string; opponent: string; richese: string;
|
||||
territory: string; location: string; ecazForces: number; materialized: number | null;
|
||||
staging: string[];
|
||||
};
|
||||
export const occupyRicheseSeat = (game: Game, actor: string) => {
|
||||
const player = game.players.find(p => p.id === actor);
|
||||
assert.ok(player);
|
||||
return player;
|
||||
};
|
||||
const clean = (game: Game) => !game.response && !game.phaseOpening && !game.decision;
|
||||
export function nextOccupyRichese(game: Game): Game {
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'richeseAllyOpportunity')
|
||||
return applyAction(game, game.decision.player, { type: 'decision', decline: true });
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'faceDance')
|
||||
return applyAction(game, game.decision.player, { type: 'decision', reveal: false });
|
||||
const next = nextRicheseStrongholdsNativeStep(game);
|
||||
assert.ok(next, 'Native continuation must not manufacture a sealed plan.');
|
||||
return applyAction(game, next.actor, next.action);
|
||||
}
|
||||
function advance(game: Game, predicate: (game: Game) => boolean): Game {
|
||||
for (let attempt = 0; attempt < 1600; attempt++) {
|
||||
if (predicate(game)) return game;
|
||||
game = nextOccupyRichese(game);
|
||||
}
|
||||
throw Error('Native Occupy/Richese continuation did not reach its boundary.');
|
||||
}
|
||||
function orderBlow(game: Game, worm: boolean, position: number): void {
|
||||
const at = game.spiceDeck.findIndex((card, i) => i >= position && (worm
|
||||
? 'worm' in card && !card.greatMaker && !card.suppressed
|
||||
: 'territory' in card && card.territory !== 'carthag'));
|
||||
assert.ok(at >= position);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
function reserveBoard(game: Game): void {
|
||||
for (const player of game.players) {
|
||||
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0);
|
||||
player.forces = {};
|
||||
if (player.elites) {
|
||||
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0);
|
||||
player.elites.forces = {};
|
||||
}
|
||||
if (player.advisors) player.advisors = {};
|
||||
player.spice = 30;
|
||||
}
|
||||
}
|
||||
function place(game: Game, owner: string, location: string, count: number): void {
|
||||
const player = occupyRicheseSeat(game, owner);
|
||||
assert.ok(Number.isSafeInteger(count) && count >= 0 && count <= player.reserves);
|
||||
player.reserves -= count;
|
||||
if (count) player.forces[location] = (player.forces[location] ?? 0) + count;
|
||||
}
|
||||
|
||||
/** Real fresh Advanced family deal and natural Nexus alliance. Explicitly
|
||||
* controlled conserved board/bank/card staging follows original setup; only
|
||||
* native shipment creates the concealed marker, and actual chooseBattle admits
|
||||
* the coalition. No phase, faction, alliance, plan or marker is assigned. */
|
||||
export function ecazOccupyRicheseFixture(options: EcazOccupyRicheseOptions = {}): EcazOccupyRicheseFixture {
|
||||
const allyFaction = options.allyFaction ?? 'richese';
|
||||
const opponentFaction = options.opponentFaction ?? 'guild';
|
||||
assert.notEqual(allyFaction, opponentFaction);
|
||||
const roster: FactionId[] = allyFaction === 'ixians' && opponentFaction === 'choam'
|
||||
? ['ecaz', 'ixians', 'choam', 'richese', 'moritani', 'tleilaxu']
|
||||
: [...new Set<FactionId>(['ecaz', allyFaction, opponentFaction, 'richese', 'choam'])];
|
||||
const expansions: Game['expansions'] = ['ecaz', 'choam', ...(roster.includes('ixians') ? ['ix'] as const : [])];
|
||||
let game: Game;
|
||||
if (options.initial) {
|
||||
game = structuredClone(options.initial);
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.equal(game.turn, 1);
|
||||
assert.equal(game.advanced, true);
|
||||
assert.equal(game.ecazOccupyPreview, true);
|
||||
assert.deepEqual(game.players.map(p => p.faction), roster);
|
||||
assert.deepEqual(game.expansions, expansions);
|
||||
} else {
|
||||
game = createGame('ECAZOCCUPYRICHESE', newPlayer(roster[0], roster[0], roster[0]), true, expansions);
|
||||
for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
|
||||
for (let i = roster.length - 1; i >= 0; i--) {
|
||||
const position = i + 1;
|
||||
if (viewGame(game, roster[i]).playerPositions[roster[i]] !== position)
|
||||
game = applyAction(game, roster[i], { type: 'seatPosition', position });
|
||||
}
|
||||
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
||||
game = initializeEcazOccupyGameForAudit(game);
|
||||
}
|
||||
const initial = structuredClone(game);
|
||||
game = finishEcazOccupySetup(game);
|
||||
const afterSetup = structuredClone(game);
|
||||
const factionSeat = (faction: FactionId) => {
|
||||
const player = game.players.find(p => p.faction === faction);
|
||||
assert.ok(player);
|
||||
return player.id;
|
||||
};
|
||||
const ecaz = factionSeat('ecaz'), ally = factionSeat(allyFaction);
|
||||
const opponent = factionSeat(opponentFaction), richese = factionSeat('richese');
|
||||
reserveBoard(game);
|
||||
const staging = ['After original faction setup: return each physical board counter to its own reserves and stage thirty-spice bank balances.'];
|
||||
orderBlow(game, false, 0); orderBlow(game, false, 1);
|
||||
game = advance(game, g => g.turn === 2 && g.phase === 0 && clean(g));
|
||||
orderBlow(game, true, 0); orderBlow(game, false, 1); orderBlow(game, false, 2);
|
||||
game = advance(game, g => g.phase === 1 && !!g.nexus && !g.spiceWindow && !g.spiceResolution && clean(g));
|
||||
game = applyAction(game, ecaz, { type: 'alliance', target: ally });
|
||||
game = applyAction(game, ally, { type: 'alliance', target: ecaz });
|
||||
game = advance(game, g => g.phase === 5 && clean(g));
|
||||
reserveBoard(game);
|
||||
const battleTerritory = 'carthag';
|
||||
const sector = territory(battleTerritory).sectors[0];
|
||||
const location = `${battleTerritory}:${sector}`;
|
||||
const ecazForces = options.ecazForces ?? 5;
|
||||
for (const token of game.ecazAmbassadors?.tokens ?? []) {
|
||||
if (token.zone !== 'placed' || token.location !== battleTerritory) continue;
|
||||
token.zone = 'supply';
|
||||
token.location = null;
|
||||
staging.push('Return the actual Carthag Ambassador to its existing supply for this clean controlled battle position.');
|
||||
}
|
||||
place(game, ecaz, location, ecazForces);
|
||||
if (ally !== richese || options.marker === undefined) place(game, ally, location, 4);
|
||||
if (opponent !== richese || options.marker === undefined) place(game, opponent, location, 8);
|
||||
const mixed = options.mixedRicheseForces ?? 0;
|
||||
if (options.marker !== undefined) {
|
||||
assert.ok(ally === richese || opponent === richese);
|
||||
const reserves = options.richeseReserves ?? 20;
|
||||
assert.ok(Number.isSafeInteger(reserves) && reserves >= 0 && reserves + mixed <= 20);
|
||||
place(game, richese, 'polar_sink:0', occupyRicheseSeat(game, richese).reserves - reserves - mixed);
|
||||
place(game, richese, location, mixed);
|
||||
staging.push(`Conserve Richese counters: ${mixed} ordinary battle counters, ${reserves} reserves, remainder in Polar Sink; native No-Field ${options.marker} shipment remains concealed.`);
|
||||
}
|
||||
staging.push(`Conserve ${ecazForces} Ecaz counters and ordinary allied/opposing counters in Carthag; original Nexus alliance is unchanged.`);
|
||||
stageEcazOccupyCard(game, opponent, card => card.effect === 'karama');
|
||||
let shipped = false;
|
||||
while (game.phase === 5) {
|
||||
if (!clean(game)) { game = nextOccupyRichese(game); continue; }
|
||||
const actor = game.active!;
|
||||
if (actor === richese && options.marker !== undefined && !shipped) {
|
||||
const player = occupyRicheseSeat(game, richese);
|
||||
const token = player.noField!.tokens.find(t => t.value === options.marker)!;
|
||||
assert.ok(token);
|
||||
const action: Action = { type: 'ship', territory: battleTerritory, sector,
|
||||
noField: token.id, event: player.noFieldEvent };
|
||||
game = applyAction(game, richese, action);
|
||||
game = advance(game, clean);
|
||||
shipped = true;
|
||||
}
|
||||
game = applyAction(game, actor, { type: 'endMovement' });
|
||||
}
|
||||
game = advance(game, clean);
|
||||
if (options.omitPreview) {
|
||||
delete game.ecazOccupyPreview;
|
||||
staging.push('Before actual battle admission: omit the audit label; keep the native original setup inventory and rule composition unchanged.');
|
||||
}
|
||||
const beforeBattle = structuredClone(game);
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === battleTerritory);
|
||||
assert.ok(choice, 'Concealed zero remains actual native battle presence.');
|
||||
// Mixed ownership fixtures stop before admission so rejection is testable.
|
||||
if (!mixed) game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: battleTerritory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
|
||||
return { initial, afterSetup, beforeBattle, game, ecaz, ally, opponent, richese,
|
||||
territory: battleTerritory, location, ecazForces,
|
||||
materialized: options.marker === undefined ? null : Math.min(options.marker, options.richeseReserves ?? 20), staging };
|
||||
}
|
||||
export function openOccupyRichese(fixture: EcazOccupyRicheseFixture, lead: 'ecaz' | 'ally', cancel = false): Game {
|
||||
let game = fixture.game;
|
||||
assert.equal(game.decision?.kind, 'ecazBattleLead');
|
||||
game = applyAction(game, fixture.ecaz, { type: 'decision', event: game.decision!.event,
|
||||
lead: lead === 'ecaz' ? fixture.ecaz : fixture.ally });
|
||||
if (cancel) {
|
||||
assert.equal(game.response?.kind, 'ecazOccupy');
|
||||
const card = occupyRicheseSeat(game, fixture.opponent).hand.find(c => c.effect === 'karama')!;
|
||||
game = applyAction(game, fixture.opponent, { type: 'card', mode: 'cancel', card: card.id });
|
||||
}
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (game.battle && !game.battle.preparation && (!game.battle.preLeader || game.battle.preLeader.closed) && clean(game)) return game;
|
||||
game = nextOccupyRichese(game);
|
||||
}
|
||||
throw Error('Original Richese preleader and Occupy responses did not finish.');
|
||||
}
|
||||
export function finishOccupyRichese(state: Game): Game {
|
||||
let game = state;
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
if (!game.battle && clean(game) && !game.pendingTreacheryDiscard) return game;
|
||||
game = nextOccupyRichese(game);
|
||||
}
|
||||
throw Error('Native Occupy/Richese reveal aftermath did not finish.');
|
||||
}
|
||||
@@ -92,16 +92,11 @@ void test('unknown is not false: decisive AND/OR branches dominate regardless of
|
||||
void test('compilation freezes current answers once, not a callback or later fact state', () => {
|
||||
const player = { hand: [], traitors: [], traitorChoices: [], spice: 7 };
|
||||
const asked = decode(group('or', fact(), shipment()));
|
||||
let reads = 0;
|
||||
const frozen = compileMixedShipmentExpression(asked, (f) => {
|
||||
reads++;
|
||||
return truthFactAnswer(player, f);
|
||||
});
|
||||
const frozen = compileMixedShipmentExpression(asked, (f) => truthFactAnswer(player, f));
|
||||
player.spice = 0;
|
||||
assert.equal(matchesShipment({ claim: frozen }, null), true);
|
||||
assert.equal(matchesShipment({ claim: JSON.parse(JSON.stringify(frozen)) as ShipmentExpression },
|
||||
{ territory: 'arrakeen', amount: 1 }), true);
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(matchesShipment({
|
||||
claim: compileMixedShipmentExpression(asked, (f) => truthFactAnswer(player, f)),
|
||||
}, null), false);
|
||||
|
||||
@@ -182,6 +182,11 @@ const ECAZ_OCCUPY_SCENARIOS: readonly Scenario[] = [
|
||||
...ECAZ_OCCUPY_BASE_SCENARIOS.map((scenario, index): Scenario => ({
|
||||
...scenario, ordinal: 192 + index, ecazTreachery: true,
|
||||
})),
|
||||
...[2, 3, 4, 5, 6].map((players): Scenario => ({
|
||||
ordinal: 215 + players - 2, profile: 'ecaz-occupy', rules: 'advanced',
|
||||
expansions: ['ecaz', 'choam'],
|
||||
roster: (['ecaz', 'richese', 'guild', 'fremen', 'emperor', 'beneGesserit'] as FactionId[]).slice(0, players),
|
||||
})),
|
||||
];
|
||||
const PAIRED_NEXUS_SCENARIOS: readonly Scenario[] = EXPANSION_ROSTER_SCENARIOS
|
||||
.map(scenario => ({
|
||||
@@ -321,7 +326,7 @@ function usage() {
|
||||
'[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' +
|
||||
'[--resume FAILED_GAME.json]\n' +
|
||||
'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant, Advanced Moritani assassination and skill profiles select their documented rosters. Stronghold-factions is Advanced only: Ixians + CHOAM, native Ixians + Tleilaxu, or native CHOAM + Richese with classic opponents, two through six seats; mixed four-native rosters also run at four through six seats. Selected Ix/CHOAM decks determine the canonical 47/35 Treachery Cards; Richese has a separate ten-card cache and all samples have six separate Stronghold Cards without other modules. Scenario names identify their native roster. --players requires a supported profile. Output must be a new private directory outside the checkout.' +
|
||||
'\nEcaz-occupy is Advanced only: native Ecaz plus classic/Ixian/Tleilaxu/CHOAM paired or mixed rosters, two through six seats; selected E3/E1/E2 sources determine ordinary33/47/35 cards. Explicit independent three-card samples additionally use36/50/38. No Richese mixed planning or other optional overlays.'
|
||||
'\nEcaz-occupy is Advanced only: native Ecaz plus classic/Ixian/Tleilaxu/CHOAM/Richese paired or mixed rosters, two through six seats; selected E3/E1/E2 sources determine ordinary33/47/35 cards. Explicit independent three-card samples additionally use36/50/38. Richese marker-only or ordinary pools retain original reveal timing; own mixed No-Field dialing, whole-plan inspection and optional overlays remain guarded.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -358,7 +363,7 @@ function scenarioName(scenario: Scenario) {
|
||||
return `${scenario.profile}-${native.join('-')}-${scenario.roster.length}-${scenario.rules}`;
|
||||
}
|
||||
if (scenario.profile === 'ecaz-occupy') {
|
||||
const native = scenario.roster.filter(faction => ['ixians', 'tleilaxu', 'choam'].includes(faction));
|
||||
const native = scenario.roster.filter(faction => ['ixians', 'tleilaxu', 'choam', 'richese'].includes(faction));
|
||||
if (native.length || scenario.ecazTreachery)
|
||||
return `${scenario.profile}${scenario.ecazTreachery ? '-treachery' : ''}${native.length ? '-' + native.join('-') : ''}-${scenario.roster.length}-${scenario.rules}`;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ async function main() {
|
||||
if (values.help) {
|
||||
console.log(
|
||||
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|banker-income|factions|stronghold-factions|nexus|moritani-assassinate|ecaz-treachery|ecaz-occupy|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement|ixian-betrayal|harkonnen-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Stronghold-factions opts a fresh ready 2–6-seat Advanced roster with at least one selected native Ixians, Tleilaxu, CHOAM or Richese faction and otherwise classic opponents into Stronghold Cards alone. Select one or both distinct Ix/CHOAM decks; each native faction requires its family deck. The selected decks supply 47 Ix or combined, or 35 CHOAM canonical physical Treachery Cards, with a separate ten-card Richese cache when Richese plays. Native setup choices, HMS placement, Face Dancers, Richese cache and CHOAM Auditor remain genuine; custody starts unowned and is claimed by actual END Mentat. It does not redeal, reset or retrofit a saved game. Banker-income opts a fresh 2–6-seat classic Basic/Advanced base-deck roster, or supported Basic Ixian/Tleilaxu/CHOAM/Moritani skill roster with its single required deck, into automatic deferred normal income and native Mentat collection with all fourteen Leader Skills. Original bank payer costs exclude the separate Tleilaxu free-revival award; already-earned spice stays with its original faction under the existing provisional policy. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts a fresh classic Basic/Advanced base-deck roster, or Basic Tleilaxu plus classic opponents with the original Ix47 deck and no optional Sandtrout, into the exact normal-auction purchased-card replacement with Nexus alone. Native Ixians, Harkonnen buyers and special purchase sources remain unsupported. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Harkonnen-betrayal opts a fresh 2–6-seat classic Basic/Advanced roster with native Harkonnen, base 33-card Treachery and Nexus alone into cancellation of an actual declared personal or allied Harkonnen Traitor Card after native counters, physical retirement and shuffle, and one private actual Mentat replacement. Modules, overlays, public starts and old-game retrofits remain excluded. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
|
||||
'\nEcaz-occupy (--profile ecaz-occupy) opts only a fresh ready 2–6-seat Advanced lobby with native Ecaz and classic/Ixian/Tleilaxu/CHOAM opponents (optional native Moritani). Distinct selected decks include ecaz and each native ix/choam family; ordinary33/47/35 cards retain real original E3/E1/E2 offers/HMS/Auditor/Face Dancers. Explicit --ecaz-treachery independently adds the three physical variant cards, making36/50/38. No Richese mixed planning, modules or overlays.' +
|
||||
'\nEcaz-occupy (--profile ecaz-occupy) opts only a fresh ready 2–6-seat Advanced lobby with native Ecaz and classic/Ixian/Tleilaxu/CHOAM/Richese opponents (optional native Moritani). Distinct selected decks include ecaz and each native ix/choam family; ordinary33/47/35 cards retain real original offers/HMS/Auditor/Face Dancers and Richese cache. Explicit --ecaz-treachery independently adds the three physical variant cards, making36/50/38. Native Richese ordinary and marker-only pools compose with selected lead plans; original own mixed No-Field dialing, whole-plan disclosure and optional overlays remain guarded.' +
|
||||
'\nLeader-skills accepts explicit --mentat-question for the private uniform Mentat preview. Existing classic/CHOAM skill starts and supported Basic Ixian/Tleilaxu/CHOAM/Moritani native rosters retain their original setup and all fourteen skills. Public activation and the empty-hand ruling remain pending; no other modules or Advanced native skill rosters are enabled.',
|
||||
);
|
||||
return;
|
||||
|
||||
Reference in new issue
Block a user