Compose source-clear Basic even-force Ecaz Occupy
This commit is contained in:
1 parent
63e57bd894
commit
5f082696a6
18 files changed
+861
-52
No files matched your search
@@ -16,6 +16,40 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Basic Ecaz Occupy even-force — bounded Development Verified:**
|
||||
fresh Basic `ecaz-occupy` now admits native Ecaz with classic or optional
|
||||
Moritani opponents and the exact `ecaz` deck, no optional modules. Genuine
|
||||
setup, original Nexus/worm alliance, shipments, Karama and Collection remain
|
||||
native; older played Basic tables are not retrofitted. For an even Ecaz force
|
||||
count the publisher page-8 paragraph and the page-15 example agree, so the
|
||||
contribution and ordinary-win Ecaz losses are E/2. Their odd-count examples
|
||||
still contradict each other, so an uncanceled odd Basic count is rejected at
|
||||
the original battle choice before any lead frame is saved — not rounded,
|
||||
canceled or replaced. Advanced keeps its separately authorized ceiling
|
||||
arithmetic. Basic has no Emperor/Fremen elite inventory and its supplied
|
||||
support normalizes to zero, so the shared editor blocks nonzero support rather
|
||||
than inventing a Basic payment rule.
|
||||
The pure coalition quote now requires an explicit `advanced` band with no
|
||||
default or alias; every production re-quote caller passes the actual band.
|
||||
Affected Basic/helper cases47/47, the affected Advanced/CLI union352/352 on a
|
||||
stable tree, types and lint pass. Five genuine Basic two-through-six-seat games
|
||||
were run: four complete with 2,090 accepted actions, no rejections and 54 JSON
|
||||
continuations; four-seat seed **20261299** is captured at the preserved
|
||||
odd-count conflict after 321 accepted actions. It is not rerun or bypassed.
|
||||
Those four completed natural games contain no coalition lead choice, so the
|
||||
actual coalition arithmetic evidence comes from the controlled programs and the
|
||||
human path. Authenticated human390px **FEJ6SBFB v14** kept the admitted
|
||||
Ecaz/Guild/Emperor seats and native wallets, then used a conserved even-force
|
||||
board after a genuine Nexus alliance. The table showed the correct Basic labels:
|
||||
a total-dial slider bounded2..6 (2 fixed Ecaz strength for four Ecaz forces,
|
||||
variable pool Guild's four) with no spice-support control. Real sealed plans
|
||||
(Ecaz Sanya4 plus total dial4 against Emperor Hasimir6) produced an8–6 Ecaz win;
|
||||
the log and state record Ecaz's two mandatory Occupy casualties, Guild's two
|
||||
variable casualties, and the Emperor's eight territory forces to the Tanks. Every
|
||||
seat remains at20 physical forces after refresh. See
|
||||
[Basic even-force source](ECAZ_OCCUPY_RULES.md#basic-even-force-composition--4-october-2026).
|
||||
Full combinations/assurance/public/deployed acceptance remain open.
|
||||
|
||||
**Native Ecaz ordinary-disc Skills — bounded Development Verified:**
|
||||
fresh Basic/Advanced Ecaz with classic opponents retains the exact `ecaz` deck,
|
||||
all fourteen skills, five native assignment discs and original Loyalty/setup.
|
||||
|
||||
@@ -1,6 +1,49 @@
|
||||
# Ecaz Occupy: Advanced source and historical conflict
|
||||
# Ecaz Occupy: Basic and Advanced source, and historical conflict
|
||||
|
||||
Historical primary-source audit, 2026-09-07, corrected after independent verification. The old publisher/designer casualty-rounding conflict remains unresolved as a publisher correction and for Basic. Its earlier false attribution to Jack Reda is still withdrawn. The later user-authorized Advanced source below selects explicit Advanced arithmetic; it does not retroactively certify that attribution.
|
||||
Historical primary-source audit, 2026-09-07, corrected after independent verification. The old publisher/designer casualty-rounding conflict remains unresolved as a publisher correction and for odd Basic counts. Its earlier false attribution to Jack Reda is still withdrawn. The later user-authorized Advanced source below selects explicit Advanced arithmetic, and the Basic even-force composition selects E/2 for even counts; neither retroactively certifies that attribution.
|
||||
|
||||
## Basic even-force composition — 4 October 2026
|
||||
|
||||
Fresh local `ecaz-occupy` now admits Basic as well as Advanced. Basic uses the
|
||||
exact `ecaz` Treachery deck with classic or optional Moritani opponents and no
|
||||
optional cards or modules. Genuine setup, original Nexus/worm alliance,
|
||||
shipments, Karama and Collection remain native; older played Basic tables are
|
||||
not retrofitted.
|
||||
|
||||
For an even Ecaz force count the publisher page-8 general paragraph and the
|
||||
page-15 example agree: contribution and ordinary-win Ecaz losses are E/2, and
|
||||
E/2 survive. The two pages still contradict each other for odd counts, so an
|
||||
uncanceled odd Basic count is rejected at the original battle choice before a
|
||||
lead frame is saved. It is not silently rounded, canceled or replaced with
|
||||
another fight. Advanced keeps its separately authorized ceiling arithmetic.
|
||||
|
||||
Basic has no Emperor/Fremen elite inventory (elites require Advanced or
|
||||
Homeworlds), and the engine normalizes a supplied Basic support amount to zero
|
||||
rather than inventing paid Basic support. The shared editor therefore blocks
|
||||
nonzero support; no additional Basic payment rule is created. Native Karama
|
||||
after the lead choice, selected-lead own-pool cancellation, original winner
|
||||
cards, traitor/mutual/explosion outcomes, joint three-stronghold coexistence and
|
||||
original Mentat victory all use the existing handlers.
|
||||
|
||||
Focused source-band cases plus native runtime cases cover E2/E4 under both
|
||||
leads, free full-strength ordinary commitments, typed Basic Fedaykin/Sardaukar
|
||||
strength-one casualties, cancellation under both leads, and the four legal
|
||||
policies. Five genuine Basic two-through-six-seat games were run: four complete
|
||||
with 2,090 accepted actions, no rejections and 54 JSON continuations; the
|
||||
four-seat game is captured at the preserved odd-count conflict and is not rerun
|
||||
or bypassed. Four completed natural games contain no coalition lead choice, so
|
||||
the actual coalition arithmetic evidence comes from the controlled programs and
|
||||
the human path rather than those games.
|
||||
|
||||
Authenticated human390px **FEJ6SBFB v14** kept the admitted Ecaz/Guild/Emperor
|
||||
seats and native wallets, then staged a conserved even-force board after the
|
||||
genuine Nexus alliance. The table showed the correct Basic labels: a total-dial
|
||||
slider bounded 2..6 (2 fixed Ecaz strength for four Ecaz forces plus Guild's
|
||||
four-force variable pool) with no spice-support control. Real sealed plans —
|
||||
Ecaz Sanya Ecaz 4 with total dial 4 against Emperor Hasimir Fenring 6 — produced
|
||||
an 8–6 Ecaz win. The log and saved state record Ecaz's two mandatory Occupy
|
||||
casualties, Guild's two variable casualties, and the Emperor's eight territory
|
||||
forces to the Tanks; every seat remains at 20 physical forces after refresh.
|
||||
|
||||
## Authorized Advanced cutover — 2 October 2026
|
||||
|
||||
@@ -232,7 +275,7 @@ Ecaz losses on ordinary allied win = ceil(E / 2)
|
||||
Ecaz survivors on ordinary allied win = floor(E / 2)
|
||||
```
|
||||
|
||||
The historical publisher/mirror example still conflicts for E=5, and the earlier audits correctly record that publisher gap. Basic still needs a supported interpretation. Advanced now uses the separately user-authorized source cutover above: its explicit ceil contribution and floor survivors select the arithmetic without claiming a designer correction. Fixed Ecaz losses stay independent of the ally's variable dial losses.
|
||||
The historical publisher/mirror example still conflicts for E=5, and the earlier audits correctly record that publisher gap. Basic now has the bounded even-force composition above: E/2 is source-clear for even counts, and the still-contradictory odd counts are rejected rather than rounded. Advanced uses the separately user-authorized source cutover above: its explicit ceil contribution and floor survivors select the arithmetic without claiming a designer correction. Fixed Ecaz losses stay independent of the ally's variable dial losses.
|
||||
|
||||
## Occupation, controller and battle order
|
||||
|
||||
@@ -285,4 +328,4 @@ Implement a pure, owner-labelled Occupy battle profile shared by validation, AI,
|
||||
|
||||
Acceptance examples should include Ecaz counts 1–5, either selected faction, all three relative storm positions, both win/loss outcomes, Basic/Advanced supported dials, Fremen free strength, advisor-only BG exclusion, printed and BG-converted cancellation, and JSON recovery after selection. Separate targeted follow-ups should cover the source-scoped special battle effects above, agreed/default desert allocation, Advanced Collection, and actual three-co-occupied victory. Keep the optional Homeworld/Nexus/Discovery modifiers explicitly outside the initial profile until their own faces and outcomes are wired; no ordinary Occupy rule may be used to bypass them.
|
||||
|
||||
The September audit changed no runtime and corrected the false designer attribution. The October Advanced cutover above is separate; Basic, optional/exotic interactions and independently unanswered cancellation details retain their historical boundaries.
|
||||
The September audit changed no runtime and corrected the false designer attribution. The October Advanced cutover and the October Basic even-force composition above are separate; optional/exotic interactions and independently unanswered cancellation details retain their historical boundaries.
|
||||
@@ -1,5 +1,65 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 4 October 2026 — Basic even-force Ecaz Occupy composition
|
||||
|
||||
Two bounded owners supplied the source-band coalition quote and native Basic
|
||||
runtime cases. One integration owner admitted Basic, migrated production
|
||||
re-quotes, corrected a genuine total-dial validation gap and connected the CLI
|
||||
help, canonical guide and source records.
|
||||
|
||||
The coalition quote now requires an explicit `advanced` boolean with no default
|
||||
or alias. Active Basic requires an even Ecaz count and uses E/2 for the mandatory
|
||||
contribution and ordinary-win Ecaz losses; Advanced keeps its ceiling arithmetic.
|
||||
Canceled Basic permits the preserved odd count through the original own-pool
|
||||
cancellation frame with zero fixed strength. The publisher page-8 paragraph and
|
||||
page-15 example still contradict each other for odd counts, so an uncanceled odd
|
||||
Basic battle is rejected at the original choice before a lead frame exists. It is
|
||||
not rounded, canceled or replaced. Basic has no Emperor/Fremen elite inventory
|
||||
(elites require Advanced or Homeworlds), and the engine normalizes a supplied
|
||||
Basic support amount to zero rather than inventing paid Basic support; the shared
|
||||
editor blocks nonzero support.
|
||||
|
||||
A genuine defect surfaced during integration: Basic `validatePlan` bounded the
|
||||
dial by the ordinary own pool before the coalition quote ran, so a legal total
|
||||
such as six was rejected. The quoted-force branch now includes the actual Occupy
|
||||
profile, and the Basic Prescience numeric dial branch includes the coalition
|
||||
lead; native feasibility still validates the exact total. The board projection
|
||||
gains an optional public Basic-coalition flag so only fresh Basic tables collapse
|
||||
the pair into one battle side; older played Basic games are not retrofitted.
|
||||
|
||||
The Basic fixture originally borrowed a wallet so the rival could afford its
|
||||
eight-force sand shipment. Removing that artificial wallet exposed the real
|
||||
unaffordable shipment; the fixture now transfers eight original rival reserves
|
||||
to the battlefield after the genuine Nexus instead, keeping native wallets,
|
||||
phases, alliance, plans and receipts. The genuine Basic fixture does not accept
|
||||
an authenticated lobby yet; the authenticated staging path is separate.
|
||||
|
||||
Affected Basic/helper cases47/47 and the affected Advanced/CLI union352/352 on a
|
||||
stable tree pass with types/lint. Five genuine Basic two-through-six-seat games
|
||||
were run: four complete with 2,090 accepted actions, no rejections and 54 JSON
|
||||
continuations; four-seat seed **20261299** is captured at the preserved
|
||||
odd-count conflict after 321 accepted actions. The captured game, report, state
|
||||
and trace remain outside the checkout and are not rerun or bypassed. The four
|
||||
completed natural games contain no coalition lead choice, so the coalition
|
||||
arithmetic evidence comes from the controlled programs and the human path, not
|
||||
from those games.
|
||||
|
||||
Authenticated human390px **FEJ6SBFB v14** kept the admitted Ecaz/Guild/Emperor
|
||||
seats and native wallets, then staged a conserved even-force board after the
|
||||
genuine Nexus alliance. The table showed the correct Basic labels: a total-dial
|
||||
slider bounded 2..6 (2 fixed Ecaz strength for four Ecaz forces plus Guild's
|
||||
four-force variable pool) with no spice-support control. Real sealed plans —
|
||||
Ecaz Sanya Ecaz 4 with total dial 4 against Emperor Hasimir Fenring 6 — produced
|
||||
an 8–6 Ecaz win. The log and saved state record Ecaz's two mandatory Occupy
|
||||
casualties, Guild's two variable casualties, and the Emperor's eight territory
|
||||
forces to the Tanks; every seat remains at 20 physical forces after refresh.
|
||||
The Basic Karama window, lead choice, plan editor and Collection continuation all
|
||||
use the existing shared controls.
|
||||
|
||||
No natural-history claim for controlled positions, reset, public activation,
|
||||
full-mode certification or deployed evidence; live **ab5c782** remains last
|
||||
observed.
|
||||
|
||||
## 4 October 2026 — native Ecaz ordinary-disc skills and temporary Duke composition
|
||||
|
||||
Two bounded owners supplied native setup/ordinary rescue/revival/Ambassador
|
||||
|
||||
+16
-1
@@ -85,7 +85,22 @@ Public, faction, optional-module and full-mode readiness gates are unchanged.
|
||||
Secrecy (physical page 16), Duke Vidal's ghola exception (30), Kull and other
|
||||
independent decisions are not reconciled by this cutover.
|
||||
|
||||
## Advanced Ecaz Occupy cutover — 2 October 2026
|
||||
## Basic Ecaz Occupy even-force boundary — 4 October 2026
|
||||
|
||||
Fresh bounded `ecaz-occupy` now admits Basic with the exact `ecaz` deck and
|
||||
classic or optional Moritani opponents, no optional modules. For an even Ecaz
|
||||
force count the publisher page-8 paragraph and the page-15 example agree:
|
||||
contribution and ordinary-win Ecaz losses are E/2. Their odd-count examples
|
||||
still contradict each other, so an uncanceled odd Basic count is rejected at
|
||||
the original battle choice before any lead frame is saved; it is not rounded,
|
||||
canceled or replaced. Advanced keeps its separately authorized ceiling
|
||||
arithmetic. Basic has no Emperor/Fremen elite inventory and a supplied Basic
|
||||
support amount normalizes to zero, so the shared editor blocks nonzero support
|
||||
rather than creating a Basic payment rule. This is a source-bound composition,
|
||||
not a publisher correction. See the
|
||||
[Basic even-force contract](ECAZ_OCCUPY_RULES.md#basic-even-force-composition--4-october-2026).
|
||||
|
||||
## Authorized Advanced Ecaz Occupy cutover — 2 October 2026
|
||||
|
||||
The authorized supplied revision resolves the Advanced combined army:
|
||||
**Occupy B, physical PDF page 32** selects Ecaz or its current ally as lead.
|
||||
|
||||
@@ -25,6 +25,8 @@ export type BoardSeat = Pick<Player, 'id' | 'faction' | 'ally' | 'advisors'> &
|
||||
ForcePresence;
|
||||
export type BoardContext = MobileBoard & {
|
||||
advanced: boolean;
|
||||
/** Fresh Basic coalition protocol; existing played Basic tables are not retrofitted. */
|
||||
ecazOccupyPreview?: boolean;
|
||||
storm: number;
|
||||
order: readonly string[];
|
||||
players: readonly BoardSeat[];
|
||||
@@ -121,7 +123,7 @@ export function quoteBattleBoard(g: BoardContext) {
|
||||
};
|
||||
const members = (seat: BoardSeat, site: string) => {
|
||||
const ally = players.find(other => other.id === seat.ally && other.ally === seat.id);
|
||||
return g.advanced && ally && (seat.faction === 'ecaz' || ally.faction === 'ecaz') &&
|
||||
return (g.advanced || g.ecazOccupyPreview === true) && ally && (seat.faction === 'ecaz' || ally.faction === 'ecaz') &&
|
||||
fighterCount(ally, site) > 0 ? [seat, ally] : [seat];
|
||||
};
|
||||
const emitted = new Set<string>();
|
||||
|
||||
@@ -138,9 +138,10 @@ function calculate(
|
||||
if (occupy) {
|
||||
const ecaz = input.players.find(player => player.id === occupy.ecaz);
|
||||
const ally = input.players.find(player => player.id === occupy.ally);
|
||||
requireCombat(input.advanced && ecaz?.faction === 'ecaz' && ally && combatants.includes(occupy.lead),
|
||||
requireCombat(input.advanced === occupy.advanced && ecaz?.faction === 'ecaz' && ally && combatants.includes(occupy.lead),
|
||||
'Occupy force powers need the actual Ecaz coalition and selected lead.');
|
||||
const profile = quoteEcazOccupyBattle({
|
||||
advanced: input.advanced,
|
||||
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,
|
||||
|
||||
@@ -27,6 +27,7 @@ export type EcazOccupyBattleSeat = Readonly<{
|
||||
noFieldPresence?: true;
|
||||
}>;
|
||||
export type EcazOccupyBattleInput = Readonly<{
|
||||
advanced: boolean;
|
||||
/** Original battle chooser, before Ecaz selects the coalition's plan actor. */
|
||||
battleOrderActor: string;
|
||||
ecaz: EcazOccupyBattleSeat;
|
||||
@@ -35,6 +36,7 @@ export type EcazOccupyBattleInput = Readonly<{
|
||||
canceled: boolean;
|
||||
}>;
|
||||
export type EcazOccupyBattleProfile = Readonly<{
|
||||
advanced: boolean;
|
||||
battleOrderActor: string;
|
||||
ecaz: string;
|
||||
ally: string;
|
||||
@@ -90,13 +92,17 @@ function requireBattle(condition: unknown, message: string): asserts condition {
|
||||
const text = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 0;
|
||||
|
||||
/** Authorized Advanced source: ceil(E/2) contributes at full strength and
|
||||
* costs no support. The selected lead supplies the plan and pays for the
|
||||
* actual ally's dial; selecting Ecaz does not transfer the ally's counters. */
|
||||
/** Advanced retains ceil(E/2); source-clear Basic admits only even E and uses
|
||||
* E/2. The increment is free. The selected lead supplies the plan and pays for
|
||||
* the actual ally's dial; selecting Ecaz does not transfer the ally's counters. */
|
||||
export function quoteEcazOccupyBattle(
|
||||
input: EcazOccupyBattleInput,
|
||||
): EcazOccupyBattleProfile {
|
||||
const { ecaz, ally, lead, canceled, battleOrderActor } = input;
|
||||
const { advanced, ecaz, ally, lead, canceled, battleOrderActor } = input;
|
||||
requireBattle(
|
||||
typeof advanced === 'boolean',
|
||||
'The Occupy profile needs an explicit Basic or Advanced rules band.',
|
||||
);
|
||||
requireBattle(
|
||||
ecaz && ally && text(ecaz.id) && text(ally.id) && ecaz.id !== ally.id &&
|
||||
ecaz.faction === 'ecaz' && ally.faction !== 'ecaz' &&
|
||||
@@ -131,10 +137,16 @@ export function quoteEcazOccupyBattle(
|
||||
typeof canceled === 'boolean',
|
||||
'The Occupy profile needs its pre-plan cancellation state.',
|
||||
);
|
||||
requireBattle(
|
||||
advanced || canceled || ecaz.forces.normal % 2 === 0,
|
||||
'Uncanceled Basic Occupy needs an even Ecaz force count; odd-force publisher casualty wording remains unresolved.',
|
||||
);
|
||||
const forceOwner = canceled ? lead : ally.id;
|
||||
const forces = forceOwner === ecaz.id ? ecaz.forces : ally.forces;
|
||||
const fixedEcazDial = canceled ? 0 : Math.ceil(ecaz.forces.normal / 2);
|
||||
const fixedEcazDial = canceled ? 0 :
|
||||
advanced ? Math.ceil(ecaz.forces.normal / 2) : ecaz.forces.normal / 2;
|
||||
return {
|
||||
advanced,
|
||||
battleOrderActor,
|
||||
ecaz: ecaz.id,
|
||||
ally: ally.id,
|
||||
|
||||
+22
-12
@@ -7338,7 +7338,7 @@ function ecazBattleProfile(g: Game): EcazOccupyBattleProfile | null {
|
||||
if (!b || !occupy?.lead) return null;
|
||||
const lead = occupy.lead;
|
||||
const ecaz = getPlayer(g, occupy.ecaz), ally = getPlayer(g, occupy.ally);
|
||||
requireRule(g.advanced && occupy.event === b.event && ecaz.faction === 'ecaz' &&
|
||||
requireRule(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) &&
|
||||
(occupyArmiesConnected(g, ecaz, ally, b.territory) ||
|
||||
@@ -7347,6 +7347,7 @@ function ecazBattleProfile(g: Game): EcazOccupyBattleProfile | null {
|
||||
'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({
|
||||
advanced: g.advanced,
|
||||
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,
|
||||
@@ -9230,10 +9231,13 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game {
|
||||
return initializeSetupGameForAudit(state, false, false, false, false, false, false, true);
|
||||
}
|
||||
function ecazOccupyCompositionSupported(g: Game): boolean {
|
||||
return g.advanced === true && Array.isArray(g.expansions) &&
|
||||
return typeof g.advanced === 'boolean' && Array.isArray(g.expansions) &&
|
||||
g.expansions.includes('ecaz') &&
|
||||
g.expansions.every(id => ['ecaz', 'ix', 'choam'].includes(id)) &&
|
||||
new Set(g.expansions).size === g.expansions.length &&
|
||||
(g.advanced || (g.expansions.length === 1 && g.expansions[0] === 'ecaz' &&
|
||||
!g.ecazTreachery && g.players.every(p => p.faction === 'ecaz' ||
|
||||
p.faction === 'moritani' || CLASSIC_FACTIONS[p.faction]))) &&
|
||||
g.players.length >= 2 && g.players.length <= 6 &&
|
||||
g.players.some(p => p.faction === 'ecaz') &&
|
||||
g.players.every(p => faction(p.faction).expansion === 'base' ||
|
||||
@@ -9244,10 +9248,10 @@ function ecazOccupyCompositionSupported(g: Game): boolean {
|
||||
!g.discoveryStash && !g.greatMaker &&
|
||||
(g.ecazTreachery === undefined || g.ecazTreachery === true);
|
||||
}
|
||||
/** Fresh native Advanced Occupy profile; public starts and Basic are unchanged. */
|
||||
/** Fresh native Occupy profile; Basic's disputed odd-force arithmetic stays guarded. */
|
||||
export function initializeEcazOccupyGameForAudit(state: Game): Game {
|
||||
requireRule(ecazOccupyCompositionSupported(state),
|
||||
'Advanced Occupy requires native Ecaz, classic/Moritani/Ixian/Tleilaxu/CHOAM/Richese factions and their distinct selected family decks, without optional overlays.');
|
||||
'Occupy requires supported native factions and their distinct selected decks; Basic uses Ecaz/classic/optional Moritani without optional modules.');
|
||||
requireFreshBaseRuntime(state);
|
||||
requireFreshFactionInventory(state);
|
||||
const g = initializeSetupGameForAudit(state, false, false, false, false, false, false, true, state.ecazTreachery === true);
|
||||
@@ -18628,7 +18632,7 @@ function validatePlan(
|
||||
const forces = planCombatForces(g, p, opponent);
|
||||
const profile = ecazBattleProfile(g);
|
||||
const occupy = profile?.lead === p.id ? profile : null;
|
||||
const typedForces = g.advanced || p.faction === 'ixians';
|
||||
const typedForces = g.advanced || p.faction === 'ixians' || !!occupy;
|
||||
const bankerSpice = input.bankerSpice === undefined ? 0 : input.bankerSpice;
|
||||
requireRule(typeof bankerSpice === 'number' && Number.isSafeInteger(bankerSpice) && bankerSpice >= 0 && bankerSpice <= 3,
|
||||
'Choose zero to decline, or one through three spice for Spice Banker.');
|
||||
@@ -30124,6 +30128,9 @@ function applyActionInner(
|
||||
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 combined = (g.advanced || g.ecazOccupyPreview === true) && ecaz &&
|
||||
ally?.ally === ecaz.id && connected && !isAdvisor(ally, choice.territory) &&
|
||||
[choice.attacker, choice.defender].some(seat => seat === ecaz.id || seat === ally.id);
|
||||
const noFieldPlayers = g.players.filter(player =>
|
||||
player.noField?.deployed?.location.territory === choice.territory &&
|
||||
(player.id === choice.attacker || player.id === choice.defender ||
|
||||
@@ -30136,6 +30143,12 @@ function applyActionInner(
|
||||
),
|
||||
'Mixed ordinary-force and No-Field battle dialing awaits a ruling. Reveal voluntarily before Battle while that option is available.',
|
||||
);
|
||||
if (combined) {
|
||||
requireRule(ecazOccupyCompositionSupported(g),
|
||||
'Combined Occupy requires source-selected native factions; optional overlays remain gated.');
|
||||
requireRule(g.advanced || fighterCount(ecaz!, choice.territory) % 2 === 0,
|
||||
'Odd-force Basic Occupy awaits the preserved publisher casualty-rounding ruling.');
|
||||
}
|
||||
const battleEvent = crypto.randomUUID();
|
||||
g.battle = {
|
||||
...choice,
|
||||
@@ -30155,12 +30168,9 @@ function applyActionInner(
|
||||
revealed: false,
|
||||
traitorCalls: {},
|
||||
};
|
||||
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),
|
||||
'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] };
|
||||
if (combined) {
|
||||
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 {
|
||||
createMentatQuestion(g);
|
||||
beginStrongholdBattle(g);
|
||||
@@ -30244,7 +30254,7 @@ function applyActionInner(
|
||||
);
|
||||
const value =
|
||||
field === 'dial'
|
||||
? g.advanced || p.faction === 'ixians'
|
||||
? g.advanced || p.faction === 'ixians' || b.ecazOccupy?.lead === p.id
|
||||
? typeof action.value === 'number'
|
||||
? action.value
|
||||
: NaN
|
||||
|
||||
+7
-6
@@ -1257,10 +1257,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Verified',
|
||||
summary: 'Ecaz chooses who leads its combined army. The authorized Advanced revision supplies mandatory Ecaz strength and lead-own forces after Karama; the bounded fresh local rule and human controls are Development Verified.',
|
||||
summary: 'Ecaz chooses who leads its combined army. Basic uses the source-clear even-force half contribution and losses; the authorized Advanced revision supplies ceiling strength and lead-own forces after Karama. Bounded fresh local rules and human controls are Development Verified.',
|
||||
steps: [
|
||||
'When reciprocal Ecaz allies coexist with fighting forces, Ecaz chooses Ecaz or its ally to lead. Every participating whole-territory army must be clear of storm and connected; no component-only battle or casualties are created. The selected lead uses its own leaders, Treachery cards and spice; the original battle-order chooser is not replaced. Advisors are not fighters but retain their native voluntary fighter-conversion opportunity.',
|
||||
'With Occupy active, the total dial includes the ceiling of half the Ecaz fighters as mandatory full-strength contribution without spice cost, plus the chosen allied army strength. The variable army retains its native Fremen/Fedaykin or Emperor/Sardaukar properties even when Ecaz forms the plan.',
|
||||
'Basic combined battles are admitted for the exact Ecaz deck with classic or optional Moritani opponents and no optional modules. An even Ecaz force count contributes half at full strength and loses half on an ordinary win; the printed page-8 and page-15 odd-count examples conflict, so an uncanceled odd Basic count is rejected before any lead choice rather than choosing a rounding. Basic has no Emperor/Fremen elite inventory and its supplied support normalizes to zero.',
|
||||
'With Advanced Occupy active, the total dial includes the ceiling of half the Ecaz fighters as mandatory full-strength contribution without spice cost, plus the chosen allied army strength. The variable army retains its native Fremen/Fedaykin or Emperor/Sardaukar properties even when Ecaz forms the plan.',
|
||||
'An ordinary victory leaves the floor of half the Ecaz fighters alive and separately applies the variable army’s dial losses. A sole successful Traitor-call winner preserves both armies; an ordinary defeat, mutual Traitor result or resolved explosion removes the actual combined side. Ecaz and its ally share the printed same-faction Traitor/Face Dancer relation.',
|
||||
'Eligible Karama can prevent Occupy after the lead choice and before powers/plans. The chosen lead remains lead and dials its own army; the other ally contributes zero. The plan editor then uses the new force pool and no fixed Ecaz strength.',
|
||||
'An Ixian variable army takes its own typed losses and chooses native surviving-Suboid substitution, including when Ecaz formed the plan. Actual Ixians own the exchange/counter; the selected lead still owns winner cards and continuation. CHOAM support income uses original eligible payer/donor shares, not the free fixed contribution.',
|
||||
@@ -1269,14 +1270,14 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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/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.',
|
||||
'These numbers come from the user-authorized revised Advanced PDF, physical pages 32 and 19, not a newly located publisher erratum. Basic keeps the preserved even-only composition above. 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. Basic admits Ecaz/classic/optional Moritani and the exact ecaz deck. 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 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: 'Implementation', status: 'Partial', detail: 'Combined-side enumeration, explicit lead choice, native Occupy response, source-band even Basic or Advanced arithmetic 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 390px controls cover mandatory/free dials, native Karama redial, Ix substitutions and whole-side Face Dance. Richese human T78EFNZK chose Ecaz lead, sealed total5/support2 with its real leader, revealed the ally’s original No-Field5 into only two available reserves, and reached Collection with Ecaz2 and two Richese losses. Basic human FEJ6SBFB shows the correct even-force labels, a total-dial slider bounded2..6 with no support control, and real Ecaz/guild casualties after an8–6 win. Other combinations remain open.', evidence: ['components/ecaz-occupy.tsx', 'components/face-dance-decision.tsx', 'components/game-table.tsx', 'game/ecaz-occupy-options.ts'] },
|
||||
{ area: '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: 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'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Development Verified: affected363 rules cases,26 actual native programs including all four legal policies, and original390px Richese/No-Field controls. Source-band Basic adds focused even/odd/canceled quote cases and native runtime cases with all four policies and original Collection/victory. Five genuine2..6-seat Basic games: four complete2090 accepted actions/54 JSON continuations/no rejection; four-seat seed20261299 is captured at the preserved odd-count conflict. Earlier46 ordinary/variant games and source-specific card evidence remain historical; complete factions/optional combinations and comprehensive assurance follow all rules.', evidence: ['tests/ecaz-occupy-battle.test.ts', 'tests/basic-ecaz-occupy-runtime.test.ts', 'tests/ecaz-occupy-richese.test.ts', 'tests/ecaz-occupy-stone.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
|
||||
],
|
||||
related: ['faction-ecaz', 'advanced-ecaz', 'ecaz-ambassadors', 'battle', 'victory', 'implementation-checklist'],
|
||||
},
|
||||
|
||||
@@ -82,6 +82,7 @@ function validateContext(context: TraitorDeclarationContext): void {
|
||||
requireDeclaration(ecaz?.faction === 'ecaz' && ally && [context.attacker, context.defender].includes(occupy.lead),
|
||||
'Traitor co-side voters need the actual Ecaz coalition and selected combatant.');
|
||||
quoteEcazOccupyBattle({
|
||||
advanced: occupy.advanced,
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Game, type Player } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { TERRITORIES, location as locationKey } from '../game/board';
|
||||
import { ecazOccupyDialChoices, ecazOccupyPlanControl } from '../game/ecaz-occupy-options';
|
||||
import { advanceToNextStorm, finishToMovement } from './fixture-advanced-source';
|
||||
import { chooseEcazOccupyLead, cancelEcazOccupy, openEcazOccupyPlans,
|
||||
ecazOccupyTerritoryForces, stageEcazOccupyCard, allowEcazOccupyResponses,
|
||||
type EcazOccupyFixture } from './fixture-ecaz-occupy';
|
||||
import { basicEcazOccupyFixture, basicEcazOccupyBattleAction } from './fixture-basic-ecaz-occupy';
|
||||
|
||||
const seat = (game: Game, id: string): Player => game.players.find(p => p.id === id)!;
|
||||
const forces = (game: Game, fixture: EcazOccupyFixture, id: string) =>
|
||||
ecazOccupyTerritoryForces(game, id, fixture.territory);
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
function leader(game: Game, id: string, strongest: boolean): string {
|
||||
return seat(game, id).leaders.filter(l => !l.dead && !l.usedAt)
|
||||
.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength)[0].id;
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Parameters<typeof applyAction>[2]): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'A rejected native action cannot commit any part of the battle.');
|
||||
}
|
||||
|
||||
/** Exchange an existing physical Traitor Card, preserving the native deck. */
|
||||
function holdTraitor(game: Game, owner: string, identity: string): void {
|
||||
const receiver = seat(game, owner);
|
||||
if (receiver.traitors.includes(identity)) return;
|
||||
const reserve = game.traitorReserve ?? [];
|
||||
const index = reserve.indexOf(identity);
|
||||
if (index >= 0) {
|
||||
reserve[index] = receiver.traitors[0];
|
||||
receiver.traitors[0] = identity;
|
||||
} else {
|
||||
const donor = game.players.find(p => p.traitors.includes(identity));
|
||||
assert.ok(donor, 'The chosen leader must have a real physical Traitor Card.');
|
||||
const donorIndex = donor.traitors.indexOf(identity);
|
||||
donor.traitors[donorIndex] = receiver.traitors[0];
|
||||
receiver.traitors[0] = identity;
|
||||
}
|
||||
}
|
||||
|
||||
function aftermath(state: Game): Game {
|
||||
let game = state;
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (game.response) game = allowEcazOccupyResponses(game);
|
||||
else if (game.decision?.kind === 'battleLosses')
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', choice: 0 });
|
||||
else if (game.decision?.kind === 'battleCards')
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', discard: [] });
|
||||
else if (game.pendingTreacheryDiscard)
|
||||
game = applyAction(game, game.players[0].id, { type: 'advanceBots' });
|
||||
else return game;
|
||||
}
|
||||
throw new Error('The original Basic battle aftermath did not finish.');
|
||||
}
|
||||
|
||||
function resolve(fixture: EcazOccupyFixture, lead: 'ecaz' | 'ally', options: {
|
||||
outcome?: 'win' | 'loss' | 'traitor' | 'mutualTraitors' | 'explosion'; canceled?: boolean;
|
||||
} = {}): { game: Game; before: Game; actor: string } {
|
||||
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||||
const outcome = options.outcome ?? 'win';
|
||||
let game = options.canceled ? cancelEcazOccupy(fixture, lead) : chooseEcazOccupyLead(fixture, lead);
|
||||
game = openEcazOccupyPlans(game);
|
||||
const before = structuredClone(game);
|
||||
const profile = viewGame(game, actor).battle!.ecazOccupy!.profile!;
|
||||
const voter = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
|
||||
let ownLeader = leader(game, actor, outcome !== 'loss');
|
||||
const opponentLeader = leader(game, fixture.opponent, outcome === 'loss');
|
||||
if (outcome === 'traitor' || outcome === 'mutualTraitors') {
|
||||
holdTraitor(game, voter, opponentLeader);
|
||||
if (outcome === 'mutualTraitors') {
|
||||
const physical = [...(game.traitorReserve ?? []), ...game.players.flatMap(p => p.traitors)];
|
||||
ownLeader = seat(game, actor).leaders.find(l => !l.dead && !l.usedAt && physical.includes(l.id))!.id;
|
||||
holdTraitor(game, fixture.opponent, ownLeader);
|
||||
}
|
||||
}
|
||||
const weapon = outcome === 'explosion' ? stageEcazOccupyCard(game, actor, c => c.kind === 'lasgun') : undefined;
|
||||
const defense = outcome === 'explosion' ? stageEcazOccupyCard(game, fixture.opponent, c => c.kind === 'shield') : undefined;
|
||||
game = applyAction(game, actor, { type: 'battlePlan', dial: profile.fixedEcazDial + (outcome === 'loss' ? 0 : 2),
|
||||
support: 0, leader: ownLeader, ...(weapon ? { weapon } : {}) });
|
||||
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: outcome === 'loss' ? 8 : 0,
|
||||
support: 0, leader: opponentLeader, ...(defense ? { defense } : {}) });
|
||||
for (let attempt = 0; attempt < 10 && game.battle; attempt++) {
|
||||
const battle = viewGame(game, actor).battle!;
|
||||
const next = battle.traitorVoters.find(id => !battle.traitorSubmitted.includes(id));
|
||||
if (!next) break;
|
||||
const call = next === voter && ['traitor', 'mutualTraitors'].includes(outcome) ||
|
||||
next === fixture.opponent && outcome === 'mutualTraitors';
|
||||
game = applyAction(game, next, { type: 'traitorCall', call });
|
||||
}
|
||||
game = aftermath(game);
|
||||
assert.equal(game.battle, null, 'One original combined battle must retire, not duplicate a second fight.');
|
||||
return { game, before, actor };
|
||||
}
|
||||
|
||||
for (const ecazForces of [2, 4]) for (const lead of ['ecaz', 'ally'] as const)
|
||||
void test(`Basic E${ecazForces} ${lead} lead: free full-strength commitment and equal Ecaz loss/survival`, () => {
|
||||
const fixture = basicEcazOccupyFixture({ ecazForces });
|
||||
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||||
const pending = reload(fixture.game); // Only an actual pending lead decision is resumed.
|
||||
const prepared = openEcazOccupyPlans(chooseEcazOccupyLead({ ...fixture, game: pending }, lead));
|
||||
const view = viewGame(prepared, actor);
|
||||
const profile = view.battle!.ecazOccupy!.profile!;
|
||||
assert.equal(profile.fixedEcazDial, ecazForces / 2);
|
||||
assert.equal(profile.forceOwner, fixture.ally);
|
||||
assert.equal(profile.maxDial, ecazForces / 2 + 4);
|
||||
assert.equal(profile.maxSupport, 0);
|
||||
assert.equal(ecazOccupyPlanControl(view, ecazForces / 2 + 2, 0)!.blocked, null);
|
||||
assert.notEqual(ecazOccupyPlanControl(view, ecazForces / 2 + 2, 1)!.blocked, null);
|
||||
const { game, before } = resolve(fixture, lead);
|
||||
assert.equal(game.lastBattleContext!.winner, actor);
|
||||
assert.equal(game.lastBattleContext!.result, 'normal');
|
||||
assert.equal(forces(game, fixture, fixture.ecaz), ecazForces / 2);
|
||||
assert.equal(seat(game, fixture.ecaz).tanks - seat(before, fixture.ecaz).tanks, ecazForces / 2);
|
||||
assert.equal(forces(game, fixture, fixture.ally), 2);
|
||||
assert.equal(seat(game, fixture.ally).tanks - seat(before, fixture.ally).tanks, 2);
|
||||
assert.equal(forces(game, fixture, fixture.opponent), 0);
|
||||
for (const id of [fixture.ecaz, fixture.ally]) assert.equal(seat(game, id).spice, seat(before, id).spice);
|
||||
assert.equal(game.phase, 7, 'Surviving allies coexist into original Collection without resettlement.');
|
||||
});
|
||||
|
||||
for (const allyFaction of ['emperor', 'fremen'] as const) for (const lead of ['ecaz', 'ally'] as const)
|
||||
void test(`Basic ${allyFaction} ${lead} lead uses native ordinary inventory without Advanced elite strength`, () => {
|
||||
const fixture = basicEcazOccupyFixture({ allyFaction, ecazForces: 4 });
|
||||
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||||
const prepared = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, lead));
|
||||
const profile = viewGame(prepared, actor).battle!.ecazOccupy!.profile!;
|
||||
assert.equal(profile.maxDial, 6, 'Four real Basic ordinary forces provide four variable strength.');
|
||||
const { game, before } = resolve(fixture, lead);
|
||||
assert.equal(game.lastBattleContext!.winner, actor);
|
||||
assert.equal(forces(game, fixture, fixture.ally), 2);
|
||||
assert.equal(seat(game, actor).spice, seat(before, actor).spice);
|
||||
});
|
||||
|
||||
for (const outcome of ['loss', 'traitor', 'mutualTraitors', 'explosion'] as const)
|
||||
for (const lead of ['ecaz', 'ally'] as const)
|
||||
void test(`Basic ${outcome} with ${lead} lead settles both physical armies through original handlers`, () => {
|
||||
const fixture = basicEcazOccupyFixture({ ecazForces: 4 });
|
||||
const { game, before, actor } = resolve(fixture, lead, { outcome });
|
||||
assert.equal(game.lastBattleContext!.result, outcome === 'loss' ? 'normal' : outcome);
|
||||
assert.equal(game.lastBattleContext!.winner, outcome === 'traitor' ? actor : outcome === 'loss' ? fixture.opponent : null);
|
||||
for (const [id, count] of [[fixture.ecaz, 4], [fixture.ally, 4]] as const) {
|
||||
assert.equal(forces(game, fixture, id), outcome === 'traitor' ? count : 0);
|
||||
assert.equal(seat(game, id).tanks - seat(before, id).tanks, outcome === 'traitor' ? 0 : count);
|
||||
const bounty = outcome === 'traitor' && id === actor
|
||||
? seat(before, fixture.opponent).leaders.find(l => l.id === leader(before, fixture.opponent, false))!.strength : 0;
|
||||
assert.equal(seat(game, id).spice - seat(before, id).spice, bounty,
|
||||
'Basic support is free while the original defeated-leader bounty still belongs to the lead.');
|
||||
}
|
||||
});
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const)
|
||||
void test(`Basic printed Karama after ${lead} lead permits only that lead's own free ordinary dial`, () => {
|
||||
const fixture = basicEcazOccupyFixture({ ecazForces: 4 });
|
||||
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||||
const canceled = cancelEcazOccupy(fixture, lead);
|
||||
const prepared = openEcazOccupyPlans(canceled);
|
||||
const profile = viewGame(prepared, actor).battle!.ecazOccupy!.profile!;
|
||||
assert.equal(profile.canceled, true);
|
||||
assert.equal(profile.fixedEcazDial, 0);
|
||||
assert.equal(profile.forceOwner, actor);
|
||||
assert.equal(profile.maxDial, 4);
|
||||
const { game, before } = resolve(fixture, lead, { canceled: true });
|
||||
assert.equal(game.lastBattleContext!.winner, actor);
|
||||
assert.equal(forces(game, fixture, fixture.ecaz), lead === 'ecaz' ? 2 : 4);
|
||||
assert.equal(forces(game, fixture, fixture.ally), lead === 'ally' ? 2 : 4);
|
||||
assert.equal(seat(game, actor).spice, seat(before, actor).spice);
|
||||
assert.equal(seat(game, fixture.ecaz).ally, fixture.ally);
|
||||
});
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const)
|
||||
void test(`Basic ${lead} lead owns printed plan cards and its original aftermath discard`, () => {
|
||||
const fixture = basicEcazOccupyFixture();
|
||||
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||||
const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
|
||||
const shield = stageEcazOccupyCard(fixture.game, actor, card => card.kind === 'shield');
|
||||
let game = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, lead));
|
||||
const nonLeadHand = structuredClone(seat(game, nonLead).hand);
|
||||
reject(game, nonLead, { type: 'battlePlan', dial: 4, support: 0,
|
||||
leader: leader(game, nonLead, true), defense: shield });
|
||||
game = applyAction(game, actor, { type: 'battlePlan', dial: 4, support: 0,
|
||||
leader: leader(game, actor, true), defense: shield });
|
||||
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 0, support: 0,
|
||||
leader: leader(game, fixture.opponent, false) });
|
||||
for (const voter of viewGame(game, actor).battle!.traitorVoters)
|
||||
game = applyAction(game, voter, { type: 'traitorCall', call: false });
|
||||
assert.equal(game.decision?.kind, 'battleCards');
|
||||
assert.equal(game.decision!.player, actor);
|
||||
const pending = reload(game);
|
||||
game = applyAction(pending, actor, { type: 'decision', discard: [shield] });
|
||||
game = aftermath(game);
|
||||
assert.ok(game.discard.some(card => card.id === shield));
|
||||
assert.ok(!seat(game, actor).hand.some(card => card.id === shield));
|
||||
assert.deepEqual(seat(game, nonLead).hand, nonLeadHand);
|
||||
assert.equal(game.phase, 7);
|
||||
});
|
||||
|
||||
void test('Basic odd uncanceled native choice rejects before creating a lead frame or committing forces', () => {
|
||||
const fixture = basicEcazOccupyFixture({ ecazForces: 3, chooseBattle: false });
|
||||
const { actor, action } = basicEcazOccupyBattleAction(fixture);
|
||||
reject(fixture.game, actor, action);
|
||||
assert.equal(fixture.game.battle, null);
|
||||
assert.equal(fixture.game.decision, null);
|
||||
assert.equal(forces(fixture.game, fixture, fixture.ecaz), 3);
|
||||
assert.equal(forces(fixture.game, fixture, fixture.ally), 4);
|
||||
});
|
||||
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const)
|
||||
for (const lead of ['ecaz', 'ally'] as const)
|
||||
void test(`Basic ${difficulty} policy selects ${lead} and seals a legal actual-owner plan`, () => {
|
||||
const fixture = basicEcazOccupyFixture();
|
||||
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||||
const leadView = viewGame(fixture.game, fixture.ecaz);
|
||||
leadView.players.find(p => p.id === fixture.ecaz)!.bot = difficulty;
|
||||
const choose = botActions(leadView).find(a => a.type === 'decision' && a.lead === actor);
|
||||
assert.ok(choose, 'Every policy must retain both legal native lead choices.');
|
||||
let game = openEcazOccupyPlans(applyAction(fixture.game, fixture.ecaz, choose));
|
||||
const view = viewGame(game, actor);
|
||||
view.players.find(p => p.id === actor)!.bot = difficulty;
|
||||
const plan = botActions(view).find(a => a.type === 'battlePlan');
|
||||
assert.ok(plan, 'The actual lead must receive a sealable native candidate.');
|
||||
assert.ok(ecazOccupyDialChoices(view).some(c => c.dial === plan.dial && c.support === (plan.support ?? 0)));
|
||||
assert.equal(ecazOccupyPlanControl(view, Number(plan.dial), Number(plan.support ?? 0))!.blocked, null);
|
||||
game = applyAction(game, actor, plan);
|
||||
assert.ok(game.battle!.plans[actor], 'The selected lead, not the force owner, owns the sealed plan.');
|
||||
const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
|
||||
assert.equal(game.battle!.plans[nonLead], undefined);
|
||||
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 0, support: 0,
|
||||
leader: leader(game, fixture.opponent, false) });
|
||||
for (const voter of viewGame(game, actor).battle!.traitorVoters)
|
||||
game = applyAction(game, voter, { type: 'traitorCall', call: false });
|
||||
game = aftermath(game);
|
||||
assert.equal(game.battle, null);
|
||||
assert.equal(game.phase, 7, 'The policy-generated real battle must continue into native Collection.');
|
||||
});
|
||||
|
||||
void test('Basic city survivors get no city income; shared Collection resumes once at its actual decision', () => {
|
||||
const city = basicEcazOccupyFixture({ territory: 'arrakeen' });
|
||||
const cityResult = resolve(city, 'ecaz');
|
||||
assert.equal(cityResult.game.phase, 7);
|
||||
for (const id of [city.ecaz, city.ally])
|
||||
assert.equal(seat(cityResult.game, id).spice, seat(cityResult.before, id).spice,
|
||||
'Basic Arrakeen grants no Advanced two-spice city income.');
|
||||
assert.equal(forces(cityResult.game, city, city.ecaz), 2);
|
||||
assert.equal(forces(cityResult.game, city, city.ally), 2);
|
||||
|
||||
const desert = basicEcazOccupyFixture();
|
||||
// Controlled bank-funded physical spice after native setup; not a collection receipt.
|
||||
desert.game.spice[desert.location] = 5;
|
||||
const resolution = resolve(desert, 'ally');
|
||||
const before = resolution.before;
|
||||
let game = resolution.game;
|
||||
assert.equal(game.decision?.kind, 'ecazSpice');
|
||||
assert.equal(game.spice[desert.location], 0);
|
||||
const pending = reload(game); // Resume only the actual allocation decision.
|
||||
game = applyAction(pending, pending.decision!.player,
|
||||
{ type: 'decision', event: pending.ecazCollection!.event, allocation: { kind: 'equal' } });
|
||||
assert.equal(game.decision, null);
|
||||
assert.equal(game.ecazCollection!.stage, 'complete');
|
||||
assert.equal(seat(game, desert.ecaz).spice - seat(before, desert.ecaz).spice, 2);
|
||||
assert.equal(seat(game, desert.ally).spice - seat(before, desert.ally).spice, 3);
|
||||
assert.equal(game.ecazCollection!.settled.length, 1);
|
||||
reject(game, pending.decision!.player,
|
||||
{ type: 'decision', event: pending.ecazCollection!.event, allocation: { kind: 'equal' } });
|
||||
});
|
||||
|
||||
void test('Basic reciprocal coalition coexists in three shared strongholds and wins through original Mentat', () => {
|
||||
const fixture = basicEcazOccupyFixture();
|
||||
let game = resolve(fixture, 'ecaz').game;
|
||||
game = finishToMovement(advanceToNextStorm(game));
|
||||
// Conserved controlled board after the next genuine Storm; no alliance,
|
||||
// victory decision, phase or reserve identity is manufactured.
|
||||
for (const player of game.players) {
|
||||
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0);
|
||||
player.forces = {};
|
||||
}
|
||||
const cities = TERRITORIES.filter(t => t.type === 'stronghold' &&
|
||||
t.sectors.some(sector => sector !== game.storm)).slice(0, 3).map(t => t.id);
|
||||
assert.equal(cities.length, 3, 'Three original strongholds remain outside the actual Storm.');
|
||||
for (const city of cities) {
|
||||
const sector = TERRITORIES.find(t => t.id === city)!.sectors.find(s => s !== game.storm)!;
|
||||
assert.ok(sector !== undefined);
|
||||
for (const id of [fixture.ecaz, fixture.ally]) {
|
||||
seat(game, id).reserves--;
|
||||
seat(game, id).forces[locationKey(city, sector)] = 1;
|
||||
}
|
||||
}
|
||||
const progress = viewGame(game, fixture.ecaz).victoryProgress.find(p => p.player === fixture.ecaz)!;
|
||||
assert.deepEqual([...progress.jointlyOccupied].sort(), [...cities].sort());
|
||||
assert.equal(progress.qualifies, true);
|
||||
while (game.phase === 5) {
|
||||
game = allowEcazOccupyResponses(game);
|
||||
game = applyAction(game, game.active!, { type: 'endMovement' });
|
||||
}
|
||||
assert.equal(game.phase, 7, 'Shared allied cities must not produce spurious coalition battles.');
|
||||
for (let attempt = 0; attempt < 50 && game.status === 'playing'; attempt++) {
|
||||
game = allowEcazOccupyResponses(game);
|
||||
const actor = game.players.find(p => !game.ready.includes(p.id))!.id;
|
||||
game = applyAction(game, actor, { type: 'ready' });
|
||||
}
|
||||
assert.equal(game.status, 'finished');
|
||||
assert.deepEqual([...game.winner].sort(), [fixture.ecaz, fixture.ally].sort());
|
||||
});
|
||||
@@ -10,30 +10,183 @@ import {
|
||||
type EcazOccupyBattleInput,
|
||||
} from '../game/ecaz-occupy-battle';
|
||||
|
||||
const ordinary = (normal: number): CombatForces => ({
|
||||
normal, elite: 0, eliteStrength: 2, freeSupport: false,
|
||||
const ordinary = (normal: number, advanced: boolean): CombatForces => ({
|
||||
normal, elite: 0, eliteStrength: advanced ? 2 : 1, freeSupport: !advanced,
|
||||
});
|
||||
function battle(
|
||||
advanced: boolean,
|
||||
ecazCount: number,
|
||||
lead = 'ecaz-seat',
|
||||
canceled = false,
|
||||
allyForces = ordinary(4),
|
||||
allyForces = ordinary(4, advanced),
|
||||
): EcazOccupyBattleInput {
|
||||
return {
|
||||
advanced,
|
||||
battleOrderActor: 'enemy-seat',
|
||||
ecaz: { id: 'ecaz-seat', faction: 'ecaz', ally: 'ally-seat', forces: ordinary(ecazCount) },
|
||||
ecaz: { id: 'ecaz-seat', faction: 'ecaz', ally: 'ally-seat', forces: ordinary(ecazCount, advanced) },
|
||||
ally: { id: 'ally-seat', faction: 'atreides', ally: 'ecaz-seat', forces: allyForces },
|
||||
lead,
|
||||
canceled,
|
||||
};
|
||||
}
|
||||
|
||||
void test('Basic E2/E4 normal wins split Ecaz equally and settle the real ally pool with either lead', () => {
|
||||
for (const ecazCount of [2, 4]) {
|
||||
for (const lead of ['ecaz-seat', 'ally-seat']) {
|
||||
const profile = quoteEcazOccupyBattle(battle(false, ecazCount, lead));
|
||||
const fixed = ecazCount / 2;
|
||||
for (const variableDial of [0, 2]) {
|
||||
const dial = quoteEcazOccupyDial(profile, fixed + variableDial, 0);
|
||||
assert.equal(dial.payer, lead);
|
||||
assert.equal(dial.forceOwner, 'ally-seat');
|
||||
const win = quoteEcazOccupyOutcome(profile, {
|
||||
result: 'normal', won: true, dial: fixed + variableDial, support: 0,
|
||||
});
|
||||
assert.deepEqual(win.fixedLosses, [{ owner: 'ecaz-seat', normal: fixed, elite: 0 }]);
|
||||
assert.equal(ecazCount - win.fixedLosses[0].normal, fixed);
|
||||
assert.equal(win.casualties?.owner, 'ally-seat');
|
||||
assert.deepEqual(win.casualties?.options, [
|
||||
{ normal: variableDial, elite: 0, paidNormal: 0, paidElite: 0 },
|
||||
]);
|
||||
assert.equal(4 - win.casualties!.options[0].normal, 4 - variableDial);
|
||||
assert.deepEqual(win.destroyedArmies, []);
|
||||
assert.deepEqual(quoteEcazOccupyOutcome(profile, {
|
||||
result: 'normal', won: false, dial: fixed + variableDial, support: 0,
|
||||
}), {
|
||||
destroyedArmies: ['ecaz-seat', 'ally-seat'], fixedLosses: [], casualties: null,
|
||||
});
|
||||
}
|
||||
assert.deepEqual(ecazOccupyDialOptions(profile, 0), [
|
||||
{ dial: fixed, support: 0, variableDial: 0 },
|
||||
{ dial: fixed + 1, support: 0, variableDial: 1 },
|
||||
{ dial: fixed + 2, support: 0, variableDial: 2 },
|
||||
{ dial: fixed + 3, support: 0, variableDial: 3 },
|
||||
{ dial: fixed + 4, support: 0, variableDial: 4 },
|
||||
]);
|
||||
assert.throws(() => quoteEcazOccupyDial(profile, fixed - 1, 0), EcazOccupyBattleError);
|
||||
assert.throws(() => quoteEcazOccupyDial(profile, fixed + 0.5, 0), EcazOccupyBattleError);
|
||||
assert.throws(() => quoteEcazOccupyDial(profile, fixed + 1, 1), EcazOccupyBattleError);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic Fedaykin and Sardaukar stay typed, full strength one, free, and physically allied under either lead', () => {
|
||||
const forces: CombatForces = { normal: 2, elite: 2, eliteStrength: 1, freeSupport: true };
|
||||
for (const faction of ['fremen', 'emperor'] as const) {
|
||||
for (const ecazCount of [2, 4]) {
|
||||
for (const lead of ['ecaz-seat', 'ally-seat']) {
|
||||
const input = battle(false, ecazCount, lead, false, forces);
|
||||
const profile = quoteEcazOccupyBattle({ ...input, ally: { ...input.ally, faction } });
|
||||
const fixed = ecazCount / 2;
|
||||
const win = quoteEcazOccupyOutcome(profile, {
|
||||
result: 'normal', won: true, dial: fixed + 2, support: 0,
|
||||
});
|
||||
assert.deepEqual(win.fixedLosses, [{ owner: 'ecaz-seat', normal: fixed, elite: 0 }]);
|
||||
assert.equal(win.casualties?.owner, 'ally-seat');
|
||||
assert.deepEqual(win.casualties?.options, [
|
||||
{ normal: 2, elite: 0, paidNormal: 0, paidElite: 0 },
|
||||
{ normal: 1, elite: 1, paidNormal: 0, paidElite: 0 },
|
||||
{ normal: 0, elite: 2, paidNormal: 0, paidElite: 0 },
|
||||
]);
|
||||
assert.deepEqual(quoteEcazOccupyDial(profile, fixed + 4, 0).options, [
|
||||
{ normal: 2, elite: 2, paidNormal: 0, paidElite: 0 },
|
||||
]);
|
||||
assert.throws(() => quoteEcazOccupyDial(profile, fixed + 5, 0), EcazOccupyBattleError);
|
||||
assert.throws(() => quoteEcazOccupyDial(profile, fixed + 2, 1), EcazOccupyBattleError);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic cancellation uses the selected own pool even for odd Ecaz and preserves the other army on a normal win', () => {
|
||||
const forces: CombatForces = { normal: 0, elite: 2, eliteStrength: 1, freeSupport: true };
|
||||
for (const ecazCount of [2, 4, 5]) {
|
||||
for (const lead of ['ecaz-seat', 'ally-seat']) {
|
||||
const input = battle(false, ecazCount, lead, true, forces);
|
||||
const profile = quoteEcazOccupyBattle({
|
||||
...input, ally: { ...input.ally, faction: 'emperor' },
|
||||
});
|
||||
const dial = quoteEcazOccupyDial(profile, 1, 0);
|
||||
assert.equal(dial.forceOwner, lead);
|
||||
assert.equal(dial.payer, lead);
|
||||
assert.equal(dial.fixedEcazDial, 0);
|
||||
assert.deepEqual(dial.options, [{
|
||||
normal: lead === 'ecaz-seat' ? 1 : 0,
|
||||
elite: lead === 'ally-seat' ? 1 : 0,
|
||||
paidNormal: 0, paidElite: 0,
|
||||
}]);
|
||||
const win = quoteEcazOccupyOutcome(profile, {
|
||||
result: 'normal', won: true, dial: 1, support: 0,
|
||||
});
|
||||
assert.equal(win.casualties?.owner, lead);
|
||||
assert.deepEqual(win.fixedLosses, []);
|
||||
assert.deepEqual(win.destroyedArmies, []);
|
||||
assert.deepEqual(quoteEcazOccupyOutcome(profile, {
|
||||
result: 'normal', won: false, dial: 1, support: 0,
|
||||
}), {
|
||||
destroyedArmies: ['ecaz-seat', 'ally-seat'], fixedLosses: [], casualties: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
const input = battle(false, 5, 'ecaz-seat', true);
|
||||
assert.throws(() => quoteEcazOccupyBattle({
|
||||
...input, ally: { ...input.ally, forces: ordinary(0, false) },
|
||||
}), EcazOccupyBattleError);
|
||||
assert.throws(() => quoteEcazOccupyBattle({
|
||||
...input, ecaz: { ...input.ecaz, forces: ordinary(0, false) },
|
||||
}), EcazOccupyBattleError);
|
||||
});
|
||||
|
||||
void test('Basic sole traitor victory spares both armies; defeat, mutual traitors and explosion spare neither', () => {
|
||||
for (const ecazCount of [2, 4]) {
|
||||
for (const lead of ['ecaz-seat', 'ally-seat']) {
|
||||
for (const canceled of [false, true]) {
|
||||
const profile = quoteEcazOccupyBattle(battle(false, ecazCount, lead, canceled));
|
||||
const dial = canceled ? 1 : ecazCount / 2 + 1;
|
||||
assert.deepEqual(quoteEcazOccupyOutcome(profile, {
|
||||
result: 'traitor', won: true, dial, support: 0,
|
||||
}), { destroyedArmies: [], fixedLosses: [], casualties: null });
|
||||
for (const result of ['traitor', 'mutualTraitors', 'explosion'] as const)
|
||||
assert.deepEqual(quoteEcazOccupyOutcome(profile, {
|
||||
result, won: false, dial, support: 0,
|
||||
}), {
|
||||
destroyedArmies: ['ecaz-seat', 'ally-seat'], fixedLosses: [], casualties: null,
|
||||
});
|
||||
for (const result of ['mutualTraitors', 'explosion'] as const)
|
||||
assert.throws(() => quoteEcazOccupyOutcome(profile, {
|
||||
result, won: true, dial, support: 0,
|
||||
}), EcazOccupyBattleError);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('uncanceled Basic odd Ecaz is source-blocked before commitment, while Advanced keeps ceil losses and floor survivors', () => {
|
||||
for (const ecazCount of [1, 3, 5, 19]) {
|
||||
for (const lead of ['ecaz-seat', 'ally-seat']) {
|
||||
assert.throws(() => quoteEcazOccupyBattle(battle(false, ecazCount, lead)), EcazOccupyBattleError);
|
||||
const profile = quoteEcazOccupyBattle(battle(true, ecazCount, lead));
|
||||
const win = quoteEcazOccupyOutcome(profile, {
|
||||
result: 'normal', won: true, dial: Math.ceil(ecazCount / 2), support: 0,
|
||||
});
|
||||
assert.equal(ecazCount - win.fixedLosses[0].normal, Math.floor(ecazCount / 2));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('the Occupy rules band is required rather than defaulting malformed or missing input to Advanced', () => {
|
||||
for (const advanced of [undefined, null, 0, 1, 'false', 'true']) {
|
||||
const input = { ...battle(true, 4), advanced } as unknown as EcazOccupyBattleInput;
|
||||
assert.throws(() => quoteEcazOccupyBattle(input), EcazOccupyBattleError);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Advanced E1–5 ordinary wins lose ceil Ecaz separately and retain floor, with either lead', () => {
|
||||
for (const [ecazCount, fixed, survivors] of [
|
||||
[1, 1, 0], [2, 1, 1], [3, 2, 1], [4, 2, 2], [5, 3, 2],
|
||||
]) {
|
||||
for (const lead of ['ecaz-seat', 'ally-seat']) {
|
||||
const profile = quoteEcazOccupyBattle(battle(ecazCount, lead));
|
||||
const profile = quoteEcazOccupyBattle(battle(true, ecazCount, lead));
|
||||
const dial = quoteEcazOccupyDial(profile, fixed + 1.5, 1);
|
||||
assert.equal(dial.variableDial, 1.5);
|
||||
assert.equal(dial.payer, lead);
|
||||
@@ -56,7 +209,7 @@ void test('Advanced E1–5 ordinary wins lose ceil Ecaz separately and retain fl
|
||||
void test('Advanced E1–5 normal defeat destroys both real armies regardless of the selected lead', () => {
|
||||
for (const ecazCount of [1, 2, 3, 4, 5]) {
|
||||
for (const lead of ['ecaz-seat', 'ally-seat']) {
|
||||
const profile = quoteEcazOccupyBattle(battle(ecazCount, lead));
|
||||
const profile = quoteEcazOccupyBattle(battle(true, ecazCount, lead));
|
||||
const loss = quoteEcazOccupyOutcome(profile, {
|
||||
result: 'normal', won: false, dial: Math.ceil(ecazCount / 2), support: 0,
|
||||
});
|
||||
@@ -68,7 +221,7 @@ void test('Advanced E1–5 normal defeat destroys both real armies regardless of
|
||||
});
|
||||
|
||||
void test('Ecaz cannot be dialed below its mandatory increment or charged extra support for it', () => {
|
||||
const profile = quoteEcazOccupyBattle(battle(5));
|
||||
const profile = quoteEcazOccupyBattle(battle(true, 5));
|
||||
assert.throws(() => quoteEcazOccupyDial(profile, 2.5, 0), /include 3 fixed Ecaz/);
|
||||
assert.throws(() => quoteEcazOccupyDial(profile, 3, 1), /legal physical commitment/);
|
||||
assert.deepEqual(quoteEcazOccupyDial(profile, 3, 0).options, [
|
||||
@@ -80,7 +233,7 @@ void test('Ecaz cannot be dialed below its mandatory increment or charged extra
|
||||
});
|
||||
|
||||
void test('Fremen and Fedaykin remain free and physically Fremen even when Ecaz supplies the plan', () => {
|
||||
const input = battle(3, 'ecaz-seat', false, {
|
||||
const input = battle(true, 3, 'ecaz-seat', false, {
|
||||
normal: 2, elite: 2, eliteStrength: 2, freeSupport: true,
|
||||
});
|
||||
const profile = quoteEcazOccupyBattle({ ...input, ally: { ...input.ally, faction: 'fremen' } });
|
||||
@@ -106,7 +259,7 @@ void test('Fremen and Fedaykin remain free and physically Fremen even when Ecaz
|
||||
});
|
||||
|
||||
void test('Sardaukar keep typed supported casualties and their native opponent-dependent strength', () => {
|
||||
const input = battle(3, 'ecaz-seat', false, {
|
||||
const input = battle(true, 3, 'ecaz-seat', false, {
|
||||
normal: 3, elite: 2, eliteStrength: 2, freeSupport: false,
|
||||
});
|
||||
const emperor = { ...input.ally, faction: 'emperor' as const };
|
||||
@@ -141,7 +294,7 @@ void test('Sardaukar keep typed supported casualties and their native opponent-d
|
||||
void test('cancellation retains either selected lead, recomputes its own dial, and leaves the other army undialed on a win', () => {
|
||||
const fremen: CombatForces = { normal: 2, elite: 2, eliteStrength: 2, freeSupport: true };
|
||||
for (const lead of ['ecaz-seat', 'ally-seat']) {
|
||||
const input = battle(5, lead, false, fremen);
|
||||
const input = battle(true, 5, lead, false, fremen);
|
||||
const active = quoteEcazOccupyBattle({ ...input, ally: { ...input.ally, faction: 'fremen' } });
|
||||
assert.equal(quoteEcazOccupyDial(active, 4, 0).forceOwner, 'ally-seat');
|
||||
const canceled = quoteEcazOccupyBattle({ ...input, canceled: true, ally: { ...input.ally, faction: 'fremen' } });
|
||||
@@ -172,7 +325,7 @@ void test('cancellation retains either selected lead, recomputes its own dial, a
|
||||
void test('sole traitor victory waives both pools losses; sole defeat, mutual traitors and explosion destroy the actual side', () => {
|
||||
for (const lead of ['ecaz-seat', 'ally-seat']) {
|
||||
for (const canceled of [false, true]) {
|
||||
const profile = quoteEcazOccupyBattle(battle(5, lead, canceled));
|
||||
const profile = quoteEcazOccupyBattle(battle(true, 5, lead, canceled));
|
||||
const dial = canceled ? 0 : 3;
|
||||
assert.deepEqual(quoteEcazOccupyOutcome(profile, {
|
||||
result: 'traitor', won: true, dial, support: 0,
|
||||
@@ -190,7 +343,7 @@ void test('sole traitor victory waives both pools losses; sole defeat, mutual tr
|
||||
});
|
||||
|
||||
void test('funding-limited legal options retain unsupported half dials without spending the fixed contribution', () => {
|
||||
const profile = quoteEcazOccupyBattle(battle(1, 'ecaz-seat', false, ordinary(2)));
|
||||
const profile = quoteEcazOccupyBattle(battle(true, 1, 'ecaz-seat', false, ordinary(2, true)));
|
||||
assert.deepEqual(ecazOccupyDialOptions(profile, 0), [
|
||||
{ dial: 1, support: 0, variableDial: 0 },
|
||||
{ dial: 1.5, support: 0, variableDial: 0.5 },
|
||||
@@ -207,25 +360,25 @@ void test('funding-limited legal options retain unsupported half dials without s
|
||||
});
|
||||
|
||||
void test('nonreciprocal allies, advisors-only or disconnected pools, and foreign lead actors cannot create a combined army', () => {
|
||||
const input = battle(3);
|
||||
const input = battle(true, 3);
|
||||
assert.throws(() => quoteEcazOccupyBattle({
|
||||
...input, ally: { ...input.ally, ally: null },
|
||||
}), /reciprocal ally/);
|
||||
assert.throws(() => quoteEcazOccupyBattle({
|
||||
...input, ally: { ...input.ally, faction: 'beneGesserit', forces: ordinary(0) },
|
||||
...input, ally: { ...input.ally, faction: 'beneGesserit', forces: ordinary(0, true) },
|
||||
}), /storm-connected fighters/);
|
||||
assert.throws(() => quoteEcazOccupyBattle({
|
||||
...input, ecaz: { ...input.ecaz, forces: ordinary(0) },
|
||||
...input, ecaz: { ...input.ecaz, forces: ordinary(0, true) },
|
||||
}), /storm-connected fighters/);
|
||||
assert.throws(() => quoteEcazOccupyBattle({ ...input, lead: 'enemy-seat' }), /lead must be Ecaz/);
|
||||
assert.throws(() => quoteEcazOccupyBattle({ ...input, battleOrderActor: '' }), /battle-order actor/);
|
||||
assert.throws(() => quoteEcazOccupyBattle({
|
||||
...input, ally: { ...input.ally, forces: ordinary(21) },
|
||||
...input, ally: { ...input.ally, forces: ordinary(21, true) },
|
||||
}), EcazOccupyBattleError);
|
||||
});
|
||||
|
||||
void test('two twenty-counter armies are legal separate pools, not an over-cap merged CombatForces', () => {
|
||||
const profile = quoteEcazOccupyBattle(battle(20, 'ecaz-seat', false, ordinary(20)));
|
||||
const profile = quoteEcazOccupyBattle(battle(true, 20, 'ecaz-seat', false, ordinary(20, true)));
|
||||
const outcome = quoteEcazOccupyOutcome(profile, {
|
||||
result: 'normal', won: true, dial: 30, support: 20,
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ function profile(
|
||||
allyForces = native(4),
|
||||
): EcazOccupyBattleProfile {
|
||||
return quoteEcazOccupyBattle({
|
||||
advanced: true,
|
||||
battleOrderActor: 'ecaz-user',
|
||||
ecaz: {
|
||||
id: 'ecaz-user', faction: 'ecaz', ally: 'ally-user',
|
||||
|
||||
@@ -132,7 +132,7 @@ for (const role of ['ally', 'opponent'] as const) for (const marker of [0, 3, 5]
|
||||
|
||||
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,
|
||||
const input: EcazOccupyBattleInput = { advanced: true, 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);
|
||||
|
||||
@@ -16,6 +16,7 @@ const ix: CombatForces = {
|
||||
};
|
||||
function profile(ecazNormal = 5, lead = 'ecaz-user', canceled = false, allyForces = ix): EcazOccupyBattleProfile {
|
||||
return quoteEcazOccupyBattle({
|
||||
advanced: true,
|
||||
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 },
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeEcazOccupyGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { TERRITORIES, location as locationKey } from '../game/board';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { advanceToNextStorm } from './fixture-advanced-source';
|
||||
import { finishEcazOccupySetup, settleEcazOccupyArrival, stageEcazOccupyCard,
|
||||
type EcazOccupyFixture } from './fixture-ecaz-occupy';
|
||||
|
||||
export type BasicEcazOccupyOptions = {
|
||||
/** Existing authenticated ready lobby; never recreate seats or ready it again. */
|
||||
initial?: Game;
|
||||
ecazForces?: number;
|
||||
allyFaction?: 'guild' | 'emperor' | 'fremen';
|
||||
opponentFaction?: 'guild' | 'emperor' | 'fremen';
|
||||
allyForces?: number;
|
||||
territory?: string;
|
||||
/** Odd rule probes stop before the native chooseBattle commitment. */
|
||||
chooseBattle?: boolean;
|
||||
};
|
||||
|
||||
function step(game: Game): Game {
|
||||
if (game.response) return applyAction(game,
|
||||
game.players.find(p => !game.response!.passed.includes(p.id))!.id, { type: 'passResponse' });
|
||||
if (game.phaseOpening) return applyAction(game,
|
||||
game.players.find(p => !game.phaseOpening!.passed.includes(p.id))!.id, { type: 'ready' });
|
||||
if (game.decision?.kind === 'ecazPlacement')
|
||||
return applyAction(game, game.decision.player, { type: 'decision', decline: true });
|
||||
if (game.phase === 0 && !game.decision && game.stormPending === null) {
|
||||
const dialer = game.stormDialers.find(id => game.stormDials[id] === undefined)!;
|
||||
return applyAction(game, dialer, { type: 'stormDial', amount: game.turn === 1 ? 0 : 1 });
|
||||
}
|
||||
if (game.phase === 3 && game.auction && !game.decision)
|
||||
return applyAction(game, game.auction.active, { type: 'passBid' });
|
||||
if (game.decision?.kind === 'wormRide')
|
||||
return applyAction(game, game.decision.player, { type: 'decision', accept: false });
|
||||
if (game.decision?.kind === 'guildShipment')
|
||||
return applyAction(game, game.decision.player, { type: 'decision', allow: true });
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id);
|
||||
view.players.find(p => p.id === player.id)!.bot = 'Easy';
|
||||
const candidates = botActions(view);
|
||||
const action = candidates.find(a => a.type === 'ready') ?? candidates[0];
|
||||
if (action) return applyAction(game, player.id, action);
|
||||
}
|
||||
throw new Error(`No native Basic action at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
}
|
||||
|
||||
function orderBlow(game: Game, worm: boolean, position: number): void {
|
||||
const index = game.spiceDeck.findIndex((card, i) => i >= position &&
|
||||
(worm ? 'worm' in card && !card.greatMaker && !card.suppressed :
|
||||
'territory' in card && !['the_great_flat', 'arrakeen'].includes(card.territory)));
|
||||
assert.ok(index >= position, 'Reorder only original unplayed Spice Cards.');
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
|
||||
export function basicEcazOccupyBattleAction(fixture: EcazOccupyFixture): { actor: string; action: Action } {
|
||||
const game = fixture.game;
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === fixture.territory);
|
||||
assert.ok(choice, 'The physical coalition and rival must produce one native battle choice.');
|
||||
return { actor: choice.chooser, action: { type: 'chooseBattle', territory: fixture.territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
|
||||
}
|
||||
|
||||
/** Genuine Basic native deal, original Storm/Blow/Nexus/alliance and shipments.
|
||||
* Only physical board forces and unplayed card order are controlled after setup.
|
||||
* Native wallets, phases, reciprocal alliance, plans and receipts remain original. */
|
||||
export function basicEcazOccupyFixture(options: BasicEcazOccupyOptions = {}): EcazOccupyFixture {
|
||||
const allyFaction = options.allyFaction ?? 'guild';
|
||||
const opponentFaction = options.opponentFaction ?? (allyFaction === 'guild' ? 'emperor' : 'guild');
|
||||
assert.notEqual(allyFaction, opponentFaction);
|
||||
let setup: Game;
|
||||
if (options.initial) {
|
||||
setup = structuredClone(options.initial);
|
||||
} else {
|
||||
const roster = ['ecaz', allyFaction, opponentFaction] as const;
|
||||
setup = createGame('BASICECAZOCCUPY', newPlayer('ecaz', 'ecaz', 'ecaz'), false, ['ecaz']);
|
||||
for (const faction of roster.slice(1)) joinGame(setup, newPlayer(faction, faction, faction));
|
||||
for (let i = roster.length - 1; i >= 0; i--)
|
||||
if (viewGame(setup, roster[i]).playerPositions[roster[i]] !== i + 3)
|
||||
setup = applyAction(setup, roster[i], { type: 'seatPosition', position: i + 3 });
|
||||
for (const player of setup.players) setup = applyAction(setup, player.id, { type: 'ready' });
|
||||
}
|
||||
setup = initializeEcazOccupyGameForAudit(setup);
|
||||
assert.equal(setup.advanced, false);
|
||||
assert.deepEqual(setup.expansions, ['ecaz']);
|
||||
assert.ok(!setup.ecazTreachery && !setup.leaderSkills && !setup.homeworlds && !setup.nexusCards &&
|
||||
!setup.strongholdCards && !setup.techTokens && !setup.discoveryEnabled);
|
||||
const ecazSeat = setup.players.find(player => player.faction === 'ecaz');
|
||||
const allySeat = setup.players.find(player => player.faction === allyFaction);
|
||||
const opponentSeat = setup.players.find(player => player.faction === opponentFaction);
|
||||
assert.ok(ecazSeat && allySeat && opponentSeat, 'The actual lobby must contain the requested native factions.');
|
||||
const ecaz = ecazSeat.id, ally = allySeat.id, opponent = opponentSeat.id;
|
||||
const initial = structuredClone(setup);
|
||||
let game = finishEcazOccupySetup(setup);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)]
|
||||
.map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort(),
|
||||
'The original Basic deal retains the exact Ecaz family deck.');
|
||||
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 = {};
|
||||
}
|
||||
}
|
||||
orderBlow(game, false, 0);
|
||||
orderBlow(game, false, 1);
|
||||
for (let attempt = 0; attempt < 800 && !(game.phase === 5 &&
|
||||
!game.phaseOpening && !game.response && !game.decision); attempt++) game = step(game);
|
||||
assert.equal(game.phase, 5, 'The first original Basic movement must open.');
|
||||
while (game.phase === 5) game = game.response || game.phaseOpening || game.decision
|
||||
? step(game) : applyAction(game, game.active!, { type: 'endMovement' });
|
||||
game = advanceToNextStorm(game);
|
||||
orderBlow(game, true, 0);
|
||||
orderBlow(game, false, 1);
|
||||
orderBlow(game, false, 2);
|
||||
let allied = false;
|
||||
for (let attempt = 0; attempt < 500; attempt++) {
|
||||
if (!allied && game.phase === 1 && game.nexus && !game.spiceWindow &&
|
||||
!game.spiceResolution && !game.phaseOpening && !game.response && !game.decision) {
|
||||
game = applyAction(game, ecaz, { type: 'alliance', target: ally });
|
||||
game = applyAction(game, ally, { type: 'alliance', target: ecaz });
|
||||
allied = true;
|
||||
}
|
||||
if (game.phase === 5 && !game.phaseOpening && !game.response && !game.decision) break;
|
||||
game = step(game);
|
||||
}
|
||||
assert.ok(allied && game.phase === 5, 'A genuine Basic worm Nexus must form the reciprocal alliance.');
|
||||
assert.equal(game.players.find(p => p.id === ecaz)!.ally, ally);
|
||||
assert.equal(game.players.find(p => p.id === ally)!.ally, ecaz);
|
||||
const territory = options.territory ?? 'the_great_flat';
|
||||
const sector = TERRITORIES.find(t => t.id === territory)!.sectors.find(s => s !== game.storm)!;
|
||||
assert.ok(sector !== undefined);
|
||||
const location = locationKey(territory, sector);
|
||||
const ecazForces = options.ecazForces ?? 4;
|
||||
// Controlled rival position after the genuine Nexus. Transfer eight original
|
||||
// reserve counters without claiming an unaffordable played shipment history.
|
||||
const rival = game.players.find(player => player.id === opponent)!;
|
||||
assert.ok(rival.reserves >= 8);
|
||||
rival.reserves -= 8;
|
||||
rival.forces[location] = 8;
|
||||
stageEcazOccupyCard(game, opponent, card => card.effect === 'karama');
|
||||
while (game.phase === 5) {
|
||||
if (game.response || game.phaseOpening || game.decision) { game = step(game); continue; }
|
||||
const actor = game.active!;
|
||||
if (actor === ecaz || actor === ally) {
|
||||
const amount = actor === ecaz ? ecazForces : options.allyForces ?? 4;
|
||||
game = settleEcazOccupyArrival(applyAction(game, actor,
|
||||
{ type: 'ship', territory, sector, amount }));
|
||||
}
|
||||
game = applyAction(game, actor, { type: 'endMovement' });
|
||||
}
|
||||
while (game.phaseOpening || game.response || game.decision) game = step(game);
|
||||
assert.equal(game.phase, 6);
|
||||
const fixture: EcazOccupyFixture = { game, initial, afterSetup, ecaz, ally,
|
||||
opponent, territory, location, ecazForces };
|
||||
if (options.chooseBattle !== false) {
|
||||
const { actor, action } = basicEcazOccupyBattleAction(fixture);
|
||||
fixture.game = applyAction(game, actor, action);
|
||||
assert.equal(fixture.game.decision?.kind, 'ecazBattleLead');
|
||||
}
|
||||
return fixture;
|
||||
}
|
||||
@@ -178,6 +178,11 @@ const ECAZ_OCCUPY_BASE_SCENARIOS: readonly Scenario[] = [
|
||||
})),
|
||||
];
|
||||
const ECAZ_OCCUPY_SCENARIOS: readonly Scenario[] = [
|
||||
...[2, 3, 4, 5, 6].map((players): Scenario => ({
|
||||
ordinal: 293 + players - 2, profile: 'ecaz-occupy', rules: 'basic',
|
||||
expansions: ['ecaz'],
|
||||
roster: (['ecaz', 'fremen', 'emperor', 'beneGesserit', 'harkonnen', 'guild'] as FactionId[]).slice(0, players),
|
||||
})),
|
||||
...ECAZ_OCCUPY_BASE_SCENARIOS,
|
||||
...ECAZ_OCCUPY_BASE_SCENARIOS.map((scenario, index): Scenario => ({
|
||||
...scenario, ordinal: 192 + index, ecazTreachery: true,
|
||||
@@ -378,7 +383,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/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.' +
|
||||
'\nEcaz-occupy supports Basic even-force and Advanced: 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. Basic uses Ecaz/classic/optional Moritani and the exact ecaz deck, with E/2 mandatory contribution and losses. An uncanceled odd Basic count remains blocked by the preserved publisher casualty-rounding conflict. 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.' +
|
||||
'\nThe four earlier native skill profiles retain their Basic samples. Advanced Ix-skills/choam-skills/tleilaxu-skills use documented native plus classic families; Advanced moritani-skills composes its original non-Harkonnen classic assassination roster. Richese-skills adds Basic/Advanced Richese or paired CHOAM tables, Advanced Richese/Tleilaxu/CHOAM and optional Ix-deck tables without native Ixians. All retain fourteen skills and original cache/native inventories. Native Ixian Technology on Richese lots, own mixed No-Field, other roster/modules and public starts retain their separate guards.'
|
||||
+ '\nEcaz-skills uses native five-disc Ecaz with classic opponents, the ecaz deck and all fourteen skills in Basic/Advanced; Advanced Harkonnen is excluded by the existing shared-Duke capture boundary. Ambassador and ordinary skill paths retain native custody. Shared-Duke assignment and combined Occupy skills remain separate boundaries; no public starts or save retrofit.'
|
||||
);
|
||||
@@ -775,8 +780,6 @@ async function main() {
|
||||
throw new Error('Moritani assassination samples require Advanced rules.');
|
||||
if (profile === 'stronghold-factions' && rules === 'basic')
|
||||
throw new Error('Stronghold factions samples require Advanced rules.');
|
||||
if (profile === 'ecaz-occupy' && rules === 'basic')
|
||||
throw new Error('Ecaz Occupy samples require Advanced rules.');
|
||||
const resume = values.resume ? resumedGame(values.resume) : null;
|
||||
const requestedSamples = profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus'
|
||||
? MODULE_SCENARIOS
|
||||
|
||||
@@ -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, supported Basic Ixian/Tleilaxu/CHOAM/Moritani skill roster with its single required deck, or Advanced native Ixians, Tleilaxu and/or CHOAM plus classic roster with distinct required family decks, 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/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.' +
|
||||
'\nEcaz-occupy (--profile ecaz-occupy) opts only a fresh ready 2–6-seat lobby with native Ecaz and classic/Ixian/Tleilaxu/CHOAM/Richese opponents (optional native Moritani). Advanced uses distinct selected ecaz/ix/choam decks; Basic uses the exact ecaz deck with classic/optional Moritani opponents. 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 (Advanced only). Basic combined battles require an even Ecaz force count; the preserved publisher odd-force casualty-rounding conflict rejects before any lead choice. 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 retains existing classic and Basic native entries and adds native Basic Richese/CHOAM. Advanced Ixians, Tleilaxu, CHOAM and/or Richese plus classic rosters use distinct required family decks and all fourteen skills, with original cache, Face Dancers, Auditor exclusion and typed aftermath. Foreign revival grants no new skill. Separate non-Harkonnen Advanced Moritani uses the ecaz deck and original skill-first assassination preview. Native Ecaz supports its five ordinary discs, original Ambassador/Duke custody and ecaz deck with classic opponents in Basic/Advanced; Advanced Harkonnen retains its existing Duke capture exclusion. Explicit --mentat-question stays private; banker-income additionally supports native Richese and Ecaz, never ordinary seller/allowed Emperor income or a bank grant as a payer cost. Basic auction recipients remain unchanged. Public activation, exhausted-cache counts, native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, shared-Duke assignment, combined Occupy skills and other pending module/rulings remain guarded.',
|
||||
);
|
||||
return;
|
||||
|
||||
Reference in new issue
Block a user