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:
1 parent
659e2a4a9a
commit
52832dc0fd
16 files changed
+1129
-55
No files matched your search
@@ -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>,
|
||||
];
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<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 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)}
|
||||
>
|
||||
<option value="">Choose from your supply</option>
|
||||
{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)}
|
||||
>
|
||||
<option value="">Choose a stronghold outside the storm</option>
|
||||
<option value="">Choose an available stronghold</option>
|
||||
{destinations.map((candidate) => (
|
||||
<option key={candidate.id} value={candidate.id}>
|
||||
{candidate.name}
|
||||
{candidate.sheltered ? ' · sheltered from the storm' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -301,7 +329,7 @@ export function EcazPlacement({
|
||||
)}
|
||||
{!destinations.length && (
|
||||
<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>
|
||||
)}
|
||||
{!affordable && (
|
||||
@@ -312,6 +340,7 @@ export function EcazPlacement({
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
data-testid="ecaz-ambassador-placement-submit"
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
@@ -326,10 +355,13 @@ export function EcazPlacement({
|
||||
Place Ambassador · {state.nextCost} spice
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="ecaz-ambassador-placement-finish"
|
||||
variant="outline"
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'decision', decline: true })}
|
||||
onClick={() => {
|
||||
if (!busy) act({ type: 'decision', decline: true });
|
||||
}}
|
||||
>
|
||||
Finish Ambassador placement
|
||||
</Button>
|
||||
|
||||
@@ -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({
|
||||
})}
|
||||
<NoFieldBoardMarkers players={g.players} />
|
||||
<TerrorBoardMarkers tokens={g.moritaniTerror?.tokens ?? []} atomics={g.moritaniAtomics} />
|
||||
<AmbassadorBoardMarkers tokens={g.ambassadors?.tokens ?? []} />
|
||||
{Object.entries(g.spice)
|
||||
.filter(([, n]) => n > 0)
|
||||
.map(([k, n]) => {
|
||||
|
||||
Reference in new issue
Block a user