Allow Ecaz Ambassadors on the placed mobile stronghold

Share dynamic stronghold legality across placement, cancellation, bots and controls. Keep the HMS sheltered, preserve original fees and entry effects, and display its public Ambassador attached to the stronghold interior.

Cover genuine Basic/Advanced placement, paid movement entry, card continuations, prevention and native relocation. No Terror or direct-shipment permission is added.
This commit is contained in:
Quintine committed 2026-10-24 17:55:16 +11:00
1 parent 659e2a4a9a
commit 52832dc0fd
16 files changed
+1129 -55

No files matched your search

+42
View File
@@ -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 [
<g
key={token.id}
transform={`translate(${place.center[0] + offset[0]},${place.center[1] + offset[1]})`}
pointerEvents="none"
aria-label={label}
>
<title>{label}</title>
<rect x="10" y="-10" width="37" height="20" rx="5" fill="#182725" stroke={identity.color} />
<circle r="15" fill="#182725" stroke={identity.color} strokeWidth="2" />
<text y="5" textAnchor="middle" fill={identity.color} fontSize="15" fontWeight="700">
{identity.sigil}
</text>
<text x="30" y="4" textAnchor="middle" fill="#f2dfb9" fontSize="10" fontWeight="700">
Amb
</text>
</g>,
];
})}
</>
);
}
+48 -16
View File
@@ -19,14 +19,14 @@ import {
AMBASSADOR_COMMON_GUIDANCE, AMBASSADOR_COMMON_GUIDANCE,
AMBASSADOR_REFERENCE, AMBASSADOR_REFERENCE,
} from '@/game/ambassador-reference'; } from '@/game/ambassador-reference';
import { TERRITORIES } from '@/game/board'; import { territory } from '@/game/board';
import { faction } from '@/game/catalog'; import { faction } from '@/game/catalog';
export const AMBASSADOR_COVERAGE = 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) { function tokenLocation(token: AmbassadorToken) {
if (token.zone === 'placed') 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 { return {
pool: 'Unused pool', pool: 'Unused pool',
supply: 'Available supply', supply: 'Available supply',
@@ -211,6 +211,40 @@ export function EcazPlacement({
const id = useId(); const id = useId();
const [selectedToken, setSelectedToken] = useState(''); const [selectedToken, setSelectedToken] = useState('');
const [selectedTerritory, setSelectedTerritory] = useState(''); const [selectedTerritory, setSelectedTerritory] = useState('');
return (
<EcazPlacementChoice
game={game}
act={act}
busy={busy}
id={id}
selectedToken={selectedToken}
selectedTerritory={selectedTerritory}
onSelectToken={setSelectedToken}
onSelectTerritory={setSelectedTerritory}
/>
);
}
/** 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 me = game.players.find((player) => player.id === game.me);
const state = game.ambassadors; const state = game.ambassadors;
if ( if (
@@ -221,14 +255,7 @@ export function EcazPlacement({
) )
return null; return null;
const supply = state.tokens.filter((token) => token.zone === 'supply'); const supply = state.tokens.filter((token) => token.zone === 'supply');
const destinations = TERRITORIES.filter( const destinations = state.destinations;
(territory) =>
territory.type === 'stronghold' &&
!territory.sectors.includes(game.storm) &&
!state.tokens.some(
(token) => token.zone === 'placed' && token.location === territory.id,
),
);
const token = supply.find((candidate) => candidate.id === selectedToken); const token = supply.find((candidate) => candidate.id === selectedToken);
const destination = destinations.find( const destination = destinations.find(
(candidate) => candidate.id === selectedTerritory, (candidate) => candidate.id === selectedTerritory,
@@ -254,7 +281,7 @@ export function EcazPlacement({
className="min-h-11 w-full" className="min-h-11 w-full"
value={token?.id ?? ''} value={token?.id ?? ''}
disabled={busy || state.blocked} disabled={busy || state.blocked}
onChange={(event) => setSelectedToken(event.target.value)} onChange={(event) => onSelectToken(event.target.value)}
> >
<option value="">Choose from your supply</option> <option value="">Choose from your supply</option>
{supply.map((candidate) => ( {supply.map((candidate) => (
@@ -279,12 +306,13 @@ export function EcazPlacement({
className="min-h-11 w-full" className="min-h-11 w-full"
value={destination?.id ?? ''} value={destination?.id ?? ''}
disabled={busy || state.blocked} disabled={busy || state.blocked}
onChange={(event) => setSelectedTerritory(event.target.value)} onChange={(event) => onSelectTerritory(event.target.value)}
> >
<option value="">Choose a stronghold outside the storm</option> <option value="">Choose an available stronghold</option>
{destinations.map((candidate) => ( {destinations.map((candidate) => (
<option key={candidate.id} value={candidate.id}> <option key={candidate.id} value={candidate.id}>
{candidate.name} {candidate.name}
{candidate.sheltered ? ' · sheltered from the storm' : ''}
</option> </option>
))} ))}
</select> </select>
@@ -301,7 +329,7 @@ export function EcazPlacement({
)} )}
{!destinations.length && ( {!destinations.length && (
<p className="m-0 text-sm leading-6"> <p className="m-0 text-sm leading-6">
No ordinary stronghold is currently available for an Ambassador. No stronghold is currently available for an Ambassador.
</p> </p>
)} )}
{!affordable && ( {!affordable && (
@@ -312,6 +340,7 @@ export function EcazPlacement({
)} )}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
data-testid="ecaz-ambassador-placement-submit"
className="min-h-11 whitespace-normal motion-reduce:transition-none" className="min-h-11 whitespace-normal motion-reduce:transition-none"
disabled={disabled} disabled={disabled}
onClick={() => { onClick={() => {
@@ -326,10 +355,13 @@ export function EcazPlacement({
Place Ambassador · {state.nextCost} spice Place Ambassador · {state.nextCost} spice
</Button> </Button>
<Button <Button
data-testid="ecaz-ambassador-placement-finish"
variant="outline" variant="outline"
className="min-h-11 whitespace-normal motion-reduce:transition-none" className="min-h-11 whitespace-normal motion-reduce:transition-none"
disabled={busy} disabled={busy}
onClick={() => act({ type: 'decision', decline: true })} onClick={() => {
if (!busy) act({ type: 'decision', decline: true });
}}
> >
Finish Ambassador placement Finish Ambassador placement
</Button> </Button>
+2
View File
@@ -50,6 +50,7 @@ import { NexusSuboids } from './nexus-suboids';
import { NexusAdvisors } from './nexus-advisors'; import { NexusAdvisors } from './nexus-advisors';
import { NexusSardaukar } from './nexus-sardaukar'; import { NexusSardaukar } from './nexus-sardaukar';
import { TerrorBoardMarkers } from './terror-board-markers'; import { TerrorBoardMarkers } from './terror-board-markers';
import { AmbassadorBoardMarkers } from './ambassador-board-markers';
import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options'; import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options';
import { GuildHomeworldShipment } from './guild-homeworld-shipment'; import { GuildHomeworldShipment } from './guild-homeworld-shipment';
import { JunctionTransport } from './junction-transport'; import { JunctionTransport } from './junction-transport';
@@ -1837,6 +1838,7 @@ export function GameTable({
})} })}
<NoFieldBoardMarkers players={g.players} /> <NoFieldBoardMarkers players={g.players} />
<TerrorBoardMarkers tokens={g.moritaniTerror?.tokens ?? []} atomics={g.moritaniAtomics} /> <TerrorBoardMarkers tokens={g.moritaniTerror?.tokens ?? []} atomics={g.moritaniAtomics} />
<AmbassadorBoardMarkers tokens={g.ambassadors?.tokens ?? []} />
{Object.entries(g.spice) {Object.entries(g.spice)
.filter(([, n]) => n > 0) .filter(([, n]) => n > 0)
.map(([k, n]) => { .map(([k, n]) => {
+20
View File
@@ -16,6 +16,26 @@ open mode gates.
## Current checkpoint and work ## 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.** **Basic Guild Ambassador No-Field — 24 October. Prototyped / Partial.**
Native Richese0/3/5 can replace the whole independent grant with one concealed 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, free marker. Allied use now has actual supplier choice and beneficiary consent,
+87
View File
@@ -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. 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 ## 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. - **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.
+26
View File
@@ -1,5 +1,31 @@
# Dune implementation status # 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 ## 24 October 2026 — Basic Guild Ambassador No-Field
Connect native concealed0/3/5 and explicit allied supplier/beneficiary consent Connect native concealed0/3/5 and explicit allied supplier/beneficiary consent
+10
View File
@@ -1,5 +1,15 @@
# Rule decision index # 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 ## Provisional Basic Guild Ambassador No-Field composition — 24 October 2026
Use the 14 October choose-recommended authority for the previously gated Use the 14 October choose-recommended authority for the previously gated
+1 -9
View File
@@ -1814,15 +1814,7 @@ function policyActions(g: GameView): Action[] {
ambassadors.nextCost <= (me.spice ?? 0) ambassadors.nextCost <= (me.spice ?? 0)
) { ) {
const token = ambassadors.tokens.find((t) => t.zone === 'supply'); const token = ambassadors.tokens.find((t) => t.zone === 'supply');
const target = TERRITORIES.find( const target = ambassadors.destinations[0];
(t) =>
t.type === 'stronghold' &&
t.id !== MOBILE_STRONGHOLD &&
!t.sectors.includes(g.storm) &&
!ambassadors.tokens.some(
(a) => a.zone === 'placed' && a.location === t.id,
),
);
if (token && target) if (token && target)
return [{ type: 'decision', token: token.id, territory: target.id }]; return [{ type: 'decision', token: token.id, territory: target.id }];
} }
+81
View File
@@ -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<Player, 'spice'>,
token: string,
to: string,
): ReturnType<typeof placeAmbassador> {
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),
});
}
+6 -18
View File
@@ -143,6 +143,7 @@ import {
import { homeworldRevivalDestinations, quoteHomeworldRevivalDestination } from './homeworld-revival-destinations'; import { homeworldRevivalDestinations, quoteHomeworldRevivalDestination } from './homeworld-revival-destinations';
import { terrorEntrySignature, terrorSelectionSignature, validateTerrorEntrySignature } from './terror-entry-receipt'; import { terrorEntrySignature, terrorSelectionSignature, validateTerrorEntrySignature } from './terror-entry-receipt';
import { arrivalOverlapSignature, overlapOwnsAmbassadorDiscard, overlapOwnsTerrorDiscard, overlapOwnsWormTerror } from './arrival-overlap'; import { arrivalOverlapSignature, overlapOwnsAmbassadorDiscard, overlapOwnsTerrorDiscard, overlapOwnsWormTerror } from './arrival-overlap';
import { ambassadorPlacementDestination, ambassadorPlacementDestinations, quoteEcazAmbassadorPlacement } from './ecaz-ambassador-placement';
import { GuildAmbassadorMarkerError, markerDestinations, markerOwner, quoteGuildAmbassadorMarker, import { GuildAmbassadorMarkerError, markerDestinations, markerOwner, quoteGuildAmbassadorMarker,
type GuildAmbassadorMarker, type GuildAmbassadorMarkerQuote } from './guild-ambassador-no-field'; type GuildAmbassadorMarker, type GuildAmbassadorMarkerQuote } from './guild-ambassador-no-field';
import { import {
@@ -468,7 +469,6 @@ import {
import { import {
validateAmbassadors, validateAmbassadors,
createAmbassadors, createAmbassadors,
placeAmbassador,
triggerAmbassador, triggerAmbassador,
destroyAmbassador, destroyAmbassador,
replenishAmbassadors, replenishAmbassadors,
@@ -7119,12 +7119,9 @@ function treacheryDiscardIntegrity(g: Game) {
['choam', 'ixians'].includes(entry.effect!) && ['choam', 'ixians'].includes(entry.effect!) &&
['none', 'wormRide'].includes(entry.resume) && ['none', 'wormRide'].includes(entry.resume) &&
validAmbassadorResume(g, entry) && validAmbassadorResume(g, entry) &&
TERRITORIES.some( ambassadorPlacementDestination(g, entry.territory).allowed &&
(t) => ambassadorPlacementDestination(g, entry.territory).stronghold &&
t.id === entry.territory && validGameLocation(g, entry.territory, entry.sector) &&
t.type === 'stronghold' &&
t.sectors.includes(entry.sector),
) &&
owner?.faction === 'ecaz' && owner?.faction === 'ecaz' &&
beneficiary && beneficiary &&
(beneficiary.id === owner.id || (beneficiary.id === owner.id ||
@@ -21167,19 +21164,9 @@ function ambassadorPlacementQuote(
token: string, token: string,
to: string, to: string,
) { ) {
const destination = TERRITORIES.find((t) => t.id === to);
requireRule(g.ecazAmbassadors, 'No Ambassador supply is available.'); requireRule(g.ecazAmbassadors, 'No Ambassador supply is available.');
try { try {
return placeAmbassador(g.ecazAmbassadors, token, { return quoteEcazAmbassadorPlacement(g, owner, token, to);
turn: g.turn,
availableSpice: owner.spice,
destination: {
id: to,
stronghold: destination?.type === 'stronghold',
allowed: !!destination,
inStorm: !destination || destination.sectors.includes(g.storm),
},
});
} catch (error) { } catch (error) {
throw new RuleError( throw new RuleError(
error instanceof Error ? error.message : 'Invalid Ambassador placement.', error instanceof Error ? error.message : 'Invalid Ambassador placement.',
@@ -36277,6 +36264,7 @@ export function viewGame(state: Game, id: string) {
ambassadors: g.ecazAmbassadors ambassadors: g.ecazAmbassadors
? { ? {
tokens: g.ecazAmbassadors.tokens.map((token) => ({ ...token })), tokens: g.ecazAmbassadors.tokens.map((token) => ({ ...token })),
destinations: ambassadorPlacementDestinations(g),
nextCost: nextCost:
(g.ecazAmbassadors.placement?.turn === g.turn (g.ecazAmbassadors.placement?.turn === g.turn
? g.ecazAmbassadors.placement.count ? g.ecazAmbassadors.placement.count
+5 -4
View File
@@ -1,6 +1,6 @@
import { terrorLocationAllowed, type TerrorLocationContext } from './terror-location'; import { terrorLocationAllowed, type TerrorLocationContext } from './terror-location';
import type { Game, ResponseWindow } from './engine'; import type { Game, ResponseWindow } from './engine';
import { TERRITORIES } from './board'; import { ambassadorPlacementDestination } from './ecaz-ambassador-placement';
import { import {
blockAmbassadorPlacement, blockAmbassadorPlacement,
type AmbassadorState, type AmbassadorState,
@@ -31,6 +31,8 @@ export type PlacementCancellationContext = Pick<
| 'status' | 'status'
| 'phase' | 'phase'
| 'turn' | 'turn'
| 'storm'
| 'mobileStronghold'
| 'players' | 'players'
| 'pendingEcazPlacement' | 'pendingEcazPlacement'
| 'ecazPlacementTurn' | 'ecazPlacementTurn'
@@ -89,9 +91,8 @@ export function quotePlacementCancellation(
Array.isArray(state.tokens) && Array.isArray(state.tokens) &&
Array.isArray(state.cohort) && Array.isArray(state.cohort) &&
state.tokens.filter((t) => t.id === pending.token).length === 1 && state.tokens.filter((t) => t.id === pending.token).length === 1 &&
TERRITORIES.some( ambassadorPlacementDestination(g, pending.territory).allowed &&
(t) => t.id === pending.territory && t.type === 'stronghold', ambassadorPlacementDestination(g, pending.territory).stronghold &&
) &&
whole(pending.cost) && whole(pending.cost) &&
pending.cost > 0, pending.cost > 0,
'No current physical Ambassador placement is awaiting cancellation.', 'No current physical Ambassador placement is awaiting cancellation.',
+12 -8
View File
@@ -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.', '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.', '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.', '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: [ checklist: [
{ {
area: 'Implementation', area: 'Implementation',
status: 'Partial', status: 'Partial',
detail: 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.', '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/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'], 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', area: 'Player controls',
status: 'Partial', status: 'Partial',
detail: 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.', '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-entry.tsx', 'components/ambassador-shipment.tsx', 'components/ambassador-no-field.tsx', 'components/game-table.tsx'], 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', area: 'AI',
status: 'Partial', status: 'Partial',
detail: 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'], evidence: ['game/bots.ts', 'tests/ambassador-revival-income-engine.test.ts', 'tests/duke-ambassador-engine.test.ts', 'docs/TLEILAXU_AMBASSADOR_RULES.md'],
}, },
{ {
area: 'Documentation', area: 'Documentation',
status: 'Partial', status: 'Partial',
detail: 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'], 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', area: 'Verification',
status: 'Partial', status: 'Partial',
detail: 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: [ 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/ambassador-repeat-leader-engine.test.ts',
'tests/fixture-ambassador-repeat-leaders.ts', 'tests/fixture-ambassador-repeat-leaders.ts',
'tests/tleilaxu-ambassador-leaders.test.ts', 'tests/tleilaxu-ambassador-leaders.test.ts',
+212
View File
@@ -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: <strong>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, /<option[^>]*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(/<g /g) ?? []).length, 3);
for (const token of tokens.filter((candidate) => 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(`<title>${label}</title>`));
assert.ok(html.includes(`stroke="${identity.color}"`));
assert.ok(html.includes(`>${identity.sigil}</text>`));
}
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: [] })), '');
});
+173
View File
@@ -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);
});
}
+163
View File
@@ -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/);
});
+241
View File
@@ -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<T>(run: () => T, rolls: readonly number[] = []): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(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<AmbassadorEffect, 'ecaz'>[] = [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<typeof createEcazHmsAmbassadorFixture>;
/** 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 };
}