Preserve Guild advisor arrivals and support matching Semuta costs
This commit is contained in:
1 parent
81fbbca309
commit
617a6593a9
14 files changed
+1649
-14
No files matched your search
@@ -16,6 +16,48 @@ open mode gates.
|
|||||||
|
|
||||||
|
|
||||||
## Current checkpoint and work
|
## Current checkpoint and work
|
||||||
|
**Guild advisor arrival and Semuta matching costs — 26 October. Prototyped / Partial.**
|
||||||
|
Fresh Guild board transport now retains an advisor group's source stance when
|
||||||
|
joining a foreign group, including its Guild ally. Shared projected entry and
|
||||||
|
live declaration/commit use that source. New declarations carry an optional
|
||||||
|
source stamp; old recorded orders retain their original settlement/signature
|
||||||
|
format. Fighter cross-entry, direct reserve defaults and advisor locks retain
|
||||||
|
their distinct rules. This repairs an observed source omission, not a new ruling.
|
||||||
|
|
||||||
|
Existing `karamaAdvisorFlipDiscard` also admits Planetologist gathering, Guild/
|
||||||
|
Junction transport and Fremen Ambassador matching costs. Exact native parents
|
||||||
|
retain the gathered queue/approvals, unused transport opportunity or consumed
|
||||||
|
Ambassador trigger. Claim/pass applies denial once, then actual legal alternatives
|
||||||
|
remain available. Existing persistent controls/minimal bots serve these sources;
|
||||||
|
no mode gate changes.
|
||||||
|
|
||||||
|
Three disjoint source lanes and root integration pass **22 files,255/255**,
|
||||||
|
types/lint/build and **sixteen native engine/GameTable SSR/actual Semuta-callback
|
||||||
|
journeys** on unchanged source. Thirty-five new cases cover eight cost sources,
|
||||||
|
native Guild arrival/default/lock behavior, a labelled legacy-format supplement,
|
||||||
|
claim/pass, allowance, legal alternatives, source/stale rejection and four minimal
|
||||||
|
profiles. Gather158/162 earns real all14 Skill offers/Planetologist, E bribe1 and
|
||||||
|
BG's paid Basin2/OhGap1; allowing the first group moves nothing before the second.
|
||||||
|
Guild169 earns a real reciprocal Nexus pact; Junction164 earns lobby Homeworlds
|
||||||
|
and its live high-Guild offer. Both types really take a different paid1 route
|
||||||
|
after denial. Ambassador227 earns native Fremen supply, paid token/entrant,
|
||||||
|
BG/Ecaz pact and actual grant; the legal Polar alternate retains its used token
|
||||||
|
and all ordinary allowances. Every45-card source uses actual cache1 and dealt
|
||||||
|
Karama; no played physical state, skill, pact, clock or parent is installed.
|
||||||
|
|
||||||
|
Feedback removes an unused import and fixed per-seat reply/rate-window
|
||||||
|
assumptions. The rejected allied target then exposes the actual Guild source
|
||||||
|
omission, which is repaired with native regressions rather than avoiding it.
|
||||||
|
The observed native frame-format change justifies the affected Guild permission,
|
||||||
|
rate, Betrayal and Secret Ally checks; broad assurance remains deferred.
|
||||||
|
Private report/logs:
|
||||||
|
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-incoming-matching-proof-v3/`;
|
||||||
|
journeys/HTML: adjacent `20261026-semuta-incoming-matching-runtime/`.
|
||||||
|
Wider grant/module combinations, complete modes and final assurance remain open.
|
||||||
|
No saved-room/D1/server, reset/restart, deployment or recovery operation occurs;
|
||||||
|
DOM/hydration, browser/mobile and deployed acceptance remain due.
|
||||||
|
[Source and scope](SEMUTA_SOURCE_UPDATE_20260907.md#guild-advisor-arrival-and-matching-costs--26-october-2026).
|
||||||
|
|
||||||
**Semuta on Spiritual Advisor and relocation costs — 26 October. Prototyped / Partial.**
|
**Semuta on Spiritual Advisor and relocation costs — 26 October. Prototyped / Partial.**
|
||||||
Existing terminal/advisor cost continuations now retain ordinary Spiritual
|
Existing terminal/advisor cost continuations now retain ordinary Spiritual
|
||||||
shipping, matching and committed-arrival parents, plus Ambassador/Discovery
|
shipping, matching and committed-arrival parents, plus Ambassador/Discovery
|
||||||
|
|||||||
@@ -1,5 +1,46 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 26 October 2026 — Guild advisor arrival and Semuta matching costs
|
||||||
|
|
||||||
|
**Prototyped / Partial.** Fix fresh Guild board transport dropping an advisor's
|
||||||
|
origin when quoting/validating a foreign arrival. Shared human/bot entry and live
|
||||||
|
declaration/commit now preserve its source stance, including arrival beside the
|
||||||
|
actual Guild ally. A conditional source stamp preserves old recorded declaration
|
||||||
|
settlement and signature bytes; direct reserve defaults, fighter restrictions
|
||||||
|
and fresh-advisor locks remain distinct. The native Betrayal frame admits the
|
||||||
|
new optional field under the same semantic validation. This follows the existing
|
||||||
|
board-source contract, without new rulings or mode gates.
|
||||||
|
|
||||||
|
Existing advisor cost continuation also retains actual gathered, Guild/Junction
|
||||||
|
and Fremen Ambassador matching declarations through Semuta claim/pass. Native
|
||||||
|
denial leaves the whole gather unused, preserves the original transport grant
|
||||||
|
or restores the consumed Ambassador's chooser. Real legal alternatives complete
|
||||||
|
their original payment/arrival once. Persistent controls/minimal bots are reused.
|
||||||
|
[Source and scope](SEMUTA_SOURCE_UPDATE_20260907.md#guild-advisor-arrival-and-matching-costs--26-october-2026).
|
||||||
|
|
||||||
|
Three disjoint source lanes and root integration pass **22 files,255/255**,
|
||||||
|
types/lint/build and **sixteen native engine/GameTable SSR/actual Semuta-callback
|
||||||
|
journeys** on unchanged source. Thirty-five new cases cover eight cost sources,
|
||||||
|
native Guild arrival/default/lock behavior, labelled legacy-format acceptance,
|
||||||
|
claim/pass, allowance, alternatives, source/stale rejection and four profiles.
|
||||||
|
Gather158/162 earns actual Planetologist/all14 Skills, E bribe1 and paid BG2+1;
|
||||||
|
Guild169/Junction164 earn a real pact or live World offer and preserve their
|
||||||
|
unused transport before actual paid1 replacements. Ambassador227 earns original
|
||||||
|
Fremen supply, token/entrant fees and mutual BG/Ecaz alliance, then its real
|
||||||
|
Polar alternate keeps token/ordinary allowances. Actual cache1 and dealt Karama
|
||||||
|
supply all45-card sources; no played force, stance, wallet, skill, pact, clock,
|
||||||
|
source or parent is installed.
|
||||||
|
|
||||||
|
Feedback removes an unused import and fixed reply/rate-window assertions. The
|
||||||
|
allied replacement's rejection exposes the actual source-stance defect; native
|
||||||
|
regressions prove its correction and the retained older receipt shape. Its frame
|
||||||
|
change justifies the related Guild boundary cases. Final frozen v3 report/logs:
|
||||||
|
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-incoming-matching-proof-v3/`;
|
||||||
|
scripts/journeys/HTML: adjacent `20261026-semuta-incoming-matching-runtime/`.
|
||||||
|
Broader modules, full modes and final rules/recovery/privacy/custody assurance
|
||||||
|
remain open; these results do not certify DOM/hydration, browser/mobile or
|
||||||
|
deployment. No saved-room/D1/server, reset/restart or recovery operation occurs.
|
||||||
|
|
||||||
## 26 October 2026 — Semuta on Spiritual Advisor and relocation costs
|
## 26 October 2026 — Semuta on Spiritual Advisor and relocation costs
|
||||||
|
|
||||||
**Prototyped / Partial.** Existing terminal/advisor cost continuations now retain
|
**Prototyped / Partial.** Existing terminal/advisor cost continuations now retain
|
||||||
|
|||||||
@@ -2,6 +2,97 @@
|
|||||||
|
|
||||||
7 September 2026. Read `docs/SEMUTA_DRUG_ENGINE_AUDIT.md` in full, inspected the physical card again, checked current capacity/recovery code, and freshly searched publisher sources. No project runtime edits.
|
7 September 2026. Read `docs/SEMUTA_DRUG_ENGINE_AUDIT.md` in full, inspected the physical card again, checked current capacity/recovery code, and freshly searched publisher sources. No project runtime edits.
|
||||||
|
|
||||||
|
## Guild advisor arrival and matching costs — 26 October 2026
|
||||||
|
|
||||||
|
**Prototyped / Partial.** Existing `karamaAdvisorFlipDiscard` now admits native
|
||||||
|
Planetologist gathering, Guild/Junction transport and Fremen Ambassador matching
|
||||||
|
costs. The [recorded matching contract](ADVISOR_MATCHING.md) remains authoritative:
|
||||||
|
one origin's prevention stops a whole uncommitted gather; transport prevention
|
||||||
|
retains its unused producer opportunity; Ambassador prevention retains its
|
||||||
|
already-consumed trigger and restores that benefit's chooser. This extends fresh
|
||||||
|
cost coverage without a new ruling, public start or mode/publication gate.
|
||||||
|
|
||||||
|
Private cost signatures conditionally bind the actual gather order/queue/index/
|
||||||
|
approvals, transport declaration/opportunity/denials or matching Ambassador/
|
||||||
|
token. Pure native validation checks the exact current selected packet. It
|
||||||
|
executes no payment, transfer, collection or arrival. Fresh claim/pass restores
|
||||||
|
the original response and applies its native denial once. The denied packet
|
||||||
|
stays blocked under its existing producer identity; real different routes or
|
||||||
|
packets remain available. Ambassador has no invented decline or trigger refund.
|
||||||
|
Earlier cost signature formats remain unchanged when these new sources are
|
||||||
|
absent. Existing persistent neutral controls/private choice and minimal profiles
|
||||||
|
serve the new sources.
|
||||||
|
|
||||||
|
The original advisor replacement test exposes a separate native defect: Guild
|
||||||
|
entry projection and live declaration/commit omit the board origin when deriving
|
||||||
|
arrival stance, treating an actual advisor as a fighter beside foreign forces.
|
||||||
|
The existing physical28/source-stance contract and shared occupancy rule already
|
||||||
|
permit advisor coexistence. The correction passes origin through the shared
|
||||||
|
entry quote and stamps fresh Advanced BG board declarations with optional
|
||||||
|
`advisorArrivalSource`. Commit revalidates that exact original source; permission/
|
||||||
|
rate adapters and conditional signatures retain it. Unstamped recorded orders
|
||||||
|
keep their original protocol and receipt bytes. The native Betrayal frame's
|
||||||
|
shape check admits this optional field under the same semantic validator.
|
||||||
|
No stored order is rewritten. Direct reserve defaults, fighter occupancy,
|
||||||
|
advisor locks and existing empty-arrival conversion keep their own rules.
|
||||||
|
|
||||||
|
Three disjoint [gather](../tests/semuta-advisor-gather-engine.test.ts),
|
||||||
|
[transport](../tests/semuta-advisor-transport-matching-engine.test.ts) and
|
||||||
|
[Ambassador](../tests/semuta-advisor-ambassador-matching-engine.test.ts) lanes plus
|
||||||
|
[native Guild regressions](../tests/guild-advisor-source-arrival-engine.test.ts)
|
||||||
|
and root integration pass **22 files,255/255**, types/lint/build and **sixteen
|
||||||
|
native engine/GameTable SSR/actual Semuta-callback journeys** on unchanged source.
|
||||||
|
Thirty-five new cases cover eight original cost sources, native arrival/default/
|
||||||
|
lock behavior, a labelled controlled legacy-format supplement, claim/pass,
|
||||||
|
allowance, genuine legal replacements, source/stale rejection and four minimal
|
||||||
|
profiles. The observed native frame change justifies the related Guild quote,
|
||||||
|
permission, rate, Betrayal and Secret Ally cases; it does not start a broad
|
||||||
|
save/privacy/custody campaign.
|
||||||
|
|
||||||
|
Gather158/162 uses original Advanced R/C/BG/A/E, exact CHOAM and all14 Skills from
|
||||||
|
the original undealt initializer. Real five-seat offers/assignments earn BG
|
||||||
|
Planetologist. E really bribes1; BG pays4 for Basin2 in turn1 and2 for OhGap1 in
|
||||||
|
turn2, retaining its original Arrakeen advisor beside A10. The two-origin gather
|
||||||
|
opens original first/second counters; actual first allowance changes no physical
|
||||||
|
state. Either denial preserves both groups and unused movement, then the real
|
||||||
|
same-type Basin1→OhGap replacement uses one normal movement.
|
||||||
|
|
||||||
|
Guild169/Junction164 uses original Advanced R/C/BG/A/G, exact CHOAM. BG's real
|
||||||
|
setup advisor and paid Basin2 provide both incoming-type directions. Guild earns
|
||||||
|
its actual next-turn Worm Nexus and reciprocal alliance. Junction instead earns
|
||||||
|
real lobby Homeworlds and high-Guild's current half-price offer. Costs stop before
|
||||||
|
permission/rate/payment. Either denied packet permits actual paid1 transfer to
|
||||||
|
empty Polar for fighters or Guild-occupied Tuek's for advisors. Only an actual
|
||||||
|
price difference opens a rate counter; city full1/half1 proceeds to original
|
||||||
|
income. The new source-stamp regression proves native quote/live allied advisor
|
||||||
|
arrival, while fighters still cannot cross into that ally. Direct reserve
|
||||||
|
shipping retains its original fighter default and temporary-visit contract.
|
||||||
|
|
||||||
|
Ambassador227 uses original six-seat R/C/Ecaz/BG/A/E, exact CHOAM+Ecaz. Actual
|
||||||
|
final-Traitor lottery earns Fremen supply, Ecaz pays1 for the Carthag token, BG
|
||||||
|
pays2 for Tuek's2, real Worm Nexus proposals earn the BG/Ecaz pact, and A pays1
|
||||||
|
to trigger that token. Ecaz really grants relocation to BG; both one-counter
|
||||||
|
opposite-type selections stop at matching. After denial the real fighter1→Polar
|
||||||
|
alternate keeps the used token and all ordinary allowances, then finishes its
|
||||||
|
original child once. Actual cache1 and originally dealt Karama supply every45-
|
||||||
|
card source. No played hand, force, stance, wallet, skill, pact, clock, receipt
|
||||||
|
or parent is installed. Controlled malformed/legacy copies are labelled
|
||||||
|
supplements, not genuine historical captures.
|
||||||
|
|
||||||
|
Feedback removes an unused import and assumptions about one manual pass per seat
|
||||||
|
or a mandatory rate window without a price difference. The actual allied-entry
|
||||||
|
failure then leads to the source fix rather than a different destination that
|
||||||
|
hides it. Final frozen v3 report binds2924 files to
|
||||||
|
`8ffd8a7292d297e9dae89dcf1dbda42aeb4aa01487cecf45ab83903d498adef0`.
|
||||||
|
Private report/logs:
|
||||||
|
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-incoming-matching-proof-v3/`;
|
||||||
|
scripts/journeys/HTML: adjacent `20261026-semuta-incoming-matching-runtime/`.
|
||||||
|
|
||||||
|
Wider grant/module combinations, complete modes and final rules/recovery/privacy/
|
||||||
|
custody assurance remain open. SSR/component callbacks do not certify DOM/
|
||||||
|
hydration, browser/mobile or deployed acceptance. No saved-room, D1, server,
|
||||||
|
reset/restart, deployment or recovery operation occurs.
|
||||||
|
|
||||||
## Spiritual Advisor and relocation costs — 26 October 2026
|
## Spiritual Advisor and relocation costs — 26 October 2026
|
||||||
|
|
||||||
**Prototyped / Partial.** Existing `karamaTerminalDiscard` admits the original
|
**Prototyped / Partial.** Existing `karamaTerminalDiscard` admits the original
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function botEntryAllowed(
|
|||||||
g,
|
g,
|
||||||
p,
|
p,
|
||||||
to,
|
to,
|
||||||
kind === 'move' ? source : undefined,
|
kind === 'move' || kind === 'guildShip' ? source : undefined,
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
source &&
|
source &&
|
||||||
|
|||||||
+39
-11
@@ -1269,6 +1269,9 @@ type PendingGuildTransport = {
|
|||||||
cost: number;
|
cost: number;
|
||||||
allyPayment: number;
|
allyPayment: number;
|
||||||
advisors: boolean;
|
advisors: boolean;
|
||||||
|
/** Fresh board transports retain their actual source stance at foreign arrivals.
|
||||||
|
* Absent on declarations recorded before this source cutover. */
|
||||||
|
advisorArrivalSource?: string;
|
||||||
};
|
};
|
||||||
type GuildTransportOrder = Omit<PendingGuildTransport, 'event' | 'signature' | 'fromReserves' | 'permission'>;
|
type GuildTransportOrder = Omit<PendingGuildTransport, 'event' | 'signature' | 'fromReserves' | 'permission'>;
|
||||||
type JunctionTransportDeclaration = {
|
type JunctionTransportDeclaration = {
|
||||||
@@ -6086,12 +6089,21 @@ function semutaMarketGrantKaramaResponse(g: Game, response: ResponseWindow, play
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/** Matching and completed relocation can retain the original Ambassador child.
|
||||||
|
* Their native source validators still prove the exact parent before admission. */
|
||||||
|
function semutaAdvisorAmbassadorParent(g: Game, response: ResponseWindow): boolean {
|
||||||
|
if (response.kind !== 'advisorFlip') return false;
|
||||||
|
if (response.advisorAmbassadorMatchingSource)
|
||||||
|
return !!g.pendingAdvisorAmbassadorMatching &&
|
||||||
|
response.advisorAmbassadorMatchingSource.parent === g.pendingAmbassador?.event;
|
||||||
|
return response.automaticAdvisorFlip?.producer === 'specialRelocation' &&
|
||||||
|
g.pendingAutomaticAdvisorRelocation?.resume.kind === 'ambassador';
|
||||||
|
}
|
||||||
/** Ordinary Advanced advisor conversion sources retain their native timing:
|
/** Ordinary Advanced advisor conversion sources retain their native timing:
|
||||||
* preparation, uncommitted matching or an already completed native producer. */
|
* preparation, uncommitted matching or an already completed native producer. */
|
||||||
function semutaAdvisorFlipKaramaResponse(g: Game, response: ResponseWindow, player: string): boolean {
|
function semutaAdvisorFlipKaramaResponse(g: Game, response: ResponseWindow, player: string): boolean {
|
||||||
const spiritual = response.kind === 'advisorFlip' && semutaSpiritualKaramaParent(g,response);
|
const spiritual = response.kind === 'advisorFlip' && semutaSpiritualKaramaParent(g,response);
|
||||||
const relocation = response.automaticAdvisorFlip?.producer === 'specialRelocation';
|
const ambassador = semutaAdvisorAmbassadorParent(g,response);
|
||||||
const ambassador = relocation && g.pendingAutomaticAdvisorRelocation?.resume.kind === 'ambassador';
|
|
||||||
if (response.kind !== 'advisorFlip' || !g.advanced ||
|
if (response.kind !== 'advisorFlip' || !g.advanced ||
|
||||||
response.owner !== byFaction(g,'beneGesserit')?.id ||
|
response.owner !== byFaction(g,'beneGesserit')?.id ||
|
||||||
g.battle || g.decision || g.phaseOpening || g.pendingKarama || g.truthtrance ||
|
g.battle || g.decision || g.phaseOpening || g.pendingKarama || g.truthtrance ||
|
||||||
@@ -6101,8 +6113,7 @@ function semutaAdvisorFlipKaramaResponse(g: Game, response: ResponseWindow, play
|
|||||||
(g.pendingSpiritualAdvisor && !spiritual) || (g.pendingAmbassador && !ambassador) || g.pendingTerrorEntry || g.pendingArrivalOverlap ||
|
(g.pendingSpiritualAdvisor && !spiritual) || (g.pendingAmbassador && !ambassador) || g.pendingTerrorEntry || g.pendingArrivalOverlap ||
|
||||||
(!spiritual && !semutaAdvisorArrivalSupported(g,response)) || g.ornithopter || g.summonedWorm || g.wormRides.length > 0 ||
|
(!spiritual && !semutaAdvisorArrivalSupported(g,response)) || g.ornithopter || g.summonedWorm || g.wormRides.length > 0 ||
|
||||||
g.currentAuctionSale || g.choamMarket || g.pendingChoamMarketGhola || g.pendingIxAlly ||
|
g.currentAuctionSale || g.choamMarket || g.pendingChoamMarketGhola || g.pendingIxAlly ||
|
||||||
response.advisorGatherMatchingSource || response.advisorTransportMatchingSource ||
|
(response.spiritualAdvisorSource && !spiritual) ||
|
||||||
response.advisorAmbassadorMatchingSource || (response.spiritualAdvisorSource && !spiritual) ||
|
|
||||||
(response.advisorResume !== undefined && !(response.advisorResume === 'preparation' && response.advisorPreparationSource)) ||
|
(response.advisorResume !== undefined && !(response.advisorResume === 'preparation' && response.advisorPreparationSource)) ||
|
||||||
(!!g.pendingAdvisorMatching !== !!response.advisorMatchingSource) ||
|
(!!g.pendingAdvisorMatching !== !!response.advisorMatchingSource) ||
|
||||||
(!!g.pendingAutomaticAdvisorFlips?.length !== !!response.automaticAdvisorFlip)) return false;
|
(!!g.pendingAutomaticAdvisorFlips?.length !== !!response.automaticAdvisorFlip)) return false;
|
||||||
@@ -6359,6 +6370,16 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
|
|||||||
noField:p.noField, noFieldEvent:p.noFieldEvent, flipBlocked:p.advisorFlipMovementBlocked,
|
noField:p.noField, noFieldEvent:p.noFieldEvent, flipBlocked:p.advisorFlipMovementBlocked,
|
||||||
hajr:p.hajrPlays, window:p.hajrWindow, leaders:p.leaders, kwisatz:p.kwisatz})),
|
hajr:p.hajrPlays, window:p.hajrWindow, leaders:p.leaders, kwisatz:p.kwisatz})),
|
||||||
} : undefined,
|
} : undefined,
|
||||||
|
advisorMatchingParent: advisor && c.use.response.advisorGatherMatchingSource ? {
|
||||||
|
gather:g.pendingAdvisorGatherMatching,
|
||||||
|
} : advisor && c.use.response.advisorTransportMatchingSource ? {
|
||||||
|
transport:g.pendingAdvisorTransportMatching, denied:g.advisorTransportMatchingDenied,
|
||||||
|
pending:g.pendingGuildTransport, rate:g.guildRateBlocked,
|
||||||
|
permission:g.guildTransportBlocked, price:g.karamaShipping, nexus:g.nexusCards,
|
||||||
|
} : advisor && c.use.response.advisorAmbassadorMatchingSource ? {
|
||||||
|
matching:g.pendingAdvisorAmbassadorMatching, ambassador:g.pendingAmbassador,
|
||||||
|
tokens:g.ecazAmbassadors,
|
||||||
|
} : undefined,
|
||||||
advisorParent: advisorProducer === 'guildTransport' ? {
|
advisorParent: advisorProducer === 'guildTransport' ? {
|
||||||
transport:g.pendingAutomaticAdvisorTransport, pending:g.pendingGuildTransport,
|
transport:g.pendingAutomaticAdvisorTransport, pending:g.pendingGuildTransport,
|
||||||
rate:g.guildRateBlocked, permission:g.guildTransportBlocked,
|
rate:g.guildRateBlocked, permission:g.guildTransportBlocked,
|
||||||
@@ -6965,8 +6986,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
|||||||
(continuation?.kind === 'karamaBonusDiscard' && continuation.use.response.source === 'ambassador') ||
|
(continuation?.kind === 'karamaBonusDiscard' && continuation.use.response.source === 'ambassador') ||
|
||||||
(continuation?.kind === 'karamaNoFieldDiscard' && continuation.use.response.source === 'ambassadorNoField') ||
|
(continuation?.kind === 'karamaNoFieldDiscard' && continuation.use.response.source === 'ambassadorNoField') ||
|
||||||
(continuation?.kind === 'karamaAdvisorFlipDiscard' &&
|
(continuation?.kind === 'karamaAdvisorFlipDiscard' &&
|
||||||
continuation.use.response.automaticAdvisorFlip?.producer === 'specialRelocation' &&
|
semutaAdvisorAmbassadorParent(g,continuation.use.response));
|
||||||
g.pendingAutomaticAdvisorRelocation?.resume.kind === 'ambassador');
|
|
||||||
requireRule(
|
requireRule(
|
||||||
(g.status === 'playing' || (truth && g.status === 'setup')) &&
|
(g.status === 'playing' || (truth && g.status === 'setup')) &&
|
||||||
pending.sequence === sequence &&
|
pending.sequence === sequence &&
|
||||||
@@ -8624,8 +8644,7 @@ function semutaOfferSupported(
|
|||||||
!g.ornithopter && !g.ecologicalStorm)) &&
|
!g.ornithopter && !g.ecologicalStorm)) &&
|
||||||
(richeseGiftDiscard || ambassadorBenefitDiscard || (!g.pendingExchange && (replacementCostDiscard || !g.pendingIxAlly) && !g.pendingNullentropy &&
|
(richeseGiftDiscard || ambassadorBenefitDiscard || (!g.pendingExchange && (replacementCostDiscard || !g.pendingIxAlly) && !g.pendingNullentropy &&
|
||||||
(!g.pendingAmbassador || advisorDiscard &&
|
(!g.pendingAmbassador || advisorDiscard &&
|
||||||
continuation.use.response.automaticAdvisorFlip?.producer === 'specialRelocation' &&
|
semutaAdvisorAmbassadorParent(g,continuation.use.response)) &&
|
||||||
g.pendingAutomaticAdvisorRelocation?.resume.kind === 'ambassador') &&
|
|
||||||
(terrorAllianceDiscard || !g.pendingTerrorEntry))) &&
|
(terrorAllianceDiscard || !g.pendingTerrorEntry))) &&
|
||||||
(richeseGiftDiscard || noFieldDiscard || continuation.kind === 'karamaShipmentDiscard' ||
|
(richeseGiftDiscard || noFieldDiscard || continuation.kind === 'karamaShipmentDiscard' ||
|
||||||
continuation.kind === 'specialKaramaDiscard' ||
|
continuation.kind === 'specialKaramaDiscard' ||
|
||||||
@@ -11401,7 +11420,7 @@ function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation
|
|||||||
case 'guildTransport': {
|
case 'guildTransport': {
|
||||||
requireRule(typeof continuation.fromReserves === 'boolean', 'The Guild transport source flag is malformed.');
|
requireRule(typeof continuation.fromReserves === 'boolean', 'The Guild transport source flag is malformed.');
|
||||||
const frame = continuation.frame;
|
const frame = continuation.frame;
|
||||||
requireRule(frame && Object.keys(frame).sort().join(',') ===
|
requireRule(frame && Object.keys(frame).filter(key => key !== 'advisorArrivalSource').sort().join(',') ===
|
||||||
'advisors,allyPayment,amount,cost,elite,eliteGroup,group,origin,player,sector,to,turn',
|
'advisors,allyPayment,amount,cost,elite,eliteGroup,group,origin,player,sector,to,turn',
|
||||||
'The saved Guild transport frame is malformed.');
|
'The saved Guild transport frame is malformed.');
|
||||||
const p = getPlayer(g, frame.player);
|
const p = getPlayer(g, frame.player);
|
||||||
@@ -31622,6 +31641,7 @@ function guildTransportSignature(intent: PendingGuildTransport): string {
|
|||||||
intent.group, intent.eliteGroup, intent.origin, intent.to, intent.sector,
|
intent.group, intent.eliteGroup, intent.origin, intent.to, intent.sector,
|
||||||
intent.amount, intent.elite, intent.cost, intent.allyPayment, intent.advisors];
|
intent.amount, intent.elite, intent.cost, intent.allyPayment, intent.advisors];
|
||||||
if (intent.permission !== undefined) fields.push(intent.permission);
|
if (intent.permission !== undefined) fields.push(intent.permission);
|
||||||
|
if (intent.advisorArrivalSource !== undefined) fields.push(intent.advisorArrivalSource);
|
||||||
return JSON.stringify(fields);
|
return JSON.stringify(fields);
|
||||||
}
|
}
|
||||||
function ordinaryGuildTransportFullCost(g: Game, p: Player, frame: GuildTransportOrder): number | null {
|
function ordinaryGuildTransportFullCost(g: Game, p: Player, frame: GuildTransportOrder): number | null {
|
||||||
@@ -31678,6 +31698,8 @@ function validateGuildTransportWindow(g: Game, response: ResponseWindow) {
|
|||||||
Number.isSafeInteger(intent.elite) && intent.elite >= 0 && intent.elite <= intent.amount &&
|
Number.isSafeInteger(intent.elite) && intent.elite >= 0 && intent.elite <= intent.amount &&
|
||||||
Number.isSafeInteger(intent.allyPayment) && intent.allyPayment >= 0 &&
|
Number.isSafeInteger(intent.allyPayment) && intent.allyPayment >= 0 &&
|
||||||
intent.allyPayment <= intent.cost && typeof intent.advisors === 'boolean' &&
|
intent.allyPayment <= intent.cost && typeof intent.advisors === 'boolean' &&
|
||||||
|
(intent.advisorArrivalSource === undefined || g.advanced && shipper.faction === 'beneGesserit' &&
|
||||||
|
!intent.fromReserves && intent.to !== 'reserves' && intent.advisorArrivalSource === intent.origin) &&
|
||||||
(intent.fromReserves
|
(intent.fromReserves
|
||||||
? intent.origin === 'reserves' && shipper.faction === 'fremen' &&
|
? intent.origin === 'reserves' && shipper.faction === 'fremen' &&
|
||||||
intent.group.length === 0 && Object.keys(intent.eliteGroup).length === 0 &&
|
intent.group.length === 0 && Object.keys(intent.eliteGroup).length === 0 &&
|
||||||
@@ -31711,6 +31733,7 @@ function guildTransportOrder(intent: PendingGuildTransport, cost = intent.cost):
|
|||||||
group: intent.group, eliteGroup: intent.eliteGroup, to: intent.to,
|
group: intent.group, eliteGroup: intent.eliteGroup, to: intent.to,
|
||||||
sector: intent.sector, amount: intent.amount, elite: intent.elite,
|
sector: intent.sector, amount: intent.amount, elite: intent.elite,
|
||||||
cost, allyPayment: intent.allyPayment, advisors: intent.advisors,
|
cost, allyPayment: intent.allyPayment, advisors: intent.advisors,
|
||||||
|
...(intent.advisorArrivalSource !== undefined ? {advisorArrivalSource:intent.advisorArrivalSource} : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function guildTransportOpportunity(g: Game, player: string): string {
|
function guildTransportOpportunity(g: Game, player: string): string {
|
||||||
@@ -31787,7 +31810,9 @@ function validateGuildTransportForCommit(
|
|||||||
requireRule((fromReserves || to === 'reserves' || to !== origin) &&
|
requireRule((fromReserves || to === 'reserves' || to !== origin) &&
|
||||||
(to !== 'reserves' || p.faction === 'guild' || !!guildSecretEvent) &&
|
(to !== 'reserves' || p.faction === 'guild' || !!guildSecretEvent) &&
|
||||||
(to !== MOBILE_STRONGHOLD || p.faction === 'ixians') &&
|
(to !== MOBILE_STRONGHOLD || p.faction === 'ixians') &&
|
||||||
advisors === (to !== 'reserves' && arrivalAsAdvisor(g, p, to)),
|
(frame.advisorArrivalSource === undefined || g.advanced && p.faction === 'beneGesserit' &&
|
||||||
|
!fromReserves && to !== 'reserves' && frame.advisorArrivalSource === origin) &&
|
||||||
|
advisors === (to !== 'reserves' && arrivalAsAdvisor(g, p, to, frame.advisorArrivalSource)),
|
||||||
'The original Guild transport destination or advisor stance is unavailable.');
|
'The original Guild transport destination or advisor stance is unavailable.');
|
||||||
const sourceLock = p.advisors?.[origin]?.lockedTurn;
|
const sourceLock = p.advisors?.[origin]?.lockedTurn;
|
||||||
requireRule(to === 'reserves' || advisors || sourceLock !== g.turn ||
|
requireRule(to === 'reserves' || advisors || sourceLock !== g.turn ||
|
||||||
@@ -38039,7 +38064,9 @@ function applyActionInner(
|
|||||||
to !== 'reserves' || p.faction === 'guild' || !!guildSecretEvent,
|
to !== 'reserves' || p.faction === 'guild' || !!guildSecretEvent,
|
||||||
'Only the Guild may ship forces back to reserves.',
|
'Only the Guild may ship forces back to reserves.',
|
||||||
);
|
);
|
||||||
const advisors = to !== 'reserves' && arrivalAsAdvisor(g, p, to);
|
const advisorArrivalSource = g.advanced && p.faction === 'beneGesserit' &&
|
||||||
|
!fromReserves && to !== 'reserves' ? origin : undefined;
|
||||||
|
const advisors = to !== 'reserves' && arrivalAsAdvisor(g, p, to, advisorArrivalSource);
|
||||||
const sourceLock = p.advisors?.[origin]?.lockedTurn;
|
const sourceLock = p.advisors?.[origin]?.lockedTurn;
|
||||||
requireRule(
|
requireRule(
|
||||||
to === 'reserves' ||
|
to === 'reserves' ||
|
||||||
@@ -38066,6 +38093,7 @@ function applyActionInner(
|
|||||||
const frame: GuildTransportOrder = {
|
const frame: GuildTransportOrder = {
|
||||||
turn: g.turn, player: id, origin, group, eliteGroup, to, sector,
|
turn: g.turn, player: id, origin, group, eliteGroup, to, sector,
|
||||||
amount: n, elite, cost, allyPayment, advisors,
|
amount: n, elite, cost, allyPayment, advisors,
|
||||||
|
...(advisorArrivalSource !== undefined ? {advisorArrivalSource} : {}),
|
||||||
};
|
};
|
||||||
if(execution==='live'&&openAdvisorTransportMatching(g,{kind:'guild',frame,fromReserves,...(guildSecretEvent?{secret:guildSecretEvent}:{})}))return g;
|
if(execution==='live'&&openAdvisorTransportMatching(g,{kind:'guild',frame,fromReserves,...(guildSecretEvent?{secret:guildSecretEvent}:{})}))return g;
|
||||||
if (!guildSecretEvent) {
|
if (!guildSecretEvent) {
|
||||||
|
|||||||
@@ -1497,6 +1497,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'tests/semuta-spiritual-arrival-engine.test.ts',
|
'tests/semuta-spiritual-arrival-engine.test.ts',
|
||||||
'tests/fixture-semuta-advisor-relocation.ts',
|
'tests/fixture-semuta-advisor-relocation.ts',
|
||||||
'tests/semuta-advisor-relocation-engine.test.ts',
|
'tests/semuta-advisor-relocation-engine.test.ts',
|
||||||
|
'tests/fixture-semuta-advisor-gather.ts',
|
||||||
|
'tests/semuta-advisor-gather-engine.test.ts',
|
||||||
|
'tests/fixture-semuta-advisor-transport-matching.ts',
|
||||||
|
'tests/semuta-advisor-transport-matching-engine.test.ts',
|
||||||
|
'tests/guild-advisor-source-arrival-engine.test.ts',
|
||||||
|
'tests/fixture-semuta-advisor-ambassador-matching.ts',
|
||||||
|
'tests/semuta-advisor-ambassador-matching-engine.test.ts',
|
||||||
'tests/fixture-semuta-spiritual-foresight.ts',
|
'tests/fixture-semuta-spiritual-foresight.ts',
|
||||||
'tests/semuta-spiritual-foresight-engine.test.ts',
|
'tests/semuta-spiritual-foresight-engine.test.ts',
|
||||||
'tests/fixture-semuta-storm-dancer.ts',
|
'tests/fixture-semuta-storm-dancer.ts',
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||||
|
import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
|
||||||
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||||
|
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||||
|
import {
|
||||||
|
actSemutaCombatCapabilities, nextSemutaCombatCapabilities,
|
||||||
|
semutaCombatCapabilitiesPlayer, semutaCombatCapabilitiesInventory, semutaCombatCapabilitiesClean,
|
||||||
|
passSemutaCombatCapabilities, claimSemutaCombatCapabilities,
|
||||||
|
} from './fixture-semuta-combat-capabilities';
|
||||||
|
|
||||||
|
export type SemutaAdvisorAmbassadorMatchingDirection = 'fightersToAdvisors' | 'advisorsToFighters';
|
||||||
|
export type SemutaAdvisorAmbassadorMatchingStep = { actor: string; action: Action };
|
||||||
|
export const actSemutaAdvisorAmbassadorMatching = actSemutaCombatCapabilities;
|
||||||
|
export const semutaAdvisorAmbassadorMatchingPlayer = semutaCombatCapabilitiesPlayer;
|
||||||
|
export const semutaAdvisorAmbassadorMatchingInventory = semutaCombatCapabilitiesInventory;
|
||||||
|
export const semutaAdvisorAmbassadorMatchingClean = semutaCombatCapabilitiesClean;
|
||||||
|
export const passSemutaAdvisorAmbassadorMatching = passSemutaCombatCapabilities;
|
||||||
|
export const claimSemutaAdvisorAmbassadorMatching = claimSemutaCombatCapabilities;
|
||||||
|
export const semutaAdvisorAmbassadorMatchingPhysical = (game: Game) => game.players.map(p => ({
|
||||||
|
id: p.id, spice: p.spice, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites,
|
||||||
|
moved: p.moved, shipped: p.shipped, ally: p.ally, allySinceTurn: p.allySinceTurn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Native setup/quiet controls only; placements, Ambassador selections and
|
||||||
|
* matching remain source/caller owned. Spiritual accompaniment is declined. */
|
||||||
|
export function nextSemutaAdvisorAmbassadorMatching(game: Game): SemutaAdvisorAmbassadorMatchingStep | null {
|
||||||
|
if (game.response?.kind === 'advisorFlip') return null;
|
||||||
|
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
|
||||||
|
if (game.status === 'setup' && game.setupStage === 'prediction')
|
||||||
|
return { actor: 'beneGesserit', action: { type: 'predict', faction: 'atreides', turn: 10 } };
|
||||||
|
const ecaz = game.players.find(p => p.faction === 'ecaz');
|
||||||
|
if (game.status === 'setup' && game.setupStage === 'forces' && ecaz?.reserves === 20)
|
||||||
|
return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } };
|
||||||
|
const bg = game.players.find(p => p.faction === 'beneGesserit');
|
||||||
|
if (game.status === 'setup' && game.setupStage === 'forces' && bg && !bg.advisorSetup)
|
||||||
|
return { actor: bg.id, action: { type: 'advisorSetup', territory: 'arrakeen', sector: 10 } };
|
||||||
|
const d = game.decision;
|
||||||
|
if (d?.kind === 'ecazPlacement' || d?.kind === 'ecazAmbassador') return null;
|
||||||
|
if (d?.kind === 'advisorBattle' || d?.kind === 'advisor' || d?.kind === 'intrusion')
|
||||||
|
return { actor: d.player, action: { type: 'decision', accept: false } };
|
||||||
|
}
|
||||||
|
return nextSemutaCombatCapabilities(game);
|
||||||
|
}
|
||||||
|
export const nextSemutaAdvisorAmbassadorMatchingStep = nextSemutaAdvisorAmbassadorMatching;
|
||||||
|
export function advanceSemutaAdvisorAmbassadorMatching(game: Game, until: (state: Game) => boolean,
|
||||||
|
actions: SemutaAdvisorAmbassadorMatchingStep[] = []): Game {
|
||||||
|
for (let count = 0; count < 3200; count++) {
|
||||||
|
if (until(game)) return game;
|
||||||
|
assert.notEqual(game.status, 'finished');
|
||||||
|
const next = nextSemutaAdvisorAmbassadorMatching(game);
|
||||||
|
assert.ok(next, `Supply the actual Fremen Ambassador choice at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||||
|
game = actSemutaAdvisorAmbassadorMatching(game, next.actor, next.action, actions);
|
||||||
|
}
|
||||||
|
throw Error('The native Semuta/Ambassador matching source did not reach its finite boundary');
|
||||||
|
}
|
||||||
|
export function allowSemutaAdvisorAmbassadorMatching(game: Game, actions: SemutaAdvisorAmbassadorMatchingStep[] = []): Game {
|
||||||
|
const event = game.response?.advisorAmbassadorMatchingSource?.event; assert.ok(event);
|
||||||
|
for (let count = 0; game.response?.advisorAmbassadorMatchingSource?.event === event && count < game.players.length; count++) {
|
||||||
|
const p = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(p);
|
||||||
|
game = actSemutaAdvisorAmbassadorMatching(game, p.id, { type: 'passResponse' }, actions);
|
||||||
|
}
|
||||||
|
assert.notEqual(game.response?.advisorAmbassadorMatchingSource?.event, event); return game;
|
||||||
|
}
|
||||||
|
/** Finish only this consumed Ambassador grant. A prevented caller must first
|
||||||
|
* choose a real legal alternate; no new decline payload is introduced here. */
|
||||||
|
export function finishSemutaAdvisorAmbassadorMatching(game: Game, actions: SemutaAdvisorAmbassadorMatchingStep[] = []): Game {
|
||||||
|
assert.ok(!game.pendingTreacheryDiscard);
|
||||||
|
return advanceSemutaAdvisorAmbassadorMatching(game, state => !state.pendingAmbassador &&
|
||||||
|
!state.pendingAdvisorAmbassadorMatching && semutaAdvisorAmbassadorMatchingClean(state), actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function originalFremenPacket() {
|
||||||
|
const original = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
|
||||||
|
const supply: Exclude<AmbassadorEffect, 'ecaz'>[] = ['fremen', ...original.filter(effect => effect !== 'fremen').slice(0, 4)];
|
||||||
|
const order = [...supply, ...original.filter(effect => !supply.includes(effect))], current = [...original], scalars: number[] = [];
|
||||||
|
for (let index = current.length - 1; index > 0; index--) {
|
||||||
|
const from = current.indexOf(order[index]); assert.ok(from >= 0 && from <= index);
|
||||||
|
scalars.push(Math.floor((from + 0.5) * 4294967296 / (index + 1)));
|
||||||
|
[current[index], current[from]] = [current[from], current[index]];
|
||||||
|
}
|
||||||
|
assert.deepEqual(current, order); return { supply, order, scalars };
|
||||||
|
}
|
||||||
|
function createOriginalPacket(game: Game, step: SemutaAdvisorAmbassadorMatchingStep,
|
||||||
|
actions: SemutaAdvisorAmbassadorMatchingStep[], scalars: number[]) {
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||||
|
const native = 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 < scalars.length ? scalars[cursor++] : 0xffffffff;
|
||||||
|
else Reflect.apply(native, crypto, [array]);
|
||||||
|
return array;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const next = applyAction(game, step.actor, step.action); assert.equal(cursor, scalars.length);
|
||||||
|
actions.push(structuredClone(step)); return next;
|
||||||
|
} finally {
|
||||||
|
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||||
|
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Real native Fremen packet, paid token/fighter group and Nexus alliance.
|
||||||
|
* The matching response precedes any independent relocation. */
|
||||||
|
export function createSemutaAdvisorAmbassadorMatchingFixture(options: { direction?: SemutaAdvisorAmbassadorMatchingDirection } = {}) {
|
||||||
|
const direction = options.direction ?? 'fightersToAdvisors';
|
||||||
|
const richese = 'richese', holder = 'choam', ecaz = 'ecaz', owner = 'beneGesserit', entrant = 'atreides', spender = 'emperor';
|
||||||
|
let game = createGame('SEMUTAADVISORAMBASSADORMATCHING', newPlayer(richese, 'Richese', 'richese'), true, ['choam', 'ecaz']);
|
||||||
|
for (const faction of ['choam', 'ecaz', 'beneGesserit', 'atreides', 'emperor'] as const)
|
||||||
|
joinGame(game, newPlayer(faction, faction, faction));
|
||||||
|
const initial = structuredClone(game), actions: SemutaAdvisorAmbassadorMatchingStep[] = [];
|
||||||
|
const act = (actor: string, action: Action) => { game = actSemutaAdvisorAmbassadorMatching(game, actor, action, actions); };
|
||||||
|
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorAmbassadorMatching(game, until, actions); };
|
||||||
|
const transition = (run: () => void) => {
|
||||||
|
const before = structuredClone(game), at = actions.length; run();
|
||||||
|
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||||
|
};
|
||||||
|
for (const p of game.players) act(p.id, { type: 'ready' });
|
||||||
|
const lobby = structuredClone(game);
|
||||||
|
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||||
|
const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.equal(game.semutaPreview, true);
|
||||||
|
assert.ok(game.players.every(p => p.hand.length === 0));
|
||||||
|
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||||
|
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||||
|
const originalInventory = semutaAdvisorAmbassadorMatchingInventory(game); assert.equal(originalInventory.length, 45);
|
||||||
|
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
|
||||||
|
const ordinary = game.deck.filter(card => card.kind === 'projectile' || card.kind === 'poison').slice(0, 5);
|
||||||
|
assert.equal(ordinary.length, 5); const selected = [...ordinary, printed];
|
||||||
|
const dealOrder = selected.map((card, position) => {
|
||||||
|
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||||
|
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||||
|
return { card: card.id, position, from };
|
||||||
|
});
|
||||||
|
const beforeStartingDeal = structuredClone(game), cost = printed.id, packet = originalFremenPacket();
|
||||||
|
let packetCreation: { before: Game; steps: SemutaAdvisorAmbassadorMatchingStep[]; after: Game } | null = null;
|
||||||
|
let advisorSetup: { before: Game; steps: SemutaAdvisorAmbassadorMatchingStep[]; after: Game } | null = null;
|
||||||
|
for (let count = 0; game.status === 'setup' && count < 180; count++) {
|
||||||
|
const next = nextSemutaAdvisorAmbassadorMatching(game); assert.ok(next);
|
||||||
|
const before = structuredClone(game), at = actions.length;
|
||||||
|
const creates = game.setupStage === 'traitors' && next.action.type === 'traitor' &&
|
||||||
|
game.players.filter(p => p.traitorChoices.length).length === 1;
|
||||||
|
if (creates) game = createOriginalPacket(game, next, actions, packet.scalars);
|
||||||
|
else act(next.actor, next.action);
|
||||||
|
const record = { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||||
|
if (creates) packetCreation = record;
|
||||||
|
if (next.action.type === 'advisorSetup') advisorSetup = record;
|
||||||
|
}
|
||||||
|
assert.equal(game.status, 'playing'); assert.ok(packetCreation && advisorSetup);
|
||||||
|
const afterSetup = structuredClone(game);
|
||||||
|
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||||
|
assert.deepEqual(game.ecazAmbassadors!.cohort.map(id => game.ecazAmbassadors!.tokens.find(t => t.id === id)!.effect), packet.supply);
|
||||||
|
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||||
|
const orderLand = (positions: number[]) => positions.map(position => {
|
||||||
|
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
|
||||||
|
!['imperial_basin', 'arrakeen', 'tueks_sietch', 'carthag'].includes(card.territory));
|
||||||
|
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||||
|
return { position, from, card: structuredClone(card) };
|
||||||
|
});
|
||||||
|
const spiceOrder = orderLand([0, 1]);
|
||||||
|
let beforeBid: Game | undefined, bid: SemutaAdvisorAmbassadorMatchingStep | undefined, bidAt = -1;
|
||||||
|
for (let count = 0; count < 1000 && !semutaAdvisorAmbassadorMatchingPlayer(game, holder).hand.some(c => c.id === SEMUTA_DRUG_ID); count++) {
|
||||||
|
const next = nextSemutaAdvisorAmbassadorMatching(game); assert.ok(next);
|
||||||
|
if (next.actor === holder && next.action.type === 'richeseBid' && next.action.amount === 1) {
|
||||||
|
beforeBid = structuredClone(game); bid = structuredClone(next); bidAt = actions.length;
|
||||||
|
}
|
||||||
|
act(next.actor, next.action);
|
||||||
|
}
|
||||||
|
assert.ok(beforeBid && bid && bidAt >= 0);
|
||||||
|
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||||
|
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||||
|
reach(state => state.turn === 1 && state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||||
|
const token = game.ecazAmbassadors!.tokens.find(t => t.effect === 'fremen' && t.zone === 'supply'); assert.ok(token);
|
||||||
|
const placement = transition(() => {
|
||||||
|
act(ecaz, { type: 'decision', token: token.id, territory: 'carthag' });
|
||||||
|
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||||
|
act(ecaz, { type: 'decision', decline: true });
|
||||||
|
});
|
||||||
|
assert.equal(semutaAdvisorAmbassadorMatchingPlayer(placement.before, ecaz).spice - semutaAdvisorAmbassadorMatchingPlayer(game, ecaz).spice, 1);
|
||||||
|
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && semutaAdvisorAmbassadorMatchingClean(state));
|
||||||
|
const fighterShipment = transition(() => {
|
||||||
|
act(owner, { type: 'ship', territory: 'tueks_sietch', sector: 5, amount: 2, elite: 0, allyPayment: 0 });
|
||||||
|
reach(semutaAdvisorAmbassadorMatchingClean);
|
||||||
|
});
|
||||||
|
assert.equal(semutaAdvisorAmbassadorMatchingPlayer(fighterShipment.before, owner).spice - semutaAdvisorAmbassadorMatchingPlayer(game, owner).spice, 2);
|
||||||
|
reach(state => state.turn === 2 && state.phase === 1 && semutaAdvisorAmbassadorMatchingClean(state) &&
|
||||||
|
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0);
|
||||||
|
const beforeWorm = structuredClone(game), beforeWormInventory = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||||
|
const wormIndex = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.thumper && !card.suppressed);
|
||||||
|
assert.ok(wormIndex >= 0); const worm = game.spiceDeck.splice(wormIndex, 1)[0]; game.spiceDeck.unshift(worm);
|
||||||
|
const wormOrder = { position: 0, from: wormIndex, card: structuredClone(worm) }, wormFollowupOrder = orderLand([1, 2]);
|
||||||
|
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), beforeWormInventory);
|
||||||
|
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && semutaAdvisorAmbassadorMatchingClean(state));
|
||||||
|
const alliance = transition(() => {
|
||||||
|
act(owner, { type: 'alliance', target: ecaz }); act(ecaz, { type: 'alliance', target: owner });
|
||||||
|
});
|
||||||
|
reach(state => state.turn === 2 && state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||||
|
act(ecaz, { type: 'decision', decline: true });
|
||||||
|
reach(state => state.turn === 2 && state.phase === 5 && state.active === entrant && semutaAdvisorAmbassadorMatchingClean(state));
|
||||||
|
const entrantShipment = transition(() => {
|
||||||
|
act(entrant, { type: 'ship', territory: 'carthag', sector: 11, amount: 1, elite: 0, allyPayment: 0 });
|
||||||
|
reach(state => !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'offer');
|
||||||
|
});
|
||||||
|
const event = game.pendingAmbassador!.event;
|
||||||
|
const trigger = transition(() => {
|
||||||
|
act(ecaz, { type: 'decision', event, trigger: true, beneficiary: owner });
|
||||||
|
reach(state => !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'move');
|
||||||
|
});
|
||||||
|
const fightersToAdvisors: Action = { type: 'decision', event, forces: { 'tueks_sietch:5': 1 }, territory: 'arrakeen', sector: 10 };
|
||||||
|
const advisorsToFighters: Action = { type: 'decision', event, forces: { 'arrakeen:10': 1 }, territory: 'tueks_sietch', sector: 5 };
|
||||||
|
const alternate: Action = { type: 'decision', event, forces: { 'tueks_sietch:5': 1 }, territory: 'polar_sink', sector: 0 };
|
||||||
|
const relocationAction = direction === 'fightersToAdvisors' ? fightersToAdvisors : advisorsToFighters;
|
||||||
|
const from = direction === 'fightersToAdvisors' ? 'tueks_sietch:5' : 'arrakeen:10';
|
||||||
|
const to = direction === 'fightersToAdvisors' ? 'arrakeen:10' : 'tueks_sietch:5';
|
||||||
|
const declaration = transition(() => { act(owner, relocationAction); });
|
||||||
|
assert.ok(game.response?.kind === 'advisorFlip' && game.response.advisorAmbassadorMatchingSource);
|
||||||
|
assert.ok(game.pendingAdvisorAmbassadorMatching && game.pendingAmbassador);
|
||||||
|
assert.deepEqual(semutaAdvisorAmbassadorMatchingInventory(game), originalInventory);
|
||||||
|
return { game, kind: 'advisorFlip' as const, advanced: true as const, direction, richese, holder, owner, ecaz, entrant, spender,
|
||||||
|
beneficiary: owner, cost, karama: cost, semuta: SEMUTA_DRUG_ID,
|
||||||
|
action: { type: 'card', card: cost, mode: 'cancel' } as Action,
|
||||||
|
originalResponse: structuredClone(game.response), originalSource: structuredClone(game.response.advisorAmbassadorMatchingSource),
|
||||||
|
originalParent: structuredClone(game.pendingAdvisorAmbassadorMatching), originalAmbassador: structuredClone(game.pendingAmbassador),
|
||||||
|
originalPhysical: structuredClone(semutaAdvisorAmbassadorMatchingPhysical(game)),
|
||||||
|
originalStance: structuredClone(semutaAdvisorAmbassadorMatchingPlayer(game, owner).advisors!),
|
||||||
|
originalTokens: structuredClone(game.ecazAmbassadors!), token: token.id, event, from, to, relocationAction, alternate,
|
||||||
|
fightersToAdvisors, advisorsToFighters, initial, lobby, setup, beforeStartingDeal, afterSetup, advisorSetup,
|
||||||
|
packet, packetCreation, dealOrder, deal: selected.map(c => c.id), originalInventory, originalSpice, spiceOrder,
|
||||||
|
acquisition, acquired, cache: acquisition, placement, fighterShipment, beforeWorm, wormOrder, wormFollowupOrder,
|
||||||
|
alliance, entrantShipment, trigger, declaration, actions, steps: actions, sourceActionCount: actions.length,
|
||||||
|
staging: [
|
||||||
|
'Original narrow Advanced Ecaz Semuta initializer on ready R/C/Ecaz/BG/A/E, exact CHOAM+Ecaz and no modules. Native Ecaz Basin6/BG Arrakeen1 beside A10 setup execute. Only unopened main/Spice positions, scalar lotteries and original nine-draw Fremen packet are selected; C buys cache1 and E receives printed Karama.',
|
||||||
|
'Ecaz really pays1 to place its original Fremen token in Carthag at first Revival. BG actually pays2 to ship two reserves into empty Tuek’s5 as fighters, retaining its original peaceful Arrakeen advisor. Actual next Storm, original worm/Nexus and reciprocal BG/Ecaz proposals earn the alliance.',
|
||||||
|
'Atreides actually pays1 into the placed Carthag token; its earlier Spiritual opportunity is declined through native controls. Ecaz actually triggers Fremen for its BG ally and consumes the original token. BG selects a real one-counter opposite-type relocation, which stops at precommit matching without transferring the packet.',
|
||||||
|
'The source exposes both actual stance directions and a native legal alternate relocating one Tuek’s fighter to empty Polar Sink. No new decline is invented. No played hand, wallet, token, ally, army, stance, clock, parent or Semuta window is installed.',
|
||||||
|
] };
|
||||||
|
}
|
||||||
|
export type SemutaAdvisorAmbassadorMatchingFixture = ReturnType<typeof createSemutaAdvisorAmbassadorMatchingFixture>;
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game,
|
||||||
|
} from '../game/engine';
|
||||||
|
import { distance } from '../game/board';
|
||||||
|
import { isAdvisor } from '../game/advisors';
|
||||||
|
import { advisorMatchingKey } from '../game/advisor-matching';
|
||||||
|
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||||
|
import { planetologistLeader } from '../game/planetologist-movement';
|
||||||
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||||
|
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||||
|
import {
|
||||||
|
actSemutaRicheseCacheKarama, nextSemutaRicheseCacheKarama,
|
||||||
|
semutaRicheseCacheKaramaClean, semutaRicheseCacheKaramaInventory, semutaRicheseCacheKaramaPlayer,
|
||||||
|
} from './fixture-semuta-richese-cache-karama';
|
||||||
|
import {
|
||||||
|
passSemutaFremenGuildMovement, claimSemutaFremenGuildMovement,
|
||||||
|
} from './fixture-semuta-fremen-guild-movement';
|
||||||
|
|
||||||
|
export type SemutaAdvisorGatherPosition = 'first' | 'second';
|
||||||
|
export type SemutaAdvisorGatherStep = { actor: string; action: Action };
|
||||||
|
export const actSemutaAdvisorGather = actSemutaRicheseCacheKarama;
|
||||||
|
export const passSemutaAdvisorGather = passSemutaFremenGuildMovement;
|
||||||
|
export const claimSemutaAdvisorGather = claimSemutaFremenGuildMovement;
|
||||||
|
export const semutaAdvisorGatherPlayer = semutaRicheseCacheKaramaPlayer;
|
||||||
|
export const semutaAdvisorGatherInventory = semutaRicheseCacheKaramaInventory;
|
||||||
|
export const semutaAdvisorGatherClean = semutaRicheseCacheKaramaClean;
|
||||||
|
export const semutaAdvisorGatherPhysical = (game: Game) => ({
|
||||||
|
turn: game.turn, phase: game.phase, storm: game.storm, order: game.order, active: game.active,
|
||||||
|
ready: game.ready, movementRemaining: game.movementRemaining,
|
||||||
|
spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard, skills: game.leaderSkills,
|
||||||
|
players: game.players.map(player => ({ id: player.id, spice: player.spice, forces: player.forces,
|
||||||
|
advisors: player.advisors, reserves: player.reserves, tanks: player.tanks, elites: player.elites,
|
||||||
|
leaders: player.leaders, shipped: player.shipped, moved: player.moved, ally: player.ally })),
|
||||||
|
});
|
||||||
|
export function semutaAdvisorGatherSkillInventory(game: Game) {
|
||||||
|
const state = game.leaderSkills!;
|
||||||
|
const ids = [...state.deck, ...Object.values(state.offers).flatMap(offer => offer.cards),
|
||||||
|
...state.assignments.map(assignment => assignment.skill)].sort();
|
||||||
|
assert.equal(new Set(ids).size, ids.length, 'Every original all14 Skill retains one physical slot'); return ids;
|
||||||
|
}
|
||||||
|
const training: Record<string, LeaderSkillId> = {
|
||||||
|
richese: 'killer-medic', choam: 'swordmaster-of-ginaz', beneGesserit: 'planetologist',
|
||||||
|
atreides: 'prana-bindu-adept', emperor: 'master-of-assassins',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Original two-card offers are really selected and assigned. Fresh matching
|
||||||
|
* declarations and their costs remain caller-owned, while optional preparation
|
||||||
|
* and accompaniment are declined through their actual original controls. */
|
||||||
|
export function nextSemutaAdvisorGather(game: Game): SemutaAdvisorGatherStep | null {
|
||||||
|
if (game.pendingTreacheryDiscard || game.truthtrance || game.response?.kind === 'advisorFlip') return null;
|
||||||
|
if (!game.phaseOpening && !game.response) {
|
||||||
|
if (game.status === 'setup' && game.setupStage === 'prediction')
|
||||||
|
return { actor: 'beneGesserit', action: { type: 'predict', faction: 'atreides', turn: 10 } };
|
||||||
|
if (game.status === 'setup' && game.setupStage === 'leaderSkills') {
|
||||||
|
const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!;
|
||||||
|
const offer = own.offer, skill = training[actor];
|
||||||
|
assert.ok(offer && offer.cards.includes(skill) && !own.unavailableSkills?.[skill]);
|
||||||
|
const player = semutaAdvisorGatherPlayer(game, actor);
|
||||||
|
const leader = own.eligibleLeaders.sort((a, b) =>
|
||||||
|
player.leaders.find(disc => disc.id === b.id)!.strength - player.leaders.find(disc => disc.id === a.id)!.strength ||
|
||||||
|
a.id.localeCompare(b.id))[0]; assert.ok(leader);
|
||||||
|
return { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
|
||||||
|
}
|
||||||
|
if (game.status === 'setup' && game.setupStage === 'forces' &&
|
||||||
|
!semutaAdvisorGatherPlayer(game, 'beneGesserit').advisorSetup)
|
||||||
|
return { actor: 'beneGesserit', action: { type: 'advisorSetup', territory: 'arrakeen', sector: 10 } };
|
||||||
|
if (game.decision?.kind === 'advisorBattle' || game.decision?.kind === 'advisor')
|
||||||
|
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
|
||||||
|
if (game.decision?.kind === 'intrusion') return null;
|
||||||
|
}
|
||||||
|
return nextSemutaRicheseCacheKarama(game);
|
||||||
|
}
|
||||||
|
export function advanceSemutaAdvisorGather(game: Game, until: (state: Game) => boolean,
|
||||||
|
actions: SemutaAdvisorGatherStep[] = []): Game {
|
||||||
|
for (let count = 0; count < 2000; count++) {
|
||||||
|
if (until(game)) return game;
|
||||||
|
assert.notEqual(game.status, 'finished');
|
||||||
|
const step = nextSemutaAdvisorGather(game);
|
||||||
|
assert.ok(step, 'Choose the original matching counter or fresh cost before continuing');
|
||||||
|
game = actSemutaAdvisorGather(game, step.actor, step.action, actions);
|
||||||
|
}
|
||||||
|
throw Error('The original Semuta gather programme did not reach its finite boundary');
|
||||||
|
}
|
||||||
|
function allowOneGatherSource(game: Game, actions: SemutaAdvisorGatherStep[] = []): Game {
|
||||||
|
const source = game.response?.advisorGatherMatchingSource; assert.ok(source);
|
||||||
|
for (let count = 0; game.response?.advisorGatherMatchingSource?.event === source.event && count < game.players.length; count++) {
|
||||||
|
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||||
|
game = actSemutaAdvisorGather(game, actor.id, { type: 'passResponse' }, actions);
|
||||||
|
}
|
||||||
|
assert.notEqual(game.response?.advisorGatherMatchingSource?.event, source.event); return game;
|
||||||
|
}
|
||||||
|
/** Allow the actual remaining origin counters, then only their original
|
||||||
|
* movement suffix. The first approval alone never flips or transfers a force. */
|
||||||
|
export function allowSemutaAdvisorGather(game: Game, actions: SemutaAdvisorGatherStep[] = []): Game {
|
||||||
|
assert.ok(game.response?.advisorGatherMatchingSource);
|
||||||
|
for (let count = 0; game.response?.advisorGatherMatchingSource && count < 2; count++)
|
||||||
|
game = allowOneGatherSource(game, actions);
|
||||||
|
assert.equal(game.pendingAdvisorGatherMatching, undefined);
|
||||||
|
return finishSemutaAdvisorGather(game, actions);
|
||||||
|
}
|
||||||
|
export function finishSemutaAdvisorGather(game: Game, actions: SemutaAdvisorGatherStep[] = []): Game {
|
||||||
|
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
|
||||||
|
assert.equal(game.pendingAdvisorGatherMatching, undefined);
|
||||||
|
return advanceSemutaAdvisorGather(game, semutaAdvisorGatherClean, actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fresh original Advanced Semuta+all14 Skills development entry. BG's real
|
||||||
|
* selected Planetologist and two paid fighter shipments supply the packet;
|
||||||
|
* only untouched original Skill/Treachery/Spice orders are selected. */
|
||||||
|
export function createSemutaAdvisorGatherFixture(options: { position?: SemutaAdvisorGatherPosition } = {}) {
|
||||||
|
const position = options.position ?? 'first', richese = 'richese', holder = 'choam', owner = 'beneGesserit',
|
||||||
|
spender = 'atreides', emperor = 'emperor';
|
||||||
|
let game = createGame('SEMUTAADVISORGATHER', newPlayer(richese, 'Richese', 'richese'), true, ['choam']);
|
||||||
|
for (const faction of [holder, owner, spender, emperor] as const)
|
||||||
|
joinGame(game, newPlayer(faction, faction, faction));
|
||||||
|
const initial = structuredClone(game), actions: SemutaAdvisorGatherStep[] = [];
|
||||||
|
const act = (actor: string, action: Action) => { game = actSemutaAdvisorGather(game, actor, action, actions); };
|
||||||
|
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorGather(game, until, actions); };
|
||||||
|
const transition = (run: () => void) => {
|
||||||
|
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);
|
||||||
|
// Existing explicit Skills entry requires an undealt lobby with no already
|
||||||
|
// allocated Skill state; it creates the original deck instead of redealing.
|
||||||
|
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game, true));
|
||||||
|
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
|
||||||
|
entry: 'original-undealt-semuta-all14-skills', skills: true };
|
||||||
|
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
|
||||||
|
assert.ok(game.leaderSkills); assert.deepEqual(game.expansions, ['choam']);
|
||||||
|
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||||
|
assert.ok(!game.homeworlds && !game.nexusCards && !game.techTokens && !game.strongholdCards &&
|
||||||
|
!game.discoveryEnabled && !game.ecazTreachery);
|
||||||
|
assert.ok(!Object.keys(game.leaderSkills.offers).length && !game.leaderSkills.assignments.length);
|
||||||
|
const originalInventory = semutaAdvisorGatherInventory(game), originalSkillInventory = semutaAdvisorGatherSkillInventory(game);
|
||||||
|
assert.equal(originalInventory.length, 45); assert.equal(game.deck.length, 35); assert.equal(game.richeseCache!.length, 10);
|
||||||
|
assert.deepEqual(originalSkillInventory, LEADER_SKILL_CARDS.map(card => card.id).sort());
|
||||||
|
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
|
||||||
|
const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 4); assert.equal(ordinary.length, 4);
|
||||||
|
const selected = [ordinary[0], ordinary[1], ordinary[2], printed, ordinary[3]], cost = printed.id;
|
||||||
|
const dealOrder = selected.map((card, index) => {
|
||||||
|
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= index);
|
||||||
|
[game.deck[index], game.deck[from]] = [game.deck[from], game.deck[index]];
|
||||||
|
return { position: index, from, card: card.id };
|
||||||
|
});
|
||||||
|
const beforeSkillOrder = structuredClone(game), originalSkillOrder = [...game.leaderSkills.deck];
|
||||||
|
const wanted = game.players.map(player => training[player.id]), rest = game.leaderSkills.deck.filter(skill => !wanted.includes(skill));
|
||||||
|
const skillOrder = wanted.flatMap(skill => [skill, rest.shift()!]).concat(rest);
|
||||||
|
game.leaderSkills.deck.splice(0, game.leaderSkills.deck.length, ...skillOrder);
|
||||||
|
assert.deepEqual(semutaAdvisorGatherSkillInventory(game), originalSkillInventory);
|
||||||
|
const beforeStartingDeal = structuredClone(game);
|
||||||
|
let advisorSetup: ReturnType<typeof transition> | null = null, skillOffers: Game | null = null;
|
||||||
|
const skillChoices: ReturnType<typeof transition>[] = [];
|
||||||
|
for (let count = 0; game.status === 'setup' && count < 120; count++) {
|
||||||
|
const step = nextSemutaAdvisorGather(game); assert.ok(step);
|
||||||
|
const before = structuredClone(game), start = actions.length;
|
||||||
|
if (before.setupStage === 'leaderSkills' && !skillOffers) skillOffers = structuredClone(game);
|
||||||
|
act(step.actor, step.action);
|
||||||
|
const record = { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||||
|
if (step.action.type === 'leaderSkill') skillChoices.push(record);
|
||||||
|
if (step.action.type === 'advisorSetup') advisorSetup = record;
|
||||||
|
}
|
||||||
|
assert.equal(game.status, 'playing'); assert.ok(advisorSetup && skillOffers); assert.equal(skillChoices.length, 5);
|
||||||
|
const afterSetup = structuredClone(game), trainer = planetologistLeader(game, owner); assert.ok(trainer);
|
||||||
|
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
||||||
|
assert.equal(semutaAdvisorGatherPlayer(game, owner).forces['arrakeen:10'], 1);
|
||||||
|
assert.equal(isAdvisor(semutaAdvisorGatherPlayer(game, owner), 'arrakeen'), true);
|
||||||
|
assert.equal(semutaAdvisorGatherPlayer(game, spender).forces['arrakeen:10'], 10);
|
||||||
|
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||||
|
const spiceOrder = [0, 1, 2, 3].map(index => {
|
||||||
|
const from = game.spiceDeck.findIndex((card, cardIndex) => cardIndex >= index && 'territory' in card && !card.discovery &&
|
||||||
|
!['imperial_basin', 'oh_gap', 'arrakeen'].includes(card.territory));
|
||||||
|
assert.ok(from >= index); const card = game.spiceDeck.splice(from, 1)[0];
|
||||||
|
game.spiceDeck.splice(index, 0, card); return { position: index, from, card: structuredClone(card) };
|
||||||
|
});
|
||||||
|
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||||
|
let beforeBid: Game | undefined, bid: SemutaAdvisorGatherStep | undefined, bidAt = -1;
|
||||||
|
for (let count = 0; count < 900 && !semutaAdvisorGatherPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||||
|
const step = nextSemutaAdvisorGather(game); assert.ok(step);
|
||||||
|
if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) {
|
||||||
|
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
|
||||||
|
}
|
||||||
|
act(step.actor, step.action);
|
||||||
|
}
|
||||||
|
assert.ok(beforeBid && bid && bidAt >= 0, 'CHOAM genuinely buys the original cache Semuta for one spice');
|
||||||
|
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||||
|
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||||
|
assert.equal(semutaAdvisorGatherPlayer(beforeBid, holder).spice - semutaAdvisorGatherPlayer(game, holder).spice, 1);
|
||||||
|
assert.equal(semutaAdvisorGatherPlayer(game, richese).spice - semutaAdvisorGatherPlayer(beforeBid, richese).spice, 1);
|
||||||
|
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && semutaAdvisorGatherClean(state));
|
||||||
|
const fundingAction: Action = { type: 'bribe', target: owner, amount: 1 };
|
||||||
|
const funding = transition(() => { act(emperor, fundingAction); });
|
||||||
|
const firstShipment = transition(() => {
|
||||||
|
act(owner, { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 2, elite: 0, allyPayment: 0 });
|
||||||
|
reach(semutaAdvisorGatherClean);
|
||||||
|
});
|
||||||
|
assert.equal(semutaAdvisorGatherPlayer(firstShipment.before, owner).spice - semutaAdvisorGatherPlayer(game, owner).spice, 4);
|
||||||
|
reach(state => state.turn === 2 && state.phase === 5 && state.active === owner && semutaAdvisorGatherClean(state));
|
||||||
|
const beforeSecondShipment = structuredClone(game);
|
||||||
|
const secondShipment = transition(() => {
|
||||||
|
act(owner, { type: 'ship', territory: 'oh_gap', sector: 9, amount: 1, elite: 0, allyPayment: 0 });
|
||||||
|
reach(semutaAdvisorGatherClean);
|
||||||
|
});
|
||||||
|
assert.equal(semutaAdvisorGatherPlayer(secondShipment.before, owner).spice - semutaAdvisorGatherPlayer(game, owner).spice, 2);
|
||||||
|
const territory = 'arrakeen', location = 'arrakeen:10', sourceLocations = ['imperial_basin:9', 'oh_gap:9'];
|
||||||
|
const player = semutaAdvisorGatherPlayer(game, owner);
|
||||||
|
assert.equal(player.forces[sourceLocations[0]], 2); assert.equal(player.forces[sourceLocations[1]], 1);
|
||||||
|
assert.equal(isAdvisor(player, 'imperial_basin'), false); assert.equal(isAdvisor(player, 'oh_gap'), false);
|
||||||
|
assert.equal(player.forces[location], 1); assert.equal(isAdvisor(player, territory), true);
|
||||||
|
assert.equal(player.shipped, true); assert.equal(player.moved, 0); assert.equal(planetologistLeader(game, owner), trainer);
|
||||||
|
assert.deepEqual(sourceLocations.map(source => distance(source, location)), [1, 1]);
|
||||||
|
const forces = { 'imperial_basin:9': 1, 'oh_gap:9': 1 };
|
||||||
|
const nativeAction: Action = { type: 'move', planetologist: 'gather', forces, territory, sector: 10 };
|
||||||
|
const largerPacket: Action = { ...nativeAction, forces: { 'imperial_basin:9': 2, 'oh_gap:9': 1 } };
|
||||||
|
const replacement: Action = { type: 'move', from: 'imperial_basin:9', territory: 'oh_gap', sector: 9, amount: 1 };
|
||||||
|
const beforeDeclaration = structuredClone(game), beforePhysical = structuredClone(semutaAdvisorGatherPhysical(game));
|
||||||
|
const declaration = transition(() => { act(owner, nativeAction); });
|
||||||
|
const firstSource = structuredClone(game.response?.advisorGatherMatchingSource); assert.ok(firstSource);
|
||||||
|
assert.equal(firstSource.origin, 'imperial_basin'); assert.equal(game.pendingAdvisorGatherMatching?.receipts.length, 2);
|
||||||
|
assert.deepEqual(semutaAdvisorGatherPhysical(game), beforePhysical);
|
||||||
|
let firstAllowance: ReturnType<typeof transition> | null = null;
|
||||||
|
if (position === 'second') firstAllowance = transition(() => {
|
||||||
|
game = allowOneGatherSource(game, actions);
|
||||||
|
assert.deepEqual(semutaAdvisorGatherPhysical(game), beforePhysical);
|
||||||
|
});
|
||||||
|
const parent = game.pendingAdvisorGatherMatching, matching = game.response?.advisorGatherMatchingSource;
|
||||||
|
assert.ok(parent && matching); assert.equal(parent.index, position === 'first' ? 0 : 1);
|
||||||
|
assert.equal(parent.approved.length, parent.index);
|
||||||
|
assert.equal(matching.origin, position === 'first' ? 'imperial_basin' : 'oh_gap');
|
||||||
|
assert.equal(game.response?.kind, 'advisorFlip'); assert.equal(game.response?.owner, owner);
|
||||||
|
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
|
||||||
|
assert.equal(game.phaseOpening ?? null, null); assert.equal(game.decision ?? null, null);
|
||||||
|
assert.ok(semutaAdvisorGatherPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||||
|
assert.ok(semutaAdvisorGatherPlayer(game, spender).hand.some(card => card.id === cost));
|
||||||
|
assert.deepEqual(semutaAdvisorGatherInventory(game), originalInventory);
|
||||||
|
assert.deepEqual(semutaAdvisorGatherSkillInventory(game), originalSkillInventory);
|
||||||
|
const source = structuredClone(game), action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||||
|
return { game, source, beforeCost: source, position, kind: 'advisorGatherMatching' as const, advanced: true as const,
|
||||||
|
richese, holder, owner, bg: owner, spender, atreides: spender, emperor, cost, semuta: SEMUTA_DRUG_ID, action, nativeAction,
|
||||||
|
territory, resident: territory, location, destination: territory, destinationLocation: location, forces,
|
||||||
|
sourceLocations, sourceCounts: { 'imperial_basin:9': 2, 'oh_gap:9': 1 }, trainer, skill: 'planetologist' as const,
|
||||||
|
replacement, sameTypeReplacement: replacement, largerPacket,
|
||||||
|
originalResponse: structuredClone(game.response!), originalParent: structuredClone(parent),
|
||||||
|
originalSource: structuredClone(matching), originalGatherSource: structuredClone(matching),
|
||||||
|
originalPhysical: structuredClone(semutaAdvisorGatherPhysical(game)), originalHistory: structuredClone(game.log),
|
||||||
|
originalDenials: structuredClone(game.advisorMatchingDenied ?? []), expectedDenial: advisorMatchingKey(matching),
|
||||||
|
initial, lobby, setup, initialization, beforeStartingDeal, beforeSkillOrder, originalSkillOrder, skillOrder,
|
||||||
|
skillOffers, skillChoices, advisorSetup, afterSetup, dealOrder, spiceOrder, originalSpice,
|
||||||
|
acquired, acquisition, funding, fundingAction, firstShipment, beforeSecondShipment, secondShipment,
|
||||||
|
beforeDeclaration, declaration, firstSource, firstAllowance, originalInventory, originalSkillInventory,
|
||||||
|
actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||||
|
staging: [
|
||||||
|
'Original explicit Advanced Semuta all14 Skills initializer acts on the ready undealt R/C/BG/A/E exact-CHOAM lobby. It creates the original Skill deck once; no preexisting Skill state is redealt and no Basic game is converted.',
|
||||||
|
'Reorder only the untouched original all14 Skill deck and unopened starting Treachery positions. Each of five actual two-card offers is really selected on a living eligible native disc; BG earns Planetologist and A is genuinely dealt printed Karama.',
|
||||||
|
'Actual advisorSetup places BG beside original A10 Arrakeen. Four safe original ordinary Spice positions are selected; actual first/second Storm, dual Spice, Charity, native auctions and Revival execute. CHOAM really buys original cache Semuta for one spice.',
|
||||||
|
'E actually bribes one spice. BG pays four for Basin2 in turn1 and two for OhGap1 in turn2; both groups remain fighters and are graph-distance1 from its original resident Arrakeen advisor. No played wallet, force, stance, trainer, clock or response is installed.',
|
||||||
|
position === 'second'
|
||||||
|
? 'Submit the real two-origin gather, then actually allow the first Basin matching counter. All physical counters, stances, payments and movement use remain unchanged; stop at the original second OhGap counter with one approved event.'
|
||||||
|
: 'Submit the real two-origin gather and stop at the original first Basin matching counter, before any physical transfer or movement use.',
|
||||||
|
'Fresh cost targets A original printed Karama. Denial keeps the whole move unused and permits the real same-type Basin→OhGap alternative; native allowance completes all remaining origin controls before one actual gather commit.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type SemutaAdvisorGatherFixture = ReturnType<typeof createSemutaAdvisorGatherFixture>;
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import { isAdvisor } from '../game/advisors';
|
||||||
|
import { splitLocation, territory } from '../game/board';
|
||||||
|
import { guildShipmentCost } from '../game/shipment-price';
|
||||||
|
import { guildTransportQuote } from '../game/transport-quote';
|
||||||
|
import { junctionTransportChoice } from '../game/junction-transport-options';
|
||||||
|
import { advisorTransportMatchingKey } from '../game/advisor-transport-matching';
|
||||||
|
import type { HomeworldShipmentSources } from '../game/homeworld-shipment-options';
|
||||||
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||||
|
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||||
|
import {
|
||||||
|
actSemutaAdvisorMatching, nextSemutaAdvisorMatching, passSemutaAdvisorMatching, claimSemutaAdvisorMatching,
|
||||||
|
semutaAdvisorMatchingPlayer, semutaAdvisorMatchingInventory,
|
||||||
|
} from './fixture-semuta-advisor-matching';
|
||||||
|
|
||||||
|
export type SemutaAdvisorTransportMatchingKind = 'guild' | 'junction';
|
||||||
|
export type SemutaAdvisorTransportMatchingDirection = 'fightersToAdvisors' | 'advisorsToFighters';
|
||||||
|
export type SemutaAdvisorTransportMatchingStep = { actor: string; action: Action };
|
||||||
|
export const actSemutaAdvisorTransportMatching = actSemutaAdvisorMatching;
|
||||||
|
export const passSemutaAdvisorTransportMatching = passSemutaAdvisorMatching;
|
||||||
|
export const claimSemutaAdvisorTransportMatching = claimSemutaAdvisorMatching;
|
||||||
|
export const semutaAdvisorTransportMatchingPlayer = semutaAdvisorMatchingPlayer;
|
||||||
|
export const semutaAdvisorTransportMatchingInventory = semutaAdvisorMatchingInventory;
|
||||||
|
export const semutaAdvisorTransportMatchingClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
|
||||||
|
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingAdvisorTransportMatching &&
|
||||||
|
!game.pendingGuildTransport && !game.pendingHomeworldShipment && !game.pendingAutomaticAdvisorFlips?.length;
|
||||||
|
export const semutaAdvisorTransportMatchingPhysical = (game: Game) => ({ homeworlds: game.homeworlds,
|
||||||
|
players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces, elites: p.elites,
|
||||||
|
advisors: p.advisors, reserves: p.reserves, tanks: p.tanks, shipped: p.shipped, moved: p.moved, ally: p.ally })) });
|
||||||
|
const ownedResponse = (game: Game) => !!game.response && (game.response.kind === 'advisorFlip' ||
|
||||||
|
game.response.kind === 'guildTransportPermission' || game.response.kind === 'guildRate');
|
||||||
|
|
||||||
|
/** Keep matching, native permission/rate and every fresh group caller-owned.
|
||||||
|
* Guild actually waits; typed Homeworld interception uses its native reply. */
|
||||||
|
export function nextSemutaAdvisorTransportMatching(game: Game): SemutaAdvisorTransportMatchingStep | null {
|
||||||
|
if (game.pendingTreacheryDiscard || game.truthtrance || ownedResponse(game)) return null;
|
||||||
|
if (!game.response && !game.phaseOpening) {
|
||||||
|
if (game.decision?.kind === 'guildTiming')
|
||||||
|
return { actor: game.decision.player, action: { type: 'decision', take: false } };
|
||||||
|
if (game.decision?.kind === 'homeworldShipmentGuild')
|
||||||
|
return { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, allow: true } };
|
||||||
|
}
|
||||||
|
return nextSemutaAdvisorMatching(game);
|
||||||
|
}
|
||||||
|
export const nextSemutaAdvisorTransportMatchingStep = nextSemutaAdvisorTransportMatching;
|
||||||
|
export function advanceSemutaAdvisorTransportMatching(game: Game, until: (state: Game) => boolean,
|
||||||
|
actions: SemutaAdvisorTransportMatchingStep[] = []): Game {
|
||||||
|
for (let count = 0; count < 2400; count++) {
|
||||||
|
if (until(game)) return game;
|
||||||
|
assert.notEqual(game.status, 'finished');
|
||||||
|
const step = nextSemutaAdvisorTransportMatching(game);
|
||||||
|
assert.ok(step, `Choose the original matching/permission/rate/fresh control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||||
|
game = actSemutaAdvisorTransportMatching(game, step.actor, step.action, actions);
|
||||||
|
}
|
||||||
|
throw Error('The original Semuta/transport matching chronology did not reach its finite boundary');
|
||||||
|
}
|
||||||
|
function layer(game: Game) {
|
||||||
|
const response = game.response;
|
||||||
|
return response ? JSON.stringify([response.kind, response.advisorTransportMatchingSource?.event,
|
||||||
|
response.guildTransportEvent, response.guildRateEvent, response.automaticAdvisorFlip?.event]) : null;
|
||||||
|
}
|
||||||
|
/** Explicitly allow one original response only. Matching allowance can open
|
||||||
|
* the independently owned native permission/rate or payment continuation. */
|
||||||
|
export function allowSemutaAdvisorTransportMatching(game: Game, actions: SemutaAdvisorTransportMatchingStep[] = []): Game {
|
||||||
|
const original = layer(game); assert.ok(original);
|
||||||
|
for (let count = 0; layer(game) === original && count < game.players.length; count++) {
|
||||||
|
const response: NonNullable<Game['response']> = game.response!;
|
||||||
|
const actor = game.players.find(p => !response.passed.includes(p.id)); assert.ok(actor);
|
||||||
|
game = actSemutaAdvisorTransportMatching(game, actor.id, { type: 'passResponse' }, actions);
|
||||||
|
}
|
||||||
|
assert.notEqual(layer(game), original); return game;
|
||||||
|
}
|
||||||
|
/** Finish only original ordinary income/arrival controls. Matching and native
|
||||||
|
* permission/rate remain explicit; the same BG movement turn stays active. */
|
||||||
|
export function finishSemutaAdvisorTransportMatching(game: Game, actions: SemutaAdvisorTransportMatchingStep[] = []): Game {
|
||||||
|
assert.equal(game.pendingTreacheryDiscard ?? null, null, 'Choose the original fresh cost first');
|
||||||
|
return advanceSemutaAdvisorTransportMatching(game, semutaAdvisorTransportMatchingClean, actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Original Advanced R/C/BG/A/G with actual paid Semuta, original printed
|
||||||
|
* Karama and two physically earned BG groups. No played state is installed. */
|
||||||
|
export function createSemutaAdvisorTransportMatchingFixture(options: {
|
||||||
|
kind?: SemutaAdvisorTransportMatchingKind; direction?: SemutaAdvisorTransportMatchingDirection;
|
||||||
|
} = {}) {
|
||||||
|
const kind = options.kind ?? 'guild', direction = options.direction ?? 'fightersToAdvisors';
|
||||||
|
const seller = 'richese', holder = 'choam', owner = 'beneGesserit', spender = 'atreides', guild = 'guild';
|
||||||
|
const homeworlds = kind === 'junction';
|
||||||
|
let game = createGame('SEMUTAADVISORTRANSPORTMATCHING', newPlayer(seller, 'Richese', 'richese'), true, ['choam']);
|
||||||
|
for (const faction of [holder, owner, spender, guild] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||||
|
const initial = structuredClone(game), actions: SemutaAdvisorTransportMatchingStep[] = [];
|
||||||
|
const act = (actor: string, action: Action) => { game = actSemutaAdvisorTransportMatching(game, actor, action, actions); };
|
||||||
|
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorTransportMatching(game, until, actions); };
|
||||||
|
const transition = (run: () => void) => {
|
||||||
|
const before = structuredClone(game), at = actions.length; run();
|
||||||
|
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||||
|
};
|
||||||
|
const enableHomeworlds = homeworlds ? transition(() => { act(game.host, { type: 'homeworlds', enabled: true }); }) : null;
|
||||||
|
for (const p of game.players) act(p.id, { type: 'ready' });
|
||||||
|
const lobby = structuredClone(game);
|
||||||
|
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||||
|
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
|
||||||
|
entry: homeworlds ? 'original-undealt-semuta-homeworld-preview' : 'original-undealt-advanced-semuta-preview' };
|
||||||
|
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
|
||||||
|
assert.equal(!!game.homeworlds, homeworlds); assert.ok(game.players.every(p => !p.hand.length));
|
||||||
|
assert.ok(!game.nexusCards && !game.leaderSkills && !game.techTokens && !game.strongholdCards &&
|
||||||
|
!game.discoveryEnabled && !game.ecazTreachery);
|
||||||
|
const originalInventory = semutaAdvisorTransportMatchingInventory(game); assert.equal(originalInventory.length, 45);
|
||||||
|
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
|
||||||
|
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 4);
|
||||||
|
assert.equal(ordinary.length, 4);
|
||||||
|
const selected = [ordinary[0], ordinary[1], ordinary[2], printed, ordinary[3]], cost = printed.id;
|
||||||
|
const dealOrder = selected.map((card, position) => {
|
||||||
|
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||||
|
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||||
|
return { position, from, card: card.id };
|
||||||
|
});
|
||||||
|
const beforeStartingDeal = structuredClone(game);
|
||||||
|
let advisorSetup: { before: Game; steps: SemutaAdvisorTransportMatchingStep[]; after: Game } | null = null;
|
||||||
|
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||||
|
const step = nextSemutaAdvisorTransportMatching(game); assert.ok(step);
|
||||||
|
const before = step.action.type === 'advisorSetup' ? structuredClone(game) : null;
|
||||||
|
act(step.actor, step.action);
|
||||||
|
if (before) advisorSetup = { before, steps: [structuredClone(step)], after: structuredClone(game) };
|
||||||
|
}
|
||||||
|
assert.equal(game.status, 'playing'); assert.ok(advisorSetup);
|
||||||
|
const afterSetup = structuredClone(game);
|
||||||
|
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||||
|
assert.equal(semutaAdvisorTransportMatchingPlayer(game, owner).forces['arrakeen:10'], 1);
|
||||||
|
assert.equal(isAdvisor(semutaAdvisorTransportMatchingPlayer(game, owner), 'arrakeen'), true);
|
||||||
|
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||||
|
const spiceOrder: { turn: number; position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||||
|
const orderLand = (position: number) => {
|
||||||
|
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
|
||||||
|
!['imperial_basin', 'arrakeen', 'tueks_sietch'].includes(card.territory));
|
||||||
|
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||||
|
spiceOrder.push({ turn: game.turn, position, from, card: structuredClone(card) });
|
||||||
|
};
|
||||||
|
for (let position = 0; position < 4; position++) orderLand(position);
|
||||||
|
let beforeBid: Game | undefined, bid: SemutaAdvisorTransportMatchingStep | undefined, bidAt = -1;
|
||||||
|
for (let count = 0; count < 900 && !semutaAdvisorTransportMatchingPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||||
|
const step = nextSemutaAdvisorTransportMatching(game); assert.ok(step);
|
||||||
|
if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) {
|
||||||
|
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
|
||||||
|
}
|
||||||
|
act(step.actor, step.action);
|
||||||
|
}
|
||||||
|
assert.ok(beforeBid && bid && bidAt >= 0);
|
||||||
|
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||||
|
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||||
|
assert.equal(semutaAdvisorTransportMatchingPlayer(beforeBid, holder).spice - semutaAdvisorTransportMatchingPlayer(game, holder).spice, 1);
|
||||||
|
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && semutaAdvisorTransportMatchingClean(state));
|
||||||
|
const fighterShipment = transition(() => {
|
||||||
|
act(owner, { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 2, elite: 0, allyPayment: 0 });
|
||||||
|
reach(semutaAdvisorTransportMatchingClean);
|
||||||
|
});
|
||||||
|
assert.equal(semutaAdvisorTransportMatchingPlayer(fighterShipment.before, owner).spice - semutaAdvisorTransportMatchingPlayer(game, owner).spice, 4);
|
||||||
|
assert.equal(semutaAdvisorTransportMatchingPlayer(game, owner).forces['imperial_basin:9'], 2);
|
||||||
|
let beforeWorm: Game | null = null, nexus: Game | null = null;
|
||||||
|
let wormOrder: { position: number; from: number; card: Game['spiceDeck'][number] } | null = null;
|
||||||
|
let alliance: ReturnType<typeof transition> | null = null;
|
||||||
|
if (kind === 'guild') {
|
||||||
|
reach(state => state.turn === 2 && state.phase === 1 && semutaAdvisorTransportMatchingClean(state) &&
|
||||||
|
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0);
|
||||||
|
beforeWorm = structuredClone(game);
|
||||||
|
const from = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
|
||||||
|
assert.ok(from >= 0); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.unshift(card);
|
||||||
|
wormOrder = { position: 0, from, card: structuredClone(card) }; orderLand(1); orderLand(2);
|
||||||
|
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && semutaAdvisorTransportMatchingClean(state));
|
||||||
|
nexus = structuredClone(game);
|
||||||
|
alliance = transition(() => { act(owner, { type: 'alliance', target: guild }); act(guild, { type: 'alliance', target: owner }); });
|
||||||
|
assert.equal(semutaAdvisorTransportMatchingPlayer(game, owner).ally, guild);
|
||||||
|
assert.equal(semutaAdvisorTransportMatchingPlayer(game, guild).ally, owner);
|
||||||
|
}
|
||||||
|
reach(state => state.turn === 2 && state.phase === 5 && state.active === owner && semutaAdvisorTransportMatchingClean(state));
|
||||||
|
let junctionOffer: ReturnType<typeof transition> | null = null, offerStep: SemutaAdvisorTransportMatchingStep | null = null;
|
||||||
|
if (kind === 'junction') {
|
||||||
|
const sponsor = viewGame(game, guild).junctionTransport; assert.ok(sponsor?.canOffer); assert.equal(sponsor.recipient, owner);
|
||||||
|
offerStep = { actor: guild, action: { type: 'offerJunctionTransport', event: sponsor.offerEvent, rate: 'half' } };
|
||||||
|
junctionOffer = transition(() => { act(offerStep!.actor, offerStep!.action); });
|
||||||
|
}
|
||||||
|
const sourceLocation = direction === 'fightersToAdvisors' ? 'imperial_basin:9' : 'arrakeen:10';
|
||||||
|
const destinationLocation = direction === 'fightersToAdvisors' ? 'arrakeen:10' : 'imperial_basin:9';
|
||||||
|
const replacementLocation = direction === 'fightersToAdvisors' ? 'polar_sink:0' : 'tueks_sietch:5';
|
||||||
|
const declarationFor = (to: string, amount: number) => {
|
||||||
|
const sources: HomeworldShipmentSources = { [sourceLocation]: { normal: amount, elite: 0 } };
|
||||||
|
const point = splitLocation(to);
|
||||||
|
if (kind === 'guild') {
|
||||||
|
const action: Action = { type: 'guildShip', from: sourceLocation, territory: point.territory,
|
||||||
|
sector: point.sector, amount, elite: 0, allyPayment: 0 };
|
||||||
|
const quote = guildTransportQuote(viewGame(game, owner), action); assert.deepEqual(quote.unavailableReasons, []);
|
||||||
|
return { action, sources, price: guildShipmentCost(territory(point.territory).type, amount, true) };
|
||||||
|
}
|
||||||
|
const quote = junctionTransportChoice(viewGame(game, owner), to, sources, 0); assert.equal(quote.blocked, null); assert.ok(quote.action);
|
||||||
|
return { action: quote.action, sources, price: quote.cost };
|
||||||
|
};
|
||||||
|
const transportQuote = declarationFor(destinationLocation, 1), replacementQuote = declarationFor(replacementLocation, 1);
|
||||||
|
const largerQuote = direction === 'fightersToAdvisors' ? declarationFor(destinationLocation, 2) : null;
|
||||||
|
const beforeDeclaration = structuredClone(game), nativeAction = transportQuote.action;
|
||||||
|
assert.equal(semutaAdvisorTransportMatchingPlayer(game, owner).shipped, false);
|
||||||
|
const declaration = transition(() => { act(owner, nativeAction); });
|
||||||
|
assert.ok(game.response?.advisorTransportMatchingSource && game.pendingAdvisorTransportMatching);
|
||||||
|
assert.equal(game.response.kind, 'advisorFlip'); assert.equal(game.pendingAdvisorTransportMatching.intent.kind, kind);
|
||||||
|
assert.equal(game.pendingGuildTransport ?? null, null); assert.equal(game.pendingHomeworldShipment ?? null, null);
|
||||||
|
assert.deepEqual(semutaAdvisorTransportMatchingPhysical(game), semutaAdvisorTransportMatchingPhysical(beforeDeclaration));
|
||||||
|
assert.ok(semutaAdvisorTransportMatchingPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||||
|
assert.ok(semutaAdvisorTransportMatchingPlayer(game, spender).hand.some(card => card.id === cost));
|
||||||
|
const source = structuredClone(game), originalResponse = structuredClone(game.response);
|
||||||
|
const originalParent = structuredClone(game.pendingAdvisorTransportMatching), originalSource = structuredClone(game.response.advisorTransportMatchingSource);
|
||||||
|
const opportunity = originalParent.opportunity, denialKey = advisorTransportMatchingKey(originalSource, opportunity);
|
||||||
|
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||||
|
assert.deepEqual(semutaAdvisorTransportMatchingInventory(game), originalInventory);
|
||||||
|
return { game, source, beforeCost: source, advanced: true as const, kind, direction, owner, bg: owner, recipient: owner,
|
||||||
|
seller, richese: seller, holder, spender, atreides: spender, guild, actor: owner, shipper: owner,
|
||||||
|
cost, karama: cost, semuta: SEMUTA_DRUG_ID, action, nativeAction, originalResponse, originalParent, originalPending: originalParent,
|
||||||
|
originalSource, receipt: originalSource, opportunity, denialKey, originalDenied: structuredClone(game.advisorTransportMatchingDenied ?? []),
|
||||||
|
originalPhysical: structuredClone(semutaAdvisorTransportMatchingPhysical(game)), originalOffer: structuredClone(game.junctionOffer ?? null),
|
||||||
|
sourceLocation, destinationLocation, origin: splitLocation(sourceLocation).territory, destination: splitLocation(destinationLocation).territory,
|
||||||
|
replacementLocation, replacementActor: owner, replacementAction: replacementQuote.action, replacementQuote,
|
||||||
|
noMatch: replacementQuote.action, largerPacket: largerQuote?.action ?? null, largerQuote,
|
||||||
|
amount: 1, price: transportQuote.price, transportCost: transportQuote.price, transportQuote,
|
||||||
|
initial, enableHomeworlds, lobby, setup, initialization, beforeStartingDeal, afterSetup, advisorSetup,
|
||||||
|
deal: selected.map(card => card.id), dealOrder, originalInventory, originalSpice, spiceOrder,
|
||||||
|
acquisition, acquired, fighterShipment, beforeWorm, wormOrder, nexus, alliance, junctionOffer, offerStep, beforeDeclaration, declaration,
|
||||||
|
actions, steps: actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||||
|
staging: [
|
||||||
|
'Original Advanced R/C/BG/A/G exact-CHOAM Semuta initialization on a genuinely ready undealt lobby; only original unopened starting-card/Spice positions and scalar lotteries are selected. Atreides really receives printed Karama and CHOAM really buys cache1 Semuta. No played hand, wallet, force, stance, clock, alliance, grant or pending receipt is installed.',
|
||||||
|
'BG actually places its original advisor at Arrakeen10 beside A10, declines preparation and pays four to ship two original reserves to empty Basin9 on turn1. Actual native Guild timing/interception/income, Collection, Mentat, next Storm and phase controls execute. Both original groups remain for BG’s unused turn2 shipment.',
|
||||||
|
kind === 'guild'
|
||||||
|
? 'One original unopened turn2 Worm and safe original follow-up lands produce a real Nexus. BG actually proposes Guild alliance and Guild accepts; the original reciprocal alliance earns native board cross-shipment. No Nexus Cards module or Secret Ally is selected.'
|
||||||
|
: 'Homeworlds are enabled by their real original lobby action. Original typed custody stays native; high Guild really offers its current half-price Junction event to active BG. This source has no alliance and no other optional module.',
|
||||||
|
'Declare the original one-counter opposite-type transport and stop at its native matching response, before payment, withdrawal/transfer, permission or rate. Denial keeps the original opportunity unused; expose a real empty Polar destination for fighters, or Guild-occupied Tueks for original advisors. Board groups keep their source stance unless matching resident BG; the genuine larger two-fighter packet remains available separately.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type SemutaAdvisorTransportMatchingFixture = ReturnType<typeof createSemutaAdvisorTransportMatchingFixture>;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import { isAdvisor } from '../game/advisors';
|
||||||
|
import { guildTransportQuote } from '../game/transport-quote';
|
||||||
|
import {
|
||||||
|
createSemutaAdvisorTransportMatchingFixture as fixture,
|
||||||
|
actSemutaAdvisorTransportMatching as act, allowSemutaAdvisorTransportMatching as allow,
|
||||||
|
finishSemutaAdvisorTransportMatching as finish, semutaAdvisorTransportMatchingPlayer as player,
|
||||||
|
semutaAdvisorTransportMatchingInventory as inventory,
|
||||||
|
} from './fixture-semuta-advisor-transport-matching';
|
||||||
|
|
||||||
|
const advisorEntry: Action = {type:'guildShip',from:'arrakeen:10',territory:'tueks_sietch',
|
||||||
|
sector:5,amount:1,elite:0,allyPayment:0};
|
||||||
|
function complete(game: Game) {
|
||||||
|
for (let count=0;game.response && ['guildTransportPermission','guildRate'].includes(game.response.kind) && count<3;count++)
|
||||||
|
game=allow(game);
|
||||||
|
return finish(game);
|
||||||
|
}
|
||||||
|
function reject(game: Game, actor: string, action: Action) {
|
||||||
|
const before=structuredClone(game);assert.throws(()=>applyAction(game,actor,action));assert.deepEqual(game,before);
|
||||||
|
}
|
||||||
|
function source() {
|
||||||
|
const f=fixture({kind:'guild'}),game=structuredClone(f.beforeDeclaration);
|
||||||
|
assert.equal(game.pendingAdvisorTransportMatching,undefined);
|
||||||
|
assert.ok((player(game,f.guild).forces['tueks_sietch:5']??0)>0);
|
||||||
|
assert.equal(player(game,f.owner).ally,f.guild);assert.equal(player(game,f.guild).ally,f.owner);
|
||||||
|
assert.equal(isAdvisor(player(game,f.owner),'arrakeen'),true);
|
||||||
|
assert.equal(player(game,f.owner).forces['arrakeen:10'],1);
|
||||||
|
return {f,game};
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('fresh Guild board transport quotes and delivers the actual advisor into its allied foreign group',()=>{
|
||||||
|
const {f,game:before}=source();
|
||||||
|
assert.deepEqual(guildTransportQuote(viewGame(before,f.owner),advisorEntry).unavailableReasons,[]);
|
||||||
|
let game=act(before,f.owner,advisorEntry);
|
||||||
|
assert.equal(game.response?.kind,'guildTransportPermission');assert.equal(game.pendingAdvisorTransportMatching,undefined);
|
||||||
|
assert.equal(game.pendingGuildTransport?.advisorArrivalSource,'arrakeen');assert.equal(game.pendingGuildTransport?.advisors,true);
|
||||||
|
assert.deepEqual(player(game,f.owner).forces,player(before,f.owner).forces);
|
||||||
|
game=complete(game);
|
||||||
|
assert.equal(player(game,f.owner).forces['arrakeen:10']??0,0);
|
||||||
|
assert.equal(player(game,f.owner).forces['tueks_sietch:5'],1);assert.equal(isAdvisor(player(game,f.owner),'tueks_sietch'),true);
|
||||||
|
assert.deepEqual(player(game,f.guild).forces,player(before,f.guild).forces);
|
||||||
|
assert.equal(player(game,f.owner).spice,player(before,f.owner).spice-1);
|
||||||
|
assert.equal(player(game,f.guild).spice,player(before,f.guild).spice+1);
|
||||||
|
assert.equal(player(game,f.owner).shipped,true);assert.equal(player(game,f.owner).moved,0);
|
||||||
|
assert.equal(game.pendingAutomaticAdvisorFlips,undefined);assert.deepEqual(inventory(game),f.originalInventory);
|
||||||
|
reject(game,f.owner,advisorEntry);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('the source-stance cutover blocks fighter cross-entry and preserves the direct reserve default',()=>{
|
||||||
|
const {f,game}=source();
|
||||||
|
const fighter:Action={...advisorEntry,from:'imperial_basin:9'};
|
||||||
|
assert.ok(guildTransportQuote(viewGame(game,f.owner),fighter).unavailableReasons.length>0);
|
||||||
|
reject(game,f.owner,fighter);
|
||||||
|
const reserve=act(game,f.owner,{type:'ship',territory:'tueks_sietch',sector:5,amount:1,elite:0,allyPayment:0});
|
||||||
|
assert.ok(reserve.pendingShipment);assert.equal(reserve.pendingShipment.advisors,false);
|
||||||
|
assert.equal('advisorArrivalSource' in reserve.pendingShipment,false);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a fresh Guild advisor declaration rejects a changed source before payment or transfer',()=>{
|
||||||
|
const {f,game:before}=source(),game=act(before,f.owner,advisorEntry);
|
||||||
|
assert.ok(game.pendingGuildTransport);game.pendingGuildTransport.advisorArrivalSource='polar_sink';
|
||||||
|
reject(game,f.holder,{type:'passResponse'});
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('controlled legacy Guild permission bytes retain the original already-approved matching settlement',()=>{
|
||||||
|
const f=fixture({kind:'guild'});let game=allow(structuredClone(f.game));
|
||||||
|
assert.equal(game.response?.kind,'guildTransportPermission');const intent=game.pendingGuildTransport;assert.ok(intent);
|
||||||
|
// Explicit format supplement to a genuine approved prefix. This is not a
|
||||||
|
// historical saved-game capture and changes no physical source or approval.
|
||||||
|
delete intent.advisorArrivalSource;
|
||||||
|
const fields:unknown[]=[intent.event,intent.turn,intent.player,intent.fromReserves,intent.group,
|
||||||
|
intent.eliteGroup,intent.origin,intent.to,intent.sector,intent.amount,intent.elite,
|
||||||
|
intent.cost,intent.allyPayment,intent.advisors];
|
||||||
|
if(intent.permission!==undefined)fields.push(intent.permission);
|
||||||
|
intent.signature=JSON.stringify(fields);
|
||||||
|
game=complete(game);
|
||||||
|
assert.equal(player(game,f.owner).forces['imperial_basin:9'],1);
|
||||||
|
assert.equal(player(game,f.owner).forces['arrakeen:10'],2);assert.equal(isAdvisor(player(game,f.owner),'arrakeen'),true);
|
||||||
|
assert.equal(player(game,f.owner).shipped,true);assert.equal(player(game,f.owner).moved,0);
|
||||||
|
assert.deepEqual(inventory(game),f.originalInventory);assert.equal(game.pendingGuildTransport??null,null);
|
||||||
|
});
|
||||||
@@ -238,11 +238,12 @@ void test('source storm, destination geometry, ally occupancy and stronghold cap
|
|||||||
unavailable(fixture(), cross({ territory: MOBILE_STRONGHOLD, sector: 0 }));
|
unavailable(fixture(), cross({ territory: MOBILE_STRONGHOLD, sector: 0 }));
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('new Bene Gesserit advisor source locks forbid fighter arrival but existing destination advisors preserve the lock', () => {
|
void test('fresh Guild transport retains advisor source locks at foreign arrivals and forbids matching into resident fighters', () => {
|
||||||
const g = fixture('beneGesserit');
|
const g = fixture('beneGesserit');
|
||||||
g.players[0].advisors = { imperial_basin: { lockedTurn: g.turn } };
|
g.players[0].advisors = { imperial_basin: { lockedTurn: g.turn } };
|
||||||
army(g, 2, { 'carthag:11': 1, 'imperial_basin:10': 1 });
|
army(g, 2, { 'carthag:11': 1, 'imperial_basin:10': 1 });
|
||||||
unavailable(g, cross());
|
const foreign = enabled(g, cross(), 2);
|
||||||
|
assert.equal(foreign.players[0].advisors!.carthag.lockedTurn, g.turn);
|
||||||
army(g, 0, { ...g.players[0].forces, 'carthag:11': 1 });
|
army(g, 0, { ...g.players[0].forces, 'carthag:11': 1 });
|
||||||
g.players[0].advisors.carthag = {};
|
g.players[0].advisors.carthag = {};
|
||||||
const after = enabled(g, cross(), 2);
|
const after = enabled(g, cross(), 2);
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import { botActions } from '../game/bots';
|
||||||
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||||
|
import { isAdvisor } from '../game/advisors';
|
||||||
|
import { advisorAmbassadorMatchingKey } from '../game/advisor-ambassador-matching';
|
||||||
|
import {
|
||||||
|
createSemutaAdvisorAmbassadorMatchingFixture as fixture, actSemutaAdvisorAmbassadorMatching as act,
|
||||||
|
passSemutaAdvisorAmbassadorMatching as pass, claimSemutaAdvisorAmbassadorMatching as claim,
|
||||||
|
allowSemutaAdvisorAmbassadorMatching as allow, finishSemutaAdvisorAmbassadorMatching as finish,
|
||||||
|
semutaAdvisorAmbassadorMatchingPlayer as player, semutaAdvisorAmbassadorMatchingInventory as inventory,
|
||||||
|
semutaAdvisorAmbassadorMatchingPhysical as physical, type SemutaAdvisorAmbassadorMatchingFixture,
|
||||||
|
} from './fixture-semuta-advisor-ambassador-matching';
|
||||||
|
|
||||||
|
const variants = [{ direction: 'fightersToAdvisors' as const }, { direction: 'advisorsToFighters' as const }];
|
||||||
|
const allowances = (game: Game) => game.players.map(p => ({ id: p.id, spice: p.spice, reserves: p.reserves,
|
||||||
|
tanks: p.tanks, elites: p.elites, moved: p.moved, shipped: p.shipped, ally: p.ally, allySinceTurn: p.allySinceTurn }));
|
||||||
|
function reject(game: Game, actor: string, action: Action) {
|
||||||
|
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||||
|
}
|
||||||
|
function nativeSource(f: SemutaAdvisorAmbassadorMatchingFixture) {
|
||||||
|
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||||
|
assert.deepEqual(f.setup.expansions, ['choam', 'ecaz']); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||||
|
assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', 'ecaz', 'beneGesserit', 'atreides', 'emperor']);
|
||||||
|
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
|
||||||
|
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||||
|
assert.equal(f.packet.scalars.length, 9); assert.equal(f.packetCreation.steps.length, 1);
|
||||||
|
assert.equal(f.packetCreation.steps[0].action.type, 'traitor');
|
||||||
|
assert.equal(f.advisorSetup.steps[0].action.type, 'advisorSetup');
|
||||||
|
assert.equal(player(f.afterSetup, f.owner).forces['arrakeen:10'], 1);
|
||||||
|
assert.equal(player(f.afterSetup, f.entrant).forces['arrakeen:10'], 10);
|
||||||
|
assert.equal(isAdvisor(player(f.afterSetup, f.owner), 'arrakeen'), true);
|
||||||
|
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
|
||||||
|
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
|
||||||
|
assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
|
||||||
|
assert.equal(player(f.placement.before, f.ecaz).spice - player(f.placement.after, f.ecaz).spice, 1);
|
||||||
|
assert.ok(f.placement.steps.some(step => step.actor === f.ecaz && step.action.type === 'decision' && step.action.token === f.token));
|
||||||
|
assert.equal(player(f.fighterShipment.before, f.owner).spice - player(f.fighterShipment.after, f.owner).spice, 2);
|
||||||
|
assert.equal(player(f.fighterShipment.after, f.owner).forces['tueks_sietch:5'], 2);
|
||||||
|
assert.equal(isAdvisor(player(f.fighterShipment.after, f.owner), 'tueks_sietch'), false);
|
||||||
|
assert.equal(f.alliance.before.turn, 2); assert.equal(f.alliance.before.phase, 1); assert.equal(f.alliance.before.nexus, true);
|
||||||
|
assert.equal(f.alliance.steps.length, 2); assert.ok(f.alliance.steps.every(step => step.action.type === 'alliance'));
|
||||||
|
assert.equal(player(f.game, f.owner).ally, f.ecaz); assert.equal(player(f.game, f.ecaz).ally, f.owner);
|
||||||
|
assert.ok('worm' in f.wormOrder.card);
|
||||||
|
assert.equal(player(f.entrantShipment.before, f.entrant).spice - player(f.entrantShipment.after, f.entrant).spice, 1);
|
||||||
|
assert.equal(player(f.entrantShipment.after, f.entrant).forces['carthag:11'], 1);
|
||||||
|
assert.equal(f.trigger.before.pendingAmbassador?.stage, 'offer'); assert.equal(f.trigger.after.pendingAmbassador?.stage, 'move');
|
||||||
|
assert.equal(f.originalAmbassador.event, f.event); assert.equal(f.originalAmbassador.beneficiary, f.owner);
|
||||||
|
assert.equal(f.originalAmbassador.effect, 'fremen'); assert.equal(f.originalAmbassador.entrant, f.entrant);
|
||||||
|
assert.equal(f.game.ecazAmbassadors!.tokens.find(t => t.id === f.token)?.zone, 'used');
|
||||||
|
assert.deepEqual(f.declaration.steps, [{ actor: f.owner, action: f.relocationAction }]);
|
||||||
|
assert.deepEqual(physical(f.declaration.before), f.originalPhysical, 'The selected packet has not relocated before matching');
|
||||||
|
assert.equal(f.game.active, f.entrant); assert.notEqual(f.game.active, f.owner);
|
||||||
|
assert.deepEqual(Object.keys(f.originalSource).sort(), ['advisors', 'destination', 'event', 'group', 'origin', 'owner', 'parent', 'phase', 'sector', 'turn']);
|
||||||
|
assert.equal(f.originalSource.owner, f.owner); assert.equal(f.originalSource.parent, f.event);
|
||||||
|
assert.equal(f.originalSource.advisors, f.direction === 'fightersToAdvisors');
|
||||||
|
assert.deepEqual(f.originalParent.receipt, f.originalSource);
|
||||||
|
assert.equal(f.originalParent.parentSignature, JSON.stringify(f.originalAmbassador));
|
||||||
|
assert.deepEqual(f.game.response, f.originalResponse); assert.equal(f.game.phase, 5); assert.equal(f.game.turn, 2);
|
||||||
|
assert.equal(f.game.pendingTreacheryDiscard ?? null, null); assert.equal(f.originalInventory.length, 45);
|
||||||
|
assert.deepEqual(inventory(f.game), f.originalInventory); assert.equal(f.sourceActionCount, f.actions.length);
|
||||||
|
}
|
||||||
|
function open(f: SemutaAdvisorAmbassadorMatchingFixture) {
|
||||||
|
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||||
|
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaAdvisorFlipDiscard');
|
||||||
|
if (pending.continuation.kind !== 'karamaAdvisorFlipDiscard') throw Error('Use the original precommit Ambassador matching cost');
|
||||||
|
assert.equal(pending.batch.cause, 'karama:advisorFlip'); assert.equal(pending.reaction?.stage, 'offer');
|
||||||
|
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||||
|
[{ card: f.cost, by: f.spender, face: true }]);
|
||||||
|
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||||
|
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||||
|
assert.deepEqual(game.pendingAdvisorAmbassadorMatching, f.originalParent);
|
||||||
|
assert.deepEqual(game.pendingAmbassador, f.originalAmbassador); assert.deepEqual(game.ecazAmbassadors, f.originalTokens);
|
||||||
|
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(player(game, f.owner).advisors, f.originalStance);
|
||||||
|
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||||
|
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||||
|
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||||
|
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||||
|
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||||
|
}
|
||||||
|
function canceled(game: Game, f: SemutaAdvisorAmbassadorMatchingFixture, claimed: boolean) {
|
||||||
|
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingAdvisorAmbassadorMatching, undefined);
|
||||||
|
assert.equal(game.response ?? null, null); assert.equal(game.decision?.kind, 'ecazAmbassador'); assert.equal(game.decision?.player, f.owner);
|
||||||
|
assert.deepEqual(game.pendingAmbassador, { ...f.originalAmbassador,
|
||||||
|
advisorMatchingDenied: [advisorAmbassadorMatchingKey(f.originalSource)] });
|
||||||
|
assert.deepEqual(game.ecazAmbassadors, f.originalTokens); assert.deepEqual(physical(game), f.originalPhysical);
|
||||||
|
assert.deepEqual(player(game, f.owner).advisors, f.originalStance);
|
||||||
|
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||||
|
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||||
|
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||||
|
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||||
|
reject(game, f.owner, f.relocationAction); reject(game, f.owner, { ...f.relocationAction, fighters: true });
|
||||||
|
reject(game, f.spender, f.action); assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||||
|
}
|
||||||
|
function alternate(game: Game, f: SemutaAdvisorAmbassadorMatchingFixture) {
|
||||||
|
const steps: { actor: string; action: Action }[] = [];
|
||||||
|
game = act(game, f.owner, f.alternate, steps); game = finish(game, steps);
|
||||||
|
assert.equal(game.pendingAmbassador ?? null, null); assert.equal(game.pendingAdvisorAmbassadorMatching, undefined);
|
||||||
|
assert.equal(player(game, f.owner).forces['tueks_sietch:5'], 1); assert.equal(player(game, f.owner).forces['polar_sink:0'], 1);
|
||||||
|
assert.equal(player(game, f.owner).forces['arrakeen:10'], 1); assert.equal(isAdvisor(player(game, f.owner), 'arrakeen'), true);
|
||||||
|
assert.equal(isAdvisor(player(game, f.owner), 'polar_sink'), false);
|
||||||
|
assert.deepEqual(allowances(game), allowances(f.game)); assert.deepEqual(game.ecazAmbassadors, f.originalTokens);
|
||||||
|
for (const row of physical(game).filter(p => p.id !== f.owner))
|
||||||
|
assert.deepEqual(row, f.originalPhysical.find(p => p.id === row.id));
|
||||||
|
assert.equal(game.log.filter(row => row.text.includes('through the Fremen Ambassador. Their ordinary')).length, 1);
|
||||||
|
assert.ok(steps.every(step => !['ship', 'move', 'card', 'endMovement'].includes(step.action.type)));
|
||||||
|
reject(game, f.owner, f.alternate); assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const variant of variants) for (const claimed of [false, true]) void test(
|
||||||
|
`Advanced native Fremen Ambassador ${variant.direction} matching cost Semuta ${claimed ? 'claim' : 'pass'} keeps its consumed trigger and permits one real legal alternate`, () => {
|
||||||
|
const f = fixture(variant); nativeSource(f); const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||||
|
const game = canceled(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||||
|
reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event });
|
||||||
|
alternate(game, f);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
void test('native Fremen Ambassador matching allowance relocates only the selected incoming packet and preserves resident type and ordinary allowances', () => {
|
||||||
|
for (const variant of variants) {
|
||||||
|
const f = fixture(variant); nativeSource(f); const game = finish(allow(structuredClone(f.game)));
|
||||||
|
assert.equal(game.pendingAmbassador ?? null, null); assert.equal(game.pendingAdvisorAmbassadorMatching, undefined);
|
||||||
|
assert.equal(player(game, f.owner).forces[f.from] ?? 0, player(f.game, f.owner).forces[f.from] - 1);
|
||||||
|
assert.equal(player(game, f.owner).forces[f.to], player(f.game, f.owner).forces[f.to] + 1);
|
||||||
|
assert.equal(isAdvisor(player(game, f.owner), f.originalSource.destination), f.direction === 'fightersToAdvisors');
|
||||||
|
assert.deepEqual(allowances(game), allowances(f.game)); assert.deepEqual(game.ecazAmbassadors, f.originalTokens);
|
||||||
|
for (const row of physical(game).filter(p => p.id !== f.owner))
|
||||||
|
assert.deepEqual(row, f.originalPhysical.find(p => p.id === row.id));
|
||||||
|
assert.equal(game.log.filter(row => row.text.includes('through the Fremen Ambassador. Their ordinary')).length, 1);
|
||||||
|
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||||
|
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||||
|
assert.deepEqual(inventory(game), f.originalInventory); reject(game, f.owner, f.relocationAction);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('fresh Fremen Ambassador matching cost rejects stale and underlying controls before any physical relocation', () => {
|
||||||
|
for (const variant of variants) {
|
||||||
|
const f = fixture(variant), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||||
|
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||||
|
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
|
||||||
|
reject(game, f.owner, { type: 'passResponse' }); reject(game, f.owner, f.relocationAction);
|
||||||
|
reject(game, f.owner, f.alternate); reject(game, f.entrant, { type: 'endMovement' });
|
||||||
|
assert.deepEqual(game.pendingAdvisorAmbassadorMatching, f.originalParent);
|
||||||
|
assert.deepEqual(game.pendingAmbassador, f.originalAmbassador); assert.deepEqual(physical(game), f.originalPhysical);
|
||||||
|
canceled(pass(game), f, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('controlled Fremen matching parent and source mismatches reject before cost and fresh Semuta appropriation', () => {
|
||||||
|
const f = fixture();
|
||||||
|
// Separate negative copies; the primary programme never installs a parent,
|
||||||
|
// receipt, army, stance or source event.
|
||||||
|
const changes: ((game: Game) => void)[] = [
|
||||||
|
game => { game.pendingAdvisorAmbassadorMatching!.signature += ':changed'; },
|
||||||
|
game => { game.pendingAdvisorAmbassadorMatching!.parentSignature += ':changed'; },
|
||||||
|
game => { game.pendingAdvisorAmbassadorMatching!.receipt.parent = `${f.event}:stale`; },
|
||||||
|
];
|
||||||
|
for (const change of changes) {
|
||||||
|
const beforeCost = structuredClone(f.game); change(beforeCost); reject(beforeCost, f.spender, f.action);
|
||||||
|
assert.ok(player(beforeCost, f.spender).hand.some(card => card.id === f.cost));
|
||||||
|
const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event; change(offered);
|
||||||
|
reject(offered, f.holder, { type: 'semutaCommit', event });
|
||||||
|
assert.ok(player(offered, f.holder).hand.some(card => card.id === f.semuta));
|
||||||
|
assert.equal(offered.discard.filter(card => card.id === f.cost).length, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('all four minimal profiles legally answer both native Ambassador matching used costs and propose an actual legal alternate', () => {
|
||||||
|
for (const variant of variants) {
|
||||||
|
const f = fixture(variant), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||||
|
for (const difficulty of DIFFICULTIES) {
|
||||||
|
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||||
|
const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty);
|
||||||
|
assert.deepEqual(own, before); assert.ok(action.type === 'semutaPass' || action.type === 'semutaCommit'); assert.equal(action.event, event);
|
||||||
|
let game = act(structuredClone(offered), f.holder, action); const claimed = action.type === 'semutaCommit';
|
||||||
|
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||||
|
const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||||
|
const selected = botActions(selecting)[0]; assert.ok(selected);
|
||||||
|
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost); game = act(game, f.holder, selected);
|
||||||
|
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||||
|
game = canceled(game, f, claimed);
|
||||||
|
const beneficiary = viewGame(game, f.owner); beneficiary.players.find(p => p.id === f.owner)!.bot = difficulty;
|
||||||
|
const proposed = botActions(beneficiary)[0]; assert.ok(proposed); assert.equal(proposed.type, 'decision');
|
||||||
|
assert.doesNotThrow(() => applyAction(game, f.owner, proposed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import { isAdvisor } from '../game/advisors';
|
||||||
|
import { botActions } from '../game/bots';
|
||||||
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||||
|
import {
|
||||||
|
createSemutaAdvisorGatherFixture as fixture, actSemutaAdvisorGather as act,
|
||||||
|
passSemutaAdvisorGather as pass, claimSemutaAdvisorGather as claim,
|
||||||
|
allowSemutaAdvisorGather as allow, finishSemutaAdvisorGather as finish,
|
||||||
|
semutaAdvisorGatherPlayer as player, semutaAdvisorGatherInventory as inventory,
|
||||||
|
semutaAdvisorGatherPhysical as physical, semutaAdvisorGatherSkillInventory as skills,
|
||||||
|
type SemutaAdvisorGatherFixture,
|
||||||
|
} from './fixture-semuta-advisor-gather';
|
||||||
|
|
||||||
|
function reject(game: Game, actor: string, action: Action) {
|
||||||
|
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||||
|
}
|
||||||
|
function nativeSource(f: SemutaAdvisorGatherFixture) {
|
||||||
|
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||||
|
assert.deepEqual(f.setup.expansions, ['choam']); assert.ok(f.setup.leaderSkills);
|
||||||
|
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
|
||||||
|
assert.deepEqual(f.game.players.map(player => player.faction), ['richese', 'choam', 'beneGesserit', 'atreides', 'emperor']);
|
||||||
|
assert.equal(f.skillChoices.length, 5); assert.ok(f.skillOffers && f.advisorSetup);
|
||||||
|
assert.ok(f.skillChoices.some(choice => choice.steps.some(step => step.actor === f.owner && step.action.type === 'leaderSkill' &&
|
||||||
|
step.action.skill === f.skill && step.action.leader === f.trainer)));
|
||||||
|
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||||
|
assert.equal(player(f.afterSetup, f.owner).forces[f.location], 1);
|
||||||
|
assert.equal(player(f.afterSetup, f.spender).forces[f.location], 10);
|
||||||
|
assert.equal(isAdvisor(player(f.afterSetup, f.owner), f.territory), true);
|
||||||
|
assert.equal(f.acquisition.price, 1);
|
||||||
|
assert.ok(f.acquisition.steps.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||||
|
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
|
||||||
|
assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
|
||||||
|
assert.deepEqual(f.funding.steps, [{ actor: f.emperor, action: f.fundingAction }]);
|
||||||
|
assert.equal(player(f.firstShipment.before, f.owner).spice - player(f.firstShipment.after, f.owner).spice, 4);
|
||||||
|
assert.equal(player(f.secondShipment.before, f.owner).spice - player(f.secondShipment.after, f.owner).spice, 2);
|
||||||
|
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.owner);
|
||||||
|
assert.equal(player(f.game, f.owner).shipped, true); assert.equal(player(f.game, f.owner).moved, 0);
|
||||||
|
assert.equal(player(f.game, f.owner).forces['imperial_basin:9'], 2);
|
||||||
|
assert.equal(player(f.game, f.owner).forces['oh_gap:9'], 1);
|
||||||
|
assert.equal(isAdvisor(player(f.game, f.owner), 'imperial_basin'), false);
|
||||||
|
assert.equal(isAdvisor(player(f.game, f.owner), 'oh_gap'), false);
|
||||||
|
assert.deepEqual(f.declaration.steps, [{ actor: f.owner, action: f.nativeAction }]);
|
||||||
|
assert.deepEqual(f.originalParent.receipts.map(source => source.origin), ['imperial_basin', 'oh_gap']);
|
||||||
|
assert.equal(f.originalParent.index, f.position === 'first' ? 0 : 1);
|
||||||
|
assert.deepEqual(f.originalParent.approved, f.position === 'first' ? [] : [f.firstSource.event]);
|
||||||
|
assert.equal(f.originalSource.origin, f.position === 'first' ? 'imperial_basin' : 'oh_gap');
|
||||||
|
assert.deepEqual(f.originalParent.receipts[f.originalParent.index], f.originalSource);
|
||||||
|
assert.deepEqual(f.game.response, f.originalResponse);
|
||||||
|
assert.equal(f.originalResponse.advisorGatherMatchingPosition?.current, f.position === 'first' ? 1 : 2);
|
||||||
|
assert.equal(f.originalResponse.advisorGatherMatchingPosition?.total, 2);
|
||||||
|
if (f.position === 'second') {
|
||||||
|
assert.ok(f.firstAllowance);
|
||||||
|
const response = f.firstAllowance.before.response; assert.ok(response);
|
||||||
|
const passers = f.firstAllowance.steps.map(step => step.actor);
|
||||||
|
assert.ok(passers.length > 0); assert.equal(new Set(passers).size, passers.length);
|
||||||
|
assert.ok(passers.every(actor => f.firstAllowance!.before.players.some(player => player.id === actor)));
|
||||||
|
assert.ok(f.firstAllowance.steps.every(step => step.action.type === 'passResponse'));
|
||||||
|
assert.deepEqual(physical(f.firstAllowance.before), physical(f.firstAllowance.after));
|
||||||
|
} else assert.equal(f.firstAllowance, null);
|
||||||
|
assert.deepEqual(physical(f.game), f.originalPhysical);
|
||||||
|
assert.deepEqual(physical(f.beforeDeclaration), f.originalPhysical);
|
||||||
|
assert.equal(f.sourceActionCount, f.actions.length); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||||
|
assert.deepEqual(skills(f.game), f.originalSkillInventory);
|
||||||
|
}
|
||||||
|
function open(f: SemutaAdvisorGatherFixture) {
|
||||||
|
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||||
|
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaAdvisorFlipDiscard');
|
||||||
|
assert.equal(pending.reaction?.stage, 'offer'); assert.equal(pending.batch.cause, 'karama:advisorFlip');
|
||||||
|
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||||
|
[{ card: f.cost, by: f.spender, face: true }]);
|
||||||
|
if (pending.continuation.kind !== 'karamaAdvisorFlipDiscard') throw Error('Use the original native advisor-flip cost');
|
||||||
|
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||||
|
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||||
|
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||||
|
assert.deepEqual(game.pendingAdvisorGatherMatching, f.originalParent);
|
||||||
|
assert.deepEqual(physical(game), f.originalPhysical);
|
||||||
|
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||||
|
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||||
|
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||||
|
assert.deepEqual(inventory(game), f.originalInventory); assert.deepEqual(skills(game), f.originalSkillInventory); return game;
|
||||||
|
}
|
||||||
|
function prevented(game: Game, f: SemutaAdvisorGatherFixture, claimed: boolean) {
|
||||||
|
game = finish(game);
|
||||||
|
assert.equal(game.pendingAdvisorGatherMatching, undefined);
|
||||||
|
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
|
||||||
|
assert.deepEqual(physical(game), f.originalPhysical);
|
||||||
|
assert.deepEqual(game.advisorMatchingDenied, [...f.originalDenials, f.expectedDenial]);
|
||||||
|
assert.equal(player(game, f.owner).advisorFlipMovementBlocked, undefined);
|
||||||
|
assert.equal(game.log.filter(row => row.automatic?.name === 'Gathered group matching prevented').length,
|
||||||
|
f.originalHistory.filter(row => row.automatic?.name === 'Gathered group matching prevented').length + 1);
|
||||||
|
reject(game, f.owner, f.nativeAction);
|
||||||
|
reject(game, f.owner, { ...f.nativeAction, forces: Object.fromEntries(Object.entries(f.forces).reverse()) });
|
||||||
|
reject(game, f.owner, { type: 'move', forces: f.originalSource.group, territory: f.territory, sector: 10 });
|
||||||
|
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||||
|
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||||
|
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||||
|
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||||
|
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||||
|
assert.deepEqual(inventory(game), f.originalInventory); assert.deepEqual(skills(game), f.originalSkillInventory); return game;
|
||||||
|
}
|
||||||
|
function replaceOriginalMove(game: Game, f: SemutaAdvisorGatherFixture) {
|
||||||
|
const before = structuredClone(physical(game)), wallet = player(game, f.owner).spice;
|
||||||
|
const result = finish(act(game, f.owner, f.replacement));
|
||||||
|
assert.equal(player(result, f.owner).moved, 1); assert.equal(player(result, f.owner).shipped, true);
|
||||||
|
assert.equal(player(result, f.owner).spice, wallet);
|
||||||
|
assert.equal(player(result, f.owner).forces['imperial_basin:9'], 1);
|
||||||
|
assert.equal(player(result, f.owner).forces['oh_gap:9'], 2);
|
||||||
|
assert.equal(player(result, f.owner).forces[f.location], 1);
|
||||||
|
assert.equal(isAdvisor(player(result, f.owner), f.territory), true);
|
||||||
|
assert.deepEqual(result.players.filter(player => player.id !== f.owner).map(player =>
|
||||||
|
({ id: player.id, spice: player.spice, forces: player.forces, reserves: player.reserves })),
|
||||||
|
before.players.filter(player => player.id !== f.owner).map(player =>
|
||||||
|
({ id: player.id, spice: player.spice, forces: player.forces, reserves: player.reserves })));
|
||||||
|
assert.deepEqual(inventory(result), f.originalInventory); assert.deepEqual(skills(result), f.originalSkillInventory); return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const position of ['first', 'second'] as const) for (const claimed of [false, true]) void test(
|
||||||
|
`Advanced native ${position} gather matching used cancellation cost ${claimed ? 'claim' : 'pass'} preserves both origins and permits one legal replacement`, () => {
|
||||||
|
const f = fixture({ position }); nativeSource(f);
|
||||||
|
const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||||
|
const game = prevented(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||||
|
reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event });
|
||||||
|
replaceOriginalMove(game, f);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const position of ['first', 'second'] as const) void test(
|
||||||
|
`Advanced native ${position} gather matching allowance commits the original two-origin packet once after every group approval`, () => {
|
||||||
|
const f = fixture({ position }); nativeSource(f);
|
||||||
|
const game = allow(structuredClone(f.game));
|
||||||
|
assert.equal(game.pendingAdvisorGatherMatching, undefined); assert.equal(player(game, f.owner).moved, 1);
|
||||||
|
assert.equal(player(game, f.owner).forces[f.location], 3);
|
||||||
|
assert.equal(player(game, f.owner).forces['imperial_basin:9'], 1);
|
||||||
|
assert.equal(player(game, f.owner).forces['oh_gap:9'] ?? 0, 0);
|
||||||
|
assert.equal(isAdvisor(player(game, f.owner), f.territory), true);
|
||||||
|
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice);
|
||||||
|
assert.equal(player(game, f.owner).shipped, true);
|
||||||
|
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||||
|
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||||
|
assert.deepEqual(inventory(game), f.originalInventory); assert.deepEqual(skills(game), f.originalSkillInventory);
|
||||||
|
const before = structuredClone(game); assert.equal(finish(game), game); assert.deepEqual(game, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const position of ['first', 'second'] as const) void test(
|
||||||
|
`fresh ${position} gather matching costs reject stale and underlying controls while original approvals stay parked`, () => {
|
||||||
|
const f = fixture({ position }), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||||
|
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||||
|
reject(game, f.spender, { type: 'semutaCommit', event });
|
||||||
|
reject(game, f.spender, f.action); reject(game, f.owner, { type: 'passResponse' });
|
||||||
|
reject(game, f.owner, f.nativeAction); reject(game, f.owner, f.replacement);
|
||||||
|
reject(game, f.owner, { type: 'endMovement' });
|
||||||
|
assert.deepEqual(game.pendingAdvisorGatherMatching, f.originalParent);
|
||||||
|
assert.deepEqual(physical(game), f.originalPhysical); prevented(pass(game), f, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('unearned gather approval or changed original signature rejects before cost and before a fresh Semuta answer', () => {
|
||||||
|
const f = fixture({ position: 'second' });
|
||||||
|
for (const change of ['signature', 'approval'] as const) {
|
||||||
|
const game = structuredClone(f.game); assert.ok(game.pendingAdvisorGatherMatching);
|
||||||
|
if (change === 'signature') game.pendingAdvisorGatherMatching.signature += ':changed';
|
||||||
|
else game.pendingAdvisorGatherMatching.approved[0] = 'unearned';
|
||||||
|
reject(game, f.spender, f.action);
|
||||||
|
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||||
|
}
|
||||||
|
const offered = open(f); assert.ok(offered.pendingAdvisorGatherMatching);
|
||||||
|
offered.pendingAdvisorGatherMatching.approved[0] = 'unearned';
|
||||||
|
reject(offered, f.holder, { type: 'semutaCommit', event: offered.pendingTreacheryDiscard!.batch.event });
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('all four minimal policies answer both actual native gather matching costs legally', () => {
|
||||||
|
for (const position of ['first', 'second'] as const) {
|
||||||
|
const f = fixture({ position }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||||
|
for (const difficulty of DIFFICULTIES) {
|
||||||
|
const own = viewGame(offered, f.holder); own.players.find(player => player.id === f.holder)!.bot = difficulty;
|
||||||
|
const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty);
|
||||||
|
assert.deepEqual(own, before);
|
||||||
|
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass'); assert.equal(action.event, event);
|
||||||
|
let game = act(structuredClone(offered), f.holder, action);
|
||||||
|
const claimed = action.type === 'semutaCommit';
|
||||||
|
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||||
|
const choosing = viewGame(game, f.holder); choosing.players.find(player => player.id === f.holder)!.bot = difficulty;
|
||||||
|
const selected = botActions(choosing)[0]; assert.ok(selected);
|
||||||
|
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
|
||||||
|
game = act(game, f.holder, selected);
|
||||||
|
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||||
|
replaceOriginalMove(prevented(game, f, claimed), f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import { botActions } from '../game/bots';
|
||||||
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||||
|
import { isAdvisor } from '../game/advisors';
|
||||||
|
import { advisorTransportMatchingKey } from '../game/advisor-transport-matching';
|
||||||
|
import { guildTransportQuote } from '../game/transport-quote';
|
||||||
|
import { junctionTransportChoice } from '../game/junction-transport-options';
|
||||||
|
import type { HomeworldShipmentSources } from '../game/homeworld-shipment-options';
|
||||||
|
import {
|
||||||
|
createSemutaAdvisorTransportMatchingFixture as fixture, actSemutaAdvisorTransportMatching as act,
|
||||||
|
passSemutaAdvisorTransportMatching as pass, claimSemutaAdvisorTransportMatching as claim,
|
||||||
|
allowSemutaAdvisorTransportMatching as allow, finishSemutaAdvisorTransportMatching as finish,
|
||||||
|
semutaAdvisorTransportMatchingPlayer as player, semutaAdvisorTransportMatchingInventory as inventory,
|
||||||
|
semutaAdvisorTransportMatchingPhysical as physical, type SemutaAdvisorTransportMatchingFixture,
|
||||||
|
} from './fixture-semuta-advisor-transport-matching';
|
||||||
|
|
||||||
|
const kinds = ['guild', 'junction'] as const, directions = ['fightersToAdvisors', 'advisorsToFighters'] as const;
|
||||||
|
const clock = (game: Game) => ({ turn: game.turn, phase: game.phase, active: game.active, ready: game.ready,
|
||||||
|
order: game.order, movementRemaining: game.movementRemaining, storm: game.storm,
|
||||||
|
spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
|
||||||
|
guildTimingGranted: game.guildTimingGranted, guildTimingLocked: game.guildTimingLocked });
|
||||||
|
function reject(game: Game, actor: string, action: Action) {
|
||||||
|
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||||
|
}
|
||||||
|
function nativeSource(f: SemutaAdvisorTransportMatchingFixture) {
|
||||||
|
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||||
|
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||||
|
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
|
||||||
|
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||||
|
assert.equal(player(f.afterSetup, f.owner).forces['arrakeen:10'], 1);
|
||||||
|
assert.equal(player(f.afterSetup, f.spender).forces['arrakeen:10'], 10);
|
||||||
|
assert.equal(player(f.afterSetup, f.guild).forces['tueks_sietch:5'], 5);
|
||||||
|
assert.equal(isAdvisor(player(f.afterSetup, f.owner), 'arrakeen'), true);
|
||||||
|
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
|
||||||
|
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquired, f.holder).spice, 1);
|
||||||
|
assert.equal(player(f.acquired, f.seller).spice - player(f.acquisition.before, f.seller).spice, 1);
|
||||||
|
assert.equal(player(f.fighterShipment.before, f.owner).spice - player(f.fighterShipment.after, f.owner).spice, 4);
|
||||||
|
assert.deepEqual(f.fighterShipment.steps[0], { actor: f.owner, action: {
|
||||||
|
type: 'ship', territory: 'imperial_basin', sector: 9, amount: 2, elite: 0, allyPayment: 0,
|
||||||
|
} });
|
||||||
|
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.owner);
|
||||||
|
assert.equal(player(f.game, f.owner).forces['arrakeen:10'], 1);
|
||||||
|
assert.equal(player(f.game, f.owner).forces['imperial_basin:9'], 2);
|
||||||
|
assert.equal(isAdvisor(player(f.game, f.owner), 'arrakeen'), true);
|
||||||
|
assert.equal(isAdvisor(player(f.game, f.owner), 'imperial_basin'), false);
|
||||||
|
assert.equal(player(f.game, f.owner).shipped, false); assert.equal(player(f.game, f.owner).moved, 0);
|
||||||
|
if (f.kind === 'guild') {
|
||||||
|
assert.equal(f.enableHomeworlds, null); assert.equal(f.game.homeworlds ?? null, null);
|
||||||
|
assert.ok(f.nexus?.nexus && f.beforeWorm && f.wormOrder && f.alliance);
|
||||||
|
assert.deepEqual(f.alliance.steps, [{ actor: f.owner, action: { type: 'alliance', target: f.guild } },
|
||||||
|
{ actor: f.guild, action: { type: 'alliance', target: f.owner } }]);
|
||||||
|
assert.equal(player(f.game, f.owner).ally, f.guild); assert.equal(player(f.game, f.guild).ally, f.owner);
|
||||||
|
} else {
|
||||||
|
assert.ok(f.enableHomeworlds && f.junctionOffer && f.offerStep);
|
||||||
|
assert.deepEqual(f.enableHomeworlds.steps, [{ actor: f.game.host, action: { type: 'homeworlds', enabled: true } }]);
|
||||||
|
assert.equal(f.setup.homeworlds?.custody, null); assert.ok(f.afterSetup.homeworlds?.custody);
|
||||||
|
assert.equal(f.nexus, null); assert.equal(f.alliance, null); assert.equal(player(f.game, f.owner).ally ?? null, null);
|
||||||
|
assert.deepEqual(f.junctionOffer.steps, [f.offerStep]); assert.ok(f.originalOffer);
|
||||||
|
assert.equal(f.originalOffer.recipient, f.owner);
|
||||||
|
}
|
||||||
|
assert.equal(f.game.nexusCards ?? null, null);
|
||||||
|
assert.deepEqual(f.declaration.steps, [{ actor: f.owner, action: f.nativeAction }]);
|
||||||
|
assert.deepEqual(f.game.response, f.originalResponse); assert.equal(f.game.response?.kind, 'advisorFlip');
|
||||||
|
assert.deepEqual(f.game.pendingAdvisorTransportMatching, f.originalParent);
|
||||||
|
assert.equal(f.originalParent.intent.kind, f.kind); assert.equal(f.originalParent.signature, JSON.stringify(f.originalParent.intent));
|
||||||
|
assert.deepEqual(f.originalParent.receipt, f.originalSource); assert.equal(f.originalParent.opportunity, f.opportunity);
|
||||||
|
assert.equal(f.originalSource.owner, f.owner); assert.equal(f.originalSource.producer, f.kind);
|
||||||
|
assert.equal(f.originalSource.origin, f.origin); assert.equal(f.originalSource.destination, f.destination);
|
||||||
|
assert.deepEqual(f.originalSource.group, { [f.sourceLocation]: 1 });
|
||||||
|
assert.equal(f.originalSource.advisors, f.direction === 'fightersToAdvisors');
|
||||||
|
assert.equal(f.denialKey, advisorTransportMatchingKey(f.originalSource, f.opportunity));
|
||||||
|
assert.equal(f.game.pendingGuildTransport ?? null, null); assert.equal(f.game.pendingHomeworldShipment ?? null, null);
|
||||||
|
assert.deepEqual(physical(f.game), physical(f.beforeDeclaration)); assert.equal(f.price, 1);
|
||||||
|
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta));
|
||||||
|
assert.ok(player(f.game, f.spender).hand.some(card => card.id === f.cost));
|
||||||
|
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||||
|
assert.equal(f.sourceActionCount, f.actions.length);
|
||||||
|
}
|
||||||
|
function open(f: SemutaAdvisorTransportMatchingFixture) {
|
||||||
|
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||||
|
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaAdvisorFlipDiscard');
|
||||||
|
if (pending.continuation.kind !== 'karamaAdvisorFlipDiscard') throw Error('Expected the original native transport matching cost');
|
||||||
|
assert.equal(pending.batch.cause, 'karama:advisorFlip');
|
||||||
|
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||||
|
[{ card: f.cost, by: f.spender, face: true }]);
|
||||||
|
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||||
|
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||||
|
assert.deepEqual(game.pendingAdvisorTransportMatching, f.originalParent);
|
||||||
|
assert.deepEqual(game.advisorTransportMatchingDenied ?? [], f.originalDenied);
|
||||||
|
assert.deepEqual(game.junctionOffer ?? null, f.originalOffer);
|
||||||
|
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game));
|
||||||
|
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||||
|
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||||
|
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||||
|
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||||
|
}
|
||||||
|
function complete(game: Game, actions: { actor: string; action: Action }[] = []) {
|
||||||
|
for (let count = 0; count < 6 && game.response &&
|
||||||
|
(game.response.advisorTransportMatchingSource || ['guildTransportPermission', 'guildRate'].includes(game.response.kind)); count++)
|
||||||
|
game = allow(game, actions);
|
||||||
|
return finish(game, actions);
|
||||||
|
}
|
||||||
|
function denied(state: Game, f: SemutaAdvisorTransportMatchingFixture, claimed: boolean) {
|
||||||
|
const game = finish(state); assert.equal(game.pendingAdvisorTransportMatching ?? null, null);
|
||||||
|
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||||
|
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game));
|
||||||
|
assert.deepEqual(game.junctionOffer ?? null, f.originalOffer);
|
||||||
|
assert.deepEqual(game.advisorTransportMatchingDenied, [...f.originalDenied, { owner: f.owner, turn: game.turn, key: f.denialKey }]);
|
||||||
|
assert.deepEqual(game.guildTransportBlocked ?? null, f.game.guildTransportBlocked ?? null);
|
||||||
|
assert.deepEqual(game.guildRateBlocked ?? null, f.game.guildRateBlocked ?? null);
|
||||||
|
assert.equal(player(game, f.owner).shipped, false); assert.equal(player(game, f.owner).moved, 0);
|
||||||
|
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||||
|
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||||
|
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||||
|
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||||
|
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||||
|
const view = viewGame(game, f.owner); assert.equal(view.advisorTransportMatchingDenied?.length, 1);
|
||||||
|
if (f.kind === 'guild') assert.ok(guildTransportQuote(view, f.nativeAction).unavailableReasons.some(reason => /prevented/.test(reason)));
|
||||||
|
else assert.match(junctionTransportChoice(view, f.destinationLocation, f.nativeAction.sources as HomeworldShipmentSources).blocked ?? '', /prevented/);
|
||||||
|
reject(game, f.owner, f.nativeAction); reject(game, f.spender, f.action);
|
||||||
|
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||||
|
}
|
||||||
|
function transferred(game: Game, f: SemutaAdvisorTransportMatchingFixture, to = f.destinationLocation, amount = 1) {
|
||||||
|
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingAdvisorTransportMatching ?? null, null);
|
||||||
|
assert.equal(game.pendingGuildTransport ?? null, null); assert.equal(game.pendingHomeworldShipment ?? null, null);
|
||||||
|
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null); assert.deepEqual(clock(game), clock(f.game));
|
||||||
|
const owner = player(game, f.owner), original = player(f.game, f.owner);
|
||||||
|
assert.equal(owner.forces[f.sourceLocation] ?? 0, original.forces[f.sourceLocation] - amount);
|
||||||
|
assert.equal(owner.forces[to], (original.forces[to] ?? 0) + amount);
|
||||||
|
assert.equal(owner.spice, original.spice - f.price); assert.equal(owner.reserves, original.reserves); assert.equal(owner.tanks, original.tanks);
|
||||||
|
assert.equal(owner.shipped, true); assert.equal(owner.moved, 0);
|
||||||
|
assert.equal(isAdvisor(owner, to.split(':')[0]), to === f.destinationLocation ? f.originalSource.advisors : f.direction === 'advisorsToFighters');
|
||||||
|
assert.deepEqual(game.homeworlds ?? null, f.game.homeworlds ?? null);
|
||||||
|
for (const p of game.players.filter(p => p.id !== f.owner)) {
|
||||||
|
const prior = player(f.game, p.id); assert.deepEqual(p.forces, prior.forces);
|
||||||
|
assert.equal(p.reserves, prior.reserves); assert.equal(p.tanks, prior.tanks);
|
||||||
|
assert.equal(p.spice, prior.spice + Number(f.kind === 'guild' && p.id === f.guild) * f.price);
|
||||||
|
}
|
||||||
|
if (f.kind === 'junction') assert.equal(game.junctionOffer ?? null, null);
|
||||||
|
reject(game, f.owner, f.nativeAction); assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const kind of kinds) for (const direction of directions) for (const claimed of [false, true]) void test(
|
||||||
|
`native ${kind} ${direction} matching Karama cost Semuta ${claimed ? 'claim' : 'pass'} keeps payment and packet uncommitted before a real replacement`, () => {
|
||||||
|
const f = fixture({ kind, direction }); nativeSource(f); const offered = open(f);
|
||||||
|
const game = denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||||
|
transferred(complete(act(game, f.replacementActor, f.replacementAction)), f, f.replacementLocation);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
void test('native Guild and Junction matching allowance keeps original permission rate and payment in their real order', () => {
|
||||||
|
for (const kind of kinds) for (const direction of directions) {
|
||||||
|
const f = fixture({ kind, direction }); nativeSource(f);
|
||||||
|
let game = allow(structuredClone(f.game));
|
||||||
|
if (kind === 'guild') {
|
||||||
|
assert.equal(game.response?.kind, 'guildTransportPermission'); assert.deepEqual(physical(game), f.originalPhysical);
|
||||||
|
game = allow(game);
|
||||||
|
assert.ok(game.response?.kind === 'guildRate' || game.response?.kind === 'guildIncome');
|
||||||
|
if (game.response.kind === 'guildRate') assert.deepEqual(physical(game), f.originalPhysical);
|
||||||
|
else {
|
||||||
|
assert.equal(player(game, f.owner).shipped, true);
|
||||||
|
assert.equal(player(game, f.owner).forces[f.destinationLocation], player(f.game, f.owner).forces[f.destinationLocation] + 1);
|
||||||
|
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice - f.price);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const steps: { actor: string; action: Action }[] = [], done = transferred(complete(game, steps), f);
|
||||||
|
assert.ok(steps.every(step => step.action.type === 'passResponse' || step.action.type === 'decision'));
|
||||||
|
assert.ok(player(done, f.spender).hand.some(card => card.id === f.cost));
|
||||||
|
assert.ok(player(done, f.holder).hand.some(card => card.id === f.semuta));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native transport denial permits the larger actual two-fighter matching packet in the same unused opportunity', () => {
|
||||||
|
for (const kind of kinds) {
|
||||||
|
const f = fixture({ kind }), game = denied(pass(open(f)), f, false); assert.ok(f.largerPacket);
|
||||||
|
const done = complete(act(game, f.owner, f.largerPacket));
|
||||||
|
assert.equal(player(done, f.owner).forces['arrakeen:10'], 3);
|
||||||
|
assert.equal(player(done, f.owner).forces['imperial_basin:9'] ?? 0, 0);
|
||||||
|
assert.equal(player(done, f.owner).spice, player(f.game, f.owner).spice - f.largerQuote!.price);
|
||||||
|
assert.equal(player(done, f.owner).shipped, true); assert.equal(player(done, f.owner).moved, 0);
|
||||||
|
assert.deepEqual(done.advisorTransportMatchingDenied, game.advisorTransportMatchingDenied);
|
||||||
|
assert.deepEqual(inventory(done), f.originalInventory);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native transport matching cost rejects stale foreign and underlying declarations while its exact cost is offered', () => {
|
||||||
|
for (const kind of kinds) for (const direction of directions) {
|
||||||
|
const f = fixture({ kind, direction }), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||||
|
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||||
|
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
|
||||||
|
reject(game, f.owner, f.nativeAction); reject(game, f.owner, f.replacementAction);
|
||||||
|
reject(game, f.owner, { type: 'passResponse' }); reject(game, f.owner, { type: 'endMovement' });
|
||||||
|
const done = denied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('controlled changed private transport intent or opportunity rejects the original matching cost before spending', () => {
|
||||||
|
for (const kind of kinds) for (const field of ['signature', 'opportunity'] as const) {
|
||||||
|
const f = fixture({ kind }), game = structuredClone(f.game);
|
||||||
|
// Explicit malformed-receipt supplement only; the native primary source
|
||||||
|
// has no installed hand, force, wallet, stance, alliance or grant.
|
||||||
|
game.pendingAdvisorTransportMatching![field] += ':changed';
|
||||||
|
reject(game, f.spender, f.action); assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('all four minimal profiles legally claim or pass real Guild and Junction matching costs in both type directions', () => {
|
||||||
|
for (const kind of kinds) for (const direction of directions) {
|
||||||
|
const f = fixture({ kind, direction }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||||
|
for (const difficulty of DIFFICULTIES) {
|
||||||
|
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||||
|
const proposed = botActions(own)[0]; assert.ok(proposed);
|
||||||
|
assert.ok(proposed.type === 'semutaCommit' || proposed.type === 'semutaPass'); assert.equal(proposed.event, event);
|
||||||
|
let game = act(structuredClone(offered), f.holder, proposed); const claimed = proposed.type === 'semutaCommit';
|
||||||
|
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||||
|
const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||||
|
const selected = botActions(selecting)[0]; assert.ok(selected);
|
||||||
|
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost); game = act(game, f.holder, selected);
|
||||||
|
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||||
|
denied(game, f, claimed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user