diff --git a/components/ambassador-board-markers.tsx b/components/ambassador-board-markers.tsx new file mode 100644 index 00000000..c89539f3 --- /dev/null +++ b/components/ambassador-board-markers.tsx @@ -0,0 +1,42 @@ +import { MOBILE_STRONGHOLD, territory } from '@/game/board'; +import { faction } from '@/game/catalog'; +import type { AmbassadorToken } from '@/game/ecaz-ambassadors'; + +/** Placed Ambassador faces are public. Unplaced custody never appears on the map. */ +export function AmbassadorBoardMarkers({ + tokens, +}: { + tokens: readonly AmbassadorToken[]; +}) { + return ( + <> + {tokens.flatMap((token) => { + if (token.zone !== 'placed' || !token.location) return []; + const place = territory(token.location); + const identity = faction(token.effect); + const label = `${identity.name} Ambassador in ${place.name}`; + // Separate Ambassadors from the force row, spice and hidden Terror markers. + // The HMS has its own wide box, with room beside its native force row. + const offset = token.location === MOBILE_STRONGHOLD ? [65, 24] : [-22, -59]; + return [ + + {label} + + + + {identity.sigil} + + + Amb + + , + ]; + })} + + ); +} diff --git a/components/ecaz-ambassadors.tsx b/components/ecaz-ambassadors.tsx index 68523f11..f8dad6db 100644 --- a/components/ecaz-ambassadors.tsx +++ b/components/ecaz-ambassadors.tsx @@ -19,14 +19,14 @@ import { AMBASSADOR_COMMON_GUIDANCE, AMBASSADOR_REFERENCE, } from '@/game/ambassador-reference'; -import { TERRITORIES } from '@/game/board'; +import { territory } from '@/game/board'; import { faction } from '@/game/catalog'; export const AMBASSADOR_COVERAGE = - 'Development coverage: Ambassador placement, Ecaz’s direct Duke acquisition and consensual alliance, Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen, Guild and Tleilaxu entry effects are supported, plus Bene Gesserit copies. The Tleilaxu effect offers physical forces or a first-death own leader, including Atreides’ separate Kwisatz Haderach token. Basic offers an optional new-ally Duke loan; Advanced automatically grants the available Duke after accepted alliance consent. One-battle loan tenure is provisional; an unused living loan is set aside at turn end. Ordinary revival accounting, repeat-return/shared-leader cases and competing arrivals remain for refinement; full Ecaz starts remain disabled.'; + 'Development coverage: Ambassador placement, including the placed Hidden Mobile Stronghold, Ecaz’s direct Duke acquisition and consensual alliance, Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen, Guild and Tleilaxu entry effects are supported, plus Bene Gesserit copies. The Tleilaxu effect offers physical forces or a first-death own leader, including Atreides’ separate Kwisatz Haderach token. Basic offers an optional new-ally Duke loan; Advanced automatically grants the available Duke after accepted alliance consent. One-battle loan tenure is provisional; an unused living loan is set aside at turn end. Ordinary revival accounting, repeat-return/shared-leader cases and competing arrivals remain for refinement; Ecaz starts remain labelled previews.'; function tokenLocation(token: AmbassadorToken) { if (token.zone === 'placed') - return `Placed in ${TERRITORIES.find((t) => t.id === token.location)?.name ?? 'a stronghold'}`; + return `Placed in ${token.location ? territory(token.location).name : 'a stronghold'}`; return { pool: 'Unused pool', supply: 'Available supply', @@ -211,6 +211,40 @@ export function EcazPlacement({ const id = useId(); const [selectedToken, setSelectedToken] = useState(''); const [selectedTerritory, setSelectedTerritory] = useState(''); + return ( + + ); +} + +/** Uses only the current projected supply and destinations, including the placed HMS. */ +export function EcazPlacementChoice({ + game, + act, + busy, + id, + selectedToken, + selectedTerritory, + onSelectToken, + onSelectTerritory, +}: { + game: GameView; + act: (action: Action) => void; + busy: boolean; + id: string; + selectedToken: string; + selectedTerritory: string; + onSelectToken: (token: string) => void; + onSelectTerritory: (territory: string) => void; +}) { const me = game.players.find((player) => player.id === game.me); const state = game.ambassadors; if ( @@ -221,14 +255,7 @@ export function EcazPlacement({ ) return null; const supply = state.tokens.filter((token) => token.zone === 'supply'); - const destinations = TERRITORIES.filter( - (territory) => - territory.type === 'stronghold' && - !territory.sectors.includes(game.storm) && - !state.tokens.some( - (token) => token.zone === 'placed' && token.location === territory.id, - ), - ); + const destinations = state.destinations; const token = supply.find((candidate) => candidate.id === selectedToken); const destination = destinations.find( (candidate) => candidate.id === selectedTerritory, @@ -254,7 +281,7 @@ export function EcazPlacement({ className="min-h-11 w-full" value={token?.id ?? ''} disabled={busy || state.blocked} - onChange={(event) => setSelectedToken(event.target.value)} + onChange={(event) => onSelectToken(event.target.value)} > {supply.map((candidate) => ( @@ -279,12 +306,13 @@ export function EcazPlacement({ className="min-h-11 w-full" value={destination?.id ?? ''} disabled={busy || state.blocked} - onChange={(event) => setSelectedTerritory(event.target.value)} + onChange={(event) => onSelectTerritory(event.target.value)} > - + {destinations.map((candidate) => ( ))} @@ -301,7 +329,7 @@ export function EcazPlacement({ )} {!destinations.length && (

- No ordinary stronghold is currently available for an Ambassador. + No stronghold is currently available for an Ambassador.

)} {!affordable && ( @@ -312,6 +340,7 @@ export function EcazPlacement({ )}
diff --git a/components/game-table.tsx b/components/game-table.tsx index 33feb207..d28d51a7 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -50,6 +50,7 @@ import { NexusSuboids } from './nexus-suboids'; import { NexusAdvisors } from './nexus-advisors'; import { NexusSardaukar } from './nexus-sardaukar'; import { TerrorBoardMarkers } from './terror-board-markers'; +import { AmbassadorBoardMarkers } from './ambassador-board-markers'; import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options'; import { GuildHomeworldShipment } from './guild-homeworld-shipment'; import { JunctionTransport } from './junction-transport'; @@ -1837,6 +1838,7 @@ export function GameTable({ })} + {Object.entries(g.spice) .filter(([, n]) => n > 0) .map(([k, n]) => { diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 69b5fc87..2ec63bb1 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,26 @@ open mode gates. ## Current checkpoint and work +**Ecaz Ambassador on the placed HMS — 24 October. Prototyped / Partial.** +The shared placement quote now includes the genuinely placed Hidden Mobile +Stronghold in Basic and Advanced, including its intrinsic storm shelter. +Original supply, increasing fee, occupied-token exclusion, placement Karama and +entrant continuation remain. A face-up board marker stays attached to the HMS +interior through native movement; controls, projection and minimal bots share +available destinations. CHOAM/Ix discard continuations accept that real location. +[Source composition and bounded proof](ECAZ_AMBASSADORS_RULES.md#hms-implementation-and-proof). + +Disjoint quote/source/controls lanes and bounded source/continuation review pass +**12 files135/135**, types/lint/build, twelve gameplay/control/GameTable SSR +journeys and eight existing-profile continuations (eight accepted proposals). +Actual three-seat public setup and original lotteries supply46 Basic or50 +Advanced actions before paid placement. Entry, Emperor5/decline, original held +Karama, storm-point shelter, native next-Storm relocation and Basic card effects +have focused evidence. Feedback repaired source-fixture assumptions and seven +floating test registrations. No direct-shipment/Terror permission, complete-mode +certificate, full AI refinement or saved-room/D1/reset/restart/deployment operation. +Broader combinations and comprehensive/browser/deployed acceptance remain open. + **Basic Guild Ambassador No-Field — 24 October. Prototyped / Partial.** Native Richese0/3/5 can replace the whole independent grant with one concealed free marker. Allied use now has actual supplier choice and beneficiary consent, diff --git a/docs/ECAZ_AMBASSADORS_RULES.md b/docs/ECAZ_AMBASSADORS_RULES.md index 8d436574..fd693f70 100644 --- a/docs/ECAZ_AMBASSADORS_RULES.md +++ b/docs/ECAZ_AMBASSADORS_RULES.md @@ -2,6 +2,93 @@ Audit: 2026-09-06. Scope: ambassador inventory, setup, placement, triggering, effects, and necessary continuation boundaries. This is a developer audit, not complete Ecaz support. Only this document was written. +## Placed Hidden Mobile Stronghold — 24 October 2026 + +The source permission now composes with the actual placed HMS in Basic and +Advanced. Publisher E3 physical p7 permits placement in any stronghold without +an Ambassador outside storm; the authorized revised source's physical p30,A +uses the same placement language. Publisher E1 p9 identifies the HMS as a +stronghold and protects it from storms and worms. E3 p5 expressly excludes +Terror from the HMS but supplies no corresponding Ambassador exclusion. +[Official E3](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf), +[official E1](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf). + +This is composition of those general permissions and intrinsic shelter, not a +new designer-specific HMS/Ambassador ruling. A bounded fresh official-index +search reproduced these passages; direct E3 PDF retrieval returned403. The +user-supplied revised page is used under its already recorded source authority, +without republishing it. No priority, price, faction admission or optional-module +ruling follows from this change. + +A legitimately placed HMS with a printed non-stronghold pointer is eligible, +even when that pointing sector is in storm. An absent/malformed pointer cannot +create a location. Placement does not require Ecaz forces or Ixian control of +the stronghold. Preserve one token per stronghold, the original supply and +increasing1/2/3 fees, original placement opportunity and Karama cancellation. +Other dynamic territories do not inherit this permission automatically. + +The token belongs to the HMS interior, not its pointing territory. Existing +entry exclusions and effect choices apply to an actual entry. Leaving it hidden +keeps it attached when Ix moves the HMS; movement does not place or charge it +again. Direct shipment by other factions remains prohibited under E1; this +change supplies no transport permission. Moritani Terror remains excluded. + +The focused implementation uses one shared location/placement quote for the +engine, available-control projection and cancellation. HMS-origin card discard +continuations use that same real stronghold location. Public board markers and +inspectors identify the attached face; no private event or hand is displayed. +Complete modes, broader child/optional combinations and final assurance remain +open; the checkpoint and verification section below records bounded evidence. + +### HMS implementation and proof + +The focused helper is `game/ecaz-ambassador-placement.ts`; engine projection, +placement cancellation and minimal bots consume its eligible destinations. +`EcazPlacementChoice` supplies actual paid/finish callbacks and busy guards. +`AmbassadorBoardMarkers` places public face-up identities on printed strongholds +or the HMS interior; full table rendering includes that layer. The shared +fresh-discard location validator admits actual HMS CHOAM/Ix parents without +changing event, token, beneficiary, effect or original entrant continuation. + +The source fixture uses actual public Ecaz/Ix/Guild setup and private Traitor +choices. It selects only three original unopened Treachery positions, the +native Ix private choice, nine original Ecaz packet scalars and four unopened +ordinary Spice positions. Native Storm, forces, HMS placement and phases run +normally:46 Basic or50 Advanced source actions before paid placement. No played +clock, army, alliance, token or pending receipt is installed. Guild pays for +one force at the pointer, then legally moves into HMS sector0; the Emperor +trigger gains its printed5 bank spice. The alternate branch declines and both +resume the original entrant. A separate original held Ix Karama prevents +placement before payment; native next-Storm movement keeps an untriggered +Ambassador attached. Basic CHOAM/Ix exercise their actual card continuations. + +Frozen proof passes **12 affected offline files135/135**, types/lint and +production build. Twelve actual placement-control/gameplay/GameTable SSR +journeys cover both modes, storm-point shelter, cancellation and relocation, +plus Basic CHOAM/Ix. Eight existing-profile own-view continuations accept eight +legal proposals, with no strategy refinement or difficulty calibration. +Bounded independent source/continuation review found no concrete blocker. +The first real46-action source reproduced the old static-stronghold rejection. +Feedback fixed missing fixture expansion flags, absent optional-field assertions, +an invalid Imperial Basin pointer and seven floating test registrations; +no rule was loosened to accommodate a fixture. + +Private evidence: +`/home/quintine/.local/share/dune/checkpoints/20261024-ecaz-hms-ambassador/`, +including `frozen-integrated-2/report.json`, `runtime-smoke.json` and HTML. +Verified base `659e2a4a9a8443785372d652133cdc4a11aca969`, source tree +`09336c7c223d21b46f32cc625df3a8d4ffd362df8c2664d3aa29d540317bdcce` +(2686 nonignored files, identical before/after checks). Only checkpoint prose, +reference strings and evidence paths follow that proof; delivery records the +reviewed overlay and pushed revision separately. The user PDF stays untracked. + +Framework aliases and CSS stubs mean this is control callback/gameplay/SSR +proof, not browser visual or deployed acceptance. Other effects/copies, +optional modules and wider HMS combinations need their own integration proof; +complete modes, comprehensive rules/recovery/privacy/custody assurance and +final deployed acceptance remain unfinished. No Terror/transport permission, +server operation, saved-game/D1/reset/restart or deployment occurs here. + ## Primary evidence and precedence - **E3:** [GF9 Ecaz & Moritani rulebook](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf), printed p.4 components; pp.7–8 Ecaz; p.9 Duke Vidal; p.15 built-in Ecaz FAQ; p.16 Karama table. Targeted searches retrieved the publisher's indexed text. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 692eada1..63a34aa3 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,31 @@ # Dune implementation status +## 24 October 2026 — Ecaz Ambassador on the placed HMS + +Share dynamic placement legality between the engine, projected choices, +placement cancellation and minimal bots. A legitimately placed HMS is an +eligible sheltered stronghold; absent/malformed pointers remain invalid. +Preserve physical supply, increasing fee, occupancy and original Karama event. +Public face-up markers use the HMS interior identity through native relocation. +CHOAM/Ix discard continuations recognize that same actual stronghold location. +Source composition does not extend Terror or other factions' direct shipment. + +Disjoint quote/source/controls lanes and bounded review pass12 files135/135, +types/lint/build, twelve gameplay/control/GameTable SSR journeys and eight +existing-profile continuations accepting eight proposals. Genuine public +Ecaz/Ix/Guild setup, original undealt-card choices/lotteries and native HMS +placement supply46 Basic or50 Advanced actions. Actual callbacks exercise +paid placement and busy guards; entry/Emperor5/decline, held Karama, +storm-point shelter, native next-Storm relocation and Basic CHOAM/Ix effects +retain original forces/payment/entrant. Fixture feedback repairs absent optional +fields, missing expansion flags and an invalid pointer; lint repairs seven +floating test registrations. No installed played phase, army or reaction receipt. + +[Source, exact proof and limitations](ECAZ_AMBASSADORS_RULES.md#hms-implementation-and-proof) +retain wider-combination, complete-mode and comprehensive/browser/deployed gates. +Private final proof:20261024-ecaz-hms-ambassador/frozen-integrated-2. +No strategy/calibration or saved-room/D1/reset/restart/deployment operation. + ## 24 October 2026 — Basic Guild Ambassador No-Field Connect native concealed0/3/5 and explicit allied supplier/beneficiary consent diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 1a740e9a..eb462911 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,15 @@ # Rule decision index +## Ecaz Ambassador on the placed HMS — 24 October 2026 + +Compose E3 any-stronghold placement (p7; authorized revised p30,A) with E1 +p9 HMS identity and intrinsic storm shelter. The token attaches to the HMS +interior, retaining original fees, trigger exclusions and native relocation; +no other faction gains direct shipment. E3 expressly excludes Terror instead. +No direct HMS/Ambassador clarification was found in the bounded official +search; this is general source composition, without a new priority or numerical +ruling. [Source, permission and boundaries](ECAZ_AMBASSADORS_RULES.md#placed-hidden-mobile-stronghold--24-october-2026). + ## Provisional Basic Guild Ambassador No-Field composition — 24 October 2026 Use the 14 October choose-recommended authority for the previously gated diff --git a/game/bots.ts b/game/bots.ts index 2e3413f3..ab297d41 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1814,15 +1814,7 @@ function policyActions(g: GameView): Action[] { ambassadors.nextCost <= (me.spice ?? 0) ) { const token = ambassadors.tokens.find((t) => t.zone === 'supply'); - const target = TERRITORIES.find( - (t) => - t.type === 'stronghold' && - t.id !== MOBILE_STRONGHOLD && - !t.sectors.includes(g.storm) && - !ambassadors.tokens.some( - (a) => a.zone === 'placed' && a.location === t.id, - ), - ); + const target = ambassadors.destinations[0]; if (token && target) return [{ type: 'decision', token: token.id, territory: target.id }]; } diff --git a/game/ecaz-ambassador-placement.ts b/game/ecaz-ambassador-placement.ts new file mode 100644 index 00000000..107122f1 --- /dev/null +++ b/game/ecaz-ambassador-placement.ts @@ -0,0 +1,81 @@ +import type { Game, Player } from './engine'; +import { MOBILE_STRONGHOLD, TERRITORIES, territory } from './board'; +import { placeAmbassador } from './ecaz-ambassadors'; + +export type AmbassadorPlacementBoard = Pick< + Game, + 'turn' | 'storm' | 'mobileStronghold' | 'ecazAmbassadors' +>; + +function mobileStrongholdPlaced(g: AmbassadorPlacementBoard): boolean { + const pointer = g.mobileStronghold?.location; + return ( + typeof pointer === 'string' && + TERRITORIES.some( + (destination) => + destination.type !== 'stronghold' && + destination.sectors.some( + (sector) => pointer === `${destination.id}:${sector}`, + ), + ) + ); +} + +/** Compose E3 p.7's any-stronghold permission with E1 p.9's sheltered HMS. + * This is a general-rule composition, not a separate HMS/Ambassador ruling. + */ +export function ambassadorPlacementDestination( + g: AmbassadorPlacementBoard, + to: string, +): { id: string; stronghold: boolean; inStorm: boolean; allowed: boolean } { + const destination = TERRITORIES.find((candidate) => candidate.id === to); + const mobile = to === MOBILE_STRONGHOLD && mobileStrongholdPlaced(g); + return { + id: to, + stronghold: mobile || destination?.type === 'stronghold', + inStorm: mobile ? false : !destination || destination.sectors.includes(g.storm), + allowed: mobile || !!destination, + }; +} + +/** Location choices only; token supply, funds and the placement turn are quoted + * separately. Other dynamic locations do not inherit the HMS permission. + */ +export function ambassadorPlacementDestinations( + g: AmbassadorPlacementBoard, +): { id: string; name: string; sheltered: boolean }[] { + const destinations = TERRITORIES.filter( + (destination) => destination.type === 'stronghold', + ).map(({ id, name }) => ({ id, name, sheltered: false })); + if (mobileStrongholdPlaced(g)) + destinations.push({ + id: MOBILE_STRONGHOLD, + name: territory(MOBILE_STRONGHOLD).name, + sheltered: true, + }); + return destinations.filter(({ id }) => { + const destination = ambassadorPlacementDestination(g, id); + return ( + destination.stronghold && + destination.allowed && + !destination.inStorm && + !g.ecazAmbassadors?.tokens.some( + (token) => token.zone === 'placed' && token.location === id, + ) + ); + }); +} + +export function quoteEcazAmbassadorPlacement( + g: AmbassadorPlacementBoard, + owner: Pick, + token: string, + to: string, +): ReturnType { + if (!g.ecazAmbassadors) throw new Error('No Ambassador supply is available.'); + return placeAmbassador(g.ecazAmbassadors, token, { + turn: g.turn, + availableSpice: owner.spice, + destination: ambassadorPlacementDestination(g, to), + }); +} diff --git a/game/engine.ts b/game/engine.ts index 10b539d3..fe959f8d 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -143,6 +143,7 @@ import { import { homeworldRevivalDestinations, quoteHomeworldRevivalDestination } from './homeworld-revival-destinations'; import { terrorEntrySignature, terrorSelectionSignature, validateTerrorEntrySignature } from './terror-entry-receipt'; import { arrivalOverlapSignature, overlapOwnsAmbassadorDiscard, overlapOwnsTerrorDiscard, overlapOwnsWormTerror } from './arrival-overlap'; +import { ambassadorPlacementDestination, ambassadorPlacementDestinations, quoteEcazAmbassadorPlacement } from './ecaz-ambassador-placement'; import { GuildAmbassadorMarkerError, markerDestinations, markerOwner, quoteGuildAmbassadorMarker, type GuildAmbassadorMarker, type GuildAmbassadorMarkerQuote } from './guild-ambassador-no-field'; import { @@ -468,7 +469,6 @@ import { import { validateAmbassadors, createAmbassadors, - placeAmbassador, triggerAmbassador, destroyAmbassador, replenishAmbassadors, @@ -7119,12 +7119,9 @@ function treacheryDiscardIntegrity(g: Game) { ['choam', 'ixians'].includes(entry.effect!) && ['none', 'wormRide'].includes(entry.resume) && validAmbassadorResume(g, entry) && - TERRITORIES.some( - (t) => - t.id === entry.territory && - t.type === 'stronghold' && - t.sectors.includes(entry.sector), - ) && + ambassadorPlacementDestination(g, entry.territory).allowed && + ambassadorPlacementDestination(g, entry.territory).stronghold && + validGameLocation(g, entry.territory, entry.sector) && owner?.faction === 'ecaz' && beneficiary && (beneficiary.id === owner.id || @@ -21167,19 +21164,9 @@ function ambassadorPlacementQuote( token: string, to: string, ) { - const destination = TERRITORIES.find((t) => t.id === to); requireRule(g.ecazAmbassadors, 'No Ambassador supply is available.'); try { - return placeAmbassador(g.ecazAmbassadors, token, { - turn: g.turn, - availableSpice: owner.spice, - destination: { - id: to, - stronghold: destination?.type === 'stronghold', - allowed: !!destination, - inStorm: !destination || destination.sectors.includes(g.storm), - }, - }); + return quoteEcazAmbassadorPlacement(g, owner, token, to); } catch (error) { throw new RuleError( error instanceof Error ? error.message : 'Invalid Ambassador placement.', @@ -36277,6 +36264,7 @@ export function viewGame(state: Game, id: string) { ambassadors: g.ecazAmbassadors ? { tokens: g.ecazAmbassadors.tokens.map((token) => ({ ...token })), + destinations: ambassadorPlacementDestinations(g), nextCost: (g.ecazAmbassadors.placement?.turn === g.turn ? g.ecazAmbassadors.placement.count diff --git a/game/placement-cancellation.ts b/game/placement-cancellation.ts index 6bb6a9ba..38fdf40a 100644 --- a/game/placement-cancellation.ts +++ b/game/placement-cancellation.ts @@ -1,6 +1,6 @@ import { terrorLocationAllowed, type TerrorLocationContext } from './terror-location'; import type { Game, ResponseWindow } from './engine'; -import { TERRITORIES } from './board'; +import { ambassadorPlacementDestination } from './ecaz-ambassador-placement'; import { blockAmbassadorPlacement, type AmbassadorState, @@ -31,6 +31,8 @@ export type PlacementCancellationContext = Pick< | 'status' | 'phase' | 'turn' + | 'storm' + | 'mobileStronghold' | 'players' | 'pendingEcazPlacement' | 'ecazPlacementTurn' @@ -89,9 +91,8 @@ export function quotePlacementCancellation( Array.isArray(state.tokens) && Array.isArray(state.cohort) && state.tokens.filter((t) => t.id === pending.token).length === 1 && - TERRITORIES.some( - (t) => t.id === pending.territory && t.type === 'stronghold', - ) && + ambassadorPlacementDestination(g, pending.territory).allowed && + ambassadorPlacementDestination(g, pending.territory).stronghold && whole(pending.cost) && pending.cost > 0, 'No current physical Ambassador placement is awaiting cancellation.', diff --git a/game/reference.ts b/game/reference.ts index 00a7e349..7d75ff1d 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1543,43 +1543,47 @@ export const RULE_TOPICS: RuleTopic[] = [ '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.', 'In bare Basic Richese/Ecaz tables, No-Field may replace the whole Guild grant under an explicit provisional policy: concealed native0/3/5 counts as one and ships free; allied use reveals immediately within four available counters and keeps the one/two-spice transport payment to Guild or Bank. Richese chooses its token/payer and the beneficiary must accept before transfer. Prior native revelation, shared token history and physical fallback after scoped prevention remain; ordinary allowances are unchanged. Advanced/optional profiles remain separate. 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. A Terror entry now waits for the arrival’s own interaction — Guild income, a Bene Gesserit fighter Intrusion or advisor choice, or Fremen storm protection — and then opens without replaying payment or force transfer. Ambassador-created child arrivals retain their own contracts. The native-module extension now composes ordinary paid arrival order with Atomics, Homeworlds and Nexus; other combinations and complete rules remain unfinished.', 'The Tleilaxu Ambassador returns one eligible own leader or up to four physical forces free. First-death leaders and the first-killed Atreides KH need not wait for the ordinary cohort. Under the provisional repeat interpretation, later native leaders must satisfy their ordinary death condition, and later KH returns must meet their saved next-cohort marker. Preserve the actual opening before resurrection; KH advances its marker beyond both owner and old KH cycles without resetting activation losses or adding a native disc. Ecaz retains native identity bookkeeping. Ecaz may instead return the shared Duke alive and unclaimed under his independent permission; its ally cannot inherit that permission. Native and BG copied grants share eligibility and exclusive alternatives. A positive return provisionally earns seated Tleilaxu one bank spice per beneficiary/turn, sharing ordinary free revival; Ghola stays separate. Canceling income keeps the return and spent entitlement. Ordinary allowance independence and wider custody/module combinations remain provisional.', - '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, and Basic even-force Occupy now composes the same shared controls. Remaining effects, exceptional Duke custody and complete Ecaz rules remain unfinished. Mobile stronghold placement is gated. Normal public Ecaz starts remain development previews; complete rules and unsupported combinations stay gated.', + '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, and Basic even-force Occupy now composes the same shared controls. Remaining effects, exceptional Duke custody and complete Ecaz rules remain unfinished. Ambassadors can be placed on the genuinely placed Hidden Mobile Stronghold, including when its pointer is in storm: the interior is sheltered. Its face-up marker stays attached through Ixian movement. Placement still pays the increasing fee and can be prevented by Karama. This supplies no Terror or direct-shipment permission. Normal public Ecaz starts remain development previews; complete rules and unsupported combinations stay gated.', ], checklist: [ { area: 'Implementation', status: 'Partial', detail: - 'Basic Guild No-Field now connects native concealment, allied request/supply/consent, immediate materialization, provisional transport payment, source-scoped prevention and original arrival. A shared ordinary death-cohort quote admits eligible native/KH repeats without importing normal phase/slot/price or native early-service gates into the independent grant. Preserve actual openings before native resurrection, retain Ecaz identity tracking and advance KH above its actual cycle while keeping five native discs and loss activation. Existing Duke, source-bound income and original entrant continuation remain; wider custody/modules stay partial.', - evidence: ['game/engine.ts', 'game/guild-ambassador-no-field.ts', 'game/guild-ambassador-continuation.ts', 'game/revival.ts', 'game/tleilaxu-ambassador-leaders.ts', 'game/tleilaxu-ambassador-income.ts', 'game/karama-context.ts', 'game/ecaz-duke-revival.ts', 'game/bot-arrival.ts'], + 'Placed HMS now shares one eligible-destination quote across paid placement, projection, cancellation and minimal bots; its CHOAM/Ix card parents retain the actual entrant. Basic Guild No-Field now connects native concealment, allied request/supply/consent, immediate materialization, provisional transport payment, source-scoped prevention and original arrival. A shared ordinary death-cohort quote admits eligible native/KH repeats without importing normal phase/slot/price or native early-service gates into the independent grant. Preserve actual openings before native resurrection, retain Ecaz identity tracking and advance KH above its actual cycle while keeping five native discs and loss activation. Existing Duke, source-bound income and original entrant continuation remain; wider custody/modules stay partial.', + evidence: ['game/ecaz-ambassador-placement.ts', 'game/engine.ts', 'game/guild-ambassador-no-field.ts', 'game/guild-ambassador-continuation.ts', 'game/revival.ts', 'game/tleilaxu-ambassador-leaders.ts', 'game/tleilaxu-ambassador-income.ts', 'game/karama-context.ts', 'game/ecaz-duke-revival.ts', 'game/bot-arrival.ts'], }, { area: 'Player controls', status: 'Partial', detail: - 'Persistent No-Field controls show exact marker/destination/payment choices, provisional guidance, busy reasons and both refusal routes, including a native zero-reserve grant. Persistent free-return guidance explains first-return and later-cohort eligibility, KH’s separate marker, ordinary-slot independence and the provisional shared income. Original exact native/KH/Duke buttons, busy controls, force alternatives and committed-return/income continuation remain. Earlier mobile arrival evidence stays separate.', - evidence: ['components/ecaz-entry.tsx', 'components/ambassador-shipment.tsx', 'components/ambassador-no-field.tsx', 'components/game-table.tsx'], + 'Placement controls show the available sheltered HMS; public face-up board markers stay on its interior through native relocation. Persistent No-Field controls show exact marker/destination/payment choices, provisional guidance, busy reasons and both refusal routes, including a native zero-reserve grant. Persistent free-return guidance explains first-return and later-cohort eligibility, KH’s separate marker, ordinary-slot independence and the provisional shared income. Original exact native/KH/Duke buttons, busy controls, force alternatives and committed-return/income continuation remain. Earlier mobile arrival evidence stays separate.', + evidence: ['components/ecaz-ambassadors.tsx', 'components/ambassador-board-markers.tsx', 'components/ecaz-entry.tsx', 'components/ambassador-shipment.tsx', 'components/ambassador-no-field.tsx', 'components/game-table.tsx'], }, { area: 'AI', status: 'Partial', detail: - 'Eight new existing-profile continuations accept12 legal proposals from native-empty or allied-request states, without strategy/calibration. Twelve existing legal profiles select repeated native/copy/KH choices and finish the original entrant, using server-private eligibility. Earlier original Duke/income and placement/consent evidence remains separate. No strategy refinement or strength calibration.', + 'Eight HMS-entry existing-profile continuations accept eight legal proposals across Basic and Advanced. Eight earlier No-Field existing-profile continuations accept12 legal proposals from native-empty or allied-request states, without strategy/calibration. Twelve existing legal profiles select repeated native/copy/KH choices and finish the original entrant, using server-private eligibility. Earlier original Duke/income and placement/consent evidence remains separate. No strategy refinement or strength calibration.', evidence: ['game/bots.ts', 'tests/ambassador-revival-income-engine.test.ts', 'tests/duke-ambassador-engine.test.ts', 'docs/TLEILAXU_AMBASSADOR_RULES.md'], }, { area: 'Documentation', status: 'Partial', detail: - 'Guild No-Field now distinguishes the selected Basic cap/fee policy from printed authority, independent allowances and remaining Advanced/module gates. Source-bound effects, copies and Duke guides remain. Native own-arrival child-first/live-token order stays provisional. Separate native transport permission, independent purchased typed prices and own/allied marker billing preserve original sources and approvals. Immune Junction now keeps its offered tariff/payees with an explicit unused-purchase choice. Cunning opportunity scope, special-stop card return and unused-price expiry are selected interpretations; full combinations remain open.', + 'The placed-HMS permission records general stronghold/storm-shelter source composition without implying a designer-specific ruling or new transport/Terror permission. Guild No-Field now distinguishes the selected Basic cap/fee policy from printed authority, independent allowances and remaining Advanced/module gates. Source-bound effects, copies and Duke guides remain. Native own-arrival child-first/live-token order stays provisional. Separate native transport permission, independent purchased typed prices and own/allied marker billing preserve original sources and approvals. Immune Junction now keeps its offered tariff/payees with an explicit unused-purchase choice. Cunning opportunity scope, special-stop card return and unused-price expiry are selected interpretations; full combinations remain open.', evidence: ['docs/ECAZ_AMBASSADORS_RULES.md', 'docs/GUILD_AMBASSADOR_IMPLEMENTATION.md', 'docs/GUILD_AMBASSADOR_RULES.md', 'docs/TLEILAXU_AMBASSADOR_RULES.md', 'docs/DEFERRED_SHIPMENT_ARRIVAL.md', 'docs/BOT_ARRIVAL_PREFLIGHT.md'], }, { area: 'Verification', status: 'Partial', detail: - 'Current Basic Guild-marker proof passes13 files129/129, types/lint/build, fifteen gameplay/control/GameTable SSR journeys and eight minimal-policy continuations. Genuine53/71/74-action sources plus two explicitly conserved shortage arrangements cover native0/3/5, prior reveal, consent, Guild/Bank fee, prevention/refusal and physical fallback. Earlier repeat13-file114/114, types/lint/build and three controlled gameplay/rendering programmes pass: original Basic paid-entry sources plus assigned native histories reach the next actual ordinary Revival; separate Advanced actual entry returns KH with old2/cohort3 → marker4. Twelve legal policies finish original entry. First-opening, income allow/cancel and ordinary controls pass. Controlled histories are not earned repeat-battle/full-game proof. Earlier original Duke/income evidence stays separate; full modes, wider combinations, assurance and deployed acceptance remain open.', + 'Placed-HMS proof passes12 files135/135, types/lint/build, twelve actual placement-control/gameplay/GameTable SSR journeys and eight minimal policies. Actual46/50-action Basic/Advanced sources cover paid placement, entry/Emperor5/decline, held Karama, storm shelter and native relocation; Basic CHOAM/Ix retain original card continuations. Browser/deployed and complete-mode acceptance remain open. Earlier Basic Guild-marker proof passes13 files129/129, types/lint/build, fifteen gameplay/control/GameTable SSR journeys and eight minimal-policy continuations. Genuine53/71/74-action sources plus two explicitly conserved shortage arrangements cover native0/3/5, prior reveal, consent, Guild/Bank fee, prevention/refusal and physical fallback. Earlier repeat13-file114/114, types/lint/build and three controlled gameplay/rendering programmes pass: original Basic paid-entry sources plus assigned native histories reach the next actual ordinary Revival; separate Advanced actual entry returns KH with old2/cohort3 → marker4. Twelve legal policies finish original entry. First-opening, income allow/cancel and ordinary controls pass. Controlled histories are not earned repeat-battle/full-game proof. Earlier original Duke/income evidence stays separate; full modes, wider combinations, assurance and deployed acceptance remain open.', evidence: [ + 'tests/fixture-ecaz-hms-ambassador.ts', + 'tests/ecaz-hms-ambassador-engine.test.ts', + 'tests/ecaz-hms-ambassador-controls.test.tsx', + 'tests/ecaz-hms-placement-quote.test.ts', 'tests/ambassador-repeat-leader-engine.test.ts', 'tests/fixture-ambassador-repeat-leaders.ts', 'tests/tleilaxu-ambassador-leaders.test.ts', diff --git a/tests/ecaz-hms-ambassador-controls.test.tsx b/tests/ecaz-hms-ambassador-controls.test.tsx new file mode 100644 index 00000000..75815fad --- /dev/null +++ b/tests/ecaz-hms-ambassador-controls.test.tsx @@ -0,0 +1,212 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Children, createElement, isValidElement, type ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { AmbassadorBoardMarkers } from '../components/ambassador-board-markers'; +import { + AmbassadorInspector, + AmbassadorSupply, + EcazPlacement, + EcazPlacementChoice, +} from '../components/ecaz-ambassadors'; +import { MOBILE_STRONGHOLD, territory } from '../game/board'; +import { faction } from '../game/catalog'; +import { createAmbassadors, type AmbassadorToken } from '../game/ecaz-ambassadors'; +import { + createGame, + joinGame, + newPlayer, + viewGame, + type Action, + type GameView, +} from '../game/engine'; + +// These projected-state fixtures isolate the client contract. The source suite +// separately starts Ixians/Ecaz and performs actual HMS placement and entry. +function projected(placed = true): GameView { + const game = createGame('ECAZHMSCONTROLS', newPlayer('e', 'Ecaz', 'ecaz'), false, ['ix', 'ecaz']); + joinGame(game, newPlayer('i', 'Ixians', 'ixians')); + Object.assign(game, { + status: 'playing', phase: 4, turn: 3, storm: 9, + order: ['e', 'i'], decision: { kind: 'ecazPlacement', player: 'e' }, + mobileStronghold: { location: placed ? 'imperial_basin:9' : null }, + ecazAmbassadors: createAmbassadors(() => 0), + }); + game.players[0].spice = 6; + const view = viewGame(game, 'e'); + view.ambassadors!.nextCost = 2; + return view; +} + +function markup(game: GameView, busy = false) { + return renderToStaticMarkup(createElement(EcazPlacement, { + game, busy, act() {}, + })); +} + +function click(node: ReactNode, selector: string): boolean { + for (const child of Children.toArray(node)) { + if (!isValidElement<{ + children?: ReactNode; + onClick?: () => void; + 'data-testid'?: string; + }>(child)) continue; + if (child.props['data-testid'] === selector && child.props.onClick) { + child.props.onClick(); + return true; + } + if (click(child.props.children, selector)) return true; + } + return false; +} + +function choose( + game: GameView, + selectedToken = game.ambassadors!.tokens.find((token) => token.zone === 'supply')!.id, + selectedTerritory = MOBILE_STRONGHOLD, + busy = false, + finish = false, +): Action | undefined { + let action: Action | undefined; + const tree = EcazPlacementChoice({ + game, busy, id: 'ecaz-placement-test', selectedToken, selectedTerritory, + onSelectToken() {}, onSelectTerritory() {}, + act(value) { action = value; }, + }); + click(tree, finish ? 'ecaz-ambassador-placement-finish' : 'ecaz-ambassador-placement-submit'); + return action; +} + +void test('placement offers a projected placed HMS as sheltered even when its attached sector is in the storm', () => { + const view = projected(); + assert.ok(view.ambassadors!.destinations.some((destination) => + destination.id === MOBILE_STRONGHOLD && destination.sheltered, + )); + const html = markup(view); + assert.match(html, /value="hidden_mobile_stronghold">Hidden Mobile Stronghold · sheltered from the storm<\/option>/); + assert.match(html, /Next placement: 2 spice<\/strong>/); + assert.match(html, /Your available spice: 6/); + assert.match(html, /Finish Ambassador placement/); + assert.doesNotMatch(markup(projected(false)), /value="hidden_mobile_stronghold"/); +}); + +void test('placement consumes only authoritative available destinations and never reconstructs missing strongholds', () => { + const view = projected(); + view.ambassadors!.destinations = view.ambassadors!.destinations.filter( + (destination) => destination.id === MOBILE_STRONGHOLD, + ); + const html = markup(view); + assert.match(html, /value="hidden_mobile_stronghold"/); + assert.doesNotMatch(html, /]*value="(?:arrakeen|carthag)"/); + view.ambassadors!.destinations = []; + assert.doesNotMatch(markup(view), /value="hidden_mobile_stronghold"/); + assert.match(markup(view), /No stronghold is currently available for an Ambassador/); + assert.equal(choose(view), undefined); +}); + +void test('actual placement callback preserves the projected HMS and supply token with the current price', () => { + const view = projected(); + const token = view.ambassadors!.tokens.find((candidate) => candidate.zone === 'supply')!; + assert.deepEqual(choose(view, token.id), { + type: 'decision', token: token.id, territory: MOBILE_STRONGHOLD, + }); + const selected = renderToStaticMarkup(createElement(EcazPlacementChoice, { + game: view, busy: false, id: 'selected', selectedToken: token.id, + selectedTerritory: MOBILE_STRONGHOLD, onSelectToken() {}, + onSelectTerritory() {}, act() {}, + })); + assert.match(selected, /value="hidden_mobile_stronghold" selected=""/); + assert.match(selected, /Place Ambassador · 2 spice/); + assert.deepEqual(choose(view, token.id, MOBILE_STRONGHOLD, false, true), { + type: 'decision', decline: true, + }); +}); + +void test('busy, blocked, insufficient spice, stale choices and unavailable supply never dispatch placement', () => { + const view = projected(); + const token = view.ambassadors!.tokens.find((candidate) => candidate.zone === 'supply')!.id; + assert.equal(choose(view, token, MOBILE_STRONGHOLD, true), undefined); + assert.equal(choose(view, token, MOBILE_STRONGHOLD, true, true), undefined); + assert.equal(choose(view, ''), undefined); + assert.equal(choose(view, 'stale-token'), undefined); + assert.equal(choose(view, token, ''), undefined); + assert.equal(choose(view, token, 'stale-destination'), undefined); + for (const field of ['blocked', 'spice', 'supply', 'destination'] as const) { + const changed = structuredClone(view); + if (field === 'blocked') changed.ambassadors!.blocked = true; + if (field === 'spice') changed.players[0].spice = 1; + if (field === 'supply') changed.ambassadors!.tokens.find((candidate) => candidate.id === token)!.zone = 'used'; + if (field === 'destination') changed.ambassadors!.destinations = []; + assert.equal(choose(changed, token), undefined, field); + assert.deepEqual(choose(changed, token, MOBILE_STRONGHOLD, false, true), { + type: 'decision', decline: true, + }, field); + } + const poor = structuredClone(view); + poor.players[0].spice = 1; + assert.match(markup(poor), /You need 1 more spice for this placement/); + assert.equal((markup(view, true).match(/<(?:select|button)\b[^>]*\sdisabled=""/g) ?? []).length, 4); +}); + +void test('placement controls retain their actual Ecaz actor and pending decision boundary', () => { + for (const field of ['viewer', 'actor', 'decision', 'ambassadors'] as const) { + const view = projected(); + const token = view.ambassadors!.tokens.find((candidate) => candidate.zone === 'supply')!.id; + if (field === 'viewer') view.me = 'i'; + if (field === 'actor') view.decision!.player = 'i'; + if (field === 'decision') view.decision = null; + if (field === 'ambassadors') view.ambassadors = null; + assert.equal(markup(view), '', field); + assert.equal(choose(view, token), undefined, field); + } +}); + +void test('public token inspection names an HMS-attached Ambassador and remains read-only for every viewer', () => { + const token: AmbassadorToken = { + id: 'ambassador-emperor', effect: 'emperor', zone: 'placed', location: MOBILE_STRONGHOLD, + }; + const html = renderToStaticMarkup(createElement(AmbassadorInspector, { token })); + assert.match(html, /Inspect Emperor Ambassador: Placed in Hidden Mobile Stronghold/); + assert.match(html, /Placed in Hidden Mobile Stronghold/); + assert.doesNotMatch(html, /Placed in a stronghold/); + for (const viewer of ['e', 'i']) { + const view = projected(); + view.me = viewer; + view.ambassadors!.tokens = [token]; + const supply = renderToStaticMarkup(createElement(AmbassadorSupply, { game: view })); + assert.match(supply, /Placed in Hidden Mobile Stronghold/); + assert.doesNotMatch(supply, /Place Ambassador ·|Finish Ambassador placement/); + } +}); + +void test('public map marks all placed faction faces at their actual territory, including the static HMS box', () => { + const tokens: AmbassadorToken[] = [ + { id: 'ambassador-emperor', effect: 'emperor', zone: 'placed', location: MOBILE_STRONGHOLD }, + { id: 'ambassador-atreides', effect: 'atreides', zone: 'placed', location: 'arrakeen' }, + { id: 'ambassador-harkonnen', effect: 'harkonnen', zone: 'placed', location: 'carthag' }, + { id: 'ambassador-ecaz', effect: 'ecaz', zone: 'supply', location: null }, + { id: 'ambassador-guild', effect: 'guild', zone: 'pool', location: null }, + { id: 'ambassador-richese', effect: 'richese', zone: 'used', location: null }, + { id: 'ambassador-beneGesserit', effect: 'beneGesserit', zone: 'removed', location: null }, + ]; + const html = renderToStaticMarkup(createElement('svg', null, + createElement(AmbassadorBoardMarkers, { tokens }), + )); + assert.equal((html.match(/ candidate.zone === 'placed')) { + const identity = faction(token.effect); + const place = territory(token.location!); + const label = `${identity.name} Ambassador in ${place.name}`; + assert.ok(html.includes(`aria-label="${label}"`)); + assert.ok(html.includes(`${label}`)); + assert.ok(html.includes(`stroke="${identity.color}"`)); + assert.ok(html.includes(`>${identity.sigil}`)); + } + const hms = territory(MOBILE_STRONGHOLD); + assert.ok(html.includes(`translate(${hms.center[0] + 65},${hms.center[1] + 24})`)); + const arrakeen = territory('arrakeen'); + assert.ok(html.includes(`translate(${arrakeen.center[0] - 22},${arrakeen.center[1] - 59})`)); + assert.doesNotMatch(html, /Ecaz Ambassador|Spacing Guild Ambassador|Richese Ambassador|Bene Gesserit Ambassador|supply|pool|used|removed/); + assert.equal((html.match(/>Amb<\/text>/g) ?? []).length, 3); + assert.equal(renderToStaticMarkup(createElement(AmbassadorBoardMarkers, { tokens: [] })), ''); +}); diff --git a/tests/ecaz-hms-ambassador-engine.test.ts b/tests/ecaz-hms-ambassador-engine.test.ts new file mode 100644 index 00000000..748c8677 --- /dev/null +++ b/tests/ecaz-hms-ambassador-engine.test.ts @@ -0,0 +1,173 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame } from '../game/engine'; +import { GRAPH, MOBILE_LOCATION, MOBILE_STRONGHOLD, splitLocation, territory } from '../game/board'; +import { + actEcazHmsAmbassador as act, advanceEcazHmsAmbassador as advance, + createEcazHmsAmbassadorFixture, ecazHmsAmbassadorClean as clean, + ecazHmsAmbassadorInventory as inventory, ecazHmsAmbassadorPlayer as player, + enterEcazHmsAmbassador, passEcazHmsAmbassadorResponses as passResponses, placeEcazHmsAmbassador, + type EcazHmsAmbassadorStep, +} from './fixture-ecaz-hms-ambassador'; + +for (const advanced of [false, true]) { + const mode = advanced ? 'Advanced' : 'Basic'; + void test(`${mode}: native public HMS setup permits paid end-Revival Ambassador placement`, () => { + const fixture = createEcazHmsAmbassadorFixture({ advanced }); + const before = fixture.game, actions: EcazHmsAmbassadorStep[] = []; + assert.equal(fixture.setup.status, 'setup'); + assert.deepEqual(fixture.setup.players.map(player => player.faction), ['ecaz', 'ixians', 'guild']); + assert.equal(fixture.privateChoice.before.decision?.kind, 'ixSetup'); + assert.equal(fixture.hmsPlacement.before.decision?.kind, 'mobileStronghold'); + assert.equal(fixture.hmsPlacement.before.stormResolution, null); + assert.equal(fixture.hmsPlacement.after.mobileStronghold?.location, fixture.point); + assert.equal(before.phase, 4); assert.equal(before.turn, 1); + assert.equal(before.decision?.kind, 'ecazPlacement'); + assert.equal(player(before, fixture.ix).forces[MOBILE_LOCATION], 6); + assert.equal(player(before, fixture.ix).elites?.forces[MOBILE_LOCATION], 3); + assert.ok(viewGame(before, fixture.ecaz).ambassadors!.destinations.some(destination => + destination.id === MOBILE_STRONGHOLD && destination.sheltered)); + const game = placeEcazHmsAmbassador(fixture, actions); + assert.equal(player(game, fixture.ecaz).spice, player(before, fixture.ecaz).spice - 1); + assert.deepEqual(game.ecazAmbassadors!.placement, { turn: 1, count: 1, blocked: false }); + assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === fixture.token)?.location, MOBILE_STRONGHOLD); + assert.equal(game.mobileStronghold?.location, fixture.point); + assert.deepEqual(inventory(game), fixture.originalInventory); + const snapshot = structuredClone(game); + assert.throws(() => act(game, fixture.ecaz, fixture.placement)); + assert.deepEqual(game, snapshot, 'Replaying the placement cannot charge or move a token again'); + assert.ok(!viewGame(game, fixture.ecaz).ambassadors!.destinations.some(destination => destination.id === MOBILE_STRONGHOLD)); + }); + + void test(`${mode}: a legitimately storm-pointed HMS shelters a placed Ambassador`, () => { + const fixture = createEcazHmsAmbassadorFixture({ advanced, inStorm: true }); + assert.equal(splitLocation(fixture.point).sector, fixture.game.storm); + const game = placeEcazHmsAmbassador(fixture); + assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === fixture.token)?.location, MOBILE_STRONGHOLD); + assert.equal(player(game, fixture.ix).forces[MOBILE_LOCATION], 6); + assert.equal(player(game, fixture.ix).elites?.forces[MOBILE_LOCATION], 3); + assert.equal(player(game, fixture.ecaz).spice, player(fixture.game, fixture.ecaz).spice - 1); + }); + + void test(`${mode}: actual paid Guild entry may decline or trigger the HMS Emperor Ambassador once`, () => { + const fixture = createEcazHmsAmbassadorFixture({ advanced }); + const placed = placeEcazHmsAmbassador(fixture); + const continuation: EcazHmsAmbassadorStep[] = []; + const arrival = enterEcazHmsAmbassador(placed, fixture, continuation), before = arrival.game; + assert.equal(player(arrival.shipment.after, fixture.guild).spice, + player(arrival.shipment.before, fixture.guild).spice - 1); + assert.equal(player(arrival.shipment.after, fixture.guild).shipped, true); + assert.equal(player(before, fixture.guild).moved, 1); + assert.equal(before.active, fixture.guild); + assert.equal(before.pendingAmbassador?.token, fixture.token); + assert.equal(viewGame(before, fixture.ecaz).decision?.kind, 'ecazAmbassador'); + assert.equal(viewGame(before, fixture.guild).decision?.player, fixture.ecaz); + const originalArmy = structuredClone(player(before, fixture.guild)); + for (const trigger of [false, true]) { + let game = act(before, fixture.ecaz, trigger + ? { type: 'decision', event: arrival.event, trigger: true, beneficiary: fixture.ecaz } + : { type: 'decision', event: arrival.event, decline: true }); + game = advance(game, clean); + assert.equal(game.pendingAmbassador, null); + assert.equal(player(game, fixture.ecaz).spice, player(before, fixture.ecaz).spice + (trigger ? 5 : 0)); + const marker = game.ecazAmbassadors!.tokens.find(token => token.id === fixture.token)!; + assert.equal(marker.zone, trigger ? 'used' : 'placed'); + assert.equal(marker.location, trigger ? null : MOBILE_STRONGHOLD); + assert.deepEqual(player(game, fixture.guild), originalArmy, 'The original shipment, payment and movement do not replay'); + const snapshot = structuredClone(game); + assert.throws(() => act(game, fixture.ecaz, + { type: 'decision', event: arrival.event, trigger: true, beneficiary: fixture.ecaz })); + assert.deepEqual(game, snapshot); + game = act(game, fixture.guild, fixture.endMovement); + assert.ok(!game.movementRemaining?.includes(fixture.guild)); + assert.equal(player(game, fixture.guild).forces[MOBILE_LOCATION], 1); + assert.deepEqual(inventory(game), fixture.originalInventory); + } + }); + + void test(`${mode}: an untriggered Ambassador stays on the HMS through its real next-Storm relocation`, () => { + const fixture = createEcazHmsAmbassadorFixture({ advanced }); + const actions: EcazHmsAmbassadorStep[] = []; + let game = placeEcazHmsAmbassador(fixture, actions); + game = act(game, fixture.ecaz, fixture.finishPlacement, actions); + game = advance(game, state => state.turn === 2 && !state.response && !state.phaseOpening && + state.decision?.kind === 'mobileStronghold' && !state.decision.placement, actions); + assert.equal(game.mobileStronghold?.location, fixture.point); + const destination = GRAPH[fixture.point].find(key => splitLocation(key).sector !== game.storm && + territory(splitLocation(key).territory).type !== 'stronghold'); + assert.ok(destination); + const before = structuredClone(game), route = [fixture.point, destination]; + game = act(game, fixture.ix, { type: 'decision', route, collect: false }, actions); + game = passResponses(game, actions); + assert.equal(game.mobileStronghold?.location, destination); + assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === fixture.token)?.location, MOBILE_STRONGHOLD); + assert.ok(!game.ecazAmbassadors!.tokens.some(token => token.location === fixture.point)); + assert.deepEqual(player(game, fixture.ix).forces, player(before, fixture.ix).forces); + assert.deepEqual(player(game, fixture.ix).elites, player(before, fixture.ix).elites); + assert.deepEqual(inventory(game), fixture.originalInventory); + }); +} + +void test('original held Karama cancels the first HMS placement without paying or moving its token', () => { + const fixture = createEcazHmsAmbassadorFixture({ karama: true }); + assert.ok(fixture.karama); + assert.ok(player(fixture.privateChoice.after, fixture.ix).hand.some(card => card.id === fixture.karama)); + const declared = act(fixture.game, fixture.ecaz, fixture.placement); + assert.equal(declared.response?.kind, 'ecazPlacement'); + assert.deepEqual(declared.pendingEcazPlacement, { + token: fixture.token, territory: MOBILE_STRONGHOLD, turn: 1, cost: 1, + }); + assert.equal(player(declared, fixture.ecaz).spice, player(fixture.game, fixture.ecaz).spice); + let game = act(declared, fixture.ix, { type: 'card', card: fixture.karama, mode: 'cancel' }); + game = advance(game, state => state.phase === 5 && clean(state)); + assert.equal(player(game, fixture.ecaz).spice, player(fixture.game, fixture.ecaz).spice); + const marker = game.ecazAmbassadors!.tokens.find(token => token.id === fixture.token)!; + assert.equal(marker.zone, 'supply'); assert.equal(marker.location, null); + assert.deepEqual(game.ecazAmbassadors!.placement, { turn: 1, count: 0, blocked: true }); + assert.equal(game.ecazPlacementTurn, 1); + assert.ok(game.discard.some(card => card.id === fixture.karama)); + assert.equal(game.mobileStronghold?.location, fixture.point); + assert.deepEqual(inventory(game), fixture.originalInventory); +}); + +void test('a real HMS placement response retains the original fee and commits after its actual passes', () => { + const fixture = createEcazHmsAmbassadorFixture({ karama: true }); + let game = act(fixture.game, fixture.ecaz, fixture.placement); + const beforeSpice = player(game, fixture.ecaz).spice; + assert.equal(game.response?.kind, 'ecazPlacement'); + game = act(game, fixture.ecaz, { type: 'passResponse' }); + assert.equal(player(game, fixture.ecaz).spice, beforeSpice); + assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === fixture.token)?.zone, 'supply'); + game = passResponses(game); + assert.equal(player(game, fixture.ecaz).spice, beforeSpice - 1); + assert.equal(game.ecazAmbassadors!.placement?.count, 1); + assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === fixture.token)?.location, MOBILE_STRONGHOLD); +}); + +for (const effect of ['choam', 'ixians'] as const) { + void test(`actual HMS ${effect} Ambassador discard finishes its own card effect and original entrant`, () => { + const fixture = createEcazHmsAmbassadorFixture({ effect }); + const arrival = enterEcazHmsAmbassador(placeEcazHmsAmbassador(fixture), fixture); + let game = act(arrival.game, fixture.ecaz, { + type: 'decision', event: arrival.event, trigger: true, beneficiary: fixture.ecaz, + }); + assert.equal(game.pendingAmbassador?.stage, 'cards'); + assert.equal(game.pendingAmbassador.effect, effect); + const before = structuredClone(game), card = player(game, fixture.ecaz).hand[0]; assert.ok(card); + game = act(game, fixture.ecaz, { type: 'decision', event: arrival.event, cards: [card.id] }); + game = advance(game, clean); + assert.equal(game.pendingAmbassador, null); + assert.ok(game.discard.some(discarded => discarded.id === card.id)); + assert.equal(player(game, fixture.ecaz).spice, + player(before, fixture.ecaz).spice + (effect === 'choam' ? 3 : 0)); + assert.equal(player(game, fixture.ecaz).hand.length, + player(before, fixture.ecaz).hand.length - (effect === 'choam' ? 1 : 0)); + assert.equal(game.deck.length, before.deck.length - (effect === 'ixians' ? 1 : 0)); + assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === fixture.token)?.zone, 'used'); + assert.deepEqual(player(game, fixture.guild), player(before, fixture.guild)); + assert.deepEqual(inventory(game), fixture.originalInventory); + game = act(game, fixture.guild, fixture.endMovement); + assert.ok(!game.movementRemaining?.includes(fixture.guild)); + assert.equal(player(game, fixture.guild).forces[MOBILE_LOCATION], 1); + }); +} diff --git a/tests/ecaz-hms-placement-quote.test.ts b/tests/ecaz-hms-placement-quote.test.ts new file mode 100644 index 00000000..c4c351df --- /dev/null +++ b/tests/ecaz-hms-placement-quote.test.ts @@ -0,0 +1,163 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { MOBILE_STRONGHOLD as HMS, TERRITORIES } from '../game/board'; +import { + blockAmbassadorPlacement, + createAmbassadors, + placeAmbassador, +} from '../game/ecaz-ambassadors'; +import { + ambassadorPlacementDestination, + ambassadorPlacementDestinations, + quoteEcazAmbassadorPlacement as quote, + type AmbassadorPlacementBoard, +} from '../game/ecaz-ambassador-placement'; + +function fixture(): AmbassadorPlacementBoard { + return { + turn: 3, + storm: 18, + mobileStronghold: { location: 'polar_sink:0' }, + ecazAmbassadors: createAmbassadors(() => 0), + }; +} +function supply(g: AmbassadorPlacementBoard): string[] { + return g.ecazAmbassadors!.tokens + .filter((token) => token.zone === 'supply') + .map((token) => token.id); +} +function rejectUnchanged( + g: AmbassadorPlacementBoard, + token = supply(g)[0], + to = HMS, + spice = 10, + message?: RegExp, +) { + const before = structuredClone(g), owner = { spice }; + if (message) assert.throws(() => quote(g, owner, token, to), message); + else assert.throws(() => quote(g, owner, token, to)); + assert.deepEqual(g, before); + assert.deepEqual(owner, { spice }); +} + +void test('a placed HMS is a sheltered Ambassador destination even when its pointer is in the storm', () => { + for (const pointer of ['polar_sink:0', 'arsunt:11']) { + const g = fixture(), owner = { spice: 1 }; + g.mobileStronghold = { location: pointer }; + g.storm = pointer === 'polar_sink:0' ? 0 : 11; + const before = structuredClone(g), token = supply(g)[0]; + const destination = ambassadorPlacementDestination(g, HMS); + assert.deepEqual(destination, { + id: HMS, stronghold: true, inStorm: false, allowed: true, + }); + assert.deepEqual( + ambassadorPlacementDestinations(g).find((candidate) => candidate.id === HMS), + { id: HMS, name: 'Hidden Mobile Stronghold', sheltered: true }, + ); + const result = quote(g, owner, token, HMS); + assert.deepEqual(result, placeAmbassador(g.ecazAmbassadors!, token, { + turn: g.turn, availableSpice: owner.spice, destination, + })); + assert.equal(result.cost, 1); + assert.equal(result.state.tokens.find((candidate) => candidate.id === token)?.location, HMS); + assert.deepEqual(result.state.placement, { turn: 3, count: 1, blocked: false }); + assert.deepEqual(g, before); + assert.deepEqual(owner, { spice: 1 }); + } +}); + +void test('unplaced and malformed HMS pointers never create an Ambassador destination', () => { + const mobileStates: AmbassadorPlacementBoard['mobileStronghold'][] = [ + undefined, + null, + { location: null }, + ...[ + '', 'missing:0', 'arrakeen:10', `${HMS}:0`, 'cistern:0', + 'arsunt:0', 'arsunt:11:1', 'polar_sink:', 'polar_sink:00', 'arsunt:NaN', + ].map((location) => ({ location })), + ]; + for (const mobileStronghold of mobileStates) { + const g = fixture(); + g.mobileStronghold = mobileStronghold; + assert.deepEqual(ambassadorPlacementDestination(g, HMS), { + id: HMS, stronghold: false, inStorm: true, allowed: false, + }); + assert.equal(ambassadorPlacementDestinations(g).some((to) => to.id === HMS), false); + rejectUnchanged(g, undefined, HMS, 10, /eligible stronghold outside the storm/); + } +}); + +void test('choices retain printed storm legality and admit only the placed HMS among dynamic locations', () => { + const g = fixture(); + g.storm = 11; + g.mobileStronghold = { location: 'arsunt:11' }; + const before = structuredClone(g), choices = ambassadorPlacementDestinations(g); + assert.deepEqual(choices, [ + ...TERRITORIES.filter((to) => to.type === 'stronghold' && !to.sectors.includes(g.storm)) + .map(({ id, name }) => ({ id, name, sheltered: false })), + { id: HMS, name: 'Hidden Mobile Stronghold', sheltered: true }, + ]); + assert.equal(choices.some((to) => to.id === 'carthag'), false); + assert.deepEqual(ambassadorPlacementDestination(g, 'carthag'), { + id: 'carthag', stronghold: true, inStorm: true, allowed: true, + }); + rejectUnchanged(g, undefined, 'carthag', 10, /eligible stronghold outside the storm/); + for (const to of ['imperial_basin', 'polar_sink', 'cistern', 'kaitain', 'missing']) + rejectUnchanged(g, undefined, to, 10, /eligible stronghold outside the storm/); + assert.deepEqual(g, before); +}); + +void test('HMS placement shares ordinary one two three fees without requiring Ecaz forces or control', () => { + const g = fixture(), owner = { spice: 6 }, tokens = supply(g); + for (const [index, to] of ['arrakeen', HMS, 'carthag'].entries()) { + const before = structuredClone(g), balance = owner.spice; + const result = quote(g, owner, tokens[index], to); + assert.equal(result.cost, index + 1); + assert.deepEqual(g, before); + assert.equal(owner.spice, balance); + g.ecazAmbassadors = result.state; + owner.spice -= result.cost; + } + assert.equal(owner.spice, 0); + assert.deepEqual(g.ecazAmbassadors!.placement, { turn: 3, count: 3, blocked: false }); + assert.equal(ambassadorPlacementDestinations(g).some((to) => to.id === HMS), false); + rejectUnchanged(g, tokens[3], 'sietch_tabr', 3, /Not enough spice/); +}); + +void test('one Ambassador of any effect occupies the HMS and an already placed token cannot relocate', () => { + const g = fixture(), tokens = supply(g); + const token = g.ecazAmbassadors!.cohort[0]; + g.ecazAmbassadors = quote(g, { spice: 10 }, token, HMS).state; + assert.equal(ambassadorPlacementDestinations(g).some((to) => to.id === HMS), false); + rejectUnchanged(g, tokens.find((id) => id !== token), HMS, 10, /already contains an Ambassador/); + rejectUnchanged(g, token, 'arrakeen', 10, /placed tokens cannot relocate/); + g.ecazAmbassadors = quote(g, { spice: 10 }, tokens.find((id) => id !== token)!, 'arrakeen').state; + assert.equal(ambassadorPlacementDestinations(g).some((to) => to.id === 'arrakeen'), false); + rejectUnchanged(g, tokens[2], 'arrakeen', 10, /already contains an Ambassador/); +}); + +void test('HMS quotes preserve Karama placement blocking, prior fees and the next-turn reset', () => { + const g = fixture(), tokens = supply(g); + g.ecazAmbassadors = quote(g, { spice: 10 }, tokens[0], 'arrakeen').state; + g.ecazAmbassadors = blockAmbassadorPlacement(g.ecazAmbassadors, g.turn); + assert.deepEqual(g.ecazAmbassadors.placement, { turn: 3, count: 1, blocked: true }); + rejectUnchanged(g, tokens[1], HMS, 10, /blocked for this turn/); + g.turn = 2; + rejectUnchanged(g, tokens[1], HMS, 10, /current Ambassador placement turn/); + g.turn = 4; + const before = structuredClone(g), result = quote(g, { spice: 1 }, tokens[1], HMS); + assert.equal(result.cost, 1); + assert.deepEqual(result.state.placement, { turn: 4, count: 1, blocked: false }); + assert.deepEqual(g, before); +}); + +void test('HMS quotes require the original physical supply and an integer spice balance', () => { + const g = fixture(); + const pool = g.ecazAmbassadors!.tokens.find((token) => token.zone === 'pool')!; + rejectUnchanged(g, pool.id, HMS, 10, /from your supply/); + rejectUnchanged(g, 'missing-token', HMS, 10, /from your supply/); + for (const spice of [0, -1, 0.5, NaN, Infinity]) rejectUnchanged(g, undefined, HMS, spice); + const noSupply = fixture(); + noSupply.ecazAmbassadors = undefined; + rejectUnchanged(noSupply, 'missing-token', HMS, 10, /No Ambassador supply/); +}); diff --git a/tests/fixture-ecaz-hms-ambassador.ts b/tests/fixture-ecaz-hms-ambassador.ts new file mode 100644 index 00000000..0a95b05a --- /dev/null +++ b/tests/fixture-ecaz-hms-ambassador.ts @@ -0,0 +1,241 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine'; +import { MOBILE_LOCATION, MOBILE_STRONGHOLD, TERRITORIES, splitLocation } from '../game/board'; +import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; + +export type EcazHmsAmbassadorStep = { actor: string; action: Action }; +export type EcazHmsAmbassadorTransition = { + before: Game; steps: EcazHmsAmbassadorStep[]; after: Game; +}; +export const ecazHmsAmbassadorPlayer = (game: Game, id: string) => { + const player = game.players.find(player => player.id === id); assert.ok(player); return player; +}; +export const ecazHmsAmbassadorClean = (game: Game) => !game.response && !game.phaseOpening && + !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard; +export const ecazHmsAmbassadorInventory = (game: Game) => { + const cards = [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []), + ...(game.ixAuction?.cards ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? []), + ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), + ...game.players.flatMap(player => player.hand)]; + const ids = cards.map(card => card.id).sort(); + assert.equal(new Set(ids).size, ids.length, 'Original physical Treachery Cards retain distinct current slots'); + return ids; +}; + +/** Select original scalar lotteries only; native UUIDs and byte entropy remain + * untouched. The nine supplied values are consumed by the original Ecaz packet. */ +function originalLottery(run: () => T, rolls: readonly number[] = []): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const original = crypto.getRandomValues.bind(crypto); let cursor = 0; + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array && array.length === 1) + array[0] = cursor < rolls.length ? rolls[cursor++] : 0xffffffff; + else Reflect.apply(original, crypto, [array]); + return array; + }; + try { + const result = run(); assert.equal(cursor, rolls.length, 'Original Ambassador packet consumes its nine selected draws'); + return result; + } finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +export function actEcazHmsAmbassador(game: Game, actor: string, action: Action, + actions: EcazHmsAmbassadorStep[] = []): Game { + const next = originalLottery(() => applyAction(game, actor, action)); + actions.push({ actor, action: structuredClone(action) }); return next; +} +/** Only live native windows advance. Placement, entry and later HMS movement + * are caller-owned controls, never installed fixture state. */ +export function nextEcazHmsAmbassadorStep(game: Game): EcazHmsAmbassadorStep | null { + if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) { + if (game.decision?.kind === 'ecazPlacement' || game.decision?.kind === 'ecazAmbassador' || + game.decision?.kind === 'mobileStronghold') return null; + if (game.status === 'setup' && game.setupStage === 'forces' && + ecazHmsAmbassadorPlayer(game, 'ecaz').reserves === 20) + return { actor: 'ecaz', action: { type: 'ecazSetup', placements: { 'imperial_basin:9': 6 } } }; + if (game.decision?.kind === 'guildTiming') + return { actor: game.decision.player, action: { type: 'decision', take: false } }; + if (game.decision?.kind === 'ixAuction') { + const card = game.ixAuction!.cards[0]; assert.ok(card); + return { actor: game.decision.player, action: { type: 'decision', card: card.id, position: 'bottom' } }; + } + } + return nextRicheseStrongholdsNativeStep(game); +} +export function advanceEcazHmsAmbassador(game: Game, until: (state: Game) => boolean, + actions: EcazHmsAmbassadorStep[] = []): Game { + for (let count = 0; count < 1600; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The genuine HMS chronology ended before its requested control'); + const next = nextEcazHmsAmbassadorStep(game); + assert.ok(next, `Choose the original control before advancing: ${JSON.stringify({ + status: game.status, turn: game.turn, phase: game.phase, active: game.active, decision: game.decision, + })}`); + game = actEcazHmsAmbassador(game, next.actor, next.action, actions); + } + throw Error('The original HMS Ambassador source did not reach its finite boundary'); +} +export function passEcazHmsAmbassadorResponses(game: Game, + actions: EcazHmsAmbassadorStep[] = []): Game { + for (let count = 0; game.response && count < 60; count++) { + const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor); + game = actEcazHmsAmbassador(game, actor.id, { type: 'passResponse' }, actions); + } + assert.equal(game.response, null); return game; +} +function originalPacket(effect: 'emperor' | 'choam' | 'ixians') { + const pool = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz'); + const supply: Exclude[] = [effect, + ...pool.filter(candidate => candidate !== effect).slice(0, 4)]; + const desired = [...supply, ...pool.filter(candidate => !supply.includes(candidate))]; + const current = [...pool], rolls: number[] = []; + for (let i = current.length - 1; i > 0; i--) { + const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i); + rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1))); + [current[i], current[j]] = [current[j], current[i]]; + } + assert.deepEqual(current, desired); assert.equal(rolls.length, 9); return { rolls, supply }; +} + +/** Genuine public bare E1+E3 Basic/Advanced source. Native starting forces, + * private Ixian starting-card choice, first Storm and HMS placement all execute. + * Stop at the actual end-of-Revival opportunity BEFORE Ambassador declaration. + * Only original unopened card/Spice positions and the original packet lottery + * are selected. No army, wallet, alliance, phase, token, receipt or event is set. */ +export function createEcazHmsAmbassadorFixture(options: { + advanced?: boolean; effect?: 'emperor' | 'choam' | 'ixians'; inStorm?: boolean; karama?: boolean; +} = {}) { + const advanced = options.advanced ?? false, effect = options.effect ?? 'emperor'; + const ecaz = 'ecaz', ix = 'ixians', guild = 'guild'; + let game = createGame('ECAZHMSAMBASSADOR', newPlayer(ecaz, 'Ecaz', 'ecaz'), advanced, ['ix', 'ecaz']); + joinGame(game, newPlayer(ix, 'Ixians', 'ixians')); + joinGame(game, newPlayer(guild, 'Guild', 'guild')); + const initial = structuredClone(game), actions: EcazHmsAmbassadorStep[] = []; + const act = (actor: string, action: Action) => { game = actEcazHmsAmbassador(game, actor, action, actions); }; + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); + const setup = structuredClone(game); + assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); + assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && + !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery); + assert.ok(game.players.every(player => player.hand.length === 0)); + const originalInventory = ecazHmsAmbassadorInventory(game); + const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 3); + assert.equal(ordinary.length, 3); + const karamaCard = options.karama ? game.deck.find(card => card.effect === 'karama') : null; + if (options.karama) { assert.ok(karamaCard); ordinary[0] = karamaCard; } + const dealOrder = ordinary.map((card, position) => { + 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]]; + return { position, from, card: card.id }; + }); + const beforeStartingDeal = structuredClone(game), packet = originalPacket(effect); + let packetCreation: EcazHmsAmbassadorTransition | null = null; + let privateChoice: EcazHmsAmbassadorTransition | null = null; + for (let count = 0; game.status === 'setup' && count < 100; count++) { + const next = nextEcazHmsAmbassadorStep(game); assert.ok(next); + const before = structuredClone(game); + const initializesForces = game.setupStage === 'traitors' && next.action.type === 'traitor' && + game.players.filter(player => player.traitorChoices.length > 0).length === 1; + if (initializesForces) { + game = originalLottery(() => applyAction(game, next.actor, next.action), packet.rolls); + actions.push(structuredClone(next)); + packetCreation = { before, steps: [structuredClone(next)], after: structuredClone(game) }; + } else { + act(next.actor, next.action); + if (before.decision?.kind === 'ixSetup') + privateChoice = { before, steps: [structuredClone(next)], after: structuredClone(game) }; + } + } + assert.equal(game.status, 'playing'); assert.ok(packetCreation && privateChoice); + assert.deepEqual(game.ecazAmbassadors!.cohort.map(id => + game.ecazAmbassadors!.tokens.find(token => token.id === id)!.effect), packet.supply); + assert.equal(ecazHmsAmbassadorPlayer(game, ix).forces[MOBILE_LOCATION], 6); + assert.equal(ecazHmsAmbassadorPlayer(game, ix).elites?.forces[MOBILE_LOCATION], 3); + assert.equal(game.mobileStronghold?.location, null); + assert.ok(!options.karama || ecazHmsAmbassadorPlayer(game, ix).hand.some(card => card.id === karamaCard!.id)); + const afterSetup = structuredClone(game), originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + const spiceOrder = [0, 1, 2, 3].map(position => { + const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery); + assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; + game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) }; + }); + assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice); + game = advanceEcazHmsAmbassador(game, state => !state.response && !state.phaseOpening && + state.decision?.kind === 'mobileStronghold' && state.decision.placement, actions); + const point = options.inStorm + ? `${TERRITORIES.find(territory => territory.type !== 'stronghold' && territory.sectors.includes(game.storm))!.id}:${game.storm}` + : 'rock_outcroppings:14'; + const hmsStep: EcazHmsAmbassadorStep = { actor: ix, action: { type: 'decision', location: point } }; + const hmsBefore = structuredClone(game); act(ix, hmsStep.action); + const hmsPlacement: EcazHmsAmbassadorTransition = { before: hmsBefore, steps: [hmsStep], after: structuredClone(game) }; + game = advanceEcazHmsAmbassador(game, state => state.phase === 4 && !state.response && !state.phaseOpening && + state.decision?.kind === 'ecazPlacement', actions); + const token = game.ecazAmbassadors!.tokens.find(token => token.effect === effect)!; + assert.equal(token.zone, 'supply'); assert.equal(token.location, null); + assert.equal(game.ecazAmbassadors!.placement, null); + assert.equal(game.mobileStronghold?.location, point); + assert.equal(ecazHmsAmbassadorPlayer(game, ix).forces[MOBILE_LOCATION], 6); + assert.equal(ecazHmsAmbassadorPlayer(game, ix).elites?.forces[MOBILE_LOCATION], 3); + assert.deepEqual(ecazHmsAmbassadorInventory(game), originalInventory); + const placementStep: EcazHmsAmbassadorStep = { actor: ecaz, + action: { type: 'decision', token: token.id, territory: MOBILE_STRONGHOLD } }; + return { game, initial, lobby, setup, beforeStartingDeal, afterSetup, ecaz, ix, guild, entrant: guild, + token: token.id, effect, advanced, location: MOBILE_STRONGHOLD, interior: MOBILE_LOCATION, + point, pointer: splitLocation(point), actions, originalInventory, originalSpice, dealOrder, spiceOrder, + packetCreation, privateChoice, hmsPlacement, karama: karamaCard?.id ?? null, placementStep, + placement: placementStep.action, finishPlacement: { type: 'decision', decline: true } satisfies Action, + shipment: { type: 'ship', territory: splitLocation(point).territory, sector: splitLocation(point).sector, + amount: 1, elite: 0, allyPayment: 0 } satisfies Action, + movement: { type: 'move', from: point, territory: MOBILE_STRONGHOLD, sector: 0, amount: 1 } satisfies Action, + endMovement: { type: 'endMovement' } satisfies Action, + staging: [ + 'Actual public Ecaz/Ixians/Guild ready/start and private Traitor choices. Select only three original unopened Treachery positions, then choose the native first offered Ixian card; the original remainder shuffle deals the other two cards.', + 'Select exactly nine original Ecaz packet scalars on the final Traitor choice, before native forces and Ambassador inventory are created. Ecaz is the first seat; Ixians and Guild follow.', + 'Select four original unopened ordinary Spice positions. Native first Storm dials, destruction, HMS placement, Spice, Bidding and Revival all execute; no played phase, wallet, army, alliance, token custody, reaction or receipt is assigned.', + 'The source stops before original paid Ambassador placement. HMS starts with its native six counters, including three original Cyborgs. Its source pointer comes from the actual first-Storm placement decision.', + ], + }; +} +export type EcazHmsAmbassadorFixture = ReturnType; + +/** Submit the actual source declaration and settle only its live response. */ +export function placeEcazHmsAmbassador(fixture: EcazHmsAmbassadorFixture, + actions: EcazHmsAmbassadorStep[] = []): Game { + let game = actEcazHmsAmbassador(fixture.game, fixture.ecaz, fixture.placement, actions); + game = passEcazHmsAmbassadorResponses(game, actions); + assert.equal(game.decision?.kind, 'ecazPlacement'); + assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === fixture.token)?.location, MOBILE_STRONGHOLD); + return game; +} +/** Start from a committed real placement. Native earlier turns end normally; + * Guild pays for an outside shipment, then really moves into the HMS interior. + * The original Ecaz entry offer remains a caller-owned decision. */ +export function enterEcazHmsAmbassador(source: Game, fixture: EcazHmsAmbassadorFixture, + actions: EcazHmsAmbassadorStep[] = []) { + let game = actEcazHmsAmbassador(source, fixture.ecaz, fixture.finishPlacement, actions); + game = advanceEcazHmsAmbassador(game, state => state.phase === 5 && state.active === fixture.guild && + ecazHmsAmbassadorClean(state), actions); + const beforeShipment = structuredClone(game), shipmentStart = actions.length; + game = actEcazHmsAmbassador(game, fixture.guild, fixture.shipment, actions); + game = advanceEcazHmsAmbassador(game, ecazHmsAmbassadorClean, actions); + const shipment: EcazHmsAmbassadorTransition = { before: beforeShipment, + steps: structuredClone(actions.slice(shipmentStart)), after: structuredClone(game) }; + assert.equal(ecazHmsAmbassadorPlayer(game, fixture.guild).forces[fixture.point], 1); + assert.ok(!game.pendingAmbassador); + const beforeMovement = structuredClone(game), movementStart = actions.length; + game = actEcazHmsAmbassador(game, fixture.guild, fixture.movement, actions); + game = advanceEcazHmsAmbassador(game, state => !state.response && !state.phaseOpening && + state.pendingAmbassador?.stage === 'offer', actions); + const movement: EcazHmsAmbassadorTransition = { before: beforeMovement, + steps: structuredClone(actions.slice(movementStart)), after: structuredClone(game) }; + assert.equal(game.pendingAmbassador?.territory, MOBILE_STRONGHOLD); + assert.equal(game.pendingAmbassador.entrant, fixture.guild); + assert.equal(game.pendingAmbassador.arrivalCause, 'movement'); + assert.equal(ecazHmsAmbassadorPlayer(game, fixture.guild).forces[MOBILE_LOCATION], 1); + assert.equal(ecazHmsAmbassadorPlayer(game, fixture.guild).forces[fixture.point] ?? 0, 0); + return { game, event: game.pendingAmbassador.event, shipment, movement }; +}