Support Basic No-Field Guild Ambassador shipments
Connect native concealment and allied supplier/recipient consent to the independent grant, with explicit provisional cap and transport-fee policy. Preserve actual tokens, ordinary allowances, source-scoped Karama prevention and original income/arrival continuation. Add genuine three/four-seat sources, focused quote/control/engine cases and bounded runtime/minimal-bot proof. Advanced and optional profiles retain their existing guards.
This commit is contained in:
1 parent
4b2f4f3fa3
commit
659e2a4a9a
19 files changed
+2221
-56
No files matched your search
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export type AmbassadorNoFieldOffer = NonNullable<
|
||||
NonNullable<GameView['ambassadorEntry']>['noField']
|
||||
>;
|
||||
|
||||
const titles: Record<AmbassadorNoFieldOffer['stage'], string> = {
|
||||
own: 'Use your No-Field',
|
||||
request: 'Request an allied No-Field',
|
||||
supply: 'Supply an allied No-Field',
|
||||
consent: 'Confirm allied No-Field shipment',
|
||||
};
|
||||
|
||||
const submitLabels: Record<AmbassadorNoFieldOffer['stage'], string> = {
|
||||
own: 'Confirm No-Field choice',
|
||||
request: 'Request selected No-Field shipment',
|
||||
supply: 'Offer selected No-Field shipment',
|
||||
consent: 'Accept selected No-Field offer',
|
||||
};
|
||||
|
||||
/** Receives complete server actions; selecting a marker never edits a physical shipment. */
|
||||
export function AmbassadorNoFieldChoice({
|
||||
offer,
|
||||
event,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
act,
|
||||
busy,
|
||||
id = 'ambassador-no-field-choice',
|
||||
}: {
|
||||
offer: AmbassadorNoFieldOffer;
|
||||
event: string;
|
||||
selectedKey: string;
|
||||
onSelect: (key: string) => void;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
id?: string;
|
||||
}) {
|
||||
const selected = offer.options.find((option) => option.key === selectedKey);
|
||||
const reason = selected?.blocked ??
|
||||
(selected ? null : 'Choose a No-Field option.');
|
||||
return (
|
||||
<section
|
||||
className="flex min-w-0 flex-col gap-3 rounded border p-3"
|
||||
aria-label="Ambassador No-Field shipment"
|
||||
data-event={event}
|
||||
data-stage={offer.stage}
|
||||
>
|
||||
<h4 className="m-0 font-semibold">{titles[offer.stage]}</h4>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Choose a No-Field as the entire Ambassador shipment grant.
|
||||
</p>
|
||||
<p className="m-0 text-sm leading-6">{offer.policy}</p>
|
||||
<label className="flex flex-col gap-2" htmlFor={`${id}-option`}>
|
||||
No-Field choice
|
||||
<select
|
||||
id={`${id}-option`}
|
||||
className="min-h-11"
|
||||
value={selected?.key ?? ''}
|
||||
disabled={busy || !offer.options.length}
|
||||
onChange={(e) => {
|
||||
if (!busy) onSelect(e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">Choose a No-Field option</option>
|
||||
{offer.options.map((option) => (
|
||||
<option key={option.key} value={option.key} disabled={!!option.blocked}>
|
||||
{option.label}{option.blocked ? ` · ${option.blocked}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{reason && <p id={`${id}-reason`} className="m-0 text-sm">{reason}</p>}
|
||||
<Button
|
||||
data-testid="ambassador-no-field-submit"
|
||||
className="min-h-11 whitespace-normal"
|
||||
disabled={busy || !!reason}
|
||||
aria-describedby={reason ? `${id}-reason` : undefined}
|
||||
onClick={() => {
|
||||
if (!busy && selected && !selected.blocked) act(selected.action);
|
||||
}}
|
||||
>
|
||||
{submitLabels[offer.stage]}
|
||||
</Button>
|
||||
{offer.decline && (
|
||||
<Button
|
||||
data-testid="ambassador-no-field-decline"
|
||||
className="min-h-11 whitespace-normal"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (!busy && offer.decline) act(offer.decline);
|
||||
}}
|
||||
>
|
||||
{offer.declineLabel ?? 'Decline No-Field offer'}
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AmbassadorNoFieldSelection({
|
||||
offer,
|
||||
event,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
offer: AmbassadorNoFieldOffer;
|
||||
event: string;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [selectedKey, setSelectedKey] = useState('');
|
||||
return (
|
||||
<AmbassadorNoFieldChoice
|
||||
id={id}
|
||||
offer={offer}
|
||||
event={event}
|
||||
selectedKey={selectedKey}
|
||||
onSelect={setSelectedKey}
|
||||
act={act}
|
||||
busy={busy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AmbassadorNoField({
|
||||
game,
|
||||
act,
|
||||
busy,
|
||||
destination,
|
||||
sector,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
destination?: string;
|
||||
sector?: number;
|
||||
}) {
|
||||
const entry = game.ambassadorEntry;
|
||||
if (
|
||||
entry?.stage !== 'ship' ||
|
||||
game.decision?.kind !== 'ecazAmbassador' ||
|
||||
game.decision.player !== game.me ||
|
||||
!entry.noField
|
||||
) return null;
|
||||
const offer = entry.noField;
|
||||
const filtered = offer.stage === 'own' || offer.stage === 'request'
|
||||
? {
|
||||
...offer,
|
||||
options: offer.options.filter((option) =>
|
||||
!option.territory ||
|
||||
((!destination || option.territory === destination) &&
|
||||
(sector === undefined || option.sector === sector)),
|
||||
),
|
||||
}
|
||||
: offer;
|
||||
return (
|
||||
<AmbassadorNoFieldSelection
|
||||
key={`${entry.event}:${offer.stage}:${destination ?? ''}:${sector ?? ''}`}
|
||||
offer={filtered}
|
||||
event={entry.event}
|
||||
act={act}
|
||||
busy={busy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { Action, GameView } from '@/game/engine';
|
||||
import { location, territory } from '@/game/board';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { AmbassadorNoField } from './ambassador-no-field';
|
||||
|
||||
const bounded = (value: number, min: number, max: number) =>
|
||||
Math.max(min, Math.min(max, Number.isSafeInteger(value) ? value : min));
|
||||
@@ -26,24 +27,46 @@ export function AmbassadorShipment({
|
||||
const entry = game.ambassadorEntry;
|
||||
if (
|
||||
entry?.stage !== 'ship' ||
|
||||
entry.beneficiary !== game.me ||
|
||||
game.decision?.kind !== 'ecazAmbassador' ||
|
||||
game.decision.player !== game.me ||
|
||||
!entry.shipment
|
||||
game.decision.player !== game.me
|
||||
)
|
||||
return null;
|
||||
if (entry.noField?.stage === 'supply' || entry.noField?.stage === 'consent')
|
||||
return <AmbassadorNoField game={game} act={act} busy={busy} />;
|
||||
if (entry.beneficiary !== game.me || !entry.shipment) return null;
|
||||
const me = game.players.find((p) => p.id === game.me)!;
|
||||
const shipment = entry.shipment;
|
||||
const destinations = shipment.destinations.map((d) => ({
|
||||
territory: d.territory,
|
||||
sector: d.sector,
|
||||
blocked: d.blocked,
|
||||
}));
|
||||
for (const option of entry.noField?.options ?? []) {
|
||||
if (!option.territory || option.sector === undefined) continue;
|
||||
const existing = destinations.find((d) =>
|
||||
d.territory === option.territory && d.sector === option.sector,
|
||||
);
|
||||
if (existing) {
|
||||
if (!option.blocked) existing.blocked = null;
|
||||
} else destinations.push({
|
||||
territory: option.territory,
|
||||
sector: option.sector,
|
||||
blocked: option.blocked,
|
||||
});
|
||||
}
|
||||
const destination =
|
||||
shipment.destinations.find(
|
||||
destinations.find(
|
||||
(d) => location(d.territory, d.sector) === destinationKey,
|
||||
) ??
|
||||
shipment.destinations.find((d) => !d.blocked) ??
|
||||
shipment.destinations[0];
|
||||
destinations.find((d) => !d.blocked) ??
|
||||
destinations[0];
|
||||
const physicalDestination = shipment.destinations.find((d) =>
|
||||
d.territory === destination?.territory && d.sector === destination?.sector,
|
||||
);
|
||||
const maximum = Math.min(
|
||||
shipment.maximum,
|
||||
me.reserves,
|
||||
destination?.maximum ?? shipment.maximum,
|
||||
physicalDestination?.maximum ?? 0,
|
||||
);
|
||||
const amount = maximum ? bounded(count ?? maximum, 1, maximum) : 0;
|
||||
const minimumElite = Math.max(
|
||||
@@ -54,11 +77,11 @@ export function AmbassadorShipment({
|
||||
const elite = bounded(eliteCount, minimumElite, maximumElite);
|
||||
const reason = !amount
|
||||
? 'You have no reserve forces available for this shipment.'
|
||||
: !destination
|
||||
: !physicalDestination
|
||||
? 'No shipment destination is currently available.'
|
||||
: destination.blocked;
|
||||
: physicalDestination.blocked;
|
||||
const territories = [
|
||||
...new Set(shipment.destinations.map((d) => d.territory)),
|
||||
...new Set(destinations.map((d) => d.territory)),
|
||||
];
|
||||
return (
|
||||
<div
|
||||
@@ -112,9 +135,9 @@ export function AmbassadorShipment({
|
||||
id={`${id}-territory`}
|
||||
className="min-h-11"
|
||||
value={destination?.territory ?? ''}
|
||||
disabled={busy || !shipment.destinations.length}
|
||||
disabled={busy || !destinations.length}
|
||||
onChange={(e) => {
|
||||
const options = shipment.destinations.filter(
|
||||
const options = destinations.filter(
|
||||
(d) => d.territory === e.target.value,
|
||||
);
|
||||
const chosen = options.find((d) => !d.blocked) ?? options[0];
|
||||
@@ -142,7 +165,7 @@ export function AmbassadorShipment({
|
||||
disabled={busy || !destination}
|
||||
onChange={(e) => setDestinationKey(e.target.value)}
|
||||
>
|
||||
{shipment.destinations
|
||||
{destinations
|
||||
.filter((d) => d.territory === destination?.territory)
|
||||
.map((d) => (
|
||||
<option key={d.sector} value={location(d.territory, d.sector)}>
|
||||
@@ -152,16 +175,13 @@ export function AmbassadorShipment({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{destination?.advisors && (
|
||||
{physicalDestination?.advisors && (
|
||||
<p className="m-0 text-sm">These forces join your advisors.</p>
|
||||
)}
|
||||
<p className="m-0 text-sm" aria-live="polite">
|
||||
Selected: {amount} physical forces, including {elite} elites. Cost: 0
|
||||
spice.
|
||||
</p>
|
||||
<p className="m-0 text-sm">
|
||||
No-Field substitution for this effect remains unfinished.
|
||||
</p>
|
||||
{reason && (
|
||||
<p id={`${id}-reason`} className="m-0 text-sm leading-6">
|
||||
{reason}
|
||||
@@ -195,6 +215,13 @@ export function AmbassadorShipment({
|
||||
>
|
||||
Send no forces
|
||||
</Button>
|
||||
<AmbassadorNoField
|
||||
game={game}
|
||||
act={act}
|
||||
busy={busy}
|
||||
destination={destination?.territory}
|
||||
sector={destination?.sector}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,28 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Basic Guild Ambassador No-Field — 24 October. Prototyped / Partial.**
|
||||
Native Richese0/3/5 can replace the whole independent grant with one concealed
|
||||
free marker. Allied use now has actual supplier choice and beneficiary consent,
|
||||
immediate reserve-capped materialization within four, and provisional printed
|
||||
one/two-spice Guild/Bank payment. Original token history, native prior reveal,
|
||||
ordinary allowances, source-scoped Karama prevention and the original entrant
|
||||
remain intact. Persistent controls also work with zero physical reserves.
|
||||
[Selected source interpretation](GUILD_AMBASSADOR_RULES.md#basic-no-field-composition--24-october-2026)
|
||||
distinguishes the unresolved cap/fee combination from publisher authority.
|
||||
|
||||
Disjoint quote, genuine source and controls lanes plus bounded review pass
|
||||
**13 files129/129**, types/lint/build, fifteen gameplay/control/GameTable SSR
|
||||
journeys and eight existing-profile continuations (12 accepted proposals).
|
||||
Actual three/four-seat setup, two placements, accepted Ecaz alliance and paid
|
||||
entrant supply53/71/74 source actions; two shortage cases explicitly stage
|
||||
conserved prior deployments. Feedback fixes a lost decision after native reveal,
|
||||
held-Karama versus automatic-response fixtures and three fixture type errors.
|
||||
[Exact behavior, proof and limits](GUILD_AMBASSADOR_IMPLEMENTATION.md#basic-native-and-allied-no-field--24-october-2026).
|
||||
Native Advanced/optional profiles, broader originating-card/arrival combinations,
|
||||
complete modes, comprehensive/browser/deployed acceptance and full AI remain
|
||||
unfinished. No saved-room/D1/reset/restart/deployment operation.
|
||||
|
||||
**Basic shared Ambassador/Terror arrival — 24 October. Prototyped / Partial.**
|
||||
The existing bare Basic Richese profile now admits a paid shared native entry.
|
||||
Both originally eligible owners keep the selected provisional storm order
|
||||
|
||||
@@ -2,6 +2,77 @@
|
||||
|
||||
7 September 2026. The Guild Ambassador now provides an independent free physical reserve shipment for Ecaz or its ally, including a permitted Bene Gesserit copy. It is the eighth implemented ordinary Ambassador effect. This checkpoint does not enable full Ecaz, Advanced or expansion starts or establish complete expansion compliance.
|
||||
|
||||
## Basic native and allied No-Field — 24 October 2026
|
||||
|
||||
**Prototyped / Partial.** Bare Basic Richese/Ecaz tables without optional modules
|
||||
now use No-Field for the whole Guild Ambassador grant. The
|
||||
[source-selected provisional composition](GUILD_AMBASSADOR_RULES.md#basic-no-field-composition--24-october-2026)
|
||||
admits native concealed0/3/5 as one free marker without reserve withdrawal.
|
||||
Allied immediate materialization uses `min(value, recipient reserves)` within
|
||||
four, never an invented truncation of five available pieces. The specialized
|
||||
one/two-spice allied transport payment goes to Guild, or Bank when absent,
|
||||
despite the general free grant; this precedence remains provisional.
|
||||
|
||||
The beneficiary chooses the requested destination. Richese privately chooses
|
||||
its physical token and supplier/recipient/split payment; the beneficiary accepts
|
||||
or declines the exact offer before any transfer. A two-spice fee may split1/1.
|
||||
Either refusal returns the original physical shipment choice. Native Richese
|
||||
explicitly reveals an old marker first; accepted allied use reveals the old
|
||||
supplier marker once, then immediately materializes the new token from the
|
||||
recipient's own reserves. Normal token availability/history remains shared.
|
||||
Zero reserve counters do not erase a native concealed-marker opportunity.
|
||||
|
||||
New controls retain their event, supplier/beneficiary actor, exact server option,
|
||||
provisional guidance and busy/blocked reasons. Printed Karama prevents only this
|
||||
grant's marker substitution and retains the physical-force alternative. Ordinary
|
||||
shipment/movement, normal No-Field block flags and unrelated tariff benefits
|
||||
remain unchanged. Successful payment opens its exact Guild-income response
|
||||
before existing arrival continuation; canceling income keeps the paid shipment.
|
||||
New optional declaration/request fields and a committed marker receipt witness
|
||||
do not retrofit old physical receipts or change their zero-price validation.
|
||||
|
||||
Pure quotes live in `game/guild-ambassador-no-field.ts`; root integration covers
|
||||
authoritative declaration/consent, payment, token placement/revelation and the
|
||||
existing typed arrival parent. The ordinary physical quote remains separate.
|
||||
Basic minimal bots answer supplier/consent requests using their own projected
|
||||
options and can complete a native marker grant when no physical reserve remains.
|
||||
No strategy or strength calibration was added.
|
||||
|
||||
The final **13-file129/129** related union, `npm run check:quick` and production
|
||||
build pass. Fifteen gameplay/control/GameTable SSR journeys exercise native
|
||||
0/3/5, actual old-marker reveal, zero reserves, allied consent/refusal, cap5→4,
|
||||
zero materialization, Guild/Bank payment, printed prevention, physical fallback
|
||||
and the original entrant's actual move/end. Eight existing-profile continuations
|
||||
accept12 proposals from native-empty or allied-request positions. Pure/controller
|
||||
regressions keep fees, token events, typed counts and legacy behavior covered.
|
||||
|
||||
Genuine three/four-seat Basic setup selects only original undealt cards/Spice
|
||||
and nine original packet rolls. Ecaz actually pays for two placements, forms
|
||||
an accepted alliance with Richese through its reusable token and declines its
|
||||
optional Basic Duke loan. Ecaz/Richese have already shipped normally; Harkonnen
|
||||
then pays for the real Guild-marker entry. Runtime sources retain53/71/74
|
||||
actual actions. Native-empty and allied5/reserves4 cases separately stage prior
|
||||
conserved deployments into existing groups, not played movement. Source cases
|
||||
with actual held Karama keep the prevention response; ordinary hands autoallow.
|
||||
Feedback repaired a lost decision after native prior reveal, those response
|
||||
assumptions and three fixture type errors. Bounded source/continuation review
|
||||
found no remaining blocking issue.
|
||||
|
||||
Private proof: `/home/quintine/.local/share/dune/checkpoints/20261024-guild-ambassador-no-field/`,
|
||||
`frozen-integrated/report.json`, `runtime-smoke.json`, HTML/logs and
|
||||
`verified-files.json`. Before final prose: base4b2f4f3fa3853aa1acd46631d186c746a06842f8,
|
||||
source treed30c6163b8e7e07e73159f27f8dfb2e49726d88ec777b4ce377eb05bb48fd8a3,
|
||||
2680 tracked/nonignored files unchanged throughout verification. Four final
|
||||
prose/reference overlays preserve code/test hashes; delivery checks links,
|
||||
evidence, reference syntax/keys, explicit staging and remote push.
|
||||
|
||||
This supersedes the earlier No-Field implementation gate below only for this
|
||||
Basic profile. Native Advanced, optional modules and wider originating-card or
|
||||
competing arrival combinations retain their separate boundaries. SSR uses
|
||||
framework aliases/CSS stubs and is not browser/visual/deployed acceptance.
|
||||
Complete rules, comprehensive saved-game/privacy/custody assurance and full AI
|
||||
remain unfinished. No saved room, D1, reset, restart or deployment operation.
|
||||
|
||||
## Rules and bounded behavior
|
||||
|
||||
[GUILD_AMBASSADOR_RULES.md](GUILD_AMBASSADOR_RULES.md) records the primary sources and unresolved combinations. E3 pp.8,15 establishes the reserve grant and immediate shipment before the original entrant continues. The beneficiary may select one through four physical forces, including any required or chosen elites, and a clear destination sector. An explicit zero-force choice is implemented as the reading of “up to four”; the triggered token remains committed. No available reserves or board destination completes without moving forces under the documented unavailable-effect interpretation. An implementation-blocked arrival is not silently treated as a rules-impossible destination.
|
||||
@@ -18,13 +89,13 @@ The durable receipt validates historical actor, event, token, quantity and child
|
||||
|
||||
## Controls and AI
|
||||
|
||||
Only the beneficiary receives `ambassadorEntry.shipment`. The dedicated event-keyed form shows physical count, elite minimum/maximum, destination territory/sector, zero cost and an explicit **Send no forces** action. Server-projected blocked reasons disable submission and are associated with the controls. There is no payment dialog or No-Field selector.
|
||||
Only the beneficiary receives `ambassadorEntry.shipment`. The dedicated event-keyed form shows physical count, elite minimum/maximum, destination territory/sector, zero cost and an explicit **Send no forces** action. Server-projected blocked reasons disable submission and are associated with the controls. This dated physical-only baseline is extended by the Basic No-Field controls above.
|
||||
|
||||
All four AI profiles choose a positive projected legal shipment when available, preserve canonical elite allocation and otherwise choose zero. They filter blocked destinations before scoring. Offer/copy preference uses only the bot's own reserve availability, not an ally's hidden resources. For a source-bound BG follow-up, only its owner receives `guildAmbassadorAdvisorChoices`; UI and AI use the supplied sectors and blocking reasons. The AI falls back to Polar Sink or decline. Legacy accompaniment controls retain their prior behavior.
|
||||
|
||||
## Remaining boundaries
|
||||
|
||||
- **No-Field substitution remains unavailable pending the user's interpretation.** The combined free price, four-force cap, concealed value and mandatory allied substitution fee have no direct publisher ruling established here. Moving an existing marker with the Fremen effect does not settle this shipment question.
|
||||
- **No-Field:** the24 October Basic prototype above supersedes the original implementation gate. Native Advanced and optional combined profiles remain gated; the combined cap and fee policy is provisional, without a direct publisher ruling.
|
||||
- Simultaneous foreign-BG Intrusion/Terror and Ambassador/Terror remain explicit arrival gates. A rejected declaration commits no forces. The supported sequential or secondary cases do not resolve those competing priorities.
|
||||
- Moritani Atomics Aftermath's shipment prohibition still requires its gated effect producer/state. Homeworlds, Discovery locations and their component modifiers require their own gated modules. The physical quote is not evidence that these absent combinations have been implemented.
|
||||
- Tleilaxu Ambassador, complete Occupy combat/collection/victory, exceptional Duke custody and full optional-module combinations remain unfinished. The source-version conflict about Occupy combat rounding is unchanged.
|
||||
|
||||
@@ -2,6 +2,50 @@
|
||||
|
||||
Primary-source review, 7 September 2026. This is a source contract, not an implementation or a claim of complete expansion compliance. Read alongside [runtime readiness](GUILD_AMBASSADOR_RUNTIME_READINESS.md).
|
||||
|
||||
## Basic No-Field composition — 24 October 2026
|
||||
|
||||
This supersedes the older No-Field implementation gate below for bare Basic
|
||||
Richese/Ecaz tables without optional modules. The 14 October standing
|
||||
choose-recommended instruction authorizes a provisional composition; it does
|
||||
not turn it into a publisher ruling or decide broader Advanced combinations.
|
||||
A bounded fresh check of official indexed E2/E3 extracts found no direct
|
||||
answer for this later free grant. Direct PDF retrieval returned403; existing
|
||||
primary-source links and physical-page citations remain the provenance.
|
||||
|
||||
E2 physical p6 permits native shipment substitution and immediately reveals an
|
||||
allied token. P10 treats an unrevealed marker as one force, including zero.
|
||||
P11 requires one spice to a stronghold or two elsewhere for allied use, paid
|
||||
by either ally to Guild, or Bank if Guild is absent. This is transport payment,
|
||||
not a service fee paid to Richese. That FAQ predates E3's free up-to-four grant
|
||||
(p8), so fee precedence and the combined cap still need explicit qualification.
|
||||
|
||||
Selected provisional policy:
|
||||
|
||||
- Substitute the whole independent grant; no marker plus extra counters.
|
||||
- Native Richese0/3/5 counts as one while concealed and ships for free without
|
||||
withdrawing physical reserves. Preserve normal marker history; explicitly
|
||||
reveal a previous native marker before choosing a different one.
|
||||
- Allied use immediately materializes `min(value, recipient reserves)` actual
|
||||
counters and must fit four. A5 marker can deliver four when only four exist;
|
||||
it cannot truncate five available counters just to fit. An original supplier
|
||||
marker reveals once at accepted allied commitment, using Richese's own pieces.
|
||||
- Preserve the specialized allied one/two-spice payment to Guild/Bank despite
|
||||
the general free grant. Either ally may pay; a two-spice shipment can split
|
||||
equally. This precedence is provisional, not an E3-specific FAQ answer.
|
||||
- The beneficiary requests the destination, Richese selects its own token and
|
||||
payer, and the beneficiary accepts or declines the exact offer before any
|
||||
payment/reveal. Decline returns the original physical shipment choice.
|
||||
- Printed Karama may stop marker substitution for this independent grant only.
|
||||
Keep its physical-force alternative and ordinary shipment state. Allowance
|
||||
commits token/payment once, then any original Guild income and arrival suffix.
|
||||
|
||||
The existing unused ordinary shipment, movement, purchased tariff and normal
|
||||
No-Field block flags are not consumed or reset. The source event/token history
|
||||
and private choices stay authoritative. Existing serialized physical receipts
|
||||
retain their old zero-cost contract; new marker receipts carry their own witness.
|
||||
Optional modules, native Advanced and wider child interactions retain their
|
||||
separate gates; this is a working Basic prototype, not full-rule acceptance.
|
||||
|
||||
## Authority and the independent grant
|
||||
|
||||
E3 pp.7–8 gives Ecaz the optional trigger and lets Ecaz assign its benefit to its ally. The benefit sends up to four beneficiary-owned reserve forces to a clear territory, free. The physical Guild marker excludes a Guild entrant; an entrant is not the beneficiary. The BG marker copies effects, so distinguish its physical identity from its chosen effect. E3 p.15 expressly classifies the Guild effect as an immediate shipment, interrupting the entrant before its movement. Occupy permits the Ecaz pair to co-occupy and counts that pair as one faction. E3 p.16's Ambassador Karama entry prevents placement, not an already-triggered effect. [Official E3](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf)
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 24 October 2026 — Basic Guild Ambassador No-Field
|
||||
|
||||
Connect native concealed0/3/5 and explicit allied supplier/beneficiary consent
|
||||
to the whole independent grant. Native use counts one/free; allied immediate
|
||||
materialization fits four, with provisional one/two-spice transport to Guild or
|
||||
Bank. No extra counters join the marker. Actual old native reveal, allied prior
|
||||
reveal, shared last-token history, physical fallback after refusal/prevention
|
||||
and original entrant continuation remain distinct. New optional marker/request
|
||||
fields and a committed receipt witness leave legacy physical receipts intact.
|
||||
|
||||
Disjoint quote/source/controls lanes and bounded source/continuation review pass
|
||||
13 files129/129, types/lint/build, fifteen gameplay/control/GameTable SSR
|
||||
journeys and eight existing-profile paths accepting12 proposals. Genuine
|
||||
three/four-seat setup, original Ambassador packet, two paid placements, accepted
|
||||
Ecaz alliance and paid entrant supply53/71/74 source actions. Two shortage cases
|
||||
explicitly move conserved original counters to existing groups; no source clock,
|
||||
alliance or pending receipt is installed. Held-Karama sources preserve meaningful
|
||||
prevention controls; ordinary hands autoallow. The actual reveal feedback found
|
||||
and fixed a lost native shipment decision. Three fixture type errors were fixed.
|
||||
|
||||
[Exact source, policy and evidence](GUILD_AMBASSADOR_IMPLEMENTATION.md#basic-native-and-allied-no-field--24-october-2026)
|
||||
retain Basic-only bounds, unfinished Advanced/modules/complete modes and final
|
||||
assurance/deployed gates. No server, saved-game/D1/reset/restart/deployment
|
||||
operation or strategy/calibration work.
|
||||
|
||||
## 24 October 2026 — Basic shared Ambassador and Terror arrival
|
||||
|
||||
Admit the original ordered native entry in the existing bare Basic Richese
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Rule decision index
|
||||
|
||||
## Provisional Basic Guild Ambassador No-Field composition — 24 October 2026
|
||||
|
||||
Use the 14 October choose-recommended authority for the previously gated
|
||||
independent grant: native concealed0/3/5 counts as one/free; allied immediate
|
||||
reserve-capped materialization must fit four, while the specialized printed
|
||||
one/two-spice Guild/Bank payment survives the general free grant. No extra
|
||||
counters accompany the marker. Supplier choice and beneficiary consent precede
|
||||
payment/reveal; marker cancellation affects only this grant. No direct publisher
|
||||
combination ruling was found. [Exact primary passages, policy and boundaries](GUILD_AMBASSADOR_RULES.md#basic-no-field-composition--24-october-2026).
|
||||
|
||||
## Provisional Sapho Extortion payer order — 18 October 2026
|
||||
|
||||
Apply the general printed first/last ordered-action effect to Extortion's
|
||||
|
||||
@@ -1950,6 +1950,12 @@ function policyActions(g: GameView): Action[] {
|
||||
const entry = g.ambassadorEntry;
|
||||
if (!entry) return [];
|
||||
const action = { type: 'decision' as const, event: entry.event };
|
||||
if (entry.noField && (entry.noField.stage === 'supply' || entry.noField.stage === 'consent' ||
|
||||
(entry.noField.stage === 'own' && !entry.shipment?.maximum))) {
|
||||
const choice = entry.noField.options.find(option => !option.blocked);
|
||||
if (choice) return [choice.action];
|
||||
if (entry.noField.decline) return [entry.noField.decline];
|
||||
}
|
||||
const canBuyRichese =
|
||||
(me.spice ?? 0) >= 3 &&
|
||||
(me.hand?.length ?? me.handLimit) < me.handLimit;
|
||||
|
||||
+267
-20
@@ -143,6 +143,8 @@ import {
|
||||
import { homeworldRevivalDestinations, quoteHomeworldRevivalDestination } from './homeworld-revival-destinations';
|
||||
import { terrorEntrySignature, terrorSelectionSignature, validateTerrorEntrySignature } from './terror-entry-receipt';
|
||||
import { arrivalOverlapSignature, overlapOwnsAmbassadorDiscard, overlapOwnsTerrorDiscard, overlapOwnsWormTerror } from './arrival-overlap';
|
||||
import { GuildAmbassadorMarkerError, markerDestinations, markerOwner, quoteGuildAmbassadorMarker,
|
||||
type GuildAmbassadorMarker, type GuildAmbassadorMarkerQuote } from './guild-ambassador-no-field';
|
||||
import {
|
||||
homeworldLowBonus,
|
||||
homeworldRevivalKaramaBlock,
|
||||
@@ -1068,7 +1070,7 @@ export type ResponseWindow = {
|
||||
occupiedPayment?: { event: string; contributions: { payer: string; amount: number }[] };
|
||||
/** Bank-paid portion of a frozen Homeworld charity claim. */
|
||||
charityHomeworld?: number;
|
||||
source?: 'ambassador' | 'ambassadorRevival';
|
||||
source?: 'ambassador' | 'ambassadorRevival' | 'ambassadorNoField' | 'ambassadorNoFieldIncome';
|
||||
intent?: string;
|
||||
advisors?: boolean;
|
||||
/** Saved exact ordinary Guild-rate response; not the independent Karama rate. */
|
||||
@@ -1188,6 +1190,7 @@ type PendingShipment = {
|
||||
homeworldSources?: NativeReserveSelections;
|
||||
source?: 'ambassador';
|
||||
ambassadorEvent?: string;
|
||||
ambassadorMarker?: GuildAmbassadorMarker;
|
||||
/** Absent in legacy rooms; new declarations cannot resume in a later turn. */
|
||||
turn?: number;
|
||||
player: string;
|
||||
@@ -1759,13 +1762,19 @@ export type Game = {
|
||||
| 'revival'
|
||||
| 'arrival';
|
||||
nexusPurchase?: { event: string; signature: string };
|
||||
guildMarkerBlocked?: true;
|
||||
guildMarkerRequest?: {
|
||||
owner: string; territory: string; sector: number; tokenEvent: string;
|
||||
offer?: GuildAmbassadorMarkerQuote;
|
||||
};
|
||||
relocation?: {
|
||||
order: FremenAmbassadorMove;
|
||||
next: 'flip' | 'intrusion' | 'terror' | 'finish';
|
||||
};
|
||||
shipmentReceipt?: {
|
||||
order: GuildAmbassadorShipment;
|
||||
next: 'intrusion' | 'terror' | 'accompany' | 'advisor' | 'finish';
|
||||
next: 'income' | 'intrusion' | 'terror' | 'accompany' | 'advisor' | 'finish';
|
||||
markerCommitSignature?: string;
|
||||
advisorArrival?: {
|
||||
player: string;
|
||||
territory: string;
|
||||
@@ -14631,6 +14640,10 @@ function choamSaleCancellationQuote(g: Game, response: ResponseWindow) {
|
||||
}
|
||||
function noFieldCancellationQuote(g: Game, response: ResponseWindow) {
|
||||
if (response.kind !== 'richeseNoField') return null;
|
||||
if (response.source === 'ambassadorNoField') {
|
||||
const shipment = validateAmbassadorMarkerResponse(g,response);
|
||||
return {kind:'own' as const,player:shipment.ambassadorMarker!.owner,blockedTurn:g.turn,pendingShipment:null};
|
||||
}
|
||||
try {
|
||||
return quoteNoFieldCancellation(g, response);
|
||||
} catch (error) {
|
||||
@@ -15463,7 +15476,7 @@ function finishAutomaticDecision(g: Game): boolean {
|
||||
const choices = guildAmbassadorRule(() =>
|
||||
guildAmbassadorShipments(g, beneficiary.id),
|
||||
);
|
||||
if (!choices.maximum || !choices.destinations.length) {
|
||||
if ((!choices.maximum || !choices.destinations.length) && !guildMarkerAvailable(g,beneficiary.id)) {
|
||||
g.decision = null;
|
||||
log(
|
||||
g,
|
||||
@@ -17559,7 +17572,8 @@ function ambassadorRelocationIntegrity(g: Game) {
|
||||
if (entry?.effect === 'guild' && entry.stage === 'arrival') {
|
||||
const next = entry.shipmentReceipt?.next;
|
||||
requireRule(
|
||||
next === 'terror' ||
|
||||
next === 'income' ||
|
||||
next === 'terror' ||
|
||||
next === 'accompany' ||
|
||||
next === 'advisor' ||
|
||||
next === 'finish',
|
||||
@@ -17620,7 +17634,10 @@ function ambassadorRelocationIntegrity(g: Game) {
|
||||
'This saved Terror does not match its Guild Ambassador arrival.',
|
||||
);
|
||||
requireRule(
|
||||
next === 'accompany' || next === 'finish'
|
||||
next === 'income'
|
||||
? responses.some(response => response.kind === 'guildIncome' && response.source === 'ambassadorNoFieldIncome' &&
|
||||
response.intent === entry.event && response.owner === byFaction(g,'guild')?.id && response.amount === order.cost)
|
||||
: next === 'accompany' || next === 'finish'
|
||||
? relatedTerror
|
||||
: relatedResponses.length +
|
||||
relatedDecisions.length +
|
||||
@@ -17641,6 +17658,7 @@ function ambassadorRelocationIntegrity(g: Game) {
|
||||
if (entry?.stage === 'move') currentFremenAmbassador(g);
|
||||
if (entry?.stage === 'ship') {
|
||||
const { beneficiary } = currentGuildAmbassador(g);
|
||||
markerRequestIntegrity(g);
|
||||
const selections = decisions.filter(
|
||||
(decision) => decision.kind === 'ecazAmbassador',
|
||||
);
|
||||
@@ -17648,19 +17666,22 @@ function ambassadorRelocationIntegrity(g: Game) {
|
||||
(decision) => decision.kind === 'guildShipment',
|
||||
);
|
||||
if (g.pendingShipment) {
|
||||
requireRule(
|
||||
g.pendingShipment.source === 'ambassador' &&
|
||||
g.phase === 5 &&
|
||||
!selections.length &&
|
||||
stops.length > 0,
|
||||
'The pending Guild Ambassador shipment has lost its shipment decision.',
|
||||
);
|
||||
for (const stop of stops) validateGuildShipmentDecision(g, stop);
|
||||
if (g.pendingShipment.ambassadorMarker && !stops.length) {
|
||||
const markerResponses = responses.filter(response => response.source === 'ambassadorNoField');
|
||||
requireRule(!selections.length && markerResponses.length > 0,
|
||||
'The independent marker shipment has lost its original response.');
|
||||
for (const response of markerResponses) validateAmbassadorMarkerResponse(g,response);
|
||||
} else {
|
||||
requireRule(g.pendingShipment.source === 'ambassador' && g.phase === 5 &&
|
||||
!selections.length && stops.length > 0,
|
||||
'The pending Guild Ambassador shipment has lost its shipment decision.');
|
||||
for (const stop of stops) validateGuildShipmentDecision(g, stop);
|
||||
}
|
||||
} else
|
||||
requireRule(
|
||||
!stops.length &&
|
||||
selections.length > 0 &&
|
||||
selections.every((decision) => decision.player === beneficiary.id),
|
||||
selections.every((decision) => decision.player === (entry.guildMarkerRequest && !entry.guildMarkerRequest.offer ? entry.guildMarkerRequest.owner : beneficiary.id)),
|
||||
'The Guild Ambassador has lost its beneficiary shipment selection.',
|
||||
);
|
||||
}
|
||||
@@ -17844,11 +17865,164 @@ function guildAmbassadorRule<T>(calculate: () => T): T {
|
||||
try {
|
||||
return calculate();
|
||||
} catch (error) {
|
||||
if (error instanceof GuildAmbassadorShipmentError)
|
||||
if (error instanceof GuildAmbassadorShipmentError || error instanceof GuildAmbassadorMarkerError)
|
||||
throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const GUILD_MARKER_POLICY = 'Provisional Guild Ambassador composition: a concealed native No-Field counts as one and ships free. An allied marker reveals immediately, must fit the four-force grant, and retains the printed one/two-spice transport payment to Guild or Bank. No ordinary shipment is spent.';
|
||||
type GuildMarkerView = {
|
||||
stage: 'own' | 'request' | 'supply' | 'consent'; policy: string;
|
||||
options: {key: string; label: string; territory?: string; sector?: number; action: Action; blocked: string | null}[];
|
||||
decline?: Action; declineLabel?: string;
|
||||
};
|
||||
function guildMarkerAvailable(g: Game, beneficiary: string): boolean {
|
||||
return bareBasicSemutaProfile(g) && !g.pendingAmbassador?.guildMarkerBlocked && !!markerOwner(g, beneficiary);
|
||||
}
|
||||
function quoteAmbassadorMarker(g: Game, beneficiary: string, fields: {
|
||||
tokenId: string; tokenEvent: string; territory: string; sector: number; payer?: string; elite?: number;
|
||||
}, checkFunds = true): GuildAmbassadorMarkerQuote {
|
||||
requireRule(guildMarkerAvailable(g, beneficiary), 'This independent No-Field grant is unavailable.');
|
||||
const quote = guildAmbassadorRule(() => quoteGuildAmbassadorMarker(g, beneficiary, fields, checkFunds));
|
||||
requireRule(uncommittedSpice(g, getPlayer(g, quote.marker.owner)) >= quote.marker.ownerPayment,
|
||||
'The supplier needs enough uncommitted spice for this transport payment.');
|
||||
if (checkFunds) requireRule(uncommittedSpice(g, getPlayer(g, beneficiary)) >= quote.marker.recipientPayment,
|
||||
'The recipient needs enough uncommitted spice for this transport payment.');
|
||||
return quote;
|
||||
}
|
||||
function markerRequestIntegrity(g: Game) {
|
||||
const entry = g.pendingAmbassador, request = entry?.guildMarkerRequest;
|
||||
if (!request) return;
|
||||
const owner = entry.beneficiary && markerOwner(g, entry.beneficiary);
|
||||
requireRule(entry.stage === 'ship' && entry.effect === 'guild' && owner &&
|
||||
owner.id !== entry.beneficiary && owner.id === request.owner && owner.noFieldEvent === request.tokenEvent &&
|
||||
guildMarkerAvailable(g, entry.beneficiary!) && validGameLocation(g, request.territory, request.sector),
|
||||
'This allied No-Field request has lost its original supplier or grant.');
|
||||
if (request.offer) {
|
||||
const order = request.offer;
|
||||
requireRule(order.player === entry.beneficiary && order.territory === request.territory &&
|
||||
order.sector === request.sector && order.marker.owner === request.owner &&
|
||||
order.marker.tokenEvent === request.tokenEvent,
|
||||
'The allied marker offer differs from its requested arrival.');
|
||||
}
|
||||
}
|
||||
function ambassadorMarkerView(g: Game, id: string): GuildMarkerView | null {
|
||||
const entry = g.pendingAmbassador;
|
||||
if (!entry || entry.stage !== 'ship' || entry.effect !== 'guild' ||
|
||||
g.decision?.kind !== 'ecazAmbassador' || g.decision.player !== id ||
|
||||
!entry.beneficiary || !guildMarkerAvailable(g, entry.beneficiary)) return null;
|
||||
markerRequestIntegrity(g);
|
||||
const beneficiary = getPlayer(g, entry.beneficiary), supplier = markerOwner(g, beneficiary.id)!;
|
||||
const request = entry.guildMarkerRequest;
|
||||
const view: GuildMarkerView = {stage: request ? request.offer ? 'consent' : 'supply' :
|
||||
supplier.id === beneficiary.id ? 'own' : 'request', policy: GUILD_MARKER_POLICY, options: []};
|
||||
const add = (label: string, action: Action, at?: {territory: string; sector: number}, blocked: string | null = null) =>
|
||||
view.options.push({key: JSON.stringify(action), label, action, blocked, ...at});
|
||||
if (request?.offer) {
|
||||
const offer = request.offer;
|
||||
let blocked: string | null = null;
|
||||
try { quoteAmbassadorMarker(g, beneficiary.id, {...offer.marker, territory: offer.territory,
|
||||
sector: offer.sector, elite: offer.elite}); } catch (error) { if (!(error instanceof RuleError)) throw error; blocked = error.message; }
|
||||
add(`Accept No-Field ${offer.marker.value}: ${offer.amount} physical forces to ${territory(offer.territory).name}, sector ${offer.sector}. ${getPlayer(g, offer.marker.payer === 'both' ? supplier.id : offer.marker.payer).name}${offer.marker.payer === 'both' ? ' and recipient each pay 1' : ` pays ${offer.cost}`} spice to Guild or Bank.`,
|
||||
{type: 'decision', event: entry.event, acceptNoField: true}, undefined, blocked);
|
||||
} else if (supplier.noField?.deployed && supplier.id === beneficiary.id) {
|
||||
add('Reveal your existing No-Field before choosing another marker', {type:'decision', event:entry.event, revealNoField:true});
|
||||
} else {
|
||||
let destinations: ReturnType<typeof markerDestinations> = [];
|
||||
try { destinations = guildAmbassadorRule(() => markerDestinations(g, beneficiary.id)); } catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
if (view.stage === 'request') {
|
||||
for (const at of destinations) add(`Ask Richese for a No-Field shipment to ${territory(at.territory).name}, sector ${at.sector}`,
|
||||
{type:'decision', event:entry.event, requestNoField:true, territory:at.territory, sector:at.sector}, at);
|
||||
} else for (const at of request ? [{territory:request.territory,sector:request.sector}] : destinations) {
|
||||
for (const token of supplier.noField!.tokens.filter(token => token.id !== supplier.noField!.lastShipped)) {
|
||||
const cost = territory(at.territory).type === 'stronghold' ? 1 : 2;
|
||||
for (const payer of view.stage === 'own' ? [supplier.id] : [supplier.id,beneficiary.id,...(cost === 2 ? ['both'] : [])]) {
|
||||
try {
|
||||
const order = quoteAmbassadorMarker(g, beneficiary.id, {tokenId:token.id, tokenEvent:supplier.noFieldEvent!, ...at, payer}, view.stage === 'own');
|
||||
add(view.stage === 'own' ? `Ship concealed No-Field ${token.value} for free to ${territory(at.territory).name}, sector ${at.sector}` :
|
||||
`Offer No-Field ${token.value}: ${order.amount} forces; ${payer === 'both' ? 'each ally pays 1 spice' : `${getPlayer(g,payer).name} pays ${order.cost} spice`}`,
|
||||
view.stage === 'own' ? {type:'decision',event:entry.event,amount:1,noField:token.id,noFieldEvent:supplier.noFieldEvent, ...at} :
|
||||
{type:'decision',event:entry.event,offerNoField:token.id,noFieldEvent:supplier.noFieldEvent,payer,elite:order.elite}, at);
|
||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (request) { view.decline = {type:'decision',event:entry.event,declineNoField:true}; view.declineLabel = 'Return to the original shipment choice'; }
|
||||
return view;
|
||||
}
|
||||
function declareAmbassadorMarker(g: Game, order: GuildAmbassadorMarkerQuote) {
|
||||
const entry = g.pendingAmbassador!;
|
||||
validateAmbassadorShipmentArrival(g, order);
|
||||
const {marker,...shipment} = order;
|
||||
g.pendingShipment = {...shipment, ambassadorMarker:marker, turn:g.turn, source:'ambassador', ambassadorEvent:entry.event};
|
||||
g.response = {kind:'richeseNoField',owner:order.marker.owner,passed:[],source:'ambassadorNoField',intent:entry.event};
|
||||
g.decision = null;
|
||||
log(g, 'A No-Field substitution was declared for the independent Guild Ambassador shipment. Its payment, token and forces wait for the response.');
|
||||
}
|
||||
function validateAmbassadorMarkerResponse(g: Game, response: ResponseWindow) {
|
||||
const shipment = g.pendingShipment, entry = g.pendingAmbassador;
|
||||
requireRule(response.kind === 'richeseNoField' && response.source === 'ambassadorNoField' &&
|
||||
entry?.stage === 'ship' && entry.effect === 'guild' && response.intent === entry.event &&
|
||||
shipment?.source === 'ambassador' && shipment.ambassadorEvent === entry.event &&
|
||||
shipment.ambassadorMarker?.owner === response.owner,
|
||||
'The No-Field response has lost its original independent grant.');
|
||||
validateAmbassadorShipmentOrder(g, shipment);
|
||||
return shipment;
|
||||
}
|
||||
function decideAmbassadorMarker(g: Game, actor: Player, action: Action): boolean {
|
||||
const {entry,beneficiary} = currentGuildAmbassador(g, stringField(action.event));
|
||||
const request = entry.guildMarkerRequest;
|
||||
markerRequestIntegrity(g);
|
||||
if (request) {
|
||||
if (action.declineNoField === true) {
|
||||
delete entry.guildMarkerRequest;
|
||||
g.decision = {kind:'ecazAmbassador',player:beneficiary.id};
|
||||
return true;
|
||||
}
|
||||
if (!request.offer) {
|
||||
requireRule(actor.id === request.owner, 'Richese chooses its own physical marker and payment.');
|
||||
request.offer = quoteAmbassadorMarker(g, beneficiary.id, {tokenId:stringField(action.offerNoField),
|
||||
tokenEvent:stringField(action.noFieldEvent), territory:request.territory, sector:request.sector,
|
||||
payer:stringField(action.payer), elite:action.elite === undefined ? undefined : integer(action.elite,0,4,'Elite forces')}, false);
|
||||
g.decision = {kind:'ecazAmbassador',player:beneficiary.id};
|
||||
} else {
|
||||
requireRule(actor.id === beneficiary.id && action.acceptNoField === true, 'Accept or decline this allied No-Field offer.');
|
||||
const offer = request.offer;
|
||||
const quoted = quoteAmbassadorMarker(g,beneficiary.id,{...offer.marker, territory:offer.territory,sector:offer.sector,elite:offer.elite});
|
||||
requireRule(JSON.stringify(quoted) === JSON.stringify(offer), 'The offered token, physical forces or payment changed. Request a fresh offer.');
|
||||
declareAmbassadorMarker(g,quoted);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (action.requestNoField === true) {
|
||||
const supplier = markerOwner(g,beneficiary.id);
|
||||
requireRule(guildMarkerAvailable(g,beneficiary.id) && supplier && supplier.id !== beneficiary.id,
|
||||
'A current Richese ally must choose and authorize its own marker.');
|
||||
const to = stringField(action.territory), sector = integer(action.sector,0,18,'Sector');
|
||||
requireRule(guildAmbassadorRule(() => markerDestinations(g,beneficiary.id)).some(at => at.territory === to && at.sector === sector), 'Choose an available independent shipment destination.');
|
||||
entry.guildMarkerRequest = {owner:supplier.id,tokenEvent:supplier.noFieldEvent!,territory:to,sector};
|
||||
g.decision = {kind:'ecazAmbassador',player:supplier.id};
|
||||
return true;
|
||||
}
|
||||
if (action.revealNoField === true) {
|
||||
requireRule(guildMarkerAvailable(g,beneficiary.id) && actor.id === beneficiary.id && actor.faction === 'richese' && actor.noField?.deployed,
|
||||
'Only Richese may reveal its existing marker before its independent shipment.');
|
||||
revealPlayerNoField(g,actor,'voluntary');
|
||||
g.decision = {kind:'ecazAmbassador',player:beneficiary.id};
|
||||
return true;
|
||||
}
|
||||
if (action.noField !== undefined) {
|
||||
requireRule(actor.id === beneficiary.id && actor.faction === 'richese' && action.amount === 1 &&
|
||||
(action.elite === undefined || action.elite === 0) &&
|
||||
(action.allyPayment === undefined || action.allyPayment === 0) && action.alliedNoField === undefined,
|
||||
'Substitute one native marker for the whole grant, without additional counters.');
|
||||
declareAmbassadorMarker(g,quoteAmbassadorMarker(g,beneficiary.id,{tokenId:stringField(action.noField),
|
||||
tokenEvent:stringField(action.noFieldEvent),territory:stringField(action.territory),sector:integer(action.sector,0,18,'Sector')}));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function quoteAmbassadorShipment(g: Game, action: Action) {
|
||||
const { beneficiary } = currentGuildAmbassador(g, stringField(action.event));
|
||||
return guildAmbassadorRule(() =>
|
||||
@@ -17862,7 +18036,7 @@ function offerAmbassadorShipment(g: Game) {
|
||||
const choices = guildAmbassadorRule(() =>
|
||||
guildAmbassadorShipments(g, beneficiary.id),
|
||||
);
|
||||
if (choices.maximum && choices.destinations.length)
|
||||
if ((choices.maximum && choices.destinations.length) || guildMarkerAvailable(g,beneficiary.id))
|
||||
g.decision = { kind: 'ecazAmbassador', player: beneficiary.id };
|
||||
else {
|
||||
log(
|
||||
@@ -17899,6 +18073,20 @@ function validateAmbassadorShipmentOrder(g: Game, shipment: PendingShipment) {
|
||||
!shipment.alliedNoField,
|
||||
'This declaration does not match its independent Guild Ambassador shipment.',
|
||||
);
|
||||
if (shipment.ambassadorMarker) {
|
||||
const marker = shipment.ambassadorMarker;
|
||||
const order = quoteAmbassadorMarker(g,beneficiary.id,{...marker,territory:shipment.territory,
|
||||
sector:shipment.sector,elite:shipment.elite});
|
||||
requireRule(shipment.amount === order.amount && shipment.elite === order.elite &&
|
||||
shipment.cost === order.cost && shipment.allyPayment === 0 && shipment.advisors === order.advisors &&
|
||||
JSON.stringify(marker) === JSON.stringify(order.marker),
|
||||
'The declared independent marker changed its original token, payment or force allocation.');
|
||||
if (marker.mode === 'allied') requireRule(entry.guildMarkerRequest?.offer &&
|
||||
JSON.stringify(entry.guildMarkerRequest.offer) === JSON.stringify(order),
|
||||
'This allied marker has no matching supplier offer and recipient consent.');
|
||||
validateAmbassadorShipmentArrival(g,order);
|
||||
return order;
|
||||
}
|
||||
const order = quoteAmbassadorShipment(g, {
|
||||
type: 'decision',
|
||||
event: entry.event,
|
||||
@@ -17916,8 +18104,46 @@ function validateAmbassadorShipmentOrder(g: Game, shipment: PendingShipment) {
|
||||
validateAmbassadorShipmentArrival(g, order);
|
||||
return order;
|
||||
}
|
||||
function commitAmbassadorMarker(g: Game, order: GuildAmbassadorShipment & {marker: GuildAmbassadorMarker}) {
|
||||
const entry = g.pendingAmbassador!, p = getPlayer(g,order.player), marker = order.marker;
|
||||
const supplier = getPlayer(g,marker.owner);
|
||||
if (marker.mode === 'allied' && supplier.noField!.deployed) revealPlayerNoField(g,supplier,'beforeAlly');
|
||||
if (marker.mode === 'own') supplier.noField = noFieldRule(() => deployRicheseNoField(supplier.noField!,{
|
||||
tokenId:marker.tokenId,controller:p.id,location:{territory:order.territory,sector:order.sector},
|
||||
}));
|
||||
else {
|
||||
const result = noFieldRule(() => shipAlliedRicheseNoField(supplier.noField!,{
|
||||
tokenId:marker.tokenId,controller:p.id,location:{territory:order.territory,sector:order.sector},
|
||||
},p.reserves));
|
||||
requireRule(result.forces === order.amount, 'The approved marker no longer matches its physical arrival.');
|
||||
supplier.noField = result.state;
|
||||
p.reserves -= order.amount;
|
||||
if (p.elites) p.elites.reserves -= order.elite;
|
||||
if (order.amount) place(p,order.territory,order.sector,order.amount,order.elite);
|
||||
}
|
||||
supplier.noFieldEvent = crypto.randomUUID();
|
||||
supplier.spice -= marker.ownerPayment;
|
||||
p.spice -= marker.recipientPayment;
|
||||
observeOccupation(g);
|
||||
delete entry.guildMarkerRequest;
|
||||
entry.stage = 'arrival';
|
||||
const guild = byFaction(g,'guild');
|
||||
entry.shipmentReceipt = {order,markerCommitSignature:JSON.stringify(order),next:order.cost > 0 && guild ? 'income' : 'intrusion'};
|
||||
log(g, marker.mode === 'own'
|
||||
? `${p.name} shipped one concealed No-Field for free through the Guild Ambassador. Its value remains private and ordinary shipment is unchanged.`
|
||||
: `${supplier.name}'s No-Field ${marker.value} immediately revealed ${order.amount} ${p.name} forces through the Guild Ambassador. The allies paid ${order.cost} spice to ${guild ? 'Guild' : 'Bank'} under the provisional independent-grant policy; ordinary shipment is unchanged.`,
|
||||
{faction:p.faction,name:'Guild Ambassador No-Field shipment'});
|
||||
if (entry.shipmentReceipt.next === 'income') {
|
||||
g.response = {...guildPaymentResponse(g,guild!.id,[marker.ownerPayment,marker.recipientPayment],undefined,
|
||||
[{payer:supplier.id,amount:marker.ownerPayment},{payer:p.id,amount:marker.recipientPayment}]),
|
||||
source:'ambassadorNoFieldIncome',intent:entry.event};
|
||||
return;
|
||||
}
|
||||
continueGuildAmbassadorShipment(g,entry.event);
|
||||
}
|
||||
function commitAmbassadorShipment(g: Game, shipment: PendingShipment) {
|
||||
const order = validateAmbassadorShipmentOrder(g, shipment);
|
||||
if (order.marker) { commitAmbassadorMarker(g,{...order,marker:order.marker}); return; }
|
||||
const { entry, beneficiary: p } = currentGuildAmbassador(
|
||||
g,
|
||||
shipment.ambassadorEvent,
|
||||
@@ -17956,6 +18182,7 @@ function continueGuildAmbassadorShipment(g: Game, event: string) {
|
||||
const receipt = entry.shipmentReceipt;
|
||||
const { order } = guildAmbassadorArrival(g, event, receipt.next);
|
||||
const p = getPlayer(g, order.player);
|
||||
if (receipt.next === 'income') return;
|
||||
if (receipt.next === 'intrusion') {
|
||||
receipt.next = 'terror';
|
||||
if (intrusion(g, p, order.territory, { ambassadorEvent: event })) return;
|
||||
@@ -17968,7 +18195,7 @@ function continueGuildAmbassadorShipment(g: Game, event: string) {
|
||||
p,
|
||||
order.territory,
|
||||
order.sector,
|
||||
order.amount,
|
||||
order.marker ? Math.max(1,order.amount) : order.amount,
|
||||
order.elite,
|
||||
'ambassador',
|
||||
'ambassador',
|
||||
@@ -19099,7 +19326,7 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
|
||||
const owner = getPlayer(g, entry.owner),
|
||||
entrant = getPlayer(g, entry.entrant);
|
||||
requireRule(
|
||||
p.id === (entry.stage === 'offer' ? owner.id : entry.beneficiary),
|
||||
p.id === (entry.stage === 'offer' ? owner.id : entry.stage === 'ship' && entry.guildMarkerRequest && !entry.guildMarkerRequest.offer ? entry.guildMarkerRequest.owner : entry.beneficiary),
|
||||
'The Ambassador choice belongs to another player.',
|
||||
);
|
||||
if (entry.stage === 'loan') {
|
||||
@@ -19374,6 +19601,7 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
|
||||
resolveAmbassadorEffect(g);
|
||||
} else if (entry.stage === 'ship') {
|
||||
currentGuildAmbassador(g, stringField(action.event));
|
||||
if (decideAmbassadorMarker(g,p,action)) return;
|
||||
if (action.amount === 0) {
|
||||
requireRule(
|
||||
action.noField === undefined && action.alliedNoField === undefined,
|
||||
@@ -27116,6 +27344,15 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
g.decision = resume.decision;
|
||||
g.pendingKarama = resume.pendingKarama;
|
||||
g.phaseOpening = resume.phaseOpening;
|
||||
} else if (response.kind === 'richeseNoField' && response.source === 'ambassadorNoField') {
|
||||
const shipment = validateAmbassadorMarkerResponse(g,response), entry = g.pendingAmbassador!;
|
||||
g.pendingShipment = null;
|
||||
if (canceled) {
|
||||
entry.guildMarkerBlocked = true;
|
||||
delete entry.guildMarkerRequest;
|
||||
g.decision = {kind:'ecazAmbassador',player:entry.beneficiary!};
|
||||
log(g,'Karama prevented No-Field substitution for this independent Guild Ambassador grant. No payment, force or token changed; physical reserve shipment remains available.');
|
||||
} else offerShipment(g,shipment);
|
||||
} else if (response.kind === 'richeseNoField') {
|
||||
const quote = canceled ? noFieldCancellationQuote(g, response) : null;
|
||||
const shipment = g.pendingShipment;
|
||||
@@ -27983,6 +28220,15 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
name: canceled ? 'Shipment income prevented' : 'Shipment income',
|
||||
},
|
||||
);
|
||||
if (response.source === 'ambassadorNoFieldIncome') {
|
||||
const entry = g.pendingAmbassador!;
|
||||
requireRule(entry?.stage === 'arrival' && entry.shipmentReceipt?.next === 'income' &&
|
||||
response.intent === entry.event && response.owner === byFaction(g,'guild')?.id &&
|
||||
response.amount === entry.shipmentReceipt.order.cost,
|
||||
'This Guild income has lost its committed No-Field shipment.');
|
||||
entry.shipmentReceipt.next = 'intrusion';
|
||||
continueGuildAmbassadorShipment(g,entry.event);
|
||||
}
|
||||
} else if (response.kind === 'harkonnenTraitor') {
|
||||
const b = g.battle!;
|
||||
if (canceled) b.traitorCalls[response.owner] = false;
|
||||
@@ -29188,11 +29434,11 @@ function finishGuildTransportRateResponse(g: Game, response: ResponseWindow, can
|
||||
function offerShipment(g: Game, shipment: PendingShipment) {
|
||||
validatePhysicalShipment(g, shipment);
|
||||
validateShipmentArrival(g, shipment);
|
||||
checkShipmentIncomeFunding(g, getPlayer(g, shipment.player), shipment.cost, shipment.allyPayment);
|
||||
if (!shipment.ambassadorMarker) checkShipmentIncomeFunding(g, getPlayer(g, shipment.player), shipment.cost, shipment.allyPayment);
|
||||
const p = getPlayer(g, shipment.player),
|
||||
id = p.id;
|
||||
const { territory: to, sector: s } = shipment;
|
||||
const n = shipment.alliedNoField ? 1 : shipment.amount;
|
||||
const n = shipment.alliedNoField || shipment.ambassadorMarker ? 1 : shipment.amount;
|
||||
const guild = byFaction(g, 'guild');
|
||||
if (
|
||||
g.advanced &&
|
||||
@@ -36057,6 +36303,7 @@ export function viewGame(state: Game, id: string) {
|
||||
stage: entry.stage,
|
||||
effect: entry.effect ?? token.effect,
|
||||
beneficiary: entry.beneficiary ?? null,
|
||||
noField: ambassadorMarkerView(g, id),
|
||||
shipment:
|
||||
entry.stage === 'ship' && id === entry.beneficiary
|
||||
? (() => {
|
||||
|
||||
@@ -26,6 +26,7 @@ export type GuildAmbassadorArrivalContext = Pick<
|
||||
| 'lastBattleContext'
|
||||
>;
|
||||
export type GuildAmbassadorArrivalNext =
|
||||
| 'income'
|
||||
| 'intrusion'
|
||||
| 'terror'
|
||||
| 'accompany'
|
||||
@@ -78,7 +79,7 @@ export function validateGuildAmbassadorArrivalContext(
|
||||
entry.phase === g.phase &&
|
||||
id(event) &&
|
||||
entry.event === event &&
|
||||
['intrusion', 'terror', 'accompany', 'advisor', 'finish'].includes(
|
||||
['income', 'intrusion', 'terror', 'accompany', 'advisor', 'finish'].includes(
|
||||
next,
|
||||
) &&
|
||||
record(entry.shipmentReceipt) &&
|
||||
@@ -134,11 +135,11 @@ export function validateGuildAmbassadorArrivalContext(
|
||||
record(order) &&
|
||||
order.player === beneficiary.id &&
|
||||
whole(order.amount) &&
|
||||
order.amount >= 1 &&
|
||||
(order.amount >= 1 || !!order.marker) &&
|
||||
order.amount <= 4 &&
|
||||
whole(order.elite) &&
|
||||
order.elite <= order.amount &&
|
||||
order.cost === 0 &&
|
||||
(order.marker ? [0,1,2].includes(order.cost) : order.cost === 0) &&
|
||||
order.allyPayment === 0 &&
|
||||
typeof order.advisors === 'boolean' &&
|
||||
boardLocation(order.territory, order.sector) &&
|
||||
@@ -146,6 +147,21 @@ export function validateGuildAmbassadorArrivalContext(
|
||||
!Object.hasOwn(order, 'alliedNoField'),
|
||||
'The saved Guild Ambassador receipt is not a physical free reserve shipment.',
|
||||
);
|
||||
if (order.marker) {
|
||||
const marker = order.marker, supplier = g.players.find(p => p.id === marker.owner);
|
||||
requireArrival(entry.shipmentReceipt.markerCommitSignature === JSON.stringify(order) &&
|
||||
supplier?.faction === 'richese' && id(marker.tokenEvent) && id(marker.stateSignature) &&
|
||||
supplier.noField?.tokens.some(token => token.id === marker.tokenId && token.value === marker.value) &&
|
||||
(marker.mode === 'own' ? marker.owner === beneficiary.id && order.amount === 1 && order.elite === 0 &&
|
||||
order.cost === 0 && marker.ownerPayment === 0 && marker.recipientPayment === 0 :
|
||||
marker.mode === 'allied' && marker.owner !== beneficiary.id &&
|
||||
order.amount <= marker.value && order.cost === (territory(order.territory).type === 'stronghold' ? 1 : 2) &&
|
||||
whole(marker.ownerPayment) && whole(marker.recipientPayment) &&
|
||||
marker.ownerPayment + marker.recipientPayment === order.cost) &&
|
||||
(next !== 'income' || marker.mode === 'allied' && order.cost > 0),
|
||||
'The committed independent marker shipment has lost its original receipt.');
|
||||
} else requireArrival(next !== 'income' && entry.shipmentReceipt.markerCommitSignature === undefined,
|
||||
'An ordinary free shipment has no marker payment continuation.');
|
||||
const advisorArrival = entry.shipmentReceipt.advisorArrival;
|
||||
requireArrival(
|
||||
next === 'finish' ? record(advisorArrival) : advisorArrival === undefined,
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import type { Game, Player } from './engine';
|
||||
import { arrivalAsAdvisor } from './advisors';
|
||||
import {
|
||||
gameTerritories,
|
||||
location,
|
||||
MOBILE_STRONGHOLD,
|
||||
splitLocation,
|
||||
TERRITORIES,
|
||||
territory,
|
||||
validGameLocation,
|
||||
} from './board';
|
||||
import { FACTIONS } from './catalog';
|
||||
import { presenceAt, type ForcePresence } from './force-presence';
|
||||
import { territoryEntryBlock } from './occupancy';
|
||||
import {
|
||||
deployRicheseNoField,
|
||||
revealRicheseNoField,
|
||||
shipAlliedRicheseNoField,
|
||||
validateRicheseNoField,
|
||||
} from './richese-no-field';
|
||||
|
||||
export class GuildAmbassadorMarkerError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'GuildAmbassadorMarkerError';
|
||||
}
|
||||
}
|
||||
export type GuildAmbassadorMarkerSelection = {
|
||||
tokenId: string;
|
||||
tokenEvent: string;
|
||||
territory: string;
|
||||
sector: number;
|
||||
payer?: string;
|
||||
elite?: number;
|
||||
};
|
||||
export type GuildAmbassadorMarker = {
|
||||
mode: 'own' | 'allied';
|
||||
owner: string;
|
||||
tokenId: string;
|
||||
tokenEvent: string;
|
||||
value: 0 | 3 | 5;
|
||||
payer: string;
|
||||
ownerPayment: number;
|
||||
recipientPayment: number;
|
||||
stateSignature: string;
|
||||
};
|
||||
export type GuildAmbassadorMarkerQuote = {
|
||||
player: string;
|
||||
amount: number;
|
||||
elite: number;
|
||||
territory: string;
|
||||
sector: number;
|
||||
advisors: boolean;
|
||||
cost: 0 | 1 | 2;
|
||||
allyPayment: 0;
|
||||
marker: GuildAmbassadorMarker;
|
||||
};
|
||||
type BoardSeat = Pick<Player, 'id' | 'faction' | 'ally' | 'advisors'> & ForcePresence;
|
||||
type MarkerContext = {
|
||||
owner: Player;
|
||||
recipient: Player;
|
||||
mode: 'own' | 'allied';
|
||||
seats: BoardSeat[];
|
||||
};
|
||||
const text = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 0;
|
||||
const count = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
function requireMarker(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new GuildAmbassadorMarkerError(message);
|
||||
}
|
||||
function noFieldRule<T>(operation: () => T): T {
|
||||
try {
|
||||
return operation();
|
||||
} catch (error) {
|
||||
if (error instanceof GuildAmbassadorMarkerError) throw error;
|
||||
throw new GuildAmbassadorMarkerError(
|
||||
error instanceof Error ? error.message : 'Invalid No-Field custody.',
|
||||
);
|
||||
}
|
||||
}
|
||||
function usableLocation(g: Game, key: string) {
|
||||
const at = splitLocation(key);
|
||||
return location(at.territory, at.sector) === key &&
|
||||
validGameLocation(g, at.territory, at.sector) &&
|
||||
(at.territory !== MOBILE_STRONGHOLD || !!g.mobileStronghold?.location);
|
||||
}
|
||||
function printedLocation(to: string, sector: number) {
|
||||
return TERRITORIES.some((candidate) =>
|
||||
candidate.id === to && candidate.sectors.includes(sector));
|
||||
}
|
||||
|
||||
/** Identify only the native owner or the beneficiary's current mutual Richese
|
||||
* ally. Inventory, event and physical custody are validated by the quote. */
|
||||
export function markerOwner(g: Game, beneficiary: string): Player | null {
|
||||
const recipient = g.players.find((candidate) => candidate.id === beneficiary);
|
||||
if (!recipient) return null;
|
||||
if (recipient.faction === 'richese') return recipient;
|
||||
return g.players.find((candidate) => candidate.faction === 'richese' &&
|
||||
candidate.id === recipient.ally && candidate.ally === recipient.id) ?? null;
|
||||
}
|
||||
|
||||
function validateReserves(p: Player) {
|
||||
requireMarker(count(p.reserves) && count(p.elites?.reserves ?? 0) &&
|
||||
(p.elites?.reserves ?? 0) <= p.reserves,
|
||||
'The physical or elite reserves are invalid.');
|
||||
}
|
||||
function context(g: Game, beneficiary: string): MarkerContext {
|
||||
requireMarker(Array.isArray(g.players) && g.players.length > 0 &&
|
||||
g.players.every((p) => record(p) && text(p.id) &&
|
||||
FACTIONS.some((faction) => faction.id === p.faction)) &&
|
||||
new Set(g.players.map((p) => p.id)).size === g.players.length &&
|
||||
new Set(g.players.map((p) => p.faction)).size === g.players.length,
|
||||
'The No-Field shipment requires unique valid seated identities and factions.');
|
||||
requireMarker(count(g.storm) && g.storm <= 18,
|
||||
'The storm sector is invalid.');
|
||||
if (g.mobileStronghold?.location) {
|
||||
const pointer = splitLocation(g.mobileStronghold.location);
|
||||
requireMarker(usableLocation(g, g.mobileStronghold.location) &&
|
||||
printedLocation(pointer.territory, pointer.sector),
|
||||
'The mobile stronghold must point to a printed board location.');
|
||||
}
|
||||
const seats: BoardSeat[] = [];
|
||||
for (const p of g.players) {
|
||||
requireMarker(p.ally === null || p.ally === undefined ||
|
||||
(text(p.ally) && g.players.some((other) => other.id === p.ally &&
|
||||
other.id !== p.id && other.ally === p.id)),
|
||||
'The shipment requires current mutual seated alliances.');
|
||||
requireMarker(record(p.forces) && (!p.elites ||
|
||||
(record(p.elites) && record(p.elites.forces))),
|
||||
'The board force custody is invalid.');
|
||||
const totals: Record<string, number> = {};
|
||||
for (const [key, amount] of Object.entries(p.forces)) {
|
||||
requireMarker(usableLocation(g, key) && count(amount),
|
||||
'Invalid board force location or quantity.');
|
||||
const to = splitLocation(key).territory;
|
||||
totals[to] = (totals[to] ?? 0) + amount;
|
||||
requireMarker(count(totals[to]), 'The territory force quantity is invalid.');
|
||||
}
|
||||
for (const [key, amount] of Object.entries(p.elites?.forces ?? {}))
|
||||
requireMarker(usableLocation(g, key) && count(amount) &&
|
||||
amount <= (p.forces[key] ?? 0),
|
||||
'Elite forces must be part of the physical force inventory.');
|
||||
const deployed = p.noField?.deployed;
|
||||
requireMarker(!p.noField || p.faction === 'richese',
|
||||
'Only Richese may hold a No-Field inventory.');
|
||||
requireMarker(deployed === undefined || deployed === null ||
|
||||
(record(deployed) && record(deployed.location) &&
|
||||
text(deployed.location.territory) && count(deployed.location.sector) &&
|
||||
usableLocation(g, location(deployed.location.territory,
|
||||
deployed.location.sector)) &&
|
||||
count((totals[deployed.location.territory] ?? 0) + 1)),
|
||||
'The concealed marker has an invalid public location.');
|
||||
requireMarker(!p.advisors || record(p.advisors),
|
||||
'The advisor inventory is invalid.');
|
||||
for (const [to, stance] of Object.entries(p.advisors ?? {}))
|
||||
requireMarker(gameTerritories(g).some((candidate) => candidate.id === to) &&
|
||||
record(stance) && (stance.lockedTurn === undefined || count(stance.lockedTurn)),
|
||||
'The advisor stance is invalid.');
|
||||
// Copy public custody explicitly: no hands, balances or opponent token
|
||||
// denominations are needed to forecast the compulsory prior reveal.
|
||||
seats.push({
|
||||
id: p.id,
|
||||
faction: p.faction,
|
||||
ally: p.ally,
|
||||
forces: { ...p.forces },
|
||||
advisors: p.advisors,
|
||||
noField: deployed ? { deployed: {
|
||||
location: { ...deployed.location },
|
||||
} } : undefined,
|
||||
});
|
||||
}
|
||||
const recipient = g.players.find((p) => p.id === beneficiary);
|
||||
const owner = markerOwner(g, beneficiary);
|
||||
requireMarker(recipient && owner,
|
||||
'Choose Richese or its current mutual ally for the No-Field shipment.');
|
||||
requireMarker(owner.noField && text(owner.noFieldEvent),
|
||||
'The No-Field inventory and selection event must be initialized.');
|
||||
noFieldRule(() => validateRicheseNoField(owner.noField!));
|
||||
requireMarker(!owner.noField.deployed ||
|
||||
owner.noField.deployed.controller === owner.id,
|
||||
'The existing concealed marker must be controlled by its Richese owner.');
|
||||
validateReserves(owner);
|
||||
validateReserves(recipient);
|
||||
const mode = owner.id === recipient.id ? 'own' : 'allied';
|
||||
requireMarker(mode === 'own' || recipient.faction !== 'fremen',
|
||||
'Fremen reserves are on the planet; allied No-Field shipment needs off-planet reserves.');
|
||||
return { owner, recipient, mode, seats };
|
||||
}
|
||||
|
||||
function forecastPriorReveal(c: MarkerContext) {
|
||||
if (c.mode === 'own') {
|
||||
requireMarker(!c.owner.noField!.deployed,
|
||||
'Reveal the existing No-Field explicitly before using this grant.');
|
||||
return c.owner.noField!;
|
||||
}
|
||||
const previous = c.owner.noField!;
|
||||
if (!previous.deployed) return previous;
|
||||
const revealed = noFieldRule(() => revealRicheseNoField(previous, {
|
||||
tokenId: previous.deployed!.tokenId,
|
||||
reserves: c.owner.reserves,
|
||||
cause: 'beforeAlly',
|
||||
}));
|
||||
const owner = c.seats.find((seat) => seat.id === c.owner.id)!;
|
||||
const key = location(revealed.location.territory, revealed.location.sector);
|
||||
const total = (owner.forces[key] ?? 0) + revealed.forces;
|
||||
requireMarker(count(total), 'The prior reveal produces an invalid force quantity.');
|
||||
if (revealed.forces) owner.forces = { ...owner.forces, [key]: total };
|
||||
owner.noField = undefined;
|
||||
requireMarker(count(presenceAt(owner, revealed.location.territory)),
|
||||
'The prior reveal produces an invalid territory force quantity.');
|
||||
return revealed.state;
|
||||
}
|
||||
function destination(g: Game, c: MarkerContext, to: string, sector: number) {
|
||||
requireMarker(text(to) && count(sector) && printedLocation(to, sector) &&
|
||||
to !== MOBILE_STRONGHOLD,
|
||||
'Choose a printed planet territory and sector for the No-Field shipment.');
|
||||
requireMarker(sector === 0 || sector !== g.storm,
|
||||
'The shipment destination is in storm.');
|
||||
const recipient = c.seats.find((seat) => seat.id === c.recipient.id)!;
|
||||
const advisors = arrivalAsAdvisor({ advanced: g.advanced, players: c.seats },
|
||||
recipient, to);
|
||||
const blocked = noFieldRule(() => territoryEntryBlock(c.seats, recipient.id,
|
||||
to, advisors));
|
||||
requireMarker(!blocked, blocked ?? 'This destination cannot be entered.');
|
||||
requireMarker(c.mode !== 'own' || count(presenceAt(recipient, to) + 1),
|
||||
'The resulting marker presence is invalid.');
|
||||
return advisors;
|
||||
}
|
||||
|
||||
/** Whole-grant provisional composition: native Richese substitutes one hidden
|
||||
* marker for the free grant. An allied marker reveals immediately and must
|
||||
* materialize at most four actual recipient counters. Its compulsory printed
|
||||
* one/two-spice fee remains payable; the grant adds no other counters or rate.
|
||||
* The caller binds the live Ambassador, parent event, consent, prevention and
|
||||
* uncommitted budgets. Nothing here moves tokens, forces, cards or spice. */
|
||||
export function quoteGuildAmbassadorMarker(
|
||||
g: Game,
|
||||
beneficiary: string,
|
||||
selection: GuildAmbassadorMarkerSelection,
|
||||
checkFunds = true,
|
||||
): GuildAmbassadorMarkerQuote {
|
||||
requireMarker(record(selection) && text(selection.tokenId) &&
|
||||
text(selection.tokenEvent) && text(selection.territory) &&
|
||||
count(selection.sector) &&
|
||||
(selection.payer === undefined || text(selection.payer)) &&
|
||||
(selection.elite === undefined || count(selection.elite)),
|
||||
'Choose a valid No-Field token, selection event, destination and force type.');
|
||||
const c = context(g, beneficiary), { owner, recipient, mode } = c;
|
||||
requireMarker(selection.tokenEvent === owner.noFieldEvent,
|
||||
'The No-Field selection event is stale.');
|
||||
const previous = forecastPriorReveal(c);
|
||||
const deployment = {
|
||||
tokenId: selection.tokenId,
|
||||
controller: recipient.id,
|
||||
location: { territory: selection.territory, sector: selection.sector },
|
||||
};
|
||||
let amount: number;
|
||||
let value: 0 | 3 | 5;
|
||||
if (mode === 'own') {
|
||||
noFieldRule(() => deployRicheseNoField(previous, deployment));
|
||||
value = previous.tokens.find((token) => token.id === selection.tokenId)!.value;
|
||||
amount = 1;
|
||||
} else {
|
||||
const revealed = noFieldRule(() => shipAlliedRicheseNoField(previous,
|
||||
deployment, recipient.reserves));
|
||||
amount = revealed.forces;
|
||||
value = revealed.value;
|
||||
requireMarker(amount <= 4,
|
||||
'The allied No-Field must materialize at most four available reserve forces.');
|
||||
}
|
||||
const advisors = destination(g, c, selection.territory, selection.sector);
|
||||
const eliteReserves = recipient.elites?.reserves ?? 0;
|
||||
const minimum = mode === 'own' ? 0 :
|
||||
Math.max(0, amount - (recipient.reserves - eliteReserves));
|
||||
const elite = selection.elite ?? minimum;
|
||||
requireMarker(count(elite) && (mode === 'own' ? elite === 0 :
|
||||
elite >= minimum && elite <= Math.min(amount, eliteReserves)),
|
||||
'Choose available elites within the materialized allied forces; native markers contain no elites.');
|
||||
const at = location(selection.territory, selection.sector);
|
||||
const boardRecipient = c.seats.find((seat) => seat.id === recipient.id)!;
|
||||
requireMarker(count((boardRecipient.forces[at] ?? 0) + amount) &&
|
||||
count(presenceAt(boardRecipient, selection.territory) + amount) &&
|
||||
count((recipient.elites?.forces[at] ?? 0) + elite),
|
||||
'The resulting force quantity is invalid.');
|
||||
const cost: 0 | 1 | 2 = mode === 'own' ? 0 :
|
||||
territory(selection.territory).type === 'stronghold' ? 1 : 2;
|
||||
const payer = selection.payer ?? owner.id;
|
||||
requireMarker(mode === 'own' ? payer === owner.id :
|
||||
payer === owner.id || payer === recipient.id || payer === 'both',
|
||||
'Choose Richese, the recipient, or an equal split as the transport payer.');
|
||||
requireMarker(payer !== 'both' || cost === 2,
|
||||
'An equal split requires the two-spice transport fee.');
|
||||
const ownerPayment = payer === owner.id ? cost : payer === 'both' ? 1 : 0;
|
||||
const recipientPayment = cost - ownerPayment;
|
||||
if (ownerPayment)
|
||||
requireMarker(count(owner.spice) && owner.spice >= ownerPayment,
|
||||
'Richese needs enough spice for its transport payment.');
|
||||
if (checkFunds && recipientPayment)
|
||||
requireMarker(count(recipient.spice) && recipient.spice >= recipientPayment,
|
||||
'The recipient needs enough spice for its transport payment.');
|
||||
return {
|
||||
player: recipient.id,
|
||||
amount,
|
||||
elite,
|
||||
territory: selection.territory,
|
||||
sector: selection.sector,
|
||||
advisors,
|
||||
cost,
|
||||
allyPayment: 0,
|
||||
marker: {
|
||||
mode,
|
||||
owner: owner.id,
|
||||
tokenId: selection.tokenId,
|
||||
tokenEvent: selection.tokenEvent,
|
||||
value,
|
||||
payer,
|
||||
ownerPayment,
|
||||
recipientPayment,
|
||||
stateSignature: JSON.stringify({
|
||||
owner: { id: owner.id, noField: owner.noField, event: owner.noFieldEvent,
|
||||
reserves: owner.reserves, elites: owner.elites?.reserves ?? 0 },
|
||||
recipient: { id: recipient.id, reserves: recipient.reserves,
|
||||
elites: recipient.elites?.reserves ?? 0 },
|
||||
storm: g.storm,
|
||||
advanced: g.advanced,
|
||||
seats: c.seats,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Destination choices use public board legality and the required old-marker
|
||||
* reveal, independently of token denomination, physical reserves and funding.
|
||||
* The final quote always checks the selected token's actual grant amount. */
|
||||
export function markerDestinations(g: Game, beneficiary: string):
|
||||
{ territory: string; sector: number; advisors: boolean }[] {
|
||||
const c = context(g, beneficiary);
|
||||
forecastPriorReveal(c);
|
||||
const result: { territory: string; sector: number; advisors: boolean }[] = [];
|
||||
for (const t of TERRITORIES)
|
||||
for (const sector of t.sectors) {
|
||||
try {
|
||||
result.push({ territory: t.id, sector,
|
||||
advisors: destination(g, c, t.id, sector) });
|
||||
} catch (error) {
|
||||
if (!(error instanceof GuildAmbassadorMarkerError)) throw error;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { presenceAt } from './force-presence';
|
||||
import { territoryEntryBlock } from './occupancy';
|
||||
import { homeworldSpiritualAdvisorQuote } from './homeworld-mobility';
|
||||
import type { GuildAmbassadorMarker } from './guild-ambassador-no-field';
|
||||
|
||||
export class GuildAmbassadorShipmentError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -25,8 +26,9 @@ export type GuildAmbassadorShipment = {
|
||||
territory: string;
|
||||
sector: number;
|
||||
advisors: boolean;
|
||||
cost: 0;
|
||||
cost: 0 | 1 | 2;
|
||||
allyPayment: 0;
|
||||
marker?: GuildAmbassadorMarker;
|
||||
};
|
||||
export type GuildAmbassadorShipments = {
|
||||
maximum: number;
|
||||
@@ -283,11 +285,11 @@ export function quoteGuildAmbassadorAdvisor(
|
||||
shipper.faction !== 'fremen' &&
|
||||
record(shipment) &&
|
||||
count(shipment.amount) &&
|
||||
shipment.amount >= 1 &&
|
||||
(shipment.amount >= 1 || shipment.marker?.mode === 'allied') &&
|
||||
shipment.amount <= 4 &&
|
||||
count(shipment.elite) &&
|
||||
shipment.elite <= shipment.amount &&
|
||||
shipment.cost === 0 &&
|
||||
(shipment.marker ? [0,1,2].includes(shipment.cost) : shipment.cost === 0) &&
|
||||
shipment.allyPayment === 0 &&
|
||||
typeof shipment.advisors === 'boolean' &&
|
||||
typeof shipment.territory === 'string' &&
|
||||
|
||||
@@ -117,9 +117,10 @@ const cancellationSources = {
|
||||
: auctionSource(g),
|
||||
richesePurchaseIncome: (g) => g.pendingRichesePurchaseIncome ?? null,
|
||||
richeseGift: (g) => g.pendingRicheseGift ?? null,
|
||||
richeseNoField: (g) => ({
|
||||
richeseNoField: (g, response) => ({
|
||||
...movementSource(g),
|
||||
shipment: g.pendingShipment ?? null,
|
||||
...(response.source === 'ambassadorNoField' ? { ambassador: g.pendingAmbassador ?? null } : {}),
|
||||
}),
|
||||
harkonnenBonus: (g, response) =>
|
||||
response.source === 'ambassador'
|
||||
@@ -133,7 +134,7 @@ const cancellationSources = {
|
||||
plans: g.battle?.plans ?? null,
|
||||
calls: g.battle?.traitorCalls ?? null,
|
||||
}),
|
||||
guildIncome: () => null,
|
||||
guildIncome: (g,response) => response.source === 'ambassadorNoFieldIncome' ? {ambassador:g.pendingAmbassador ?? null} : null,
|
||||
guildTransportPermission: g => ({
|
||||
...(g.pendingGuildTransport ? { transport: g.pendingGuildTransport } : {}),
|
||||
...(g.pendingHomeworldShipment ? { homeworld: g.pendingHomeworldShipment } : {}),
|
||||
|
||||
+14
-9
@@ -1541,7 +1541,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'The mobile stronghold can receive a non-Ixian group only from the territory it points to. Ecaz and its ally may co-occupy under Occupy. Advisors keep their applicable stance and accompaniment locks; an eligible arrival can request fighters. CHOAM may use Baliset during Shipment and Movement; if allowed, choose another legal destination. Intrusion or Terror resolves before the original entrant continues. A destination that causes both settles the Intrusion first and then offers the committed Terror entry; no payment or force transfer replays. There is no extra decline after triggering; if no legal move remains, the effect finishes without moving pieces and its token stays consumed.',
|
||||
'The Guild Ambassador grants an immediate free shipment of zero through four physical reserve forces for Ecaz or its ally, including typed elites. Choose a clear territory and sector; this does not spend ordinary shipment or movement, ally aid or a retained rate card. Fremen may use destinations outside their ordinary reinforcement radius, but their southern reserves stay on-planet and this effect never permits storm entry. Only Ixians may ship directly into their placed mobile stronghold.',
|
||||
'During Shipment and Movement, Guild can use its special Karama to stop another eligible off-planet Ambassador shipment before forces arrive; it does not stop this grant during a phase-one worm sequence. A successful off-planet shipment may invite Bene Gesserit accompaniment and accrues applicable phase-five Heighliner income once. BG can accompany Ixians into the mobile stronghold. Projected accompaniment choices show blocked sectors and keep Polar Sink and decline available when legal. Intrusion, separate Terror and accompanying fighter-triggered Ambassadors finish before the original entrant or worm rider resumes.',
|
||||
'No-Field substitution into the free four-force grant remains unavailable. In bounded classic plus Ecaz/Moritani games, a shared Ambassador/Terror entry gives both owners saved optional prompts in current storm order without repeating the committed entry; this is a provisional table order, not a printed publisher priority. 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.',
|
||||
'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.',
|
||||
],
|
||||
@@ -1550,40 +1550,45 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'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/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'],
|
||||
'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'],
|
||||
},
|
||||
{
|
||||
area: 'Player controls',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'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/game-table.tsx'],
|
||||
'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'],
|
||||
},
|
||||
{
|
||||
area: 'AI',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'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 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.',
|
||||
evidence: ['game/bots.ts', 'tests/ambassador-revival-income-engine.test.ts', 'tests/duke-ambassador-engine.test.ts', 'docs/TLEILAXU_AMBASSADOR_RULES.md'],
|
||||
},
|
||||
{
|
||||
area: 'Documentation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'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/TLEILAXU_AMBASSADOR_RULES.md', 'docs/DEFERRED_SHIPMENT_ARRIVAL.md', 'docs/BOT_ARRIVAL_PREFLIGHT.md'],
|
||||
'Guild No-Field now distinguishes the selected Basic cap/fee policy from printed authority, independent allowances and remaining Advanced/module gates. Source-bound effects, copies and Duke guides remain. Native own-arrival child-first/live-token order stays provisional. Separate native transport permission, independent purchased typed prices and own/allied marker billing preserve original sources and approvals. Immune Junction now keeps its offered tariff/payees with an explicit unused-purchase choice. Cunning opportunity scope, special-stop card return and unused-price expiry are selected interpretations; full combinations remain open.',
|
||||
evidence: ['docs/ECAZ_AMBASSADORS_RULES.md', 'docs/GUILD_AMBASSADOR_IMPLEMENTATION.md', 'docs/GUILD_AMBASSADOR_RULES.md', 'docs/TLEILAXU_AMBASSADOR_RULES.md', 'docs/DEFERRED_SHIPMENT_ARRIVAL.md', 'docs/BOT_ARRIVAL_PREFLIGHT.md'],
|
||||
},
|
||||
{
|
||||
area: 'Verification',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Current 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.',
|
||||
'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.',
|
||||
evidence: [
|
||||
'tests/ambassador-repeat-leader-engine.test.ts',
|
||||
'tests/fixture-ambassador-repeat-leaders.ts',
|
||||
'tests/tleilaxu-ambassador-leaders.test.ts',
|
||||
'tests/fixture-ambassador-revival-income.ts',
|
||||
'tests/fixture-guild-ambassador-no-field.ts',
|
||||
'tests/guild-ambassador-no-field-engine.test.ts',
|
||||
'tests/guild-ambassador-no-field-bank.test.ts',
|
||||
'tests/guild-ambassador-no-field-controls.test.tsx',
|
||||
'tests/guild-ambassador-no-field-quote.test.ts',
|
||||
'tests/ambassador-revival-income-engine.test.ts',
|
||||
'tests/tleilaxu-ambassador-income.test.ts',
|
||||
'tests/basic-ecaz-tleilaxu-occupy.test.ts',
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
|
||||
export type GuildAmbassadorNoFieldStep = { actor: string; action: Action };
|
||||
export type GuildAmbassadorNoFieldTransition = {
|
||||
before: Game; steps: GuildAmbassadorNoFieldStep[]; after: Game;
|
||||
};
|
||||
export const guildAmbassadorNoFieldPlayer = (game: Game, id: string) => {
|
||||
const player = game.players.find(player => player.id === id); assert.ok(player); return player;
|
||||
};
|
||||
export const guildAmbassadorNoFieldClean = (game: Game) => !game.response && !game.phaseOpening &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export const guildAmbassadorNoFieldInventory = (game: Game) => {
|
||||
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(player => player.hand)];
|
||||
const ids = cards.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'Each original physical Treachery Card has one current slot');
|
||||
return ids;
|
||||
};
|
||||
|
||||
/** Select only original scalar lotteries. Nine selected draws belong to the
|
||||
* original Ambassador packet; UUIDs and other byte entropy remain native. */
|
||||
function withOriginalScalarLottery<T>(run: () => T, packet: 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 < packet.length ? packet[cursor++] : 0xffffffff;
|
||||
else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try {
|
||||
const result = run(); assert.equal(cursor, packet.length, 'The original packet consumes its nine selected draws');
|
||||
return result;
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function actGuildAmbassadorNoField(game: Game, actor: string, action: Action,
|
||||
actions: GuildAmbassadorNoFieldStep[] = []): Game {
|
||||
const next = withOriginalScalarLottery(() => applyAction(game, actor, action));
|
||||
actions.push({ actor, action: structuredClone(action) }); return next;
|
||||
}
|
||||
function nextStep(game: Game): GuildAmbassadorNoFieldStep | null {
|
||||
if (!game.response && !game.phaseOpening) {
|
||||
if (game.decision?.kind === 'ecazAmbassador' || game.decision?.kind === 'ecazPlacement') return null;
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' &&
|
||||
guildAmbassadorNoFieldPlayer(game, 'ecaz').reserves === 20)
|
||||
return { actor: 'ecaz', action: { type: 'ecazSetup', placements: { 'imperial_basin:9': 6 } } };
|
||||
if (game.decision?.kind === 'richeseCache') return {
|
||||
actor: game.decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
|
||||
card: game.richeseCache![0].id, method: 'onceAround', direction: 'counterclockwise' },
|
||||
};
|
||||
if (game.decision?.kind === 'richeseUnbid') return { actor: game.decision.player,
|
||||
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
|
||||
}
|
||||
return nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
export function advanceGuildAmbassadorNoField(game: Game, until: (state: Game) => boolean,
|
||||
actions: GuildAmbassadorNoFieldStep[] = []): Game {
|
||||
for (let count = 0; count < 1600; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Basic source ended before its next control');
|
||||
const next = nextStep(game);
|
||||
assert.ok(next, `Choose the original Ambassador control before advancing: ${JSON.stringify({
|
||||
status: game.status, phase: game.phase, active: game.active, decision: game.decision,
|
||||
entry: game.pendingAmbassador && { stage: game.pendingAmbassador.stage, effect: game.pendingAmbassador.effect },
|
||||
})}`);
|
||||
game = actGuildAmbassadorNoField(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original Guild Ambassador source did not reach its finite boundary');
|
||||
}
|
||||
export function passGuildAmbassadorNoFieldResponses(game: Game,
|
||||
actions: GuildAmbassadorNoFieldStep[] = []): 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 = actGuildAmbassadorNoField(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.equal(game.response, null); return game;
|
||||
}
|
||||
function originalGuildPacket() {
|
||||
const pool = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
|
||||
const supply: Exclude<AmbassadorEffect, 'ecaz'>[] = ['guild', 'atreides', 'emperor', 'fremen', 'harkonnen'];
|
||||
const desired = [...supply, ...pool.filter(effect => !supply.includes(effect))];
|
||||
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 };
|
||||
}
|
||||
|
||||
/** Fresh public bare Basic E2/E3 source. Ecaz pays for both original markers.
|
||||
* Its reusable self-marker really creates the Richese alliance at Carthag;
|
||||
* Richese explicitly accepts and Ecaz declines the optional Basic Duke loan.
|
||||
* Both future beneficiaries already made their own paid ordinary shipments and
|
||||
* ended their movement turns before Harkonnen pays for the Guild-marker entry.
|
||||
* Only original undealt card/packet order is selected. No wallet, army, phase,
|
||||
* alliance, allowance, token custody or continuation is assigned. */
|
||||
export function createGuildAmbassadorNoFieldFixture(options: {
|
||||
beneficiary: 'own' | 'allied'; priorMarker?: boolean; karama?: boolean; includeGuild?: boolean;
|
||||
}) {
|
||||
let game = createGame('GUILDAMBASSADORNOFIELD', newPlayer('ecaz', 'Ecaz', 'ecaz'), false, ['ecaz', 'choam']);
|
||||
const includeGuild = options.includeGuild !== false;
|
||||
const roster = includeGuild ? ['richese', 'harkonnen', 'guild'] as const : ['richese', 'harkonnen'] as const;
|
||||
for (const faction of roster)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const ownerEcaz = 'ecaz', richese = 'richese', entrantHark = 'harkonnen', guild = 'guild';
|
||||
const beneficiaryID = options.beneficiary === 'own' ? richese : ownerEcaz;
|
||||
const actions: GuildAmbassadorNoFieldStep[] = [], staging: string[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actGuildAmbassadorNoField(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceGuildAmbassadorNoField(game, until, actions); };
|
||||
const transition = (run: () => void): GuildAmbassadorNoFieldTransition => {
|
||||
const before = structuredClone(game), start = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, false);
|
||||
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 = guildAmbassadorNoFieldInventory(game);
|
||||
const originalMainDeck = game.deck.map(card => card.id).sort();
|
||||
const originalCache = game.richeseCache!.map(card => card.id).sort();
|
||||
// Harkonnen receives two actual starting cards; all other seats receive one.
|
||||
const dealCount = game.players.length + 1;
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, dealCount);
|
||||
assert.equal(ordinary.length, dealCount);
|
||||
const heldKarama = options.karama ? game.deck.find(card => card.effect === 'karama') : null;
|
||||
if (options.karama) { assert.ok(heldKarama); ordinary[2] = heldKarama; }
|
||||
const deal = ordinary.map(card => card.id);
|
||||
const dealOrder = deal.map((id, position) => {
|
||||
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game), packet = originalGuildPacket();
|
||||
let packetCreation: GuildAmbassadorNoFieldTransition | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const next = nextStep(game); assert.ok(next);
|
||||
const initializesForces = game.setupStage === 'traitors' && next.action.type === 'traitor' &&
|
||||
game.players.filter(player => player.traitorChoices.length > 0).length === 1;
|
||||
if (initializesForces) {
|
||||
const before = structuredClone(game);
|
||||
game = withOriginalScalarLottery(() => 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);
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(packetCreation);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.ecazAmbassadors!.cohort.map(id =>
|
||||
game.ecazAmbassadors!.tokens.find(token => token.id === id)!.effect), packet.supply);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1].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);
|
||||
reach(state => state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
const selfToken = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz')!;
|
||||
const guildToken = game.ecazAmbassadors!.tokens.find(token => token.effect === 'guild')!;
|
||||
const placements = transition(() => {
|
||||
act(ownerEcaz, { type: 'decision', token: selfToken.id, territory: 'carthag' });
|
||||
reach(state => !state.response && state.decision?.kind === 'ecazPlacement');
|
||||
act(ownerEcaz, { type: 'decision', token: guildToken.id, territory: 'habbanya_ridge_sietch' });
|
||||
reach(state => !state.response && state.decision?.kind === 'ecazPlacement');
|
||||
act(ownerEcaz, { type: 'decision', decline: true });
|
||||
});
|
||||
assert.equal(guildAmbassadorNoFieldPlayer(game, ownerEcaz).spice,
|
||||
guildAmbassadorNoFieldPlayer(placements.before, ownerEcaz).spice - 3);
|
||||
reach(state => state.phase === 5 && state.active === ownerEcaz && guildAmbassadorNoFieldClean(state));
|
||||
assert.deepEqual(game.order, [ownerEcaz, richese, entrantHark, ...(includeGuild ? [guild] : [])]);
|
||||
assert.equal(game.storm, 1);
|
||||
const ordinaryEcaz = transition(() => {
|
||||
act(ownerEcaz, { type: 'ship', amount: 1, territory: 'polar_sink', sector: 0, elite: 0, allyPayment: 0 });
|
||||
reach(guildAmbassadorNoFieldClean); act(ownerEcaz, { type: 'endMovement' });
|
||||
reach(state => state.active === richese && guildAmbassadorNoFieldClean(state));
|
||||
});
|
||||
const tokens = guildAmbassadorNoFieldPlayer(game, richese).noField!.tokens.map(token => ({ ...token }));
|
||||
const priorToken = tokens.find(token => token.value === 3)!;
|
||||
const alliance = transition(() => {
|
||||
const richeseEvent = guildAmbassadorNoFieldPlayer(game, richese).noFieldEvent!;
|
||||
act(richese, options.priorMarker
|
||||
? { type: 'ship', noField: priorToken.id, event: richeseEvent, territory: 'carthag', sector: 11 }
|
||||
: { type: 'ship', amount: 1, territory: 'carthag', sector: 11, elite: 0, allyPayment: 0 });
|
||||
reach(state => !state.response && state.pendingAmbassador?.stage === 'offer');
|
||||
act(ownerEcaz, { type: 'decision', event: game.pendingAmbassador!.event,
|
||||
trigger: true, beneficiary: ownerEcaz, choice: 'alliance' });
|
||||
assert.equal(game.pendingAmbassador?.stage, 'allianceReply');
|
||||
act(richese, { type: 'decision', event: game.pendingAmbassador!.event, accept: true });
|
||||
assert.equal(game.pendingAmbassador?.stage, 'loan');
|
||||
act(ownerEcaz, { type: 'decision', event: game.pendingAmbassador!.event, loan: false });
|
||||
reach(guildAmbassadorNoFieldClean);
|
||||
});
|
||||
assert.equal(guildAmbassadorNoFieldPlayer(game, ownerEcaz).ally, richese);
|
||||
assert.equal(guildAmbassadorNoFieldPlayer(game, richese).ally, ownerEcaz);
|
||||
act(richese, { type: 'endMovement' });
|
||||
reach(state => state.active === entrantHark && guildAmbassadorNoFieldClean(state));
|
||||
const arrival = transition(() => {
|
||||
act(entrantHark, { type: 'ship', amount: 1, territory: 'habbanya_ridge_sietch', sector: 17,
|
||||
elite: 0, allyPayment: 0 });
|
||||
reach(state => !state.response && state.pendingAmbassador?.stage === 'offer');
|
||||
});
|
||||
assert.equal(guildAmbassadorNoFieldPlayer(game, entrantHark).spice,
|
||||
guildAmbassadorNoFieldPlayer(arrival.before, entrantHark).spice - 1);
|
||||
const beforeTrigger = structuredClone(game);
|
||||
act(ownerEcaz, { type: 'decision', event: game.pendingAmbassador!.event, trigger: true, beneficiary: beneficiaryID });
|
||||
assert.ok(game.pendingAmbassador); assert.equal(game.pendingAmbassador.stage, 'ship');
|
||||
assert.equal(game.pendingAmbassador.effect, 'guild');
|
||||
assert.equal(game.decision?.player, beneficiaryID);
|
||||
assert.equal(game.active, entrantHark);
|
||||
assert.ok(guildAmbassadorNoFieldPlayer(game, ownerEcaz).shipped);
|
||||
assert.ok(guildAmbassadorNoFieldPlayer(game, richese).shipped);
|
||||
assert.deepEqual(guildAmbassadorNoFieldInventory(game), originalInventory);
|
||||
const event = game.pendingAmbassador.event;
|
||||
return { game, event, ownerEcaz, richese, beneficiaryID, entrantHark, guild, includeGuild, tokens,
|
||||
territory: 'sietch_tabr', sector: 14, desert: 'habbanya_ridge_flat', desertSector: 17,
|
||||
priorToken, currentTokenEvent: guildAmbassadorNoFieldPlayer(game, richese).noFieldEvent!,
|
||||
karama: heldKarama?.id ?? null, actions, staging, lobby, setup, beforeStartingDeal, afterSetup,
|
||||
originalInventory, originalMainDeck, originalCache, originalSpice, dealOrder, spiceOrder,
|
||||
packetCreation, placements, ordinaryEcaz, alliance, arrival, beforeTrigger };
|
||||
}
|
||||
export type GuildAmbassadorNoFieldFixture = ReturnType<typeof createGuildAmbassadorNoFieldFixture>;
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
actGuildAmbassadorNoField as act, advanceGuildAmbassadorNoField as advance,
|
||||
createGuildAmbassadorNoFieldFixture as fixture, guildAmbassadorNoFieldClean as clean,
|
||||
guildAmbassadorNoFieldInventory as inventory, guildAmbassadorNoFieldPlayer as player,
|
||||
passGuildAmbassadorNoFieldResponses as passResponses,
|
||||
} from './fixture-guild-ambassador-no-field';
|
||||
|
||||
void test('genuine three-seat allied Guild Ambassador shipment pays its fixed stronghold fee to the bank when Guild is absent', () => {
|
||||
const f = fixture({ beneficiary: 'allied', includeGuild: false, karama: true });
|
||||
const before = structuredClone(f.game), totalSpice = before.players.reduce((sum, p) => sum + p.spice, 0);
|
||||
const beforeAllowances = { active: before.active, remaining: before.movementRemaining,
|
||||
players: before.players.map(p => ({ id: p.id, shipped: p.shipped, moved: p.moved })) };
|
||||
assert.equal(f.includeGuild, false); assert.equal(before.players.length, 3);
|
||||
assert.ok(before.players.every(p => p.faction !== 'guild'));
|
||||
assert.deepEqual(before.players.map(p => p.faction), ['ecaz', 'richese', 'harkonnen']);
|
||||
assert.deepEqual(before.order, [f.ownerEcaz, f.richese, f.entrantHark]);
|
||||
assert.deepEqual(f.staging, []);
|
||||
assert.equal(f.dealOrder.length, 4, 'The original three-seat deal gives Harkonnen its actual second card');
|
||||
let game = act(f.game, f.beneficiaryID, { type: 'decision', event: f.event, requestNoField: true,
|
||||
territory: f.territory, sector: f.sector });
|
||||
assert.equal(game.decision?.player, f.richese);
|
||||
game = act(game, f.richese, { type: 'decision', event: f.event,
|
||||
offerNoField: f.tokens.find(token => token.value === 3)!.id,
|
||||
noFieldEvent: player(game, f.richese).noFieldEvent, payer: f.richese, elite: 0 });
|
||||
assert.equal(game.decision?.player, f.beneficiaryID);
|
||||
game = act(game, f.beneficiaryID, { type: 'decision', event: f.event, acceptNoField: true });
|
||||
assert.equal(game.response?.kind, 'richeseNoField');
|
||||
for (const p of game.players) {
|
||||
assert.equal(p.spice, player(before, p.id).spice);
|
||||
assert.equal(p.reserves, player(before, p.id).reserves);
|
||||
assert.deepEqual(p.forces, player(before, p.id).forces);
|
||||
}
|
||||
game = passResponses(game); game = advance(game, clean);
|
||||
assert.equal(player(game, f.beneficiaryID).forces[`${f.territory}:${f.sector}`], 3);
|
||||
assert.equal(player(game, f.beneficiaryID).reserves, player(before, f.beneficiaryID).reserves - 3);
|
||||
assert.equal(player(game, f.richese).noField!.deployed, null);
|
||||
assert.equal(player(game, f.richese).noField!.lastShipped, f.tokens.find(token => token.value === 3)!.id);
|
||||
assert.equal(player(game, f.richese).spice, player(before, f.richese).spice - 1);
|
||||
assert.equal(player(game, f.beneficiaryID).spice, player(before, f.beneficiaryID).spice);
|
||||
assert.equal(player(game, f.entrantHark).spice, player(before, f.entrantHark).spice);
|
||||
assert.equal(game.players.reduce((sum, p) => sum + p.spice, 0), totalSpice - 1);
|
||||
assert.deepEqual({ active: game.active, remaining: game.movementRemaining,
|
||||
players: game.players.map(p => ({ id: p.id, shipped: p.shipped, moved: p.moved })) }, beforeAllowances);
|
||||
assert.equal(game.pendingAmbassador, null);
|
||||
game = act(game, f.entrantHark, { type: 'move', from: 'habbanya_ridge_sietch:17', amount: 1,
|
||||
territory: f.desert, sector: f.desertSector });
|
||||
game = advance(game, clean);
|
||||
assert.equal(player(game, f.entrantHark).forces[`${f.desert}:${f.desertSector}`], 1);
|
||||
game = act(game, f.entrantHark, { type: 'endMovement' });
|
||||
assert.ok(!game.movementRemaining?.includes(f.entrantHark));
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
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 {
|
||||
AmbassadorNoField,
|
||||
AmbassadorNoFieldChoice,
|
||||
type AmbassadorNoFieldOffer,
|
||||
} from '../components/ambassador-no-field';
|
||||
import { AmbassadorShipment } from '../components/ambassador-shipment';
|
||||
import type { Action, GameView } from '../game/engine';
|
||||
|
||||
const event = 'guild-ambassador-no-field-original';
|
||||
|
||||
function option(
|
||||
key: string,
|
||||
fields: Partial<AmbassadorNoFieldOffer['options'][number]> = {},
|
||||
): AmbassadorNoFieldOffer['options'][number] {
|
||||
return {
|
||||
key,
|
||||
label: `Concealed No-Field ${key} · Arrakeen sector 10 · free`,
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
action: {
|
||||
type: 'decision', event, amount: 1, noField: `richese-${key}`,
|
||||
territory: 'arrakeen', sector: 10,
|
||||
},
|
||||
blocked: null,
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
function offer(stage: AmbassadorNoFieldOffer['stage'] = 'own'): AmbassadorNoFieldOffer {
|
||||
return {
|
||||
stage,
|
||||
policy: 'Provisional Guild Ambassador No-Field policy: a concealed native marker counts as one force and costs no spice. This independent grant does not spend ordinary shipment or movement.',
|
||||
options: [0, 3, 5].map((value) => option(String(value))),
|
||||
};
|
||||
}
|
||||
|
||||
// These component contracts deliberately use projected offers. The related
|
||||
// engine source fixtures exercise actual token custody, payment and continuation.
|
||||
function projected(noField: AmbassadorNoFieldOffer | null = offer()): GameView {
|
||||
return {
|
||||
me: 'r',
|
||||
players: [
|
||||
{ id: 'r', name: 'Richese', faction: 'richese', reserves: 10 },
|
||||
{ id: 'e', name: 'Ecaz', faction: 'ecaz', reserves: 10 },
|
||||
],
|
||||
decision: { kind: 'ecazAmbassador', player: 'r', event },
|
||||
ambassadorEntry: {
|
||||
event, stage: 'ship', effect: 'guild', beneficiary: 'r', noField,
|
||||
shipment: {
|
||||
maximum: 4, eliteReserves: 0,
|
||||
destinations: [{ territory: 'arrakeen', sector: 10, maximum: 4, blocked: null }],
|
||||
},
|
||||
},
|
||||
} as unknown as GameView;
|
||||
}
|
||||
|
||||
function markup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(AmbassadorShipment, {
|
||||
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(
|
||||
current: AmbassadorNoFieldOffer,
|
||||
selectedKey: string,
|
||||
busy = false,
|
||||
decline = false,
|
||||
): Action | undefined {
|
||||
let action: Action | undefined;
|
||||
const tree = AmbassadorNoFieldChoice({
|
||||
offer: current, event, selectedKey, busy,
|
||||
onSelect() {}, act(value) { action = value; },
|
||||
});
|
||||
click(tree, decline ? 'ambassador-no-field-decline' : 'ambassador-no-field-submit');
|
||||
return action;
|
||||
}
|
||||
|
||||
void test('native marker controls retain the separate physical grant and submit only the complete projected token action', () => {
|
||||
const current = offer();
|
||||
const html = markup(projected(current));
|
||||
assert.match(html, /aria-label="Ambassador No-Field shipment"/);
|
||||
assert.match(html, /Provisional Guild Ambassador No-Field policy/);
|
||||
assert.match(html, /does not spend ordinary shipment or movement/);
|
||||
for (const value of [0, 3, 5]) assert.match(html, new RegExp(`Concealed No-Field ${value}`));
|
||||
assert.match(html, /Selected: 4 physical forces, including 0 elites/);
|
||||
assert.match(html, /Ship selected forces for free/);
|
||||
assert.match(html, /Send no forces/);
|
||||
assert.doesNotMatch(html, /No-Field substitution.*unfinished/);
|
||||
for (const selected of current.options) {
|
||||
const action = choose(current, selected.key);
|
||||
assert.deepEqual(action, selected.action);
|
||||
assert.equal(action?.amount, 1);
|
||||
assert.equal(action?.elite, undefined);
|
||||
assert.equal(action?.smuggler, undefined);
|
||||
assert.equal(action?.type, 'decision');
|
||||
}
|
||||
});
|
||||
|
||||
void test('zero reserves still expose marker destinations without inventing physical forces', () => {
|
||||
const game = projected();
|
||||
game.players[0].reserves = 0;
|
||||
game.ambassadorEntry!.shipment!.maximum = 0;
|
||||
game.ambassadorEntry!.shipment!.destinations = [];
|
||||
const html = markup(game);
|
||||
assert.match(html, /value="arrakeen" selected=""/);
|
||||
assert.match(html, /value="arrakeen:10" selected=""/);
|
||||
assert.match(html, /Concealed No-Field 0/);
|
||||
assert.match(html, /Selected: 0 physical forces, including 0 elites/);
|
||||
assert.match(html, /You have no reserve forces available for this shipment/);
|
||||
assert.match(html, /Send no forces/);
|
||||
});
|
||||
|
||||
void test('an existing native marker can be revealed without choosing or carrying a destination', () => {
|
||||
const current = offer();
|
||||
current.options = [option('reveal', {
|
||||
label: 'Reveal existing No-Field', territory: undefined, sector: undefined,
|
||||
action: { type: 'decision', event, revealNoField: true },
|
||||
})];
|
||||
const selection = AmbassadorNoField({
|
||||
game: projected(current), busy: false, act() {}, destination: 'hagga_basin', sector: 12,
|
||||
});
|
||||
assert.ok(selection);
|
||||
assert.equal(selection.props.offer.options.length, 1);
|
||||
assert.deepEqual(choose(current, 'reveal'), current.options[0].action);
|
||||
});
|
||||
|
||||
void test('allied request hides token values and both private decision stages replace the physical form', () => {
|
||||
const request = offer('request');
|
||||
request.policy = 'Request a whole-grant No-Field substitution from your actual Richese ally. The ally chooses its private marker before you consent.';
|
||||
request.options = [option('request-arrakeen', {
|
||||
label: 'Ask Richese for a No-Field in Arrakeen sector 10',
|
||||
action: { type: 'decision', event, territory: 'arrakeen', sector: 10, requestNoField: true },
|
||||
})];
|
||||
const requestGame = projected(request);
|
||||
requestGame.me = 'e';
|
||||
requestGame.decision!.player = 'e';
|
||||
requestGame.ambassadorEntry!.beneficiary = 'e';
|
||||
const requestHtml = markup(requestGame);
|
||||
assert.match(requestHtml, /Request an allied No-Field/);
|
||||
assert.match(requestHtml, /Ask Richese for a No-Field in Arrakeen/);
|
||||
assert.doesNotMatch(requestHtml, /No-Field [035]/);
|
||||
assert.match(requestHtml, /Ship selected forces for free/);
|
||||
assert.deepEqual(choose(request, request.options[0].key), request.options[0].action);
|
||||
|
||||
for (const stage of ['supply', 'consent'] as const) {
|
||||
const current = offer(stage);
|
||||
current.policy = 'The allied marker reveals immediately: 3 reserve forces, capped at four. Mandatory transport fee: 1 spice to the Guild or bank, paid by either ally; this does not spend ordinary shipment or movement.';
|
||||
current.options = stage === 'supply'
|
||||
? [0, 3, 5].flatMap((value) => ['r', 'e'].map((payer) => option(`${stage}-${value}-${payer}`, {
|
||||
label: `Offer No-Field ${value} · ${Math.min(value, 4)} forces · ${payer === 'r' ? 'Richese' : 'Ecaz'} pays 1 spice`,
|
||||
action: { type: 'decision', event, choice: stage, token: `richese-${value}`, payer },
|
||||
})))
|
||||
: [option(`${stage}-3`, {
|
||||
label: 'Accept No-Field 3 · 3 forces · Richese pays 1 spice',
|
||||
action: { type: 'decision', event, choice: stage, token: 'richese-3', payer: 'r' },
|
||||
})];
|
||||
current.decline = { type: 'decision', event, declineNoField: true };
|
||||
current.declineLabel = 'Decline and return to the original grant';
|
||||
const game = projected(current);
|
||||
game.ambassadorEntry!.beneficiary = 'e';
|
||||
game.ambassadorEntry!.shipment = null;
|
||||
if (stage === 'consent') {
|
||||
game.me = 'e';
|
||||
game.decision!.player = 'e';
|
||||
}
|
||||
const html = markup(game);
|
||||
assert.match(html, /aria-label="Ambassador No-Field shipment"/);
|
||||
assert.match(html, /No-Field 3 · 3 forces · Richese pays 1 spice/);
|
||||
if (stage === 'supply') {
|
||||
assert.match(html, /No-Field 0 · 0 forces · Ecaz pays 1 spice/);
|
||||
assert.match(html, /No-Field 5 · 4 forces · Ecaz pays 1 spice/);
|
||||
}
|
||||
assert.match(html, /Mandatory transport fee: 1 spice to the Guild or bank/);
|
||||
assert.match(html, /Decline and return to the original grant/);
|
||||
assert.doesNotMatch(html, /Physical forces to ship|Ship selected forces for free|Send no forces/);
|
||||
for (const selected of current.options)
|
||||
assert.deepEqual(choose(current, selected.key), selected.action);
|
||||
assert.deepEqual(choose(current, '', false, true), current.decline);
|
||||
}
|
||||
});
|
||||
|
||||
void test('busy, unselected, blocked and obsolete choices cannot dispatch through their callbacks', () => {
|
||||
const current = offer('supply');
|
||||
current.options.push(option('blocked', { blocked: 'The payer cannot cover this transport fee.' }));
|
||||
current.decline = { type: 'decision', event, declineNoField: true };
|
||||
for (const selected of ['', 'obsolete', 'blocked']) assert.equal(choose(current, selected), undefined);
|
||||
assert.equal(choose(current, '3', true), undefined);
|
||||
assert.equal(choose(current, '', true, true), undefined);
|
||||
const html = renderToStaticMarkup(createElement(AmbassadorNoFieldChoice, {
|
||||
offer: current, event, selectedKey: '3', onSelect() {}, act() {}, busy: true,
|
||||
}));
|
||||
assert.equal((html.match(/<button\b[^>]*\sdisabled=""/g) ?? []).length, 2);
|
||||
assert.match(html, /The payer cannot cover this transport fee/);
|
||||
assert.equal(choose({ ...current, options: [] }, '3'), undefined);
|
||||
});
|
||||
|
||||
void test('fresh event, stage and destination projections reset selection and use only the incoming authoritative action', () => {
|
||||
const game = projected();
|
||||
const first = AmbassadorNoField({ game, busy: false, act() {}, destination: 'arrakeen', sector: 10 });
|
||||
assert.ok(first);
|
||||
const next = structuredClone(game);
|
||||
next.ambassadorEntry!.event = 'guild-ambassador-no-field-next';
|
||||
next.ambassadorEntry!.noField!.options[1].action = { type: 'decision', event: 'guild-ambassador-no-field-next', amount: 1, noField: 'new-3', territory: 'arrakeen', sector: 10 };
|
||||
const fresh = AmbassadorNoField({ game: next, busy: false, act() {}, destination: 'arrakeen', sector: 10 });
|
||||
assert.ok(fresh);
|
||||
assert.notEqual(first.key, fresh.key);
|
||||
assert.deepEqual(choose(fresh.props.offer, '3'), next.ambassadorEntry!.noField!.options[1].action);
|
||||
next.ambassadorEntry!.noField!.stage = 'supply';
|
||||
const stage = AmbassadorNoField({ game: next, busy: false, act() {} });
|
||||
assert.ok(stage);
|
||||
assert.notEqual(fresh.key, stage.key);
|
||||
const otherDestination = AmbassadorNoField({ game, busy: false, act() {}, destination: 'hagga_basin', sector: 12 });
|
||||
assert.ok(otherDestination);
|
||||
assert.notEqual(first.key, otherDestination.key);
|
||||
assert.equal(otherDestination.props.offer.options.length, 0);
|
||||
assert.equal(choose(otherDestination.props.offer, '3'), undefined);
|
||||
});
|
||||
|
||||
void test('only the current Ambassador decision actor gets controls, even if an obsolete projection remains', () => {
|
||||
const game = projected();
|
||||
game.decision!.player = 'e';
|
||||
assert.equal(markup(game), '');
|
||||
assert.equal(AmbassadorNoField({ game, busy: false, act() {} }), null);
|
||||
const ended = projected();
|
||||
ended.ambassadorEntry!.stage = 'move';
|
||||
assert.equal(markup(ended), '');
|
||||
const noOffer = projected(null);
|
||||
assert.doesNotMatch(markup(noOffer), /Ambassador No-Field shipment/);
|
||||
assert.match(markup(noOffer), /Send no forces/);
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
actGuildAmbassadorNoField as act, advanceGuildAmbassadorNoField as advance,
|
||||
createGuildAmbassadorNoFieldFixture as fixture, guildAmbassadorNoFieldClean as clean,
|
||||
guildAmbassadorNoFieldInventory as inventory, guildAmbassadorNoFieldPlayer as player,
|
||||
passGuildAmbassadorNoFieldResponses as passResponses,
|
||||
type GuildAmbassadorNoFieldFixture,
|
||||
} from './fixture-guild-ambassador-no-field';
|
||||
|
||||
function allowances(game: Game) {
|
||||
return { active: game.active, order: game.order, remaining: game.movementRemaining,
|
||||
players: game.players.map(p => ({ id: p.id, shipped: p.shipped, moved: p.moved })) };
|
||||
}
|
||||
function material(game: Game) {
|
||||
return game.players.map(p => ({ id: p.id, spice: p.spice, reserves: p.reserves, forces: p.forces,
|
||||
noField: p.noField, noFieldEvent: p.noFieldEvent, noFieldBlockedTurn: p.noFieldBlockedTurn }));
|
||||
}
|
||||
function nativeAction(f: GuildAmbassadorNoFieldFixture, value: 0 | 3 | 5, extra: Partial<Action> = {}): Action {
|
||||
const token = f.tokens.find(token => token.value === value)!;
|
||||
return { type: 'decision', event: f.event, amount: 1, noField: token.id,
|
||||
noFieldEvent: player(f.game, f.richese).noFieldEvent,
|
||||
territory: f.territory, sector: f.sector, ...extra };
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action, reason?: RegExp) {
|
||||
const before = structuredClone(game), submitted = structuredClone(action);
|
||||
if (reason) assert.throws(() => act(game, actor, action), reason);
|
||||
else assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before); assert.deepEqual(action, submitted);
|
||||
}
|
||||
function finishEntrant(f: GuildAmbassadorNoFieldFixture, game: Game) {
|
||||
game = advance(game, clean);
|
||||
assert.equal(game.pendingAmbassador, null);
|
||||
assert.equal(game.active, f.entrantHark);
|
||||
assert.equal(player(game, f.entrantHark).forces['habbanya_ridge_sietch:17'], 1);
|
||||
game = act(game, f.entrantHark, { type: 'move', from: 'habbanya_ridge_sietch:17', amount: 1,
|
||||
territory: f.desert, sector: f.desertSector });
|
||||
game = advance(game, clean);
|
||||
assert.equal(player(game, f.entrantHark).forces[`${f.desert}:${f.desertSector}`], 1);
|
||||
game = act(game, f.entrantHark, { type: 'endMovement' });
|
||||
game = advance(game, state => state.active === f.guild && clean(state));
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('genuine Basic Guild marker source uses native deal, paid placements, accepted Ecaz alliance and paid entrant', () => {
|
||||
for (const beneficiary of ['own', 'allied'] as const) {
|
||||
const f = fixture({ beneficiary }), g = f.game;
|
||||
assert.deepEqual(f.staging, []);
|
||||
assert.equal(g.advanced, false); assert.deepEqual(g.expansions, ['ecaz', 'choam']);
|
||||
assert.ok(!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens &&
|
||||
!g.strongholdCards && !g.discoveryEnabled && !g.ecazTreachery);
|
||||
assert.equal(g.status, 'playing'); assert.equal(g.turn, 1); assert.equal(g.phase, 5);
|
||||
assert.ok(g.pendingAmbassador); assert.equal(g.pendingAmbassador.stage, 'ship');
|
||||
assert.equal(g.pendingAmbassador.effect, 'guild');
|
||||
assert.equal(g.pendingAmbassador.entrant, f.entrantHark);
|
||||
assert.equal(g.pendingAmbassador.beneficiary, f.beneficiaryID);
|
||||
assert.equal(g.decision?.player, f.beneficiaryID);
|
||||
assert.deepEqual(g.order, [f.ownerEcaz, f.richese, f.entrantHark, f.guild]);
|
||||
assert.equal(player(g, f.ownerEcaz).ally, f.richese); assert.equal(player(g, f.richese).ally, f.ownerEcaz);
|
||||
assert.equal(player(g, f.ownerEcaz).shipped, true); assert.equal(player(g, f.richese).shipped, true);
|
||||
assert.equal(player(g, f.entrantHark).shipped, true); assert.equal(player(g, f.guild).shipped, false);
|
||||
assert.equal(player(g, f.richese).forces['carthag:11'], 1);
|
||||
assert.equal(player(g, f.ownerEcaz).forces['polar_sink:0'], 1);
|
||||
assert.equal(player(g, f.entrantHark).forces['habbanya_ridge_sietch:17'], 1);
|
||||
assert.equal(g.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz')!.zone, 'supply');
|
||||
assert.equal(g.ecazAmbassadors!.tokens.find(token => token.effect === 'guild')!.zone, 'used');
|
||||
assert.ok(f.alliance.steps.some(step => step.actor === f.richese && step.action.accept === true));
|
||||
assert.ok(f.alliance.steps.some(step => step.actor === f.ownerEcaz && step.action.loan === false));
|
||||
assert.deepEqual(inventory(g), f.originalInventory);
|
||||
}
|
||||
});
|
||||
|
||||
for (const value of [0, 3, 5] as const) {
|
||||
void test(`native Basic Richese Guild grant conceals original ${value} for free without spending its ordinary allowance`, () => {
|
||||
const f = fixture({ beneficiary: 'own', karama: true }), before = structuredClone(f.game);
|
||||
const token = f.tokens.find(token => token.value === value)!;
|
||||
let game = act(f.game, f.richese, nativeAction(f, value));
|
||||
assert.equal(game.response?.kind, 'richeseNoField');
|
||||
assert.deepEqual(material(game), material(before));
|
||||
game = passResponses(game);
|
||||
assert.deepEqual(player(game, f.richese).noField!.deployed, {
|
||||
tokenId: token.id, controller: f.richese, location: { territory: f.territory, sector: f.sector },
|
||||
});
|
||||
assert.equal(player(game, f.richese).noField!.lastShipped, token.id);
|
||||
assert.equal(player(game, f.richese).reserves, player(before, f.richese).reserves);
|
||||
assert.equal(player(game, f.richese).forces[`${f.territory}:${f.sector}`] ?? 0, 0);
|
||||
for (const p of game.players) assert.equal(p.spice, player(before, p.id).spice);
|
||||
assert.deepEqual(allowances(game), allowances(before));
|
||||
const publicMarker = viewGame(game, f.entrantHark).players.find(p => p.id === f.richese)!.noField!.deployed!;
|
||||
assert.equal(publicMarker.effectiveForces, 1);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
finishEntrant(f, game);
|
||||
});
|
||||
}
|
||||
|
||||
function requestAllied(f: GuildAmbassadorNoFieldFixture, game = f.game, desert = false) {
|
||||
return act(game, f.beneficiaryID, { type: 'decision', event: f.event, requestNoField: true,
|
||||
territory: desert ? f.desert : f.territory, sector: desert ? f.desertSector : f.sector });
|
||||
}
|
||||
function offerAllied(f: GuildAmbassadorNoFieldFixture, game: Game, value: 0 | 3 | 5,
|
||||
payer = f.richese) {
|
||||
return act(game, f.richese, { type: 'decision', event: f.event,
|
||||
offerNoField: f.tokens.find(token => token.value === value)!.id,
|
||||
noFieldEvent: player(game, f.richese).noFieldEvent, payer, elite: 0 });
|
||||
}
|
||||
|
||||
void test('a five-marker materializes four available allied counters without inventing the missing fifth', () => {
|
||||
const f = fixture({ beneficiary: 'allied' });
|
||||
// Controlled prior reserve shortage after the genuine grant: move existing
|
||||
// Ecaz counters to its original setup location, without changing total custody.
|
||||
const source = structuredClone(f.game), recipient = player(source,f.beneficiaryID);
|
||||
recipient.forces['imperial_basin:9'] += recipient.reserves - 4;
|
||||
recipient.reserves = 4;
|
||||
const before = structuredClone(source);
|
||||
let game = requestAllied(f,source);
|
||||
game = offerAllied(f,game,5,f.richese);
|
||||
game = act(game,f.beneficiaryID,{type:'decision',event:f.event,acceptNoField:true});
|
||||
game = passResponses(game);
|
||||
assert.equal(player(game,f.beneficiaryID).reserves,0);
|
||||
assert.equal(player(game,f.beneficiaryID).forces[`${f.territory}:${f.sector}`],4);
|
||||
assert.equal(player(game,f.richese).noField!.lastShipped,f.tokens.find(token => token.value === 5)!.id);
|
||||
assert.deepEqual(allowances(game),allowances(before));
|
||||
for (const p of game.players) assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum,n) => sum+n,0),20);
|
||||
finishEntrant(f,game);
|
||||
});
|
||||
|
||||
void test('native No-Field remains available when the physical four-force grant has no reserve counter', () => {
|
||||
const f = fixture({ beneficiary: 'own' });
|
||||
// Controlled prior deployment conserves all original counters; the new
|
||||
// concealed marker itself withdraws none and may later reveal zero.
|
||||
const source = structuredClone(f.game), richese = player(source,f.richese);
|
||||
richese.forces['carthag:11'] += richese.reserves;
|
||||
richese.reserves = 0;
|
||||
const view = viewGame(source,f.richese);
|
||||
assert.equal(view.ambassadorEntry?.shipment?.maximum,0);
|
||||
assert.ok(view.ambassadorEntry?.noField?.options.some(option => option.action.noField === f.tokens.find(token => token.value === 5)!.id));
|
||||
let game = act(source,f.richese,nativeAction(f,5));
|
||||
game = passResponses(game);
|
||||
assert.equal(player(game,f.richese).reserves,0);
|
||||
assert.equal(player(game,f.richese).forces['carthag:11'],20);
|
||||
assert.equal(player(game,f.richese).noField!.deployed!.tokenId,f.tokens.find(token => token.value === 5)!.id);
|
||||
finishEntrant(f,game);
|
||||
});
|
||||
|
||||
void test('original ordinary starting hands automatically allow both Guild marker declarations without a pending Karama responder', () => {
|
||||
for (const beneficiary of ['own', 'allied'] as const) {
|
||||
const f = fixture({ beneficiary }), before = structuredClone(f.game);
|
||||
assert.ok(f.game.players.every(p => p.hand.every(card => card.effect !== 'karama')));
|
||||
let game = beneficiary === 'own' ? act(f.game, f.richese, nativeAction(f, 3)) : requestAllied(f);
|
||||
if (beneficiary === 'allied') {
|
||||
game = offerAllied(f, game, 3, f.beneficiaryID);
|
||||
game = act(game, f.beneficiaryID, { type: 'decision', event: f.event, acceptNoField: true });
|
||||
assert.equal(player(game, f.beneficiaryID).forces[`${f.territory}:${f.sector}`], 3);
|
||||
assert.equal(player(game, f.beneficiaryID).reserves, player(before, f.beneficiaryID).reserves - 3);
|
||||
assert.equal(player(game, f.richese).noField!.deployed, null);
|
||||
} else {
|
||||
assert.equal(player(game, f.richese).noField!.deployed!.tokenId, f.tokens.find(token => token.value === 3)!.id);
|
||||
assert.equal(player(game, f.richese).reserves, player(before, f.richese).reserves);
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
assert.deepEqual(allowances(game), allowances(before));
|
||||
finishEntrant(f, game);
|
||||
}
|
||||
});
|
||||
|
||||
for (const desert of [false, true]) {
|
||||
for (const payee of ['supplier', 'recipient', ...(desert ? ['both'] : [])]) {
|
||||
void test(`allied Basic three-marker ${desert ? 'desert' : 'stronghold'} grant charges the printed fee to ${payee}`, () => {
|
||||
const f = fixture({ beneficiary: 'allied', karama: true }), before = structuredClone(f.game);
|
||||
const payer = payee === 'supplier' ? f.richese : payee === 'recipient' ? f.beneficiaryID : 'both';
|
||||
let game = requestAllied(f, f.game, desert);
|
||||
assert.equal(game.decision?.player, f.richese);
|
||||
assert.deepEqual(material(game), material(before));
|
||||
game = offerAllied(f, game, 3, payer);
|
||||
assert.equal(game.decision?.player, f.beneficiaryID);
|
||||
assert.deepEqual(material(game), material(before));
|
||||
game = act(game, f.beneficiaryID, { type: 'decision', event: f.event, acceptNoField: true });
|
||||
assert.equal(game.response?.kind, 'richeseNoField');
|
||||
assert.deepEqual(material(game), material(before));
|
||||
game = passResponses(game);
|
||||
const cost = desert ? 2 : 1, ownerPayment = payee === 'supplier' ? cost : payee === 'both' ? 1 : 0;
|
||||
const recipientPayment = cost - ownerPayment;
|
||||
const target = desert ? `${f.desert}:${f.desertSector}` : `${f.territory}:${f.sector}`;
|
||||
assert.equal(player(game, f.beneficiaryID).forces[target], 3);
|
||||
assert.equal(player(game, f.beneficiaryID).reserves, player(before, f.beneficiaryID).reserves - 3);
|
||||
assert.equal(player(game, f.richese).reserves, player(before, f.richese).reserves);
|
||||
assert.equal(player(game, f.richese).noField!.deployed, null);
|
||||
assert.equal(player(game, f.richese).noField!.lastShipped, f.tokens.find(token => token.value === 3)!.id);
|
||||
assert.equal(player(game, f.richese).spice, player(before, f.richese).spice - ownerPayment);
|
||||
assert.equal(player(game, f.beneficiaryID).spice, player(before, f.beneficiaryID).spice - recipientPayment);
|
||||
assert.equal(player(game, f.guild).spice, player(before, f.guild).spice + cost);
|
||||
assert.deepEqual(allowances(game), allowances(before));
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
finishEntrant(f, game);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void test('actual prior native three-marker must be explicitly revealed before a different Guild marker is declared', () => {
|
||||
const f = fixture({ beneficiary: 'own', priorMarker: true });
|
||||
const original = structuredClone(f.game), richese = player(original, f.richese);
|
||||
assert.deepEqual(richese.noField!.deployed, {
|
||||
tokenId: f.priorToken.id, controller: f.richese, location: { territory: 'carthag', sector: 11 },
|
||||
});
|
||||
reject(f.game, f.richese, nativeAction(f, 5), /reveal/i);
|
||||
let game = act(f.game, f.richese, { type: 'decision', event: f.event, revealNoField: true });
|
||||
assert.equal(game.pendingAmbassador?.stage, 'ship'); assert.equal(game.decision?.player, f.richese);
|
||||
assert.equal(player(game, f.richese).noField!.deployed, null);
|
||||
assert.equal(player(game, f.richese).forces['carthag:11'], 3);
|
||||
assert.equal(player(game, f.richese).reserves, richese.reserves - 3);
|
||||
assert.equal(player(game, f.richese).noField!.lastShipped, f.priorToken.id);
|
||||
assert.notEqual(player(game, f.richese).noFieldEvent, richese.noFieldEvent);
|
||||
assert.deepEqual(allowances(game), allowances(original));
|
||||
reject(game, f.richese, nativeAction(f, 5), /event|stale/i);
|
||||
reject(game, f.richese, nativeAction(f, 3, { noFieldEvent: player(game, f.richese).noFieldEvent }), /twice|same|row/i);
|
||||
const revealed = structuredClone(game);
|
||||
game = act(game, f.richese, nativeAction(f, 5, { noFieldEvent: player(game, f.richese).noFieldEvent }));
|
||||
game = passResponses(game);
|
||||
assert.equal(player(game, f.richese).noField!.deployed!.tokenId, f.tokens.find(token => token.value === 5)!.id);
|
||||
assert.equal(player(game, f.richese).reserves, player(revealed, f.richese).reserves);
|
||||
assert.equal(player(game, f.richese).spice, richese.spice);
|
||||
assert.deepEqual(allowances(game), allowances(original));
|
||||
finishEntrant(f, game);
|
||||
});
|
||||
|
||||
void test('allied zero grant reveals the actual older supplier marker only after recipient consent and neutral prevention passes', () => {
|
||||
const f = fixture({ beneficiary: 'allied', priorMarker: true, karama: true }), before = structuredClone(f.game);
|
||||
let game = requestAllied(f);
|
||||
game = offerAllied(f, game, 0, f.beneficiaryID);
|
||||
assert.deepEqual(material(game), material(before));
|
||||
game = act(game, f.beneficiaryID, { type: 'decision', event: f.event, acceptNoField: true });
|
||||
assert.equal(game.response?.kind, 'richeseNoField');
|
||||
assert.deepEqual(material(game), material(before));
|
||||
game = passResponses(game);
|
||||
assert.equal(player(game, f.richese).forces['carthag:11'], 3);
|
||||
assert.equal(player(game, f.richese).reserves, player(before, f.richese).reserves - 3);
|
||||
assert.equal(player(game, f.richese).noField!.deployed, null);
|
||||
assert.equal(player(game, f.richese).noField!.lastShipped, f.tokens.find(token => token.value === 0)!.id);
|
||||
assert.equal(player(game, f.beneficiaryID).reserves, player(before, f.beneficiaryID).reserves);
|
||||
assert.equal(player(game, f.beneficiaryID).forces[`${f.territory}:${f.sector}`] ?? 0, 0);
|
||||
assert.equal(player(game, f.beneficiaryID).spice, player(before, f.beneficiaryID).spice - 1);
|
||||
assert.equal(player(game, f.guild).spice, player(before, f.guild).spice + 1);
|
||||
assert.deepEqual(allowances(game), allowances(before));
|
||||
finishEntrant(f, game);
|
||||
});
|
||||
|
||||
for (const who of ['supplier', 'recipient'] as const) {
|
||||
void test(`allied Guild substitution ${who} refusal returns the same free physical grant without payment or token changes`, () => {
|
||||
const f = fixture({ beneficiary: 'allied' }), before = structuredClone(f.game);
|
||||
let game = requestAllied(f);
|
||||
if (who === 'recipient') game = offerAllied(f, game, 3);
|
||||
game = act(game, who === 'supplier' ? f.richese : f.beneficiaryID,
|
||||
{ type: 'decision', event: f.event, declineNoField: true });
|
||||
assert.equal(game.decision?.player, f.beneficiaryID);
|
||||
assert.ok(game.pendingAmbassador); assert.equal(game.pendingAmbassador.event, f.event);
|
||||
assert.equal(game.pendingAmbassador.stage, 'ship');
|
||||
assert.deepEqual(material(game), material(before)); assert.deepEqual(allowances(game), allowances(before));
|
||||
game = act(game, f.beneficiaryID, { type: 'decision', event: f.event, amount: 4,
|
||||
territory: f.territory, sector: f.sector, elite: 0 });
|
||||
game = advance(game, clean);
|
||||
assert.equal(player(game, f.beneficiaryID).forces[`${f.territory}:${f.sector}`], 4);
|
||||
assert.equal(player(game, f.beneficiaryID).reserves, player(before, f.beneficiaryID).reserves - 4);
|
||||
for (const p of game.players) assert.equal(p.spice, player(before, p.id).spice);
|
||||
assert.deepEqual(player(game, f.richese).noField, player(before, f.richese).noField);
|
||||
assert.deepEqual(allowances(game), allowances(before)); finishEntrant(f, game);
|
||||
});
|
||||
}
|
||||
|
||||
void test('allied five cannot enlarge the four-force grant and malformed native mixtures preserve the live opportunity', () => {
|
||||
const allied = fixture({ beneficiary: 'allied' });
|
||||
const requested = requestAllied(allied);
|
||||
assert.ok(player(requested, allied.beneficiaryID).reserves > 4);
|
||||
reject(requested, allied.richese, { type: 'decision', event: allied.event,
|
||||
offerNoField: allied.tokens.find(token => token.value === 5)!.id,
|
||||
noFieldEvent: player(requested, allied.richese).noFieldEvent, payer: allied.richese, elite: 0 }, /four|4/i);
|
||||
const own = fixture({ beneficiary: 'own' });
|
||||
for (const extra of [{ amount: 4 }, { elite: 1 }, { allyPayment: 1 }, { noFieldEvent: 'stale-selection' },
|
||||
{ event: 'stale-parent' }, { alliedNoField: true }])
|
||||
reject(own.game, own.richese, nativeAction(own, 3, extra));
|
||||
reject(own.game, own.entrantHark, nativeAction(own, 3));
|
||||
let game = act(own.game, own.richese, nativeAction(own, 3));
|
||||
game = passResponses(game); finishEntrant(own, game);
|
||||
});
|
||||
|
||||
for (const beneficiary of ['own', 'allied'] as const) {
|
||||
void test(`actual held Karama prevents the ${beneficiary} Guild marker only, preserving ordinary shipment state and physical fallback`, () => {
|
||||
const f = fixture({ beneficiary, karama: true }), before = structuredClone(f.game);
|
||||
assert.ok(f.karama); assert.ok(player(f.game, f.entrantHark).hand.some(card => card.id === f.karama));
|
||||
let game = beneficiary === 'own' ? act(f.game, f.richese, nativeAction(f, 3)) : requestAllied(f);
|
||||
if (beneficiary === 'allied') {
|
||||
game = offerAllied(f, game, 3, f.beneficiaryID);
|
||||
game = act(game, f.beneficiaryID, { type: 'decision', event: f.event, acceptNoField: true });
|
||||
}
|
||||
assert.equal(game.response?.kind, 'richeseNoField');
|
||||
assert.deepEqual(material(game), material(before));
|
||||
game = act(game, f.entrantHark, { type: 'card', mode: 'cancel', card: f.karama });
|
||||
game = advance(game, state => !state.response && !state.pendingTreacheryDiscard);
|
||||
assert.deepEqual(material(game), material(before)); assert.deepEqual(allowances(game), allowances(before));
|
||||
assert.ok(game.pendingAmbassador); assert.equal(game.pendingAmbassador.event, f.event);
|
||||
assert.equal(game.pendingAmbassador.stage, 'ship');
|
||||
assert.equal(game.decision?.player, f.beneficiaryID);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.equal(player(game, f.richese).noFieldBlockedTurn, player(before, f.richese).noFieldBlockedTurn);
|
||||
assert.deepEqual(game.richeseAllyBlocked, before.richeseAllyBlocked);
|
||||
if (beneficiary === 'own') reject(game, f.richese, nativeAction(f, 3));
|
||||
else reject(game, f.beneficiaryID, { type: 'decision', event: f.event, requestNoField: true,
|
||||
territory: f.territory, sector: f.sector });
|
||||
game = act(game, f.beneficiaryID, { type: 'decision', event: f.event, amount: 1,
|
||||
territory: f.territory, sector: f.sector, elite: 0 });
|
||||
game = advance(game, clean);
|
||||
assert.equal(player(game, f.beneficiaryID).forces[`${f.territory}:${f.sector}`], 1);
|
||||
assert.equal(player(game, f.beneficiaryID).reserves, player(before, f.beneficiaryID).reserves - 1);
|
||||
assert.deepEqual(allowances(game), allowances(before)); finishEntrant(f, game);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, newPlayer, type Game } from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { MOBILE_STRONGHOLD, TERRITORIES } from '../game/board';
|
||||
import {
|
||||
createRicheseNoField,
|
||||
deployRicheseNoField,
|
||||
} from '../game/richese-no-field';
|
||||
import {
|
||||
GuildAmbassadorMarkerError,
|
||||
markerDestinations,
|
||||
markerOwner,
|
||||
quoteGuildAmbassadorMarker as quote,
|
||||
type GuildAmbassadorMarkerSelection,
|
||||
} from '../game/guild-ambassador-no-field';
|
||||
|
||||
function fixture(faction: FactionId = 'ecaz', advanced = false) {
|
||||
const g = createGame('GUILDAMBMARKER', newPlayer('p', 'Recipient', faction), advanced);
|
||||
g.players.push(newPlayer('q', 'Other', 'atreides'),
|
||||
newPlayer('r', 'Supplier', faction === 'richese' ? 'ecaz' : 'richese'));
|
||||
Object.assign(g, { status: 'playing', turn: 2, phase: 5, storm: 18,
|
||||
active: 'q', order: ['p', 'q', 'r'] });
|
||||
for (const player of g.players)
|
||||
Object.assign(player, { forces: {}, reserves: 20, tanks: 0, spice: 5,
|
||||
elites: undefined, hand: [], shipped: true, moved: 2 });
|
||||
const owner = g.players.find((p) => p.faction === 'richese')!;
|
||||
owner.noField = createRicheseNoField(['token0', 'token3', 'token5']);
|
||||
owner.noFieldEvent = 'selection-event';
|
||||
if (owner.id !== 'p') {
|
||||
owner.ally = 'p';
|
||||
g.players[0].ally = owner.id;
|
||||
}
|
||||
return g;
|
||||
}
|
||||
function selection(extra: Partial<GuildAmbassadorMarkerSelection> = {}): GuildAmbassadorMarkerSelection {
|
||||
return { tokenId: 'token3', tokenEvent: 'selection-event',
|
||||
territory: 'sietch_tabr', sector: 14, ...extra };
|
||||
}
|
||||
function rejected(g: Game, chosen = selection(), checkFunds = true) {
|
||||
const before = structuredClone(g), previous = structuredClone(chosen);
|
||||
assert.throws(() => quote(g, 'p', chosen, checkFunds), GuildAmbassadorMarkerError);
|
||||
assert.deepEqual(g, before);
|
||||
assert.deepEqual(chosen, previous);
|
||||
}
|
||||
function priorMarker(g: Game, value: 0 | 3 | 5,
|
||||
territory = 'red_chasm', sector = 7) {
|
||||
const owner = markerOwner(g, 'p')!;
|
||||
owner.noField = deployRicheseNoField(owner.noField!, {
|
||||
tokenId: `token${value}`, controller: owner.id,
|
||||
location: { territory, sector },
|
||||
});
|
||||
}
|
||||
|
||||
void test('native zero three and five substitute one free concealed marker with no reserve withdrawal', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const reserves of [0, 2, 20])
|
||||
for (const value of [0, 3, 5] as const) {
|
||||
const g = fixture('richese', advanced), p = g.players[0];
|
||||
p.reserves = reserves;
|
||||
p.spice = 0;
|
||||
const chosen = selection({ tokenId: `token${value}` }), before = structuredClone(g);
|
||||
const result = quote(g, 'p', chosen);
|
||||
assert.deepEqual({ ...result, marker: { ...result.marker, stateSignature: '' } }, {
|
||||
player: 'p', amount: 1, elite: 0, territory: 'sietch_tabr', sector: 14,
|
||||
advisors: false, cost: 0, allyPayment: 0,
|
||||
marker: { mode: 'own', owner: 'p', tokenId: `token${value}`,
|
||||
tokenEvent: 'selection-event', value, payer: 'p', ownerPayment: 0,
|
||||
recipientPayment: 0, stateSignature: '' },
|
||||
});
|
||||
assert.ok(result.marker.stateSignature);
|
||||
assert.deepEqual(g, before);
|
||||
assert.equal(markerOwner(g, 'p'), p);
|
||||
assert.ok(markerDestinations(g, 'p').some((to) => to.territory === 'sietch_tabr'));
|
||||
}
|
||||
});
|
||||
|
||||
void test('allied materialization uses the exact reserve cap and rejects five actual counters instead of truncating', () => {
|
||||
for (const value of [0, 3, 5] as const)
|
||||
for (const reserves of [0, 1, 2, 3, 4, 5, 20]) {
|
||||
const g = fixture();
|
||||
g.players[0].reserves = reserves;
|
||||
const chosen = selection({ tokenId: `token${value}` });
|
||||
if (Math.min(value, reserves) > 4) {
|
||||
rejected(g, chosen);
|
||||
} else {
|
||||
const before = structuredClone(g), result = quote(g, 'p', chosen);
|
||||
assert.equal(result.amount, Math.min(value, reserves));
|
||||
assert.equal(result.marker.value, value);
|
||||
assert.equal(result.marker.mode, 'allied');
|
||||
assert.equal(result.marker.owner, 'r');
|
||||
assert.equal(result.cost, 1);
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('allied mandatory fees permit either full payer or only the exact two-spice equal split', () => {
|
||||
for (const faction of ['ecaz', 'guild'] as const)
|
||||
for (const stronghold of [false, true])
|
||||
for (const payer of ['p', 'r', 'both']) {
|
||||
const g = fixture(faction);
|
||||
// The independent grant keeps the compulsory printed transport fee,
|
||||
// including an already purchased rate or the Guild's native rate.
|
||||
Object.assign(g, { karamaShipping: { player: 'p' } });
|
||||
const chosen = selection({ payer,
|
||||
territory: stronghold ? 'sietch_tabr' : 'red_chasm',
|
||||
sector: stronghold ? 14 : 7 });
|
||||
if (stronghold && payer === 'both') {
|
||||
rejected(g, chosen);
|
||||
continue;
|
||||
}
|
||||
const result = quote(g, 'p', chosen), cost = stronghold ? 1 : 2;
|
||||
assert.equal(result.cost, cost);
|
||||
assert.equal(result.marker.ownerPayment,
|
||||
payer === 'r' ? cost : payer === 'both' ? 1 : 0);
|
||||
assert.equal(result.marker.recipientPayment,
|
||||
payer === 'p' ? cost : payer === 'both' ? 1 : 0);
|
||||
assert.equal(result.allyPayment, 0);
|
||||
}
|
||||
rejected(fixture(), selection({ payer: 'q' }));
|
||||
rejected(fixture('richese'), selection({ payer: 'r' }));
|
||||
const g = fixture();
|
||||
g.players[2].spice = 0;
|
||||
rejected(g);
|
||||
assert.equal(quote(g, 'p', selection({ payer: 'p' })).marker.recipientPayment, 1);
|
||||
g.players[0].spice = 0;
|
||||
rejected(g, selection({ payer: 'p' }));
|
||||
assert.equal(quote(g, 'p', selection({ payer: 'p' }), false).cost, 1);
|
||||
rejected(g, selection({ payer: 'r' }), false);
|
||||
});
|
||||
|
||||
void test('allied typed reserves are selected within the real amount while native markers always have zero elites', () => {
|
||||
const g = fixture('emperor'), p = g.players[0];
|
||||
p.reserves = 4;
|
||||
p.elites = { reserves: 3, tanks: 0, forces: {}, revived: 0 };
|
||||
assert.equal(quote(g, 'p', selection()).elite, 2);
|
||||
assert.equal(quote(g, 'p', selection({ elite: 3 })).elite, 3);
|
||||
assert.equal(quote(g, 'p', selection({ tokenId: 'token5' })).elite, 3);
|
||||
for (const elite of [0, 1, 4]) rejected(g, selection({ elite }));
|
||||
assert.equal(quote(g, 'p', selection({ tokenId: 'token0' })).elite, 0);
|
||||
rejected(g, selection({ tokenId: 'token0', elite: 1 }));
|
||||
rejected(fixture('richese'), selection({ elite: 1 }));
|
||||
});
|
||||
|
||||
void test('native prior markers require an explicit reveal and shared last-token history remains binding', () => {
|
||||
const g = fixture('richese');
|
||||
priorMarker(g, 3);
|
||||
rejected(g, selection({ tokenId: 'token5' }));
|
||||
assert.throws(() => markerDestinations(g, 'p'), GuildAmbassadorMarkerError);
|
||||
g.players[0].noField!.deployed = null;
|
||||
rejected(g, selection());
|
||||
assert.equal(quote(g, 'p', selection({ tokenId: 'token5' })).amount, 1);
|
||||
g.players[0].noField!.hiddenReturn = true;
|
||||
rejected(g, selection());
|
||||
assert.equal(quote(g, 'p', selection({ tokenId: 'token0' })).marker.value, 0);
|
||||
rejected(fixture(), selection({ tokenEvent: 'old-selection-event' }));
|
||||
rejected(fixture(), selection({ tokenId: 'unknown-token' }));
|
||||
});
|
||||
|
||||
void test('allied prior reveal uses the supplier stock before checking actual destination occupancy', () => {
|
||||
for (const [oldValue, ownerReserves, allowed] of [
|
||||
[0, 20, true], [3, 0, true], [3, 1, false], [5, 2, false],
|
||||
] as const) {
|
||||
const g = fixture('emperor'), owner = g.players[2];
|
||||
g.players[0].reserves = 4;
|
||||
owner.reserves = ownerReserves;
|
||||
priorMarker(g, oldValue, 'sietch_tabr', 14);
|
||||
const chosen = selection({ tokenId: oldValue === 5 ? 'token3' : 'token5' });
|
||||
if (allowed) {
|
||||
const before = structuredClone(g), result = quote(g, 'p', chosen);
|
||||
assert.equal(result.amount, 4);
|
||||
assert.ok(markerDestinations(g, 'p').some((to) => to.territory === 'sietch_tabr'));
|
||||
assert.deepEqual(g, before);
|
||||
} else {
|
||||
rejected(g, chosen);
|
||||
assert.ok(!markerDestinations(g, 'p').some((to) => to.territory === 'sietch_tabr'));
|
||||
}
|
||||
assert.equal(owner.noField!.deployed!.tokenId, `token${oldValue}`);
|
||||
assert.equal(owner.reserves, ownerReserves);
|
||||
}
|
||||
const g = fixture();
|
||||
priorMarker(g, 3);
|
||||
rejected(g); // A compulsory reveal never clears the shared last-shipped ban.
|
||||
const before = structuredClone(g);
|
||||
assert.equal(quote(g, 'p', selection({ tokenId: 'token0' })).amount, 0);
|
||||
assert.deepEqual(g, before);
|
||||
});
|
||||
|
||||
void test('a No-Field recipient must be Richese or its current reciprocal off-planet ally', () => {
|
||||
const mutations: ((g: Game) => void)[] = [
|
||||
(g) => { g.players[0].ally = null; },
|
||||
(g) => { g.players[2].ally = null; },
|
||||
(g) => { g.players[0].ally = 'q'; },
|
||||
(g) => { g.players[0].faction = 'fremen'; },
|
||||
(g) => { g.players[2].faction = 'harkonnen'; },
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const g = fixture();
|
||||
mutate(g);
|
||||
rejected(g);
|
||||
}
|
||||
assert.equal(markerOwner(fixture(), 'q'), null);
|
||||
assert.equal(markerOwner(fixture(), 'missing'), null);
|
||||
});
|
||||
|
||||
void test('printed sectors, storm, mobile-stronghold exclusion and occupying sides apply to markers', () => {
|
||||
const g = fixture('richese');
|
||||
g.mobileStronghold = { location: 'wind_pass:14' };
|
||||
g.storm = 14;
|
||||
for (const to of [
|
||||
{ territory: 'sietch_tabr', sector: 14 },
|
||||
{ territory: 'wind_pass', sector: 14 },
|
||||
{ territory: 'sietch_tabr', sector: 15 },
|
||||
{ territory: MOBILE_STRONGHOLD, sector: 0 },
|
||||
{ territory: 'homeworld', sector: 0 },
|
||||
{ territory: 'discovery', sector: 0 },
|
||||
]) rejected(g, selection(to));
|
||||
assert.equal(quote(g, 'p', selection({ territory: 'wind_pass', sector: 15 })).amount, 1);
|
||||
assert.equal(quote(g, 'p', selection({ territory: 'polar_sink', sector: 0 })).amount, 1);
|
||||
assert.ok(markerDestinations(g, 'p').every((to) =>
|
||||
to.sector !== 14 && to.territory !== MOBILE_STRONGHOLD));
|
||||
g.storm = 18;
|
||||
g.players[1].forces = { 'sietch_tabr:14': 1 };
|
||||
g.players[2].forces = { 'sietch_tabr:14': 1 };
|
||||
rejected(g);
|
||||
});
|
||||
|
||||
void test('native Ecaz co-occupation and existing Bene Gesserit advisor stance retain shared entry rules', () => {
|
||||
const g = fixture();
|
||||
priorMarker(g, 3, 'sietch_tabr', 14);
|
||||
g.players[1].forces = { 'sietch_tabr:14': 1 };
|
||||
assert.equal(quote(g, 'p', selection({ tokenId: 'token0' })).advisors, false);
|
||||
const bg = fixture('beneGesserit', true);
|
||||
bg.players[0].forces = { 'sietch_tabr:14': 1 };
|
||||
bg.players[0].advisors = { sietch_tabr: { lockedTurn: bg.turn } };
|
||||
bg.players[1].forces = { 'sietch_tabr:14': 1 };
|
||||
bg.players[2].forces = { 'sietch_tabr:14': 1 };
|
||||
assert.equal(quote(bg, 'p', selection()).advisors, true);
|
||||
bg.advanced = false;
|
||||
rejected(bg);
|
||||
});
|
||||
|
||||
void test('malformed physical marker or typed custody and malformed selections reject without mutation', () => {
|
||||
const mutations: ((g: Game) => void)[] = [
|
||||
(g) => { g.players[0].reserves = -1; },
|
||||
(g) => { g.players[2].reserves = NaN; },
|
||||
(g) => { g.players[0].elites = { reserves: 21, tanks: 0, forces: {}, revived: 0 }; },
|
||||
(g) => { g.players[1].forces = { 'unknown:0': 1 }; },
|
||||
(g) => { g.players[1].forces = { 'red_chasm:7': -1 }; },
|
||||
(g) => { g.players[1].elites = { reserves: 0, tanks: 0, revived: 0,
|
||||
forces: { 'red_chasm:7': 1 } }; },
|
||||
(g) => { g.players[2].noField!.tokens[0].id = 'token3'; },
|
||||
(g) => { g.players[2].noFieldEvent = ''; },
|
||||
(g) => { priorMarker(g, 0); g.players[2].noField!.deployed!.controller = 'p'; },
|
||||
(g) => { g.players[0].forces = { 'sietch_tabr:14': Number.MAX_SAFE_INTEGER }; },
|
||||
(g) => { g.storm = 19; },
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const g = fixture();
|
||||
mutate(g);
|
||||
rejected(g);
|
||||
}
|
||||
for (const extra of [
|
||||
{ tokenId: null }, { tokenEvent: 1 }, { sector: 1.5 }, { payer: '' },
|
||||
{ elite: -1 }, { elite: NaN }, { elite: '0' },
|
||||
]) rejected(fixture(), { ...selection(), ...extra } as unknown as GuildAmbassadorMarkerSelection);
|
||||
});
|
||||
|
||||
void test('quotes and destination choices never consult cards RNG or an unchecked recipient balance', () => {
|
||||
const g = fixture(), chosen = selection({ payer: 'p' });
|
||||
priorMarker(g, 0);
|
||||
const expected = quote(g, 'p', chosen, false), destinations = markerDestinations(g, 'p');
|
||||
for (const player of g.players)
|
||||
for (const key of ['hand', 'leaders', 'traitors'])
|
||||
Object.defineProperty(player, key, { get() { throw new Error(`private ${key}`); } });
|
||||
Object.defineProperty(g.players[0], 'spice', {
|
||||
get() { throw new Error('unchecked private recipient balance'); },
|
||||
});
|
||||
for (const key of ['deck', 'discard', 'aid'])
|
||||
Object.defineProperty(g, key, { get() { throw new Error(`unneeded ${key}`); } });
|
||||
const random = Math.random;
|
||||
Math.random = () => { throw new Error('unexpected RNG'); };
|
||||
try {
|
||||
assert.deepEqual(quote(g, 'p', chosen, false), expected);
|
||||
assert.deepEqual(markerDestinations(g, 'p'), destinations);
|
||||
} finally {
|
||||
Math.random = random;
|
||||
}
|
||||
});
|
||||
|
||||
void test('the destination domain remains finite and every zero-reserve native choice has a quote witness', () => {
|
||||
const g = fixture('richese');
|
||||
g.players[0].reserves = 0;
|
||||
const before = structuredClone(g), choices = markerDestinations(g, 'p');
|
||||
assert.ok(choices.length > 0);
|
||||
assert.equal(new Set(choices.map((to) => `${to.territory}:${to.sector}`)).size, choices.length);
|
||||
assert.ok(choices.length <= TERRITORIES.flatMap((to) => to.sectors).length);
|
||||
for (const to of choices) {
|
||||
const result = quote(g, 'p', selection({ ...to, tokenId: 'token0' }));
|
||||
assert.equal(result.amount, 1);
|
||||
assert.equal(result.advisors, to.advisors);
|
||||
}
|
||||
const signature = quote(g, 'p', selection()).marker.stateSignature;
|
||||
assert.equal(quote(g, 'p', selection()).marker.stateSignature, signature);
|
||||
g.players[1].forces = { 'red_chasm:7': 1 };
|
||||
assert.notEqual(quote(g, 'p', selection()).marker.stateSignature, signature);
|
||||
g.players[1].forces = {};
|
||||
assert.deepEqual(g, before);
|
||||
choices.length = 0;
|
||||
assert.ok(markerDestinations(g, 'p').length > 0);
|
||||
});
|
||||
Reference in new issue
Block a user