Integrate authorized Advanced Ecaz Occupy combined battles

This commit is contained in:
Quintine committed 2026-10-02 13:06:00 +10:00
1 parent bf4122029e
commit e112df858a
29 files changed
+1897 -123

No files matched your search

+13
View File
@@ -239,6 +239,19 @@ rules, legal AI and human phone controls are bounded Development Verified;
save/privacy/custody assurance follows all rules. See the save/privacy/custody assurance follows all rules. See the
[bounded integration](docs/STRONGHOLD_CARDS.md#expanded-e1e2-faction-rules). [bounded integration](docs/STRONGHOLD_CARDS.md#expanded-e1e2-faction-rules).
For local **Advanced Ecaz combined-army Occupy**, use
`node --import tsx tools/start-prototype.ts --profile ecaz-occupy`
with the existing `--db`, `--room`, `--version` and private `--out` arguments
on a fresh ready2..6-seat Advanced lobby: native Ecaz, classic opponents,
optional native Moritani, expansion `ecaz` alone and ordinary33 Treachery Cards.
No three-card variant or other overlays. The selected lead owns leaders/cards/
payment; total dial includes mandatory free `ceil(E/2)` plus native allied
strength. Karama switches to the selected lead's own army, other ally zero.
Whole participating armies must be clear and connected; BG's voluntary fighter
choice remains. Rule/390px controls are bounded Development Verified, not full
Ecaz/Basic/public-start acceptance. See the
[authorized source and native proof](docs/ECAZ_OCCUPY_RULES.md#authorized-advanced-cutover--2-october-2026).
Above the board, **Treachery draw pile** and **Spice draw pile** show live card Above the board, **Treachery draw pile** and **Spice draw pile** show live card
counts separately from hands, auction cards and discards. See counts separately from hands, auction cards and discards. See
[draw-pile counts](docs/DRAW_PILES.md). [draw-pile counts](docs/DRAW_PILES.md).
+81
View File
@@ -0,0 +1,81 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { ecazOccupyLeadOptions, ecazOccupyPlanControl } from '@/game/ecaz-occupy-options';
import { Button } from './ui/button';
export function EcazOccupyLeadChoice({ game, act, busy }: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const options = ecazOccupyLeadOptions(game);
const occupy = game.battle?.ecazOccupy;
if (!occupy || !options.length) return null;
const ecaz = game.players.find((player) => player.id === occupy.ecaz)?.name ?? 'Ecaz';
const ally = game.players.find((player) => player.id === occupy.ally)?.name ?? 'Ecaz’s ally';
return <section aria-label="Ecaz combined-army lead" className="space-y-3">
<p>
{ecaz} chooses who forms the plan. The lead uses their own leaders,
Treachery cards and spice. With Occupy active, {ally} supplies the variable
dialed army regardless of who leads; Ecaz’s contribution is mandatory and free.
</p>
<p className="fine">
Karama may cancel Occupy after this choice, before battle powers and plans.
The chosen lead then dials only their own army; the other ally contributes zero.
</p>
{options.map((option) => <div key={option.lead}>
<Button className="game-action min-h-11 whitespace-normal"
disabled={busy || !!option.blocked}
onClick={() => { if (!busy && !option.blocked) act(option.action); }}>
{option.label} leads · their leaders, cards and spice
</Button>
{option.blocked && <p className="fine">{option.blocked}</p>}
</div>)}
</section>;
}
/** Public owner labels belong above the existing native plan/power editor. */
export function EcazOccupyBattleSummary({ game }: { game: GameView }) {
const occupy = game.battle?.ecazOccupy;
const profile = occupy?.profile;
if (!occupy || !profile) return null;
const lead = game.players.find((player) => player.id === profile.planOwner)?.name ?? profile.planOwner;
const forceOwner = game.players.find((player) => player.id === profile.forceOwner)?.name ?? profile.forceOwner;
const other = game.players.find((player) => player.id ===
(profile.lead === profile.ecaz ? profile.ally : profile.ecaz))?.name ?? 'The other ally';
return <section aria-label="Ecaz battle army and plan owners" className="notice space-y-2">
<p><strong>{lead}</strong> forms the plan, chooses their leaders and cards, and pays any spice support.</p>
{profile.canceled ? <p>
Occupy canceled: {forceOwner} dials their own army. {other} contributes zero;
there is no fixed Ecaz strength. Co-occupancy permission is unchanged.
</p> : <p>
The variable dial uses <strong>{forceOwner}’s army</strong>, including its native force properties.
The total dial must include <strong>{profile.fixedEcazDial} fixed Ecaz strength</strong> at full strength,
with no spice cost, plus the chosen variable army strength.
</p>}
{game.response?.kind === 'ecazOccupy' && <p className="fine">
Allow this power to keep the combined army, or use an eligible Karama below.
Cancellation keeps {lead} as lead and replaces the variable army with {lead}’s own forces before planning.
</p>}
</section>;
}
/** Render next to the native total wheel/support input, not a second plan editor. */
export function EcazOccupyDialExplanation({ game, dial, support }: {
game: GameView;
dial: number;
support: number;
}) {
const control = ecazOccupyPlanControl(game, dial, support);
if (!control) return null;
const forceOwner = game.players.find((player) => player.id === control.profile.forceOwner)?.name ?? control.profile.forceOwner;
return <div aria-label="Ecaz total dial and payment" className="space-y-2">
<p className="fine">
Total dial {dial} = fixed Ecaz {control.fixedEcazDial} + {forceOwner}’s variable strength{' '}
{control.variableDial ?? '—'}. Spice support applies only to the variable army.
Your personal spice: {control.personalSpice}. Current legal support budget: {control.supportMaximum}.
</p>
{control.blocked && <output className="notice">{control.blocked}</output>}
</div>;
}
+39 -16
View File
@@ -12,6 +12,8 @@ import { HandBrowser } from './hand-browser';
import { LobbyBotControls } from './lobby-bot-controls'; import { LobbyBotControls } from './lobby-bot-controls';
import { AdvancedPreviewNotice, RulesetControls } from './ruleset-controls'; import { AdvancedPreviewNotice, RulesetControls } from './ruleset-controls';
import { IxRicheseTechnology } from './ix-richese-technology'; import { IxRicheseTechnology } from './ix-richese-technology';
import { EcazOccupyLeadChoice, EcazOccupyBattleSummary, EcazOccupyDialExplanation } from './ecaz-occupy';
import { ecazOccupyOwnProfile, ecazOccupyPlanControl } from '@/game/ecaz-occupy-options';
import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from '@/game/nexus-guild-cunning-options'; import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from '@/game/nexus-guild-cunning-options';
import { import {
matchesShipment, matchesShipment,
@@ -424,27 +426,34 @@ export function GameTable({
), ),
); );
const ownBattleForces = g.battle?.ownForces; const ownBattleForces = g.battle?.ownForces;
const occupyProfile = ecazOccupyOwnProfile(g);
const battleDialMinimum = occupyProfile?.fixedEcazDial ?? 0;
const battleFreeSupport = ownBattleForces?.freeSupport ??
(me.faction === 'fremen' && !g.battle?.fremenSupportBlocked);
const battleForceOwnerName = occupyProfile
? g.players.find((player) => player.id === occupyProfile.forceOwner)?.name ?? occupyProfile.forceOwner
: me.name;
const battleForces = ownBattleForces const battleForces = ownBattleForces
? ownBattleForces.normal + ownBattleForces.elite ? ownBattleForces.normal + ownBattleForces.elite
: g.battle : g.battle
? fighterCount(me, g.battle.territory) ? fighterCount(me, g.battle.territory)
: 0; : 0;
const battleDialMaximum = ownBattleForces const battleDialMaximum = occupyProfile?.maxDial ?? (ownBattleForces
? maxCombatDial(ownBattleForces) ? maxCombatDial(ownBattleForces)
: g.advanced || me.faction === 'ixians' : g.advanced || me.faction === 'ixians'
? 40 ? 40
: battleForces; : battleForces);
const battleDialStep = g.advanced || me.faction === 'ixians' ? 0.5 : 1; const battleDialStep = g.advanced || me.faction === 'ixians' ? 0.5 : 1;
// A draft carried from a previous battle must show and submit the same bounded value. // A draft carried from a previous battle must show and submit the same bounded value.
const battleDial = Number.isFinite(dial) const battleDial = Number.isFinite(dial)
? Math.max( ? Math.max(
0, battleDialMinimum,
Math.min( Math.min(
Math.round(dial / battleDialStep) * battleDialStep, Math.round(dial / battleDialStep) * battleDialStep,
battleDialMaximum, battleDialMaximum,
), ),
) )
: 0; : battleDialMinimum;
const [support, setSupport] = useState(0); const [support, setSupport] = useState(0);
const [bankerSpice, setBankerSpice] = useState(0); const [bankerSpice, setBankerSpice] = useState(0);
const strongholdBankSupport = const strongholdBankSupport =
@@ -537,12 +546,15 @@ export function GameTable({
leader: me.leaders.find(l => l.id === selectedBattleLeader), weapon: selectedBattleWeapon, leader: me.leaders.find(l => l.id === selectedBattleLeader), weapon: selectedBattleWeapon,
defense: selectedBattleDefense, kwisatz: selectedBattleKwisatz, defense: selectedBattleDefense, kwisatz: selectedBattleKwisatz,
}, smugglerBattleModeSupported(g), { territory: g.battle.territory, spice: g.spice }) : null; }, smugglerBattleModeSupported(g), { territory: g.battle.territory, spice: g.spice }) : null;
const occupyControl = ecazOccupyPlanControl(g,
committed.dial ? Number(committed.dial.value) : battleDial,
battleFreeSupport ? 0 : battleSupport);
const battlePairValid = validBattleSlotPair(selectedBattleWeapon, selectedBattleDefense, const battlePairValid = validBattleSlotPair(selectedBattleWeapon, selectedBattleDefense,
!!selectedBattleWeapon && planetologistSpecial(selectedBattleWeapon)); !!selectedBattleWeapon && planetologistSpecial(selectedBattleWeapon));
const harassControl = harassWithdrawControlState(g.battle?.harassWithdraw, const harassControl = harassWithdrawControlState(g.battle?.harassWithdraw,
selectedWeaponId === 'ecaz-harass-withdraw' || selectedDefenseId === 'ecaz-harass-withdraw', selectedWeaponId === 'ecaz-harass-withdraw' || selectedDefenseId === 'ecaz-harass-withdraw',
committed.dial ? Number(committed.dial.value) : battleDial, committed.dial ? Number(committed.dial.value) : battleDial,
me.faction === 'fremen' && !g.battle?.fremenSupportBlocked ? 0 : battleSupport); battleFreeSupport ? 0 : battleSupport);
const selectedSpecialWeapon = me.hand?.find( const selectedSpecialWeapon = me.hand?.find(
(c) => c.id === selectedWeaponId && (isStoneBurner(c) || isMirrorWeapon(c)), (c) => c.id === selectedWeaponId && (isStoneBurner(c) || isMirrorWeapon(c)),
); );
@@ -2128,6 +2140,7 @@ export function GameTable({
moritaniDuke: 'Moritani · Duke Vidal acquisition', moritaniDuke: 'Moritani · Duke Vidal acquisition',
ecazPlacement: 'Ecaz Ambassador placement', ecazPlacement: 'Ecaz Ambassador placement',
ecazCollection: 'Ecaz stronghold collection', ecazCollection: 'Ecaz stronghold collection',
ecazOccupy: 'Ecaz combined-army Occupy',
moritaniPlacement: moritaniPlacement:
'Moritani Terror placement or relocation', 'Moritani Terror placement or relocation',
choamRevival: 'CHOAM force revival', choamRevival: 'CHOAM force revival',
@@ -2193,6 +2206,7 @@ export function GameTable({
}[g.response.kind] }[g.response.kind]
} }
</h2> </h2>
<EcazOccupyBattleSummary game={g} />
{g.paymentIncome?.low && ( {g.paymentIncome?.low && (
<p className="notice"> <p className="notice">
Of the {g.paymentIncome.gross} spice payment,{' '} Of the {g.paymentIncome.gross} spice payment,{' '}
@@ -2432,7 +2446,9 @@ export function GameTable({
<> <>
<span className="eyebrow">Player decision</span> <span className="eyebrow">Player decision</span>
<h2> <h2>
{g.decision.kind === 'ixRicheseTechnology' {g.decision.kind === 'ecazBattleLead'
? 'Ecaz · choose the combined-army lead'
: g.decision.kind === 'ixRicheseTechnology'
? 'Ixian Technology · Richese lot' ? 'Ixian Technology · Richese lot'
: g.decision.kind === 'caladanReinforcement' : g.decision.kind === 'caladanReinforcement'
? 'Caladan victory reinforcement' ? 'Caladan victory reinforcement'
@@ -2857,6 +2873,8 @@ export function GameTable({
) : g.decision.kind === 'choamAudit' || ) : g.decision.kind === 'choamAudit' ||
g.decision.kind === 'choamAuditPayment' ? ( g.decision.kind === 'choamAuditPayment' ? (
<AuditorDecision game={g} act={act} busy={busy} /> <AuditorDecision game={g} act={act} busy={busy} />
) : g.decision.kind === 'ecazBattleLead' ? (
<EcazOccupyLeadChoice game={g} act={act} busy={busy} />
) : g.decision.kind === 'strongholdCopy' ? ( ) : g.decision.kind === 'strongholdCopy' ? (
<StrongholdCopyChoice <StrongholdCopyChoice
event={g.decision.event} event={g.decision.event}
@@ -4722,6 +4740,7 @@ export function GameTable({
{' '}Tied battle: {g.players.find(p => p.id === g.battle!.tieWinner)?.name} {' '}Tied battle: {g.players.find(p => p.id === g.battle!.tieWinner)?.name}
{g.battle.tieWinner !== g.battle.aggressor ? ' (Habbanya Stronghold advantage)' : ''}. {g.battle.tieWinner !== g.battle.aggressor ? ' (Habbanya Stronghold advantage)' : ''}.
</p> </p>
<EcazOccupyBattleSummary game={g} />
{!g.battle.revealed && g.battle.leaderless.length > 0 && ( {!g.battle.revealed && g.battle.leaderless.length > 0 && (
<p className="notice block" role="status"> <p className="notice block" role="status">
{g.battle.leaderless.map(id => g.players.find(p => p.id === id)?.name).join(', ')} {g.battle.leaderless.map(id => g.players.find(p => p.id === id)?.name).join(', ')}
@@ -4857,13 +4876,15 @@ export function GameTable({
{!g.battle.submitted.includes(me.id) ? ( {!g.battle.submitted.includes(me.id) ? (
<> <>
<label htmlFor="forces-dialed"> <label htmlFor="forces-dialed">
Forces dialed <HelpTip topic="dial" /> {occupyProfile ? 'Total forces dialed' : 'Forces dialed'} <HelpTip topic="dial" />
{me.faction === 'ixians' && ( {me.faction === 'ixians' && (
<HelpTip topic="ixForces" /> <HelpTip topic="ixForces" />
)} )}
</label> </label>
<BattleWheel <BattleWheel
id="forces-dialed" id="forces-dialed"
min={battleDialMinimum}
sliderLabel={occupyProfile ? 'Total forces dialed slider' : 'Forces dialed slider'}
max={battleDialMaximum} max={battleDialMaximum}
step={battleDialStep} step={battleDialStep}
value={ value={
@@ -4874,11 +4895,16 @@ export function GameTable({
disabled={busy || !!committed.dial} disabled={busy || !!committed.dial}
onChange={setDial} onChange={setDial}
/> />
<EcazOccupyDialExplanation game={g}
dial={committed.dial ? Number(committed.dial.value) : battleDial}
support={battleFreeSupport ? 0 : battleSupport} />
<p className="fine"> <p className="fine">
{ownNoField && {ownNoField &&
g.battle.noFieldPlayers.includes(me.id) g.battle.noFieldPlayers.includes(me.id)
? `Your concealed No-Field can reveal ${battleForces} physical forces from your current reserves. This estimate is private.` ? `Your concealed No-Field can reveal ${battleForces} physical forces from your current reserves. This estimate is private.`
: `You have ${battleForces} fighting forces here.`} : occupyProfile
? `${battleForceOwnerName} supplies ${battleForces} physical fighters for the variable dial.`
: `You have ${battleForces} fighting forces here.`}
</p> </p>
{!!ownBattleForces?.temporaryElite && ( {!!ownBattleForces?.temporaryElite && (
<p className="notice"> <p className="notice">
@@ -4887,20 +4913,18 @@ export function GameTable({
)} )}
{ownBattleForces?.normalFreeSupport && ( {ownBattleForces?.normalFreeSupport && (
<p className="notice"> <p className="notice">
Your Suboids fight at full strength without spice support this turn. Spice support here applies only to Cyborgs. The variable army’s Suboids fight at full strength without spice support this turn. Spice support here applies only to Cyborgs.
</p> </p>
)} )}
{ownBattleForces?.eliteFreeSupport && ( {ownBattleForces?.eliteFreeSupport && (
<p className="notice"> <p className="notice">
Salusa Secundus is at high population. Your Salusa Secundus is at high population. The variable army’s
Sardaukar fight at their full current strength Sardaukar fight at their full current strength
without spice support; normal forces still without spice support; normal forces still
require support. require support.
</p> </p>
)} )}
{g.advanced && {g.advanced && !battleFreeSupport && (
(me.faction !== 'fremen' ||
g.battle.fremenSupportBlocked) && (
<label htmlFor="battle-spice"> <label htmlFor="battle-spice">
Spice support Spice support
{strongholdBankSupport > 0 && ( {strongholdBankSupport > 0 && (
@@ -5121,8 +5145,7 @@ export function GameTable({
? { allyPayment: Number(allyPayment) } ? { allyPayment: Number(allyPayment) }
: {}), : {}),
support: support:
me.faction === 'fremen' && battleFreeSupport
!g.battle.fremenSupportBlocked
? 0 ? 0
: battleSupport, : battleSupport,
kwisatz: selectedBattleKwisatz, kwisatz: selectedBattleKwisatz,
@@ -5131,7 +5154,7 @@ export function GameTable({
weapon, weapon,
defense, defense,
}), }),
!!stonePlanReason || !!harassControl.blocked || !!smugglerPlanReason || !!reinforcementsPlanReason || !battlePairValid, !!occupyControl?.blocked || !!stonePlanReason || !!harassControl.blocked || !!smugglerPlanReason || !!reinforcementsPlanReason || !battlePairValid,
)} )}
</> </>
) : ( ) : (
+25 -4
View File
@@ -16,8 +16,8 @@ open mode gates.
## Current checkpoint and work ## Current checkpoint and work
**Requested priority — 2 October 2026:** development-speed improvements now **Requested priority — 2 October 2026:** throughput improvements are verified
precede resuming rule content. Implement all remaining rules first; comprehensive and rule content has resumed. Implement all remaining rules first; comprehensive
review and save/recovery/privacy/custody assurance follow afterward, not at each review and save/recovery/privacy/custody assurance follow afterward, not at each
prototype. Keep legal rule behavior, usable controls, minimal legal AI and prototype. Keep legal rule behavior, usable controls, minimal legal AI and
changed-path smoke. Existing games are not reset/deleted; secret protection, changed-path smoke. Existing games are not reset/deleted; secret protection,
@@ -25,6 +25,27 @@ authorization and deployment/publication safeguards remain intact. Use the
[bounded context index](DEVELOPMENT.md#context-index) and [bounded context index](DEVELOPMENT.md#context-index) and
[feedback/proof schedule](DEVELOPMENT.md#feedback-and-proof-schedule). [feedback/proof schedule](DEVELOPMENT.md#feedback-and-proof-schedule).
**Advanced Ecaz Occupy — bounded Development Verified:** the authorized
supplied PDF physical32/19 resolves ceiling-half free Ecaz contribution,
floor-half ordinary survivors and selected-lead-own forces after Karama.
Whole-army Advanced storm eligibility (physical6/11) retains canonical
co-side membership for native voluntary BG advisor conversion. Basic and the
specific Ecaz-only Duke/Ghola decision stay unchanged.
Seven actual native-program cases and named repair feedback18/18 pass, with
types/lint, all four actual legal policies and two original CLI/human390px
battles: **Q5Z2WH8X v22**, Ecaz lead total9/free Fedaykin leaves Ecaz2/Fremen0;
**LZHMY454 v14**, printed Karama, own dial1/support1 leaves Ecaz2/Guild4 and
debits exactly1. Both reach original Collection. Source/rule and integration
reviews have no remaining finding. Fresh profile remains Ecaz/classic/optional
Moritani2..6 Advanced, base33/no overlays; full Ecaz and public starts remain gated.
See [the cutover and proof](ECAZ_OCCUPY_RULES.md#authorized-advanced-cutover--2-october-2026).
Final coherent affected rule/guide **173/173** passes. Current-source five
unstaged2..6 games complete:2,123 accepted actions, zero rejections and54JSON
continuations, `sourceUnchanged:true`. They contain no combined lead choice;
the seven native cases and human rooms prove that path, not these game samples.
Final types/lint, corrected guide10/10, build and390px Partial/Development
Verified guide pass; six changed-region local links/anchors are valid.
**Development-throughput improvements implemented and verified:** default **Development-throughput improvements implemented and verified:** default
test output preserves failures/details/stdout/stderr/counts while omitting test output preserves failures/details/stdout/stderr/counts while omitting
passing rows; `--verbose` retains full spec. Empty/bad file or name selections passing rows; `--verbose` retains full spec. Empty/bad file or name selections
@@ -37,8 +58,8 @@ Identical15-case native rule runs emitted278 bytes/12 lines concise versus
Named Carthag feedback took0.90s; discovery-only list0.18s. The earlier162-case Named Carthag feedback took0.90s; discovery-only list0.18s. The earlier162-case
union took100.22s with different coverage; use narrow failing-case feedback union took100.22s with different coverage; use narrow failing-case feedback
before one coherent affected batch. Canonical context index/handoff avoids before one coherent affected batch. Canonical context index/handoff avoids
mass file moves or duplicate ruling registries. Resume preserved Stronghold mass file moves or duplicate ruling registries. Preserved Stronghold work is
rule/control work now; do not restart save/privacy/custody/seat audit campaigns. verified and Advanced Ecaz rule work has resumed; no new assurance campaigns.
Code checkpoint **e9830b6ced80c95253cfa112bb8ed2eb6e50b74c** is pushed. Code checkpoint **e9830b6ced80c95253cfa112bb8ed2eb6e50b74c** is pushed.
[Exact mandatory CI36941218637](https://github.com/Quintine/Dune/actions/runs/36941218637), [Exact mandatory CI36941218637](https://github.com/Quintine/Dune/actions/runs/36941218637),
job110632894258, completed/success: existing isolated storage/HTTP verification job110632894258, completed/success: existing isolated storage/HTTP verification
+80 -5
View File
@@ -1,6 +1,81 @@
# Ecaz Occupy: source conflict and implementation contract # Ecaz Occupy: 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.
## Authorized Advanced cutover — 2 October 2026
The user authorized the root `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf`
as Advanced source of truth on1 October, including its identified unofficial
rulings. Exact revision/SHA and metadata2.3/editorial2.2 are bound by
[the source amendment](RULE_DECISIONS.md#authorized-source-amendment--1-october-2026).
Its **physical p32 / logical p30 Occupy B** explicitly supplies:
```text
Advanced mandatory Ecaz dial contribution = ceil(E / 2), fully supported
Advanced ordinary-win Ecaz survivors = floor(E / 2)
Advanced ordinary-win Ecaz losses = ceil(E / 2), separately from ally dial losses
```
Ecaz chooses the lead faction; that faction supplies its actual leader/cards/
plan and spice, while the active variable dial always uses **Ecaz's ally's**
force pool plus the fixed Ecaz contribution, regardless of lead.
**Physical p19 / logical p17 Ecaz Karama row** explicitly
changes a prevented Occupy to the SELECTED LEAD'S OWN force pool, with its
other ally contributing zero. Keep the selected lead and recompute legality
before plans; do not silently inherit the ally's pool when Ecaz leads.
Storm Obstruction (**physical p6**) and Battle Determination (**physical p11**)
forbid battle if any participating forces are in storm or separated by it.
Advanced eligibility therefore checks the whole participating territory armies,
including both coexisting allies; it does not invent component-only battle
plans or casualties. The canonical board quote retains actual co-side membership
when one seat represents the coalition, so an unlocked BG advisor may still
voluntarily become a fighter through its native pre-movement opportunity.
This is an authorized Advanced software/source cutover, **not** a new GF9/
designer erratum or a Basic ruling. Basic's earlier conflict stays separate.
The specific user Ecaz-only Duke revival/Ghola decision remains higher-priority
than the contrary revival exception printed on that sheet.
Advanced combined-army implementation is **Partial / Development Verified**:
native selection, owner-labelled force/support/loss settlement, printed/BG
prevention, human controls and all four minimal legal policies. Ordinary
canceled-win non-dialed ally
retention and canceled-defeat whole-side destruction compose the base same-side
outcome, not a newly located special casualty FAQ. Exotic/optional combinations
remain explicit. Source/controls/rule runtime are developed now; comprehensive
save/privacy/custody assurance follows all rules under the2 October priority.
### Bounded native rule and control proof
The fresh local `ecaz-occupy` profile admits Ecaz/classic/optional Moritani,
2..6-seat Advanced, ordinary33 Treachery Cards and the E3 faction setup alone.
It does not select the independent three-card variant, other overlays, public
expansion starts or an existing-game retrofit.
Seven actual native-program cases exercise E1/E5 free fixed contribution,
both plan/payer leads, native Fremen/Fedaykin and Emperor/Sardaukar, and both
canceled lead-own branches through original Collection continuation. Exact
odd/even, storm-relative order, traitor, mutual-traitor, explosion, printed/
BG Karama and next-turn cases have meaningful rule regressions. A reproduced
storm-isolated joint battle now has no offer; clear whole armies retain one
offer and their actual co-side membership. The existing voluntary BG advisor
offer is preserved, not re-pinned to absence. Final scoped reviews find no defect.
Original CLI/human390px **Q5Z2WH8X v22** chose Ecaz lead and total9:
mandatory Ecaz3 plus free Fremen/Fedaykin6. Native resolution left Ecaz2,
Fremen0, Ecaz spice unchanged5 and original Collection. **LZHMY454 v14**
used real printed Karama after Ecaz lead, recomputed minimum0/own pool3,
sealed dial1/support1, and resolved with Ecaz2/Guild4, one actual Ecaz
support debit and original Collection. All four policies actually seal native
legal plans for either lead, active or canceled.
The coherent affected rule/guide batch passes **173/173**. Current-source
five unstaged2..6 games complete with2,123 accepted actions, no rejections
and54JSON continuations, `sourceUnchanged:true`. No game sample triggers
a combined lead choice: profile continuation is observed there, while the
native cases and human rooms establish the actual combined rule path.
Primary-source audit, 2026-09-07, corrected after independent verification. **The casualty-rounding conflict remains unresolved by an authoritative correction.** An earlier version of this document incorrectly attributed a community member's proposed numerical correction to designer Jack Reda. That attribution and the resulting claim of resolution are withdrawn. The allied-entry and battle-role clarification below is a separate, verified designer statement.
## Provenance and attribution correction ## Provenance and attribution correction
@@ -23,7 +98,7 @@ Ecaz losses on ordinary allied win = ceil(E / 2)
Ecaz survivors on ordinary allied win = floor(E / 2) Ecaz survivors on ordinary allied win = floor(E / 2)
``` ```
The partial revision supports the upward contribution, but for E=5 the example's two losses/three survivors conflicts with p.8's three losses/two survivors. The [earlier Ambassador audit](ECAZ_AMBASSADORS_RULES.md#material-adjacent-source-discrepancy-occupy) and [remaining-effects readiness](ECAZ_REMAINING_EFFECTS_READINESS.md) therefore remain correct to identify a source gap. A primary correction or explicit supported interpretation is still needed before claiming that combat implementation fully resolves it. Ecaz's fixed contribution is not a selectable dial, and its independent losses must not also be charged against the ally's chosen dial; those structural requirements do not depend on which rounding sentence wins. 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.
## Occupation, controller and battle order ## Occupation, controller and battle order
@@ -70,10 +145,10 @@ An Advanced BG Worthless conversion uses the same substantive timing and result
The p.16 row explicitly states zero combat contribution for the non-battling ally but does not itself give a full casualty sentence for that canceled case. Ordinary-winning-side retention and losing-side destruction are a plausible composition; do not advertise an independently retrieved designer ruling on that consequence. This is a separate source check from native rounding. The p.16 row explicitly states zero combat contribution for the non-battling ally but does not itself give a full casualty sentence for that canceled case. Ordinary-winning-side retention and losing-side destruction are a plausible composition; do not advertise an independently retrieved designer ruling on that consequence. This is a separate source check from native rounding.
## Recommended bounded next slice ## Historical bounded recommendation
Implement a pure, owner-labelled Occupy battle profile shared by validation, AI, public preview and actual scoring: current Ecaz/ally side; selected combatant; battle-order actor; dialed force owner; exact Ecaz contribution; ordinary winner casualty split; and canceled profile. Keep the rounding policy explicit until resolved; retain storm-separated and advisor exclusions, and never read the non-selected ally's hand. Integrate a durable public combatant-selection stage and the pre-plan Karama opportunity before admitting plans. The p.8 arithmetic can be represented as a labelled candidate in an isolated pure helper, but an execution path must not silently treat it as a designer-corrected rule. The independently supported allied-entry and battle-side identity work is the safer immediate production slice. Implement a pure, owner-labelled Occupy battle profile shared by validation, AI, public preview and actual scoring: current Ecaz/ally side; selected combatant; battle-order actor; dialed force owner; exact Ecaz contribution; ordinary winner casualty split; and canceled profile. Keep the rounding policy explicit until resolved; retain storm-separated and advisor exclusions, and never read the non-selected ally's hand. Integrate a durable public combatant-selection stage and the pre-plan Karama opportunity before admitting plans. The p.8 arithmetic can be represented as a labelled candidate in an isolated pure helper, but an execution path must not silently treat it as a designer-corrected rule. The independently supported allied-entry and battle-side identity work is the safer immediate production slice.
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. 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.
No runtime, tests, mode gates, or prior source documents were changed in this audit. The prior false designer-erratum attribution is explicitly corrected above. Native rounding, advanced interactions and canceled-loss semantics remain separate evidence boundaries. 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.
+47
View File
@@ -1,5 +1,52 @@
# Dune implementation status # Dune implementation status
## 2 October 2026 — authorized Advanced Ecaz Occupy combined-army rules
The user-authorized supplied revision, physical32/19, resolves the Advanced
ceiling-half free Ecaz contribution, floor-half ordinary survivors and selected
lead's own army after Karama. Basic is unchanged; specific Ecaz-only Duke/Ghola
revival stays above the sheet's contrary exception. Fresh `ecaz-occupy` is
Ecaz/classic/optional Moritani2..6 Advanced, base33/E3 faction source alone,
no independent three-card variant or other overlays, public start or retrofit.
Four bounded owners supplied pure rules, native runtime, usable controls and
genuine setup/CLI/study entry. Two rule-only reviewers found a whole-army storm
eligibility defect and suppressed voluntary BG conversion. The exact supplied
physical6/11 wording rejects component-only plans/casualties: whole armies
must be clear/connected. Canonical quote membership now preserves BG's native
opportunity. Native before/after storm reproduction changes an isolated ally
battle offer to none while clear armies retain one. Final reviews find no defect.
Exact first E1 native case passed. Narrow feedback repaired seat-circle no-op,
real arrival-decision suffix, exhaustive lead-decision registries and stable
closure narrowing. A typed outcome test now checks printed-strength physical
alternatives rather than incidental array order; copied plan echo assertions
were removed. Named repaired cases18/18, types/lint, seven actual native-program
cases and all four actual policy lead/cancellation branches pass.
Original CLI/human390px **Q5Z2WH8X v22** selected Ecaz lead, minimum3/total9
with free Fremen/Fedaykin6; native battle left Ecaz2/Fremen0 and unchanged
Ecaz spice5. **LZHMY454 v14** used real printed Karama after Ecaz lead,
recomputed minimum0/own pool3, sealed dial1/support1 and left Ecaz2/Guild4,
with exactly1 Ecaz support debit. Both resumed original Collection.
Final coherent affected rule/guide **173/173** passes. The current-rule five
unstaged2..6 games complete with2,123 accepted actions/no rejections and54JSON
continuations, `sourceUnchanged:true`, ordinary33/E3 report metadata. No sample
uses a combined lead choice; those games prove profile continuation, while
named actual native cases and human rooms prove the rare combined battle path.
The earlier pre-storm-fix report is preserved as different-source evidence.
Final `check:quick` types/lint, corrected canonical guide10/10 and build pass.
The final390px guide shows Partial / Development: Verified, not full faction
acceptance. Six changed-region local links/anchors are valid. Actual CLI help
and both exact-version fresh starts pass. Owned temporary rule drivers are
removed; four managed control/guide tabs and both isolated workers are closed.
No new recovery/privacy/custody/seat/HTTP-suite or full6,500-suite assurance
campaign was added. Full rules and later comprehensive review/assurance,
administration and deployed acceptance remain unfinished.
## 2 October 2026 — expanded Stronghold No-Field, Stone and Face Dancer rules ## 2 October 2026 — expanded Stronghold No-Field, Stone and Face Dancer rules
The existing `stronghold-factions` profile now composes selected E1/E2 native The existing `stronghold-factions` profile now composes selected E1/E2 native
+27 -1
View File
@@ -1,6 +1,6 @@
# Rule decision index # Rule decision index
Updated 1 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority. Updated 2 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority.
## Authorized source amendment — 1 October 2026 ## Authorized source amendment — 1 October 2026
@@ -85,6 +85,32 @@ Public, faction, optional-module and full-mode readiness gates are unchanged.
Secrecy (physical page 16), Duke Vidal's ghola exception (30), Kull and other Secrecy (physical page 16), Duke Vidal's ghola exception (30), Kull and other
independent decisions are not reconciled by this cutover. independent decisions are not reconciled by this cutover.
## 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.
The lead uses its own leaders, cards and spice. The total dial includes
`ceil(Ecaz fighters / 2)` mandatory, full-strength, free Ecaz contribution
plus the chosen allied army strength. An ordinary win leaves
`floor(Ecaz fighters / 2)` Ecaz survivors and separately settles the variable
allied army's native dial losses.
The **Ecaz Karama row, physical PDF page 19** preserves that selected lead
but substitutes the lead's own army; the other ally contributes zero.
This supersedes the historical Advanced rounding/cancellation source gap,
not Basic and not a newly located GF9/designer erratum. The specific user
decision that **only Ecaz revives Duke Vidal, including by Ghola**, remains
above the supplied sheet's contrary exception.
The fresh, bounded `ecaz-occupy` rule/control implementation is **Partial /
Development Verified**. Whole participating armies must be clear and connected
under physical pages6/11; canonical co-side membership preserves voluntary BG
advisor conversion. Basic is unchanged. Optional/exotic compositions and full
Ecaz acceptance remain open. See the
[source comparison and exact cutover](ECAZ_OCCUPY_RULES.md#authorized-advanced-cutover--2-october-2026).
Comprehensive recovery/privacy/custody review follows all rules rather than
becoming another prototype assurance wave.
## Recorded contracts and implementation boundaries ## Recorded contracts and implementation boundaries
| Topic | Decision to preserve | Detailed authority and evidence | | Topic | Decision to preserve | Detailed authority and evidence |
+2
View File
@@ -23,6 +23,7 @@ const optionalCount = (value: unknown) => value === undefined || count(value);
const nullableCount = (value: unknown) => value === null || count(value); const nullableCount = (value: unknown) => value === null || count(value);
const decisions = { const decisions = {
ecazBattleLead: true,
harassWithdraw: true, harassWithdraw: true,
diplomatDefense: true, diplomatDefense: true,
diplomatRetreat: true, diplomatRetreat: true,
@@ -108,6 +109,7 @@ const responses = {
moritaniPlacement: true, moritaniPlacement: true,
ecazPlacement: true, ecazPlacement: true,
ecazCollection: true, ecazCollection: true,
ecazOccupy: true,
moritaniAlliance: true, moritaniAlliance: true,
moritaniDuke: true, moritaniDuke: true,
moritaniRetention: true, moritaniRetention: true,
+20 -2
View File
@@ -52,6 +52,7 @@ import {
type BattleLeaderSkill, type BattleLeaderSkill,
type LeaderSkillBattleBonus, type LeaderSkillBattleBonus,
} from './leader-skill-combat'; } from './leader-skill-combat';
import { quoteEcazOccupyOutcome, type EcazOccupyBattleProfile } from './ecaz-occupy-battle';
export class BattleResolutionQuoteError extends Error { export class BattleResolutionQuoteError extends Error {
constructor(message: string) { constructor(message: string) {
@@ -101,6 +102,7 @@ export type ResolutionCombatant = ResolutionParticipant & {
aid?: { donor: string; amount: number }; aid?: { donor: string; amount: number };
}; };
export type BattleResolutionInput = { export type BattleResolutionInput = {
ecazOccupy?: EcazOccupyBattleProfile;
/** Stable physical slots remain unchanged when Sapho changes aggressor. */ /** Stable physical slots remain unchanged when Sapho changes aggressor. */
aggressor?: string; aggressor?: string;
advanced: boolean; advanced: boolean;
@@ -137,6 +139,7 @@ export type BattleSupportPayment = {
freeByTraitor: boolean; freeByTraitor: boolean;
}; };
export type BattleResolutionQuote = { export type BattleResolutionQuote = {
fixedLosses?: { owner: string; normal: number; elite: number }[];
/** Reserve-to-Tanks costs apply to every revealed outcome, including traitors and explosions. */ /** Reserve-to-Tanks costs apply to every revealed outcome, including traitors and explosions. */
reinforcements?: { player: string; card: typeof REINFORCEMENTS_CARD; normal: number; elite: number }[]; reinforcements?: { player: string; card: typeof REINFORCEMENTS_CARD; normal: number; elite: number }[];
harassWithdraw?: (HarassWithdrawQuote & { player: string; card: typeof HARASS_WITHDRAW_CARD })[]; harassWithdraw?: (HarassWithdrawQuote & { player: string; card: typeof HARASS_WITHDRAW_CARD })[];
@@ -172,6 +175,7 @@ export type BattleResolutionQuote = {
rihani?: RihaniSkill; rihani?: RihaniSkill;
sandmaster?: string; sandmaster?: string;
casualties: { casualties: {
owner?: string;
forces: CombatForces; forces: CombatForces;
dial: number; dial: number;
support: number; support: number;
@@ -672,7 +676,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
(deaths.defender ? strengths.defender : 0), (deaths.defender ? strengths.defender : 0),
}; };
} }
const destroyedArmies = let destroyedArmies =
result === 'explosion' result === 'explosion'
? input.participants.filter((p) => p.id !== homeworld?.native).map((p) => p.id) ? input.participants.filter((p) => p.id !== homeworld?.native).map((p) => p.id)
: winner : winner
@@ -694,7 +698,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
!(winner === a ? deaths.attacker : deaths.defender)) : null; !(winner === a ? deaths.attacker : deaths.defender)) : null;
const sandmaster = winner && usesSurvivingSkilledLeader(winner.leaderSkills ?? [], 'sandmaster', winner.leader?.id, const sandmaster = winner && usesSurvivingSkilledLeader(winner.leaderSkills ?? [], 'sandmaster', winner.leader?.id,
!(winner === a ? deaths.attacker : deaths.defender)) ? winner.leader!.id : null; !(winner === a ? deaths.attacker : deaths.defender)) ? winner.leader!.id : null;
if (winner && result === 'normal') { if (winner && result === 'normal' && winner.id !== input.ecazOccupy?.lead) {
if ( if (
input.advanced || input.advanced ||
input.typedCasualties || input.typedCasualties ||
@@ -731,6 +735,19 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
basicWinnerLosses = winner.plan.dial; basicWinnerLosses = winner.plan.dial;
} }
} }
const occupyOutcome = input.ecazOccupy ? quoteEcazOccupyOutcome(input.ecazOccupy, {
result, won: winner?.id === input.ecazOccupy.lead,
dial: (a.id === input.ecazOccupy.lead ? a : d).plan.dial,
support: (a.id === input.ecazOccupy.lead ? a : d).plan.support,
}) : null;
if (occupyOutcome) {
const profile = input.ecazOccupy!;
destroyedArmies = [...new Set([
...destroyedArmies.filter(owner => owner !== profile.ecaz && owner !== profile.ally),
...occupyOutcome.destroyedArmies,
])];
if (winner?.id === profile.lead) casualties = occupyOutcome.casualties;
}
const strongholdIncome = const strongholdIncome =
result === 'mutualTraitors' result === 'mutualTraitors'
? [] ? []
@@ -872,6 +889,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
return { return {
...(harassWithdraw.length ? { harassWithdraw } : {}), ...(harassWithdraw.length ? { harassWithdraw } : {}),
...(reinforcements.length ? { reinforcements } : {}), ...(reinforcements.length ? { reinforcements } : {}),
...(occupyOutcome ? { fixedLosses: occupyOutcome.fixedLosses } : {}),
result, result,
winner: winner?.id ?? null, winner: winner?.id ?? null,
attackerTraitor: ac, attackerTraitor: ac,
+35 -27
View File
@@ -98,10 +98,33 @@ export function settledBoard(g: BoardContext) {
}); });
return { players, released }; return { players, released };
} }
export type BattleBoardBattle = {
territory: string;
attacker: string;
defender: string;
/** Actual co-side members, retained when one seat represents the joint army. */
coalition?: readonly string[];
};
export function quoteBattleBoard(g: BoardContext) { export function quoteBattleBoard(g: BoardContext) {
const { players, released } = settledBoard(g); const { players, released } = settledBoard(g);
const battles: { territory: string; attacker: string; defender: string }[] = const battles: BattleBoardBattle[] = [];
[]; const connected = (a: BoardSeat, b: BoardSeat, site: string) => {
const left = Object.entries(presenceByLocation(a))
.filter(([key, n]) => n > 0 && splitLocation(key).territory === site);
const right = Object.entries(presenceByLocation(b))
.filter(([key, n]) => n > 0 && splitLocation(key).territory === site);
const clear = (x: string, y: string) => gameDistance(g, x, y,
key => splitLocation(key === MOBILE_LOCATION ? (g.mobileStronghold?.location ?? key) : key).sector === g.storm) === 0;
return g.advanced
? left.length > 0 && right.length > 0 && left.every(([x]) => right.every(([y]) => clear(x, y)))
: left.some(([x]) => right.some(([y]) => clear(x, y)));
};
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') &&
fighterCount(ally, site) > 0 ? [seat, ally] : [seat];
};
const emitted = new Set<string>();
for (const id of g.order) { for (const id of g.order) {
const p = players.find((p) => p.id === id)!; const p = players.find((p) => p.id === id)!;
for (const t of gameTerritories(g)) { for (const t of gameTerritories(g)) {
@@ -114,31 +137,16 @@ export function quoteBattleBoard(g: BoardContext) {
g.order.indexOf(other.id) < g.order.indexOf(id) g.order.indexOf(other.id) < g.order.indexOf(id)
) )
continue; continue;
const keys = (s: BoardSeat) => const left = members(p, t.id), right = members(other, t.id);
Object.entries(presenceByLocation(s)) if (!left.every(a => right.every(b => connected(a, b, t.id)))) continue;
.filter( const sides = [left, right].map(side => side.sort((a, b) => g.order.indexOf(a.id) - g.order.indexOf(b.id)))
([key, n]) => n > 0 && splitLocation(key).territory === t.id, .sort((a, b) => g.order.indexOf(a[0].id) - g.order.indexOf(b[0].id));
) const key = JSON.stringify([t.id, sides.map(side => side.map(seat => seat.id).sort())]);
.map(([key]) => key); if (emitted.has(key)) continue;
if ( emitted.add(key);
keys(p).some((x) => const coalition = sides.find(side => side.length > 1);
keys(other).some( battles.push({ territory: t.id, attacker: sides[0][0].id, defender: sides[1][0].id,
(y) => ...(coalition ? { coalition: coalition.map(seat => seat.id) } : {}) });
gameDistance(
g,
x,
y,
(key) =>
splitLocation(
key === MOBILE_LOCATION
? (g.mobileStronghold?.location ?? key)
: key,
).sector === g.storm,
) === 0,
),
)
)
battles.push({ territory: t.id, attacker: id, defender: other.id });
} }
} }
} }
+14 -4
View File
@@ -1,4 +1,6 @@
import { discoveryBotActions } from './discovery-options'; import { discoveryBotActions } from './discovery-options';
import { ecazOccupyDialChoices, ecazOccupyOwnProfile, ecazOccupyPolicyActions } from './ecaz-occupy-options';
import { quoteEcazOccupyDial } from './ecaz-occupy-battle';
import { recruitsPlayAction } from './recruits'; import { recruitsPlayAction } from './recruits';
import type { Card } from './cards'; import type { Card } from './cards';
import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, leaderSkillBattleBonus, usesSurvivingSkilledLeader } from './leader-skill-combat'; import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, leaderSkillBattleBonus, usesSurvivingSkilledLeader } from './leader-skill-combat';
@@ -616,10 +618,12 @@ function plans(g: GameView): Action[] {
!g.advanced || (me.faction === 'fremen' && !b.fremenSupportBlocked), !g.advanced || (me.faction === 'fremen' && !b.fremenSupportBlocked),
}; };
const own = forces.normal + forces.elite; const own = forces.normal + forces.elite;
const occupyProfile = ecazOccupyOwnProfile(g);
const occupyDials = ecazOccupyDialChoices(g);
const typedForces = g.advanced || me.faction === 'ixians'; const typedForces = g.advanced || me.faction === 'ixians';
const ownStrength = typedForces const ownStrength = occupyProfile?.maxDial ?? (typedForces
? maxCombatDial(forces) ? maxCombatDial(forces)
: own; : own);
const supportCache = new Map< const supportCache = new Map<
number, number,
{ support: number; cost: number } | null { support: number; cost: number } | null
@@ -639,7 +643,10 @@ function plans(g: GameView): Action[] {
: 0) + 1, : 0) + 1,
}, },
(_, support) => (_, support) =>
casualtyOptions(forces, dial, support).map((loss) => ({ (occupyProfile
? occupyDials.some((option) => option.dial === dial && option.support === support)
? quoteEcazOccupyDial(occupyProfile, dial, support).options : []
: casualtyOptions(forces, dial, support)).map((loss) => ({
support, support,
cost: cost:
Math.max( Math.max(
@@ -889,6 +896,7 @@ function plans(g: GameView): Action[] {
ideal, ideal,
...bankSupportedDials, ...bankSupportedDials,
...freeEliteDials, ...freeEliteDials,
...occupyDials.map((option) => option.dial),
...[1, 2, 3].map((bonus) => Math.max(0, ideal - bonus)), ...[1, 2, 3].map((bonus) => Math.max(0, ideal - bonus)),
0, 0,
...(typedForces ? [0.5] : []), ...(typedForces ? [0.5] : []),
@@ -1065,7 +1073,7 @@ function plans(g: GameView): Action[] {
if (b.compliantPlan && commitments.every( if (b.compliantPlan && commitments.every(
(element) => fixed(element.field, b.compliantPlan![element.field]), (element) => fixed(element.field, b.compliantPlan![element.field]),
)) ranked.push({ type: 'battlePlan', ...b.compliantPlan }); )) ranked.push({ type: 'battlePlan', ...b.compliantPlan });
return ranked; return ecazOccupyPolicyActions(g, ranked);
} }
// This policy receives a personalized view, never the authoritative decks or rival hands. // This policy receives a personalized view, never the authoritative decks or rival hands.
@@ -1459,6 +1467,7 @@ function policyActions(g: GameView): Action[] {
(g.response.kind === 'nexusPrescience' && (g.response.kind === 'nexusPrescience' &&
g.battle!.nexusInspection?.target === me.id) || g.battle!.nexusInspection?.target === me.id) ||
g.response.kind === 'harkonnenTraitor' || g.response.kind === 'harkonnenTraitor' ||
g.response.kind === 'ecazOccupy' ||
g.response.kind === 'eliteStrength' || g.response.kind === 'eliteStrength' ||
g.response.kind === 'fremenSupport' || g.response.kind === 'fremenSupport' ||
g.response.kind === 'nexusSardaukar' || g.response.kind === 'nexusSardaukar' ||
@@ -1545,6 +1554,7 @@ function policyActions(g: GameView): Action[] {
{ type: 'decision', event: d.event, pay, ...(pay ? { count } : {}) }, { type: 'decision', event: d.event, pay, ...(pay ? { count } : {}) },
]; ];
} }
if (d.kind === 'ecazBattleLead') return ecazOccupyPolicyActions(g);
if (d.kind === 'strongholdCopy') { if (d.kind === 'strongholdCopy') {
const score = (id: string) => const score = (id: string) =>
id === 'arrakeen' id === 'arrakeen'
+28 -11
View File
@@ -1,5 +1,6 @@
import { FACTIONS, type FactionId } from './catalog'; import { FACTIONS, type FactionId } from './catalog';
import { VOICE_KINDS } from './battle-cards'; import { VOICE_KINDS } from './battle-cards';
import { quoteEcazOccupyBattle, type EcazOccupyBattleProfile } from './ecaz-occupy-battle';
export type CombatCheckKind = export type CombatCheckKind =
| 'kwisatz' | 'kwisatz'
@@ -9,6 +10,7 @@ export type CombatCheckKind =
export type CombatResponseKind = CombatCheckKind | 'voice' | 'prescience'; export type CombatResponseKind = CombatCheckKind | 'voice' | 'prescience';
export type CombatCheck = { kind: CombatCheckKind; owner: string }; export type CombatCheck = { kind: CombatCheckKind; owner: string };
export type CombatResponseInput = { export type CombatResponseInput = {
ecazOccupy?: EcazOccupyBattleProfile;
status: string; status: string;
phase: number; phase: number;
advanced: boolean; advanced: boolean;
@@ -132,6 +134,20 @@ function calculate(
input.territoryIds.includes(b.territory), input.territoryIds.includes(b.territory),
'Combat responses need two distinct seated combatants and their territory.', 'Combat responses need two distinct seated combatants and their territory.',
); );
const occupy = input.ecazOccupy;
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),
'Occupy force powers need the actual Ecaz coalition and selected lead.');
const profile = quoteEcazOccupyBattle({
battleOrderActor: occupy.battleOrderActor,
ecaz: { id: ecaz.id, faction: 'ecaz', ally: ecaz.ally, forces: occupy.ecazForces },
ally: { id: ally.id, faction: ally.faction, ally: ally.ally, forces: occupy.allyForces },
lead: occupy.lead, canceled: occupy.canceled,
});
requireCombat(profile.forceOwner === occupy.forceOwner, 'Occupy lost its actual physical force owner.');
}
requireCombat( requireCombat(
record(b.plans) && record(b.plans) &&
Object.keys(b.plans).every( Object.keys(b.plans).every(
@@ -154,27 +170,28 @@ function calculate(
requireCombat( requireCombat(
b.eliteBlocked === undefined || b.eliteBlocked === undefined ||
(ids(b.eliteBlocked) && (ids(b.eliteBlocked) &&
b.eliteBlocked.every((id) => combatants.includes(id))), b.eliteBlocked.every((id) => combatants.some(seat => (seat === occupy?.lead ? occupy.forceOwner : seat) === id))),
'Blocked elite owners are malformed.', 'Blocked elite owners are malformed.',
); );
// This is only the possible ordering of already declared checks. Current // This is only the possible ordering of already declared checks. Current
// elite counts or KH availability are deliberately not benefit prerequisites. // elite counts or KH availability are deliberately not benefit prerequisites.
const possible: CombatCheck[] = []; const possible: CombatCheck[] = [];
for (const id of combatants) { for (const id of combatants) {
const p = input.players.find((p) => p.id === id)!, const p = input.players.find(player => player.id === id)!;
other = input.players.find( const opponent = combatants.find(seat => seat !== id)!;
(p) => p.id === combatants.find((x) => x !== id), const other = input.players.find(player => player.id === (opponent === occupy?.lead ? occupy.forceOwner : opponent))!;
)!;
if (input.advanced && p.faction === 'atreides') if (input.advanced && p.faction === 'atreides')
possible.push({ kind: 'kwisatz', owner: id }); possible.push({ kind: 'kwisatz', owner: id });
const forceOwner = id === occupy?.lead ? occupy.forceOwner : id;
const forcePlayer = input.players.find(player => player.id === forceOwner)!;
if ( if (
((input.advanced && ['emperor', 'fremen'].includes(p.faction)) || ((input.advanced && ['emperor', 'fremen'].includes(forcePlayer.faction)) ||
(!input.advanced && p.faction === 'ixians')) && (!input.advanced && forcePlayer.faction === 'ixians')) &&
!(p.faction === 'emperor' && other.faction === 'fremen') !(forcePlayer.faction === 'emperor' && other.faction === 'fremen')
) )
possible.push({ kind: 'eliteStrength', owner: id }); possible.push({ kind: 'eliteStrength', owner: forceOwner });
if (input.advanced && p.faction === 'fremen') if (input.advanced && forcePlayer.faction === 'fremen')
possible.push({ kind: 'fremenSupport', owner: id }); possible.push({ kind: 'fremenSupport', owner: forceOwner });
} }
const choam = input.players.find((p) => p.faction === 'choam'); const choam = input.players.find((p) => p.faction === 'choam');
if ( if (
+243
View File
@@ -0,0 +1,243 @@
import type { FactionId } from './catalog';
import {
casualtyOptions,
maxCombatDial,
maxCombatSupport,
validCombatForces,
type Casualties,
type CombatForces,
} from './combat';
export class EcazOccupyBattleError extends Error {
constructor(message: string) {
super(message);
this.name = 'EcazOccupyBattleError';
}
}
/** Native fighter pools for an eligible whole-territory battle. The canonical
* board quote excludes storm-obstructed armies and advisors before planning. */
export type EcazOccupyBattleSeat = Readonly<{
id: string;
faction: FactionId;
ally?: string | null;
forces: CombatForces;
}>;
export type EcazOccupyBattleInput = Readonly<{
/** Original battle chooser, before Ecaz selects the coalition's plan actor. */
battleOrderActor: string;
ecaz: EcazOccupyBattleSeat;
ally: EcazOccupyBattleSeat;
lead: string;
canceled: boolean;
}>;
export type EcazOccupyBattleProfile = Readonly<{
battleOrderActor: string;
ecaz: string;
ally: string;
lead: string;
planOwner: string;
payer: string;
forceOwner: string;
canceled: boolean;
ecazForces: CombatForces;
allyForces: CombatForces;
/** Only the variable dial's physical counters, never a merged army. */
forces: CombatForces;
fixedEcazDial: number;
maxDial: number;
maxSupport: number;
}>;
export type EcazOccupyDialQuote = {
forceOwner: string;
payer: string;
fixedEcazDial: number;
variableDial: number;
support: number;
options: Casualties[];
};
export type EcazOccupyDialOption = {
dial: number;
support: number;
variableDial: number;
};
export type EcazOccupyOutcomeInput = Readonly<{
result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion';
/** Whether this coalition, not necessarily its original slot actor, won. */
won: boolean;
dial: number;
support: number;
}>;
export type EcazOccupyOutcomeQuote = {
destroyedArmies: string[];
fixedLosses: { owner: string; normal: number; elite: number }[];
casualties: {
owner: string;
forces: CombatForces;
dial: number;
support: number;
options: Casualties[];
} | null;
};
function requireBattle(condition: unknown, message: string): asserts condition {
if (!condition) throw new EcazOccupyBattleError(message);
}
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. */
export function quoteEcazOccupyBattle(
input: EcazOccupyBattleInput,
): EcazOccupyBattleProfile {
const { ecaz, ally, lead, canceled, battleOrderActor } = input;
requireBattle(
ecaz && ally && text(ecaz.id) && text(ally.id) && ecaz.id !== ally.id &&
ecaz.faction === 'ecaz' && ally.faction !== 'ecaz' &&
ecaz.ally === ally.id && ally.ally === ecaz.id,
'An Occupy battle needs Ecaz and its distinct reciprocal ally.',
);
requireBattle(
validCombatForces(ecaz.forces) && validCombatForces(ally.forces),
'An Occupy battle needs valid native physical fighter pools.',
);
requireBattle(
ecaz.forces.elite === 0 && !ecaz.forces.temporaryElite &&
!ecaz.forces.normalFixedHalf && ecaz.forces.normal > 0 &&
ally.forces.normal + ally.forces.elite > 0,
'Both Occupy members need storm-connected fighters; Ecaz uses ordinary counters, not advisors or an elite pool.',
);
requireBattle(
lead === ecaz.id || lead === ally.id,
'The Occupy lead must be Ecaz or its participating ally.',
);
requireBattle(
text(battleOrderActor),
'The original Occupy battle-order actor must be identified.',
);
requireBattle(
typeof canceled === 'boolean',
'The Occupy profile needs its pre-plan cancellation state.',
);
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);
return {
battleOrderActor,
ecaz: ecaz.id,
ally: ally.id,
lead,
planOwner: lead,
payer: lead,
forceOwner,
canceled,
ecazForces: ecaz.forces,
allyForces: ally.forces,
forces,
fixedEcazDial,
maxDial: fixedEcazDial + maxCombatDial(forces),
maxSupport: maxCombatSupport(forces),
};
}
/** The public dial includes Ecaz's mandatory increment. Support and typed
* casualties belong only to the native variable pool; fixed strength neither
* spends spice nor creates a CHOAM support payment. Funding is checked by the
* caller against the selected payer's existing native funding permissions. */
export function quoteEcazOccupyDial(
profile: EcazOccupyBattleProfile,
dial: number,
support: number,
): EcazOccupyDialQuote {
requireBattle(
Number.isFinite(dial) && Number.isSafeInteger(dial * 2) &&
dial >= profile.fixedEcazDial && dial <= profile.maxDial,
`The Occupy dial must include ${profile.fixedEcazDial} fixed Ecaz strength and fit the ${profile.forceOwner} fighter pool.`,
);
requireBattle(
Number.isSafeInteger(support) && support >= 0 && support <= profile.maxSupport,
`The Occupy support must fit the native ${profile.forceOwner} support rules; the fixed Ecaz contribution is free.`,
);
const variableDial = dial - profile.fixedEcazDial;
const options = casualtyOptions(profile.forces, variableDial, support);
requireBattle(
options.length > 0,
`The Occupy dial and support do not match a legal physical commitment by ${profile.forceOwner}.`,
);
return {
forceOwner: profile.forceOwner,
payer: profile.payer,
fixedEcazDial: profile.fixedEcazDial,
variableDial,
support,
options,
};
}
/** Legal physical commitments, limited by the selected lead's available
* support funding. Native payment permissions remain the caller's concern. */
export function ecazOccupyDialOptions(
profile: EcazOccupyBattleProfile,
availableSupport: number,
): EcazOccupyDialOption[] {
requireBattle(
Number.isSafeInteger(availableSupport) && availableSupport >= 0,
'Available Occupy support must be a nonnegative whole spice amount.',
);
const options: EcazOccupyDialOption[] = [];
const maximumSupport = Math.min(availableSupport, profile.maxSupport);
for (let halfDial = profile.fixedEcazDial * 2;
halfDial <= profile.maxDial * 2; halfDial++) {
const dial = halfDial / 2;
const variableDial = dial - profile.fixedEcazDial;
for (let support = 0; support <= maximumSupport; support++) {
if (casualtyOptions(profile.forces, variableDial, support).length > 0)
options.push({ dial, support, variableDial });
}
}
return options;
}
/** Force settlement only: leader deaths, cards, payments and bounty remain
* native battle outcomes. Karama's canceled winner keeps the other member's
* undialed army; canceled losing-side destruction follows the general rule. */
export function quoteEcazOccupyOutcome(
profile: EcazOccupyBattleProfile,
input: EcazOccupyOutcomeInput,
): EcazOccupyOutcomeQuote {
requireBattle(
['normal', 'traitor', 'mutualTraitors', 'explosion'].includes(input.result) &&
typeof input.won === 'boolean',
'The Occupy aftermath needs an actual native battle result and winning side.',
);
requireBattle(
!input.won || input.result === 'normal' || input.result === 'traitor',
'Mutual traitors and explosions have no Occupy winner.',
);
const dial = quoteEcazOccupyDial(profile, input.dial, input.support);
if (!input.won)
return {
destroyedArmies: [profile.ecaz, profile.ally],
fixedLosses: [],
casualties: null,
};
if (input.result === 'traitor')
return { destroyedArmies: [], fixedLosses: [], casualties: null };
return {
destroyedArmies: [],
fixedLosses: profile.canceled ? [] : [{
owner: profile.ecaz,
normal: profile.fixedEcazDial,
elite: 0,
}],
casualties: {
owner: profile.forceOwner,
forces: profile.forces,
dial: dial.variableDial,
support: dial.support,
options: dial.options,
},
};
}
+87
View File
@@ -0,0 +1,87 @@
import type { Action, GameView } from './engine';
import {
ecazOccupyDialOptions,
quoteEcazOccupyDial,
type EcazOccupyBattleProfile,
} from './ecaz-occupy-battle';
/** The selected lead plans and pays; the profile's force owner need not be that lead. */
export function ecazOccupyOwnProfile(game: GameView): EcazOccupyBattleProfile | null {
const battle = game.battle;
const occupy = battle?.ecazOccupy;
const profile = occupy?.profile;
return battle && occupy?.event === battle.event && profile?.planOwner === game.me
? profile : null;
}
export function ecazOccupyLeadOptions(game: GameView) {
const decision = game.decision;
const battle = game.battle;
if (decision?.kind !== 'ecazBattleLead' || !battle?.ecazOccupy ||
decision.event !== battle.event || decision.event !== battle.ecazOccupy.event) return [];
const blocked = game.status !== 'playing' || game.phase !== 6
? 'This battle is not awaiting a lead choice.'
: decision.player !== game.me ? 'Only Ecaz chooses the lead.' : null;
return decision.choices.map((lead) => ({
lead,
label: game.players.find((player) => player.id === lead)?.name ?? lead,
blocked,
action: { type: 'decision', event: decision.event, lead },
}));
}
function supportBudget(game: GameView, profile: EcazOccupyBattleProfile) {
const personalSpice = game.players.find((player) => player.id === game.me)?.spice ?? 0;
const bankSupport = game.battle?.strongholdEffects[profile.payer] === 'arrakeen' ? 2 : 0;
return {
personalSpice,
maximum: Math.min(profile.maxSupport, personalSpice + game.aid.available + bankSupport),
};
}
/** Only the lead's own projected funds enter affordability; never the other ally's spice. */
export function ecazOccupyDialChoices(game: GameView) {
const profile = ecazOccupyOwnProfile(game);
return profile ? ecazOccupyDialOptions(profile, supportBudget(game, profile).maximum) : [];
}
/** Numeric editor constraints and an explanation for an unsealable physical dial. */
export function ecazOccupyPlanControl(game: GameView, dial: number, support: number) {
const profile = ecazOccupyOwnProfile(game);
if (!profile) return null;
const budget = supportBudget(game, profile);
let blocked: string | null = null;
let variableDial: number | null = null;
try {
const quote = quoteEcazOccupyDial(profile, dial, support);
variableDial = quote.variableDial;
if (support > budget.maximum) blocked = 'The selected lead cannot afford this spice support.';
} catch (error) {
blocked = error instanceof Error ? error.message : 'Choose a legal total dial and spice support.';
}
return {
profile,
minimum: profile.fixedEcazDial,
maximum: profile.maxDial,
step: 0.5,
supportMaximum: budget.maximum,
personalSpice: budget.personalSpice,
fixedEcazDial: profile.fixedEcazDial,
variableDial,
blocked,
};
}
/**
* All four native policies share this legality adapter. Native candidate plans already
* use the selected lead's leaders/cards and TOTAL dials; no new strategy is introduced.
* It also serves prescience candidate generation before the ordinary plan editor opens.
*/
export function ecazOccupyPolicyActions(game: GameView, nativeCandidates: Action[] = []): Action[] {
if (game.decision?.kind === 'ecazBattleLead') {
return ecazOccupyLeadOptions(game).filter((option) => !option.blocked).map((option) => option.action);
}
if (!ecazOccupyOwnProfile(game)) return nativeCandidates;
return nativeCandidates.filter((action) => action.type !== 'battlePlan' ||
!ecazOccupyPlanControl(game, Number(action.dial), Number(action.support ?? 0))?.blocked);
}
+152 -38
View File
@@ -159,6 +159,7 @@ import {
} from './homeworld-native-reserves'; } from './homeworld-native-reserves';
import { validAmbassadorResume } from './ambassador-resume'; import { validAmbassadorResume } from './ambassador-resume';
import { ecazOccupancyRelation } from './ecaz-occupy'; import { ecazOccupancyRelation } from './ecaz-occupy';
import { quoteEcazOccupyBattle, quoteEcazOccupyDial, type EcazOccupyBattleProfile } from './ecaz-occupy-battle';
import { territoryEntryBlock, strongholdPathBlocked } from './occupancy'; import { territoryEntryBlock, strongholdPathBlocked } from './occupancy';
import { import {
validateGuildAmbassadorArrivalContext, validateGuildAmbassadorArrivalContext,
@@ -664,6 +665,7 @@ export type HarassAllocationReceipt = {
stage: 'offered' | 'selected'; selection: HarassWithdrawSelection | null; signature: string; stage: 'offered' | 'selected'; selection: HarassWithdrawSelection | null; signature: string;
}; };
export type Battle = { export type Battle = {
ecazOccupy?: { event: string; ecaz: string; ally: string; lead: string | null; canceled: boolean };
/** New battles bind automatic collection to the original public plan reveal. */ /** New battles bind automatic collection to the original public plan reveal. */
smugglerCollectionVersion?: 1; smugglerCollectionVersion?: 1;
smugglerCollection?: SmugglerBattleReceipt | null; smugglerCollection?: SmugglerBattleReceipt | null;
@@ -757,6 +759,7 @@ export type Auction = {
allyPayment?: number; allyPayment?: number;
}; };
export type Decision = export type Decision =
| { kind: 'ecazBattleLead'; player: string; event: string; choices: string[] }
| { kind: 'harassWithdraw'; player: string; event: string } | { kind: 'harassWithdraw'; player: string; event: string }
| { kind: 'diplomatDefense'; player: string; event: string; cards: string[]; source: string } | { kind: 'diplomatDefense'; player: string; event: string; cards: string[]; source: string }
| { kind: 'diplomatRetreat'; player: string; event: string; destinations: DiplomatRetreatDestination[] } | { kind: 'diplomatRetreat'; player: string; event: string; destinations: DiplomatRetreatDestination[] }
@@ -868,6 +871,7 @@ export type Decision =
player: string; player: string;
territory: string; territory: string;
options: Casualties[]; options: Casualties[];
forceOwner?: string;
cards: string[]; cards: string[];
} }
| { kind: 'homeworldDefense'; player: string; event: string } | { kind: 'homeworldDefense'; player: string; event: string }
@@ -969,6 +973,7 @@ export type ResponseWindow = {
| 'moritaniPlacement' | 'moritaniPlacement'
| 'ecazPlacement' | 'ecazPlacement'
| 'ecazCollection' | 'ecazCollection'
| 'ecazOccupy'
| 'moritaniAlliance' | 'moritaniAlliance'
| 'moritaniDuke' | 'moritaniDuke'
| 'moritaniRetention' | 'moritaniRetention'
@@ -1119,6 +1124,7 @@ type RicheseAllyOffer = {
payer: string; payer: string;
}; };
export type Game = { export type Game = {
ecazOccupyPreview?: boolean;
/** Fresh native Guild/Nexus payment-replacement profile, never inferred from a save. */ /** Fresh native Guild/Nexus payment-replacement profile, never inferred from a save. */
guildBetrayalPreview?: boolean; guildBetrayalPreview?: boolean;
guildBetrayal?: GuildBetrayalCursor; guildBetrayal?: GuildBetrayalCursor;
@@ -1417,6 +1423,7 @@ export type Game = {
winner: string | null; winner: string | null;
cards: string[]; cards: string[];
casualties?: { casualties?: {
owner?: string;
forces: CombatForces; forces: CombatForces;
dial: number; dial: number;
support: number; support: number;
@@ -3015,6 +3022,7 @@ function nexusTraitorParentSignature(g: Game, event: string) {
function traitorDeclarationContext(g: Game): TraitorDeclarationContext { function traitorDeclarationContext(g: Game): TraitorDeclarationContext {
const b = g.battle!; const b = g.battle!;
return { event: b.event!, attacker: b.attacker, defender: b.defender, plans: b.plans, return { event: b.event!, attacker: b.attacker, defender: b.defender, plans: b.plans,
...(b.ecazOccupy ? { ecazOccupy: ecazBattleProfile(g)! } : {}),
players: g.players.map(({id,faction,ally,traitors}) => ({id,faction,ally,traitors})), players: g.players.map(({id,faction,ally,traitors}) => ({id,faction,ally,traitors})),
heroLeaderIds: physicalTreacheryCards(g).filter(c => c.kind === 'hero').map(c => c.id), heroLeaderIds: physicalTreacheryCards(g).filter(c => c.kind === 'hero').map(c => c.id),
}; };
@@ -7280,6 +7288,31 @@ function combatForces(
(p.faction === 'fremen' && !g.battle?.fremenSupportBlocked), (p.faction === 'fremen' && !g.battle?.fremenSupportBlocked),
}; };
} }
function ecazBattleProfile(g: Game): EcazOccupyBattleProfile | null {
const b = g.battle, occupy = b?.ecazOccupy;
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' &&
ecaz.ally === ally.id && ally.ally === ecaz.id && [b.attacker, b.defender].includes(occupy.lead) &&
!isAdvisor(ecaz, b.territory) && !isAdvisor(ally, b.territory) &&
Object.entries(ecaz.forces).some(([x, n]) => n > 0 && splitLocation(x).territory === b.territory &&
Object.entries(ally.forces).some(([y, m]) => m > 0 && splitLocation(y).territory === b.territory &&
gameDistance(g, x, y, key => splitLocation(key).sector === g.storm) === 0)),
'Occupy needs the original battle and physically connected allied fighters.');
const opponent = getPlayer(g, b.attacker === occupy.lead ? b.defender : b.attacker);
return nexusRule(() => quoteEcazOccupyBattle({
battleOrderActor: b.chooser ?? b.attacker,
ecaz: { id: ecaz.id, faction: 'ecaz', ally: ecaz.ally, forces: combatForces(g, ecaz, b.territory, opponent) },
ally: { id: ally.id, faction: ally.faction, ally: ally.ally, forces: combatForces(g, ally, b.territory, opponent) },
lead, canceled: occupy.canceled,
}));
}
function planCombatForces(g: Game, p: Player, opponent: Player): CombatForces {
const profile = ecazBattleProfile(g);
return profile?.lead === p.id ? profile.forces :
combatForces(g, p, g.battle!.territory, profile?.lead === opponent.id ? getPlayer(g, profile.forceOwner) : opponent);
}
function takeBattleLosses(g: Game, p: Player, t: string, losses: Casualties) { function takeBattleLosses(g: Game, p: Player, t: string, losses: Casualties) {
if (t.startsWith('homeworld:')) { if (t.startsWith('homeworld:')) {
commitHomeworldLoss(g, p.id, t, { normal: losses.normal, elite: losses.elite }); commitHomeworldLoss(g, p.id, t, { normal: losses.normal, elite: losses.elite });
@@ -9080,6 +9113,18 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game {
requireFreshFactionInventory(state); requireFreshFactionInventory(state);
return initializeSetupGameForAudit(state, false, false, false, false, false, false, true); return initializeSetupGameForAudit(state, false, false, false, false, false, false, true);
} }
/** Fresh native Advanced Occupy profile; public starts and Basic are unchanged. */
export function initializeEcazOccupyGameForAudit(state: Game): Game {
requireRule(state.advanced === true && state.expansions.length === 1 && state.expansions[0] === 'ecaz' &&
state.players.length >= 2 && state.players.length <= 6 && state.players.some(p => p.faction === 'ecaz') &&
state.players.every(p => faction(p.faction).expansion === 'base' || p.faction === 'ecaz' || p.faction === 'moritani') &&
!state.ecazTreachery,
'Advanced Occupy requires fresh Ecaz and classic opponents, optional native Moritani, the Ecaz faction expansion and base Treachery.');
requireFreshBaseRuntime(state);
const g = initializeFactionExpansionsGameForAudit(state);
g.ecazOccupyPreview = true;
return g;
}
/** Fresh Advanced E1/E2 faction composition; normal starts and other modules stay gated. */ /** Fresh Advanced E1/E2 faction composition; normal starts and other modules stay gated. */
export function initializeStrongholdFactionsGameForAudit(state: Game): Game { export function initializeStrongholdFactionsGameForAudit(state: Game): Game {
requireRule(state.advanced === true && Array.isArray(state.expansions) && requireRule(state.advanced === true && Array.isArray(state.expansions) &&
@@ -11891,7 +11936,7 @@ function currentReinforcementsCost(g: Game, p: Player) {
const other = getPlayer(g, g.battle.attacker === p.id ? g.battle.defender : g.battle.attacker); const other = getPlayer(g, g.battle.attacker === p.id ? g.battle.defender : g.battle.attacker);
requireRule(!ecazAllyAtBattle(g, p, g.battle.territory) && requireRule(!ecazAllyAtBattle(g, p, g.battle.territory) &&
!ecazAllyAtBattle(g, other, g.battle.territory), !ecazAllyAtBattle(g, other, g.battle.territory),
'Reinforcements with co-present Ecaz allies awaits combined-army battle integration.'); 'Reinforcements with combined Occupy armies needs its additional-card source ruling.');
} }
try { try {
return quoteReinforcements(p.reserves - (p.elites?.reserves ?? 0), p.elites?.reserves ?? 0); return quoteReinforcements(p.reserves - (p.elites?.reserves ?? 0), p.elites?.reserves ?? 0);
@@ -11913,7 +11958,7 @@ function reinforcementsPreview(g: Game, p: Player) {
blocked = 'Choose Reinforcements before sealing your Battle Plan.'; blocked = 'Choose Reinforcements before sealing your Battle Plan.';
else if (ecazAllyAtBattle(g, p, battle.territory) || else if (ecazAllyAtBattle(g, p, battle.territory) ||
ecazAllyAtBattle(g, getPlayer(g, battle.attacker === p.id ? battle.defender : battle.attacker), battle.territory)) ecazAllyAtBattle(g, getPlayer(g, battle.attacker === p.id ? battle.defender : battle.attacker), battle.territory))
blocked = 'Reinforcements with co-present Ecaz allies awaits combined-army battle integration.'; blocked = 'Reinforcements with combined Occupy armies needs its additional-card source ruling.';
else if (normal + elite < 3) else if (normal + elite < 3)
blocked = 'Reinforcements needs three physical forces in your reserves.'; blocked = 'Reinforcements needs three physical forces in your reserves.';
return { blocked, normal, elite }; return { blocked, normal, elite };
@@ -18034,7 +18079,7 @@ function harassWithdrawContext(g: Game, p: Player): HarassWithdrawContext {
else if (g.players.some(player => player.faction === 'richese') || g.richeseCache !== undefined || g.richeseRemoved !== undefined) else if (g.players.some(player => player.faction === 'richese') || g.richeseCache !== undefined || g.richeseRemoved !== undefined)
blocked = 'Harass & Withdraw with the Richese card family awaits the Stone Burner timing ruling.'; blocked = 'Harass & Withdraw with the Richese card family awaits the Stone Burner timing ruling.';
else if (ecazAllyAtBattle(g, p, b.territory) || ecazAllyAtBattle(g, other, b.territory)) else if (ecazAllyAtBattle(g, p, b.territory) || ecazAllyAtBattle(g, other, b.territory))
blocked = 'Harass & Withdraw with co-present Ecaz allies awaits combined-army battle integration.'; blocked = 'Harass & Withdraw with combined Occupy armies needs its additional-card source ruling.';
else if (p.noField?.deployed?.location.territory === b.territory) else if (p.noField?.deployed?.location.territory === b.territory)
blocked = 'Reveal your No-Field before using Harass & Withdraw.'; blocked = 'Reveal your No-Field before using Harass & Withdraw.';
const locations = Object.fromEntries(Object.entries(p.forces) const locations = Object.fromEntries(Object.entries(p.forces)
@@ -18410,7 +18455,9 @@ function validatePlan(
): Plan { ): Plan {
const b = g.battle!; const b = g.battle!;
const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker); const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
const forces = combatForces(g, p, b.territory, opponent); 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';
const bankerSpice = input.bankerSpice === undefined ? 0 : input.bankerSpice; const bankerSpice = input.bankerSpice === undefined ? 0 : input.bankerSpice;
requireRule(typeof bankerSpice === 'number' && Number.isSafeInteger(bankerSpice) && bankerSpice >= 0 && bankerSpice <= 3, requireRule(typeof bankerSpice === 'number' && Number.isSafeInteger(bankerSpice) && bankerSpice >= 0 && bankerSpice <= 3,
@@ -18424,7 +18471,7 @@ function validatePlan(
requireRule( requireRule(
!typedForces || !typedForces ||
(typeof input.dial === 'number' && (typeof input.dial === 'number' &&
casualtyOptions(forces, dial, support).length > 0), (occupy ? nexusRule(() => quoteEcazOccupyDial(occupy, dial, support)).options : casualtyOptions(forces, dial, support)).length > 0),
'Dial and spice must match a legal force commitment.', 'Dial and spice must match a legal force commitment.',
); );
const allyPayment = integer( const allyPayment = integer(
@@ -18582,6 +18629,7 @@ function currentCombatResponseQuote(
status: g.status, status: g.status,
phase: g.phase, phase: g.phase,
advanced: g.advanced, advanced: g.advanced,
...(g.battle?.ecazOccupy ? { ecazOccupy: ecazBattleProfile(g)! } : {}),
territoryIds: combatLocations(g).map((t) => t.id), territoryIds: combatLocations(g).map((t) => t.id),
players: g.players.map((p) => ({ players: g.players.map((p) => ({
id: p.id, id: p.id,
@@ -18623,10 +18671,14 @@ function nextCombatResponse(g: Game) {
function combatResponses(g: Game) { function combatResponses(g: Game) {
const b = g.battle!; const b = g.battle!;
b.powerChecks = []; b.powerChecks = [];
const profile = ecazBattleProfile(g);
for (const id of [b.attacker, b.defender]) { for (const id of [b.attacker, b.defender]) {
const p = getPlayer(g, id), const planOwner = getPlayer(g, id);
other = getPlayer(g, id === b.attacker ? b.defender : b.attacker); const forceOwner = profile?.lead === id ? profile.forceOwner : id;
if (g.advanced && p.faction === 'atreides') const p = getPlayer(g, forceOwner);
const opponentId = id === b.attacker ? b.defender : b.attacker;
const other = getPlayer(g, profile?.lead === opponentId ? profile.forceOwner : opponentId);
if (g.advanced && planOwner.faction === 'atreides')
b.powerChecks.push({ kind: 'kwisatz', owner: id }); b.powerChecks.push({ kind: 'kwisatz', owner: id });
if ( if (
((g.advanced && ['emperor', 'fremen'].includes(p.faction)) || ((g.advanced && ['emperor', 'fremen'].includes(p.faction)) ||
@@ -18634,9 +18686,9 @@ function combatResponses(g: Game) {
!(p.faction === 'emperor' && other.faction === 'fremen') && !(p.faction === 'emperor' && other.faction === 'fremen') &&
combatForces(g, p, b.territory, other).elite > 0 combatForces(g, p, b.territory, other).elite > 0
) )
b.powerChecks.push({ kind: 'eliteStrength', owner: id }); b.powerChecks.push({ kind: 'eliteStrength', owner: forceOwner });
if (g.advanced && p.faction === 'fremen') if (g.advanced && p.faction === 'fremen')
b.powerChecks.push({ kind: 'fremenSupport', owner: id }); b.powerChecks.push({ kind: 'fremenSupport', owner: forceOwner });
} }
const choam = byFaction(g, 'choam'); const choam = byFaction(g, 'choam');
if ( if (
@@ -18957,11 +19009,13 @@ function findLegalBattlePlan(
return accepts(sealed) ? sealed : null; return accepts(sealed) ? sealed : null;
} }
const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker); const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
const forces = combatForces(g, p, b.territory, opponent); const forces = planCombatForces(g, p, opponent);
const profile = ecazBattleProfile(g);
const fixedDial = profile?.lead === p.id ? profile.fixedEcazDial : 0;
const typed = g.advanced || p.faction === 'ixians'; const typed = g.advanced || p.faction === 'ixians';
const maxDial = typed ? maxCombatDial(forces) : forces.normal + forces.elite; const maxDial = fixedDial + (typed ? maxCombatDial(forces) : forces.normal + forces.elite);
const numerical: { dial: number; support: number }[] = []; const numerical: { dial: number; support: number }[] = [];
for (let dial = 0; dial <= maxDial; dial += typed ? 0.5 : 1) for (let dial = fixedDial; dial <= maxDial; dial += typed ? 0.5 : 1)
for ( for (
let support = 0; let support = 0;
support <= support <=
@@ -18973,7 +19027,7 @@ function findLegalBattlePlan(
const n = { dial, support }; const n = { dial, support };
if ( if (
accepts(n) && accepts(n) &&
(!typed || casualtyOptions(forces, dial, support).length) (!typed || casualtyOptions(forces, dial - fixedDial, support).length)
) )
numerical.push(n); numerical.push(n);
} }
@@ -19552,6 +19606,8 @@ function feasiblePrescience(
// Validate the revealed element without committing any other plan components. // Validate the revealed element without committing any other plan components.
// A legal completion must exist, including compliance with the preceding Voice. // A legal completion must exist, including compliance with the preceding Voice.
const b = g.battle!; const b = g.battle!;
const profile = ecazBattleProfile(g);
const forces = planCombatForces(g, p, getPlayer(g, b.attacker === p.id ? b.defender : b.attacker));
const leaderIds: (string | null)[] = [ const leaderIds: (string | null)[] = [
null, null,
...controlledLeaders(g, p) ...controlledLeaders(g, p)
@@ -19577,7 +19633,7 @@ function feasiblePrescience(
for (const defense of field === 'defense' ? defenses.filter(candidate => inspectedPlanMatches(p, field, value, candidate)) : defenses) { for (const defense of field === 'defense' ? defenses.filter(candidate => inspectedPlanMatches(p, field, value, candidate)) : defenses) {
const maxSupport = const maxSupport =
g.advanced && field === 'dial' g.advanced && field === 'dial'
? Math.min(battleSupportBudget(g, p), (combatArmy(g, p.id, b.territory).normal + combatArmy(g, p.id, b.territory).elite)) ? Math.min(battleSupportBudget(g, p), maxCombatSupport(forces))
: 0; : 0;
for (let support = 0; support <= maxSupport; support++) { for (let support = 0; support <= maxSupport; support++) {
try { try {
@@ -19586,7 +19642,7 @@ function feasiblePrescience(
weapon, weapon,
defense, defense,
support, support,
dial: field === 'dial' ? value : 0, dial: field === 'dial' ? value : profile?.lead === p.id ? profile.fixedEcazDial : 0,
}); });
if (inspectedPlanMatches(p, field, value, plan[field])) return true; if (inspectedPlanMatches(p, field, value, plan[field])) return true;
} catch (error) { } catch (error) {
@@ -20001,6 +20057,9 @@ function traitorVoters(g: Game, b: Battle) {
const home = homeworldBattleLocation(g, b.territory); const home = homeworldBattleLocation(g, b.territory);
if (home) return [b.attacker, b.defender].filter((id) => id === home.native); if (home) return [b.attacker, b.defender].filter((id) => id === home.native);
const voters = [b.attacker, b.defender]; const voters = [b.attacker, b.defender];
if (b.ecazOccupy?.lead)
for (const owner of [b.ecazOccupy.ecaz, b.ecazOccupy.ally])
if (!voters.includes(owner)) voters.push(owner);
const harkonnen = byFaction(g, 'harkonnen'); const harkonnen = byFaction(g, 'harkonnen');
if ( if (
harkonnen?.ally && harkonnen?.ally &&
@@ -20011,6 +20070,7 @@ function traitorVoters(g: Game, b: Battle) {
return voters; return voters;
} }
function traitorBeneficiary(g: Game, b: Battle, id: string) { function traitorBeneficiary(g: Game, b: Battle, id: string) {
if (b.ecazOccupy?.lead && [b.ecazOccupy.ecaz, b.ecazOccupy.ally].includes(id)) return b.ecazOccupy.lead;
return [b.attacker, b.defender].includes(id) ? id : getPlayer(g, id).ally; return [b.attacker, b.defender].includes(id) ? id : getPlayer(g, id).ally;
} }
function pendingSmugglerBattle(g: Game): SmugglerBattleReceipt | null { function pendingSmugglerBattle(g: Game): SmugglerBattleReceipt | null {
@@ -20064,7 +20124,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
leader: controlledLeaders(g, p).find((l) => l.id === plan.leader), leader: controlledLeaders(g, p).find((l) => l.id === plan.leader),
leaderSkills: battleLeaderSkills(g, p), leaderSkills: battleLeaderSkills(g, p),
occupiedStrongholds: leaderSkillStrongholdCount(g, p), occupiedStrongholds: leaderSkillStrongholdCount(g, p),
forces: combatForces(g, p, b.territory, opponent), forces: planCombatForces(g, p, opponent),
...([cardOf(p, plan.weapon), cardOf(p, plan.defense)].some(isReinforcements) ...([cardOf(p, plan.weapon), cardOf(p, plan.defense)].some(isReinforcements)
? { reinforcementsReserves: { ? { reinforcementsReserves: {
normal: p.reserves - (p.elites?.reserves ?? 0), elite: p.elites?.reserves ?? 0, normal: p.reserves - (p.elites?.reserves ?? 0), elite: p.elites?.reserves ?? 0,
@@ -20086,6 +20146,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
try { try {
const quote = quoteBattleResolution({ const quote = quoteBattleResolution({
advanced: g.advanced, advanced: g.advanced,
...(b.ecazOccupy ? { ecazOccupy: ecazBattleProfile(g)! } : {}),
aggressor: battleAggressor(g), aggressor: battleAggressor(g),
typedCasualties: !!g.homeworlds, typedCasualties: !!g.homeworlds,
...(homeworldBattleLocation(g, b.territory) ? { homeworld: currentHomeworldBattleRules(g, b.territory)! } : {}), ...(homeworldBattleLocation(g, b.territory) ? { homeworld: currentHomeworldBattleRules(g, b.territory)! } : {}),
@@ -20142,11 +20203,13 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
for (const losses of quote.casualties!.options) quoteHomeworldLoss(g, quote.winner, b.territory, { normal: losses.normal, elite: losses.elite }); for (const losses of quote.casualties!.options) quoteHomeworldLoss(g, quote.winner, b.territory, { normal: losses.normal, elite: losses.elite });
else if (quote.winner && quote.result === 'normal') else if (quote.winner && quote.result === 'normal')
validateBattleForceLoss( validateBattleForceLoss(
getPlayer(g, quote.winner), getPlayer(g, quote.casualties?.owner ?? quote.winner),
b.territory, b.territory,
quote.basicWinnerLosses ?? quote.basicWinnerLosses ??
Math.max(...quote.casualties!.options.map((c) => c.normal + c.elite)), Math.max(...quote.casualties!.options.map((c) => c.normal + c.elite)),
); );
for (const loss of quote.fixedLosses ?? [])
validateBattleForceLoss(getPlayer(g, loss.owner), b.territory, loss.normal + loss.elite);
if (quote.homeworldExplosion) if (quote.homeworldExplosion)
for (const losses of quote.homeworldExplosion.options) quoteHomeworldLoss(g, quote.homeworldExplosion.player, b.territory, losses); for (const losses of quote.homeworldExplosion.options) quoteHomeworldLoss(g, quote.homeworldExplosion.player, b.territory, losses);
return { ...quote, smuggler }; return { ...quote, smuggler };
@@ -20370,16 +20433,14 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
if (ac && dc) { if (ac && dc) {
dead(al); dead(al);
dead(dl); dead(dl);
killTerritory(g, a, b.territory, Infinity, true); for (const owner of quote.destroyedArmies) killTerritory(g, getPlayer(g, owner), b.territory, Infinity, true);
killTerritory(g, d, b.territory, Infinity, true);
observeOccupation(g); observeOccupation(g);
log(g, 'Both leaders were traitors. Both armies were destroyed.'); log(g, 'Both leaders were traitors. Both armies were destroyed.');
} else if (ac || dc) { } else if (ac || dc) {
const loser = ac ? d : a;
const l = ac ? dl : al; const l = ac ? dl : al;
dead(l); dead(l);
winner!.spice += quote.bounty!.amount; winner!.spice += quote.bounty!.amount;
killTerritory(g, loser, b.territory, Infinity, true); for (const owner of quote.destroyedArmies) killTerritory(g, getPlayer(g, owner), b.territory, Infinity, true);
observeOccupation(g); observeOccupation(g);
log( log(
g, g,
@@ -20452,7 +20513,7 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
{ faction: loser.faction, name: 'Diplomat retreat' }); { faction: loser.faction, name: 'Diplomat retreat' });
observeOccupation(g); observeOccupation(g);
} }
killTerritory(g, loser, b.territory, Infinity, true); for (const owner of quote.destroyedArmies) killTerritory(g, getPlayer(g, owner), b.territory, Infinity, true);
observeOccupation(g); observeOccupation(g);
if (quote.basicWinnerLosses !== null) if (quote.basicWinnerLosses !== null)
killTerritory(g, winner!, b.territory, quote.basicWinnerLosses, true); killTerritory(g, winner!, b.territory, quote.basicWinnerLosses, true);
@@ -20466,6 +20527,12 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
? { faction: (isStoneBurner(aw) ? a : d).faction, name: 'Stone Burner' } ? { faction: (isStoneBurner(aw) ? a : d).faction, name: 'Stone Burner' }
: { faction: winner!.faction, name: 'Battle' }, : { faction: winner!.faction, name: 'Battle' },
); );
for (const loss of quote.fixedLosses ?? []) {
const owner = getPlayer(g, loss.owner);
takeBattleLosses(g, owner, b.territory, { normal: loss.normal, elite: loss.elite, paidNormal: 0, paidElite: 0 });
log(g, `${owner.name} sent ${loss.normal + loss.elite} mandatory Occupy forces to the Tanks.`, { faction: 'ecaz', name: 'Occupy casualties' });
}
if (quote.fixedLosses?.length) observeOccupation(g);
} }
if (g.spiceBankerIncomePreview) for (const payment of quote.payments) { if (g.spiceBankerIncomePreview) for (const payment of quote.payments) {
const player = getPlayer(g, payment.player); const player = getPlayer(g, payment.player);
@@ -20734,13 +20801,14 @@ function continueResolvedBattle(
return; return;
} }
if (winner && losses?.length === 1) { if (winner && losses?.length === 1) {
settleWinnerCasualties(g, winner, to, cards, losses[0], true); settleWinnerCasualties(g, winner, to, cards, losses[0], true, continuation.casualties?.owner);
} else if (winner && losses) } else if (winner && losses)
g.decision = { g.decision = {
kind: 'battleLosses', kind: 'battleLosses',
player: winner.id, player: winner.id,
territory: to, territory: to,
options: losses, options: losses,
...(continuation.casualties?.owner ? { forceOwner: continuation.casualties.owner } : {}),
cards, cards,
}; };
else if (winner) finishWinner(g, winner, to, cards); else if (winner) finishWinner(g, winner, to, cards);
@@ -20753,6 +20821,7 @@ function settleWinnerCasualties(
cards: string[], cards: string[],
choice: Casualties, choice: Casualties,
automatic = false, automatic = false,
forceOwner?: string,
) { ) {
if (g.pendingSukRescue) { if (g.pendingSukRescue) {
const pending = g.pendingSukRescue; const pending = g.pendingSukRescue;
@@ -20773,7 +20842,8 @@ function settleWinnerCasualties(
if (sardaukar?.casualties) requireRule(sardaukar.casualties.outcome === 'pending' && if (sardaukar?.casualties) requireRule(sardaukar.casualties.outcome === 'pending' &&
sardaukar.casualties.options.some(option => JSON.stringify(option) === JSON.stringify(choice)), sardaukar.casualties.options.some(option => JSON.stringify(option) === JSON.stringify(choice)),
'Choose an original Nexus Sardaukar casualty allocation.'); 'Choose an original Nexus Sardaukar casualty allocation.');
const losses = takeBattleLosses(g, p, to, choice); const physicalOwner = forceOwner ? getPlayer(g, forceOwner) : p;
const losses = takeBattleLosses(g, physicalOwner, to, choice);
if (sardaukar?.casualties) { if (sardaukar?.casualties) {
sardaukar.casualties.outcome = 'complete'; sardaukar.casualties.outcome = 'complete';
sardaukar.signature = nexusSardaukarSignature(sardaukar); sardaukar.signature = nexusSardaukarSignature(sardaukar);
@@ -20782,7 +20852,7 @@ function settleWinnerCasualties(
if (to.startsWith('homeworld:')) g.homeworldBattleLoss = null; if (to.startsWith('homeworld:')) g.homeworldBattleLoss = null;
log( log(
g, g,
`${p.name} sent ${choice.normal} normal and ${choice.elite} elite forces from ${combatLocationName(g, to)} to the Tanks. ${automatic ? 'This was the only legal casualty allocation for the revealed battle plan, so it was applied automatically.' : 'This applies the selected casualty allocation for the revealed battle plan.'}`, `${physicalOwner.name} sent ${choice.normal} normal and ${choice.elite} elite forces from ${combatLocationName(g, to)} to the Tanks. ${automatic ? 'This was the only legal casualty allocation for the revealed battle plan, so it was applied automatically.' : 'This applies the selected casualty allocation for the revealed battle plan.'}`,
automatic ? { faction: p.faction, name: 'Battle casualties' } : undefined, automatic ? { faction: p.faction, name: 'Battle casualties' } : undefined,
); );
stageIxSubstitution(g, p, to, cards, losses); stageIxSubstitution(g, p, to, cards, losses);
@@ -22235,6 +22305,17 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
: null; : null;
if (!canceled && bureaucratDiversion === undefined && offerBureaucratPayment(g,response.bureaucratPayment,{kind:'response'})) return; if (!canceled && bureaucratDiversion === undefined && offerBureaucratPayment(g,response.bureaucratPayment,{kind:'response'})) return;
g.response = null; g.response = null;
if (response.kind === 'ecazOccupy') {
const b = g.battle, occupy = b?.ecazOccupy;
requireRule(b && occupy?.lead && occupy.ecaz === response.owner && !b.revealed &&
!Object.keys(b.plans).length, 'Resolve Occupy before native powers and plans.');
occupy.canceled = canceled;
ecazBattleProfile(g);
createMentatQuestion(g);
beginStrongholdBattle(g);
log(g, canceled ? 'Karama canceled the combined dial: the selected lead now dials only their own forces; the other allied army contributes zero.' : 'Ecaz Occupy proceeds with its mandatory free contribution and the ally’s physical force pool.', { faction: 'ecaz', name: 'Occupy' });
return;
}
if (response.kind === 'guildRate') { if (response.kind === 'guildRate') {
if (g.pendingGuildTransport) finishGuildTransportRateResponse(g, response, canceled); if (g.pendingGuildTransport) finishGuildTransportRateResponse(g, response, canceled);
else finishGuildRateResponse(g, response, canceled); else finishGuildRateResponse(g, response, canceled);
@@ -26802,6 +26883,25 @@ function applyActionInner(
} }
return g; return g;
} }
if (g.decision?.kind === 'ecazBattleLead') {
const decision = g.decision, b = g.battle!, occupy = b.ecazOccupy!;
requireRule(t === 'decision' && id === decision.player && action.event === decision.event &&
typeof action.lead === 'string' && decision.choices.includes(action.lead) &&
Object.keys(action).every(key => ['type', 'event', 'lead'].includes(key)),
'Choose Ecaz or its current ally to lead this combined battle.');
const previous = b.attacker === occupy.ecaz || b.attacker === occupy.ally ? b.attacker : b.defender;
if (b.attacker === previous) b.attacker = action.lead;
else b.defender = action.lead;
occupy.lead = action.lead;
// The selected lead supplies normal aggressor priority, not the scheduling ally.
if (g.order.indexOf(b.attacker) > g.order.indexOf(b.defender))
[b.attacker, b.defender] = [b.defender, b.attacker];
ecazBattleProfile(g);
g.decision = null;
g.response = { kind: 'ecazOccupy', owner: occupy.ecaz, passed: [] };
log(g, `${getPlayer(g, action.lead).name} leads the combined Ecaz battle, using their own leaders, cards and spice.`, { faction: 'ecaz', name: 'Occupy' });
return g;
}
if ( if (
t === 'offerRicheseNoField' && t === 'offerRicheseNoField' &&
g.decision?.kind === 'richeseAllyOpportunity' && g.decision?.kind === 'richeseAllyOpportunity' &&
@@ -27834,7 +27934,7 @@ function applyActionInner(
'Casualty choice', 'Casualty choice',
) )
]; ];
settleWinnerCasualties(g, p, decision.territory, decision.cards, choice); settleWinnerCasualties(g, p, decision.territory, decision.cards, choice, false, decision.forceOwner);
} else if (decision.kind === 'auctionPayment') { } else if (decision.kind === 'auctionPayment') {
normalKaramaAuction(g, p, true, action.karama === true); normalKaramaAuction(g, p, true, action.karama === true);
requireRule( requireRule(
@@ -29834,8 +29934,24 @@ function applyActionInner(
revealed: false, revealed: false,
traitorCalls: {}, traitorCalls: {},
}; };
createMentatQuestion(g); const ecaz = byFaction(g, 'ecaz');
beginStrongholdBattle(g); const ally = ecaz?.ally ? getPlayer(g, ecaz.ally) : null;
const connected = ecaz && ally && Object.entries(ecaz.forces).some(([x, n]) =>
n > 0 && splitLocation(x).territory === choice.territory && Object.entries(ally.forces).some(([y, m]) =>
m > 0 && splitLocation(y).territory === choice.territory &&
gameDistance(g, x, y, key => splitLocation(key).sector === g.storm) === 0));
if (g.advanced && ecaz && ally?.ally === ecaz.id && connected &&
!isAdvisor(ally, choice.territory) && [choice.attacker, choice.defender].some(seat => seat === ecaz.id || seat === ally.id)) {
requireRule(!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.strongholdCards &&
!g.techTokens && !g.discoveryEnabled && !g.ecazTreachery &&
g.players.every(player => faction(player.faction).expansion === 'base' || ['ecaz', 'moritani'].includes(player.faction)),
'Advanced combined Occupy battles support native Ecaz/classic/Moritani with base cards; optional modules and exotic faction battle composition require their source ruling.');
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);
}
log( log(
g, g,
`${p.name} chose the battle between ${getPlayer(g, choice.attacker).name} and ${getPlayer(g, choice.defender).name} in ${combatLocationName(g, choice.territory)}. ${getPlayer(g, choice.attacker).name} remains the aggressor.`, `${p.name} chose the battle between ${getPlayer(g, choice.attacker).name} and ${getPlayer(g, choice.defender).name} in ${combatLocationName(g, choice.territory)}. ${getPlayer(g, choice.attacker).name} remains the aggressor.`,
@@ -30026,7 +30142,7 @@ function applyActionInner(
'Traitor decision is not available.', 'Traitor decision is not available.',
); );
requireRule(typeof action.call === 'boolean', 'Choose reveal or decline.'); requireRule(typeof action.call === 'boolean', 'Choose reveal or decline.');
if (g.nexusCards?.cards) ensureTraitorDeclarations(g); if (g.nexusCards?.cards || b.ecazOccupy) ensureTraitorDeclarations(g);
if (action.call) { if (action.call) {
const other = const other =
b.attacker === traitorBeneficiary(g, b, id) ? b.defender : b.attacker; b.attacker === traitorBeneficiary(g, b, id) ? b.defender : b.attacker;
@@ -30054,7 +30170,8 @@ function applyActionInner(
if ( if (
action.call && action.call &&
p.faction === 'harkonnen' && p.faction === 'harkonnen' &&
![b.attacker, b.defender].includes(id) ![b.attacker, b.defender].includes(id) &&
!(b.ecazOccupy && [b.ecazOccupy.ecaz, b.ecazOccupy.ally].includes(id))
) { ) {
g.response = { kind: 'harkonnenTraitor', owner: id, passed: [] }; g.response = { kind: 'harkonnenTraitor', owner: id, passed: [] };
return g; return g;
@@ -30597,6 +30714,7 @@ export function viewGame(state: Game, id: string) {
: null, : null,
techTokens: g.techTokens ?? null, techTokens: g.techTokens ?? null,
ecazLoyalty: g.ecazLoyalty?.card ? { player: g.ecazLoyalty.player, card: g.ecazLoyalty.card } : null, ecazLoyalty: g.ecazLoyalty?.card ? { player: g.ecazLoyalty.player, card: g.ecazLoyalty.card } : null,
ecazOccupyPreview: !!g.ecazOccupyPreview,
strongholdCards: g.strongholdCards ?? null, strongholdCards: g.strongholdCards ?? null,
discoveries: discoveryChoices(g,id,g.phase === 7 && grummanCollectionAutomatic(g)), discoveries: discoveryChoices(g,id,g.phase === 7 && grummanCollectionAutomatic(g)),
ecologicalStorm: g.decision?.kind === 'ecologicalStorm' && g.decision.player === id && g.ecologicalStorm?.stage === 'choose' ecologicalStorm: g.decision?.kind === 'ecologicalStorm' && g.decision.player === id && g.ecologicalStorm?.stage === 'choose'
@@ -31252,11 +31370,12 @@ export function viewGame(state: Game, id: string) {
battle: b battle: b
? { ? {
locationName: combatLocationName(g, b.territory), locationName: combatLocationName(g, b.territory),
ecazOccupy: b.ecazOccupy ? { ...b.ecazOccupy, profile: ecazBattleProfile(g) } : null,
native: homeworldBattleLocation(g, b.territory)?.native ?? null, native: homeworldBattleLocation(g, b.territory)?.native ?? null,
nativeBattleStrength: homeworldBattleLocation(g, b.territory)?.nativeBattleStrength ?? 0, nativeBattleStrength: homeworldBattleLocation(g, b.territory)?.nativeBattleStrength ?? 0,
opponentForces: [b.attacker, b.defender].includes(id) && opponentForces: [b.attacker, b.defender].includes(id) &&
!getPlayer(g, b.attacker === id ? b.defender : b.attacker).noField?.deployed !getPlayer(g, b.attacker === id ? b.defender : b.attacker).noField?.deployed
? combatForces(g, getPlayer(g, b.attacker === id ? b.defender : b.attacker), b.territory, me) ? planCombatForces(g, getPlayer(g, b.attacker === id ? b.defender : b.attacker), me)
: null, : null,
strongholdCopy: b.strongholdCopy ?? null, strongholdCopy: b.strongholdCopy ?? null,
strongholdEffects: Object.fromEntries( strongholdEffects: Object.fromEntries(
@@ -31268,12 +31387,7 @@ export function viewGame(state: Game, id: string) {
tieWinner: battleTieWinner(g), tieWinner: battleTieWinner(g),
noFieldPlayers: b.noFieldPlayers ?? [], noFieldPlayers: b.noFieldPlayers ?? [],
ownForces: [b.attacker, b.defender].includes(id) ownForces: [b.attacker, b.defender].includes(id)
? combatForces( ? planCombatForces(g, me, getPlayer(g, b.attacker === id ? b.defender : b.attacker))
g,
me,
b.territory,
getPlayer(g, b.attacker === id ? b.defender : b.attacker),
)
: null, : null,
truthPromises: b.truthPromises ?? [], truthPromises: b.truthPromises ?? [],
compliantPlan: compliantPlan:
+4 -2
View File
@@ -169,12 +169,14 @@ export const FACTION_RULES: Record<
'Your Ecaz Ambassador can acquire an available Duke Vidal or offer an alliance when both participants are unallied. Duke availability, loans and custody use his separate rules.', 'Your Ecaz Ambassador can acquire an available Duke Vidal or offer an alliance when both participants are unallied. Duke availability, loans and custody use his separate rules.',
'You and your ally may share territories. Divide shared desert spice by agreement; without agreement, divide as equally as possible and give the odd spice to your ally. Both factions occupying each of three qualifying strongholds provides an allied victory route.', 'You and your ally may share territories. Divide shared desert spice by agreement; without agreement, divide as equally as possible and give the odd spice to your ally. Both factions occupying each of three qualifying strongholds provides an allied victory route.',
'When you are allied with Fremen, sharing Sietch Tabr does not itself prevent the Fremen special victory. Its other conditions still apply.', 'When you are allied with Fremen, sharing Sietch Tabr does not itself prevent the Fremen special victory. Its other conditions still apply.',
'Combined Occupy combat, Tleilaxu Ambassador revival, Duke loans and revival, and competing arrival reactions remain unfinished. The connected Sietch Tabr exception does not establish every special-victory combination.', 'Basic combined Occupy combat, Tleilaxu Ambassador revival, Duke loans and exceptional custody, and remaining competing arrival reactions are unfinished. The connected Sietch Tabr exception does not establish every special-victory combination.',
], ],
advanced: [ advanced: [
'At Spice Collection, both allies receive the normal bank income from a jointly occupied Arrakeen, Carthag or Tuek’s Sietch. Canceling Ecaz’s Collection benefit leaves the ally’s income intact.', 'At Spice Collection, both allies receive the normal bank income from a jointly occupied Arrakeen, Carthag or Tuek’s Sietch. Canceling Ecaz’s Collection benefit leaves the ally’s income intact.',
'Loyalty: before initial Traitor dealing, randomly set aside one of your five native Traitor Cards face up for everyone. It stays outside the deck for the whole game, including later Traitor and Face Dancer draws. Karama cannot prevent this automatic power; the corresponding leader disc is unchanged.', 'Loyalty: before initial Traitor dealing, randomly set aside one of your five native Traitor Cards face up for everyone. It stays outside the deck for the whole game, including later Traitor and Face Dancer draws. Karama cannot prevent this automatic power; the corresponding leader disc is unchanged.',
'Loyalty and Collection have connected development implementations. Remaining Advanced powers and combined interactions are still being completed; this sheet is not a complete faction certification.', 'In the bounded Advanced Occupy preview, choose Ecaz or your ally as lead. The lead uses its own leaders, cards and spice; total dial includes the ceiling of half your fighters at free full strength plus chosen allied army strength. An ordinary win leaves the floor of half your fighters and settles allied dial losses separately. Every participating whole army must be storm-clear and connected.',
'Karama prevention keeps the selected lead but switches to that lead’s own forces; the other ally contributes zero. These numbers use the authorized revised Advanced sheet, not a Basic ruling; only Ecaz may revive Duke, including by Ghola.',
'Loyalty, Collection and bounded Advanced Occupy have connected development implementations. Remaining powers and combined interactions are still being completed; this sheet is not a complete faction certification.',
], ],
}, },
moritani: { moritani: {
+1
View File
@@ -36,6 +36,7 @@ type SourceSelector = (g: Game, response: ResponseWindow) => unknown;
* this with all pending records: independent interruptions may create those. */ * this with all pending records: independent interruptions may create those. */
const cancellationSources = { const cancellationSources = {
ecazCollection: (g) => ({ pending: g.ecazCollection ?? null }), ecazCollection: (g) => ({ pending: g.ecazCollection ?? null }),
ecazOccupy: (g) => ({ battle: battleSource(g), occupy: g.battle?.ecazOccupy ?? null }),
moritaniPlacement: (g) => ({ moritaniPlacement: (g) => ({
pending: g.pendingMoritaniPlacement ?? null, pending: g.pendingMoritaniPlacement ?? null,
terror: g.moritaniTerror ?? null, terror: g.moritaniTerror ?? null,
+1 -1
View File
@@ -92,7 +92,7 @@ export function quoteAdvisorBattleOffer(g: AdvisorContext) {
}); });
return candidate.battles.some( return candidate.battles.some(
(b) => (b) =>
b.territory === t && (b.attacker === bg.id || b.defender === bg.id), b.territory === t && (b.attacker === bg.id || b.defender === bg.id || b.coalition?.includes(bg.id)),
); );
}); });
return { released: board.released, owner: bg.id, territories }; return { released: board.released, owner: bg.id, territories };
+24 -1
View File
@@ -1251,6 +1251,29 @@ export const RULE_TOPICS: RuleTopic[] = [
], ],
}), }),
), ),
{
id: 'ecaz-occupy',
title: 'Advanced Ecaz Occupy: combined-army battles',
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.',
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.',
'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.',
'These numbers come from the user-authorized revised Advanced PDF, physical pages 32 and 19, not a newly located publisher erratum. Basic is unchanged. The specific Ecaz-only Duke revival/Ghola decision remains. Fresh local ecaz-occupy uses Ecaz/classic/optional Moritani two-to-six-seat Advanced, base33 ordinary cards and no other overlays; full Ecaz and public starts remain 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: 'Human390px original CLI rooms exercised Ecaz lead, free Fremen/Fedaykin total9 with minimum3, real printed Karama prevention, and canceled lead-own dial1/support1 with minimum0. Both battles reached native Collection with the expected physical survivors.', evidence: ['components/ecaz-occupy.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 for the bounded source: actual seven-case native program, before/after whole-army storm proof, typed/odd/even/traitor/outcome and all-policy cases, and two human390px native battles. Five unstaged2..6 games complete with2123 accepted actions/no rejection; those samples contain no combined lead choice, so they prove profile continuation, not that rare rule path. Full Ecaz and optional compositions remain open; comprehensive assurance follows all rules.', evidence: ['tests/ecaz-occupy-battle.test.ts', 'tests/ecaz-occupy-options.test.ts', 'tests/ecaz-occupy-runtime.test.ts', 'tests/board-resolution-quote.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
],
related: ['faction-ecaz', 'advanced-ecaz', 'ecaz-ambassadors', 'battle', 'victory', 'implementation-checklist'],
},
{ {
id: 'ecaz-loyalty', id: 'ecaz-loyalty',
title: 'Ecaz Loyalty: public Traitor Card', title: 'Ecaz Loyalty: public Traitor Card',
@@ -1298,7 +1321,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The Guild Ambassador grants an immediate free shipment of zero through four physical reserve forces for Ecaz or its ally, including typed elites. Choose a clear territory and sector; this does not spend ordinary shipment or movement, ally aid or a retained rate card. Fremen may use destinations outside their ordinary reinforcement radius, but their southern reserves stay on-planet and this effect never permits storm entry. Only Ixians may ship directly into their placed mobile stronghold.', 'The Guild Ambassador grants an immediate free shipment of zero through four physical reserve forces for Ecaz or its ally, including typed elites. Choose a clear territory and sector; this does not spend ordinary shipment or movement, ally aid or a retained rate card. Fremen may use destinations outside their ordinary reinforcement radius, but their southern reserves stay on-planet and this effect never permits storm entry. Only Ixians may ship directly into their placed mobile stronghold.',
'During Shipment and Movement, Guild can use its special Karama to stop another eligible off-planet Ambassador shipment before forces arrive; it does not stop this grant during a phase-one worm sequence. A successful off-planet shipment may invite Bene Gesserit accompaniment and accrues applicable phase-five Heighliner income once. BG can accompany Ixians into the mobile stronghold. Projected accompaniment choices show blocked sectors and keep Polar Sink and decline available when legal. Intrusion, separate Terror and accompanying fighter-triggered Ambassadors finish before the original entrant or worm rider resumes.', 'During Shipment and Movement, Guild can use its special Karama to stop another eligible off-planet Ambassador shipment before forces arrive; it does not stop this grant during a phase-one worm sequence. A successful off-planet shipment may invite Bene Gesserit accompaniment and accrues applicable phase-five Heighliner income once. BG can accompany Ixians into the mobile stronghold. Projected accompaniment choices show blocked sectors and keep Polar Sink and decline available when legal. Intrusion, separate Terror and accompanying fighter-triggered Ambassadors finish before the original entrant or worm rider resumes.',
'No-Field substitution into the free four-force grant remains unavailable. In bounded classic plus Ecaz/Moritani games, a shared Ambassador/Terror entry gives both owners saved optional prompts in current storm order without repeating the committed entry; this is a provisional table order, not a printed publisher priority. Competing Intrusion and Ambassador-created Fremen/Guild child arrivals remain gated. Atomics Aftermath, Homeworlds, Nexus and Discovery combinations do not gain coverage from this first overlap path.', 'No-Field substitution into the free four-force grant remains unavailable. In bounded classic plus Ecaz/Moritani games, a shared Ambassador/Terror entry gives both owners saved optional prompts in current storm order without repeating the committed entry; this is a provisional table order, not a printed publisher priority. Competing Intrusion and Ambassador-created Fremen/Guild child arrivals remain gated. Atomics Aftermath, Homeworlds, Nexus and Discovery combinations do not gain coverage from this first overlap path.',
'All eleven effect guides are readable. Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are integrated, together with Bene Gesserit copies of those eight effects and direct Duke acquisition or consensual alliance through the reusable Ecaz token. The remaining effects, combined Occupy combat, exceptional Duke custody and complete Ecaz rules remain unfinished. Mobile stronghold placement is gated. Full Ecaz starts remain disabled.', 'All eleven effect guides are readable. Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are integrated, together with Bene Gesserit copies of those eight effects and direct Duke acquisition or consensual alliance through the reusable Ecaz token. Advanced combined Occupy has a separate bounded Development Verified guide; Basic Occupy, remaining effects, exceptional Duke custody and complete Ecaz rules remain unfinished. Mobile stronghold placement is gated. Full Ecaz starts remain disabled.',
], ],
checklist: [ checklist: [
{ {
+2
View File
@@ -4,6 +4,7 @@ import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
// Exhaustive typed discriminants for the existing engine control unions. These // Exhaustive typed discriminants for the existing engine control unions. These
// are envelope checks, not a duplicate implementation of each future choice. // are envelope checks, not a duplicate implementation of each future choice.
const DECISIONS = { const DECISIONS = {
ecazBattleLead: true,
leaderSkillVisibility: true, leaderSkillVisibility: true,
leaderSkillRevival: true, leaderSkillRevival: true,
moritaniAssassinate: true, moritaniAssassinate: true,
@@ -89,6 +90,7 @@ const RESPONSES = {
moritaniPlacement: true, moritaniPlacement: true,
ecazPlacement: true, ecazPlacement: true,
ecazCollection: true, ecazCollection: true,
ecazOccupy: true,
moritaniAlliance: true, moritaniAlliance: true,
moritaniDuke: true, moritaniDuke: true,
moritaniRetention: true, moritaniRetention: true,
+19 -2
View File
@@ -1,7 +1,9 @@
import { FACTIONS, type FactionId } from './catalog'; import { FACTIONS, type FactionId } from './catalog';
import { CHEAP_HERO_TRAITOR } from './traitors'; import { CHEAP_HERO_TRAITOR } from './traitors';
import { quoteEcazOccupyBattle, type EcazOccupyBattleProfile } from './ecaz-occupy-battle';
export type TraitorDeclarationContext = { export type TraitorDeclarationContext = {
ecazOccupy?: EcazOccupyBattleProfile;
event: string; event: string;
attacker: string; attacker: string;
defender: string; defender: string;
@@ -73,17 +75,32 @@ function validateContext(context: TraitorDeclarationContext): void {
'A traitor declaration requires both sealed battle plans.', 'A traitor declaration requires both sealed battle plans.',
); );
} }
if (context.ecazOccupy) {
const occupy = context.ecazOccupy;
const ecaz = context.players.find(player => player.id === occupy.ecaz);
const ally = context.players.find(player => player.id === occupy.ally);
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({
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 },
lead: occupy.lead, canceled: occupy.canceled,
});
}
} }
function declaredElements(context: TraitorDeclarationContext, voter: string) { function declaredElements(context: TraitorDeclarationContext, voter: string) {
const owner = context.players.find((player) => player.id === voter); const owner = context.players.find((player) => player.id === voter);
requireDeclaration(owner, 'The traitor declaration voter is not seated.'); requireDeclaration(owner, 'The traitor declaration voter is not seated.');
const combatants = [context.attacker, context.defender]; const combatants = [context.attacker, context.defender];
const beneficiary = combatants.includes(voter) ? voter : owner.ally; const occupy = context.ecazOccupy;
const coSide = !!occupy && [occupy.ecaz, occupy.ally].includes(voter);
const beneficiary = coSide ? occupy.lead : combatants.includes(voter) ? voter : owner.ally;
requireDeclaration( requireDeclaration(
beneficiary && beneficiary &&
combatants.includes(beneficiary) && combatants.includes(beneficiary) &&
(beneficiary === voter || owner.faction === 'harkonnen'), (beneficiary === voter || owner.faction === 'harkonnen' || coSide),
'Only a combatant or its Harkonnen ally can declare this traitor.', 'Only a combatant or its Harkonnen ally can declare this traitor.',
); );
const target = const target =
+29
View File
@@ -6,6 +6,7 @@ import {
quoteBattleBoardContinuation, quoteBattleBoardContinuation,
quoteBattlePhaseAdvance, quoteBattlePhaseAdvance,
BoardResolutionError, BoardResolutionError,
type BoardContext,
} from '../game/board-resolution-quote'; } from '../game/board-resolution-quote';
import { import {
createGame, createGame,
@@ -93,6 +94,34 @@ void test('battle order excludes allies, polar sink and storm-separated forces,
g.players[1].ally = 'a'; g.players[1].ally = 'a';
assert.deepEqual(quoteBattleBoard(g).battles, []); assert.deepEqual(quoteBattleBoard(g).battles, []);
}); });
void test('Advanced whole Ecaz armies cannot recruit storm-isolated allies or split into component battles; Basic is unchanged', () => {
const ecaz = newPlayer('ec', 'Ecaz', 'ecaz');
const ally = newPlayer('al', 'Guild', 'guild');
const enemy = newPlayer('en', 'Emperor', 'emperor');
ecaz.ally = ally.id;
ally.ally = ecaz.id;
ecaz.forces = { 'imperial_basin:9': 1, 'imperial_basin:11': 1 };
ally.forces = { 'imperial_basin:11': 2 };
const context: BoardContext = {
advanced: true, storm: 10, order: ['ec', 'al', 'en'], players: [ecaz, ally, enemy],
};
for (const sector of [9, 11]) {
enemy.forces = { [`imperial_basin:${sector}`]: 2 };
assert.deepEqual(quoteBattleBoard(context).battles, [],
'All coexisting fighters must be clear and connected before the joint battle is offered.');
}
enemy.forces = { 'imperial_basin:9': 2 };
assert.deepEqual(quoteBattleBoard({ ...context, advanced: false }).battles,
[{ territory: 'imperial_basin', attacker: 'ec', defender: 'en' }]);
ecaz.forces = { 'imperial_basin:9': 2 };
ally.forces = { 'imperial_basin:9': 2 };
const clear = quoteBattleBoard(context).battles;
assert.equal(clear.length, 1, 'A clear joint army is one battle, not two faction battles.');
assert.ok(clear[0].coalition?.includes(ecaz.id) && clear[0].coalition.includes(ally.id),
'A represented co-side must retain membership for voluntary advisor conversion.');
assert.deepEqual(quoteBattleBoard({ ...context, storm: 9 }).battles, [],
'A stronghold-safe army in storm still cannot battle.');
});
void test('the moving stronghold uses its pointer sector for storm obstruction and the declared player order', () => { void test('the moving stronghold uses its pointer sector for storm obstruction and the declared player order', () => {
const g = fixture(); const g = fixture();
g.mobileStronghold = { location: 'red_chasm:7' }; g.mobileStronghold = { location: 'red_chasm:7' };
+237
View File
@@ -0,0 +1,237 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { CombatForces } from '../game/combat';
import {
EcazOccupyBattleError,
ecazOccupyDialOptions,
quoteEcazOccupyBattle,
quoteEcazOccupyDial,
quoteEcazOccupyOutcome,
type EcazOccupyBattleInput,
} from '../game/ecaz-occupy-battle';
const ordinary = (normal: number): CombatForces => ({
normal, elite: 0, eliteStrength: 2, freeSupport: false,
});
function battle(
ecazCount: number,
lead = 'ecaz-seat',
canceled = false,
allyForces = ordinary(4),
): EcazOccupyBattleInput {
return {
battleOrderActor: 'enemy-seat',
ecaz: { id: 'ecaz-seat', faction: 'ecaz', ally: 'ally-seat', forces: ordinary(ecazCount) },
ally: { id: 'ally-seat', faction: 'atreides', ally: 'ecaz-seat', forces: allyForces },
lead,
canceled,
};
}
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 dial = quoteEcazOccupyDial(profile, fixed + 1.5, 1);
assert.equal(dial.variableDial, 1.5);
assert.equal(dial.payer, lead);
assert.equal(dial.forceOwner, 'ally-seat');
const win = quoteEcazOccupyOutcome(profile, {
result: 'normal', won: true, dial: fixed + 1.5, support: 1,
});
assert.deepEqual(win.fixedLosses, [{ owner: 'ecaz-seat', normal: fixed, elite: 0 }]);
assert.equal(ecazCount - win.fixedLosses[0].normal, survivors);
assert.equal(win.casualties?.owner, 'ally-seat');
assert.deepEqual(win.casualties?.options, [
{ normal: 2, elite: 0, paidNormal: 1, paidElite: 0 },
]);
assert.equal(4 - win.casualties!.options[0].normal, 2);
assert.deepEqual(win.destroyedArmies, []);
}
}
});
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 loss = quoteEcazOccupyOutcome(profile, {
result: 'normal', won: false, dial: Math.ceil(ecazCount / 2), support: 0,
});
assert.deepEqual(loss, {
destroyedArmies: ['ecaz-seat', 'ally-seat'], fixedLosses: [], casualties: null,
});
}
}
});
void test('Ecaz cannot be dialed below its mandatory increment or charged extra support for it', () => {
const profile = quoteEcazOccupyBattle(battle(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, [
{ normal: 0, elite: 0, paidNormal: 0, paidElite: 0 },
]);
assert.throws(() => quoteEcazOccupyDial(profile, 7.5, 4), /fighter pool/);
assert.throws(() => quoteEcazOccupyDial(profile, 3.25, 0), /fixed Ecaz/);
assert.throws(() => quoteEcazOccupyDial(profile, 4, -1), /native ally-seat support/);
});
void test('Fremen and Fedaykin remain free and physically Fremen even when Ecaz supplies the plan', () => {
const input = battle(3, 'ecaz-seat', false, {
normal: 2, elite: 2, eliteStrength: 2, freeSupport: true,
});
const profile = quoteEcazOccupyBattle({ ...input, ally: { ...input.ally, faction: 'fremen' } });
assert.equal(profile.maxDial, 8);
const quote = quoteEcazOccupyDial(profile, 6, 0);
assert.deepEqual(quote.options, [
{ normal: 2, elite: 1, paidNormal: 0, paidElite: 0 },
{ normal: 0, elite: 2, paidNormal: 0, paidElite: 0 },
]);
assert.equal(quote.forceOwner, 'ally-seat');
assert.equal(quote.payer, 'ecaz-seat');
assert.throws(() => quoteEcazOccupyDial(profile, 6, 1), /fixed Ecaz contribution is free/);
assert.throws(() => quoteEcazOccupyDial(profile, 5.5, 0), /legal physical commitment/);
assert.deepEqual(ecazOccupyDialOptions(profile, 0), [
{ dial: 2, support: 0, variableDial: 0 },
{ dial: 3, support: 0, variableDial: 1 },
{ dial: 4, support: 0, variableDial: 2 },
{ dial: 5, support: 0, variableDial: 3 },
{ dial: 6, support: 0, variableDial: 4 },
{ dial: 7, support: 0, variableDial: 5 },
{ dial: 8, support: 0, variableDial: 6 },
]);
});
void test('Sardaukar keep typed supported casualties and their native opponent-dependent strength', () => {
const input = battle(3, 'ecaz-seat', false, {
normal: 3, elite: 2, eliteStrength: 2, freeSupport: false,
});
const emperor = { ...input.ally, faction: 'emperor' as const };
const profile = quoteEcazOccupyBattle({ ...input, ally: emperor });
const win = quoteEcazOccupyOutcome(profile, {
result: 'normal', won: true, dial: 5, support: 1,
});
assert.equal(win.casualties?.owner, 'ally-seat');
const allocations = win.casualties!.options;
for (const [normal, elite, paidNormal, paidElite] of [
[2, 1, 0, 1], [3, 1, 1, 0], [1, 2, 1, 0],
]) {
assert.ok(allocations.some(option => option.normal === normal && option.elite === elite &&
option.paidNormal === paidNormal && option.paidElite === paidElite),
'Each distinct printed-strength casualty allocation must remain available to the winner.');
}
for (const option of allocations) {
assert.equal(option.paidNormal + option.paidElite, 1);
assert.equal((option.normal + option.paidNormal) / 2 + option.elite + option.paidElite, 3,
'Casualties consume only the three variable strength, not the fixed Ecaz two.');
}
const versusFremen = quoteEcazOccupyBattle({
...input, ally: { ...emperor, forces: { ...emperor.forces, eliteStrength: 1 } },
});
assert.throws(() => quoteEcazOccupyDial(versusFremen, 8, 5), /fighter pool/);
assert.deepEqual(quoteEcazOccupyDial(versusFremen, 3, 1).options, [
{ normal: 1, elite: 0, paidNormal: 1, paidElite: 0 },
{ normal: 0, elite: 1, paidNormal: 0, paidElite: 1 },
]);
});
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 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' } });
const support = lead === 'ecaz-seat' ? 1 : 0;
const win = quoteEcazOccupyOutcome(canceled, {
result: 'normal', won: true, dial: 1, support,
});
assert.equal(canceled.lead, lead);
assert.equal(canceled.battleOrderActor, 'enemy-seat');
assert.equal(win.casualties?.owner, lead);
assert.equal(win.casualties?.dial, 1);
assert.deepEqual(win.fixedLosses, []);
assert.deepEqual(win.destroyedArmies, []);
assert.deepEqual(win.casualties?.options, [{
normal: 1, elite: 0, paidNormal: support, paidElite: 0,
}]);
if (lead === 'ecaz-seat')
assert.deepEqual(quoteEcazOccupyDial(canceled, 1, 0).options, [
{ normal: 2, elite: 0, paidNormal: 0, paidElite: 0 },
]);
else assert.throws(() => quoteEcazOccupyDial(canceled, 1, 1), /native ally-seat support/);
assert.deepEqual(quoteEcazOccupyOutcome(canceled, {
result: 'normal', won: false, dial: 1, support,
}).destroyedArmies, ['ecaz-seat', 'ally-seat']);
}
});
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 dial = canceled ? 0 : 3;
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,
}), /no Occupy winner/);
}
}
});
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)));
assert.deepEqual(ecazOccupyDialOptions(profile, 0), [
{ dial: 1, support: 0, variableDial: 0 },
{ dial: 1.5, support: 0, variableDial: 0.5 },
{ dial: 2, support: 0, variableDial: 1 },
]);
assert.deepEqual(ecazOccupyDialOptions(profile, 1), [
{ dial: 1, support: 0, variableDial: 0 },
{ dial: 1.5, support: 0, variableDial: 0.5 },
{ dial: 2, support: 0, variableDial: 1 },
{ dial: 2, support: 1, variableDial: 1 },
{ dial: 2.5, support: 1, variableDial: 1.5 },
]);
assert.throws(() => ecazOccupyDialOptions(profile, -1), /nonnegative whole spice/);
});
void test('nonreciprocal allies, advisors-only or disconnected pools, and foreign lead actors cannot create a combined army', () => {
const input = battle(3);
assert.throws(() => quoteEcazOccupyBattle({
...input, ally: { ...input.ally, ally: null },
}), /reciprocal ally/);
assert.throws(() => quoteEcazOccupyBattle({
...input, ally: { ...input.ally, faction: 'beneGesserit', forces: ordinary(0) },
}), /storm-connected fighters/);
assert.throws(() => quoteEcazOccupyBattle({
...input, ecaz: { ...input.ecaz, forces: ordinary(0) },
}), /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) },
}), 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 outcome = quoteEcazOccupyOutcome(profile, {
result: 'normal', won: true, dial: 30, support: 20,
});
assert.deepEqual(outcome.fixedLosses, [{ owner: 'ecaz-seat', normal: 10, elite: 0 }]);
assert.equal(outcome.casualties?.owner, 'ally-seat');
assert.deepEqual(outcome.casualties?.options, [
{ normal: 20, elite: 0, paidNormal: 20, paidElite: 0 },
]);
});
+127
View File
@@ -0,0 +1,127 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { controlsLeader } from '../game/leader-control';
import {
ecazOccupyDialChoices,
ecazOccupyLeadOptions,
ecazOccupyOwnProfile,
ecazOccupyPlanControl,
ecazOccupyPolicyActions,
} from '../game/ecaz-occupy-options';
import {
cancelEcazOccupy,
chooseEcazOccupyLead,
ecazOccupyFixture,
openEcazOccupyPlans,
} from './fixture-ecaz-occupy';
void test('Ecaz can choose either real plan owner; other seats cannot choose the lead', () => {
const fixture = ecazOccupyFixture({ allyFaction: 'guild', ecazForces: 3 });
const own = viewGame(fixture.game, fixture.ecaz);
const choices = ecazOccupyLeadOptions(own);
assert.deepEqual(choices.map((choice) => choice.lead), [fixture.ecaz, fixture.ally]);
for (const choice of choices) {
assert.equal(choice.blocked, null);
const game = applyAction(fixture.game, fixture.ecaz, choice.action);
const selected = ecazOccupyOwnProfile(viewGame(game, choice.lead));
assert.equal(selected?.planOwner, choice.lead);
assert.equal(selected?.payer, choice.lead);
assert.equal(selected?.forceOwner, fixture.ally);
assert.equal(selected?.fixedEcazDial, 2);
assert.equal(ecazOccupyOwnProfile(viewGame(game, fixture.opponent)), null);
}
const outsider = viewGame(fixture.game, fixture.ally);
assert.ok(ecazOccupyLeadOptions(outsider).every((choice) => !!choice.blocked));
assert.deepEqual(ecazOccupyPolicyActions(outsider), []);
for (const choice of choices)
assert.throws(() => applyAction(fixture.game, fixture.ally, choice.action));
});
void test('free fixed Ecaz strength is mandatory and cannot borrow the other ally’s spice', () => {
const fixture = ecazOccupyFixture({ allyFaction: 'guild', ecazForces: 3 });
const game = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, 'ecaz'));
// Labelled Spice Bank budget staging: no allied credit or force property changes.
game.players.find((player) => player.id === fixture.ecaz)!.spice = 0;
game.players.find((player) => player.id === fixture.ally)!.spice = 30;
const view = viewGame(game, fixture.ecaz);
const choices = ecazOccupyDialChoices(view);
assert.ok(choices.some((choice) => choice.dial === 2 && choice.support === 0));
assert.ok(choices.some((choice) => choice.dial === 2.5 && choice.support === 0));
assert.ok(choices.every((choice) => choice.support === 0 && choice.dial >= 2));
assert.equal(ecazOccupyPlanControl(view, 2, 0)?.blocked, null);
assert.ok(ecazOccupyPlanControl(view, 1.5, 0)?.blocked);
assert.ok(ecazOccupyPlanControl(view, 3, 1)?.blocked);
assert.deepEqual(ecazOccupyDialChoices(viewGame(game, fixture.ally)), []);
const me = view.players.find((player) => player.id === view.me)!;
const leader = me.leaders.find((entry) => !entry.dead && controlsLeader(me, entry) &&
(!entry.usedAt || entry.usedAt === fixture.territory))!;
assert.ok(leader);
const next = applyAction(game, fixture.ecaz, {
type: 'battlePlan', dial: 2.5, support: 0, leader: leader.id,
weapon: null, defense: null, kwisatz: false,
});
assert.equal(next.battle?.plans[fixture.ecaz].dial, 2.5);
assert.equal(next.battle?.plans[fixture.ecaz].support, 0);
assert.equal(next.players.find((player) => player.id === fixture.ecaz)?.spice, 0);
assert.equal(next.players.find((player) => player.id === fixture.ally)?.spice, 30);
});
void test('native Karama keeps either selected lead but replaces the active combined dial with their own army', () => {
for (const lead of ['ecaz', 'ally'] as const) {
const fixture = ecazOccupyFixture({ allyFaction: 'guild', ecazForces: 3 });
const game = openEcazOccupyPlans(cancelEcazOccupy(fixture, lead));
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
const view = viewGame(game, actor);
const control = ecazOccupyPlanControl(view, 0, 0)!;
assert.equal(control.profile.canceled, true);
assert.equal(control.profile.planOwner, actor);
assert.equal(control.profile.forceOwner, actor);
assert.equal(control.minimum, 0);
assert.equal(control.fixedEcazDial, 0);
assert.equal(control.maximum, lead === 'ecaz' ? 3 : 4);
assert.equal(control.blocked, null);
assert.ok(ecazOccupyDialChoices(view).some((choice) => choice.dial === 0 && choice.support === 0));
assert.ok(ecazOccupyPlanControl(view, control.maximum + 0.5, 0)?.blocked);
if (lead === 'ecaz') assert.ok(ecazOccupyPlanControl(view, 4, 3)?.blocked);
}
});
void test('typed Fremen and Emperor variable armies retain native legal increments and support', () => {
const fremen = ecazOccupyFixture({ allyFaction: 'fremen', ecazForces: 3 });
const fremenGame = openEcazOccupyPlans(chooseEcazOccupyLead(fremen, 'ecaz'));
const fremenView = viewGame(fremenGame, fremen.ecaz);
const free = ecazOccupyDialChoices(fremenView);
assert.ok(free.some((choice) => choice.variableDial === 6 && choice.dial === 8));
assert.ok(free.every((choice) => choice.support === 0 && Number.isInteger(choice.variableDial)));
assert.ok(ecazOccupyPlanControl(fremenView, 2.5, 0)?.blocked);
assert.ok(ecazOccupyPlanControl(fremenView, 3, 1)?.blocked);
const emperor = ecazOccupyFixture({ allyFaction: 'emperor', ecazForces: 3 });
const emperorGame = openEcazOccupyPlans(chooseEcazOccupyLead(emperor, 'ecaz'));
const emperorView = viewGame(emperorGame, emperor.ecaz);
const paid = ecazOccupyDialChoices(emperorView);
assert.ok(paid.some((choice) => choice.variableDial === 0.5 && choice.support === 0));
assert.ok(paid.some((choice) => choice.variableDial === 6 && choice.support === 4));
assert.equal(ecazOccupyPlanControl(emperorView, 8, 4)?.blocked, null);
});
void test('all four native policies can seal a legal total plan for either lead, active or canceled', () => {
for (const lead of ['ecaz', 'ally'] as const) for (const canceled of [false, true]) {
const fixture = ecazOccupyFixture({ allyFaction: 'guild', ecazForces: 3 });
const game = openEcazOccupyPlans(canceled
? cancelEcazOccupy(fixture, lead) : chooseEcazOccupyLead(fixture, lead));
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
for (const policy of DIFFICULTIES) {
const view = viewGame(game, actor);
view.players.find((player) => player.id === actor)!.bot = policy;
const plan = botActions(view).find((action) => action.type === 'battlePlan');
assert.ok(plan, `${policy}/${lead}/${canceled}: native policy must supply a sealable plan`);
assert.equal(ecazOccupyPlanControl(view, Number(plan.dial), Number(plan.support ?? 0))?.blocked, null);
const next = applyAction(game, actor, plan);
assert.ok(viewGame(next, actor).battle?.submitted.includes(actor),
`${policy}/${lead}/${canceled}: the native table must show the selected lead's sealed plan`);
}
}
});
+277
View File
@@ -0,0 +1,277 @@
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 { ecazOccupyFixture, chooseEcazOccupyLead, cancelEcazOccupy, openEcazOccupyPlans,
stageEcazOccupyCard, ecazOccupyTerritoryForces, settleEcazOccupyArrival, type EcazOccupyFixture } from './fixture-ecaz-occupy';
import { advanceToNextStorm, finishToMovement } from './fixture-advanced-source';
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);
function availableLeader(game: Game, id: string, strongest: boolean): string {
const leaders = seat(game, id).leaders.filter(l => !l.dead && !l.usedAt);
return leaders.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength)[0].id;
}
/** A labelled physical Traitor Card exchange; no fabricated card or call. */
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;
return;
}
const donor = game.players.find(p => p.traitors.includes(identity));
assert.ok(donor, 'The requested physical Traitor Card must exist outside the loyalty exclusion.');
const donorIndex = donor.traitors.indexOf(identity);
donor.traitors[donorIndex] = receiver.traitors[0];
receiver.traitors[0] = identity;
}
function traitorableLeader(game: Game, id: string): string {
const physical = [...(game.traitorReserve ?? []), ...game.players.flatMap(p => p.traitors)];
const leader = seat(game, id).leaders.find(l => !l.dead && !l.usedAt && physical.includes(l.id));
assert.ok(leader, 'A real non-loyal leader must have a physical Traitor Card.');
return leader.id;
}
function finishAftermath(state: Game): Game {
let game = state;
for (let attempt = 0; attempt < 100; attempt++) {
if (game.response) {
game = applyAction(game, game.players.find(p => !game.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' });
} 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 native battle aftermath did not complete.');
}
function resolve(fixture: EcazOccupyFixture, lead: 'ecaz' | 'ally', options: {
outcome?: 'win' | 'loss' | 'soleTraitor' | 'mutualTraitors' | 'explosion';
typed?: boolean; canceled?: boolean;
} = {}): { game: Game; actor: string; before: Game } {
const outcome = options.outcome ?? 'win';
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
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 fixed = profile.fixedEcazDial;
const free = seat(game, profile.forceOwner).faction === 'fremen';
const variable = outcome === 'loss' ? 0 : options.typed ? profile.maxDial - fixed : 2;
const support = free ? 0 : options.typed ? 4 : variable;
let ownLeader = availableLeader(game, actor, outcome !== 'loss');
const opponentLeader = availableLeader(game, fixture.opponent, outcome === 'loss');
const voter = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
if (outcome === 'soleTraitor' || outcome === 'mutualTraitors') {
holdTraitor(game, voter, opponentLeader);
if (outcome === 'mutualTraitors') {
ownLeader = traitorableLeader(game, actor);
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: fixed + variable,
support, leader: ownLeader, ...(weapon ? { weapon } : {}) });
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: outcome === 'loss' ? 8 : 0,
support: outcome === 'loss' ? 8 : 0, leader: opponentLeader, ...(defense ? { defense } : {}) });
for (let attempt = 0; attempt < 10 && game.battle; attempt++) {
const view = viewGame(game, actor).battle!;
const next = view.traitorVoters.find(id => !view.traitorSubmitted.includes(id));
if (!next) break;
const call = next === voter && ['soleTraitor', 'mutualTraitors'].includes(outcome) ||
next === fixture.opponent && outcome === 'mutualTraitors';
game = applyAction(game, next, { type: 'traitorCall', call });
}
game = finishAftermath(game);
assert.equal(game.battle, null, 'The actual battle must resolve once, not remain a sealed-plan scaffold.');
assert.equal(game.decision, null, 'The actual native casualty and card aftermath must finish.');
return { game, actor, before };
}
// Keep the first source arithmetic case independently runnable before the batch.
void test('Advanced Occupy E1 Ecaz lead contributes one free strength and has no Ecaz survivor', () => {
const fixture = ecazOccupyFixture({ ecazForces: 1, allyFaction: 'guild' });
const { game, actor, before } = resolve(fixture, 'ecaz');
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(forces(game, fixture, fixture.ecaz), 0);
assert.equal(seat(game, fixture.ecaz).tanks - seat(before, fixture.ecaz).tanks, 1);
assert.equal(forces(game, fixture, fixture.ally), 2);
assert.equal(seat(before, actor).spice - seat(game, actor).spice, 2,
'Only the actual ally commitment costs the selected lead spice.');
assert.equal(forces(game, fixture, fixture.opponent), 0);
});
for (const ecazForces of [1, 2, 3, 4, 5]) for (const lead of ['ecaz', 'ally'] as const)
for (const order of ['ecaz-first', 'ally-first', 'opponent-first'] as const) {
if (ecazForces === 1 && lead === 'ecaz' && order === 'ecaz-first') continue;
void test(`Advanced Occupy E${ecazForces}, ${lead} lead, ${order}: floor survivors and original payer`, () => {
const fixture = ecazOccupyFixture({ ecazForces, allyFaction: 'guild', order });
const expectedOrder = order === 'ecaz-first' ? [fixture.ecaz, fixture.ally, fixture.opponent] :
order === 'ally-first' ? [fixture.ally, fixture.opponent, fixture.ecaz] : [fixture.opponent, fixture.ecaz, fixture.ally];
assert.deepEqual(fixture.game.order, expectedOrder, 'Printed circles must create the requested real order.');
const { game, actor, before } = resolve(fixture, lead);
const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(forces(game, fixture, fixture.ecaz), Math.floor(ecazForces / 2));
assert.equal(seat(game, fixture.ecaz).tanks - seat(before, fixture.ecaz).tanks, Math.ceil(ecazForces / 2));
assert.equal(forces(game, fixture, fixture.ally), 2);
assert.equal(seat(before, actor).spice - seat(game, actor).spice, 2);
assert.equal(seat(before, nonLead).spice, seat(game, nonLead).spice);
assert.deepEqual(seat(game, nonLead).hand, seat(before, nonLead).hand);
assert.ok(game.phase >= 7, 'The one collapsed coalition battle continues to native collection.');
});
}
for (const lead of ['ecaz', 'ally'] as const)
void test(`Selected ${lead} lead retains its actual ordinary tie position, not the coalition chooser's position`, () => {
const fixture = ecazOccupyFixture({ ecazForces: 1, allyFaction: 'guild', order: 'ally-first' });
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
let game = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, lead));
const own = seat(game, actor).leaders.find(l => !l.dead &&
seat(game, fixture.opponent).leaders.some(other => !other.dead && other.strength === l.strength));
assert.ok(own, 'The native rosters must supply two equal-strength available leaders.');
const other = seat(game, fixture.opponent).leaders.find(l => !l.dead && l.strength === own.strength)!;
game = applyAction(game, actor, { type: 'battlePlan', dial: 1, support: 0, leader: own.id });
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 1, support: 1, leader: other.id });
for (const voter of viewGame(game, actor).battle!.traitorVoters)
game = applyAction(game, voter, { type: 'traitorCall', call: false });
game = finishAftermath(game);
assert.equal(game.lastBattleContext!.winner, lead === 'ecaz' ? fixture.opponent : fixture.ally);
});
for (const allyFaction of ['fremen', 'emperor'] as const) for (const lead of ['ecaz', 'ally'] as const)
void test(`Advanced Occupy ${allyFaction} typed elite commitment retains its force owner with ${lead} lead`, () => {
const fixture = ecazOccupyFixture({ allyFaction, ecazForces: 5 });
const { game, actor, before } = resolve(fixture, lead, { typed: true });
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(forces(game, fixture, fixture.ecaz), 2);
assert.equal(forces(game, fixture, fixture.ally), 0);
assert.equal(seat(game, fixture.ally).elites!.tanks - seat(before, fixture.ally).elites!.tanks, 2);
assert.equal(seat(before, actor).spice - seat(game, actor).spice, allyFaction === 'fremen' ? 0 : 4);
});
for (const lead of ['ecaz', 'ally'] as const)
void test(`Native allied Sardaukar lose their double strength against Fremen with ${lead} lead`, () => {
const fixture = ecazOccupyFixture({ allyFaction: 'emperor', opponentFaction: 'fremen', ecazForces: 5 });
const prepared = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, lead));
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
const profile = viewGame(prepared, actor).battle!.ecazOccupy!.profile!;
assert.equal(profile.maxDial - profile.fixedEcazDial, 4,
'The actual enemy faction, not the Ecaz plan actor, determines Sardaukar strength.');
const { game } = resolve(fixture, lead, { typed: true });
assert.equal(forces(game, fixture, fixture.ally), 0);
assert.equal(seat(game, fixture.ally).elites!.tanks, 2);
});
for (const outcome of ['loss', 'soleTraitor', 'mutualTraitors', 'explosion'] as const)
for (const lead of ['ecaz', 'ally'] as const)
void test(`Advanced Occupy true ${outcome} with ${lead} lead settles both physical coalition pools`, () => {
const fixture = ecazOccupyFixture({ allyFaction: 'guild', ecazForces: 5 });
const { game, actor, before } = resolve(fixture, lead, { outcome });
if (outcome === 'soleTraitor') {
assert.equal(game.lastBattleContext!.result, 'traitor');
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(forces(game, fixture, fixture.ecaz), 5);
assert.equal(forces(game, fixture, fixture.ally), 4);
assert.equal(seat(game, fixture.ecaz).tanks, seat(before, fixture.ecaz).tanks);
assert.equal(seat(game, fixture.ally).tanks, seat(before, fixture.ally).tanks);
} else {
assert.equal(game.lastBattleContext!.result, outcome === 'loss' ? 'normal' : outcome);
assert.equal(game.lastBattleContext!.winner, outcome === 'loss' ? fixture.opponent : null);
assert.equal(forces(game, fixture, fixture.ecaz), 0);
assert.equal(forces(game, fixture, fixture.ally), 0);
assert.equal(seat(game, fixture.ecaz).tanks - seat(before, fixture.ecaz).tanks, 5);
assert.equal(seat(game, fixture.ally).tanks - seat(before, fixture.ally).tanks, 4);
}
});
for (const lead of ['ecaz', 'ally'] as const)
void test(`Printed Karama with ${lead} lead recomputes own physical dial before seal`, () => {
const fixture = ecazOccupyFixture({ allyFaction: 'guild', ecazForces: 5 });
const { game, actor } = resolve(fixture, lead, { canceled: true });
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(forces(game, fixture, fixture.ecaz), lead === 'ecaz' ? 3 : 5);
assert.equal(forces(game, fixture, fixture.ally), lead === 'ally' ? 2 : 4);
});
void test('Native BG Worthless conversion cancels Occupy without changing selected lead or alliance', () => {
const fixture = ecazOccupyFixture({ allyFaction: 'guild', opponentFaction: 'beneGesserit' });
const game = openEcazOccupyPlans(cancelEcazOccupy(fixture, 'ecaz', true));
const profile = viewGame(game, fixture.ecaz).battle!.ecazOccupy!.profile!;
assert.equal(profile.canceled, true);
assert.equal(profile.forceOwner, fixture.ecaz);
assert.equal(profile.fixedEcazDial, 0);
assert.equal(seat(game, fixture.ecaz).ally, fixture.ally);
assert.equal(seat(game, fixture.ally).ally, fixture.ecaz);
});
void test('Native allied BG advisors are excluded from the Occupy fighter side and lead choice', () => {
const fixture = ecazOccupyFixture({ allyFaction: 'beneGesserit', advisorAlly: true });
assert.ok(seat(fixture.game, fixture.ally).advisors?.[fixture.territory]);
assert.notEqual(fixture.game.decision?.kind, 'ecazBattleLead');
assert.equal(viewGame(fixture.game, fixture.ecaz).battle!.ecazOccupy, null);
});
void test('Native aftermath retires only selected lead cards and four minimal policies can continue', () => {
const fixture = ecazOccupyFixture({ allyFaction: 'guild' });
const shield = stageEcazOccupyCard(fixture.game, fixture.ecaz, card => card.kind === 'shield');
let game = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, 'ecaz'));
const nonLeadHand = structuredClone(seat(game, fixture.ally).hand);
game = applyAction(game, fixture.ecaz, { type: 'battlePlan', dial: 4, support: 2,
leader: availableLeader(game, fixture.ecaz, true), defense: shield });
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 0, support: 0,
leader: availableLeader(game, fixture.opponent, false) });
for (const voter of viewGame(game, fixture.ecaz).battle!.traitorVoters)
game = applyAction(game, voter, { type: 'traitorCall', call: false });
assert.equal(game.decision?.kind, 'battleCards');
game = applyAction(game, game.decision!.player, { type: 'decision', discard: [shield] });
game = finishAftermath(game);
assert.ok(game.discard.some(card => card.id === shield));
assert.deepEqual(seat(game, fixture.ally).hand, nonLeadHand);
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(game, fixture.ecaz);
view.players.find(p => p.id === fixture.ecaz)!.bot = difficulty;
const candidates = botActions(view);
const action = candidates.find(a => a.type === 'ready');
assert.ok(action, `${difficulty} continues the actual post-battle phase.`);
const next = applyAction(game, fixture.ecaz, action);
assert.ok(next.ready.includes(fixture.ecaz) || next.phase > game.phase);
}
});
void test('Surviving coalition fights a second native battle next turn without a phase or plan retrofit', () => {
const fixture = ecazOccupyFixture({ ecazForces: 5, allyFaction: 'guild' });
let game = resolve(fixture, 'ecaz').game;
const firstEvent = game.lastBattleContext!.event;
game = advanceToNextStorm(game);
// Reorder only unplayed physical territory faces to avoid an unrelated new Nexus.
for (const position of [0, 1]) {
const index = game.spiceDeck.findIndex((card, i) => i >= position &&
'territory' in card && card.territory !== fixture.territory);
assert.ok(index >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
game = finishToMovement(game);
while (game.phase === 5) {
const actor = game.active!;
if (actor === fixture.opponent) {
game = applyAction(game, actor, { type: 'ship', territory: fixture.territory, sector: 15, amount: 2 });
game = settleEcazOccupyArrival(game);
}
game = applyAction(game, actor, { type: 'endMovement' });
}
while (game.phaseOpening)
game = applyAction(game, game.players.find(p => !game.phaseOpening!.passed.includes(p.id))!.id,
{ type: 'ready' });
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === fixture.territory)!;
assert.ok(choice);
game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: fixture.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
assert.equal(game.decision?.kind, 'ecazBattleLead');
const result = resolve({ ...fixture, game, ecazForces: 2 }, 'ecaz').game;
assert.notEqual(result.lastBattleContext!.event, firstEvent);
assert.equal(result.lastBattleContext!.turn, fixture.game.turn + 1);
assert.equal(forces(result, fixture, fixture.ecaz), 1);
assert.equal(forces(result, fixture, fixture.ally), 0);
assert.ok(result.phase >= 7);
});
+246
View File
@@ -0,0 +1,246 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeEcazOccupyGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player } from '../game/engine';
import { botActions } from '../game/bots';
import { TERRITORIES, location as locationKey, splitLocation } from '../game/board';
import { createAdvancedSourceFixture, finishToMovement, advanceToNextStorm } from './fixture-advanced-source';
import type { FactionId } from '../game/catalog';
import { baseDeck } from '../game/cards';
export type EcazOccupyFixtureOptions = {
initial?: Game;
ecazForces?: number;
allyFaction?: 'fremen' | 'emperor' | 'beneGesserit' | 'guild';
order?: 'ecaz-first' | 'ally-first' | 'opponent-first';
opponentFaction?: 'guild' | 'emperor' | 'beneGesserit' | 'fremen';
advisorAlly?: boolean;
};
export type EcazOccupyFixture = {
game: Game; ecaz: string; ally: string; opponent: string;
territory: string; location: string; ecazForces: number;
};
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.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 && ['advisor', 'intrusion', 'advisorBattle'].includes(game.decision.kind))
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 actions = botActions(view);
const action = actions.find(a => a.type === 'ready') ?? actions[0];
if (action) return applyAction(game, player.id, action);
}
throw new Error(`No native fixture action at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
}
export function settleEcazOccupyArrival(state: Game, accompany = false): Game {
let game = state;
for (let attempt = 0; attempt < 50 && (game.response || game.decision); attempt++) {
if (accompany && game.decision?.kind === 'advisor')
game = applyAction(game, game.decision.player,
{ type: 'decision', accept: true, accompany: true, amount: 1 });
else game = step(game);
}
assert.ok(!game.response && !game.decision, 'Native shipment reactions must complete.');
return game;
}
/** Conserved physical board staging AFTER genuine faction setup. No faction,
* alliance, phase, plan, force identity or battle receipt is manufactured. */
function returnBoardToReserves(game: Game): void {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0);
player.elites.forces = {};
}
if (player.advisors) player.advisors = {};
// The Spice Bank supplies a labelled test budget, not alliance credit.
player.spice = 30;
}
}
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 && card.territory !== 'the_great_flat'));
assert.ok(index >= position, 'The actual unplayed Spice Card must remain available.');
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
/** Conserved physical card exchange after real setup; retains the native draw
* and every original identity. Used only to expose a named rule case. */
export function stageEcazOccupyCard(game: Game, owner: string,
predicate: (card: Player['hand'][number]) => boolean): string {
const player = game.players.find(p => p.id === owner)!;
const held = player.hand.find(predicate);
if (held) return held.id;
const index = game.deck.findIndex(predicate);
let card: Player['hand'][number];
if (index >= 0) {
card = game.deck.splice(index, 1)[0];
if (player.hand.length >= 4) game.deck.push(player.hand.shift()!);
} else {
const donor = game.players.find(p => p.id !== owner && p.hand.some(predicate));
assert.ok(donor, 'A conserved original physical card must supply this rule case.');
const donorIndex = donor.hand.findIndex(predicate);
card = donor.hand[donorIndex];
const replacement = player.hand.shift() ?? game.deck.shift();
assert.ok(replacement, 'A conserved physical face must preserve the donor hand.');
donor.hand[donorIndex] = replacement;
}
player.hand.push(card);
return card.id;
}
/** Genuine fresh E3 faction setup, a natural worm Nexus, reciprocal alliance,
* accepted shared shipment and actual chooseBattle. Returns BEFORE lead choice.
* A supplied fresh setup retains its original deal and printed seat positions. */
export function ecazOccupyFixture(options: EcazOccupyFixtureOptions = {}): EcazOccupyFixture {
const allyFaction = options.allyFaction ?? 'fremen';
const opponentFaction = options.opponentFaction ?? (allyFaction === 'guild' ? 'emperor' : 'guild');
assert.notEqual(allyFaction, opponentFaction);
const ecazForces = options.ecazForces ?? 3;
assert.ok(Number.isInteger(ecazForces) && ecazForces >= 1 && ecazForces <= 5);
let setup: Game;
if (options.initial) {
setup = structuredClone(options.initial);
assert.equal(setup.status, 'setup', 'Captured input must be its original admitted fresh setup.');
assert.equal(setup.ecazOccupyPreview, true);
assert.equal(setup.advanced, true);
assert.deepEqual(setup.expansions, ['ecaz']);
assert.equal(setup.turn, 1, 'Captured Occupy input is never a started-game retrofit.');
assert.ok(!setup.ecazTreachery && !setup.homeworlds && !setup.nexusCards &&
!setup.leaderSkills && !setup.strongholdCards && !setup.techTokens &&
!setup.discoveryEnabled && !setup.discoveries && !setup.discoveryStash &&
!setup.greatMaker && !setup.moritaniAssassinatePreview,
'Captured Occupy inputs admit the explicit ordinary Advanced profile only.');
assert.deepEqual([...setup.deck, ...setup.discard, ...setup.players.flatMap(p => p.hand)]
.map(card => card.id).sort(), baseDeck().map(card => card.id).sort(),
'The original captured deal must retain the ordinary 33-card source.');
} else {
const roster: FactionId[] = ['ecaz', allyFaction, opponentFaction];
setup = createGame('ECAZOCCUPY', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, ['ecaz']);
for (const faction of roster.slice(1)) joinGame(setup, newPlayer(faction, faction, faction));
const desired = options.order === 'ally-first' ? [allyFaction, opponentFaction, 'ecaz'] :
options.order === 'opponent-first' || opponentFaction === 'beneGesserit' ?
[opponentFaction, 'ecaz', allyFaction] : ['ecaz', allyFaction, opponentFaction];
// Circles 3..5 remain in this order for every genuine second-turn Storm
// distance (1..6); the occupied initial circles are vacated via real actions.
for (let i = desired.length - 1; i >= 0; i--)
if (viewGame(setup, desired[i]).playerPositions[desired[i]] !== i + 3)
setup = applyAction(setup, desired[i], { type: 'seatPosition', position: i + 3 });
for (const player of setup.players) setup = applyAction(setup, player.id, { type: 'ready' });
setup = initializeEcazOccupyGameForAudit(setup);
}
assert.ok(setup.players.some(p => p.faction === 'ecaz') &&
setup.players.some(p => p.faction === allyFaction) &&
setup.players.some(p => p.faction === opponentFaction),
'The original source roster must contain the three actual factions for this rule case.');
const ecaz = setup.players.find(p => p.faction === 'ecaz')!.id;
const ally = setup.players.find(p => p.faction === allyFaction)!.id;
const opponent = setup.players.find(p => p.faction === opponentFaction)!.id;
let game = createAdvancedSourceFixture({ initial: setup }).game;
assert.equal(game.status, 'playing');
returnBoardToReserves(game);
orderBlow(game, false, 0);
orderBlow(game, false, 1);
game = finishToMovement(game);
while (game.phase === 5) 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, 'Real Nexus formation must reach native Shipment and Movement.');
assert.equal(game.players.find(p => p.id === ecaz)!.ally, ally);
const 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);
stageEcazOccupyCard(game, opponent, card => card.effect === 'karama');
while (game.phase === 5) {
const actor = game.active!;
if (![ecaz, ally, opponent].includes(actor)) {
game = applyAction(game, actor, { type: 'endMovement' });
continue;
}
const player = game.players.find(p => p.id === actor)!;
const amount = actor === ecaz ? ecazForces : actor === ally ? 4 : 8;
const elite = actor === ally && player.elites ? 2 : 0;
const action: Action = { type: 'ship', territory, sector, amount, ...(elite ? { elite } : {}) };
game = settleEcazOccupyArrival(applyAction(game, actor, action), !!options.advisorAlly && actor === ecaz);
if (actor === ally && options.advisorAlly) {
assert.equal(player.faction, 'beneGesserit');
assert.ok(game.players.find(p => p.id === ally)!.advisors?.[territory]);
}
game = applyAction(game, actor, { type: 'endMovement' });
}
while (game.phaseOpening || game.response || game.decision) game = step(game);
assert.equal(game.phase, 6);
const choices = viewGame(game, game.active!).battleChoices;
const choice = choices.find(b => b.territory === territory)!;
assert.ok(choice, 'The real three-army territory must quote a native battle.');
const chooser = choice.chooser;
game = applyAction(game, chooser, { type: 'chooseBattle', territory,
target: choice.attacker === chooser ? choice.defender : choice.attacker });
if (!options.advisorAlly) assert.equal(game.decision?.kind, 'ecazBattleLead');
return { game, ecaz, ally, opponent, territory, location, ecazForces };
}
/** Resolve only existing native counter windows, never create one. */
export function allowEcazOccupyResponses(state: Game): Game {
let game = state;
for (let attempt = 0; attempt < 100 && game.response; attempt++)
game = applyAction(game, game.players.find(p => !game.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' });
assert.equal(game.response, null);
return game;
}
export function chooseEcazOccupyLead(fixture: EcazOccupyFixture, lead: 'ecaz' | 'ally'): Game {
const decision = fixture.game.decision;
assert.ok(decision?.kind === 'ecazBattleLead', 'Only the native Ecaz lead decision may select this plan owner.');
return applyAction(fixture.game, fixture.ecaz,
{ type: 'decision', event: decision.event, lead: lead === 'ecaz' ? fixture.ecaz : fixture.ally });
}
export function cancelEcazOccupy(fixture: EcazOccupyFixture, lead: 'ecaz' | 'ally', bg = false): Game {
let game = fixture.game;
const card = stageEcazOccupyCard(game, fixture.opponent,
c => bg ? c.kind === 'worthless' : c.effect === 'karama');
game = chooseEcazOccupyLead({ ...fixture, game }, lead);
assert.equal(game.response?.kind, 'ecazOccupy');
return applyAction(game, fixture.opponent, { type: 'card', mode: 'cancel', card });
}
export function openEcazOccupyPlans(state: Game): Game {
let game = allowEcazOccupyResponses(state);
for (let attempt = 0; attempt < 20 && game.battle?.preparation; attempt++) {
game = applyAction(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
game = allowEcazOccupyResponses(game);
}
assert.ok(game.battle && !game.battle.preparation && !game.decision);
return game;
}
export function ecazOccupyTerritoryForces(game: Game, player: string, territory: string): number {
return Object.entries(game.players.find(p => p.id === player)!.forces)
.filter(([key]) => splitLocation(key).territory === territory).reduce((sum, [, n]) => sum + n, 0);
}
+31 -8
View File
@@ -18,6 +18,7 @@ import {
initializeNexusGameForAudit, initializeNexusGameForAudit,
initializePairedNexusGameForAudit, initializePairedNexusGameForAudit,
initializeEcazTreacheryGameForAudit, initializeEcazTreacheryGameForAudit,
initializeEcazOccupyGameForAudit,
initializeMoritaniAssassinateGameForAudit, initializeMoritaniAssassinateGameForAudit,
initializeCombinedNexusGameForAudit, initializeCombinedNexusGameForAudit,
initializeLeaderSkillsGameForAudit, initializeLeaderSkillsGameForAudit,
@@ -35,7 +36,7 @@ import { privateOutputDirectory, sourceSnapshot } from './verification';
const DEFAULT_SEED = 20_260_926; const DEFAULT_SEED = 20_260_926;
const DEFAULT_MAX_ACTIONS = 3_500; const DEFAULT_MAX_ACTIONS = 3_500;
const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const; const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const;
type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'moritani-assassinate' | 'stronghold-factions' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills'; type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'moritani-assassinate' | 'stronghold-factions' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills';
type Rules = 'basic' | 'advanced'; type Rules = 'basic' | 'advanced';
type Scenario = { type Scenario = {
@@ -156,6 +157,14 @@ const ECAZ_TREACHERY_SCENARIOS: readonly Scenario[] = EXPANSION_ROSTER_SCENARIOS
.map(scenario => ({ .map(scenario => ({
...scenario, profile: 'ecaz-treachery', ordinal: scenario.ordinal + 58, ...scenario, profile: 'ecaz-treachery', ordinal: scenario.ordinal + 58,
})); }));
/** Ordinary Advanced Ecaz Occupy, without the independent three-card variant. */
const ECAZ_OCCUPY_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].map(players => ({
ordinal: 169 + players - 2,
profile: 'ecaz-occupy',
rules: 'advanced',
expansions: ['ecaz'],
roster: (['ecaz', 'fremen', 'emperor', 'beneGesserit', 'harkonnen', 'guild'] as FactionId[]).slice(0, players),
}));
const PAIRED_NEXUS_SCENARIOS: readonly Scenario[] = EXPANSION_ROSTER_SCENARIOS const PAIRED_NEXUS_SCENARIOS: readonly Scenario[] = EXPANSION_ROSTER_SCENARIOS
.map(scenario => ({ .map(scenario => ({
...scenario, ...scenario,
@@ -290,10 +299,11 @@ type Result = {
function usage() { function usage() {
return ( return (
'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' + 'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' +
'[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|moritani-assassinate|stronghold-factions|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills] ' + '[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills] ' +
'[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' + '[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' +
'[--resume FAILED_GAME.json]\n' + '[--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.' '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: two through six seats, native Ecaz and classic opponents, 33 ordinary Treachery Cards and the E3 faction source. No three-card variant or optional overlays.'
); );
} }
@@ -330,15 +340,15 @@ function scenarioName(scenario: Scenario) {
return `${scenario.profile}-${native.join('-')}-${scenario.roster.length}-${scenario.rules}`; return `${scenario.profile}-${native.join('-')}-${scenario.roster.length}-${scenario.rules}`;
} }
return scenario.profile === 'base' || scenario.profile === 'homeworld' || scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus' || return scenario.profile === 'base' || scenario.profile === 'homeworld' || scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus' ||
scenario.profile === 'choam-roster' || scenario.profile === 'ecaz-roster' || scenario.profile === 'ecaz-treachery' || scenario.profile === 'moritani-assassinate' || scenario.profile === 'ix-roster' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || skillProfile(scenario.profile) scenario.profile === 'choam-roster' || scenario.profile === 'ecaz-roster' || scenario.profile === 'ecaz-treachery' || scenario.profile === 'ecaz-occupy' || scenario.profile === 'moritani-assassinate' || scenario.profile === 'ix-roster' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || skillProfile(scenario.profile)
? `${scenario.profile}-${scenario.roster.length}-${scenario.rules}` ? `${scenario.profile}-${scenario.roster.length}-${scenario.rules}`
: `${scenario.profile}-${scenario.rules}`; : `${scenario.profile}-${scenario.rules}`;
} }
function parseProfile(value: string | undefined) { function parseProfile(value: string | undefined) {
const profile = value ?? 'all'; const profile = value ?? 'all';
if (!['all', 'base', 'choam', 'ecaz', 'ecaz-treachery', 'moritani-assassinate', 'stronghold-factions', 'combined', 'combined-nexus', 'combined-homeworld-nexus', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills'].includes(profile)) if (!['all', 'base', 'choam', 'ecaz', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'combined', 'combined-nexus', 'combined-homeworld-nexus', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills'].includes(profile))
throw new Error('--profile must be all, base, choam, ecaz, ecaz-treachery, moritani-assassinate, stronghold-factions, combined, combined-nexus, combined-homeworld-nexus, homeworld, nexus, homeworld-nexus, choam-roster, ecaz-roster, ix-roster, choam-nexus, ecaz-nexus, ix-nexus, moritani-skills, tleilaxu-skills, ix-skills or choam-skills.'); throw new Error('--profile must be all, base, choam, ecaz, ecaz-treachery, ecaz-occupy, moritani-assassinate, stronghold-factions, combined, combined-nexus, combined-homeworld-nexus, homeworld, nexus, homeworld-nexus, choam-roster, ecaz-roster, ix-roster, choam-nexus, ecaz-nexus, ix-nexus, moritani-skills, tleilaxu-skills, ix-skills or choam-skills.');
return profile as Profile | 'all'; return profile as Profile | 'all';
} }
@@ -392,6 +402,8 @@ function freshGame(scenario: Scenario) {
return initializePairedNexusGameForAudit(game); return initializePairedNexusGameForAudit(game);
if (scenario.profile === 'ecaz-treachery') if (scenario.profile === 'ecaz-treachery')
return initializeEcazTreacheryGameForAudit(game); return initializeEcazTreacheryGameForAudit(game);
if (scenario.profile === 'ecaz-occupy')
return initializeEcazOccupyGameForAudit(game);
if (scenario.profile === 'moritani-assassinate') if (scenario.profile === 'moritani-assassinate')
return initializeMoritaniAssassinateGameForAudit(game); return initializeMoritaniAssassinateGameForAudit(game);
if (scenario.profile === 'stronghold-factions') if (scenario.profile === 'stronghold-factions')
@@ -448,7 +460,7 @@ function resumedGame(path: string) {
game.mentatQuestionPreview game.mentatQuestionPreview
) )
throw new Error('--resume sample scenarios exclude unsupported optional modules.'); throw new Error('--resume sample scenarios exclude unsupported optional modules.');
const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS].find( const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS].find(
(candidate) => (candidate) =>
(candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds && (candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
(candidate.profile === 'nexus' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards && (candidate.profile === 'nexus' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards &&
@@ -456,6 +468,7 @@ function resumedGame(path: string) {
(candidate.profile === 'stronghold-factions') === !!game.strongholdCards && (candidate.profile === 'stronghold-factions') === !!game.strongholdCards &&
(candidate.profile === 'ecaz-treachery') === !!game.ecazTreachery && (candidate.profile === 'ecaz-treachery') === !!game.ecazTreachery &&
(candidate.profile === 'moritani-assassinate') === !!game.moritaniAssassinatePreview && (candidate.profile === 'moritani-assassinate') === !!game.moritaniAssassinatePreview &&
(candidate.profile === 'ecaz-occupy') === !!game.ecazOccupyPreview &&
(candidate.profile === 'moritani-assassinate') === !!game.moritaniAssassinate && (candidate.profile === 'moritani-assassinate') === !!game.moritaniAssassinate &&
candidate.rules === (game.advanced ? 'advanced' : 'basic') && candidate.rules === (game.advanced ? 'advanced' : 'basic') &&
JSON.stringify(candidate.expansions) === JSON.stringify(candidate.expansions) ===
@@ -662,7 +675,7 @@ async function main() {
const profile = parseProfile(values.profile); const profile = parseProfile(values.profile);
const rules = parseRules(values.rules); const rules = parseRules(values.rules);
const players = parsePlayers(values.players); const players = parsePlayers(values.players);
if (supplied('players') && !['base', 'homeworld', 'nexus', 'homeworld-nexus', 'combined-nexus', 'combined-homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ecaz-treachery', 'moritani-assassinate', 'stronghold-factions', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus'].includes(profile) && !skillProfile(profile)) if (supplied('players') && !['base', 'homeworld', 'nexus', 'homeworld-nexus', 'combined-nexus', 'combined-homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus'].includes(profile) && !skillProfile(profile))
throw new Error('--players requires a roster or optional-module profile.'); throw new Error('--players requires a roster or optional-module profile.');
if ((profile === 'combined-nexus' || profile === 'combined-homeworld-nexus') && players !== 'all' && ![5, 6].includes(players)) if ((profile === 'combined-nexus' || profile === 'combined-homeworld-nexus') && players !== 'all' && ![5, 6].includes(players))
throw new Error(`--profile ${profile} supports only five or six players.`); throw new Error(`--profile ${profile} supports only five or six players.`);
@@ -672,11 +685,14 @@ async function main() {
throw new Error('Moritani assassination samples require Advanced rules.'); throw new Error('Moritani assassination samples require Advanced rules.');
if (profile === 'stronghold-factions' && rules === 'basic') if (profile === 'stronghold-factions' && rules === 'basic')
throw new Error('Stronghold factions samples require Advanced rules.'); 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 resume = values.resume ? resumedGame(values.resume) : null;
const requestedSamples = profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus' const requestedSamples = profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus'
? MODULE_SCENARIOS ? MODULE_SCENARIOS
: profile === 'choam-roster' || profile === 'ecaz-roster' || profile === 'ix-roster' ? EXPANSION_ROSTER_SCENARIOS : profile === 'choam-roster' || profile === 'ecaz-roster' || profile === 'ix-roster' ? EXPANSION_ROSTER_SCENARIOS
: profile === 'ecaz-treachery' ? ECAZ_TREACHERY_SCENARIOS : profile === 'ecaz-treachery' ? ECAZ_TREACHERY_SCENARIOS
: profile === 'ecaz-occupy' ? ECAZ_OCCUPY_SCENARIOS
: profile === 'moritani-assassinate' ? MORITANI_ASSASSINATE_SCENARIOS : profile === 'moritani-assassinate' ? MORITANI_ASSASSINATE_SCENARIOS
: profile === 'stronghold-factions' ? STRONGHOLD_FACTIONS_SCENARIOS : profile === 'stronghold-factions' ? STRONGHOLD_FACTIONS_SCENARIOS
: profile === 'choam-nexus' || profile === 'ecaz-nexus' || profile === 'ix-nexus' ? PAIRED_NEXUS_SCENARIOS : profile === 'choam-nexus' || profile === 'ecaz-nexus' || profile === 'ix-nexus' ? PAIRED_NEXUS_SCENARIOS
@@ -793,6 +809,13 @@ async function main() {
scope: 'Advanced selected Ixian/Tleilaxu and CHOAM/Richese native families with classic opponents; Stronghold Cards alone. Per-family ordinary decks and separate Richese caches remain explicit. This sample does not certify every Treachery effect, optional-module combination or deployed readiness.', scope: 'Advanced selected Ixian/Tleilaxu and CHOAM/Richese native families with classic opponents; Stronghold Cards alone. Per-family ordinary decks and separate Richese caches remain explicit. This sample does not certify every Treachery effect, optional-module combination or deployed readiness.',
}, },
} : {}), } : {}),
...(selected.some(scenario => scenario.profile === 'ecaz-occupy') ? {
ecazOccupy: {
treacheryCards: 33,
factionSource: 'E3',
scope: 'Advanced native Ecaz and classic opponents, two through six seats. Ordinary deck only; no independent Ecaz three-card variant or optional overlays. Authorized revised rulebook Advanced ceil contribution/floor survivors; not a designer erratum or Basic ruling.',
},
} : {}),
options: { options: {
seed, seed,
profile: resume ? resume.scenario.profile : profile, profile: resume ? resume.scenario.profile : profile,
+4
View File
@@ -9,6 +9,7 @@ import {
initializeNexusGameForAudit, initializeNexusGameForAudit,
initializeMoritaniAssassinateGameForAudit, initializeMoritaniAssassinateGameForAudit,
initializeEcazTreacheryGameForAudit, initializeEcazTreacheryGameForAudit,
initializeEcazOccupyGameForAudit,
initializeSemutaGameForAudit, initializeSemutaGameForAudit,
initializeKullGameForAudit, initializeKullGameForAudit,
initializeRicheseBetrayalGameForAudit, initializeRicheseBetrayalGameForAudit,
@@ -32,6 +33,7 @@ export const PROTOTYPE_PROFILES = [
'nexus', 'nexus',
'moritani-assassinate', 'moritani-assassinate',
'ecaz-treachery', 'ecaz-treachery',
'ecaz-occupy',
'semuta', 'semuta',
'kull', 'kull',
'richese-betrayal', 'richese-betrayal',
@@ -86,6 +88,8 @@ export function startPrototypeRoom(
? initializeStrongholdFactionsGameForAudit(initial) ? initializeStrongholdFactionsGameForAudit(initial)
: profile === 'ecaz-treachery' : profile === 'ecaz-treachery'
? initializeEcazTreacheryGameForAudit(initial) ? initializeEcazTreacheryGameForAudit(initial)
: profile === 'ecaz-occupy'
? initializeEcazOccupyGameForAudit(initial)
: profile === 'moritani-assassinate' : profile === 'moritani-assassinate'
? initializeMoritaniAssassinateGameForAudit(initial) ? initializeMoritaniAssassinateGameForAudit(initial)
: profile === 'semuta' : profile === 'semuta'
+2 -1
View File
@@ -20,7 +20,8 @@ async function main() {
}); });
if (values.help) { if (values.help) {
console.log( 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|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 roster with base 33-card Treachery and all fourteen Leader Skills alone into automatic deferred normal-band income and native Mentat collection; already-earned spice stays with its original faction under this explicit local 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 roster with base Treachery and Nexus only into the optional exact normal-auction purchased-card replacement; 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.', '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 roster with base 33-card Treachery and all fourteen Leader Skills alone into automatic deferred normal-band income and native Mentat collection; already-earned spice stays with its original faction under this explicit local 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 roster with base Treachery and Nexus only into the optional exact normal-auction purchased-card replacement; 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 opponents (optional native Moritani), expansion ecaz alone, into the authorized Advanced combined battle rule. It preserves the ordinary 33-card Treachery deck and real E3 faction setup; no Ecaz three-card variant, modules or overlays.',
); );
return; return;
} }