Offer Semuta on Spiritual Advisor and relocation costs
This commit is contained in:
1 parent
92f2e8125b
commit
81fbbca309
12 files changed
+1559
-10
No files matched your search
@@ -16,6 +16,41 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Semuta on Spiritual Advisor and relocation costs — 26 October. Prototyped / Partial.**
|
||||
Existing terminal/advisor cost continuations now retain ordinary Spiritual
|
||||
shipping, matching and committed-arrival parents, plus Ambassador/Discovery
|
||||
relocation. Pure native validators own each distinct denial and original suffix.
|
||||
Shipping prevention spends no free reserve; matching prevention restores its
|
||||
chooser; alone-conversion prevention retains the placed advisor. Conditional
|
||||
signatures bind only these new parents. Existing persistent controls/minimal
|
||||
bots serve the sources; no rule calculation or mode gate changes.
|
||||
|
||||
Three disjoint source lanes and root integration pass **13 files,163/163**,
|
||||
types/lint/build and **ten native engine/GameTable SSR/actual Semuta-callback
|
||||
journeys** on unchanged source. Twenty-six new cases cover five genuine sources,
|
||||
claim/pass, allowance, legal replacement, original continuation, stale/source
|
||||
rejection and four minimal profiles. Original Advanced setup, actual cache1 and
|
||||
dealt Karama supply every45-card source. Spiritual88/92 keeps A's real paid1
|
||||
shipment; matching denial really permits the alternative accompaniment or
|
||||
decline. Arrival93 actually withdraws/places BG1 at empty Polar before conversion,
|
||||
retains its lock/hold and then drains the original paid entry. Ambassador89
|
||||
uses the original Fremen packet, paid token/entrant and actual Ecaz6 relocation.
|
||||
Discovery114 uses actual Cistern placement, inspect/reveal and next-turn A1 free
|
||||
entry. No played hand, force, stance, wallet, clock, receipt or parent is installed.
|
||||
|
||||
Narrow feedback fixes absent-field and Discovery revealed-turn assertions.
|
||||
The private runtime harness then learns that an uncancellable replacement may
|
||||
settle immediately; only that harness is repaired/rerun. Application source stays
|
||||
identical across the passed quick/test/build proof and runtime-only companion.
|
||||
Private reports: `20261026-semuta-spiritual-relocation-proof/` and
|
||||
`20261026-semuta-spiritual-relocation-runtime-proof/` under
|
||||
`/home/quintine/.local/share/dune/checkpoints/`; journeys/HTML: adjacent
|
||||
`20261026-semuta-spiritual-relocation-runtime/`.
|
||||
Other matching/grant parents, combinations, complete modes and final assurance
|
||||
remain open. No 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#spiritual-advisor-and-relocation-costs--26-october-2026).
|
||||
|
||||
**Semuta on Homeworld, battle and Face Dance conversions — 26 October. Prototyped / Partial.**
|
||||
Existing `karamaAdvisorFlipDiscard` now offers exact printed-role costs after
|
||||
committed typed Homeworld transport, completed battle losses and completed Face
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 26 October 2026 — Semuta on Spiritual Advisor and relocation costs
|
||||
|
||||
**Prototyped / Partial.** Existing terminal/advisor cost continuations now retain
|
||||
ordinary Spiritual shipping, matching and placed-arrival sources, plus completed
|
||||
Ambassador/Discovery relocation. Exact native parent validation preserves the
|
||||
paid foreign entry, unspent free reserve or already deposited grant/transfer as
|
||||
appropriate. Denial resumes the original distinct choice/arrival once. Conditional
|
||||
signatures preserve earlier formats; existing persistent controls/minimal bots
|
||||
serve these sources without changing mode gates or native calculations.
|
||||
[Source and scope](SEMUTA_SOURCE_UPDATE_20260907.md#spiritual-advisor-and-relocation-costs--26-october-2026).
|
||||
|
||||
Three disjoint source lanes and root integration pass **13 files,163/163**,
|
||||
types/lint/build and **ten native engine/GameTable SSR/actual Semuta-callback
|
||||
journeys** on unchanged source. Twenty-six new cases cover five genuine sources,
|
||||
claim/pass, allowance, restored legal accompaniment/decline, source/stale rejection
|
||||
and four minimal profiles. Spiritual88/92 retains actual A paid1; arrival93
|
||||
actually withdraws/places Polar1 before conversion. Ambassador89 earns its native
|
||||
Fremen packet, paid token/entrant and Ecaz6 relocation; Discovery114 earns actual
|
||||
Cistern placement/reveal and next-turn A1 free entry. All45 cards stay physical;
|
||||
no played hand, force, stance, wallet, clock, outcome or parent is installed.
|
||||
|
||||
Feedback repairs two fixture assertions: absent phase-opening value and the
|
||||
separate Discovery revealed-turn field. A private runtime-only rerun corrects
|
||||
the harness's assumption that every replacement must pause for a counter; the
|
||||
original passed quick/test/build source remains identical. Private reports live
|
||||
under `/home/quintine/.local/share/dune/checkpoints/` in
|
||||
`20261026-semuta-spiritual-relocation-proof/` and its
|
||||
`20261026-semuta-spiritual-relocation-runtime-proof/` companion; journeys/HTML are
|
||||
in adjacent `20261026-semuta-spiritual-relocation-runtime/`.
|
||||
Other matching/grant parents, combinations and complete-mode/final assurance stay
|
||||
open. These results do not certify DOM/hydration, browser/mobile or deployment.
|
||||
No saved-room/D1/server, reset/restart, deployment or recovery operation occurs.
|
||||
|
||||
## 26 October 2026 — Semuta on World, battle and Face Dance conversion costs
|
||||
|
||||
**Prototyped / Partial.** Existing advisor cost continuation retains committed
|
||||
|
||||
@@ -2,6 +2,88 @@
|
||||
|
||||
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.
|
||||
|
||||
## Spiritual Advisor and relocation costs — 26 October 2026
|
||||
|
||||
**Prototyped / Partial.** Existing `karamaTerminalDiscard` admits the original
|
||||
ordinary Spiritual shipping cost; `karamaAdvisorFlipDiscard` admits its distinct
|
||||
matching/committed-arrival costs and completed Ambassador/Discovery relocation
|
||||
conversion costs. Existing persistent neutral controls, claim/pass and minimal
|
||||
legal profiles serve these sources. This extends the recorded
|
||||
[Spiritual contract](SPIRITUAL_ADVISORS.md) and
|
||||
[automatic-conversion contract](ADVISOR_AUTOMATIC_FLIPS.md), without new rulings,
|
||||
native calculations, public starts or mode/publication admission. Continuously-
|
||||
alone duration retains the existing provisional policy.
|
||||
|
||||
The focused [paid-parent qualifier](../game/semuta-spiritual-arrival.ts) checks
|
||||
the actual original nine-field ordinary shipment entry, its current shipper,
|
||||
destination and deposited counters. Native frame/source validators separately
|
||||
bind the owned chooser, exact selected packet, stage and deposit. No foreign
|
||||
shipment, grant, relocation, entry or resource quote executes in preflight.
|
||||
Spiritual exceptions apply only to this ordinary parent; Guild Ambassador,
|
||||
revival and broader module/grant combinations are not certified here. The
|
||||
relocation exception admits only the original source-owned Ambassador or
|
||||
Discovery child. Conditional signature additions bind these new frames while
|
||||
leaving earlier serialized receipt formats unchanged.
|
||||
|
||||
After fresh claim/pass, native denial runs once:
|
||||
|
||||
- Shipping retains the already paid foreign shipment and ends the free packet
|
||||
before withdrawing a BG reserve.
|
||||
- Matching retains the paid parent and all residents, restores the original
|
||||
chooser and blocks the exact denied packet. The actual other legal target or
|
||||
decline remains available. Changing labels cannot retry that same packet.
|
||||
- Spiritual alone conversion retains the already withdrawn/placed advisor,
|
||||
accompaniment lock and continuously-alone hold, then completes the free child
|
||||
and drains the original paid entry once.
|
||||
- Relocation retains the consumed token/free-entry allowance and completed force
|
||||
transfer. Denial retains the stranded advisor hold and resumes the original
|
||||
Ambassador/Discovery arrival suffix. It repeats no payment, transfer or entry.
|
||||
|
||||
Three disjoint [choice](../tests/semuta-spiritual-cost-engine.test.ts),
|
||||
[arrival](../tests/semuta-spiritual-arrival-engine.test.ts) and
|
||||
[relocation](../tests/semuta-advisor-relocation-engine.test.ts) source lanes and
|
||||
root integration pass **13 files,163/163**, types/lint/build and **ten native
|
||||
engine/GameTable SSR/actual Semuta-callback journeys** on unchanged source.
|
||||
Twenty-six new cases cover five genuine sources, claim/pass, allowance, actual
|
||||
legal replacement, original continuation, stale/source/deposit/parent rejection
|
||||
and four minimal profiles. Every source uses actual original Advanced setup,
|
||||
paid cache1 Semuta and originally dealt printed Karama; all45 cards remain
|
||||
physical. No played hand, force, stance, wallet, clock, source or parent is
|
||||
installed. Explicitly malformed negative copies are labelled supplements.
|
||||
|
||||
Spiritual88/92 uses Advanced R/C/BG/A/E and CHOAM only. A really pays1 to ship1
|
||||
to Arrakeen beside BG's original advisor or to empty Tuek's while BG has its
|
||||
original Polar fighter. Shipping stops before withdrawal; matching really passes
|
||||
that separate counter before opening. Matching denial permits actual Tuek's
|
||||
accompaniment1 or decline, while the denied Polar1 remains blocked. The native
|
||||
replacement may settle immediately when nobody can counter it. Arrival93 uses
|
||||
the same original roster: BG really chooses empty Polar1, passes shipping,
|
||||
withdraws its reserve19→18, places/locks the advisor and opens the distinct
|
||||
mandatory conversion before the original paid queue resumes.
|
||||
|
||||
Ambassador89 uses original Advanced R/C/Ecaz/BG/A with CHOAM+Ecaz. The real final
|
||||
Traitor lottery earns Fremen supply; paid token, actual A entrant and genuine
|
||||
Ecaz6 Basin→Tuek's relocation leave BG alone. Discovery114 uses the original
|
||||
Discovery-only module entry with Advanced R/C/BG/A/E. A real first-blow Cistern
|
||||
placement, A paid2 shipment/accepted Intrusion, actual Collection inspect/reveal
|
||||
and next-turn A1 free entry leave the original BG advisor stranded. Claim/pass
|
||||
retains each actual child/token and ends before unrelated Storm readiness.
|
||||
|
||||
Narrow feedback corrects absent-field and Discovery revealed-turn assertions.
|
||||
The first frozen quick/test/build steps pass. The private runtime harness alone
|
||||
then corrects its unconditional extra-counter assumption and passes all ten
|
||||
journeys on a runtime-only rerun. Both reports bind the identical2917-file tree
|
||||
`0df139bc97b5bd3e5d6bc1121bf34f0c26790506aa85557ab3f8d60421b87332`.
|
||||
Private reports under `/home/quintine/.local/share/dune/checkpoints/` are
|
||||
`20261026-semuta-spiritual-relocation-proof/` and
|
||||
`20261026-semuta-spiritual-relocation-runtime-proof/`; scripts/journeys/HTML are
|
||||
in adjacent `20261026-semuta-spiritual-relocation-runtime/`.
|
||||
|
||||
Other matching/grant parents, wider combinations, full 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.
|
||||
|
||||
## Homeworld, battle and Face Dance conversion costs — 26 October 2026
|
||||
|
||||
**Prototyped / Partial.** Existing `karamaAdvisorFlipDiscard` now offers fresh
|
||||
|
||||
+43
-10
@@ -2,6 +2,7 @@ import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income';
|
||||
import { semutaTerminalArrivalSupported } from './semuta-terminal-arrival';
|
||||
import { semutaDisasterArrivalSupported } from './semuta-disaster-arrival';
|
||||
import { semutaAdvisorArrivalSupported } from './semuta-advisor-arrival';
|
||||
import { semutaSpiritualArrivalSupported } from './semuta-spiritual-arrival';
|
||||
import { traitorDecisionChoices, bindTraitorDecisionPromise, settleTraitorDecisionPromises,
|
||||
reconcileTraitorDecisionPromises as reconcileTraitorDecisions, validateTraitorDecisionPromise,
|
||||
TraitorDecisionError, type TraitorDecisionContext, type TraitorDecisionPromise } from './traitor-decision-promises';
|
||||
@@ -5857,20 +5858,35 @@ function semutaAuctionAdvantageResponse(g: Game, response: ResponseWindow, playe
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/** Qualify only the original ordinary foreign shipment and its exact selected
|
||||
* Spiritual packet. Native quotes retain the shipping/matching/arrival stage. */
|
||||
function semutaSpiritualKaramaParent(g: Game, response: ResponseWindow): boolean {
|
||||
if (!semutaSpiritualArrivalSupported(g)) return false;
|
||||
try {
|
||||
if (response.spiritualAdvisorSource) currentSpiritualAdvisorResponse(g,response);
|
||||
else if (response.automaticAdvisorFlip?.producer === 'spiritualArrival') currentAutomaticAdvisorFlip(g,response);
|
||||
else return false;
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof RuleError) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/** These denied native branches change only knowledge, an unused packet or
|
||||
* the declared replacement attempt. Keep complex arrival/opening parents out. */
|
||||
* the declared replacement attempt. Arrival parents require an exact source. */
|
||||
function semutaTerminalKaramaResponse(g: Game, response: ResponseWindow, player: string,
|
||||
decision: Game['decision'] = g.decision): boolean {
|
||||
const spiritual = response.kind === 'advisor' && semutaSpiritualKaramaParent(g,response);
|
||||
if (!['advisor','atreidesSpice','stormPeek','faceDancerReplacement'].includes(response.kind) ||
|
||||
g.battle || g.phaseOpening || g.pendingKarama || g.pendingRevival ||
|
||||
g.pendingShipment || g.pendingGuildTransport || g.pendingHomeworldShipment ||
|
||||
g.pendingSpiritualAdvisor || g.pendingAmbassador || g.pendingTerrorEntry ||
|
||||
g.pendingArrivalOverlap || !semutaTerminalArrivalSupported(g,response) ||
|
||||
(g.pendingSpiritualAdvisor && !spiritual) || g.pendingAmbassador || g.pendingTerrorEntry ||
|
||||
g.pendingArrivalOverlap || (!spiritual && !semutaTerminalArrivalSupported(g,response)) ||
|
||||
g.pendingIxMove || g.pendingFremenMove || g.pendingChoamMove ||
|
||||
g.ornithopter || g.summonedWorm || g.wormRides.length > 0 ||
|
||||
g.choamMarket || g.pendingChoamMarketGhola) return false;
|
||||
if (response.kind === 'advisor' &&
|
||||
(response.spiritualAdvisorSource || response.advisorResume ||
|
||||
((response.spiritualAdvisorSource && !spiritual) || response.advisorResume ||
|
||||
response.advisorFollowup || response.advisorRemaining !== undefined ||
|
||||
response.advisorAmbassadorEvent !== undefined)) return false;
|
||||
try {
|
||||
@@ -6073,23 +6089,26 @@ function semutaMarketGrantKaramaResponse(g: Game, response: ResponseWindow, play
|
||||
/** Ordinary Advanced advisor conversion sources retain their native timing:
|
||||
* preparation, uncommitted matching or an already completed native producer. */
|
||||
function semutaAdvisorFlipKaramaResponse(g: Game, response: ResponseWindow, player: string): boolean {
|
||||
const spiritual = response.kind === 'advisorFlip' && semutaSpiritualKaramaParent(g,response);
|
||||
const relocation = response.automaticAdvisorFlip?.producer === 'specialRelocation';
|
||||
const ambassador = relocation && g.pendingAutomaticAdvisorRelocation?.resume.kind === 'ambassador';
|
||||
if (response.kind !== 'advisorFlip' || !g.advanced ||
|
||||
response.owner !== byFaction(g,'beneGesserit')?.id ||
|
||||
g.battle || g.decision || g.phaseOpening || g.pendingKarama || g.truthtrance ||
|
||||
g.pendingExchange || g.pendingNullentropy || g.pendingRicheseGift || g.pendingRichesePurchaseIncome ||
|
||||
g.pendingRevival || g.pendingShipment || g.pendingGuildTransport || g.pendingHomeworldShipment ||
|
||||
g.pendingFremenMove || g.pendingIxMove || g.pendingMobileMove || g.pendingChoamMove ||
|
||||
g.pendingSpiritualAdvisor || g.pendingAmbassador || g.pendingTerrorEntry || g.pendingArrivalOverlap ||
|
||||
!semutaAdvisorArrivalSupported(g,response) || g.ornithopter || g.summonedWorm || g.wormRides.length > 0 ||
|
||||
(g.pendingSpiritualAdvisor && !spiritual) || (g.pendingAmbassador && !ambassador) || g.pendingTerrorEntry || g.pendingArrivalOverlap ||
|
||||
(!spiritual && !semutaAdvisorArrivalSupported(g,response)) || g.ornithopter || g.summonedWorm || g.wormRides.length > 0 ||
|
||||
g.currentAuctionSale || g.choamMarket || g.pendingChoamMarketGhola || g.pendingIxAlly ||
|
||||
response.advisorGatherMatchingSource || response.advisorTransportMatchingSource ||
|
||||
response.advisorAmbassadorMatchingSource || response.spiritualAdvisorSource ||
|
||||
response.advisorAmbassadorMatchingSource || (response.spiritualAdvisorSource && !spiritual) ||
|
||||
(response.advisorResume !== undefined && !(response.advisorResume === 'preparation' && response.advisorPreparationSource)) ||
|
||||
(!!g.pendingAdvisorMatching !== !!response.advisorMatchingSource) ||
|
||||
(!!g.pendingAutomaticAdvisorFlips?.length !== !!response.automaticAdvisorFlip)) return false;
|
||||
if ((g.auction || g.richeseAuction) && !response.advisorPreparationSource) return false;
|
||||
if (response.automaticAdvisorFlip &&
|
||||
g.pendingAutomaticAdvisorFlips?.some(source => !['groundMovement','guildTransport','gamont','storm','homeworldTransport','battleLosses','faceDance'].includes(source.producer))) return false;
|
||||
g.pendingAutomaticAdvisorFlips?.some(source => !['groundMovement','guildTransport','gamont','storm','homeworldTransport','battleLosses','faceDance','specialRelocation','spiritualArrival'].includes(source.producer))) return false;
|
||||
const context: Game = {...g,pendingTreacheryDiscard:null,response};
|
||||
try {
|
||||
// Native source quotes validate denial without requiring the spent card,
|
||||
@@ -6367,6 +6386,14 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
|
||||
faceDance:g.pendingAutomaticAdvisorFaceDance, context:g.lastBattleContext,
|
||||
pending:g.pendingFaceDance, custody:g.homeworlds,
|
||||
players:g.players.map(p => ({id:p.id, faceDancers:p.faceDancers, replaced:p.faceDancerReplacedTurn})),
|
||||
} : advisorProducer === 'specialRelocation' ? {
|
||||
relocation:g.pendingAutomaticAdvisorRelocation, ambassador:g.pendingAmbassador,
|
||||
discovery:g.discoveryEntry, tokens:g.ecazAmbassadors,
|
||||
} : undefined,
|
||||
spiritualParent: (terminal || advisor) && (c.use.response.spiritualAdvisorSource ||
|
||||
advisorProducer === 'spiritualArrival') ? {
|
||||
frame:g.pendingSpiritualAdvisor, arrivals:g.pendingArrivalReaction,
|
||||
tech:g.techTokens,
|
||||
} : undefined,
|
||||
order: g.order,
|
||||
players: g.players.map(p => ({
|
||||
@@ -6936,7 +6963,10 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
continuation.entry.cause === 'ambassador';
|
||||
const ambassadorCost =
|
||||
(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.use.response.automaticAdvisorFlip?.producer === 'specialRelocation' &&
|
||||
g.pendingAutomaticAdvisorRelocation?.resume.kind === 'ambassador');
|
||||
requireRule(
|
||||
(g.status === 'playing' || (truth && g.status === 'setup')) &&
|
||||
pending.sequence === sequence &&
|
||||
@@ -8593,7 +8623,10 @@ function semutaOfferSupported(
|
||||
!g.pendingArrivalOverlap && !g.pendingRevival &&
|
||||
!g.ornithopter && !g.ecologicalStorm)) &&
|
||||
(richeseGiftDiscard || ambassadorBenefitDiscard || (!g.pendingExchange && (replacementCostDiscard || !g.pendingIxAlly) && !g.pendingNullentropy &&
|
||||
!g.pendingAmbassador && (terrorAllianceDiscard || !g.pendingTerrorEntry))) &&
|
||||
(!g.pendingAmbassador || advisorDiscard &&
|
||||
continuation.use.response.automaticAdvisorFlip?.producer === 'specialRelocation' &&
|
||||
g.pendingAutomaticAdvisorRelocation?.resume.kind === 'ambassador') &&
|
||||
(terrorAllianceDiscard || !g.pendingTerrorEntry))) &&
|
||||
(richeseGiftDiscard || noFieldDiscard || continuation.kind === 'karamaShipmentDiscard' ||
|
||||
continuation.kind === 'specialKaramaDiscard' ||
|
||||
bgDiscard ||
|
||||
|
||||
@@ -1490,6 +1490,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/semuta-advisor-battle-engine.test.ts',
|
||||
'tests/fixture-semuta-advisor-face-dance.ts',
|
||||
'tests/semuta-advisor-face-dance-engine.test.ts',
|
||||
'game/semuta-spiritual-arrival.ts',
|
||||
'tests/fixture-semuta-spiritual-cost.ts',
|
||||
'tests/semuta-spiritual-cost-engine.test.ts',
|
||||
'tests/fixture-semuta-spiritual-arrival.ts',
|
||||
'tests/semuta-spiritual-arrival-engine.test.ts',
|
||||
'tests/fixture-semuta-advisor-relocation.ts',
|
||||
'tests/semuta-advisor-relocation-engine.test.ts',
|
||||
'tests/fixture-semuta-spiritual-foresight.ts',
|
||||
'tests/semuta-spiritual-foresight-engine.test.ts',
|
||||
'tests/fixture-semuta-storm-dancer.ts',
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Game } from './engine';
|
||||
import { location, validGameLocation } from './board';
|
||||
|
||||
/** The ordinary foreign shipment remains deposited behind its Spiritual
|
||||
* child. Native frame/source validators separately bind the selected grant. */
|
||||
export function semutaSpiritualArrivalSupported(g: Game): boolean {
|
||||
const frame = g.pendingSpiritualAdvisor, arrivals = g.pendingArrivalReaction;
|
||||
if (!g.advanced || g.phase !== 5 || !frame || frame.decision.ambassadorEvent ||
|
||||
frame.decision.revivalEvent || g.pendingAmbassador || !Array.isArray(arrivals) ||
|
||||
arrivals.length !== 1) return false;
|
||||
const entry = arrivals[0];
|
||||
if (!entry || Object.keys(entry).sort().join(',') !==
|
||||
'amount,cause,elite,entrant,phase,resume,sector,territory,turn' ||
|
||||
entry.turn !== g.turn || entry.phase !== g.phase || entry.cause !== 'shipment' ||
|
||||
entry.resume !== 'none' || frame.decision.shipment !== entry.entrant ||
|
||||
frame.decision.destination !== location(entry.territory,entry.sector) ||
|
||||
!Number.isSafeInteger(entry.amount) || entry.amount <= 0 || entry.amount > 20 ||
|
||||
!Number.isSafeInteger(entry.elite) || entry.elite < 0 || entry.elite > entry.amount ||
|
||||
!validGameLocation(g,entry.territory,entry.sector)) return false;
|
||||
const entrant = g.players.find(p => p.id === entry.entrant);
|
||||
if (!entrant || entrant.id !== g.active || entrant.id === frame.decision.player ||
|
||||
entrant.faction === 'beneGesserit' || entrant.faction === 'fremen' ||
|
||||
entrant.shipped !== true || !g.movementRemaining?.includes(entrant.id)) return false;
|
||||
const key = location(entry.territory,entry.sector), total = entrant.forces[key] ?? 0,
|
||||
elite = entrant.elites?.forces[key] ?? 0;
|
||||
return total >= entry.amount && elite >= entry.elite &&
|
||||
total - elite >= entry.amount - entry.elite;
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
|
||||
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID } from '../game/discoveries';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
nextSemutaRicheseCacheKarama, semutaRicheseCacheKaramaClean,
|
||||
semutaRicheseCacheKaramaInventory, semutaRicheseCacheKaramaPlayer,
|
||||
} from './fixture-semuta-richese-cache-karama';
|
||||
import {
|
||||
passSemutaFremenGuildMovement, claimSemutaFremenGuildMovement,
|
||||
} from './fixture-semuta-fremen-guild-movement';
|
||||
|
||||
export type SemutaAdvisorRelocationKind = 'ambassador' | 'discovery';
|
||||
export type SemutaAdvisorRelocationStep = { actor: string; action: Action };
|
||||
export const passSemutaAdvisorRelocation = passSemutaFremenGuildMovement;
|
||||
export const claimSemutaAdvisorRelocation = claimSemutaFremenGuildMovement;
|
||||
export const semutaAdvisorRelocationPlayer = semutaRicheseCacheKaramaPlayer;
|
||||
export const semutaAdvisorRelocationInventory = semutaRicheseCacheKaramaInventory;
|
||||
export const semutaAdvisorRelocationClean = semutaRicheseCacheKaramaClean;
|
||||
export const semutaAdvisorRelocationPhysical = (game: Game) => ({
|
||||
turn: game.turn, phase: game.phase, storm: game.storm, order: game.order,
|
||||
spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
|
||||
players: game.players.map(player => ({ id: player.id, spice: player.spice,
|
||||
reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites,
|
||||
leaders: player.leaders, shipped: player.shipped, moved: player.moved, revived: player.revived, ally: player.ally })),
|
||||
});
|
||||
const cisternPlacement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
|
||||
|
||||
/** Select only real scalar packet/token draws. UUID and byte entropy stay
|
||||
* native; optional scalars must all be consumed by the actual producer. */
|
||||
function originalRelocationAction(game: Game, actor: string, action: Action,
|
||||
actions: SemutaAdvisorRelocationStep[], scalars: readonly number[] = []): Game {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto); let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1)
|
||||
array[0] = cursor < scalars.length ? scalars[cursor++] : 0xffffffff;
|
||||
else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try {
|
||||
const gameAfter = applyAction(game, actor, action);
|
||||
assert.equal(cursor, scalars.length, 'The actual original producer consumes every selected scalar draw');
|
||||
actions.push({ actor, action: structuredClone(action) }); return gameAfter;
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function actSemutaAdvisorRelocation(game: Game, actor: string, action: Action,
|
||||
actions: SemutaAdvisorRelocationStep[] = []): Game {
|
||||
return originalRelocationAction(game, actor, action, actions);
|
||||
}
|
||||
export function nextSemutaAdvisorRelocation(game: Game): SemutaAdvisorRelocationStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance || game.response?.automaticAdvisorFlip) return null;
|
||||
if (!game.phaseOpening && !game.response) {
|
||||
const bg = game.players.find(player => player.faction === 'beneGesserit'); assert.ok(bg);
|
||||
const ecaz = game.players.find(player => player.faction === 'ecaz');
|
||||
if (game.status === 'setup' && game.setupStage === 'prediction')
|
||||
return { actor: bg.id, action: { type: 'predict', faction: 'atreides', turn: 10 } };
|
||||
if (game.status === 'setup' && game.setupStage === 'forces') {
|
||||
if (ecaz?.reserves === 20)
|
||||
return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } };
|
||||
if (!bg.advisorSetup)
|
||||
return { actor: bg.id, action: { type: 'advisorSetup', territory: ecaz ? 'imperial_basin' : cisternPlacement.territory,
|
||||
sector: ecaz ? 10 : cisternPlacement.sector } };
|
||||
}
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'ecazPlacement' || decision?.kind === 'ecazAmbassador') return null;
|
||||
if (decision?.kind === 'advisorBattle' || decision?.kind === 'advisor')
|
||||
return { actor: decision.player, action: { type: 'decision', accept: false } };
|
||||
if (decision?.kind === 'intrusion')
|
||||
return { actor: decision.player, action: { type: 'decision', accept: true } };
|
||||
if (decision?.kind === 'discoveryEntry')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
|
||||
}
|
||||
return nextSemutaRicheseCacheKarama(game);
|
||||
}
|
||||
export function advanceSemutaAdvisorRelocation(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaAdvisorRelocationStep[] = []): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaAdvisorRelocation(game);
|
||||
assert.ok(step, 'Choose the original Ambassador, conversion or fresh discard before continuing');
|
||||
game = actSemutaAdvisorRelocation(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original relocation-conversion programme did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaAdvisorRelocation(game: Game,
|
||||
actions: SemutaAdvisorRelocationStep[] = []): Game {
|
||||
assert.equal(game.response?.automaticAdvisorFlip?.producer, 'specialRelocation');
|
||||
for (let count = 0; game.response?.kind === 'advisorFlip' && count < game.players.length; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actSemutaAdvisorRelocation(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.kind, 'advisorFlip'); return game;
|
||||
}
|
||||
/** Finish only the original entry parent. In phase zero stop as soon as the
|
||||
* Discovery round clears, before unrelated Storm readiness or its movement. */
|
||||
export function finishSemutaAdvisorRelocation(game: Game,
|
||||
actions: SemutaAdvisorRelocationStep[] = []): Game {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
|
||||
const discovery = game.phase === 0;
|
||||
return advanceSemutaAdvisorRelocation(game, state => discovery
|
||||
? !state.discoveryEntry && !state.response?.automaticAdvisorFlip
|
||||
: !state.pendingAmbassador && semutaAdvisorRelocationClean(state), actions);
|
||||
}
|
||||
|
||||
function fremenPacket() {
|
||||
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))];
|
||||
const 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); assert.equal(scalars.length, 9); return { supply, order, scalars };
|
||||
}
|
||||
|
||||
/** Real Advanced Semuta setup for the original narrow E3 or Discovery
|
||||
* initializer. No played cards, pieces, resources, tokens or controls are set. */
|
||||
function createRelocationSetup(kind: SemutaAdvisorRelocationKind) {
|
||||
const richese = 'richese', holder = 'choam', owner = 'beneGesserit', atreides = 'atreides';
|
||||
const spender = kind === 'ambassador' ? atreides : 'emperor';
|
||||
let game = createGame('SEMUTAADVISORRELOCATION', newPlayer(richese, 'Richese', 'richese'), true,
|
||||
kind === 'ambassador' ? ['choam', 'ecaz'] : ['choam']);
|
||||
const roster = kind === 'ambassador' ? [holder, 'ecaz', owner, atreides] as const
|
||||
: [holder, owner, atreides, 'emperor'] as const;
|
||||
for (const faction of roster) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaAdvisorRelocationStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaAdvisorRelocation(game, actor, action, actions); };
|
||||
if (kind === 'discovery') act(game.host, { type: 'moduleOption', module: 'discoveries', enabled: true });
|
||||
const configured = structuredClone(game);
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
|
||||
entry: kind === 'ambassador' ? 'original-undealt-bare-advanced-ecaz-semuta' : 'original-undealt-semuta-discovery' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
|
||||
assert.equal(!!game.discoveryEnabled, kind === 'discovery');
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.ecazTreachery);
|
||||
const originalInventory = semutaAdvisorRelocationInventory(game);
|
||||
assert.equal(originalInventory.length, 45); assert.equal(game.deck.length, 35); assert.equal(game.richeseCache!.length, 10);
|
||||
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, printed], 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), packet = kind === 'ambassador' ? fremenPacket() : null;
|
||||
let packetCreation: { before: Game; steps: SemutaAdvisorRelocationStep[]; after: Game } | null = null;
|
||||
let advisorSetup: { before: Game; steps: SemutaAdvisorRelocationStep[]; after: Game } | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 120; count++) {
|
||||
const step = nextSemutaAdvisorRelocation(game); assert.ok(step);
|
||||
const before = structuredClone(game), start = actions.length;
|
||||
const initializesForces = packet && game.setupStage === 'traitors' && step.action.type === 'traitor' &&
|
||||
game.players.filter(player => player.traitorChoices.length).length === 1;
|
||||
game = initializesForces
|
||||
? originalRelocationAction(game, step.actor, step.action, actions, packet!.scalars)
|
||||
: actSemutaAdvisorRelocation(game, step.actor, step.action, actions);
|
||||
const record = { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
if (initializesForces) packetCreation = record;
|
||||
if (step.action.type === 'advisorSetup') advisorSetup = record;
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(advisorSetup);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.ok(semutaAdvisorRelocationPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
if (packet) {
|
||||
assert.ok(packetCreation);
|
||||
assert.deepEqual(game.ecazAmbassadors!.cohort.map(id => game.ecazAmbassadors!.tokens.find(token => token.id === id)!.effect), packet.supply);
|
||||
}
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card &&
|
||||
(kind === 'discovery' && position === 0 ? card.discovery === 'discovery-hagga-basin' : !card.discovery));
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
return { game, initial, configured, lobby, setup, initialization, beforeStartingDeal, afterSetup,
|
||||
packet, packetCreation, advisorSetup, richese, holder, owner, bg: owner, atreides, spender,
|
||||
cost, semuta: SEMUTA_DRUG_ID, actions, originalInventory, originalSpice, dealOrder, spiceOrder };
|
||||
}
|
||||
|
||||
/** Actual paid cache purchase. Never substitute a physical card into a played
|
||||
* hand when acquiring Semuta for either relocation chronology. */
|
||||
function acquireSemuta(game: Game, actions: SemutaAdvisorRelocationStep[]) {
|
||||
const holder = 'choam', richese = 'richese';
|
||||
let beforeBid: Game | undefined, bid: SemutaAdvisorRelocationStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 1000 && !semutaAdvisorRelocationPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaAdvisorRelocation(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;
|
||||
}
|
||||
game = actSemutaAdvisorRelocation(game, step.actor, step.action, actions);
|
||||
}
|
||||
assert.ok(beforeBid && bid && bidAt >= 0, 'CHOAM really purchases 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(semutaAdvisorRelocationPlayer(beforeBid, holder).spice - semutaAdvisorRelocationPlayer(game, holder).spice, 1);
|
||||
assert.equal(semutaAdvisorRelocationPlayer(game, richese).spice - semutaAdvisorRelocationPlayer(beforeBid, richese).spice, 1);
|
||||
return { game, acquired, acquisition };
|
||||
}
|
||||
|
||||
export function createSemutaAdvisorRelocationFixture(options: { kind: SemutaAdvisorRelocationKind }) {
|
||||
const { kind } = options, base = createRelocationSetup(kind), { actions, owner, holder, atreides } = base;
|
||||
let game = base.game;
|
||||
const act = (actor: string, action: Action) => { game = actSemutaAdvisorRelocation(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorRelocation(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) };
|
||||
};
|
||||
let tokenPlacement: ReturnType<typeof transition> | null = null, tokenScalar: number | null = null;
|
||||
if (kind === 'discovery') {
|
||||
reach(state => state.phase === 1 && semutaAdvisorRelocationClean(state));
|
||||
const before = structuredClone(game), start = actions.length;
|
||||
const eligible = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID.cistern.type);
|
||||
const selected = eligible.findIndex(token => token.face === 'cistern'); assert.ok(selected >= 0);
|
||||
tokenScalar = Math.floor((selected + 0.5) * 4294967296 / eligible.length);
|
||||
for (let count = 0; !game.discoveries!.tokens.some(token => token.face === 'cistern' && token.status === 'placed') && count < 100; count++) {
|
||||
const step = nextSemutaAdvisorRelocation(game); assert.ok(step);
|
||||
const places = !game.response && !game.phaseOpening && !game.decision && step.action.type === 'ready' &&
|
||||
game.ready.length === game.players.length - 1;
|
||||
game = originalRelocationAction(game, step.actor, step.action, actions, places ? [tokenScalar] : []);
|
||||
}
|
||||
tokenPlacement = { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
const placed = game.discoveries!.tokens.find(token => token.face === 'cistern'); assert.ok(placed);
|
||||
assert.equal(placed.status, 'placed'); assert.equal(placed.territory, cisternPlacement.territory);
|
||||
}
|
||||
const paidSemuta = acquireSemuta(game, actions); game = paidSemuta.game;
|
||||
let placement: ReturnType<typeof transition> | null = null, trigger: ReturnType<typeof transition> | null = null;
|
||||
let intrusion: ReturnType<typeof transition> | null = null, revelation: ReturnType<typeof transition> | null = null;
|
||||
let offer: Game | null = null;
|
||||
let token: string, event: string, territory: string, from: string, destination: string, sector: number, amount: number;
|
||||
let mover: string, nativeAction: Action, shipment: ReturnType<typeof transition>;
|
||||
if (kind === 'ambassador') {
|
||||
const ecaz = 'ecaz'; territory = 'imperial_basin'; from = 'imperial_basin:10'; mover = ecaz;
|
||||
assert.equal(semutaAdvisorRelocationPlayer(game, ecaz).forces[from], 6);
|
||||
assert.equal(semutaAdvisorRelocationPlayer(game, owner).forces[from], 1);
|
||||
assert.equal(isAdvisor(semutaAdvisorRelocationPlayer(game, owner), territory), true);
|
||||
reach(state => state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
const physicalToken = game.ecazAmbassadors!.tokens.find(candidate => candidate.effect === 'fremen'); assert.ok(physicalToken);
|
||||
token = physicalToken.id; assert.equal(physicalToken.zone, 'supply');
|
||||
placement = transition(() => {
|
||||
act(ecaz, { type: 'decision', token, territory: 'carthag' });
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
});
|
||||
assert.equal(semutaAdvisorRelocationPlayer(placement.before, ecaz).spice - semutaAdvisorRelocationPlayer(game, ecaz).spice, 1);
|
||||
act(ecaz, { type: 'decision', decline: true });
|
||||
reach(state => state.phase === 5 && state.active === atreides && semutaAdvisorRelocationClean(state));
|
||||
shipment = transition(() => {
|
||||
act(atreides, { type: 'ship', territory: 'carthag', sector: 11, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(state => !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'offer');
|
||||
});
|
||||
offer = structuredClone(game); event = game.pendingAmbassador!.event;
|
||||
trigger = transition(() => {
|
||||
act(ecaz, { type: 'decision', event, trigger: true, beneficiary: ecaz });
|
||||
reach(state => !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'move');
|
||||
});
|
||||
assert.equal(game.pendingAmbassador!.effect, 'fremen'); assert.equal(game.pendingAmbassador!.beneficiary, ecaz);
|
||||
destination = 'tueks_sietch'; sector = 5; amount = 6;
|
||||
nativeAction = { type: 'decision', event, forces: { [from]: amount }, territory: destination, sector };
|
||||
} else {
|
||||
territory = cisternPlacement.territory; from = `${territory}:${cisternPlacement.sector}`; mover = atreides;
|
||||
const physicalToken = game.discoveries!.tokens.find(candidate => candidate.face === 'cistern'); assert.ok(physicalToken);
|
||||
token = physicalToken.id; assert.equal(physicalToken.status, 'placed');
|
||||
assert.equal(isAdvisor(semutaAdvisorRelocationPlayer(base.afterSetup, owner), territory), false);
|
||||
reach(state => state.phase === 5 && state.active === owner && semutaAdvisorRelocationClean(state));
|
||||
act(owner, { type: 'endMovement' });
|
||||
reach(state => state.phase === 5 && state.active === atreides && semutaAdvisorRelocationClean(state));
|
||||
shipment = transition(() => {
|
||||
act(atreides, { type: 'ship', territory, sector: cisternPlacement.sector, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'intrusion');
|
||||
});
|
||||
intrusion = transition(() => {
|
||||
act(owner, { type: 'decision', accept: true }); reach(semutaAdvisorRelocationClean);
|
||||
});
|
||||
assert.equal(semutaAdvisorRelocationPlayer(shipment.before, atreides).spice - semutaAdvisorRelocationPlayer(game, atreides).spice, 2);
|
||||
assert.equal(semutaAdvisorRelocationPlayer(game, atreides).forces[from], 1);
|
||||
assert.equal(isAdvisor(semutaAdvisorRelocationPlayer(game, owner), territory), true);
|
||||
reach(state => state.turn === 1 && state.phase === 7 && semutaAdvisorRelocationClean(state));
|
||||
assert.ok(viewGame(game, atreides).discoveries!.canInspect.includes(physicalToken.id));
|
||||
revelation = transition(() => {
|
||||
act(atreides, { type: 'discovery', token, reveal: false });
|
||||
act(atreides, { type: 'discovery', token, reveal: true });
|
||||
});
|
||||
reach(state => state.turn === 2 && state.phase === 0 && !state.response && !state.phaseOpening &&
|
||||
state.decision?.kind === 'discoveryEntry' && state.decision.player === atreides);
|
||||
const entryAction = discoveryEntryMoveAction(viewGame(game, atreides), [{ source: from, normal: 1, elite: 0 }]);
|
||||
assert.ok(entryAction); assert.ok(game.decision?.kind === 'discoveryEntry');
|
||||
event = game.decision.event; nativeAction = entryAction; destination = 'cistern'; sector = 0; amount = 1;
|
||||
}
|
||||
const beforeDeclaration = structuredClone(game);
|
||||
const relocation = transition(() => { act(mover, nativeAction); });
|
||||
const destinationLocation = `${destination}:${sector}`;
|
||||
assert.equal(semutaAdvisorRelocationPlayer(game, mover).forces[from], undefined);
|
||||
assert.equal(semutaAdvisorRelocationPlayer(game, mover).forces[destinationLocation],
|
||||
(semutaAdvisorRelocationPlayer(beforeDeclaration, mover).forces[destinationLocation] ?? 0) + amount);
|
||||
assert.deepEqual(game.players.map(player => [player.id, player.spice, player.reserves, player.tanks, player.shipped, player.moved]),
|
||||
beforeDeclaration.players.map(player => [player.id, player.spice, player.reserves, player.tanks, player.shipped, player.moved]));
|
||||
assert.equal(game.response?.kind, 'advisorFlip'); assert.equal(game.response?.owner, owner);
|
||||
assert.equal(game.response?.automaticAdvisorFlip?.producer, 'specialRelocation');
|
||||
assert.ok(game.pendingAutomaticAdvisorRelocation && game.pendingAutomaticAdvisorFlips?.length === 1);
|
||||
assert.equal(isAdvisor(semutaAdvisorRelocationPlayer(game, owner), territory), true);
|
||||
assert.ok(semutaAdvisorRelocationPlayer(game, owner).advisors?.[territory]?.aloneFlipHold);
|
||||
if (kind === 'ambassador') assert.equal(game.pendingAmbassador?.relocation?.next, 'flip');
|
||||
else assert.equal(game.discoveryEntry?.stage, 'arrival');
|
||||
assert.equal(game.phaseOpening ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
|
||||
assert.ok(semutaAdvisorRelocationPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaAdvisorRelocationPlayer(game, base.spender).hand.some(card => card.id === base.cost));
|
||||
assert.deepEqual(semutaAdvisorRelocationInventory(game), base.originalInventory);
|
||||
const source = structuredClone(game), action: Action = { type: 'card', card: base.cost, mode: 'cancel' };
|
||||
return { ...base, game, source, beforeCost: source, kind, advanced: true as const, mover, actor: mover,
|
||||
territory, origin: territory, from, location: from, destination, destinationLocation, to: destinationLocation,
|
||||
sector, amount, event, token, action, nativeAction,
|
||||
originalResponse: structuredClone(game.response!),
|
||||
originalAutomaticSource: structuredClone(game.response!.automaticAdvisorFlip!),
|
||||
originalParent: structuredClone(game.pendingAutomaticAdvisorRelocation!),
|
||||
originalQueue: structuredClone(game.pendingAutomaticAdvisorFlips!),
|
||||
originalAmbassador: structuredClone(game.pendingAmbassador ?? null),
|
||||
originalDiscoveryEntry: structuredClone(game.discoveryEntry ?? null),
|
||||
originalHeldStance: structuredClone(semutaAdvisorRelocationPlayer(game, owner).advisors![territory]),
|
||||
originalPhysical: structuredClone(semutaAdvisorRelocationPhysical(game)), originalHistory: structuredClone(game.log),
|
||||
originalAmbassadorToken: structuredClone(game.ecazAmbassadors?.tokens.find(candidate => candidate.id === token) ?? null),
|
||||
originalDiscoveryToken: structuredClone(game.discoveries?.tokens.find(candidate => candidate.id === token) ?? null),
|
||||
acquired: paidSemuta.acquired, acquisition: paidSemuta.acquisition, beforeDeclaration, relocation,
|
||||
placement, shipment, offer, trigger, tokenPlacement, tokenScalar, intrusion, revelation,
|
||||
sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
kind === 'ambassador'
|
||||
? 'Original narrow Advanced Ecaz Semuta initializer on ready undealt R/C/Ecaz/BG/A, exact CHOAM+Ecaz and no optional modules. The actual final Traitor choice consumes nine selected original packet scalars to earn Fremen supply; no token is installed.'
|
||||
: 'Actual lobby Discovery moduleOption, then original Advanced Semuta Discovery initializer on ready undealt R/C/BG/A/E, exact CHOAM and only Discoveries. No fresh profile/start gate is added.',
|
||||
'Select original unopened Treachery positions so the stopper is really dealt printed Karama. CHOAM actually buys original cache Semuta for one spice; no played hand, wallet or physical card is assigned.',
|
||||
kind === 'ambassador'
|
||||
? 'Actual Ecaz setup six in Basin10 and BG advisorSetup one beside them. Native first Storm, dual Spice, Charity, auction and Revival run. Ecaz actually pays one to place Fremen at Carthag; A really ships one there and Ecaz triggers the original token for itself.'
|
||||
: 'Actual BG advisorSetup at empty Gara Kulon8 is initially a fighter. The original Hagga Basin Discovery Spice Card and a real selected supply-token draw place Cistern there. A later pays two to ship one beside BG; actual accepted Intrusion and allowed original flip make BG an advisor, and accompaniment is declined.',
|
||||
kind === 'ambassador'
|
||||
? 'Actually relocate all six original Basin counters to empty Tueks5, leaving BG alone. The consumed token, original paid A shipment and Ambassador arrival child remain native while conversion is pending.'
|
||||
: 'At actual Collection A inspects then reveals Cistern. At the original next-turn free-entry chooser A really moves its one non-advisor parent counter into Cistern0, leaving BG alone. No fake reveal, free-entry frame or played clock is installed.',
|
||||
'Stop at the original specialRelocation automatic advisorFlip before spending the original printed Karama. Finish only that Ambassador/Discovery parent once and stop before unrelated future movement or Storm readiness.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaAdvisorRelocationFixture = ReturnType<typeof createSemutaAdvisorRelocationFixture>;
|
||||
@@ -0,0 +1,178 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
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 SemutaSpiritualArrivalStep = { actor: string; action: Action };
|
||||
export const actSemutaSpiritualArrival = actSemutaCombatCapabilities;
|
||||
export const semutaSpiritualArrivalPlayer = semutaCombatCapabilitiesPlayer;
|
||||
export const semutaSpiritualArrivalInventory = semutaCombatCapabilitiesInventory;
|
||||
export const semutaSpiritualArrivalClean = semutaCombatCapabilitiesClean;
|
||||
export const passSemutaSpiritualArrival = passSemutaCombatCapabilities;
|
||||
export const claimSemutaSpiritualArrival = claimSemutaCombatCapabilities;
|
||||
export const semutaSpiritualArrivalPhysical = (game: Game) => ({
|
||||
turn: game.turn, phase: game.phase, storm: game.storm, active: game.active, order: game.order,
|
||||
ready: game.ready, movementRemaining: game.movementRemaining, spice: game.spice,
|
||||
players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces, reserves: p.reserves,
|
||||
tanks: p.tanks, elites: p.elites, leaders: p.leaders, ally: p.ally, shipped: p.shipped, moved: p.moved })),
|
||||
});
|
||||
|
||||
/** Native quiet controls only. The free packet chooser, distinct shipping
|
||||
* counter and mandatory conversion remain explicit source/caller actions. */
|
||||
export function nextSemutaSpiritualArrival(game: Game): SemutaSpiritualArrivalStep | null {
|
||||
if (game.response?.automaticAdvisorFlip) 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 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 === 'advisor' || d?.kind === 'intrusion') return null;
|
||||
if (d?.kind === 'advisorBattle') return { actor: d.player, action: { type: 'decision', accept: false } };
|
||||
}
|
||||
return nextSemutaCombatCapabilities(game);
|
||||
}
|
||||
export const nextSemutaSpiritualArrivalStep = nextSemutaSpiritualArrival;
|
||||
export function advanceSemutaSpiritualArrival(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaSpiritualArrivalStep[] = []): Game {
|
||||
for (let count = 0; count < 2400; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextSemutaSpiritualArrival(game);
|
||||
assert.ok(next, `Supply the real Spiritual choice at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaSpiritualArrival(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The native Spiritual arrival programme did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaSpiritualArrival(game: Game, actions: SemutaSpiritualArrivalStep[] = []): Game {
|
||||
assert.ok(game.response?.automaticAdvisorFlip?.producer === 'spiritualArrival');
|
||||
for (let count = 0; game.response?.automaticAdvisorFlip?.producer === 'spiritualArrival' && count < game.players.length; count++) {
|
||||
const p = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(p);
|
||||
game = actSemutaSpiritualArrival(game, p.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.automaticAdvisorFlip?.producer, 'spiritualArrival'); return game;
|
||||
}
|
||||
/** Finish only the free arrival and original paid queued entry. Leave the
|
||||
* actual Atreides combined turn and later endMovement to the caller. */
|
||||
export function finishSemutaSpiritualArrival(game: Game, actions: SemutaSpiritualArrivalStep[] = []): Game {
|
||||
assert.ok(!game.pendingTreacheryDiscard);
|
||||
return advanceSemutaSpiritualArrival(game, state => semutaSpiritualArrivalClean(state) &&
|
||||
!state.pendingSpiritualAdvisor && !state.pendingAutomaticAdvisorFlips?.length && !state.pendingArrivalReaction?.length, actions);
|
||||
}
|
||||
|
||||
/** Real paid ordinary Atreides entry and declared BG Polar packet. No played
|
||||
* stock, advisor stance, source frame, queue, lock or counter is installed. */
|
||||
export function createSemutaSpiritualArrivalFixture() {
|
||||
const richese = 'richese', holder = 'choam', owner = 'beneGesserit', shipper = 'atreides', spender = 'emperor';
|
||||
const territory = 'polar_sink', sector = 0, location = 'polar_sink:0';
|
||||
const paidTerritory = 'tueks_sietch', paidSector = 5, paidLocation = 'tueks_sietch:5';
|
||||
let game = createGame('SEMUTASPIRITUALARRIVAL', newPlayer(richese, 'Richese', 'richese'), true, ['choam']);
|
||||
for (const faction of ['choam', 'beneGesserit', 'atreides', 'emperor'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaSpiritualArrivalStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaSpiritualArrival(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaSpiritualArrival(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.advanced, true); 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 = semutaSpiritualArrivalInventory(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, 4);
|
||||
assert.equal(ordinary.length, 4); 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;
|
||||
const setupChoices = transition(() => { reach(state => state.status === 'playing'); });
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(semutaSpiritualArrivalPlayer(game, owner).forces['arrakeen:10'], 1);
|
||||
assert.ok(semutaSpiritualArrivalPlayer(game, owner).advisors?.arrakeen);
|
||||
assert.equal(semutaSpiritualArrivalPlayer(game, shipper).forces['arrakeen:10'], 10);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let beforeBid: Game | undefined, bid: SemutaSpiritualArrivalStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 1000 && !semutaSpiritualArrivalPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const next = nextSemutaSpiritualArrival(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 === 5 && state.active === shipper && semutaSpiritualArrivalClean(state));
|
||||
assert.ok(game.players.every(p => (p.forces[location] ?? 0) === 0));
|
||||
const shipmentAction: Action = { type: 'ship', territory: paidTerritory, sector: paidSector, amount: 1, elite: 0, allyPayment: 0 };
|
||||
const shipment = transition(() => {
|
||||
act(shipper, shipmentAction);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'advisor');
|
||||
});
|
||||
assert.ok(game.decision?.kind === 'advisor'); assert.equal(game.decision.shipment, shipper);
|
||||
assert.equal(game.decision.destination, paidLocation);
|
||||
assert.equal(semutaSpiritualArrivalPlayer(shipment.before, shipper).spice - semutaSpiritualArrivalPlayer(game, shipper).spice, 1);
|
||||
const chooseAction: Action = { type: 'decision', accept: true, accompany: false, amount: 1 };
|
||||
const declaration = transition(() => { act(owner, chooseAction); });
|
||||
assert.ok(game.response?.kind === 'advisor' && game.response.spiritualAdvisorSource);
|
||||
const shippingResponse = structuredClone(game.response), shippingSource = structuredClone(game.response.spiritualAdvisorSource);
|
||||
const shippingFrame = structuredClone(game.pendingSpiritualAdvisor!);
|
||||
assert.equal(game.pendingSpiritualAdvisor?.stage, 'shipping');
|
||||
assert.equal(semutaSpiritualArrivalPlayer(game, owner).forces[location] ?? 0, 0);
|
||||
const placed = transition(() => {
|
||||
for (let count = 0; game.response?.kind === 'advisor' && count < game.players.length; count++) {
|
||||
const p = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(p);
|
||||
act(p.id, { type: 'passResponse' });
|
||||
}
|
||||
});
|
||||
assert.ok(game.response?.automaticAdvisorFlip?.producer === 'spiritualArrival');
|
||||
const automatic = game.response.automaticAdvisorFlip;
|
||||
assert.deepEqual(automatic.spiritual, shippingSource); assert.deepEqual(automatic.forces, { [location]: 1 });
|
||||
assert.equal(game.pendingSpiritualAdvisor?.stage, 'arrival');
|
||||
assert.equal(semutaSpiritualArrivalPlayer(game, owner).forces[location], 1);
|
||||
assert.equal(semutaSpiritualArrivalPlayer(game, owner).reserves, semutaSpiritualArrivalPlayer(declaration.before, owner).reserves - 1);
|
||||
assert.equal(semutaSpiritualArrivalPlayer(game, owner).advisors?.[territory]?.lockedTurn, game.turn);
|
||||
assert.equal(semutaSpiritualArrivalPlayer(game, owner).advisors?.[territory]?.aloneFlipHold, automatic.event);
|
||||
assert.deepEqual(semutaSpiritualArrivalInventory(game), originalInventory);
|
||||
return { game, kind: 'advisorFlip' as const, advanced: true as const, richese, holder, owner, shipper, spender,
|
||||
cost, karama: cost, semuta: SEMUTA_DRUG_ID, action: { type: 'card', card: cost, mode: 'cancel' } as Action,
|
||||
originalResponse: structuredClone(game.response), originalAutomaticSource: structuredClone(automatic),
|
||||
originalFrame: structuredClone(game.pendingSpiritualAdvisor!), originalQueue: structuredClone(game.pendingAutomaticAdvisorFlips!),
|
||||
originalPaidParent: structuredClone(game.pendingArrivalReaction!), originalPhysical: structuredClone(semutaSpiritualArrivalPhysical(game)),
|
||||
originalStance: structuredClone(semutaSpiritualArrivalPlayer(game, owner).advisors!),
|
||||
originalLocks: structuredClone(semutaSpiritualArrivalPlayer(game, owner).advisors?.[territory]),
|
||||
originalDeck: structuredClone(game.deck), territory, sector, location, paidTerritory, paidSector, paidLocation,
|
||||
shipmentAction, chooseAction, shippingResponse, shippingSource, shippingFrame,
|
||||
initial, lobby, setup, setupChoices, beforeStartingDeal, afterSetup, dealOrder, deal: selected.map(card => card.id),
|
||||
originalInventory, originalSpice, spiceOrder, acquisition, acquired, cache: acquisition, shipment,
|
||||
declaration, placed, actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original undealt Advanced R/C/BG/A/E Semuta initializer with CHOAM only and no optional module. Actual prediction/private Traitors/advisorSetup place BG1 beside original A10 at Arrakeen. Only unopened card/Spice positions and native scalar lotteries are selected; C buys cache1 Semuta and E receives printed Karama.',
|
||||
'Native BG preparation declines and unused turns end through actual controls. A really pays1 for one ordinary reserve into empty Tuek’s5, committing its force, payment and shipment allowance before the independent Spiritual choice. The original paid arrival queue remains native.',
|
||||
'BG actually chooses its free one-counter Polar Sink packet. Every distinct shipping-counter response is really passed. Empty Polar has no residents to match; the engine actually withdraws BG1, places it advisor-side, locks it for this turn and opens mandatory spiritualArrival conversion with the exact new hold.',
|
||||
'Stop before the separate printed conversion cost. No reserve, stance, lock, clock, card, source/frame, original arrival or Semuta window is installed; the original paid entry waits while its actual free child completes.',
|
||||
] };
|
||||
}
|
||||
export type SemutaSpiritualArrivalFixture = ReturnType<typeof createSemutaSpiritualArrivalFixture>;
|
||||
@@ -0,0 +1,196 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
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 SemutaSpiritualCostStage = 'shipping' | 'matching';
|
||||
export type SemutaSpiritualCostStep = { actor: string; action: Action };
|
||||
export const actSemutaSpiritualCost = actSemutaAdvisorMatching;
|
||||
export const passSemutaSpiritualCost = passSemutaAdvisorMatching;
|
||||
export const claimSemutaSpiritualCost = claimSemutaAdvisorMatching;
|
||||
export const semutaSpiritualCostPlayer = semutaAdvisorMatchingPlayer;
|
||||
export const semutaSpiritualCostInventory = semutaAdvisorMatchingInventory;
|
||||
export const semutaSpiritualCostClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingSpiritualAdvisor && !game.pendingArrivalReaction?.length;
|
||||
export const semutaSpiritualCostPhysical = (game: Game) => game.players.map(p => ({ id: p.id, spice: p.spice,
|
||||
forces: p.forces, elites: p.elites, reserves: p.reserves, tanks: p.tanks, advisors: p.advisors,
|
||||
shipped: p.shipped, moved: p.moved, revived: p.revived, freeForcesRevived: p.freeForcesRevived }));
|
||||
|
||||
/** Original Spiritual chooser, shipping/matching responses and fresh costs
|
||||
* remain explicit choices. No native opportunity is silently declined. */
|
||||
export function nextSemutaSpiritualCost(game: Game): SemutaSpiritualCostStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance || game.pendingSpiritualAdvisor ||
|
||||
game.response?.spiritualAdvisorSource || game.response?.automaticAdvisorFlip) return null;
|
||||
if (!game.response && !game.phaseOpening &&
|
||||
(game.decision?.kind === 'advisor' || game.decision?.kind === 'intrusion')) return null;
|
||||
return nextSemutaAdvisorMatching(game);
|
||||
}
|
||||
export const nextSemutaSpiritualCostStep = nextSemutaSpiritualCost;
|
||||
export function advanceSemutaSpiritualCost(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaSpiritualCostStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaSpiritualCost(game);
|
||||
assert.ok(step, `Choose the original Spiritual/fresh control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaSpiritualCost(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The native Semuta/Spiritual programme did not reach its finite boundary');
|
||||
}
|
||||
function responseStage(game: Game) {
|
||||
const response = game.response;
|
||||
if (response?.spiritualAdvisorSource) return JSON.stringify([response.kind, response.spiritualAdvisorSource]);
|
||||
if (response?.automaticAdvisorFlip?.producer === 'spiritualArrival') return JSON.stringify(response.automaticAdvisorFlip);
|
||||
return null;
|
||||
}
|
||||
/** Explicitly allow just this original layer; a later matching or automatic
|
||||
* conversion stays owned until another explicit call. */
|
||||
export function allowSemutaSpiritualCost(game: Game, actions: SemutaSpiritualCostStep[] = []): Game {
|
||||
const stage = responseStage(game); assert.ok(stage);
|
||||
for (let count = 0; responseStage(game) === stage && 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 = actSemutaSpiritualCost(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(responseStage(game), stage); return game;
|
||||
}
|
||||
/** Stop at the restored matching chooser, or at the same active foreign turn
|
||||
* after this original arrival queue has drained. No turn end is invented. */
|
||||
export function finishSemutaSpiritualCost(game: Game, actions: SemutaSpiritualCostStep[] = []): Game {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null, 'Choose the exact fresh cost first');
|
||||
return advanceSemutaSpiritualCost(game, state => semutaSpiritualCostClean(state) ||
|
||||
(!state.response && !state.phaseOpening && state.decision?.kind === 'advisor' &&
|
||||
state.pendingSpiritualAdvisor?.stage === 'offer'), actions);
|
||||
}
|
||||
|
||||
/** Genuine fresh Advanced R/C/BG/A/E. Only unopened physical card/Spice
|
||||
* positions and original setup lotteries are selected. */
|
||||
export function createSemutaSpiritualCostFixture(options: { stage?: SemutaSpiritualCostStage } = {}) {
|
||||
const stage = options.stage ?? 'shipping', seller = 'richese', holder = 'choam', owner = 'beneGesserit';
|
||||
const shipper = 'atreides', spender = 'emperor';
|
||||
const setupTerritory = stage === 'shipping' ? 'arrakeen' : 'polar_sink', setupSector = stage === 'shipping' ? 10 : 0;
|
||||
const territory = stage === 'shipping' ? 'arrakeen' : 'polar_sink', sector = stage === 'shipping' ? 10 : 0;
|
||||
const location = `${territory}:${sector}`, foreignDestination = stage === 'shipping' ? 'arrakeen' : 'tueks_sietch';
|
||||
const foreignSector = stage === 'shipping' ? 10 : 5, foreignLocation = `${foreignDestination}:${foreignSector}`;
|
||||
let game = createGame('SEMUTASPIRITUALCOST', newPlayer(seller, 'Richese', 'richese'), true, ['choam']);
|
||||
for (const faction of ['choam', 'beneGesserit', 'atreides', 'emperor'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaSpiritualCostStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaSpiritualCost(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaSpiritualCost(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), initialization = { before: lobby, after: setup,
|
||||
entry: 'original-undealt-advanced-semuta-preview' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(p => !p.hand.length));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaSpiritualCostInventory(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, printed], 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: SemutaSpiritualCostStep[]; after: Game } | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const step = nextSemutaSpiritualCost(game); assert.ok(step);
|
||||
if (step.action.type === 'advisorSetup') step.action = { type: 'advisorSetup', territory: setupTerritory, sector: setupSector };
|
||||
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), cost = printed.id;
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(semutaSpiritualCostPlayer(game, owner).forces[location], 1);
|
||||
assert.equal(isAdvisor(semutaSpiritualCostPlayer(game, owner), territory), stage === 'shipping');
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
for (const [position, face] of ['funeral_plain', 'hagga_basin'].entries()) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && card.territory === face && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
spiceOrder.push({ position, from, card: structuredClone(card) });
|
||||
}
|
||||
let beforeBid: Game | undefined, bid: SemutaSpiritualCostStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaSpiritualCostPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaSpiritualCost(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(semutaSpiritualCostPlayer(beforeBid, holder).spice - semutaSpiritualCostPlayer(game, holder).spice, 1);
|
||||
const bgTurn = transition(() => {
|
||||
reach(state => state.phase === 5 && state.active === owner && semutaSpiritualCostClean(state));
|
||||
act(owner, { type: 'endMovement' });
|
||||
reach(state => state.phase === 5 && state.active === shipper && semutaSpiritualCostClean(state));
|
||||
});
|
||||
const paidShipmentAction: Action = { type: 'ship', territory: foreignDestination, sector: foreignSector,
|
||||
amount: 1, elite: 0, allyPayment: 0 };
|
||||
const paidParent = transition(() => {
|
||||
act(shipper, paidShipmentAction);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'advisor');
|
||||
});
|
||||
assert.equal(semutaSpiritualCostPlayer(paidParent.before, shipper).spice - semutaSpiritualCostPlayer(game, shipper).spice, 1);
|
||||
assert.equal(semutaSpiritualCostPlayer(game, shipper).forces[foreignLocation], stage === 'shipping' ? 11 : 1);
|
||||
assert.ok(game.decision?.kind === 'advisor'); assert.equal(game.decision.player, owner);
|
||||
assert.equal(game.decision.shipment, shipper); assert.equal(game.decision.destination, foreignLocation);
|
||||
const beforeDeclaration = structuredClone(game), originalChooser = structuredClone(game.decision);
|
||||
const accompany: Action = { type: 'decision', accept: true, accompany: true, amount: 1 };
|
||||
const sink: Action = { type: 'decision', accept: true, accompany: false, amount: 1 };
|
||||
const decline: Action = { type: 'decision', accept: false }, nativeAction = stage === 'shipping' ? accompany : sink;
|
||||
const declaration = transition(() => { act(owner, nativeAction); });
|
||||
assert.equal(game.response?.kind, 'advisor'); assert.ok(game.response?.spiritualAdvisorSource);
|
||||
assert.equal(game.pendingSpiritualAdvisor?.stage, 'shipping');
|
||||
const shipping = structuredClone(game);
|
||||
const shippingAllowance = stage === 'matching'
|
||||
? transition(() => { game = allowSemutaSpiritualCost(game, actions); }) : null;
|
||||
assert.equal(game.response?.kind, stage === 'shipping' ? 'advisor' : 'advisorFlip');
|
||||
assert.equal(game.pendingSpiritualAdvisor?.stage, stage); assert.ok(game.response?.spiritualAdvisorSource && game.pendingSpiritualAdvisor);
|
||||
assert.deepEqual(semutaSpiritualCostPhysical(game), semutaSpiritualCostPhysical(beforeDeclaration));
|
||||
assert.ok(semutaSpiritualCostPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaSpiritualCostPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
const source = structuredClone(game), originalResponse = structuredClone(game.response);
|
||||
const originalFrame = structuredClone(game.pendingSpiritualAdvisor), originalSource = structuredClone(game.response.spiritualAdvisorSource);
|
||||
const originalParent = structuredClone(game.pendingArrivalReaction ?? null), originalArrival = structuredClone(originalParent?.find(arrival => arrival.entrant === shipper));
|
||||
assert.ok(originalParent?.length && originalArrival, 'Keep the actual paid foreign arrival queued behind the Spiritual choice');
|
||||
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
assert.deepEqual(semutaSpiritualCostInventory(game), originalInventory);
|
||||
return { game, source, beforeCost: source, stage, kind: originalResponse.kind, advanced: true as const,
|
||||
owner, bg: owner, recipient: owner, holder, spender, seller, richese: seller, shipper, atreides: shipper, emperor: spender,
|
||||
cost, karama: cost, semuta: SEMUTA_DRUG_ID, action, nativeAction, accompany, sink, decline,
|
||||
replacementAction: stage === 'matching' ? accompany : null, territory, sector, location,
|
||||
foreignDestination, foreignSector, foreignLocation, paidShipmentAction, paidParent, foreignCost: 1,
|
||||
originalResponse, originalFrame, originalSource, originalChooser, originalParent, originalArrival,
|
||||
originalPhysical: structuredClone(semutaSpiritualCostPhysical(game)),
|
||||
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, advisorSetup,
|
||||
deal: selected.map(card => card.id), dealOrder, originalInventory, originalSpice, spiceOrder,
|
||||
acquisition, acquired, bgTurn, beforeDeclaration, declaration, shipping, shippingAllowance,
|
||||
actions, steps: actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original Advanced Semuta initializer on the ready undealt R/C/BG/A/E lobby, exact CHOAM/main35/cache10 and no optional modules. Only unopened original starting-card/Spice positions and original scalar lotteries are selected. Emperor actually receives the printed Karama; CHOAM really purchases cache1 Semuta for one.',
|
||||
stage === 'shipping'
|
||||
? 'BG actually places its original advisor at Arrakeen10 beside native A10, declines preparation and ends movement. Atreides really pays one to ship one reserve there, then BG really declares accompaniment. Stop at the original shipping response before BG withdraws a reserve.'
|
||||
: 'BG actually places its original counter in empty Polar0, which native setup makes a fighter, then ends its real movement turn. Atreides really pays one to ship one reserve to empty Tueks5. BG really declares the Polar fallback and passes its original shipping counter; stop at the separate native matching response before withdrawal or placement.',
|
||||
'The paid foreign shipment, payment, shipped flag and queued arrival are original completed effects. No played hand, force, stance, wallet, clock, receipt or parent is installed. Shipping prevention ends the free packet; matching prevention restores the original chooser and blocks this exact Polar packet. Its real Tueks accompaniment or decline remains available; without Homeworlds no two-counter replacement is claimed.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaSpiritualCostFixture = ReturnType<typeof createSemutaSpiritualCostFixture>;
|
||||
@@ -0,0 +1,189 @@
|
||||
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 {
|
||||
createSemutaAdvisorRelocationFixture as fixture, actSemutaAdvisorRelocation as act,
|
||||
passSemutaAdvisorRelocation as pass, claimSemutaAdvisorRelocation as claim,
|
||||
allowSemutaAdvisorRelocation as allow, finishSemutaAdvisorRelocation as finish,
|
||||
semutaAdvisorRelocationPlayer as player, semutaAdvisorRelocationInventory as inventory,
|
||||
semutaAdvisorRelocationPhysical as physical, type SemutaAdvisorRelocationFixture,
|
||||
} from './fixture-semuta-advisor-relocation';
|
||||
|
||||
const relocationLogs = (game: Game, f: SemutaAdvisorRelocationFixture) => game.log.filter(row => f.kind === 'ambassador'
|
||||
? row.automatic?.name === 'Fremen Ambassador relocation'
|
||||
: row.text.includes('This one-time Discovery entry spends no spice or normal movement.')).length;
|
||||
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: SemutaAdvisorRelocationFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.ok(f.advisorSetup); assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'predict'));
|
||||
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);
|
||||
if (f.kind === 'ambassador') {
|
||||
assert.deepEqual(f.game.players.map(player => player.faction), ['richese', 'choam', 'ecaz', 'beneGesserit', 'atreides']);
|
||||
assert.deepEqual(f.setup.expansions, ['choam', 'ecaz']); assert.ok(!f.setup.discoveryEnabled);
|
||||
assert.ok(f.packet && f.packetCreation && f.placement && f.trigger && f.originalAmbassador && f.originalAmbassadorToken);
|
||||
assert.equal(f.packet.scalars.length, 9);
|
||||
assert.deepEqual(f.packetCreation.after.ecazAmbassadors!.cohort.map(id =>
|
||||
f.packetCreation!.after.ecazAmbassadors!.tokens.find(token => token.id === id)!.effect), f.packet.supply);
|
||||
assert.equal(player(f.afterSetup, f.mover).forces[f.from], 6);
|
||||
assert.equal(isAdvisor(player(f.afterSetup, f.owner), f.origin), true);
|
||||
assert.equal(player(f.placement.before, f.mover).spice - player(f.placement.after, f.mover).spice, 1);
|
||||
assert.equal(f.originalAmbassador.effect, 'fremen'); assert.equal(f.originalAmbassador.beneficiary, f.mover);
|
||||
assert.equal(f.originalAmbassador.relocation?.next, 'flip');
|
||||
assert.equal(f.originalAmbassadorToken.effect, 'fremen'); assert.equal(f.originalAmbassadorToken.zone, 'used');
|
||||
assert.equal(f.originalDiscoveryEntry, null);
|
||||
} else {
|
||||
assert.deepEqual(f.game.players.map(player => player.faction), ['richese', 'choam', 'beneGesserit', 'atreides', 'emperor']);
|
||||
assert.deepEqual(f.setup.expansions, ['choam']); assert.equal(f.setup.discoveryEnabled, true);
|
||||
assert.ok(f.configured.discoveryEnabled && f.tokenPlacement && f.intrusion && f.revelation && f.originalDiscoveryEntry);
|
||||
assert.equal(isAdvisor(player(f.afterSetup, f.owner), f.origin), false);
|
||||
assert.equal(player(f.shipment.before, f.atreides).spice - player(f.intrusion.after, f.atreides).spice, 2);
|
||||
assert.equal(isAdvisor(player(f.intrusion.after, f.owner), f.origin), true);
|
||||
assert.deepEqual(f.revelation.steps.map(step => step.action), [
|
||||
{ type: 'discovery', token: f.token, reveal: false }, { type: 'discovery', token: f.token, reveal: true },
|
||||
]);
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 0);
|
||||
assert.equal(f.originalDiscoveryEntry.stage, 'arrival');
|
||||
assert.equal(f.originalDiscoveryEntry.arrival?.owner, f.mover);
|
||||
assert.deepEqual(f.originalDiscoveryEntry.arrival?.groups, [{ source: f.from, normal: 1, elite: 0 }]);
|
||||
assert.equal(f.originalAmbassador, null); assert.equal(f.originalDiscoveryToken?.status, 'placed');
|
||||
assert.equal(f.originalDiscoveryToken?.revealedTurn, f.revelation.after.turn);
|
||||
}
|
||||
assert.deepEqual(f.relocation.steps, [{ actor: f.mover, action: f.nativeAction }]);
|
||||
assert.equal(player(f.game, f.mover).forces[f.from], undefined);
|
||||
assert.equal(player(f.game, f.mover).forces[f.to], (player(f.beforeDeclaration, f.mover).forces[f.to] ?? 0) + f.amount);
|
||||
assert.deepEqual(f.game.response, f.originalResponse);
|
||||
assert.ok(f.originalAutomaticSource.producer === 'specialRelocation');
|
||||
assert.equal(f.originalAutomaticSource.relocation.kind, f.kind);
|
||||
assert.equal(f.originalAutomaticSource.relocation.player, f.mover);
|
||||
assert.equal(f.originalAutomaticSource.relocation.origin, f.origin);
|
||||
assert.equal(f.originalAutomaticSource.relocation.destination, f.destination);
|
||||
assert.deepEqual(f.originalQueue, [f.originalAutomaticSource]);
|
||||
assert.equal(f.originalHeldStance.aloneFlipHold, f.originalAutomaticSource.event);
|
||||
assert.equal(isAdvisor(player(f.game, f.owner), f.origin), true);
|
||||
assert.equal(relocationLogs(f.game, f) - relocationLogs(f.beforeDeclaration, f), 1);
|
||||
assert.deepEqual(physical(f.game), f.originalPhysical);
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
function open(f: SemutaAdvisorRelocationFixture) {
|
||||
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.pendingAutomaticAdvisorRelocation, f.originalParent);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue);
|
||||
assert.deepEqual(game.pendingAmbassador ?? null, f.originalAmbassador);
|
||||
assert.deepEqual(game.discoveryEntry ?? null, f.originalDiscoveryEntry);
|
||||
assert.deepEqual(player(game, f.owner).advisors?.[f.origin], f.originalHeldStance);
|
||||
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); return game;
|
||||
}
|
||||
function settled(game: Game, f: SemutaAdvisorRelocationFixture, canceled: boolean) {
|
||||
game = finish(game);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
|
||||
assert.equal(game.pendingAutomaticAdvisorRelocation, undefined); assert.equal(game.pendingAutomaticAdvisorFlips, undefined);
|
||||
assert.equal(game.pendingAmbassador ?? null, null); assert.equal(game.discoveryEntry, undefined);
|
||||
assert.equal(game.response?.automaticAdvisorFlip, undefined);
|
||||
assert.deepEqual(physical(game), f.originalPhysical);
|
||||
assert.equal(isAdvisor(player(game, f.owner), f.origin), canceled);
|
||||
if (canceled) assert.deepEqual(player(game, f.owner).advisors?.[f.origin], f.originalHeldStance);
|
||||
else assert.equal(player(game, f.owner).advisors?.[f.origin], undefined);
|
||||
if (f.kind === 'ambassador')
|
||||
assert.deepEqual(game.ecazAmbassadors!.tokens.find(token => token.id === f.token), f.originalAmbassadorToken);
|
||||
else {
|
||||
assert.deepEqual(game.discoveries!.tokens.find(token => token.id === f.token), f.originalDiscoveryToken);
|
||||
assert.deepEqual(game.discoveries!.newlyRevealed, []);
|
||||
}
|
||||
assert.equal(relocationLogs(game, f), relocationLogs(f.game, f));
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function prevented(game: Game, f: SemutaAdvisorRelocationFixture, claimed: boolean) {
|
||||
game = settled(game, f, true);
|
||||
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)); return game;
|
||||
}
|
||||
|
||||
for (const kind of ['ambassador', 'discovery'] as const) for (const claimed of [false, true]) void test(
|
||||
`Advanced native ${kind} relocation advisor conversion used cancellation cost ${claimed ? 'claim' : 'pass'} retains the completed transfer and resumes its original child once`, () => {
|
||||
const f = fixture({ kind }); 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 });
|
||||
const before = structuredClone(game); assert.equal(finish(game), game); assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
for (const kind of ['ambassador', 'discovery'] as const) void test(
|
||||
`Advanced native ${kind} relocation advisor conversion allowance flips only the stranded group after the original transfer`, () => {
|
||||
const f = fixture({ kind }); nativeSource(f);
|
||||
const game = settled(allow(structuredClone(f.game)), f, false);
|
||||
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));
|
||||
});
|
||||
|
||||
for (const kind of ['ambassador', 'discovery'] as const) void test(
|
||||
`fresh ${kind} relocation conversion costs reject stale and underlying controls while the original child stays parked`, () => {
|
||||
const f = fixture({ kind }), 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.mover, f.nativeAction);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorRelocation, f.originalParent);
|
||||
assert.deepEqual(game.pendingAmbassador ?? null, f.originalAmbassador);
|
||||
assert.deepEqual(game.discoveryEntry ?? null, f.originalDiscoveryEntry);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); prevented(pass(game), f, false);
|
||||
});
|
||||
|
||||
void test('changed original relocation evidence rejects before cost and before a fresh Semuta answer', () => {
|
||||
for (const kind of ['ambassador', 'discovery'] as const) {
|
||||
const f = fixture({ kind }), beforeCost = structuredClone(f.game);
|
||||
assert.ok(beforeCost.pendingAutomaticAdvisorRelocation);
|
||||
beforeCost.pendingAutomaticAdvisorRelocation.signature = 'different-original-relocation';
|
||||
reject(beforeCost, f.spender, f.action);
|
||||
assert.ok(player(beforeCost, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.equal(beforeCost.discard.some(card => card.id === f.cost), false);
|
||||
const afterCost = open(f); assert.ok(afterCost.pendingAutomaticAdvisorRelocation);
|
||||
afterCost.pendingAutomaticAdvisorRelocation.signature = 'different-original-relocation';
|
||||
reject(afterCost, f.holder, { type: 'semutaCommit', event: afterCost.pendingTreacheryDiscard!.batch.event });
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal policies answer both actual native relocation conversion costs legally', () => {
|
||||
for (const kind of ['ambassador', 'discovery'] as const) {
|
||||
const f = fixture({ kind }), 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);
|
||||
prevented(game, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
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 { fighterCount, isAdvisor } from '../game/advisors';
|
||||
import { quoteAutomaticAdvisorFlips } from '../game/advisor-automatic-flip';
|
||||
import {
|
||||
createSemutaSpiritualArrivalFixture as fixture, actSemutaSpiritualArrival as act,
|
||||
passSemutaSpiritualArrival as pass, claimSemutaSpiritualArrival as claim,
|
||||
allowSemutaSpiritualArrival as allow, finishSemutaSpiritualArrival as finish,
|
||||
advanceSemutaSpiritualArrival as advance, semutaSpiritualArrivalClean as clean,
|
||||
semutaSpiritualArrivalPlayer as player, semutaSpiritualArrivalInventory as inventory,
|
||||
semutaSpiritualArrivalPhysical as physical, type SemutaSpiritualArrivalFixture,
|
||||
} from './fixture-semuta-spiritual-arrival';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
const placementLogs = (game: Game, owner: string) => game.log.filter(row => row.text.startsWith(`${player(game, owner).name} sent 1 free`));
|
||||
function nativeSource(f: SemutaSpiritualArrivalFixture) {
|
||||
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.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', '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.ok(f.setupChoices.steps.some(step => step.actor === f.owner && step.action.type === 'advisorSetup' &&
|
||||
step.action.territory === 'arrakeen' && step.action.sector === 10));
|
||||
assert.equal(player(f.afterSetup, f.owner).forces['arrakeen:10'], 1);
|
||||
assert.equal(player(f.afterSetup, f.shipper).forces['arrakeen:10'], 10);
|
||||
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.deepEqual(f.shipment.steps[0], { actor: f.shipper, action: f.shipmentAction });
|
||||
assert.equal(player(f.shipment.before, f.shipper).spice - player(f.shipment.after, f.shipper).spice, 1);
|
||||
assert.equal(player(f.shipment.before, f.shipper).reserves - player(f.shipment.after, f.shipper).reserves, 1);
|
||||
assert.equal(player(f.game, f.shipper).forces[f.paidLocation], 1);
|
||||
assert.equal(player(f.game, f.shipper).shipped, true); assert.equal(player(f.game, f.shipper).moved, 0);
|
||||
assert.equal(f.declaration.before.decision?.kind, 'advisor');
|
||||
assert.deepEqual(f.declaration.steps, [{ actor: f.owner, action: f.chooseAction }]);
|
||||
assert.equal(f.shippingResponse.kind, 'advisor'); assert.deepEqual(f.shippingResponse.spiritualAdvisorSource, f.shippingSource);
|
||||
assert.equal(f.shippingFrame.stage, 'shipping'); assert.equal(player(f.declaration.after, f.owner).forces[f.location] ?? 0, 0);
|
||||
assert.equal(player(f.declaration.after, f.owner).reserves, player(f.declaration.before, f.owner).reserves);
|
||||
assert.ok(f.placed.steps.some(step => step.action.type === 'passResponse'));
|
||||
assert.ok(f.placed.steps.every(step => step.action.type === 'passResponse'), 'Only the actual distinct shipping counter is allowed');
|
||||
assert.equal(f.originalFrame.stage, 'arrival'); assert.equal(f.originalFrame.event, f.shippingSource.event);
|
||||
assert.deepEqual(f.originalFrame.source, f.shippingSource);
|
||||
assert.deepEqual(f.originalFrame.deposit, { key: f.location, before: 0 });
|
||||
assert.deepEqual(f.originalFrame.order, { player: f.owner, territory: f.territory, sector: 0, amount: 1, elite: 0, advisors: true });
|
||||
assert.deepEqual(Object.keys(f.shippingSource).sort(), ['amount', 'event', 'owner', 'phase', 'sector', 'territory', 'turn']);
|
||||
assert.equal(f.shippingSource.territory, 'polar_sink'); assert.equal(f.shippingSource.sector, 0);
|
||||
assert.equal(f.originalAutomaticSource.producer, 'spiritualArrival');
|
||||
assert.deepEqual(f.originalAutomaticSource.spiritual, f.shippingSource);
|
||||
assert.deepEqual(f.originalAutomaticSource.forces, { [f.location]: 1 });
|
||||
assert.deepEqual(f.originalQueue, [f.originalAutomaticSource]);
|
||||
assert.deepEqual(f.originalPaidParent, [{ entrant: f.shipper, territory: f.paidTerritory, sector: f.paidSector,
|
||||
amount: 1, elite: 0, cause: 'shipment', resume: 'none', turn: 1, phase: 5 }]);
|
||||
assert.equal(player(f.game, f.owner).reserves, player(f.declaration.before, f.owner).reserves - 1);
|
||||
assert.equal(player(f.game, f.owner).forces[f.location], 1);
|
||||
assert.equal(player(f.game, f.owner).advisors?.[f.territory]?.lockedTurn, 1);
|
||||
assert.equal(player(f.game, f.owner).advisors?.[f.territory]?.aloneFlipHold, f.originalAutomaticSource.event);
|
||||
assert.equal(isAdvisor(player(f.game, f.owner), f.territory), true);
|
||||
assert.equal(fighterCount(player(f.game, f.owner), f.territory), 0);
|
||||
assert.equal(placementLogs(f.game, f.owner).length, 1);
|
||||
assert.equal(f.game.phase, 5); assert.equal(f.game.turn, 1); assert.equal(f.game.active, f.shipper);
|
||||
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: SemutaSpiritualArrivalFixture) {
|
||||
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 committed Spiritual conversion 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.pendingSpiritualAdvisor, f.originalFrame);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue);
|
||||
assert.deepEqual(game.pendingArrivalReaction, f.originalPaidParent);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(player(game, f.owner).advisors, f.originalStance);
|
||||
assert.deepEqual(player(game, f.owner).advisors?.[f.territory], f.originalLocks);
|
||||
assert.deepEqual(game.deck, f.originalDeck); 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 settled(state: Game, f: SemutaSpiritualArrivalFixture, prevented: boolean) {
|
||||
const steps: { actor: string; action: Action }[] = [], game = finish(state, steps);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingSpiritualAdvisor, undefined);
|
||||
assert.equal(game.pendingAutomaticAdvisorFlips, undefined); assert.equal(game.pendingArrivalReaction?.length ?? 0, 0);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.deepEqual(physical(game), f.originalPhysical, 'Neither the real free grant nor the paid arrival is transferred or paid again');
|
||||
assert.equal(player(game, f.owner).reserves, player(f.declaration.before, f.owner).reserves - 1);
|
||||
assert.equal(player(game, f.owner).forces[f.location], 1);
|
||||
assert.equal(player(game, f.shipper).forces[f.paidLocation], 1);
|
||||
assert.equal(player(game, f.owner).forces['arrakeen:10'], 1); assert.equal(player(game, f.shipper).forces['arrakeen:10'], 10);
|
||||
assert.equal(isAdvisor(player(game, f.owner), 'arrakeen'), true);
|
||||
assert.equal(isAdvisor(player(game, f.owner), f.territory), prevented);
|
||||
assert.equal(fighterCount(player(game, f.owner), f.territory), prevented ? 0 : 1);
|
||||
if (prevented) {
|
||||
assert.deepEqual(player(game, f.owner).advisors, f.originalStance);
|
||||
assert.equal(player(game, f.owner).advisors?.[f.territory]?.lockedTurn, 1);
|
||||
assert.equal(player(game, f.owner).advisors?.[f.territory]?.aloneFlipHold, f.originalAutomaticSource.event);
|
||||
} else assert.equal(player(game, f.owner).advisors?.[f.territory], undefined);
|
||||
assert.deepEqual(quoteAutomaticAdvisorFlips(game), []); assert.equal(placementLogs(game, f.owner).length, 1);
|
||||
assert.ok(steps.every(step => !['card', 'ship', 'move', 'endMovement'].includes(step.action.type)));
|
||||
assert.deepEqual(game.deck, f.originalDeck); assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function canceled(state: Game, f: SemutaSpiritualArrivalFixture, claimed: boolean) {
|
||||
const game = settled(state, f, true);
|
||||
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.spender, f.action); reject(game, f.owner, f.chooseAction); reject(game, f.shipper, f.shipmentAction); return game;
|
||||
}
|
||||
function continueMovement(state: Game, f: SemutaSpiritualArrivalFixture, prevented: boolean) {
|
||||
const steps: { actor: string; action: Action }[] = [];
|
||||
let game = act(state, f.shipper, { type: 'endMovement' }, steps);
|
||||
game = advance(game, current => current.phase >= 6 && current.phase <= 7 && clean(current), steps);
|
||||
assert.equal(steps.filter(step => step.actor === f.shipper && step.action.type === 'endMovement').length, 1);
|
||||
assert.ok(steps.every(step => !['ship', 'move', 'card'].includes(step.action.type)));
|
||||
assert.equal(game.pendingSpiritualAdvisor, undefined); assert.equal(game.pendingAutomaticAdvisorFlips, undefined);
|
||||
assert.equal(game.pendingArrivalReaction?.length ?? 0, 0); assert.equal(placementLogs(game, f.owner).length, 1);
|
||||
assert.equal(player(game, f.owner).forces[f.location], 1); assert.equal(player(game, f.shipper).forces[f.paidLocation], 1);
|
||||
assert.equal(isAdvisor(player(game, f.owner), f.territory), prevented);
|
||||
if (prevented) assert.equal(player(game, f.owner).advisors?.[f.territory]?.aloneFlipHold, f.originalAutomaticSource.event);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
|
||||
for (const claimed of [false, true]) void test(
|
||||
`Advanced native empty-Polar Spiritual arrival conversion cost Semuta ${claimed ? 'claim' : 'pass'} preserves the committed grant and paid parent then resumes once`, () => {
|
||||
const f = fixture(); 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 });
|
||||
continueMovement(game, f, true);
|
||||
},
|
||||
);
|
||||
|
||||
void test('native Spiritual arrival allowance converts the really placed Polar advisor before resuming the original paid entry', () => {
|
||||
const f = fixture(); nativeSource(f); const game = settled(allow(structuredClone(f.game)), f, false);
|
||||
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));
|
||||
continueMovement(game, f, false);
|
||||
});
|
||||
|
||||
void test('fresh Spiritual arrival cost rejects stale and underlying declarations while its exact source deposit and paid queue remain held', () => {
|
||||
const f = fixture(), 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.chooseAction);
|
||||
reject(game, f.owner, { type: 'decision', accept: false }); reject(game, f.shipper, f.shipmentAction);
|
||||
reject(game, f.shipper, { type: 'endMovement' }); reject(game, f.owner, { type: 'prepareAdvisors', territory: f.territory });
|
||||
assert.deepEqual(game.pendingSpiritualAdvisor, f.originalFrame); assert.deepEqual(game.pendingArrivalReaction, f.originalPaidParent);
|
||||
assert.deepEqual(player(game, f.owner).advisors?.[f.territory], f.originalLocks); assert.deepEqual(physical(game), f.originalPhysical);
|
||||
canceled(pass(game), f, false);
|
||||
});
|
||||
|
||||
void test('controlled Spiritual source deposit and paid-parent mismatches reject before cost or fresh Semuta appropriation', () => {
|
||||
const f = fixture();
|
||||
// These deliberately changed copies test source validation only. They are
|
||||
// separate negative controls, never the genuine fixture's native history.
|
||||
const changes: { name: string; change: (game: Game) => void }[] = [
|
||||
{ name: 'source', change(game) {
|
||||
const frame = game.pendingSpiritualAdvisor!;
|
||||
frame.source = { ...frame.source!, event: `${frame.source!.event}:stale` };
|
||||
} },
|
||||
{ name: 'deposit', change(game) {
|
||||
const frame = game.pendingSpiritualAdvisor!;
|
||||
frame.deposit = { ...frame.deposit!, before: frame.deposit!.before + 1 };
|
||||
} },
|
||||
{ name: 'paid parent', change(game) {
|
||||
game.pendingArrivalReaction = game.pendingArrivalReaction!.map((entry, index) =>
|
||||
index === 0 ? { ...entry, amount: entry.amount + 1 } : entry);
|
||||
} },
|
||||
];
|
||||
for (const { name, 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), name);
|
||||
assert.ok(player(beforeCost, f.holder).hand.some(card => card.id === f.semuta), name);
|
||||
assert.deepEqual(inventory(beforeCost), f.originalInventory);
|
||||
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), name);
|
||||
assert.equal(offered.discard.filter(card => card.id === f.cost).length, 1, name);
|
||||
assert.deepEqual(inventory(offered), f.originalInventory);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally answer the real placed Spiritual arrival conversion cost', () => {
|
||||
const f = fixture(), 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);
|
||||
canceled(game, f, claimed);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
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 { spiritualAdvisorMatchingKey } from '../game/spiritual-advisor';
|
||||
import {
|
||||
createSemutaSpiritualCostFixture as fixture, actSemutaSpiritualCost as act,
|
||||
passSemutaSpiritualCost as pass, claimSemutaSpiritualCost as claim, allowSemutaSpiritualCost as allow,
|
||||
finishSemutaSpiritualCost as finish, semutaSpiritualCostPlayer as player,
|
||||
semutaSpiritualCostInventory as inventory, semutaSpiritualCostPhysical as physical,
|
||||
semutaSpiritualCostClean as clean, type SemutaSpiritualCostFixture,
|
||||
} from './fixture-semuta-spiritual-cost';
|
||||
|
||||
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 });
|
||||
const grantLogs = (game: Game, owner: string) => game.log.filter(entry =>
|
||||
entry.text.startsWith(`${player(game, owner).name} sent 1 free`));
|
||||
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: SemutaSpiritualCostFixture) {
|
||||
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.deepEqual(f.advisorSetup.steps[0].action, { type: 'advisorSetup', territory: f.territory, sector: f.sector });
|
||||
assert.equal(player(f.afterSetup, f.owner).forces[f.location], 1);
|
||||
assert.equal(isAdvisor(player(f.afterSetup, f.owner), f.territory), f.stage === 'shipping');
|
||||
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.ok(f.bgTurn.steps.some(step => step.actor === f.owner && step.action.type === 'endMovement'));
|
||||
assert.deepEqual(f.paidParent.steps[0], { actor: f.shipper, action: f.paidShipmentAction });
|
||||
assert.equal(player(f.paidParent.before, f.shipper).spice - player(f.game, f.shipper).spice, 1);
|
||||
assert.equal(player(f.game, f.shipper).reserves, player(f.paidParent.before, f.shipper).reserves - 1);
|
||||
assert.equal(player(f.game, f.shipper).forces[f.foreignLocation], f.stage === 'shipping' ? 11 : 1);
|
||||
assert.equal(player(f.game, f.shipper).shipped, true); assert.equal(player(f.game, f.shipper).moved, 0);
|
||||
assert.deepEqual(f.beforeDeclaration.decision, f.originalChooser);
|
||||
assert.equal(f.originalChooser.shipment, f.shipper); assert.equal(f.originalChooser.destination, f.foreignLocation);
|
||||
assert.equal(f.originalChooser.ambassadorEvent ?? null, null); assert.equal(f.originalChooser.revivalEvent ?? null, null);
|
||||
assert.deepEqual(f.declaration.steps, [{ actor: f.owner, action: f.nativeAction }]);
|
||||
assert.equal(f.shipping.response?.kind, 'advisor'); assert.equal(f.shipping.pendingSpiritualAdvisor?.stage, 'shipping');
|
||||
assert.deepEqual(physical(f.shipping), physical(f.beforeDeclaration));
|
||||
if (f.stage === 'matching') {
|
||||
assert.ok(f.shippingAllowance); assert.ok(f.shippingAllowance.steps.length > 0);
|
||||
assert.ok(f.shippingAllowance.steps.every(step => step.action.type === 'passResponse'));
|
||||
assert.equal(f.game.response?.advisors, false); assert.equal(f.game.response?.automaticAdvisorFlip ?? null, null);
|
||||
} else assert.equal(f.shippingAllowance, null);
|
||||
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.shipper);
|
||||
assert.equal(f.game.phaseOpening ?? null, null); assert.equal(f.game.decision ?? null, null);
|
||||
assert.equal(f.game.response?.kind, f.stage === 'shipping' ? 'advisor' : 'advisorFlip');
|
||||
assert.deepEqual(f.game.response, f.originalResponse); assert.deepEqual(f.game.pendingSpiritualAdvisor, f.originalFrame);
|
||||
assert.equal(f.originalFrame.stage, f.stage); assert.deepEqual(f.originalFrame.decision, f.originalChooser);
|
||||
assert.deepEqual(f.originalFrame.source, f.originalSource); assert.deepEqual(f.originalFrame.denied, []);
|
||||
assert.equal(f.originalFrame.selection, JSON.stringify([f.originalSource, f.originalFrame.order]));
|
||||
assert.deepEqual(f.game.pendingArrivalReaction, f.originalParent); assert.ok(f.originalArrival);
|
||||
assert.equal(f.originalArrival.entrant, f.shipper); assert.equal(f.originalArrival.territory, f.foreignDestination);
|
||||
assert.equal(f.originalArrival.sector, f.foreignSector); assert.equal(f.originalArrival.amount, 1);
|
||||
assert.equal(f.game.pendingShipment ?? null, null); assert.equal(f.game.pendingAmbassador ?? null, null);
|
||||
assert.deepEqual(physical(f.game), physical(f.beforeDeclaration)); assert.equal(grantLogs(f.game, f.owner).length, 0);
|
||||
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: SemutaSpiritualCostFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending);
|
||||
assert.equal(pending.continuation.kind, f.stage === 'shipping' ? 'karamaTerminalDiscard' : 'karamaAdvisorFlipDiscard');
|
||||
if (pending.continuation.kind !== 'karamaTerminalDiscard' && pending.continuation.kind !== 'karamaAdvisorFlipDiscard')
|
||||
throw Error('Expected this exact native Spiritual cancellation cost');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ id: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||
[{ id: f.cost, by: f.spender, face: true }]);
|
||||
assert.equal(pending.batch.cause, `karama:${f.originalResponse.kind}`);
|
||||
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||
assert.deepEqual(game.pendingSpiritualAdvisor, f.originalFrame); assert.deepEqual(game.pendingArrivalReaction, f.originalParent);
|
||||
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(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 settled(game: Game, f: SemutaSpiritualCostFixture, placement: boolean, target = f.territory) {
|
||||
const done = finish(game); assert.equal(clean(done), true); assert.equal(done.pendingSpiritualAdvisor ?? null, null);
|
||||
assert.equal(done.pendingArrivalReaction?.length ?? 0, 0); assert.equal(done.pendingShipment ?? null, null);
|
||||
assert.deepEqual(clock(done), clock(f.game)); assert.deepEqual(inventory(done), f.originalInventory);
|
||||
assert.equal(grantLogs(done, f.owner).length, Number(placement));
|
||||
const bg = player(done, f.owner), original = player(f.game, f.owner);
|
||||
assert.equal(bg.reserves, original.reserves - Number(placement)); assert.equal(bg.spice, original.spice);
|
||||
assert.equal(bg.shipped, original.shipped); assert.equal(bg.moved, original.moved); assert.equal(bg.tanks, original.tanks);
|
||||
assert.equal(bg.forces[f.location], original.forces[f.location] + Number(placement && target === f.territory));
|
||||
if (placement && target === f.foreignDestination && f.stage === 'matching') assert.equal(bg.forces[f.foreignLocation], 1);
|
||||
for (const p of done.players.filter(p => p.id !== f.owner))
|
||||
assert.deepEqual(physical(done).find(seat => seat.id === p.id), f.originalPhysical.find(seat => seat.id === p.id));
|
||||
if (!placement) assert.deepEqual(physical(done), f.originalPhysical);
|
||||
else assert.equal(isAdvisor(bg, target), f.stage === 'shipping' || target === f.foreignDestination);
|
||||
assert.equal(player(done, f.shipper).forces[f.foreignLocation], f.stage === 'shipping' ? 11 : 1);
|
||||
reject(done, f.owner, f.nativeAction); return done;
|
||||
}
|
||||
function denied(state: Game, f: SemutaSpiritualCostFixture, claimed: boolean) {
|
||||
const game = finish(state); assert.equal(game.response ?? null, null); assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game));
|
||||
assert.equal(grantLogs(game, f.owner).length, 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));
|
||||
if (f.stage === 'matching') {
|
||||
assert.deepEqual(game.decision, f.originalChooser); assert.equal(game.pendingSpiritualAdvisor?.stage, 'offer');
|
||||
assert.deepEqual(game.pendingArrivalReaction, f.originalParent);
|
||||
assert.deepEqual(game.pendingSpiritualAdvisor?.denied, [spiritualAdvisorMatchingKey(f.originalSource)]);
|
||||
assert.equal(game.pendingSpiritualAdvisor?.source ?? null, null); assert.equal(game.pendingSpiritualAdvisor?.order ?? null, null);
|
||||
reject(game, f.owner, f.sink);
|
||||
const choices = viewGame(game, f.owner).spiritualAdvisorChoices; assert.ok(choices);
|
||||
assert.ok(choices.choices.filter(choice => !choice.accompany).every(choice => choice.blocked));
|
||||
assert.ok(choices.choices.some(choice => choice.accompany && !choice.blocked));
|
||||
} else {
|
||||
assert.equal(game.pendingSpiritualAdvisor ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(clean(game), true);
|
||||
}
|
||||
assert.deepEqual(inventory(game), f.originalInventory); reject(game, f.spender, f.action); return game;
|
||||
}
|
||||
|
||||
for (const stage of ['shipping', 'matching'] as const) for (const claimed of [false, true]) void test(
|
||||
`native Spiritual ${stage} Karama cost Semuta ${claimed ? 'claim' : 'pass'} preserves the paid parent and applies original denial once`, () => {
|
||||
const f = fixture({ stage }); nativeSource(f); const offered = open(f);
|
||||
let game = denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
if (stage === 'matching') game = act(game, f.owner, f.decline);
|
||||
settled(game, f, false);
|
||||
},
|
||||
);
|
||||
|
||||
for (const stage of ['shipping', 'matching'] as const) void test(
|
||||
`native Spiritual ${stage} allowance withdraws and places the original free packet once without another foreign payment`, () => {
|
||||
const f = fixture({ stage }); nativeSource(f); const game = settled(allow(structuredClone(f.game)), f, 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));
|
||||
},
|
||||
);
|
||||
|
||||
void test('native Spiritual matching denial retains the legal actual accompaniment replacement for claim and pass', () => {
|
||||
for (const claimed of [false, true]) {
|
||||
const f = fixture({ stage: 'matching' }), offered = open(f);
|
||||
let game = denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
assert.ok(f.replacementAction); game = act(game, f.owner, f.replacementAction);
|
||||
if (game.response?.spiritualAdvisorSource) game = allow(game);
|
||||
settled(game, f, true, f.foreignDestination);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Spiritual cost rejects stale foreign and underlying original choices while the exact cost is offered', () => {
|
||||
for (const stage of ['shipping', 'matching'] as const) {
|
||||
const f = fixture({ stage }), 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.decline);
|
||||
reject(game, f.owner, { type: 'passResponse' }); reject(game, f.shipper, { type: 'endMovement' });
|
||||
const done = denied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event });
|
||||
}
|
||||
});
|
||||
|
||||
void test('controlled Spiritual parent or exact source signature change rejects the original cost without spending it', () => {
|
||||
for (const stage of ['shipping', 'matching'] as const) for (const changed of ['parent', 'source'] as const) {
|
||||
const f = fixture({ stage }), game = structuredClone(f.game);
|
||||
// Explicit malformed-source supplement; primary source remains genuinely
|
||||
// earned. No played custody, physical group, wallet or clock is installed.
|
||||
if (changed === 'parent') game.pendingSpiritualAdvisor!.signature = 'changed-original-parent';
|
||||
else game.response!.spiritualAdvisorSource = { ...f.originalSource, amount: 2 };
|
||||
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 actual Spiritual shipping and matching costs', () => {
|
||||
for (const stage of ['shipping', 'matching'] as const) {
|
||||
const f = fixture({ stage }), 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);
|
||||
game = denied(game, f, claimed);
|
||||
if (stage === 'matching') game = act(game, f.owner, f.decline);
|
||||
settled(game, f, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user