Apply Low Ecaz opponent-owned battle lead choice
This commit is contained in:
1 parent
96de962622
commit
33523d1926
15 files changed
+597
-17
No files matched your search
@@ -12,14 +12,19 @@ export function EcazOccupyLeadChoice({ game, act, busy }: {
|
||||
const options = ecazOccupyLeadOptions(game);
|
||||
const occupy = game.battle?.ecazOccupy;
|
||||
if (!occupy || !options.length) return null;
|
||||
const decision = game.decision?.kind === 'ecazBattleLead' ? game.decision : null;
|
||||
const chooser = game.players.find((player) => player.id === decision?.player)?.name ?? decision?.player ?? 'The current chooser';
|
||||
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,
|
||||
{chooser} 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>
|
||||
{decision?.lowEcaz && <p className="fine">
|
||||
{ecaz} was at Low population when this battle opened, so its Homeworld penalty gives the opponent this choice.
|
||||
</p>}
|
||||
{!game.advanced && <p className="fine">
|
||||
Basic prototype: Ecaz’s half-force contribution and winning losses round up;
|
||||
survivors round down. The main printed rule is used provisionally because
|
||||
|
||||
@@ -2805,7 +2805,7 @@ export function GameTable({
|
||||
<span className="eyebrow">Player decision</span>
|
||||
<h2>
|
||||
{g.decision.kind === 'ecazBattleLead'
|
||||
? 'Ecaz · choose the combined-army lead'
|
||||
? g.decision.lowEcaz ? 'Low Ecaz · opponent chooses the lead' : 'Ecaz · choose the combined-army lead'
|
||||
: g.decision.kind === 'caladanReinforcement'
|
||||
? 'Caladan victory reinforcement'
|
||||
: g.decision.kind === 'grummanCollection'
|
||||
|
||||
@@ -16,6 +16,32 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Low Ecaz opponent-owned battle lead — 25 October. Prototyped / Partial.**
|
||||
The original combined-battle opener now applies the printed Low penalty:
|
||||
at native population zero through six, the opponent chooses Ecaz or its ally
|
||||
as lead. High at seven and no-World games retain Ecaz's ordinary choice.
|
||||
Persistent controls name the captured chooser; the same minimal policy adapter
|
||||
serves that owner. Selected leaders/cards/payment, physical armies, aggressor
|
||||
normalization and later Occupy cancellation remain unchanged. Existing opened
|
||||
decisions retain their saved owner. No new numerical ruling or mode admission.
|
||||
[Component source and scope](ECAZ_OCCUPY_RULES.md#low-ecaz-opponent-owned-lead-choice--25-october-2026).
|
||||
|
||||
Disjoint helper/controls and native-source work, bounded review and root
|
||||
integration pass **9 files, 160/160**, types/lint/build and six native actual
|
||||
component-callback/engine/GameTable SSR journeys. Public Basic 74/Advanced 83
|
||||
actions earn actual Ecaz6 Basin placement, real worm alliance and paid eight-
|
||||
counter shipment, leaving Low6/four spice. The opponent selects either lead;
|
||||
both modes reach Collection. Original Guild Karama keeps the chosen allied
|
||||
lead while sparing Ecaz's eight counters. A real seven-counter shipment leaves
|
||||
High7 and the ordinary Ecaz callback. Both rules bands retain all four legal
|
||||
opponent profiles. Seven helper/control cases include labelled controlled
|
||||
projections, not earned source substitutes. Setup-driver phase selection and
|
||||
unintended early Guild stopping were repaired without changing game legality.
|
||||
No played source hand/wallet/forces/phase/alliance is installed. No saved-room,
|
||||
D1, server, reset, restart or deployment operation occurred. Broader modules,
|
||||
full modes, browser/mobile/deployed and final assurance remain open; SSR and
|
||||
callbacks do not certify hydration.
|
||||
|
||||
**Purchased Karama rate with native Richese Cunning — 25 October. Prototyped / Partial.**
|
||||
The original independent rate now halves Cunning's one billable pair unit plus
|
||||
any High paid companions, rounded once and paid to the bank. The original
|
||||
|
||||
@@ -2,6 +2,62 @@
|
||||
|
||||
Historical primary-source audit, 2026-09-07, corrected after independent verification. The publisher/designer odd-force conflict remains unresolved as a publisher correction. Under the 8 October Basic-first prototype instruction, Basic now provisionally uses the main p8 paragraph: ceiling contribution/loss and floor survivors, with a visible control warning. This is not a numerical ruling by the user or designer. Advanced retains its separately authorized source. The earlier false attribution to Jack Reda remains withdrawn.
|
||||
|
||||
## Low Ecaz opponent-owned lead choice — 25 October 2026
|
||||
|
||||
**Prototyped / Partial.** The verified Ecaz Low face says that when Ecaz and
|
||||
its ally both fight another faction, that opponent chooses which allied
|
||||
faction fights. The [component audit](HOMEWORLD_COMPONENT_AUDIT.md#verified-low-side-effects)
|
||||
and [native population manifest](HOMEWORLD_COMPONENT_AUDIT.md#canonical-physical-manifest)
|
||||
bind Low to zero through six native reserves and High to seven or more.
|
||||
This is a printed penalty, not a new numerical or timing interpretation.
|
||||
The original combined-battle opener now assigns its existing two-choice
|
||||
control to the actual opposing faction at Low; at High or without Homeworlds,
|
||||
Ecaz retains that choice. No extra acknowledgement, payment or card is added.
|
||||
|
||||
The selected lead still owns its leaders, hand and spice. The physical ally
|
||||
still supplies the original variable army while Ecaz's fixed contribution is
|
||||
unchanged. Original battle-order scheduling and aggressor normalization remain.
|
||||
Karama may subsequently prevent Occupy, keeping the selected lead and excluding
|
||||
the other army; it does not undo the Homeworld's mandatory chooser penalty.
|
||||
Choices use only the original public allied identities, never rival hand
|
||||
contents. Only the captured decision owner may choose; stale events and foreign
|
||||
actors remain rejected. Already-opened decisions retain their saved owner
|
||||
rather than reopening a choice or changing an in-flight parent.
|
||||
|
||||
The persistent panel and unavailable reasons name the actual chooser and
|
||||
explain Low Ecaz. The four existing profiles consume the same original legal
|
||||
lead options without strategic tuning. This applies to newly opened admitted
|
||||
Arrakis coalitions in either rules band; it adds no coalition to Homeworld
|
||||
locations, new profile or occupation-retention rule.
|
||||
|
||||
Evidence paths: [chooser quote](../game/ecaz-homeworld-lead.ts),
|
||||
[quote cases](../tests/ecaz-homeworld-lead.test.ts),
|
||||
[native source](../tests/fixture-low-ecaz-battle-lead.ts),
|
||||
[native cases](../tests/low-ecaz-battle-lead-runtime.test.ts),
|
||||
[controls](../components/ecaz-occupy.tsx) and
|
||||
[shared choices](../game/ecaz-occupy-options.ts).
|
||||
Full rules/mode and browser/mobile/deployed acceptance remain open.
|
||||
|
||||
Frozen **9 files, 160/160**, types/lint/build and six native actual component-
|
||||
callback/engine/GameTable SSR journeys pass. Basic 74/Advanced 83 source actions
|
||||
use original public setup, actual Ecaz6 Basin placement, real worm alliance and
|
||||
a paid eight-counter city shipment: native Low6/four spice. Both opponent-
|
||||
selected leads in both modes reach Collection. Actual Guild Karama preserves
|
||||
the selected allied lead and spares Ecaz8; the High7 boundary is earned through
|
||||
an actual seven-counter shipment and accepts the ordinary Ecaz callback.
|
||||
All four original opponent profiles select legally in both bands. Seven helper/
|
||||
control cases supplement the ten native cases with clearly controlled contexts;
|
||||
no controlled source substitutes for those actual setup and shipment actions.
|
||||
The first source smoke repairs an incorrectly selected setup-phase control;
|
||||
the first frozen runtime identifies Guild's default bot stopping Emperor's
|
||||
Advanced shipment before battle. Explicitly allowing that original shipment
|
||||
preserves both armies and its genuinely dealt battle Karama; no engine rule is
|
||||
bypassed. Private source-bound proof/HTML:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261025-low-ecaz-battle-lead/`.
|
||||
No saved-room/D1/server/reset/restart/deployment operation occurred. Other
|
||||
profiles/modules, full-mode/comprehensive assurance and browser/mobile/deployed
|
||||
acceptance remain open; SSR/callbacks are not parent hydration.
|
||||
|
||||
## Bare Basic native families — 24 October 2026
|
||||
|
||||
**Prototyped / Partial.** Fresh public Basic Ecaz tables now admit native Ixians,
|
||||
@@ -315,7 +371,7 @@ 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/
|
||||
Ecaz chooses the lead faction unless its Low Homeworld assigns that choice to the opponent; the selected 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
|
||||
@@ -570,7 +626,7 @@ Jack Reda independently confirms the stronghold exception and explains the two m
|
||||
Implementation must therefore distinguish:
|
||||
|
||||
- **Battle-order actor:** determined by the normal battle sequence before Ecaz's selection.
|
||||
- **Selected battling faction:** Ecaz chooses this after battle order is determined. It supplies its own permitted leader, hand and Battle Plan; it is not permission to browse or play the ally's hand.
|
||||
- **Selected battling faction:** Ecaz chooses this after battle order is determined, except that Low Ecaz gives the choice to the opponent. It supplies its own permitted leader, hand and Battle Plan; it is not permission to browse or play the ally's hand.
|
||||
- **Dialed force owner:** always Ecaz's ally while Occupy operates.
|
||||
- **Fixed-contribution owner:** always Ecaz, with the printed half-force contribution and separately attributed casualties.
|
||||
|
||||
|
||||
@@ -132,6 +132,17 @@ The reverse galleries were also inspected directly (`/tmp/dune-rules/homeworld-r
|
||||
|
||||
These faces require actual rule integrations, not a generic faction-disabled flag. Tleilax's phase-start condition, Tupile's once-per-faction private question and hand-limit cleanup, Grumman's reveal immunity, split income and displaced Bidding/bonus ownership have different lifecycles. Occupied Ecaz is an explicit additional Duke authority and revival permission; it does not by itself answer every existing loan-expiry, revival-custody or death-cycle question.
|
||||
|
||||
## Low Ecaz battle lead — 25 October 2026
|
||||
|
||||
The printed Low Ecaz penalty now owns the original combined-battle choice:
|
||||
its opposing faction chooses Ecaz or the ally as lead at native population
|
||||
zero through six. High at seven and games without Homeworlds retain Ecaz's
|
||||
ordinary choice. Both rules bands use the same public identities and original
|
||||
plan/physical-army/payment rules. Existing opened choices retain their owner;
|
||||
Karama against Occupy afterward keeps the selected lead. No new coalition,
|
||||
profile or occupation-retention rule is introduced.
|
||||
[Canonical contract and focused evidence](ECAZ_OCCUPY_RULES.md#low-ecaz-opponent-owned-lead-choice--25-october-2026).
|
||||
|
||||
## Native custody and population
|
||||
|
||||
The planet disc holds that faction's native reserve forces. A Homeworld is a distinct off-planet location, not an Arrakis territory, additional stronghold, private hand zone or a new supply of pieces. Invaders retain their own faction and physical force types; native reserves and foreign garrisons must be distinguishable. Total faction inventory continues to include forces on Arrakis, on every Homeworld, and in the Tanks.
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 25 October 2026 — Low Ecaz gives the allied battle choice to its opponent
|
||||
|
||||
The native Homeworld Low face's printed opponent-choice penalty now connects
|
||||
to the original combined-battle opener. A focused quote uses current native
|
||||
Ecaz population, delegating at0–6 and retaining Ecaz at7+ or without Worlds.
|
||||
The original decision captures the actual opposing player and optional public
|
||||
Low marker. Existing opened decisions retain their saved chooser. Controls,
|
||||
unavailable reasons, log and the existing four-profile legality adapter name
|
||||
that owner, without new acknowledgements, payments or strategic tuning.
|
||||
|
||||
Both original allied lead choices preserve their own plan/cards/spice, the
|
||||
physical variable ally, mandatory Ecaz contribution and native aggressor order.
|
||||
Subsequent Karama may prevent Occupy but keeps the selected lead and spares the
|
||||
excluded army; it does not undo the independent Homeworld penalty. This is a
|
||||
printed-rule integration, not a new numeric/timing ruling, profile admission,
|
||||
Homeworld-location coalition or occupation-retention policy.
|
||||
[Canonical component contract](ECAZ_OCCUPY_RULES.md#low-ecaz-opponent-owned-lead-choice--25-october-2026).
|
||||
|
||||
Disjoint helper/control and native-source lanes, bounded read-only review and
|
||||
root integration pass **9 files, 160/160**, types/lint/build and six native
|
||||
actual component-callback/engine/GameTable SSR journeys. Public Basic74 and
|
||||
Advanced83 source actions earn original setup (six Ecaz in Basin), actual worm
|
||||
alliance and paid eight-counter city shipment, leaving Low6/four spice. Either
|
||||
opponent-selected lead reaches Collection in each mode. Original Guild Karama
|
||||
keeps the selected allied lead, spends its genuine card and spares Ecaz8.
|
||||
An actual seven-counter shipment earns High7/five spice and the ordinary Ecaz
|
||||
callback. Ten native cases cover denial/stale input, both leads/cancellation,
|
||||
threshold and all four original profiles in both modes. Seven helper/control
|
||||
cases include explicitly controlled projections rather than invented native
|
||||
source proof. No played hand/wallet/army/phase/alliance or decision is installed.
|
||||
|
||||
Initial source feedback repairs a fixture's setup-phase step. Frozen quick
|
||||
passes; its runtime then exposes a fixture bot spending Guild's starting Karama
|
||||
to stop Emperor's Advanced shipment before any coalition. An explicit original
|
||||
Allow keeps that shipment and the actual card for the intended battle counter.
|
||||
The final frozen related union and all runtime paths pass; no rule, assertion
|
||||
or source requirement is weakened. Private logs/HTML:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261025-low-ecaz-battle-lead/`.
|
||||
`frozen-fixture-fix/report.json` passes identical source tree
|
||||
`d08994fe70cb09a26ff5f755bcb2c1e12ab74472578951f497d505625b3c25d6`,
|
||||
2,813 files, base `96de962`. Final prose-only evidence changes are separately
|
||||
hash/link checked without repeating unchanged runtime checks. Broader modules,
|
||||
full modes, browser/mobile/deployed and final assurance remain open. SSR and
|
||||
callbacks are not parent hydration acceptance. No saved-room/D1/server/reset/
|
||||
restart/deployment operation occurred.
|
||||
|
||||
## 25 October 2026 — Purchased Karama rates compose with native Richese Cunning
|
||||
|
||||
The native unallied pair now accepts its original independent purchased rate.
|
||||
|
||||
@@ -1254,6 +1254,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
|
||||
| Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) |
|
||||
| Ix High paid-Cyborg bonus | Provisional composition: observe High before the accepted ordinary group; add up to two actual remaining Suboid Tanks per paid Cyborg outside normal allowance/free quota, without Recruits multiplication or future debt. Preserve the paid invoice and original cancellation/stop outcomes. Prior La La La suppresses new grants; a delivered bonus is one existing free-return income event, not a second award. The card establishes the grant; quota, shortage and event classification are application policy, not a located Ix FAQ. | [Current contract and source boundaries](HOMEWORLD_BENEFITS_RULES.md#provisional-ix-high-paid-revival-bonus--25-october-2026) |
|
||||
| Low Homeworld / Tleilaxu revival prevention | Provisionally prevent the qualifying return as a whole while preserving the printed Low free rate. No independent bonus-only return, cost, quota use, revival income or new High follow-up arises from a stopped event. Quiet and pending native special-Karama windows share the same eligibility. Retain the 24 October Ghola phase4 cutover and Emperor special immunity. This event/advantage composition is not a located combined FAQ. | [Current source and policy](HOMEWORLD_BENEFITS_RULES.md#provisional-low-homeworld-revival-prevention--25-october-2026) |
|
||||
| Low Ecaz battle chooser | At current native population0–6, the printed Homeworld penalty gives the opposing faction the existing choice of Ecaz or its ally as combined-battle lead. High7+ and no-World retain Ecaz. Capture the chooser when the original battle opens; existing pending decisions keep their owner. Selected plan/payer/physical armies and later Occupy cancellation remain unchanged. No new numerical ruling or coalition is introduced. | [Component source and current contract](ECAZ_OCCUPY_RULES.md#low-ecaz-opponent-owned-lead-choice--25-october-2026) |
|
||||
| High Richese reserve accompaniment | The printed High card permits reserve forces beside a No-Field. Provisionally bill one marker plus the actual paid companions as one rounded ordinary group, observe High before that group and permit its completed shipment to cross Low. Hidden value stays in reserves; optional Smuggler reserves an additional real free counter. Original No-Field/Guild decisions and invoices remain, and the two-token Cunning route retains its separate contract. Combined tariff and grouped threshold timing are composition policy, not a printed worked example. | [Current prototype and boundaries](HOMEWORLD_RULES.md#high-richese-mixed-shipment-prototype--25-october-2026) |
|
||||
| High Richese / Nexus Cunning paid group | Provisionally price the pair as one unit plus explicit paid ordinary counters; reserve the paid group first, then immediately materialize only the remaining stock for the faceup token. Observe High before the atomic group, permit crossing Low and retain the concealed token's later reserve-capped reveal. Original Nexus spend, two-token history, No-Field cancellation and Guild stop remain. No new Smuggler pair or retrofitted quantity; the later purchased-rate composition below is separate. This is combined pricing/allocation policy, not a located worked example. | [Current combination contract](NEXUS_RICHESE_RULES.md#high-homeworld-cunning-accompaniment--25-october-2026) |
|
||||
| Purchased Karama / native Richese Cunning | Provisionally apply the original independent half-normal, bank-paid grant to one pair unit plus any paid High companions, rounded once. Bind original beneficiary/owner/optional activation card even at unchanged city fee. Keep live claimed-cost history, original No-Field unused-grant/spent-Nexus outcome and existing provisional Guild-stop exact-card refund. No native Guild rate or replacement grant is invented. | [Current source and boundaries](NEXUS_RICHESE_RULES.md#purchased-karama-rate-with-native-cunning--25-october-2026) |
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Game } from './engine';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import { homeworldContext } from './homeworld-game';
|
||||
import { homeworldPopulations } from './homeworld-population';
|
||||
|
||||
export type EcazBattleLeadQuote = { player: string; lowEcaz?: true };
|
||||
|
||||
/** Quote the printed Low Ecaz chooser at battle opening. The engine keeps this
|
||||
* original owner on the decision; later population changes do not reopen it. */
|
||||
export function quoteEcazBattleLead(
|
||||
context: Pick<Game, 'advanced' | 'players' | 'homeworlds'>,
|
||||
ecazId: string,
|
||||
opponentId: string,
|
||||
): EcazBattleLeadQuote {
|
||||
if (!context.players.some(player => player.id === ecazId && player.faction === 'ecaz') ||
|
||||
ecazId === opponentId || !context.players.some(player => player.id === opponentId))
|
||||
throw new HomeworldCustodyError('The combined battle needs its actual native Ecaz and a distinct seated opponent.');
|
||||
if (!context.homeworlds?.custody) return { player: ecazId };
|
||||
const low = homeworldPopulations(homeworldContext(context), context.homeworlds.custody)
|
||||
.some(home => home.native === ecazId && home.card === 'ecaz' && home.side === 'low');
|
||||
return low ? { player: opponentId, lowEcaz: true } : { player: ecazId };
|
||||
}
|
||||
@@ -19,9 +19,10 @@ export function ecazOccupyLeadOptions(game: GameView) {
|
||||
const battle = game.battle;
|
||||
if (decision?.kind !== 'ecazBattleLead' || !battle?.ecazOccupy ||
|
||||
decision.event !== battle.event || decision.event !== battle.ecazOccupy.event) return [];
|
||||
const chooser = game.players.find(player => player.id === decision.player)?.name ?? decision.player;
|
||||
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;
|
||||
: decision.player !== game.me ? `Only ${chooser} chooses the lead.` : null;
|
||||
return decision.choices.map((lead) => ({
|
||||
lead,
|
||||
label: game.players.find((player) => player.id === lead)?.name ?? lead,
|
||||
|
||||
+9
-3
@@ -59,6 +59,7 @@ import { quoteNexusMoritaniRetention, nexusMoritaniRetentionSignature, type Nexu
|
||||
import { quoteNexusChoamBetrayal, validateNexusChoamBetrayal, nexusChoamBetrayalSignature, type NexusChoamBetrayal } from './nexus-choam-betrayal';
|
||||
import { quoteEcazInquiry, ecazInquiryAnswer, ecazInquiryHistory, ecazInquirySignature, validateEcazInquiry, type EcazInquiryReceipt } from './nexus-ecaz-inquiry';
|
||||
import { nexusCleanPlayBlocked } from './nexus-play-boundary';
|
||||
import { quoteEcazBattleLead } from './ecaz-homeworld-lead';
|
||||
import { sourceForRicheseCunning, richeseCunningRateMatches, validRicheseCunningRateSource, RicheseCunningRateError, type RicheseCunningRateSource } from './richese-cunning-rate';
|
||||
import { EMPEROR_NEXUS_REVIVALS, emperorNexusLegacyRevivalModeSupported, quoteEmperorNexusRevival, createEmperorNexusRevival, settleEmperorNexusRevival, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally';
|
||||
import { emperorNexusPurchaseEvent, quoteEmperorNexusPurchase, signEmperorNexusPurchase, validateEmperorNexusPurchase, emperorNexusBankPurchaseModeSupported, emperorNexusSellerPurchaseModeSupported, quoteEmperorNexusSellerPurchase, createEmperorNexusSellerPurchase, emperorNexusAmbassadorPurchaseModeSupported, quoteEmperorNexusAmbassadorPurchase, createEmperorNexusAmbassadorPurchase, quoteEmperorNexusSpecialPurchase, createEmperorNexusSpecialPurchase, type EmperorNexusPurchase } from './nexus-emperor-purchase';
|
||||
@@ -880,7 +881,7 @@ export type Auction = {
|
||||
allyPayment?: number;
|
||||
};
|
||||
export type Decision =
|
||||
| { kind: 'ecazBattleLead'; player: string; event: string; choices: string[] }
|
||||
| { kind: 'ecazBattleLead'; player: string; event: string; choices: string[]; lowEcaz?: true }
|
||||
| { kind: 'harassWithdraw'; player: string; event: string }
|
||||
| ({ kind: 'smugglerCollection' } & SmugglerBattleCollectionOffer)
|
||||
| ({ kind: 'sandmasterVictorySpice' } & SandmasterVictorySpiceOffer)
|
||||
@@ -34114,7 +34115,7 @@ function applyActionInner(
|
||||
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' });
|
||||
log(g, `${p.name} chose ${getPlayer(g, action.lead).name} to lead the combined Ecaz battle, using the lead’s own leaders, cards and spice.`, { faction: 'ecaz', name: 'Occupy' });
|
||||
return g;
|
||||
}
|
||||
if (
|
||||
@@ -37176,7 +37177,12 @@ function applyActionInner(
|
||||
};
|
||||
if (combined) {
|
||||
g.battle.ecazOccupy = { event: battleEvent, ecaz: ecaz!.id, ally: ally!.id, lead: null, canceled: false };
|
||||
g.decision = { kind: 'ecazBattleLead', player: ecaz!.id, event: battleEvent, choices: [ecaz!.id, ally!.id] };
|
||||
const opponent = [choice.attacker, choice.defender].find(id => id !== ecaz!.id && id !== ally!.id)!;
|
||||
const leadChoice = homeworldRule(() => quoteEcazBattleLead(g, ecaz!.id, opponent));
|
||||
g.decision = { kind: 'ecazBattleLead', ...leadChoice, event: battleEvent, choices: [ecaz!.id, ally!.id] };
|
||||
if (leadChoice.lowEcaz)
|
||||
log(g, `${getPlayer(g, opponent).name} chooses the allied battle lead because Ecaz is at Low population.`,
|
||||
{ faction: 'ecaz', name: 'Low Ecaz' });
|
||||
} else {
|
||||
createMentatQuestion(g);
|
||||
beginStrongholdBattle(g);
|
||||
|
||||
+6
-6
@@ -1469,9 +1469,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Ecaz chooses who leads its combined army. Basic provisionally follows the main printed paragraph for odd as well as even counts: ceiling contribution/loss and floor survivors. Advanced retains its user-authorized revision. Usable controls and legal participation are connected; interaction refinement remains.',
|
||||
summary: 'Ecaz chooses who leads its combined army; its opponent chooses when Ecaz is Low. Basic provisionally follows the main printed paragraph for odd as well as even counts: ceiling contribution/loss and floor survivors. Advanced retains its user-authorized revision. Usable controls and legal participation are connected; interaction refinement remains.',
|
||||
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.',
|
||||
'When reciprocal Ecaz allies coexist with fighting forces, Ecaz chooses Ecaz or its ally to lead. With Homeworlds, Low Ecaz at zero through six native reserves gives that choice to the opponent; High at seven or more keeps it with Ecaz. The panel names the current chooser, and already-opened decisions retain their saved owner. 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.',
|
||||
'Bare Basic Ecaz tables support native Ixians, Tleilaxu, CHOAM and Richese with their selected families, classic seats and optional Moritani. Real setup and combined-battle guards remain; optional configurations retain their existing boundaries. Ecaz contributes and loses the ceiling of half its forces on an ordinary win; survivors round down. This follows the main paragraph provisionally because its odd-count FAQ conflicts. Basic support, funding and battle-support income are zero; CHOAM keeps five native leaders and Emperor/Fremen have no Advanced elite inventory.',
|
||||
'With Advanced Occupy active, the total dial includes the ceiling of half the Ecaz fighters as mandatory full-strength contribution without spice cost, plus the chosen allied army strength. The variable army retains its native Fremen/Fedaykin or Emperor/Sardaukar properties even when Ecaz forms the plan.',
|
||||
'An ordinary victory leaves the floor of half the Ecaz fighters alive and separately applies the variable army’s dial losses. A sole successful Traitor-call winner preserves both armies; an ordinary defeat, mutual Traitor result or resolved explosion removes the actual combined side. Ecaz and its ally share the printed same-faction Traitor/Face Dancer relation.',
|
||||
@@ -1485,10 +1485,10 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Advanced numbers come from the user-authorized revised PDF, physical pages 32 and 19, not a new publisher erratum. Basic follows the main printed paragraph provisionally. Original faction/deck prerequisites, separate ten-card Richese cache and independent three-card variant selection stay. Ordinary-plus-marker Basic/Advanced Occupy extends the provisional additive interpretation, not the unresolved FAQ cap. Fresh plain/card Ecaz and standalone Ecaz/classic Skills retain ordinary Arrakis Occupy, including independent cards without other modules. Bare Basic native mixed markers are connected; unsupported overlays and full-mode acceptance remain separate.',
|
||||
],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Bare Basic selected native-family starts now retain their original setup and owner-labelled coalition outcomes, including allied zero/mixed Richese presence and actual Ix typed losses/substitution. Basic support stays zero; current physical pools drive half-step controls. Main-paragraph odd Basic and additive-marker policies remain provisional; optional/exotic refinement remains.', 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: 'Actual Basic wheels expose half-step Ixian totals under either lead, whole steps after Ecaz-only cancellation, zero-support native plans and private Richese mixed bounds. Nine current rendered-table programmes retain original seal/reveal, typed/substitution and Collection controls. Earlier mobile journeys stay separate; no new visual/browser certification.', evidence: ['components/ecaz-occupy.tsx', 'components/face-dance-decision.tsx', 'components/game-table.tsx', 'game/ecaz-occupy-options.ts'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four existing Basic profiles make twelve legal lead/plan/native-Ixian-substitution proposals through original Collection. Existing Advanced legal participation remains separate. Strategy and strength calibration remain deferred.', evidence: ['game/ecaz-occupy-options.ts', 'game/bots.ts', 'tests/ecaz-occupy-options.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'Authorized Advanced arithmetic and provisional main-paragraph Basic arithmetic are distinguished from the conflicting FAQ, specific Duke ruling and deferred interaction refinement.', evidence: ['docs/ECAZ_OCCUPY_RULES.md', 'docs/RULE_DECISIONS.md', 'game/reference.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'New original Homeworld coalitions assign the public lead choice to the opponent at Low Ecaz, preserving High/no-World and existing in-flight owners. Bare Basic selected native-family starts now retain their original setup and owner-labelled coalition outcomes, including allied zero/mixed Richese presence and actual Ix typed losses/substitution. Basic support stays zero; current physical pools drive half-step controls. Main-paragraph odd Basic and additive-marker policies remain provisional; optional/exotic refinement remains.', evidence: ['game/ecaz-homeworld-lead.ts', 'game/ecaz-occupy-battle.ts', 'game/board-resolution-quote.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Lead controls and blocked reasons name the actual chooser and printed Low penalty. Actual Basic wheels expose half-step Ixian totals under either lead, whole steps after Ecaz-only cancellation, zero-support native plans and private Richese mixed bounds. Nine current rendered-table programmes retain original seal/reveal, typed/substitution and Collection controls. Earlier mobile journeys stay separate; no new visual/browser certification.', evidence: ['components/ecaz-occupy.tsx', 'components/face-dance-decision.tsx', 'components/game-table.tsx', 'game/ecaz-occupy-options.ts'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'The opponent uses the original shared legal lead adapter at Low Ecaz; strategic refinement remains deferred. Four existing Basic profiles make twelve legal lead/plan/native-Ixian-substitution proposals through original Collection. Existing Advanced legal participation remains separate. Strategy and strength calibration remain deferred.', evidence: ['game/ecaz-occupy-options.ts', 'game/bots.ts', 'tests/ecaz-occupy-options.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'The printed Low Ecaz opponent-choice penalty is distinct from authorized Advanced arithmetic and provisional main-paragraph Basic arithmetic are distinguished from the conflicting FAQ, specific Duke ruling and deferred interaction refinement.', evidence: ['docs/ECAZ_OCCUPY_RULES.md', 'docs/RULE_DECISIONS.md', 'game/reference.ts'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Current Basic native12-file244/244, types/lint/build and nine controlled gameplay/rendering programmes pass. Public setup and actual Nexus/alliance/marker shipping precede labelled board/card/typed/budget staging; both leads, total3.5, native substitution, cancellation and zero/capped/mixed markers reach Collection. Four existing profiles provide12 legal proposals. Earlier Advanced/mobile/full-game evidence is historical; full modes, optional configurations, comprehensive assurance and deployed acceptance remain open.', evidence: ['tests/basic-ecaz-native-entry.test.ts', 'tests/basic-ecaz-native-occupy.test.ts', 'tests/fixture-basic-ecaz-native.ts', 'tests/fixture-ecaz-occupy-richese.ts', 'tests/ecaz-occupy-mixed-no-field.test.ts', 'tests/fixture-ecaz-occupy-mixed-no-field.ts', 'tests/ecaz-occupy-mixed-no-field-opponent.test.ts', 'tests/ecaz-occupy-battle.test.ts', 'tests/basic-ecaz-occupy-runtime.test.ts', 'tests/basic-ecaz-tleilaxu-occupy.test.ts', 'tests/ecaz-occupy-richese.test.ts', 'tests/ecaz-occupy-stone.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
|
||||
],
|
||||
related: ['faction-ecaz', 'advanced-ecaz', 'ecaz-ambassadors', 'battle', 'victory', 'implementation-checklist'],
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { createHomeworldCustody, quoteHomeworldCustody, HomeworldCustodyError } from '../game/homeworld-custody';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import { quoteEcazBattleLead } from '../game/ecaz-homeworld-lead';
|
||||
import { ecazOccupyLeadOptions, ecazOccupyPolicyActions } from '../game/ecaz-occupy-options';
|
||||
import { EcazOccupyLeadChoice } from '../components/ecaz-occupy';
|
||||
import { ecazOccupyFixture } from './fixture-ecaz-occupy';
|
||||
|
||||
/** Controlled population and custody contexts isolate the printed chooser;
|
||||
* they are not earned battle or setup chronologies. */
|
||||
function populationContext(population = 7, advanced = false): Pick<Game, 'advanced' | 'players' | 'homeworlds'> {
|
||||
const players = [newPlayer('ec', 'Ecaz', 'ecaz'), newPlayer('enemy', 'Guild', 'guild'),
|
||||
newPlayer('ally', 'Atreides', 'atreides')];
|
||||
players[0].reserves = population;
|
||||
players[0].tanks = 20 - population;
|
||||
players[0].ally = 'ally'; players[2].ally = 'ec';
|
||||
return { advanced, players, homeworlds: { custody: createHomeworldCustody(homeworldContext({ advanced, players })) } };
|
||||
}
|
||||
|
||||
void test('Low Ecaz gives its opponent the choice in Basic and Advanced; seven native counters restore Ecaz choice', () => {
|
||||
for (const advanced of [false, true]) for (const population of [0, 6, 7, 14, 20]) {
|
||||
const context = populationContext(population, advanced), before = structuredClone(context);
|
||||
assert.deepEqual(quoteEcazBattleLead(context, 'ec', 'enemy'), population < 7
|
||||
? { player: 'enemy', lowEcaz: true } : { player: 'ec' });
|
||||
assert.deepEqual(context, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('without actual Homeworld custody the ordinary Ecaz chooser remains unchanged', () => {
|
||||
const context = populationContext(0);
|
||||
assert.deepEqual(quoteEcazBattleLead({ ...context, homeworlds: undefined }, 'ec', 'enemy'), { player: 'ec' });
|
||||
assert.deepEqual(quoteEcazBattleLead({ ...context, homeworlds: { custody: null } }, 'ec', 'enemy'), { player: 'ec' });
|
||||
});
|
||||
|
||||
void test('foreign visitors do not raise native Low Ecaz population', () => {
|
||||
const context = populationContext(6);
|
||||
const visit = quoteHomeworldCustody(homeworldContext(context), context.homeworlds!.custody!, [
|
||||
{ homeworld: 'homeworld:guild', player: 'enemy', withdraw: { normal: 8, elite: 0 }, deposit: { normal: 0, elite: 0 } },
|
||||
{ homeworld: 'homeworld:ecaz', player: 'enemy', withdraw: { normal: 0, elite: 0 }, deposit: { normal: 8, elite: 0 } },
|
||||
]);
|
||||
for (const next of visit.players) context.players.find(player => player.id === next.id)!.reserves = next.reserves;
|
||||
context.homeworlds!.custody = visit.state;
|
||||
const before = structuredClone(context);
|
||||
assert.deepEqual(quoteEcazBattleLead(context, 'ec', 'enemy'), { player: 'enemy', lowEcaz: true });
|
||||
assert.deepEqual(context, before);
|
||||
});
|
||||
|
||||
void test('current native departures and returns determine fresh quotes without changing an earlier captured result', () => {
|
||||
const context = populationContext(8);
|
||||
const original = quoteEcazBattleLead(context, 'ec', 'enemy');
|
||||
const away = quoteHomeworldCustody(homeworldContext(context), context.homeworlds!.custody!, [
|
||||
{ homeworld: 'homeworld:ecaz', player: 'ec', withdraw: { normal: 2, elite: 0 }, deposit: { normal: 0, elite: 0 } },
|
||||
{ homeworld: 'homeworld:guild', player: 'ec', withdraw: { normal: 0, elite: 0 }, deposit: { normal: 2, elite: 0 } },
|
||||
]);
|
||||
for (const next of away.players) context.players.find(player => player.id === next.id)!.reserves = next.reserves;
|
||||
context.homeworlds!.custody = away.state;
|
||||
const low = quoteEcazBattleLead(context, 'ec', 'enemy');
|
||||
assert.deepEqual(low, { player: 'enemy', lowEcaz: true });
|
||||
assert.deepEqual(original, { player: 'ec' });
|
||||
const back = quoteHomeworldCustody(homeworldContext(context), context.homeworlds!.custody!, [
|
||||
{ homeworld: 'homeworld:guild', player: 'ec', withdraw: { normal: 2, elite: 0 }, deposit: { normal: 0, elite: 0 } },
|
||||
{ homeworld: 'homeworld:ecaz', player: 'ec', withdraw: { normal: 0, elite: 0 }, deposit: { normal: 2, elite: 0 } },
|
||||
]);
|
||||
for (const next of back.players) context.players.find(player => player.id === next.id)!.reserves = next.reserves;
|
||||
context.homeworlds!.custody = back.state;
|
||||
assert.deepEqual(quoteEcazBattleLead(context, 'ec', 'enemy'), { player: 'ec' });
|
||||
assert.deepEqual(low, { player: 'enemy', lowEcaz: true });
|
||||
});
|
||||
|
||||
void test('the chooser quote requires distinct seated Ecaz and opponent roles', () => {
|
||||
const context = populationContext(6), before = structuredClone(context);
|
||||
for (const [ecaz, opponent] of [['missing', 'enemy'], ['enemy', 'ec'], ['ec', 'missing'], ['ec', 'ec'], ['', 'enemy']])
|
||||
assert.throws(() => quoteEcazBattleLead(context, ecaz, opponent), HomeworldCustodyError);
|
||||
assert.deepEqual(context, before);
|
||||
});
|
||||
|
||||
void test('controlled captured Low chooser projection gives both original lead choices only to the recorded opponent', () => {
|
||||
const fixture = ecazOccupyFixture({ allyFaction: 'guild', ecazForces: 3 });
|
||||
// Only the public recorded chooser is controlled here. The native engine
|
||||
// integration tests own earning Low population and opening this decision.
|
||||
const own = viewGame(fixture.game, fixture.opponent);
|
||||
assert.equal(own.decision?.kind, 'ecazBattleLead');
|
||||
if (own.decision?.kind !== 'ecazBattleLead') throw Error('Expected original lead choice');
|
||||
own.decision = { ...own.decision, player: fixture.opponent, lowEcaz: true };
|
||||
const before = structuredClone(own), choices = ecazOccupyLeadOptions(own);
|
||||
assert.deepEqual(choices.map(choice => choice.lead), [fixture.ecaz, fixture.ally]);
|
||||
assert.ok(choices.every(choice => choice.blocked === null));
|
||||
assert.deepEqual(ecazOccupyPolicyActions(own), choices.map(choice => choice.action));
|
||||
const chooser = own.players.find(player => player.id === fixture.opponent)!.name;
|
||||
const other = viewGame(fixture.game, fixture.ecaz);
|
||||
other.decision = structuredClone(own.decision);
|
||||
assert.ok(ecazOccupyLeadOptions(other).every(choice => choice.blocked === `Only ${chooser} chooses the lead.`));
|
||||
assert.deepEqual(ecazOccupyPolicyActions(other), []);
|
||||
const html = renderToStaticMarkup(createElement(EcazOccupyLeadChoice, { game: own, busy: false, act() {} }));
|
||||
assert.ok(html.includes(chooser));
|
||||
assert.match(html, /chooses who forms the plan/);
|
||||
assert.match(html, /was at Low population when this battle opened/);
|
||||
assert.deepEqual(own, before);
|
||||
});
|
||||
|
||||
void test('an original lead decision without the Low flag keeps its recorded chooser and ordinary explanation', () => {
|
||||
const fixture = ecazOccupyFixture({ allyFaction: 'guild', ecazForces: 3 });
|
||||
const own = viewGame(fixture.game, fixture.ecaz), before = structuredClone(own);
|
||||
assert.ok(ecazOccupyLeadOptions(own).every(choice => choice.blocked === null));
|
||||
const html = renderToStaticMarkup(createElement(EcazOccupyLeadChoice, { game: own, busy: false, act() {} }));
|
||||
assert.match(html, /chooses who forms the plan/);
|
||||
assert.doesNotMatch(html, /Low population/);
|
||||
assert.deepEqual(own, before);
|
||||
});
|
||||
@@ -20,6 +20,8 @@ export function nextHomeworldEcazStep(game: Game): HomeworldEcazStep | null {
|
||||
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
|
||||
if (game.decision?.kind === 'moritaniPlacement')
|
||||
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
|
||||
if (game.decision?.kind === 'guildShipment')
|
||||
return { actor: game.decision.player, action: { type: 'decision', allow: true } };
|
||||
if (!game.phaseOpening && !game.decision && game.phase === 0 && game.stormPending === null) {
|
||||
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
|
||||
if (actor) return { actor, action: { type: 'stormDial', amount: game.turn === 1 ? 0 : 1 } };
|
||||
@@ -45,12 +47,15 @@ export function createHomeworldEcazOccupyFixture(options: {
|
||||
tech?: boolean; strongholds?: boolean;
|
||||
/** No-World control uses the same genuine alliance/Arrakis route. */
|
||||
homeworlds?: boolean;
|
||||
/** Original paid Ecaz packet; the default source keeps its four-counter army. */
|
||||
ecazForces?: number;
|
||||
/** Select only actual offered setup choices; the captured initial stays untouched. */
|
||||
finishSetup?: (initial: Game) => Game;
|
||||
} = {}): HomeworldEcazOccupyFixture {
|
||||
const advanced = options.advanced ?? true;
|
||||
const homeworlds = options.homeworlds ?? true;
|
||||
const ally = options.allyFaction ?? 'emperor';
|
||||
const ecazForces = options.ecazForces ?? 4;
|
||||
let game = createGame('WORLDECAZOCCUPY', newPlayer('ecaz', 'Ecaz', 'ecaz'), advanced, ['ecaz']);
|
||||
joinGame(game, newPlayer(ally, ally === 'emperor' ? 'Emperor' : 'Moritani', ally));
|
||||
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
|
||||
@@ -102,7 +107,7 @@ export function createHomeworldEcazOccupyFixture(options: {
|
||||
const actor = game.active!;
|
||||
if (strongholdTerritory && actor === 'ecaz' && !game.players.find(player => player.id === actor)!.shipped) {
|
||||
ship(actor, { type: 'ship', territory: strongholdTerritory,
|
||||
sector: territory(strongholdTerritory).sectors[0], amount: 4, elite: 0, allyPayment: 0, smuggler: false });
|
||||
sector: territory(strongholdTerritory).sectors[0], amount: ecazForces, elite: 0, allyPayment: 0, smuggler: false });
|
||||
reach(homeworldEcazClean);
|
||||
}
|
||||
act(actor, { type: 'endMovement' });
|
||||
@@ -128,7 +133,7 @@ export function createHomeworldEcazOccupyFixture(options: {
|
||||
const actor = game.active!;
|
||||
if (!shipped.has(actor) && !(strongholdTerritory && actor === 'ecaz')) {
|
||||
ship(actor, { type: 'ship', territory: battleTerritory, sector,
|
||||
amount: actor === 'guild' ? 2 : 4,
|
||||
amount: actor === 'ecaz' ? ecazForces : actor === 'guild' ? 2 : 4,
|
||||
elite: actor === ally && ally === 'emperor' && (advanced || homeworlds) ? 2 : 0, allyPayment: 0 });
|
||||
shipped.add(actor);
|
||||
reach(homeworldEcazClean);
|
||||
@@ -143,5 +148,5 @@ export function createHomeworldEcazOccupyFixture(options: {
|
||||
if (!options.skills) assert.equal(game.decision?.kind, 'ecazBattleLead');
|
||||
return { game, initial, afterSetup, actions, afterFirstMentat, strongholdTerritory,
|
||||
ecaz: 'ecaz', ally, opponent: 'guild',
|
||||
territory: battleTerritory, location, ecazForces: 4 };
|
||||
territory: battleTerritory, location, ecazForces };
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, type Action, type Game } from '../game/engine';
|
||||
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import {
|
||||
createHomeworldEcazOccupyFixture,
|
||||
type HomeworldEcazStep,
|
||||
} from './fixture-homeworld-ecaz-occupy';
|
||||
import { truthMovementInventory, truthMovementPlayer } from './fixture-truth-movement';
|
||||
|
||||
export type LowEcazBattleLeadStep = HomeworldEcazStep;
|
||||
export const lowEcazBattleLeadPlayer = truthMovementPlayer;
|
||||
export const lowEcazBattleLeadInventory = truthMovementInventory;
|
||||
|
||||
/** Select only actual original scalar lotteries. Event UUID entropy remains native. */
|
||||
function originalLotteries<T>(run: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto);
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = 0xffffffff;
|
||||
else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return run(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
export function actLowEcazBattleLead(game: Game, actor: string, action: Action,
|
||||
steps: LowEcazBattleLeadStep[] = []): Game {
|
||||
const after = originalLotteries(() => applyAction(game, actor, action));
|
||||
steps.push({ actor, action: structuredClone(action) }); return after;
|
||||
}
|
||||
|
||||
/** The caller owns the real lead choice and unsealed plans. Original responses
|
||||
* may pass; optional Atreides/Emperor battle preparations are declined. */
|
||||
export function nextLowEcazBattleLead(game: Game): LowEcazBattleLeadStep | null {
|
||||
if (game.decision?.kind === 'ecazBattleLead') return null;
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function advanceLowEcazBattleLead(game: Game, until: (state: Game) => boolean,
|
||||
steps: LowEcazBattleLeadStep[] = []): Game {
|
||||
for (let count = 0; count < 500; count++) {
|
||||
if (until(game)) return game;
|
||||
const next = nextLowEcazBattleLead(game);
|
||||
assert.ok(next, 'Choose the original battle lead or seal a real plan before continuing');
|
||||
game = actLowEcazBattleLead(game, next.actor, next.action, steps);
|
||||
}
|
||||
throw Error('The original Low Ecaz battle did not reach its finite continuation boundary');
|
||||
}
|
||||
export function passLowEcazBattleLead(game: Game, steps: LowEcazBattleLeadStep[] = []): Game {
|
||||
for (let count = 0; game.response && count < 80; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actLowEcazBattleLead(game, actor.id, { type: 'passResponse' }, steps);
|
||||
}
|
||||
assert.ok(!game.response); return game;
|
||||
}
|
||||
export function openLowEcazBattleLeadPlans(game: Game, steps: LowEcazBattleLeadStep[] = []): Game {
|
||||
return advanceLowEcazBattleLead(game, state => !!state.battle && !state.response &&
|
||||
!state.decision && !state.battle.preparation && (!state.battle.preLeader || state.battle.preLeader.closed), steps);
|
||||
}
|
||||
export function chooseLowEcazBattleLead(game: Game, lead: string,
|
||||
steps: LowEcazBattleLeadStep[] = []): Game {
|
||||
const decision = game.decision; assert.ok(decision?.kind === 'ecazBattleLead');
|
||||
return actLowEcazBattleLead(game, decision.player,
|
||||
{ type: 'decision', event: decision.event, lead }, steps);
|
||||
}
|
||||
|
||||
/** Public Basic/Advanced E3 + Homeworlds source. Ecaz's real setup leaves 14
|
||||
* native reserves. An actual seven/eight-counter paid city shipment leaves
|
||||
* exactly seven/six; a genuine worm Nexus earns its Emperor alliance first.
|
||||
* Only original unopened deal/Spice order and scalar lotteries are selected. */
|
||||
export function createLowEcazBattleLeadFixture(options: { advanced?: boolean; reserves?: 6 | 7 } = {}) {
|
||||
const advanced = options.advanced ?? true, reserves = options.reserves ?? 6;
|
||||
const setupSteps: LowEcazBattleLeadStep[] = [];
|
||||
const dealOrder: { position: number; from: number; card: string }[] = [];
|
||||
let beforeStartingDeal: Game | undefined;
|
||||
let originalInventory: string[] = [], karama = '';
|
||||
const f = originalLotteries(() => createHomeworldEcazOccupyFixture({
|
||||
advanced, ecazForces: 14 - reserves,
|
||||
finishSetup(initial) {
|
||||
let game = structuredClone(initial);
|
||||
assert.equal(game.status, 'setup'); assert.ok(game.homeworlds);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
originalInventory = lowEcazBattleLeadInventory(game);
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 2);
|
||||
const counter = game.deck.find(card => card.effect === 'karama');
|
||||
assert.equal(ordinary.length, 2); assert.ok(counter); karama = counter.id;
|
||||
for (const [position, card] of [...ordinary, counter].entries()) {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
dealOrder.push({ position, from, card: card.id });
|
||||
}
|
||||
beforeStartingDeal = structuredClone(game);
|
||||
for (let count = 0; count < 160 && game.status === 'setup'; count++) {
|
||||
const next = game.setupStage === 'forces' && lowEcazBattleLeadPlayer(game, 'ecaz').reserves === 20
|
||||
? { actor: 'ecaz', action: { type: 'ecazSetup', placements: { 'imperial_basin:9': 6 } } }
|
||||
: nextStrongholdFactionsNativeStep(game);
|
||||
assert.ok(next, 'The original native Ecaz setup must have its next real control');
|
||||
game = actLowEcazBattleLead(game, next.actor, next.action, setupSteps);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.deepEqual(lowEcazBattleLeadPlayer(game, 'ecaz').forces, { 'imperial_basin:9': 6 });
|
||||
assert.equal(lowEcazBattleLeadPlayer(game, 'ecaz').reserves, 14);
|
||||
assert.deepEqual(lowEcazBattleLeadPlayer(game, 'guild').hand.map(card => card.id), [karama]);
|
||||
return game;
|
||||
},
|
||||
}));
|
||||
assert.ok(beforeStartingDeal);
|
||||
const startAt = f.actions.findIndex(step => step.action.type === 'start'); assert.ok(startAt >= 0);
|
||||
const actions = [...f.actions.slice(0, startAt + 1), ...setupSteps, ...f.actions.slice(startAt + 1)];
|
||||
const ecaz = lowEcazBattleLeadPlayer(f.game, f.ecaz);
|
||||
assert.equal(ecaz.reserves, reserves); assert.equal(ecaz.forces[f.location], 14 - reserves);
|
||||
assert.equal(ecaz.ally, f.ally); assert.equal(lowEcazBattleLeadPlayer(f.game, f.ally).ally, f.ecaz);
|
||||
assert.ok(lowEcazBattleLeadPlayer(f.game, f.opponent).hand.some(card => card.id === karama));
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 6);
|
||||
assert.ok(f.game.decision?.kind === 'ecazBattleLead');
|
||||
assert.deepEqual(lowEcazBattleLeadInventory(f.game), originalInventory);
|
||||
homeworldGameIntegrity(f.game);
|
||||
const event = f.game.decision.event, source = structuredClone(f.game);
|
||||
const leadActions = f.game.decision.choices.map(lead => ({
|
||||
actor: f.game.decision!.player,
|
||||
action: { type: 'decision', event, lead } as Action,
|
||||
}));
|
||||
const shipments = actions.filter(step => step.action.type === 'ship');
|
||||
return {
|
||||
...f, game: f.game, source, beforeChoice: source, beforeStartingDeal, advanced, reserves,
|
||||
ecaz: f.ecaz, ally: f.ally, opponent: f.opponent, holder: f.opponent, karama,
|
||||
chooser: f.game.decision.player, event, leadActions,
|
||||
cancelAction: { type: 'card', card: karama, mode: 'cancel' } as Action,
|
||||
actions, steps: actions, setupSteps, shipments,
|
||||
allianceSteps: actions.filter(step => step.action.type === 'alliance'),
|
||||
dealOrder, originalInventory, originalCards: originalInventory, sourceActionCount: actions.length,
|
||||
originalBalances: Object.fromEntries(f.afterSetup.players.map(player => [player.id, player.spice])),
|
||||
balances: Object.fromEntries(f.game.players.map(player => [player.id, player.spice])),
|
||||
staging: [
|
||||
'Native public Basic/Advanced Ecaz/Emperor/Guild with actual Homeworlds lobby setting and original E3 base 33-card deck. Only three unopened original deal positions, original scalar lotteries and the existing native fixture’s unopened land/worm Spice order are selected. Guild receives its own original Karama. Ecaz actually places six original counters in Imperial Basin, earns its Emperor alliance by actual proposal/acceptance at the second-turn worm Nexus, then pays for one original city shipment of eight counters (seven at the High boundary). Emperor and Guild make their own original paid typed shipments. No bribe, army, reserve, wallet, phase, alliance, hand, receipt, population or lead decision is installed. The source stops at the real chooseBattle-created lead decision; its actual decision.player remains authoritative.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type LowEcazBattleLeadFixture = ReturnType<typeof createLowEcazBattleLeadFixture>;
|
||||
@@ -0,0 +1,145 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { ecazOccupyLeadOptions } from '../game/ecaz-occupy-options';
|
||||
import { homeworldGameIntegrity, homeworldTable } from '../game/homeworld-game';
|
||||
import {
|
||||
createLowEcazBattleLeadFixture as fixture,
|
||||
actLowEcazBattleLead as act,
|
||||
chooseLowEcazBattleLead as choose,
|
||||
openLowEcazBattleLeadPlans as openPlans,
|
||||
lowEcazBattleLeadInventory as inventory,
|
||||
lowEcazBattleLeadPlayer as player,
|
||||
} from './fixture-low-ecaz-battle-lead';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function physical(game: Game) {
|
||||
return game.players.map(seat => ({
|
||||
id: seat.id, reserves: seat.reserves, tanks: seat.tanks, forces: structuredClone(seat.forces),
|
||||
elites: structuredClone(seat.elites), spice: seat.spice, ally: seat.ally,
|
||||
leaders: structuredClone(seat.leaders),
|
||||
}));
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
const mode = advanced ? 'Advanced' : 'Basic';
|
||||
|
||||
void test(`${mode} native Low Ecaz lets its actual opponent select either coalition lead`, () => {
|
||||
const f = fixture({ advanced });
|
||||
assert.equal(f.game.homeworlds !== null, true); assert.deepEqual(f.game.expansions, ['ecaz']);
|
||||
assert.ok(!f.game.nexusCards); assert.ok(!f.game.leaderSkills);
|
||||
assert.equal(player(f.afterSetup, f.ecaz).reserves, 14);
|
||||
assert.equal(player(f.afterSetup, f.ecaz).spice, 12);
|
||||
assert.equal(player(f.game, f.ecaz).reserves, 6);
|
||||
assert.equal(player(f.game, f.ecaz).spice, 4);
|
||||
assert.equal(player(f.game, f.ecaz).forces[f.location], 8);
|
||||
assert.deepEqual(f.allianceSteps, [
|
||||
{ actor: f.ecaz, action: { type: 'alliance', target: f.ally } },
|
||||
{ actor: f.ally, action: { type: 'alliance', target: f.ecaz } },
|
||||
]);
|
||||
const shipment = f.shipments.find(step => step.actor === f.ecaz); assert.ok(shipment);
|
||||
assert.equal(shipment.action.amount, 8); assert.ok(shipment.action.homeworldSources);
|
||||
assert.equal(f.setupSteps.some(step => step.actor === f.ecaz && step.action.type === 'ecazSetup'), true);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
const home = homeworldTable(f.game)!.find(home => home.native === f.ecaz)!;
|
||||
assert.equal(home.population, 6); assert.equal(home.side, 'low');
|
||||
assert.ok(f.game.decision?.kind === 'ecazBattleLead');
|
||||
assert.equal(f.game.decision.player, f.opponent); assert.equal(f.game.decision.lowEcaz, true);
|
||||
assert.equal(f.game.decision.event, f.game.battle!.event);
|
||||
const options = ecazOccupyLeadOptions(viewGame(f.game, f.opponent));
|
||||
assert.deepEqual(options.map(option => option.lead), [f.ecaz, f.ally]);
|
||||
assert.ok(options.every(option => option.blocked === null));
|
||||
assert.ok(ecazOccupyLeadOptions(viewGame(f.game, f.ecaz)).every(option => option.blocked !== null));
|
||||
for (const lead of [f.ecaz, f.ally]) {
|
||||
const selected = choose(f.game, lead);
|
||||
assert.equal(selected.battle!.ecazOccupy!.lead, lead);
|
||||
assert.equal(selected.response?.kind, 'ecazOccupy'); assert.equal(selected.response?.owner, f.ecaz);
|
||||
assert.deepEqual(physical(selected), physical(f.game));
|
||||
const game = openPlans(selected), profile = viewGame(game, lead).battle!.ecazOccupy!.profile!;
|
||||
assert.equal(profile.planOwner, lead); assert.equal(profile.payer, lead);
|
||||
assert.equal(profile.forceOwner, f.ally); assert.equal(profile.fixedEcazDial, 4);
|
||||
assert.equal(profile.ecazForces.normal, 8); assert.equal(profile.canceled, false);
|
||||
assert.ok([game.battle!.attacker, game.battle!.defender].includes(lead));
|
||||
assert.ok([game.battle!.attacker, game.battle!.defender].includes(f.opponent));
|
||||
assert.deepEqual(inventory(game), f.originalInventory); homeworldGameIntegrity(game);
|
||||
}
|
||||
});
|
||||
|
||||
void test(`${mode} native Low Ecaz rejects Ecaz self-selection and a stale opponent event`, () => {
|
||||
const f = fixture({ advanced }), action = f.leadActions[0].action;
|
||||
reject(f.game, f.ecaz, action); reject(f.game, f.ally, action);
|
||||
reject(f.game, f.opponent, { ...action, event: `${f.event}:stale` });
|
||||
reject(f.game, f.opponent, { ...action, lead: f.opponent });
|
||||
const selected = choose(f.game, f.ally);
|
||||
assert.equal(selected.battle!.ecazOccupy!.lead, f.ally);
|
||||
reject(selected, f.opponent, { ...action, lead: f.ecaz });
|
||||
assert.equal(selected.battle!.ecazOccupy!.lead, f.ally);
|
||||
});
|
||||
|
||||
void test(`${mode} native seven-reserve Ecaz retains its ordinary battle-lead choice`, () => {
|
||||
const f = fixture({ advanced, reserves: 7 });
|
||||
assert.equal(player(f.game, f.ecaz).reserves, 7);
|
||||
assert.equal(player(f.game, f.ecaz).forces[f.location], 7);
|
||||
assert.equal(player(f.game, f.ecaz).spice, 5);
|
||||
const home = homeworldTable(f.game)!.find(home => home.native === f.ecaz)!;
|
||||
assert.equal(home.population, 7); assert.equal(home.side, 'high');
|
||||
assert.ok(f.game.decision?.kind === 'ecazBattleLead');
|
||||
assert.equal(f.game.decision.player, f.ecaz); assert.equal(f.game.decision.lowEcaz, undefined);
|
||||
reject(f.game, f.opponent, f.leadActions[0].action);
|
||||
const selected = choose(f.game, f.ally);
|
||||
assert.equal(selected.battle!.ecazOccupy!.lead, f.ally);
|
||||
assert.equal(selected.response?.kind, 'ecazOccupy');
|
||||
assert.deepEqual(inventory(selected), f.originalInventory);
|
||||
});
|
||||
|
||||
void test(`${mode} native Guild Karama cancels Occupy while preserving either opponent-selected lead`, () => {
|
||||
const f = fixture({ advanced });
|
||||
assert.deepEqual(player(f.afterSetup, f.holder).hand.map(card => card.id), [f.karama]);
|
||||
for (const lead of [f.ecaz, f.ally]) {
|
||||
let game = choose(f.game, lead);
|
||||
game = act(game, f.holder, f.cancelAction);
|
||||
game = openPlans(game);
|
||||
assert.equal(game.battle!.ecazOccupy!.lead, lead);
|
||||
assert.equal(game.battle!.ecazOccupy!.canceled, true);
|
||||
const profile = viewGame(game, lead).battle!.ecazOccupy!.profile!;
|
||||
assert.equal(profile.planOwner, lead); assert.equal(profile.payer, lead);
|
||||
assert.equal(profile.forceOwner, lead); assert.equal(profile.fixedEcazDial, 0);
|
||||
assert.equal(profile.forces.normal, lead === f.ecaz ? 8 : 2);
|
||||
assert.equal(profile.forces.elite, lead === f.ecaz ? 0 : 2);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.equal(player(game, f.holder).hand.some(card => card.id === f.karama), false);
|
||||
assert.deepEqual(physical(game), physical(f.game));
|
||||
assert.equal(game.decision?.kind, undefined);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); homeworldGameIntegrity(game);
|
||||
}
|
||||
});
|
||||
|
||||
void test(`${mode} all four native opponent policies make a legal Low Ecaz lead selection`, () => {
|
||||
const f = fixture({ advanced });
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(f.game, f.opponent);
|
||||
view.players.find(seat => seat.id === f.opponent)!.bot = difficulty;
|
||||
const before = structuredClone(view), actions = botActions(view);
|
||||
assert.ok(actions.length > 0, difficulty);
|
||||
assert.deepEqual(view, before);
|
||||
for (const action of actions) {
|
||||
assert.equal(action.type, 'decision', difficulty);
|
||||
assert.equal(action.event, f.event, difficulty);
|
||||
assert.ok(action.lead === f.ecaz || action.lead === f.ally, difficulty);
|
||||
const selected = act(f.game, f.opponent, action);
|
||||
assert.equal(selected.battle!.ecazOccupy!.lead, action.lead, difficulty);
|
||||
assert.equal(selected.response?.kind, 'ecazOccupy', difficulty);
|
||||
assert.deepEqual(inventory(selected), f.originalInventory);
|
||||
}
|
||||
const ecazView = viewGame(f.game, f.ecaz);
|
||||
ecazView.players.find(seat => seat.id === f.ecaz)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(ecazView), []);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user