Offer Semuta on Guild and Revival Spiritual grants
This commit is contained in:
1 parent
617a6593a9
commit
99d442fc68
11 files changed
+1645
-17
No files matched your search
@@ -16,6 +16,45 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Semuta on Guild Ambassador and Revival Spiritual grants — 26 October. Prototyped / Partial.**
|
||||
Existing terminal/advisor cost continuations now retain the original Guild
|
||||
Ambassador child or Tleilax SEND return through Spiritual shipping, matching and
|
||||
placed-arrival cancellation. Pure native validators bind the exact grant,
|
||||
selected packet and completed deposit. Shipping denial withdraws no BG reserve;
|
||||
matching restores the original chooser; alone-conversion denial retains the
|
||||
placed advisor/lock/hold. The original child or return/income then finishes once.
|
||||
Conditional parent signatures preserve earlier ordinary formats. Persistent
|
||||
controls/minimal bots are reused; public mode gates stay intact.
|
||||
|
||||
A focused fresh Advanced Tleilax World entry uses original R/C/BG/A/T setup,
|
||||
exact CHOAM+Ix and Homeworlds only. Native initialization creates its actual
|
||||
custody; it redeals no played game. Basic T World, extra E3 decks and other
|
||||
modules remain outside this entry. The existing provisional SEND/Spiritual
|
||||
contract stays authoritative; this is not a new publisher ruling.
|
||||
|
||||
Three disjoint source lanes and root integration pass **15 files,157/157**,
|
||||
types/lint/build and **twelve native engine/GameTable SSR/actual Semuta-callback
|
||||
journeys** on unchanged source. Thirty new cases cover six genuine sources,
|
||||
claim/pass, allowance, actual accompaniment replacement, exact child/return/
|
||||
income, source/stale rejection, entry negatives and four minimal profiles.
|
||||
Guild costs use90/94 original actions; arrival uses121. Actual Guild supply,
|
||||
paid token/A entrant, Ecaz free shipment and BG declaration earn each source.
|
||||
Revival uses217/220/221 actions: real initial market/dials, T paid1 to Hagga13,
|
||||
next-Storm casualty, free revive1 at High19 and actual SEND to Arrakeen. Native
|
||||
income1 waits through the child. Actual cache1/dealt Karama supply all45/57-card
|
||||
sources; no played force, hand, wallet, stance, custody, clock or parent is
|
||||
installed. Controlled malformed/configuration copies are labelled supplements.
|
||||
|
||||
Feedback finishes the opening market before first dials and permits immediate
|
||||
native replacement settlement when nobody can counter. Final frozen v2 report:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-spiritual-grants-proof-v2/`;
|
||||
journeys/HTML: adjacent `20261026-semuta-spiritual-grants-runtime/`.
|
||||
Nested Revival Guild/Terror children, other grants/modules, complete modes and
|
||||
final assurance remain open. No saved-room/D1/server, reset/restart, deployment
|
||||
or recovery operation occurs; DOM/hydration, browser/mobile and deployed
|
||||
acceptance remain due.
|
||||
[Source and scope](SEMUTA_SOURCE_UPDATE_20260907.md#guild-ambassador-and-revival-spiritual-costs--26-october-2026).
|
||||
|
||||
**Guild advisor arrival and Semuta matching costs — 26 October. Prototyped / Partial.**
|
||||
Fresh Guild board transport now retains an advisor group's source stance when
|
||||
joining a foreign group, including its Guild ally. Shared projected entry and
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 26 October 2026 — Semuta on Guild Ambassador and Revival Spiritual grants
|
||||
|
||||
**Prototyped / Partial.** Existing terminal/advisor cost continuations now retain
|
||||
the native Guild Ambassador child or Tleilax SEND parent through Spiritual
|
||||
shipping, matching and committed-arrival denial. Exact pure source validation
|
||||
and conditional signatures retain the grant, deposit, lock/hold, original return
|
||||
and waiting income. Shipping denial spends no free reserve; matching restores
|
||||
its chooser; conversion denial keeps the placed advisor. Native suffixes finish
|
||||
once. Earlier ordinary formats, persistent controls/minimal bots and public mode
|
||||
gates remain intact.
|
||||
[Source and scope](SEMUTA_SOURCE_UPDATE_20260907.md#guild-ambassador-and-revival-spiritual-costs--26-october-2026).
|
||||
|
||||
The focused fresh Advanced Tleilax World entry uses exact CHOAM+Ix, original
|
||||
R/C/BG/A/T and no other module. Actual native initialization creates custody
|
||||
once; Basic T World and extra E3 remain rejected without input mutation. No
|
||||
played game is upgraded or redealt. This follows the existing provisional
|
||||
SEND/Spiritual contract rather than adding a publisher ruling.
|
||||
|
||||
Three disjoint source lanes and root integration pass **15 files,157/157**,
|
||||
types/lint/build and **twelve native engine/GameTable SSR/actual Semuta-callback
|
||||
journeys** on unchanged source. Thirty new cases cover six genuine sources,
|
||||
claim/pass, allowance, real accompaniment replacement, exact child/return/income,
|
||||
source/stale/configuration rejection and four minimal profiles. Guild90/94/121
|
||||
earns original supply, paid token/entrant and real Ecaz/BG free shipments.
|
||||
Revival217/220/221 earns actual T paid1 Hagga13, next-Storm loss, free revive1 at
|
||||
High19, real SEND to Arrakeen and retained income1. Actual cache1 and dealt
|
||||
Karama supply all45/57 cards; no played stock, hand, wallet, stance, custody,
|
||||
clock, receipt or parent is installed. Malformed/configuration negatives are
|
||||
explicitly labelled supplements.
|
||||
|
||||
Narrow feedback completes the initial market before dials and follows native
|
||||
immediate settlement when no counter remains. Final frozen v2 report/logs:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-spiritual-grants-proof-v2/`;
|
||||
scripts/journeys/HTML: adjacent `20261026-semuta-spiritual-grants-runtime/`.
|
||||
Nested Revival Guild/Terror children, broader grants/modules and full-mode/final
|
||||
rules/recovery/privacy/custody assurance remain open. These are not DOM/hydration,
|
||||
browser/mobile or deployed results. No saved-room, D1, server, reset/restart,
|
||||
deployment or recovery operation occurs.
|
||||
|
||||
## 26 October 2026 — Guild advisor arrival and Semuta matching costs
|
||||
|
||||
**Prototyped / Partial.** Fix fresh Guild board transport dropping an advisor's
|
||||
|
||||
@@ -2,6 +2,92 @@
|
||||
|
||||
7 September 2026. Read `docs/SEMUTA_DRUG_ENGINE_AUDIT.md` in full, inspected the physical card again, checked current capacity/recovery code, and freshly searched publisher sources. No project runtime edits.
|
||||
|
||||
## Guild Ambassador and Revival Spiritual costs — 26 October 2026
|
||||
|
||||
**Prototyped / Partial.** Existing `karamaTerminalDiscard` and
|
||||
`karamaAdvisorFlipDiscard` now admit original Guild Ambassador-derived Spiritual
|
||||
shipping/matching/arrival costs and native Tleilax SEND-derived costs. The
|
||||
[Spiritual contract](SPIRITUAL_ADVISORS.md),
|
||||
[Guild child contract](GUILD_AMBASSADOR_RULES.md) and existing
|
||||
[provisional Revival composition](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md#revival-arrival-responses--25-october-2026)
|
||||
remain authoritative. There is no new ruling, public start or full-mode claim.
|
||||
|
||||
Pure preflight first validates the exact current Spiritual frame/packet or
|
||||
committed automatic conversion. The ordinary paid-entry qualifier stays
|
||||
unchanged. A normal phase-five Guild child additionally requires its actual
|
||||
`advisor` receipt, shipment owner and destination. A phase-four native Tleilax
|
||||
source instead requires its original current SEND return/event, player,
|
||||
destination, stage and native return validator. No phase-four Guild/Terror child
|
||||
or unrelated grant is admitted by those tests. Conditional parent signatures
|
||||
bind only these new Ambassador/token or Revival/progress/custody/free-use
|
||||
contexts; previous ordinary receipt bytes stay unchanged. Preflight executes
|
||||
neither shipment, revival, transfer, income nor arrival.
|
||||
|
||||
Fresh claim/pass restores the original response and runs native denial once.
|
||||
Shipping ends the free packet before withdrawal; matching restores its original
|
||||
chooser with the exact denied packet blocked; a legal different accompaniment
|
||||
or decline remains available. Automatic conversion prevention retains the
|
||||
already withdrawn/deposited advisor, lock and continuously-alone hold. The
|
||||
original Guild receipt/outer arrival or SEND/deferred income completes once.
|
||||
Token, paid foreign entry, free revival, physical SEND and earlier BG placement
|
||||
are never replayed. Existing persistent neutral controls/private choice and
|
||||
minimal legal profiles serve the sources.
|
||||
|
||||
The fresh development entry now also admits original Advanced R/C/BG/A/T with
|
||||
exact CHOAM+Ix and Homeworlds alone. It uses the existing native occupation
|
||||
initializer, creating actual typed custody once before play; no played state is
|
||||
retagged, upgraded or redealt. Basic T World, extra E3 decks, Skills, Nexus, Tech,
|
||||
Strongholds and Discoveries remain outside this focused envelope. Existing
|
||||
classic exact-CHOAM World behavior remains separate. This admission is prototype
|
||||
evidence, not a public publication gate.
|
||||
|
||||
Three disjoint [Guild choice](../tests/semuta-spiritual-ambassador-cost-engine.test.ts),
|
||||
[Guild arrival](../tests/semuta-spiritual-ambassador-arrival-engine.test.ts) and
|
||||
[Revival](../tests/semuta-spiritual-revival-engine.test.ts) lanes plus root
|
||||
integration pass **15 files,157/157**, types/lint/build and **twelve native
|
||||
engine/GameTable SSR/actual Semuta-callback journeys** on unchanged source.
|
||||
Thirty new cases cover six genuine sources, claim/pass, native allowance, real
|
||||
accompaniment replacement, exact continuation/income, source/stale/configuration
|
||||
rejection and four minimal profiles. Every source earns original setup, actual
|
||||
cache1 Semuta and originally dealt Karama. All45/57 Treachery cards remain
|
||||
physical; no played hand, force, wallet, stance, custody, clock, token, source or
|
||||
parent is installed. Controlled negative copies are labelled supplements.
|
||||
|
||||
Guild shipping/matching takes90/94 native actions from original Advanced
|
||||
R/C/Ecaz/BG/A, exact CHOAM+Ecaz and no modules. The original nine-draw packet
|
||||
earns Guild; Ecaz pays1 for its Carthag token, A pays1 to enter, and Ecaz really
|
||||
ships its independent free1 to Arrakeen or Tuek's. BG's original Arrakeen advisor
|
||||
accompanies the first; its original Polar fighter creates the second matching
|
||||
source after actual shipping passes. Matching denial permits real Tuek's
|
||||
accompaniment, with the already placed Ecaz counter keeping it non-alone.
|
||||
Guild arrival takes121 actions with the original six-seat E addition: Ecaz
|
||||
really ships1 to empty Tuek's and BG chooses Polar1, passes shipping and actually
|
||||
withdraws/places/locks the advisor before its separate conversion counter.
|
||||
|
||||
Revival shipping/matching/arrival takes217/220/221 actions from the original
|
||||
Advanced T World profile. The actual opening market finishes before dials11+0
|
||||
move the Storm to12. T really pays2 to ship1 to Hagga13; the earlier ordinary
|
||||
Spiritual choice is explicitly declined. A native next-Storm card1 kills that
|
||||
counter, leaving High19/Tanks1. Actual free revive1 opens the owned SEND choice;
|
||||
T really places1 in Arrakeen10. BG's actual accompaniment or Polar declaration
|
||||
then earns shipping, matching or a committed Polar deposit. Native T income1
|
||||
stays retained until the original entry/free child finishes. Replacement after
|
||||
matching uses the actual Arrakeen accompaniment; it repeats no return or fee.
|
||||
|
||||
Feedback keeps Storm dials behind the original opening market and removes an
|
||||
assumption that every legal replacement must wait for a counter when none
|
||||
remains. Final frozen v2 binds2930 files to
|
||||
`c293f5f13bcdce957d2b3f4f039d2e11b9823e9f3b451809ba761e5e3c18295b`.
|
||||
Private report/logs:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-spiritual-grants-proof-v2/`;
|
||||
scripts/journeys/HTML: adjacent `20261026-semuta-spiritual-grants-runtime/`.
|
||||
|
||||
Nested phase-four Guild/Terror children, other independent grants, wider modules,
|
||||
complete modes and final rules/recovery/privacy/custody assurance remain open.
|
||||
SSR/component callbacks do not certify DOM/hydration, browser/mobile or deployed
|
||||
acceptance. No saved-room, D1, server, reset/restart, deployment or recovery
|
||||
operation occurs.
|
||||
|
||||
## Guild advisor arrival and matching costs — 26 October 2026
|
||||
|
||||
**Prototyped / Partial.** Existing `karamaAdvisorFlipDiscard` now admits native
|
||||
|
||||
+51
-17
@@ -5861,15 +5861,31 @@ 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. */
|
||||
/** Qualify the original foreign entry and its exact selected Spiritual packet.
|
||||
* Native quotes retain the shipping/matching/arrival stage and owned parent. */
|
||||
function semutaSpiritualKaramaParent(g: Game, response: ResponseWindow): boolean {
|
||||
if (!semutaSpiritualArrivalSupported(g)) return false;
|
||||
if (!g.advanced) return false;
|
||||
const context: Game = {...g,pendingTreacheryDiscard:null,response};
|
||||
try {
|
||||
if (response.spiritualAdvisorSource) currentSpiritualAdvisorResponse(g,response);
|
||||
else if (response.automaticAdvisorFlip?.producer === 'spiritualArrival') currentAutomaticAdvisorFlip(g,response);
|
||||
if (response.spiritualAdvisorSource) currentSpiritualAdvisorResponse(context,response);
|
||||
else if (response.automaticAdvisorFlip?.producer === 'spiritualArrival') currentAutomaticAdvisorFlip(context,response);
|
||||
else return false;
|
||||
return true;
|
||||
const frame = spiritualFrameBody(context), decision = frame.decision;
|
||||
if (decision.ambassadorEvent) {
|
||||
if (g.phase !== 5 || decision.revivalEvent || g.pendingAmbassador?.revivalEvent) return false;
|
||||
const {order} = guildAmbassadorArrival(context,decision.ambassadorEvent,'advisor');
|
||||
return decision.shipment === order.player &&
|
||||
decision.destination === location(order.territory,order.sector);
|
||||
}
|
||||
if (decision.revivalEvent) {
|
||||
const parent = g.homeworldRevivalReturn;
|
||||
if (g.phase !== 4 || g.pendingAmbassador || !parent || parent.stage !== 'arrival' ||
|
||||
parent.quote.kind !== 'tleilax' || decision.revivalEvent !== parent.event ||
|
||||
decision.shipment !== parent.player || decision.destination !== parent.destination) return false;
|
||||
homeworldRule(() => validateHomeworldRevivalReturn(context,parent));
|
||||
return true;
|
||||
}
|
||||
return semutaSpiritualArrivalSupported(context);
|
||||
} catch (error) {
|
||||
if (error instanceof RuleError) return false;
|
||||
throw error;
|
||||
@@ -5881,9 +5897,10 @@ function semutaTerminalKaramaResponse(g: Game, response: ResponseWindow, player:
|
||||
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.battle || g.phaseOpening || g.pendingKarama || (g.pendingRevival && !spiritual) ||
|
||||
g.pendingShipment || g.pendingGuildTransport || g.pendingHomeworldShipment ||
|
||||
(g.pendingSpiritualAdvisor && !spiritual) || g.pendingAmbassador || g.pendingTerrorEntry ||
|
||||
(g.pendingSpiritualAdvisor && !spiritual) ||
|
||||
(g.pendingAmbassador && !(spiritual && g.pendingSpiritualAdvisor?.decision.ambassadorEvent)) || g.pendingTerrorEntry ||
|
||||
g.pendingArrivalOverlap || (!spiritual && !semutaTerminalArrivalSupported(g,response)) ||
|
||||
g.pendingIxMove || g.pendingFremenMove || g.pendingChoamMove ||
|
||||
g.ornithopter || g.summonedWorm || g.wormRides.length > 0 ||
|
||||
@@ -6092,6 +6109,10 @@ function semutaMarketGrantKaramaResponse(g: Game, response: ResponseWindow, play
|
||||
/** Matching and completed relocation can retain the original Ambassador child.
|
||||
* Their native source validators still prove the exact parent before admission. */
|
||||
function semutaAdvisorAmbassadorParent(g: Game, response: ResponseWindow): boolean {
|
||||
if ((response.kind === 'advisor' || response.kind === 'advisorFlip') &&
|
||||
g.pendingSpiritualAdvisor?.decision.ambassadorEvent &&
|
||||
(response.spiritualAdvisorSource || response.automaticAdvisorFlip?.producer === 'spiritualArrival'))
|
||||
return semutaSpiritualKaramaParent(g,response);
|
||||
if (response.kind !== 'advisorFlip') return false;
|
||||
if (response.advisorAmbassadorMatchingSource)
|
||||
return !!g.pendingAdvisorAmbassadorMatching &&
|
||||
@@ -6108,7 +6129,7 @@ function semutaAdvisorFlipKaramaResponse(g: Game, response: ResponseWindow, play
|
||||
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.pendingRevival && !spiritual) || g.pendingShipment || g.pendingGuildTransport || g.pendingHomeworldShipment ||
|
||||
g.pendingFremenMove || g.pendingIxMove || g.pendingMobileMove || g.pendingChoamMove ||
|
||||
(g.pendingSpiritualAdvisor && !spiritual) || (g.pendingAmbassador && !ambassador) || g.pendingTerrorEntry || g.pendingArrivalOverlap ||
|
||||
(!spiritual && !semutaAdvisorArrivalSupported(g,response)) || g.ornithopter || g.summonedWorm || g.wormRides.length > 0 ||
|
||||
@@ -6415,6 +6436,15 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
|
||||
advisorProducer === 'spiritualArrival') ? {
|
||||
frame:g.pendingSpiritualAdvisor, arrivals:g.pendingArrivalReaction,
|
||||
tech:g.techTokens,
|
||||
...(g.pendingSpiritualAdvisor?.decision.ambassadorEvent ? {
|
||||
ambassador:g.pendingAmbassador, tokens:g.ecazAmbassadors,
|
||||
} : {}),
|
||||
...(g.pendingSpiritualAdvisor?.decision.revivalEvent ? {
|
||||
revival:g.homeworldRevivalReturn, progress:g.homeworldRevivalProgress,
|
||||
pending:g.pendingRevival, rules:g.revivalRules, free:g.freeRevival,
|
||||
custody:g.homeworlds, income:g.revivalFreeIncome,
|
||||
players:g.players.map(p => ({id:p.id,revived:p.revived,free:p.freeForcesRevived,leader:p.leaderRevived})),
|
||||
} : {}),
|
||||
} : undefined,
|
||||
order: g.order,
|
||||
players: g.players.map(p => ({
|
||||
@@ -6922,13 +6952,17 @@ function semutaNexusProfile(g: Game): boolean {
|
||||
mixedE1E2NexusLeaderSkillsProfile(g)
|
||||
: pairedNexusModulesProfile(g) || singleE1E2NexusProfile(g) || mixedE1E2NexusProfile(g));
|
||||
}
|
||||
/** Original Richese/CHOAM Homeworld occupation only; other optional envelopes stay separate. */
|
||||
/** Original classic occupation or a focused Advanced Tleilax SEND profile.
|
||||
* Other optional envelopes remain separate; no recorded game is redealt. */
|
||||
function semutaHomeworldRoster(g: Game): boolean {
|
||||
return !!g.homeworlds && g.expansions.length === 1 && g.expansions[0] === 'choam' &&
|
||||
const classic = g.expansions.length === 1 && g.expansions[0] === 'choam' &&
|
||||
g.players.every(p => p.faction === 'richese' || p.faction === 'choam' || faction(p.faction).expansion === 'base');
|
||||
const tleilax = g.advanced === true && g.expansions.length === 2 &&
|
||||
g.expansions.includes('choam') && g.expansions.includes('ix') && !!byFaction(g,'tleilaxu') &&
|
||||
g.players.every(p => ['richese','choam','tleilaxu'].includes(p.faction) || faction(p.faction).expansion === 'base');
|
||||
return !!g.homeworlds && (classic || tleilax) &&
|
||||
g.players.length >= 2 && g.players.length <= 6 &&
|
||||
g.players.some(p => p.faction === 'richese') &&
|
||||
g.players.every(p => p.faction === 'richese' || p.faction === 'choam' ||
|
||||
faction(p.faction).expansion === 'base') &&
|
||||
!g.nexusCards && !g.leaderSkills && !g.techTokens && !g.strongholdCards &&
|
||||
!g.discoveryEnabled && !g.discoveries && !g.discoveryStash &&
|
||||
!g.greatMaker && !g.ecazTreachery;
|
||||
@@ -6985,7 +7019,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
const ambassadorCost =
|
||||
(continuation?.kind === 'karamaBonusDiscard' && continuation.use.response.source === 'ambassador') ||
|
||||
(continuation?.kind === 'karamaNoFieldDiscard' && continuation.use.response.source === 'ambassadorNoField') ||
|
||||
(continuation?.kind === 'karamaAdvisorFlipDiscard' &&
|
||||
((continuation?.kind === 'karamaAdvisorFlipDiscard' || continuation?.kind === 'karamaTerminalDiscard') &&
|
||||
semutaAdvisorAmbassadorParent(g,continuation.use.response));
|
||||
requireRule(
|
||||
(g.status === 'playing' || (truth && g.status === 'setup')) &&
|
||||
@@ -8643,7 +8677,7 @@ function semutaOfferSupported(
|
||||
!g.pendingArrivalOverlap && !g.pendingRevival &&
|
||||
!g.ornithopter && !g.ecologicalStorm)) &&
|
||||
(richeseGiftDiscard || ambassadorBenefitDiscard || (!g.pendingExchange && (replacementCostDiscard || !g.pendingIxAlly) && !g.pendingNullentropy &&
|
||||
(!g.pendingAmbassador || advisorDiscard &&
|
||||
(!g.pendingAmbassador || (advisorDiscard || terminalDiscard) &&
|
||||
semutaAdvisorAmbassadorParent(g,continuation.use.response)) &&
|
||||
(terrorAllianceDiscard || !g.pendingTerrorEntry))) &&
|
||||
(richeseGiftDiscard || noFieldDiscard || continuation.kind === 'karamaShipmentDiscard' ||
|
||||
@@ -11837,7 +11871,7 @@ export function initializeSemutaGameForAudit(state: Game, skills = false): Game
|
||||
state.expansions.includes('choam') &&
|
||||
(state.expansions.every(id => id === 'choam' || id === 'ix') || basicNative || advancedEcaz) &&
|
||||
state.players.some(p => p.faction === 'richese'),
|
||||
'Semuta needs a fresh Richese faction game: bare Basic selected native families, bare Advanced Ecaz/Richese, or CHOAM with optional Ix and supported original modules/skills; separate exact-CHOAM Homeworld occupation has no other modules. Other overlays remain excluded.');
|
||||
'Semuta needs a fresh Richese faction game: bare Basic selected native families, bare Advanced Ecaz/Richese, or CHOAM with optional Ix and supported original modules/skills; separate classic or Advanced Tleilax Homeworld occupation has no other modules. Other overlays remain excluded.');
|
||||
if (state.discoveryEnabled) requireRule(!basicNative && !skills && !state.homeworlds &&
|
||||
!state.nexusCards && discoveryModuleProfile(state),
|
||||
'Semuta Discovery keeps original Richese/CHOAM and optional Ix setup, without Skills, Homeworlds, Nexus or E3 overlays.');
|
||||
@@ -11845,7 +11879,7 @@ export function initializeSemutaGameForAudit(state: Game, skills = false): Game
|
||||
!state.discoveryEnabled && !state.discoveries,
|
||||
'Semuta Nexus retains original E1/E2 rosters without Homeworlds, Discovery or E3.');
|
||||
if (state.homeworlds) requireRule(!skills && semutaHomeworldRoster(state),
|
||||
'Semuta Homeworlds require original Richese/CHOAM and classic factions, exact CHOAM, and no other modules.');
|
||||
'Semuta Homeworlds require original Richese/CHOAM and classic factions with exact CHOAM, or Advanced Tleilax with exact CHOAM+Ix, and no other modules.');
|
||||
const g = state.discoveryEnabled ? initializeDiscoveryGameForAudit(state)
|
||||
: state.homeworlds ? initializeHomeworldOccupationGameForAudit(state)
|
||||
: skills ? initializeLeaderSkillsGameForAudit(state)
|
||||
|
||||
@@ -1504,6 +1504,12 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/guild-advisor-source-arrival-engine.test.ts',
|
||||
'tests/fixture-semuta-advisor-ambassador-matching.ts',
|
||||
'tests/semuta-advisor-ambassador-matching-engine.test.ts',
|
||||
'tests/fixture-semuta-spiritual-ambassador-cost.ts',
|
||||
'tests/semuta-spiritual-ambassador-cost-engine.test.ts',
|
||||
'tests/fixture-semuta-spiritual-ambassador-arrival.ts',
|
||||
'tests/semuta-spiritual-ambassador-arrival-engine.test.ts',
|
||||
'tests/fixture-semuta-spiritual-revival.ts',
|
||||
'tests/semuta-spiritual-revival-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,242 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaCombatCapabilities, nextSemutaCombatCapabilities,
|
||||
semutaCombatCapabilitiesPlayer, semutaCombatCapabilitiesInventory, semutaCombatCapabilitiesClean,
|
||||
passSemutaCombatCapabilities, claimSemutaCombatCapabilities,
|
||||
} from './fixture-semuta-combat-capabilities';
|
||||
|
||||
export type SemutaSpiritualAmbassadorArrivalStep = { actor: string; action: Action };
|
||||
export const actSemutaSpiritualAmbassadorArrival = actSemutaCombatCapabilities;
|
||||
export const semutaSpiritualAmbassadorArrivalPlayer = semutaCombatCapabilitiesPlayer;
|
||||
export const semutaSpiritualAmbassadorArrivalInventory = semutaCombatCapabilitiesInventory;
|
||||
export const semutaSpiritualAmbassadorArrivalClean = semutaCombatCapabilitiesClean;
|
||||
export const passSemutaSpiritualAmbassadorArrival = passSemutaCombatCapabilities;
|
||||
export const claimSemutaSpiritualAmbassadorArrival = claimSemutaCombatCapabilities;
|
||||
export const semutaSpiritualAmbassadorArrivalPhysical = (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, moved: p.moved, shipped: p.shipped })),
|
||||
});
|
||||
|
||||
/** Placements, the Guild benefit and free Spiritual choice remain explicit
|
||||
* native selections. No mandatory conversion is silently answered here. */
|
||||
export function nextSemutaSpiritualAmbassadorArrival(game: Game): SemutaSpiritualAmbassadorArrivalStep | 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 ecaz = game.players.find(p => p.faction === 'ecaz');
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && ecaz?.reserves === 20)
|
||||
return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } };
|
||||
const bg = game.players.find(p => p.faction === 'beneGesserit');
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && bg && !bg.advisorSetup)
|
||||
return { actor: bg.id, action: { type: 'advisorSetup', territory: 'arrakeen', sector: 10 } };
|
||||
const d = game.decision;
|
||||
if (d?.kind === 'ecazPlacement' || d?.kind === 'ecazAmbassador' || 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 nextSemutaSpiritualAmbassadorArrivalStep = nextSemutaSpiritualAmbassadorArrival;
|
||||
export function advanceSemutaSpiritualAmbassadorArrival(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaSpiritualAmbassadorArrivalStep[] = []): Game {
|
||||
for (let count = 0; count < 2800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextSemutaSpiritualAmbassadorArrival(game);
|
||||
assert.ok(next, `Supply the real Guild/Spiritual choice at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaSpiritualAmbassadorArrival(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The native Guild Spiritual-arrival source did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaSpiritualAmbassadorArrival(game: Game, actions: SemutaSpiritualAmbassadorArrivalStep[] = []): 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 = actSemutaSpiritualAmbassadorArrival(game, p.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.automaticAdvisorFlip?.producer, 'spiritualArrival'); return game;
|
||||
}
|
||||
/** Finish only this free child's Guild/outer suffix. The original Atreides
|
||||
* combined turn and actual endMovement remain caller-owned. */
|
||||
export function finishSemutaSpiritualAmbassadorArrival(game: Game, actions: SemutaSpiritualAmbassadorArrivalStep[] = []): Game {
|
||||
assert.ok(!game.pendingTreacheryDiscard);
|
||||
return advanceSemutaSpiritualAmbassadorArrival(game, state => !state.pendingSpiritualAdvisor &&
|
||||
!state.pendingAmbassador && !state.pendingAutomaticAdvisorFlips?.length && !state.pendingArrivalReaction?.length &&
|
||||
semutaSpiritualAmbassadorArrivalClean(state), actions);
|
||||
}
|
||||
|
||||
function originalGuildPacket() {
|
||||
const original = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
|
||||
const supply: Exclude<AmbassadorEffect, 'ecaz'>[] = ['guild', ...original.filter(effect => effect !== 'guild').slice(0, 4)];
|
||||
const order = [...supply, ...original.filter(effect => !supply.includes(effect))], current = [...original], scalars: number[] = [];
|
||||
for (let index = current.length - 1; index > 0; index--) {
|
||||
const from = current.indexOf(order[index]); assert.ok(from >= 0 && from <= index);
|
||||
scalars.push(Math.floor((from + 0.5) * 4294967296 / (index + 1)));
|
||||
[current[index], current[from]] = [current[from], current[index]];
|
||||
}
|
||||
assert.deepEqual(current, order); return { supply, order, scalars };
|
||||
}
|
||||
function createOriginalPacket(game: Game, step: SemutaSpiritualAmbassadorArrivalStep,
|
||||
actions: SemutaSpiritualAmbassadorArrivalStep[], scalars: number[]) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1)
|
||||
array[0] = cursor < scalars.length ? scalars[cursor++] : 0xffffffff;
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try {
|
||||
const next = applyAction(game, step.actor, step.action); assert.equal(cursor, scalars.length);
|
||||
actions.push(structuredClone(step)); return next;
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Native original Guild packet, paid entrant, real independent Ecaz reserve
|
||||
* and actually placed BG Polar advisor. No played parent or state is installed. */
|
||||
export function createSemutaSpiritualAmbassadorArrivalFixture() {
|
||||
const richese = 'richese', holder = 'choam', ecaz = 'ecaz', owner = 'beneGesserit', entrant = 'atreides', spender = 'emperor';
|
||||
const territory = 'polar_sink', sector = 0, location = 'polar_sink:0', guildTerritory = 'tueks_sietch', guildSector = 5, guildLocation = 'tueks_sietch:5';
|
||||
let game = createGame('SEMUTASPIRITUALAMBASSADORARRIVAL', newPlayer(richese, 'Richese', 'richese'), true, ['choam', 'ecaz']);
|
||||
for (const faction of ['choam', 'ecaz', 'beneGesserit', 'atreides', 'emperor'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaSpiritualAmbassadorArrivalStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaSpiritualAmbassadorArrival(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaSpiritualAmbassadorArrival(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), at = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||
};
|
||||
for (const p of game.players) act(p.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(p => p.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaSpiritualAmbassadorArrivalInventory(game); assert.equal(originalInventory.length, 45);
|
||||
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile' || card.kind === 'poison').slice(0, 5);
|
||||
assert.equal(ordinary.length, 5); const selected = [...ordinary, printed];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(c => c.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { card: card.id, position, from };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game), cost = printed.id, packet = originalGuildPacket();
|
||||
let packetCreation: { before: Game; steps: SemutaSpiritualAmbassadorArrivalStep[]; after: Game } | null = null;
|
||||
let advisorSetup: { before: Game; steps: SemutaSpiritualAmbassadorArrivalStep[]; after: Game } | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 180; count++) {
|
||||
const next = nextSemutaSpiritualAmbassadorArrival(game); assert.ok(next);
|
||||
const before = structuredClone(game), at = actions.length;
|
||||
const creates = game.setupStage === 'traitors' && next.action.type === 'traitor' &&
|
||||
game.players.filter(p => p.traitorChoices.length).length === 1;
|
||||
if (creates) game = createOriginalPacket(game, next, actions, packet.scalars);
|
||||
else act(next.actor, next.action);
|
||||
const record = { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||
if (creates) packetCreation = record;
|
||||
if (next.action.type === 'advisorSetup') advisorSetup = record;
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(packetCreation && advisorSetup);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(c => c.id)), selected.map(c => c.id));
|
||||
assert.deepEqual(game.ecazAmbassadors!.cohort.map(id => game.ecazAmbassadors!.tokens.find(t => t.id === id)!.effect), packet.supply);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const 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: SemutaSpiritualAmbassadorArrivalStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 1000 && !semutaSpiritualAmbassadorArrivalPlayer(game, holder).hand.some(c => c.id === SEMUTA_DRUG_ID); count++) {
|
||||
const next = nextSemutaSpiritualAmbassadorArrival(game); assert.ok(next);
|
||||
if (next.actor === holder && next.action.type === 'richeseBid' && next.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bid = structuredClone(next); bidAt = actions.length;
|
||||
}
|
||||
act(next.actor, next.action);
|
||||
}
|
||||
assert.ok(beforeBid && bid && bidAt >= 0);
|
||||
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||
reach(state => state.turn === 1 && state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
const token = game.ecazAmbassadors!.tokens.find(t => t.effect === 'guild' && t.zone === 'supply'); assert.ok(token);
|
||||
const placement = transition(() => {
|
||||
act(ecaz, { type: 'decision', token: token.id, territory: 'carthag' });
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
act(ecaz, { type: 'decision', decline: true });
|
||||
});
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === entrant && semutaSpiritualAmbassadorArrivalClean(state));
|
||||
let ordinaryDecline: { before: Game; steps: SemutaSpiritualAmbassadorArrivalStep[]; after: Game } | null = null;
|
||||
const paidAction: Action = { type: 'ship', territory: 'carthag', sector: 11, amount: 1, elite: 0, allyPayment: 0 };
|
||||
const beforeEntrantShipment = structuredClone(game), entrantAt = actions.length;
|
||||
act(entrant, paidAction);
|
||||
for (let count = 0; !(game.pendingAmbassador?.stage === 'offer' && !game.response && !game.phaseOpening) && count < 100; count++) {
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'advisor' && !game.decision.ambassadorEvent)
|
||||
ordinaryDecline = transition(() => { act(owner, { type: 'decision', accept: false }); });
|
||||
else { const next = nextSemutaSpiritualAmbassadorArrival(game); assert.ok(next); act(next.actor, next.action); }
|
||||
}
|
||||
const entrantShipment = { before: beforeEntrantShipment, steps: structuredClone(actions.slice(entrantAt)), after: structuredClone(game) };
|
||||
assert.ok(game.pendingAmbassador?.stage === 'offer'); assert.ok(ordinaryDecline);
|
||||
const event = game.pendingAmbassador.event;
|
||||
const triggerAction: Action = { type: 'decision', event, trigger: true, beneficiary: ecaz };
|
||||
const trigger = transition(() => {
|
||||
act(ecaz, triggerAction); reach(state => !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'ship');
|
||||
});
|
||||
const grantAction: Action = { type: 'decision', event, territory: guildTerritory, sector: guildSector, amount: 1, elite: 0 };
|
||||
const grant = transition(() => {
|
||||
act(ecaz, grantAction);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'advisor' && state.decision.ambassadorEvent === event);
|
||||
});
|
||||
assert.equal(semutaSpiritualAmbassadorArrivalPlayer(game, ecaz).forces[guildLocation], 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), spiritualSource = structuredClone(game.response.spiritualAdvisorSource);
|
||||
const shippingFrame = structuredClone(game.pendingSpiritualAdvisor!);
|
||||
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.ok(game.pendingSpiritualAdvisor?.stage === 'arrival' && game.pendingAmbassador?.shipmentReceipt);
|
||||
assert.equal(game.pendingAmbassador.shipmentReceipt.next, 'advisor');
|
||||
assert.equal(game.pendingSpiritualAdvisor.decision.ambassadorEvent, event);
|
||||
assert.equal(semutaSpiritualAmbassadorArrivalPlayer(game, owner).forces[location], 1);
|
||||
assert.deepEqual(semutaSpiritualAmbassadorArrivalInventory(game), originalInventory);
|
||||
return { game, kind: 'advisorFlip' as const, advanced: true as const, richese, holder, ecaz, owner, entrant, spender,
|
||||
cost, karama: cost, semuta: SEMUTA_DRUG_ID, action: { type: 'card', card: cost, mode: 'cancel' } as Action,
|
||||
originalResponse: structuredClone(game.response), originalSource: spiritualSource, originalSpiritualSource: spiritualSource,
|
||||
originalAutomaticSource: structuredClone(automatic), originalFrame: structuredClone(game.pendingSpiritualAdvisor),
|
||||
originalQueue: structuredClone(game.pendingAutomaticAdvisorFlips!), originalParent: structuredClone(game.pendingAmbassador),
|
||||
originalAmbassador: structuredClone(game.pendingAmbassador), originalReceipt: structuredClone(game.pendingAmbassador.shipmentReceipt),
|
||||
originalDeposit: structuredClone(game.pendingSpiritualAdvisor.deposit!),
|
||||
originalLocks: structuredClone(semutaSpiritualAmbassadorArrivalPlayer(game, owner).advisors?.[territory]),
|
||||
originalStance: structuredClone(semutaSpiritualAmbassadorArrivalPlayer(game, owner).advisors!),
|
||||
originalPhysical: structuredClone(semutaSpiritualAmbassadorArrivalPhysical(game)), originalTokens: structuredClone(game.ecazAmbassadors!),
|
||||
originalPaidQueue: structuredClone(game.pendingArrivalReaction ?? null), originalDeck: structuredClone(game.deck),
|
||||
token: token.id, event, territory, sector, location, guildTerritory, guildSector, guildLocation,
|
||||
paidAction, triggerAction, grantAction, chooseAction, shippingResponse, shippingFrame,
|
||||
initial, lobby, setup, beforeStartingDeal, afterSetup, packet, packetCreation, advisorSetup,
|
||||
dealOrder, deal: selected.map(c => c.id), originalInventory, originalSpice, spiceOrder,
|
||||
acquisition, acquired, cache: acquisition, placement, entrantShipment, ordinaryDecline,
|
||||
trigger, grant, declaration, placed, actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original narrow Advanced R/C/Ecaz/BG/A/E Semuta initializer, exact CHOAM+Ecaz and no modules. Original packet draws earn Guild; actual Ecaz Basin6 and BG Arrakeen1 beside A10 setup remain native. C actually buys cache1 Semuta and E is dealt original printed Karama.',
|
||||
'Ecaz actually pays1 to place Guild in Carthag. A actually pays1 to enter; BG explicitly declines that earlier ordinary Spiritual opportunity. Ecaz really triggers Guild for itself, consuming the original token before choosing its independent free reserve1 shipment to empty Tuek’s5.',
|
||||
'BG then actually chooses free Polar1 from the Guild shipment’s native advisor decision. Real shipping-counter passes withdraw and place an advisor, lock it and open mandatory spiritualArrival. The original Guild receipt remains at advisor with its exact committed order while the free child completes.',
|
||||
'No paid hand, wallet, army, stance, token, parent, source/frame, lock, deposit, clock or Semuta window is installed. Stop before the separate printed conversion cost; original native child and outer callbacks remain unchanged.',
|
||||
] };
|
||||
}
|
||||
export type SemutaSpiritualAmbassadorArrivalFixture = ReturnType<typeof createSemutaSpiritualAmbassadorArrivalFixture>;
|
||||
@@ -0,0 +1,285 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaSpiritualCost, passSemutaSpiritualCost, claimSemutaSpiritualCost, allowSemutaSpiritualCost,
|
||||
semutaSpiritualCostPlayer, semutaSpiritualCostInventory, semutaSpiritualCostPhysical,
|
||||
} from './fixture-semuta-spiritual-cost';
|
||||
import { nextSemutaRicheseCacheKarama } from './fixture-semuta-richese-cache-karama';
|
||||
|
||||
export type SemutaSpiritualAmbassadorCostStage = 'shipping' | 'matching';
|
||||
export type SemutaSpiritualAmbassadorCostStep = { actor: string; action: Action };
|
||||
export const actSemutaSpiritualAmbassadorCost = actSemutaSpiritualCost;
|
||||
export const passSemutaSpiritualAmbassadorCost = passSemutaSpiritualCost;
|
||||
export const claimSemutaSpiritualAmbassadorCost = claimSemutaSpiritualCost;
|
||||
export const allowSemutaSpiritualAmbassadorCost = allowSemutaSpiritualCost;
|
||||
export const semutaSpiritualAmbassadorCostPlayer = semutaSpiritualCostPlayer;
|
||||
export const semutaSpiritualAmbassadorCostInventory = semutaSpiritualCostInventory;
|
||||
export const semutaSpiritualAmbassadorCostPhysical = semutaSpiritualCostPhysical;
|
||||
export const semutaSpiritualAmbassadorCostClean = (game: Game) => !game.response && !game.phaseOpening &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingSpiritualAdvisor &&
|
||||
!game.pendingAmbassador && !game.pendingArrivalReaction?.length;
|
||||
|
||||
/** Keep actual Ambassador grants and Spiritual choices explicit. The earlier
|
||||
* ordinary Carthag accompaniment is separately declined by its owned source. */
|
||||
export function nextSemutaSpiritualAmbassadorCost(game: Game): SemutaSpiritualAmbassadorCostStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance || game.pendingSpiritualAdvisor ||
|
||||
game.response?.spiritualAdvisorSource || game.response?.automaticAdvisorFlip) return null;
|
||||
if (!game.response && !game.phaseOpening) {
|
||||
const bg = game.players.find(player => player.faction === 'beneGesserit'); assert.ok(bg);
|
||||
const ecaz = game.players.find(player => player.faction === 'ecaz'); assert.ok(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' && ecaz.reserves === 20)
|
||||
return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } };
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'advisor' || decision?.kind === 'intrusion' ||
|
||||
decision?.kind === 'ecazPlacement' || decision?.kind === 'ecazAmbassador') return null;
|
||||
if (decision?.kind === 'advisorBattle')
|
||||
return { actor: decision.player, action: { type: 'decision', accept: false } };
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && !bg.advisorSetup)
|
||||
return { actor: bg.id, action: { type: 'advisorSetup', territory: 'arrakeen', sector: 10 } };
|
||||
}
|
||||
return nextSemutaRicheseCacheKarama(game);
|
||||
}
|
||||
export function advanceSemutaSpiritualAmbassadorCost(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaSpiritualAmbassadorCostStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaSpiritualAmbassadorCost(game);
|
||||
assert.ok(step, 'Choose the original Ambassador, Spiritual or fresh-cost control before continuing');
|
||||
game = actSemutaSpiritualAmbassadorCost(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original Guild Ambassador Spiritual programme did not reach its finite boundary');
|
||||
}
|
||||
/** Matching prevention restores the real chooser. Otherwise finish only the
|
||||
* original Guild parent, leaving the same native entrant turn active. */
|
||||
export function finishSemutaSpiritualAmbassadorCost(game: Game,
|
||||
actions: SemutaSpiritualAmbassadorCostStep[] = []): Game {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null, 'Choose the original fresh cost first');
|
||||
return advanceSemutaSpiritualAmbassadorCost(game, state => semutaSpiritualAmbassadorCostClean(state) ||
|
||||
(!state.response && !state.phaseOpening && state.decision?.kind === 'advisor' &&
|
||||
state.pendingSpiritualAdvisor?.stage === 'offer'), actions);
|
||||
}
|
||||
|
||||
function originalGuildPacket() {
|
||||
const original = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
|
||||
const supply: Exclude<AmbassadorEffect, 'ecaz'>[] = ['guild', ...original.filter(effect => effect !== 'guild').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 };
|
||||
}
|
||||
function createOriginalPacket(game: Game, step: SemutaSpiritualAmbassadorCostStep,
|
||||
actions: SemutaSpiritualAmbassadorCostStep[], 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 after = applyAction(game, step.actor, step.action);
|
||||
assert.equal(cursor, scalars.length, 'The original Ecaz packet consumes its nine selected native draws');
|
||||
actions.push(structuredClone(step)); return after;
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Real Advanced narrow Ecaz Semuta setup, paid token and entrant, then an
|
||||
* actual free Guild child shipment. No played card, counter, stance, wallet,
|
||||
* clock, token, grant, receipt or control is assigned. */
|
||||
export function createSemutaSpiritualAmbassadorCostFixture(options: {
|
||||
stage?: SemutaSpiritualAmbassadorCostStage;
|
||||
} = {}) {
|
||||
const stage = options.stage ?? 'shipping', richese = 'richese', holder = 'choam', ecaz = 'ecaz',
|
||||
owner = 'beneGesserit', spender = 'atreides', entrant = spender, shipper = ecaz;
|
||||
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('SEMUTASPIRITUALAMBASSADORCOST', newPlayer(richese, 'Richese', 'richese'), true, ['choam', 'ecaz']);
|
||||
for (const faction of [holder, ecaz, owner, spender] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaSpiritualAmbassadorCostStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaSpiritualAmbassadorCost(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaSpiritualAmbassadorCost(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), start = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
|
||||
entry: 'original-undealt-bare-advanced-ecaz-semuta' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
|
||||
assert.deepEqual(game.expansions, ['choam', 'ecaz']); assert.ok(game.players.every(player => !player.hand.length));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaSpiritualAmbassadorCostInventory(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 = originalGuildPacket();
|
||||
let packetCreation: ReturnType<typeof transition> | null = null, advisorSetup: ReturnType<typeof transition> | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 120; count++) {
|
||||
const step = nextSemutaSpiritualAmbassadorCost(game); assert.ok(step);
|
||||
if (step.action.type === 'advisorSetup') step.action = { type: 'advisorSetup', territory, sector };
|
||||
const before = structuredClone(game), start = actions.length;
|
||||
const createsPacket = game.setupStage === 'traitors' && step.action.type === 'traitor' &&
|
||||
game.players.filter(player => player.traitorChoices.length).length === 1;
|
||||
game = createsPacket ? createOriginalPacket(game, step, actions, packet.scalars)
|
||||
: actSemutaSpiritualAmbassadorCost(game, step.actor, step.action, actions);
|
||||
const record = { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
if (createsPacket) packetCreation = record;
|
||||
if (step.action.type === 'advisorSetup') advisorSetup = record;
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(packetCreation && advisorSetup);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(semutaSpiritualAmbassadorCostPlayer(game, owner).forces[location], 1);
|
||||
assert.equal(isAdvisor(semutaSpiritualAmbassadorCostPlayer(game, owner), territory), stage === 'shipping');
|
||||
assert.ok(semutaSpiritualAmbassadorCostPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
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 && !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: SemutaSpiritualAmbassadorCostStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaSpiritualAmbassadorCostPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaSpiritualAmbassadorCost(game); assert.ok(step);
|
||||
if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
|
||||
}
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
assert.ok(beforeBid && bid && bidAt >= 0, 'CHOAM really buys the original cache Semuta for one spice');
|
||||
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||
assert.equal(semutaSpiritualAmbassadorCostPlayer(beforeBid, holder).spice - semutaSpiritualAmbassadorCostPlayer(game, holder).spice, 1);
|
||||
assert.equal(semutaSpiritualAmbassadorCostPlayer(game, richese).spice - semutaSpiritualAmbassadorCostPlayer(beforeBid, richese).spice, 1);
|
||||
reach(state => state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
const guild = game.ecazAmbassadors!.tokens.find(token => token.effect === 'guild'); assert.ok(guild);
|
||||
assert.equal(guild.zone, 'supply'); const token = guild.id;
|
||||
const placement = transition(() => {
|
||||
act(ecaz, { type: 'decision', token, territory: 'carthag' });
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
});
|
||||
assert.equal(semutaSpiritualAmbassadorCostPlayer(placement.before, ecaz).spice - semutaSpiritualAmbassadorCostPlayer(game, ecaz).spice, 1);
|
||||
act(ecaz, { type: 'decision', decline: true });
|
||||
reach(state => state.phase === 5 && state.active === owner && semutaSpiritualAmbassadorCostClean(state));
|
||||
const bgTurn = transition(() => {
|
||||
act(owner, { type: 'endMovement' });
|
||||
reach(state => state.phase === 5 && state.active === entrant && semutaSpiritualAmbassadorCostClean(state));
|
||||
});
|
||||
const paidShipmentAction: Action = { type: 'ship', territory: 'carthag', sector: 11, amount: 1, elite: 0, allyPayment: 0 };
|
||||
const earlierAccompaniment: SemutaSpiritualAmbassadorCostStep[] = [];
|
||||
const paidEntry = transition(() => {
|
||||
act(entrant, paidShipmentAction);
|
||||
for (let count = 0; !(game.pendingAmbassador?.stage === 'offer' && !game.response && !game.phaseOpening) && count < 150; count++) {
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'advisor') {
|
||||
const step: SemutaSpiritualAmbassadorCostStep = { actor: owner, action: { type: 'decision', accept: false } };
|
||||
earlierAccompaniment.push(structuredClone(step)); act(step.actor, step.action);
|
||||
} else {
|
||||
const step = nextSemutaSpiritualAmbassadorCost(game); assert.ok(step, 'Complete only the original paid Carthag entry');
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
}
|
||||
assert.equal(game.pendingAmbassador?.stage, 'offer');
|
||||
});
|
||||
assert.equal(semutaSpiritualAmbassadorCostPlayer(paidEntry.before, entrant).spice - semutaSpiritualAmbassadorCostPlayer(game, entrant).spice, 1);
|
||||
const offer = structuredClone(game), event = game.pendingAmbassador!.event;
|
||||
const trigger = transition(() => {
|
||||
act(ecaz, { type: 'decision', event, trigger: true, beneficiary: ecaz });
|
||||
reach(state => !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'ship');
|
||||
});
|
||||
const foreignShipmentAction: Action = { type: 'decision', event, territory: foreignDestination,
|
||||
sector: foreignSector, amount: 1, elite: 0 };
|
||||
const foreignShipment = transition(() => {
|
||||
act(shipper, foreignShipmentAction);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'advisor');
|
||||
});
|
||||
assert.equal(semutaSpiritualAmbassadorCostPlayer(foreignShipment.before, shipper).spice,
|
||||
semutaSpiritualAmbassadorCostPlayer(game, shipper).spice);
|
||||
assert.equal(semutaSpiritualAmbassadorCostPlayer(foreignShipment.before, shipper).reserves -
|
||||
semutaSpiritualAmbassadorCostPlayer(game, shipper).reserves, 1);
|
||||
assert.equal(semutaSpiritualAmbassadorCostPlayer(game, shipper).forces[foreignLocation], 1);
|
||||
assert.equal(semutaSpiritualAmbassadorCostPlayer(game, shipper).shipped,
|
||||
semutaSpiritualAmbassadorCostPlayer(foreignShipment.before, shipper).shipped);
|
||||
assert.ok(game.decision?.kind === 'advisor'); assert.equal(game.decision.player, owner);
|
||||
assert.equal(game.decision.shipment, shipper); assert.equal(game.decision.destination, foreignLocation);
|
||||
assert.equal(game.decision.ambassadorEvent, event);
|
||||
assert.equal(game.pendingAmbassador?.shipmentReceipt?.next, 'advisor');
|
||||
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), shippingAllowance = stage === 'matching'
|
||||
? transition(() => { game = allowSemutaSpiritualAmbassadorCost(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 && game.pendingAmbassador);
|
||||
assert.deepEqual(semutaSpiritualAmbassadorCostPhysical(game), semutaSpiritualAmbassadorCostPhysical(beforeDeclaration));
|
||||
const source = structuredClone(game), originalResponse = structuredClone(game.response),
|
||||
originalFrame = structuredClone(game.pendingSpiritualAdvisor), originalSource = structuredClone(game.response.spiritualAdvisorSource),
|
||||
originalAmbassador = structuredClone(game.pendingAmbassador);
|
||||
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
assert.ok(semutaSpiritualAmbassadorCostPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaSpiritualAmbassadorCostPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.deepEqual(semutaSpiritualAmbassadorCostInventory(game), originalInventory);
|
||||
return { game, source, beforeCost: source, stage, kind: originalResponse.kind, advanced: true as const,
|
||||
richese, seller: richese, holder, ecaz, owner, bg: owner, spender, atreides: spender, entrant, shipper, actor: owner,
|
||||
cost, karama: cost, semuta: SEMUTA_DRUG_ID, action, nativeAction, accompany, sink, decline,
|
||||
replacementAction: stage === 'matching' ? accompany : null,
|
||||
territory, sector, location, foreignDestination, foreignSector, foreignLocation,
|
||||
event, parentEvent: event, token, paidShipmentAction, foreignShipmentAction,
|
||||
originalFrame, originalParent: originalFrame, originalSource, originalResponse, originalChooser, originalAmbassador,
|
||||
originalQueue: structuredClone(game.pendingArrivalReaction ?? null),
|
||||
originalForeignReceipt: structuredClone(originalAmbassador.shipmentReceipt!),
|
||||
originalToken: structuredClone(game.ecazAmbassadors!.tokens.find(candidate => candidate.id === token)!),
|
||||
originalPhysical: structuredClone(semutaSpiritualAmbassadorCostPhysical(game)), originalHistory: structuredClone(game.log),
|
||||
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, advisorSetup, packet, packetCreation,
|
||||
deal: selected.map(card => card.id), dealOrder, spiceOrder, originalSpice, originalInventory,
|
||||
acquired, acquisition, placement, bgTurn, paidEntry, earlierAccompaniment, offer, trigger, foreignShipment,
|
||||
entryCost: 1, placementCost: 1, foreignCost: 0, beforeDeclaration, declaration, shipping, shippingAllowance,
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original narrow Advanced Ecaz Semuta initializer on the ready undealt R/C/Ecaz/BG/A exact-CHOAM+Ecaz lobby, without optional modules or a new profile gate. Only unopened card/Spice order and actual scalar lotteries are selected.',
|
||||
'The final real Traitor choice consumes nine original packet draws earning Guild supply. Actual Ecaz setup keeps six in Basin10; BG really places its advisor at occupied Arrakeen10 for shipping or its fighter at empty Polar0 for matching. A is originally dealt printed Karama; C really buys original cache Semuta for one.',
|
||||
'Native first Storm, dual Spice, Charity, Bidding and Revival execute. Ecaz pays one to place Guild at Carthag; BG ends its real turn; A pays one to enter Carthag and any earlier ordinary Spiritual accompaniment is actually declined.',
|
||||
'Ecaz triggers the actual token for itself, then really ships one native reserve free through Guild to Arrakeen10 for shipping or empty Tueks5 for matching. The physical free entrant, token use, original A payment and Guild receipt are already committed before BG declares its independent packet.',
|
||||
stage === 'shipping'
|
||||
? 'BG selects actual accompaniment beside its original Arrakeen advisor. Stop at the new explicit Spiritual shipping response before withdrawal; no matching or alone conversion is needed beside Ecazs committed foreign force.'
|
||||
: 'BG selects the actual Polar fallback beside its original fighter and really allows shipping. Stop at the separate matching counter before withdrawal. Prevention restores the original chooser; real accompaniment at Tueks remains legal because that is a different destination beside Ecazs committed foreign force.',
|
||||
'No played hand, force, stance, wallet, clock, token, parent, grant or own Semuta window is installed. Finish only the exact Guild Ambassador child and retain the original native entrant turn; no future battle, shipment or movement is invented.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaSpiritualAmbassadorCostFixture = ReturnType<typeof createSemutaSpiritualAmbassadorCostFixture>;
|
||||
@@ -0,0 +1,238 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { homeworldGameIntegrity, homeworldTable } from '../game/homeworld-game';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldRevivalDeploymentChoice } from '../game/homeworld-revival-deployment-options';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaSpiritualCost, passSemutaSpiritualCost, claimSemutaSpiritualCost, allowSemutaSpiritualCost,
|
||||
semutaSpiritualCostPlayer, semutaSpiritualCostInventory,
|
||||
} from './fixture-semuta-spiritual-cost';
|
||||
import { nextSemutaAdvisorMatching } from './fixture-semuta-advisor-matching';
|
||||
|
||||
export type SemutaSpiritualRevivalStage = 'shipping' | 'matching' | 'arrival';
|
||||
export type SemutaSpiritualRevivalStep = { actor: string; action: Action };
|
||||
export const actSemutaSpiritualRevival = actSemutaSpiritualCost;
|
||||
export const passSemutaSpiritualRevival = passSemutaSpiritualCost;
|
||||
export const claimSemutaSpiritualRevival = claimSemutaSpiritualCost;
|
||||
export const allowSemutaSpiritualRevival = allowSemutaSpiritualCost;
|
||||
export const semutaSpiritualRevivalPlayer = semutaSpiritualCostPlayer;
|
||||
export const semutaSpiritualRevivalInventory = semutaSpiritualCostInventory;
|
||||
export const semutaSpiritualRevivalClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival &&
|
||||
!game.pendingSpiritualAdvisor && !game.pendingArrivalReaction?.length && !game.pendingAutomaticAdvisorFlips?.length;
|
||||
/** Balances and advisor stance are asserted separately: the retained native
|
||||
* free-revival income may settle only after the original SEND completes. */
|
||||
export const semutaSpiritualRevivalPhysical = (game: Game) => ({ homeworlds: game.homeworlds,
|
||||
players: game.players.map(p => ({ id: p.id, forces: p.forces, elites: p.elites, reserves: p.reserves,
|
||||
tanks: p.tanks, shipped: p.shipped, moved: p.moved, revived: p.revived,
|
||||
freeForcesRevived: p.freeForcesRevived, leaders: p.leaders, ally: p.ally })) });
|
||||
|
||||
/** Original setup, exact native Storm controls and ordinary revival choices.
|
||||
* SEND, Spiritual choices/counters and fresh groups stay caller-owned. */
|
||||
export function nextSemutaSpiritualRevival(game: Game): SemutaSpiritualRevivalStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance || game.pendingSpiritualAdvisor ||
|
||||
game.response?.spiritualAdvisorSource || game.response?.automaticAdvisorFlip) return null;
|
||||
if (!game.response && !game.phaseOpening) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'advisor' || decision?.kind === 'intrusion' || decision?.kind === 'homeworldRevivalDeployment') return null;
|
||||
if (decision?.kind === 'revivalStop' || decision?.kind === 'choamFreeRevival')
|
||||
return { actor: decision.player, action: { type: 'decision', decline: true } };
|
||||
if (game.status === 'setup' && game.setupStage === 'prediction')
|
||||
return { actor: 'beneGesserit', action: { type: 'predict', faction: 'tleilaxu', turn: 10 } };
|
||||
if (game.status === 'playing' && game.phase === 0 && !decision && game.stormPending === null) {
|
||||
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
|
||||
if (actor) return { actor, action: { type: 'stormDial',
|
||||
amount: game.turn === 1 ? actor === game.stormDialers[0] ? 11 : 0 : 1 } };
|
||||
}
|
||||
}
|
||||
return nextSemutaAdvisorMatching(game);
|
||||
}
|
||||
export const nextSemutaSpiritualRevivalStep = nextSemutaSpiritualRevival;
|
||||
export function advanceSemutaSpiritualRevival(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaSpiritualRevivalStep[] = []): Game {
|
||||
for (let count = 0; count < 2400; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaSpiritualRevival(game);
|
||||
assert.ok(step, `Choose the original SEND/Spiritual/fresh control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaSpiritualRevival(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original Semuta/Revival SEND chronology did not reach its finite boundary');
|
||||
}
|
||||
/** Keep a restored matching chooser explicit. Otherwise finish the original
|
||||
* SEND queue and retained income, without another request or phase action. */
|
||||
export function finishSemutaSpiritualRevival(game: Game, actions: SemutaSpiritualRevivalStep[] = []): Game {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null, 'Choose the original fresh cost first');
|
||||
return advanceSemutaSpiritualRevival(game, state =>
|
||||
(state.homeworldRevivalReturn?.stage === 'complete' && semutaSpiritualRevivalClean(state)) ||
|
||||
(!state.response && !state.phaseOpening && state.decision?.kind === 'advisor' &&
|
||||
state.pendingSpiritualAdvisor?.stage === 'offer'), actions);
|
||||
}
|
||||
|
||||
/** Fresh Advanced R/C/BG/A/T, actual cache purchase and ordinary printed
|
||||
* Karama, real next-Storm loss, free revival and owned Homeworld SEND. */
|
||||
export function createSemutaSpiritualRevivalFixture(options: { stage?: SemutaSpiritualRevivalStage } = {}) {
|
||||
const stage = options.stage ?? 'shipping', seller = 'richese', holder = 'choam', owner = 'beneGesserit';
|
||||
const spender = 'atreides', reviver = 'tleilaxu', destination = 'arrakeen:10';
|
||||
const territory = stage === 'shipping' ? 'arrakeen' : 'polar_sink', sector = stage === 'shipping' ? 10 : 0;
|
||||
const location = `${territory}:${sector}`, casualtyLocation = 'hagga_basin:13';
|
||||
let game = createGame('SEMUTASPIRITUALREVIVAL', newPlayer(seller, 'Richese', 'richese'), true, ['choam', 'ix']);
|
||||
for (const faction of [holder, owner, spender, reviver] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaSpiritualRevivalStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaSpiritualRevival(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaSpiritualRevival(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), at = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||
};
|
||||
const enableHomeworlds = transition(() => { act(game.host, { type: 'homeworlds', enabled: true }); });
|
||||
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-tleilax-homeworld-preview' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
|
||||
assert.equal(game.homeworlds?.custody, null); assert.ok(game.players.every(p => !p.hand.length));
|
||||
assert.ok(!game.nexusCards && !game.leaderSkills && !game.techTokens && !game.strongholdCards &&
|
||||
!game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaSpiritualRevivalInventory(game); assert.equal(originalInventory.length, 57);
|
||||
const printed = game.deck.find(card => card.effect === 'karama' && !card.id.startsWith('ix-')); assert.ok(printed);
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind) && !card.effect).slice(0, 4);
|
||||
assert.equal(ordinary.length, 4);
|
||||
const selected = [ordinary[0], ordinary[1], ordinary[2], printed, ordinary[3]], cost = printed.id;
|
||||
const dealOrder = selected.map((card, 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: SemutaSpiritualRevivalStep[]; after: Game } | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 150; count++) {
|
||||
const step = nextSemutaSpiritualRevival(game); assert.ok(step);
|
||||
if (step.action.type === 'advisorSetup') step.action = { type: 'advisorSetup',
|
||||
territory: stage === 'matching' ? 'polar_sink' : 'arrakeen', sector: stage === 'matching' ? 0 : 10 };
|
||||
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 && game.homeworlds?.custody);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
const setupTerritory = stage === 'matching' ? 'polar_sink' : 'arrakeen';
|
||||
assert.equal(semutaSpiritualRevivalPlayer(game, owner).forces[stage === 'matching' ? 'polar_sink:0' : 'arrakeen:10'], 1);
|
||||
assert.equal(isAdvisor(semutaSpiritualRevivalPlayer(game, owner), setupTerritory), stage !== 'matching');
|
||||
assert.equal(semutaSpiritualRevivalPlayer(game, reviver).reserves, 20);
|
||||
assert.equal(semutaSpiritualRevivalPlayer(game, reviver).faceDancers?.length, 3);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
for (let position = 0; position < 4; position++) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
|
||||
!['hagga_basin', 'arrakeen', 'polar_sink'].includes(card.territory));
|
||||
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) });
|
||||
}
|
||||
const firstStorm = transition(() => { reach(state => state.turn === 1 && state.phase === 1); });
|
||||
assert.equal(game.storm, 12);
|
||||
let beforeBid: Game | undefined, bid: SemutaSpiritualRevivalStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaSpiritualRevivalPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaSpiritualRevival(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(semutaSpiritualRevivalPlayer(beforeBid, holder).spice - semutaSpiritualRevivalPlayer(game, holder).spice, 1);
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === reviver && semutaSpiritualRevivalClean(state));
|
||||
const homeworldSources = nativeShipmentSources(viewGame(game, reviver), 1, 0); assert.ok(homeworldSources);
|
||||
const shipmentAction: Action = { type: 'ship', territory: 'hagga_basin', sector: 13,
|
||||
amount: 1, elite: 0, allyPayment: 0, homeworldSources };
|
||||
const shipment = transition(() => {
|
||||
act(reviver, shipmentAction);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'advisor');
|
||||
});
|
||||
const firstSpiritualDecline = transition(() => { act(owner, { type: 'decision', accept: false }); });
|
||||
assert.equal(semutaSpiritualRevivalPlayer(shipment.before, reviver).spice - semutaSpiritualRevivalPlayer(game, reviver).spice, 2);
|
||||
assert.equal(semutaSpiritualRevivalPlayer(game, reviver).forces[casualtyLocation], 1);
|
||||
reach(state => state.turn === 2 && state.phase === 0 && !state.response && !state.phaseOpening && state.stormPending === 1);
|
||||
const beforeStormMovement = structuredClone(game), stormSource = structuredClone(game.stormMovementSource);
|
||||
const stormLoss = transition(() => { reach(state => state.turn === 2 && state.phase === 1); });
|
||||
assert.equal(game.storm, 13); assert.equal(semutaSpiritualRevivalPlayer(game, reviver).tanks, 1);
|
||||
assert.equal(semutaSpiritualRevivalPlayer(game, reviver).forces[casualtyLocation] ?? 0, 0);
|
||||
reach(state => state.turn === 2 && state.phase === 4 && semutaSpiritualRevivalClean(state));
|
||||
const beforeRequest = structuredClone(game), revivalAction: Action = { type: 'revive', amount: 1, elite: 0, freeElite: 0 };
|
||||
assert.equal(semutaSpiritualRevivalPlayer(game, reviver).reserves, 19);
|
||||
assert.equal(homeworldTable(game)!.find(world => world.native === reviver)!.side, 'high');
|
||||
const freeRevive = transition(() => {
|
||||
act(reviver, revivalAction);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'homeworldRevivalDeployment');
|
||||
});
|
||||
const returned = structuredClone(game), choice = homeworldRevivalDeploymentChoice(viewGame(game, reviver), destination);
|
||||
assert.equal(choice.blocked, null); assert.ok(choice.action);
|
||||
const sendAction = choice.action, send = transition(() => {
|
||||
act(reviver, sendAction);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'advisor');
|
||||
});
|
||||
assert.ok(game.decision?.kind === 'advisor'); assert.equal(game.decision.revivalEvent, game.homeworldRevivalReturn?.event);
|
||||
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);
|
||||
const shippingResponse = structuredClone(game.response), shipping = structuredClone(game);
|
||||
const shippingAllowance = stage === 'shipping' ? null : transition(() => { game = allowSemutaSpiritualRevival(game, actions); });
|
||||
assert.equal(game.pendingSpiritualAdvisor?.stage, stage);
|
||||
assert.ok(game.response && game.pendingSpiritualAdvisor && game.homeworldRevivalReturn && game.homeworldRevivalProgress);
|
||||
const automatic = game.response.automaticAdvisorFlip;
|
||||
if (stage === 'arrival') assert.ok(automatic?.producer === 'spiritualArrival');
|
||||
else assert.ok(game.response.spiritualAdvisorSource);
|
||||
assert.equal(game.homeworldRevivalReturn.stage, 'arrival'); assert.equal(game.pendingRevival ?? null, null);
|
||||
const source = structuredClone(game), originalResponse = structuredClone(game.response), originalFrame = structuredClone(game.pendingSpiritualAdvisor);
|
||||
const originalSource = structuredClone(game.response.spiritualAdvisorSource ??
|
||||
(automatic?.producer === 'spiritualArrival' ? automatic.spiritual : undefined));
|
||||
assert.ok(originalSource);
|
||||
const originalReturn = structuredClone(game.homeworldRevivalReturn), originalProgress = structuredClone(game.homeworldRevivalProgress);
|
||||
const originalQueue = structuredClone(game.pendingAutomaticAdvisorFlips ?? []), originalArrival = structuredClone(game.pendingArrivalReaction ?? []);
|
||||
assert.ok(originalArrival.some(arrival => arrival.entrant === reviver && arrival.revivalArrival?.event === originalReturn.event));
|
||||
assert.equal(originalReturn.resumeResponse?.kind, 'revivalIncome'); assert.equal(originalReturn.resumeResponse?.amount, 1);
|
||||
assert.ok(semutaSpiritualRevivalPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaSpiritualRevivalPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
homeworldGameIntegrity(game); assert.deepEqual(semutaSpiritualRevivalInventory(game), originalInventory);
|
||||
return { game, source, beforeCost: source, stage, advanced: true as const, kind: originalResponse.kind,
|
||||
owner, bg: owner, recipient: owner, holder, spender, atreides: spender, seller, richese: seller,
|
||||
reviver, tleilaxu: reviver, shipper: reviver, incomeOwner: reviver,
|
||||
cost, karama: cost, semuta: SEMUTA_DRUG_ID, action: { type: 'card', card: cost, mode: 'cancel' } as Action,
|
||||
nativeAction, accompany, sink, decline, replacementAction: stage === 'matching' ? accompany : null,
|
||||
originalResponse, originalFrame, originalSource, originalChooser, originalReturn, originalProgress,
|
||||
originalWorldParent: originalReturn, originalParent: originalReturn, originalRetainedIncome: structuredClone(originalReturn.resumeResponse),
|
||||
originalPendingRevival: structuredClone(game.pendingRevival ?? null), originalDeposit: structuredClone(originalFrame.deposit ?? null),
|
||||
originalAutomaticSource: structuredClone(automatic ?? null), originalQueue, originalArrival,
|
||||
originalPhysical: structuredClone(semutaSpiritualRevivalPhysical(game)), originalCustody: structuredClone(game.homeworlds!.custody),
|
||||
originalBalances: Object.fromEntries(game.players.map(p => [p.id, p.spice])),
|
||||
originalStance: structuredClone(semutaSpiritualRevivalPlayer(game, owner).advisors),
|
||||
territory, sector, location, destination, casualtyLocation, revivalAction, sendAction,
|
||||
initial, enableHomeworlds, lobby, setup, initialization, beforeStartingDeal, afterSetup, advisorSetup,
|
||||
deal: selected.map(card => card.id), dealOrder, originalInventory, originalSpice, spiceOrder,
|
||||
firstStorm, acquisition, acquired, shipmentAction, shipment, firstSpiritualDecline,
|
||||
beforeStormMovement, stormSource, stormLoss, beforeRequest, freeRevive, returned, send,
|
||||
beforeDeclaration, declaration, shipping, shippingResponse, shippingAllowance,
|
||||
actions, steps: actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Actual original Advanced R/C/BG/A/T exact CHOAM+Ix fresh Semuta initializer after the real Homeworlds lobby option. Only unopened original card/Spice positions and scalar lotteries are selected. Atreides really receives printed base Karama, Tleilaxu its original Face Dancers, and CHOAM actually buys cache1 Semuta.',
|
||||
'Actual first Storm dials11+0 end at12. Tleilaxu really pays two to ship one original typed native reserve to Hagga13; BG actually declines that earlier Spiritual choice. Actual next original Storm Card1 ends at13 and destroys it. Real native phases reach turn2 Revival with High19/Tanks1; no played stock, wallet, hand, stance, clock, return, casualty or custody is installed.',
|
||||
'Tleilaxu actually revives one ordinary force free, earning original High SEND and retained free-revival income1. The owned original deployment quote sends exactly that counter to Arrakeen10. The native return/progress stays at arrival, queued entry carries the original revival event, and pendingRevival is already cleared by its real completion.',
|
||||
stage === 'matching'
|
||||
? 'BG’s original setup placed a native fighter in empty Polar. After SEND, BG actually declares fallback1 and passes the original shipping counter; stop before the separate matching placement. Denial restores the original chooser, with actual accompaniment1 into the SEND territory or decline still legal.'
|
||||
: stage === 'arrival'
|
||||
? 'BG’s original advisorSetup is beside A10 Arrakeen. After SEND, BG actually declares free Polar1 and passes shipping. The real native reserve withdrawal/deposit opens its independent spiritualArrival alone-conversion counter; stop with the whole return/income parent retained.'
|
||||
: 'BG’s original advisorSetup is beside A10 Arrakeen. After SEND, BG actually declares accompaniment1 and stops at shipping before withdrawing any BG reserve. The revived T counter and original return/income remain committed.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaSpiritualRevivalFixture = ReturnType<typeof createSemutaSpiritualRevivalFixture>;
|
||||
@@ -0,0 +1,200 @@
|
||||
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 {
|
||||
createSemutaSpiritualAmbassadorArrivalFixture as fixture, actSemutaSpiritualAmbassadorArrival as act,
|
||||
passSemutaSpiritualAmbassadorArrival as pass, claimSemutaSpiritualAmbassadorArrival as claim,
|
||||
allowSemutaSpiritualAmbassadorArrival as allow, finishSemutaSpiritualAmbassadorArrival as finish,
|
||||
advanceSemutaSpiritualAmbassadorArrival as advance, semutaSpiritualAmbassadorArrivalClean as clean,
|
||||
semutaSpiritualAmbassadorArrivalPlayer as player, semutaSpiritualAmbassadorArrivalInventory as inventory,
|
||||
semutaSpiritualAmbassadorArrivalPhysical as physical, type SemutaSpiritualAmbassadorArrivalFixture,
|
||||
} from './fixture-semuta-spiritual-ambassador-arrival';
|
||||
|
||||
const spiritualLogs = (game: Game, owner: string) => game.log.filter(row => row.text.startsWith(`${player(game, owner).name} sent 1 free`));
|
||||
const guildLogs = (game: Game) => game.log.filter(row => row.text.includes('for free through the Guild Ambassador. Their ordinary shipment'));
|
||||
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: SemutaSpiritualAmbassadorArrivalFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.deepEqual(f.setup.expansions, ['choam', 'ecaz']); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', 'ecaz', 'beneGesserit', 'atreides', 'emperor']);
|
||||
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(c => c.id)), f.deal);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(c => c.id === f.cost && c.effect === 'karama'));
|
||||
assert.equal(f.packet.scalars.length, 9); assert.equal(f.packetCreation.steps.length, 1);
|
||||
assert.equal(f.advisorSetup.steps[0].action.type, 'advisorSetup');
|
||||
assert.equal(player(f.afterSetup, f.owner).forces['arrakeen:10'], 1); assert.equal(player(f.afterSetup, f.entrant).forces['arrakeen:10'], 10);
|
||||
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
|
||||
assert.equal(player(f.placement.before, f.ecaz).spice - player(f.placement.after, f.ecaz).spice, 1);
|
||||
assert.ok(f.placement.steps.some(step => step.action.token === f.token));
|
||||
assert.deepEqual(f.entrantShipment.steps[0], { actor: f.entrant, action: f.paidAction });
|
||||
assert.equal(player(f.entrantShipment.before, f.entrant).spice - player(f.entrantShipment.after, f.entrant).spice, 1);
|
||||
assert.equal(player(f.game, f.entrant).forces['carthag:11'], 1); assert.equal(player(f.game, f.entrant).shipped, true);
|
||||
assert.deepEqual(f.ordinaryDecline.steps, [{ actor: f.owner, action: { type: 'decision', accept: false } }]);
|
||||
assert.equal(f.trigger.before.pendingAmbassador?.stage, 'offer'); assert.equal(f.trigger.after.pendingAmbassador?.stage, 'ship');
|
||||
assert.deepEqual(f.trigger.steps[0], { actor: f.ecaz, action: f.triggerAction });
|
||||
assert.equal(f.originalAmbassador.event, f.event); assert.equal(f.originalAmbassador.effect, 'guild');
|
||||
assert.equal(f.originalAmbassador.beneficiary, f.ecaz); assert.equal(f.originalAmbassador.entrant, f.entrant);
|
||||
assert.equal(f.game.ecazAmbassadors!.tokens.find(t => t.id === f.token)?.zone, 'used');
|
||||
assert.deepEqual(f.grant.steps[0], { actor: f.ecaz, action: f.grantAction });
|
||||
assert.equal(player(f.grant.before, f.ecaz).spice, player(f.grant.after, f.ecaz).spice);
|
||||
assert.equal(player(f.grant.before, f.ecaz).reserves - player(f.grant.after, f.ecaz).reserves, 1);
|
||||
assert.equal(player(f.grant.before, f.ecaz).shipped, player(f.grant.after, f.ecaz).shipped);
|
||||
assert.equal(player(f.grant.before, f.ecaz).moved, player(f.grant.after, f.ecaz).moved);
|
||||
assert.equal(player(f.game, f.ecaz).forces[f.guildLocation], 1);
|
||||
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.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'));
|
||||
assert.equal(f.originalFrame.stage, 'arrival'); assert.equal(f.originalFrame.decision.ambassadorEvent, f.event);
|
||||
assert.equal(f.originalFrame.decision.shipment, f.ecaz); assert.equal(f.originalFrame.decision.destination, f.guildLocation);
|
||||
assert.deepEqual(f.originalFrame.source, f.originalSpiritualSource);
|
||||
assert.deepEqual(f.originalDeposit, { key: f.location, before: 0 });
|
||||
assert.equal(f.originalAmbassador.stage, 'arrival'); assert.equal(f.originalReceipt.next, 'advisor');
|
||||
assert.equal(f.originalReceipt.advisorArrival, undefined);
|
||||
assert.equal(f.originalReceipt.order.player, f.ecaz); assert.equal(f.originalReceipt.order.amount, 1);
|
||||
assert.equal(f.originalReceipt.order.territory, f.guildTerritory); assert.equal(f.originalReceipt.order.sector, f.guildSector);
|
||||
assert.equal(f.originalAutomaticSource.producer, 'spiritualArrival');
|
||||
assert.deepEqual(f.originalAutomaticSource.spiritual, f.originalSpiritualSource);
|
||||
assert.deepEqual(f.originalAutomaticSource.forces, { [f.location]: 1 });
|
||||
assert.deepEqual(f.originalQueue, [f.originalAutomaticSource]);
|
||||
assert.equal(player(f.game, f.owner).reserves, player(f.declaration.before, f.owner).reserves - 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(spiritualLogs(f.game, f.owner).length, 1); assert.equal(guildLogs(f.game).length, 1);
|
||||
assert.equal(f.game.active, f.entrant); assert.equal(f.game.phase, 5); assert.equal(f.game.turn, 1);
|
||||
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: SemutaSpiritualAmbassadorArrivalFixture) {
|
||||
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 real Guild Spiritual-arrival 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.pendingAmbassador, f.originalParent);
|
||||
assert.deepEqual(game.pendingAmbassador?.shipmentReceipt, f.originalReceipt);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue); assert.deepEqual(game.pendingArrivalReaction ?? null, f.originalPaidQueue);
|
||||
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.ecazAmbassadors, f.originalTokens); 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(c => c.id === f.cost), false);
|
||||
assert.equal(game.discard.filter(c => c.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: SemutaSpiritualAmbassadorArrivalFixture, 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.pendingAmbassador ?? null, null); 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, 'Original paid entry, Guild reserve and Spiritual deposit are never replayed');
|
||||
assert.equal(game.active, f.entrant); assert.equal(player(game, f.owner).forces[f.location], 1);
|
||||
assert.equal(player(game, f.ecaz).forces[f.guildLocation], 1); assert.equal(player(game, f.entrant).forces['carthag:11'], 1);
|
||||
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.deepEqual(player(game, f.owner).advisors?.[f.territory], f.originalLocks);
|
||||
} else assert.equal(player(game, f.owner).advisors?.[f.territory], undefined);
|
||||
assert.deepEqual(quoteAutomaticAdvisorFlips(game), []);
|
||||
assert.deepEqual(game.ecazAmbassadors, f.originalTokens); assert.deepEqual(game.deck, f.originalDeck);
|
||||
assert.equal(spiritualLogs(game, f.owner).length, 1); assert.equal(guildLogs(game).length, 1);
|
||||
assert.ok(steps.every(step => !['ship', 'move', 'card', 'endMovement'].includes(step.action.type)));
|
||||
reject(game, f.ecaz, f.triggerAction); reject(game, f.ecaz, f.grantAction); reject(game, f.owner, f.chooseAction);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function canceled(state: Game, f: SemutaSpiritualAmbassadorArrivalFixture, claimed: boolean) {
|
||||
const game = settled(state, f, true);
|
||||
assert.equal(player(game, f.holder).hand.filter(c => c.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(c => c.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(c => c.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(c => c.id === f.semuta).length, Number(claimed));
|
||||
reject(game, f.spender, f.action); return game;
|
||||
}
|
||||
function continueMovement(state: Game, f: SemutaSpiritualAmbassadorArrivalFixture, prevented: boolean) {
|
||||
const steps: { actor: string; action: Action }[] = [];
|
||||
let game = act(state, f.entrant, { type: 'endMovement' }, steps);
|
||||
game = advance(game, g => g.phase >= 6 && g.phase <= 7 && clean(g), steps);
|
||||
assert.equal(steps.filter(step => step.actor === f.entrant && step.action.type === 'endMovement').length, 1);
|
||||
assert.ok(steps.every(step => !['ship', 'move', 'card'].includes(step.action.type)));
|
||||
assert.equal(game.pendingAmbassador ?? null, null); assert.equal(game.pendingSpiritualAdvisor, undefined);
|
||||
assert.equal(game.pendingAutomaticAdvisorFlips, undefined); assert.equal(spiritualLogs(game, f.owner).length, 1); assert.equal(guildLogs(game).length, 1);
|
||||
assert.equal(player(game, f.owner).forces[f.location], 1); assert.equal(player(game, f.ecaz).forces[f.guildLocation], 1);
|
||||
assert.equal(player(game, f.entrant).forces['carthag:11'], 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);
|
||||
}
|
||||
|
||||
for (const claimed of [false, true]) void test(
|
||||
`Advanced native Guild Ambassador Spiritual arrival cost Semuta ${claimed ? 'claim' : 'pass'} preserves committed child and outer 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 Guild Ambassador Spiritual allowance converts the placed Polar advisor before the original child and outer suffix finish', () => {
|
||||
const f = fixture(); nativeSource(f); const game = settled(allow(structuredClone(f.game)), f, false);
|
||||
assert.ok(player(game, f.spender).hand.some(c => c.id === f.cost)); assert.ok(player(game, f.holder).hand.some(c => c.id === f.semuta));
|
||||
continueMovement(game, f, false);
|
||||
});
|
||||
|
||||
void test('fresh Guild Spiritual arrival cost rejects stale and underlying declarations while both original parents and deposit stay 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.ecaz, f.grantAction); reject(game, f.ecaz, f.triggerAction); reject(game, f.entrant, f.paidAction);
|
||||
reject(game, f.entrant, { type: 'endMovement' });
|
||||
assert.deepEqual(game.pendingSpiritualAdvisor, f.originalFrame); assert.deepEqual(game.pendingAmbassador, f.originalAmbassador);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); canceled(pass(game), f, false);
|
||||
});
|
||||
|
||||
void test('controlled Guild Spiritual source deposit and Ambassador suffix mismatches reject before cost and fresh appropriation', () => {
|
||||
const f = fixture();
|
||||
// Deliberately invalid negative copies only; the genuine primary history
|
||||
// never installs a frame, source, token, force or parent continuation.
|
||||
const changes: ((game: Game) => void)[] = [
|
||||
game => { game.pendingSpiritualAdvisor!.signature += ':changed'; },
|
||||
game => { game.pendingSpiritualAdvisor!.source = { ...game.pendingSpiritualAdvisor!.source!, event: 'different-original-packet' }; },
|
||||
game => { game.pendingSpiritualAdvisor!.deposit = { ...game.pendingSpiritualAdvisor!.deposit!, before: 1 }; },
|
||||
game => { game.pendingAmbassador!.shipmentReceipt!.next = 'finish'; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const beforeCost = structuredClone(f.game); change(beforeCost); reject(beforeCost, f.spender, f.action);
|
||||
assert.ok(player(beforeCost, f.spender).hand.some(c => c.id === f.cost));
|
||||
const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event; change(offered);
|
||||
reject(offered, f.holder, { type: 'semutaCommit', event }); assert.ok(player(offered, f.holder).hand.some(c => c.id === f.semuta));
|
||||
assert.equal(offered.discard.filter(c => c.id === f.cost).length, 1); assert.deepEqual(inventory(offered), f.originalInventory);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally answer the actual Guild Ambassador 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,225 @@
|
||||
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 { spiritualAdvisorMatchingKey } from '../game/spiritual-advisor';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
createSemutaSpiritualAmbassadorCostFixture as fixture, actSemutaSpiritualAmbassadorCost as act,
|
||||
passSemutaSpiritualAmbassadorCost as pass, claimSemutaSpiritualAmbassadorCost as claim,
|
||||
allowSemutaSpiritualAmbassadorCost as allow, finishSemutaSpiritualAmbassadorCost as finish,
|
||||
semutaSpiritualAmbassadorCostPlayer as player, semutaSpiritualAmbassadorCostInventory as inventory,
|
||||
semutaSpiritualAmbassadorCostPhysical as physical, semutaSpiritualAmbassadorCostClean as clean,
|
||||
type SemutaSpiritualAmbassadorCostFixture,
|
||||
} from './fixture-semuta-spiritual-ambassador-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: SemutaSpiritualAmbassadorCostFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.deepEqual(f.setup.expansions, ['choam', 'ecaz']);
|
||||
assert.deepEqual(f.game.players.map(player => player.faction), ['richese', 'choam', 'ecaz', 'beneGesserit', 'atreides']);
|
||||
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
|
||||
assert.deepEqual(f.afterSetup.players.flatMap(player => player.hand.map(card => card.id)), f.deal);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.equal(f.packet.scalars.length, 9); assert.ok(f.packetCreation && f.advisorSetup);
|
||||
const packetAfter = f.packetCreation.after.ecazAmbassadors!;
|
||||
assert.deepEqual(packetAfter.cohort.map(id => packetAfter.tokens.find(token => token.id === id)!.effect), f.packet.supply);
|
||||
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.price, 1); assert.equal(f.acquisition.bid.actor, f.holder);
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquired, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquired, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
|
||||
assert.equal(player(f.placement.before, f.ecaz).spice - player(f.placement.after, f.ecaz).spice, 1);
|
||||
assert.ok(f.bgTurn.steps.some(step => step.actor === f.owner && step.action.type === 'endMovement'));
|
||||
assert.deepEqual(f.paidEntry.steps[0], { actor: f.entrant, action: f.paidShipmentAction });
|
||||
assert.equal(player(f.paidEntry.before, f.entrant).spice - player(f.paidEntry.after, f.entrant).spice, 1);
|
||||
assert.equal(player(f.game, f.entrant).forces['carthag:11'], 1); assert.equal(player(f.game, f.entrant).shipped, true);
|
||||
assert.equal(f.offer.pendingAmbassador?.stage, 'offer'); assert.equal(f.originalToken.effect, 'guild');
|
||||
assert.equal(f.originalToken.zone, 'used');
|
||||
assert.equal(f.trigger.after.pendingAmbassador?.beneficiary, f.shipper);
|
||||
assert.deepEqual(f.foreignShipment.steps[0], { actor: f.shipper, action: f.foreignShipmentAction });
|
||||
assert.equal(player(f.foreignShipment.before, f.shipper).spice, player(f.game, f.shipper).spice);
|
||||
assert.equal(player(f.foreignShipment.before, f.shipper).reserves - player(f.game, f.shipper).reserves, 1);
|
||||
assert.equal(player(f.game, f.shipper).forces[f.foreignLocation], 1);
|
||||
assert.equal(player(f.game, f.shipper).shipped, player(f.foreignShipment.before, f.shipper).shipped);
|
||||
assert.equal(player(f.game, f.shipper).moved, player(f.foreignShipment.before, f.shipper).moved);
|
||||
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, f.event);
|
||||
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 && f.shippingAllowance.steps.length > 0);
|
||||
assert.ok(f.shippingAllowance.steps.every(step => step.action.type === 'passResponse'));
|
||||
assert.equal(new Set(f.shippingAllowance.steps.map(step => step.actor)).size, f.shippingAllowance.steps.length);
|
||||
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.entrant);
|
||||
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.pendingAmbassador, f.originalAmbassador);
|
||||
assert.equal(f.originalAmbassador.shipmentReceipt?.next, 'advisor');
|
||||
assert.deepEqual(f.originalAmbassador.shipmentReceipt, f.originalForeignReceipt);
|
||||
assert.deepEqual(f.game.pendingArrivalReaction ?? null, f.originalQueue);
|
||||
assert.deepEqual(physical(f.game), physical(f.beforeDeclaration)); assert.equal(grantLogs(f.game, f.owner).length, 0);
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
function open(f: SemutaSpiritualAmbassadorCostFixture) {
|
||||
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('Use the original Guild-derived Spiritual cost');
|
||||
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.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.pendingAmbassador, f.originalAmbassador);
|
||||
assert.deepEqual(game.pendingArrivalReaction ?? null, f.originalQueue);
|
||||
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 denied(game: Game, f: SemutaSpiritualAmbassadorCostFixture, claimed: boolean) {
|
||||
game = finish(game); 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.pendingAmbassador, f.originalAmbassador);
|
||||
assert.deepEqual(game.pendingArrivalReaction ?? null, f.originalQueue);
|
||||
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.pendingAmbassador ?? 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;
|
||||
}
|
||||
function settled(game: Game, f: SemutaSpiritualAmbassadorCostFixture, placed: boolean, target = f.territory) {
|
||||
game = finish(game); assert.equal(clean(game), true);
|
||||
assert.equal(game.pendingSpiritualAdvisor ?? null, null); assert.equal(game.pendingAmbassador ?? null, null);
|
||||
assert.equal(game.pendingArrivalReaction?.length ?? 0, 0); assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.deepEqual(clock(game), clock(f.game)); assert.deepEqual(inventory(game), f.originalInventory);
|
||||
assert.equal(grantLogs(game, f.owner).length, Number(placed));
|
||||
const bg = player(game, f.owner), original = player(f.game, f.owner);
|
||||
assert.equal(bg.reserves, original.reserves - Number(placed)); 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(placed && target === f.territory));
|
||||
if (placed && target === f.foreignDestination && f.stage === 'matching') assert.equal(bg.forces[f.foreignLocation], 1);
|
||||
for (const seat of game.players.filter(seat => seat.id !== f.owner))
|
||||
assert.deepEqual(physical(game).find(player => player.id === seat.id), f.originalPhysical.find(player => player.id === seat.id));
|
||||
if (!placed) assert.deepEqual(physical(game), f.originalPhysical);
|
||||
else assert.equal(isAdvisor(bg, target), f.stage === 'shipping' || target === f.foreignDestination);
|
||||
assert.equal(player(game, f.shipper).forces[f.foreignLocation], 1);
|
||||
assert.equal(player(game, f.entrant).forces['carthag:11'], 1);
|
||||
assert.deepEqual(game.ecazAmbassadors!.tokens.find(token => token.id === f.token), f.originalToken);
|
||||
reject(game, f.owner, f.nativeAction); return game;
|
||||
}
|
||||
|
||||
for (const stage of ['shipping', 'matching'] as const) for (const claimed of [false, true]) void test(
|
||||
`native Guild Ambassador Spiritual ${stage} Karama cost Semuta ${claimed ? 'claim' : 'pass'} preserves its committed grant and applies native denial once`, () => {
|
||||
const f = fixture({ stage }); nativeSource(f);
|
||||
const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
let game = denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event });
|
||||
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 Guild Ambassador Spiritual ${stage} allowance places the real free packet once without another token or foreign shipment`, () => {
|
||||
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 Guild Ambassador Spiritual matching denial preserves the real legal 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('Guild-derived Spiritual costs reject stale and underlying controls while their exact original child stays parked', () => {
|
||||
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.entrant, { type: 'endMovement' });
|
||||
reject(game, f.shipper, f.foreignShipmentAction);
|
||||
assert.deepEqual(game.pendingAmbassador, f.originalAmbassador); assert.deepEqual(physical(game), f.originalPhysical);
|
||||
denied(pass(game), f, false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('changed Guild parent or Spiritual source rejects before cost and before a fresh Semuta answer', () => {
|
||||
for (const stage of ['shipping', 'matching'] as const) {
|
||||
const f = fixture({ stage });
|
||||
for (const changed of ['parent', 'source'] as const) {
|
||||
const game = structuredClone(f.game);
|
||||
if (changed === 'parent') game.pendingSpiritualAdvisor!.signature = 'different-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));
|
||||
}
|
||||
const game = open(f); assert.ok(game.pendingAmbassador);
|
||||
game.pendingAmbassador.event = 'different-original-ambassador';
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: game.pendingTreacheryDiscard!.batch.event });
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal policies answer the actual Guild-derived Spiritual shipping and matching costs legally', () => {
|
||||
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(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);
|
||||
game = denied(game, f, claimed);
|
||||
if (stage === 'matching') game = act(game, f.owner, f.decline);
|
||||
settled(game, f, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, initializeSemutaGameForAudit, 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 { homeworldGameIntegrity, homeworldTable } from '../game/homeworld-game';
|
||||
import { spiritualAdvisorMatchingKey } from '../game/spiritual-advisor';
|
||||
import { quoteAutomaticAdvisorFlips } from '../game/advisor-automatic-flip';
|
||||
import {
|
||||
createSemutaSpiritualRevivalFixture as fixture, actSemutaSpiritualRevival as act,
|
||||
passSemutaSpiritualRevival as pass, claimSemutaSpiritualRevival as claim, allowSemutaSpiritualRevival as allow,
|
||||
finishSemutaSpiritualRevival as finish, semutaSpiritualRevivalPlayer as player,
|
||||
semutaSpiritualRevivalInventory as inventory, semutaSpiritualRevivalPhysical as physical,
|
||||
type SemutaSpiritualRevivalFixture,
|
||||
} from './fixture-semuta-spiritual-revival';
|
||||
|
||||
const stages = ['shipping', 'matching', 'arrival'] as const;
|
||||
const balances = (game: Game) => Object.fromEntries(game.players.map(p => [p.id, p.spice]));
|
||||
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 placements = (game: Game, bg: string) => game.log.filter(entry => entry.text.startsWith(`${player(game, bg).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: SemutaSpiritualRevivalFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.deepEqual(f.setup.expansions, ['choam', 'ix']); assert.equal(f.setup.homeworlds?.custody, null);
|
||||
assert.ok(f.afterSetup.homeworlds?.custody); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.deepEqual(f.enableHomeworlds.steps, [{ actor: f.game.host, action: { type: 'homeworlds', enabled: true } }]);
|
||||
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' && !card.id.startsWith('ix-')));
|
||||
assert.equal(player(f.afterSetup, f.reviver).faceDancers?.length, 3);
|
||||
assert.equal(player(f.afterSetup, f.reviver).reserves, 20); assert.equal(player(f.afterSetup, f.reviver).tanks, 0);
|
||||
assert.deepEqual(f.advisorSetup.steps[0].action, { type: 'advisorSetup',
|
||||
territory: f.stage === 'matching' ? 'polar_sink' : 'arrakeen', sector: f.stage === 'matching' ? 0 : 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.acquired, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquired, f.seller).spice - player(f.acquisition.before, f.seller).spice, 1);
|
||||
assert.deepEqual(f.firstStorm.steps.filter(step => step.action.type === 'stormDial').map(step => step.action.amount), [11, 0]);
|
||||
assert.equal(f.firstStorm.after.storm, 12); assert.deepEqual(f.shipment.steps[0], { actor: f.reviver, action: f.shipmentAction });
|
||||
assert.equal(player(f.shipment.before, f.reviver).spice - player(f.shipment.after, f.reviver).spice, 2);
|
||||
assert.equal(player(f.shipment.after, f.reviver).forces[f.casualtyLocation], 1);
|
||||
assert.deepEqual(f.firstSpiritualDecline.steps, [{ actor: f.owner, action: { type: 'decision', accept: false } }]);
|
||||
assert.equal(f.beforeStormMovement.storm, 12); assert.equal(f.beforeStormMovement.stormPending, 1);
|
||||
assert.equal(f.stormLoss.after.storm, 13); assert.equal(player(f.stormLoss.after, f.reviver).tanks, 1);
|
||||
assert.equal(player(f.beforeRequest, f.reviver).reserves, 19); assert.equal(player(f.beforeRequest, f.reviver).tanks, 1);
|
||||
assert.equal(player(f.beforeRequest, f.reviver).revived, 0);
|
||||
assert.equal(homeworldTable(f.beforeRequest)!.find(world => world.native === f.reviver)!.side, 'high');
|
||||
assert.deepEqual(f.freeRevive.steps[0], { actor: f.reviver, action: f.revivalAction });
|
||||
assert.equal(f.returned.homeworldRevivalReturn?.stage, 'choice'); assert.equal(f.returned.decision?.kind, 'homeworldRevivalDeployment');
|
||||
assert.equal(player(f.returned, f.reviver).tanks, 0); assert.equal(player(f.returned, f.reviver).reserves, 20);
|
||||
assert.equal(player(f.returned, f.reviver).revived, 1); assert.equal(player(f.returned, f.reviver).freeForcesRevived, 1);
|
||||
assert.equal(player(f.returned, f.reviver).spice, player(f.beforeRequest, f.reviver).spice);
|
||||
assert.deepEqual(f.send.steps[0], { actor: f.reviver, action: f.sendAction });
|
||||
assert.equal(player(f.game, f.reviver).forces[f.destination], 1); assert.equal(player(f.game, f.reviver).reserves, 19);
|
||||
assert.equal(f.originalReturn.stage, 'arrival'); assert.equal(f.originalReturn.source, 'normal');
|
||||
assert.equal(f.originalReturn.quote.kind, 'tleilax'); assert.deepEqual(f.originalReturn.group, { amount: 1, elite: 0, free: 1 });
|
||||
assert.equal(f.originalReturn.destination, f.destination); assert.equal(f.originalReturn.quote.beforePopulation, 19);
|
||||
assert.equal(f.originalReturn.quote.afterPopulation, 20); assert.equal(f.originalRetainedIncome?.kind, 'revivalIncome');
|
||||
assert.equal(f.originalRetainedIncome?.amount, 1); assert.equal(f.originalRetainedIncome?.owner, f.reviver);
|
||||
assert.equal(f.originalPendingRevival, null); assert.equal(f.game.pendingRevival ?? null, null);
|
||||
assert.equal(f.originalProgress.event, f.originalReturn.event); assert.equal(f.originalProgress.stage, 'arrival');
|
||||
assert.deepEqual(f.game.homeworldRevivalReturn, f.originalReturn); assert.deepEqual(f.game.homeworldRevivalProgress, f.originalProgress);
|
||||
assert.equal(f.originalChooser.revivalEvent, f.originalReturn.event); assert.equal(f.originalChooser.shipment, f.reviver);
|
||||
assert.equal(f.originalChooser.destination, f.destination); assert.deepEqual(f.declaration.steps, [{ actor: f.owner, action: f.nativeAction }]);
|
||||
assert.equal(f.originalSource.phase, 4); assert.equal(f.originalSource.revivalEvent, f.originalReturn.event);
|
||||
assert.equal(f.originalSource.owner, f.owner); assert.equal(f.originalSource.amount, 1);
|
||||
assert.equal(f.originalFrame.stage, f.stage); assert.deepEqual(f.originalFrame.source, f.originalSource);
|
||||
assert.equal(f.game.phase, 4); assert.equal(f.game.turn, 2); assert.equal(f.game.decision ?? null, null);
|
||||
assert.equal(f.game.response?.kind, f.stage === 'shipping' ? 'advisor' : 'advisorFlip');
|
||||
assert.deepEqual(f.game.pendingArrivalReaction, f.originalArrival);
|
||||
assert.ok(f.originalArrival.some(arrival => arrival.entrant === f.reviver && arrival.cause === 'homeworldRevival' &&
|
||||
arrival.revivalArrival?.event === f.originalReturn.event));
|
||||
if (f.stage === 'arrival') {
|
||||
assert.ok(f.originalAutomaticSource?.producer === 'spiritualArrival');
|
||||
assert.deepEqual(f.originalAutomaticSource.spiritual, f.originalSource); assert.deepEqual(f.originalQueue, [f.originalAutomaticSource]);
|
||||
assert.deepEqual(f.originalDeposit, { key: f.location, before: 0 });
|
||||
assert.equal(player(f.game, f.owner).reserves, player(f.beforeDeclaration, f.owner).reserves - 1);
|
||||
assert.equal(player(f.game, f.owner).forces[f.location], 1); assert.equal(isAdvisor(player(f.game, f.owner), f.territory), true);
|
||||
assert.equal(player(f.game, f.owner).advisors?.[f.territory]?.aloneFlipHold, f.originalAutomaticSource.event);
|
||||
assert.equal(placements(f.game, f.owner).length, 1);
|
||||
} else {
|
||||
assert.equal(f.originalDeposit, null); assert.deepEqual(f.originalQueue, []);
|
||||
assert.deepEqual(physical(f.game), physical(f.beforeDeclaration)); assert.equal(placements(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, 57); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
assert.equal(f.sourceActionCount, f.actions.length); homeworldGameIntegrity(f.game);
|
||||
}
|
||||
function open(f: SemutaSpiritualRevivalFixture) {
|
||||
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 the original Revival Spiritual cancellation cost');
|
||||
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.homeworldRevivalReturn, f.originalReturn); assert.deepEqual(game.homeworldRevivalProgress, f.originalProgress);
|
||||
assert.deepEqual(game.pendingSpiritualAdvisor, f.originalFrame); assert.deepEqual(game.pendingArrivalReaction, f.originalArrival);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorFlips ?? [], f.originalQueue); assert.equal(game.pendingRevival ?? null, null);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(balances(game), f.originalBalances);
|
||||
assert.deepEqual(player(game, f.owner).advisors, f.originalStance); assert.deepEqual(clock(game), clock(f.game));
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true); assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function settled(state: Game, f: SemutaSpiritualRevivalFixture, granted: boolean, held = false, target = f.territory) {
|
||||
const game = finish(state); assert.equal(game.homeworldRevivalReturn?.stage, 'complete');
|
||||
assert.equal(game.homeworldRevivalProgress?.stage, 'complete'); assert.equal(game.homeworldRevivalReturn?.event, f.originalReturn.event);
|
||||
assert.equal(game.pendingSpiritualAdvisor ?? null, null); assert.equal(game.pendingRevival ?? null, null);
|
||||
assert.equal(game.pendingArrivalReaction?.length ?? 0, 0); assert.deepEqual(game.pendingAutomaticAdvisorFlips ?? [], []);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.deepEqual(clock(game), clock(f.game)); assert.equal(game.phase, 4); assert.equal(game.turn, 2);
|
||||
for (const p of game.players) assert.equal(p.spice, f.originalBalances[p.id] + Number(p.id === f.reviver));
|
||||
const reviver = player(game, f.reviver), before = player(f.game, f.reviver), bg = player(game, f.owner);
|
||||
assert.equal(reviver.forces[f.destination], 1); assert.equal(reviver.reserves, 19); assert.equal(reviver.tanks, 0);
|
||||
assert.equal(reviver.revived, 1); assert.equal(reviver.freeForcesRevived, 1);
|
||||
assert.equal(reviver.shipped, before.shipped); assert.equal(reviver.moved, before.moved);
|
||||
assert.equal(bg.spice, player(f.game, f.owner).spice); assert.equal(bg.shipped, player(f.game, f.owner).shipped);
|
||||
assert.equal(bg.moved, player(f.game, f.owner).moved); assert.equal(bg.revived, player(f.game, f.owner).revived);
|
||||
const newlyWithdrawn = Number(granted && f.stage !== 'arrival');
|
||||
assert.equal(bg.reserves, player(f.game, f.owner).reserves - newlyWithdrawn);
|
||||
assert.equal(placements(game, f.owner).length, Number(granted));
|
||||
if (granted) {
|
||||
const key = target === 'polar_sink' ? 'polar_sink:0' : f.destination;
|
||||
assert.equal(bg.forces[key], (player(f.beforeDeclaration, f.owner).forces[key] ?? 0) + 1);
|
||||
assert.equal(isAdvisor(bg, target), f.stage === 'arrival' ? held : target !== 'polar_sink');
|
||||
}
|
||||
if (!newlyWithdrawn) assert.deepEqual(physical(game), f.originalPhysical);
|
||||
if (held) assert.deepEqual(bg.advisors, f.originalStance);
|
||||
assert.deepEqual(quoteAutomaticAdvisorFlips(game), []); homeworldGameIntegrity(game);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
reject(game, f.reviver, f.revivalAction); reject(game, f.reviver, f.sendAction); reject(game, f.owner, f.nativeAction);
|
||||
return game;
|
||||
}
|
||||
function denied(state: Game, f: SemutaSpiritualRevivalFixture, claimed: boolean) {
|
||||
let game = finish(state);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.response ?? null, null);
|
||||
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(physical(game), f.originalPhysical); assert.deepEqual(balances(game), f.originalBalances);
|
||||
assert.deepEqual(game.homeworldRevivalReturn, f.originalReturn); assert.deepEqual(game.homeworldRevivalProgress, f.originalProgress);
|
||||
assert.deepEqual(game.decision, f.originalChooser); assert.equal(game.pendingSpiritualAdvisor?.stage, 'offer');
|
||||
assert.deepEqual(game.pendingSpiritualAdvisor?.denied, [spiritualAdvisorMatchingKey(f.originalSource)]);
|
||||
reject(game, f.owner, f.sink); assert.deepEqual(game.pendingArrivalReaction, f.originalArrival);
|
||||
game = act(game, f.owner, f.decline);
|
||||
}
|
||||
const done = settled(game, f, f.stage === 'arrival', f.stage === 'arrival'); reject(done, f.spender, f.action); return done;
|
||||
}
|
||||
|
||||
for (const stage of stages) for (const claimed of [false, true]) void test(
|
||||
`native Revival SEND Spiritual ${stage} Karama cost Semuta ${claimed ? 'claim' : 'pass'} preserves the real return and finishes retained income once`, () => {
|
||||
const f = fixture({ stage }); nativeSource(f); const offered = open(f);
|
||||
denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
},
|
||||
);
|
||||
|
||||
for (const stage of stages) void test(`native Revival SEND Spiritual ${stage} allowance completes only the actual free grant and retained original income`, () => {
|
||||
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 Revival SEND matching cost denial retains real accompaniment as an independent legal replacement', () => {
|
||||
for (const claimed of [false, true]) {
|
||||
const f = fixture({ stage: 'matching' }), offered = open(f);
|
||||
let game = finish(claimed ? claim(offered, f.holder) : pass(offered));
|
||||
assert.deepEqual(game.decision, f.originalChooser); assert.ok(f.replacementAction);
|
||||
game = act(game, f.owner, f.replacementAction);
|
||||
if (game.response?.spiritualAdvisorSource) game = allow(game);
|
||||
settled(game, f, true, false, 'arrakeen');
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Revival SEND Spiritual cost rejects stale foreign and underlying return or chooser actions', () => {
|
||||
for (const stage of stages) {
|
||||
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.reviver, f.revivalAction); reject(game, f.reviver, f.sendAction);
|
||||
reject(game, f.owner, f.nativeAction); reject(game, f.owner, f.decline); reject(game, f.owner, { type: 'passResponse' });
|
||||
const done = denied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event });
|
||||
}
|
||||
});
|
||||
|
||||
void test('controlled changed original Revival SEND progress or Spiritual signature rejects before the native cost is spent', () => {
|
||||
for (const stage of stages) for (const changed of ['progress', 'spiritual'] as const) {
|
||||
const f = fixture({ stage }), game = structuredClone(f.game);
|
||||
// Explicit malformed-parent supplement; primary return, stock, casualty,
|
||||
// custody, hands and clocks are all earned through the original programme.
|
||||
if (changed === 'progress') game.homeworldRevivalProgress!.signature += ':changed';
|
||||
else game.pendingSpiritualAdvisor!.signature += ':changed';
|
||||
reject(game, f.spender, f.action); assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
}
|
||||
});
|
||||
|
||||
void test('fresh undealt Tleilax Semuta World configuration negatives retain Basic and extra-deck admission gates without mutation', () => {
|
||||
const f = fixture();
|
||||
for (const variant of ['basic', 'extra-ecaz-deck'] as const) {
|
||||
// Configuration negatives on a captured genuinely ready undealt lobby,
|
||||
// not a played Basic-to-Advanced conversion or a claimed native programme.
|
||||
const config = structuredClone(f.lobby);
|
||||
assert.ok(config.players.every(p => p.hand.length === 0)); assert.notEqual(config.status, 'playing');
|
||||
if (variant === 'basic') config.advanced = false;
|
||||
else config.expansions.push('ecaz');
|
||||
const before = structuredClone(config);
|
||||
assert.throws(() => initializeSemutaGameForAudit(config)); assert.deepEqual(config, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally claim or pass the three genuine Revival SEND Spiritual costs', () => {
|
||||
for (const stage of stages) {
|
||||
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 === 'semutaPass' || proposed.type === 'semutaCommit'); assert.equal(proposed.event, event);
|
||||
let game = act(structuredClone(offered), f.holder, proposed); const claimed = proposed.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const selected = botActions(selecting)[0]; assert.ok(selected);
|
||||
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost); game = act(game, f.holder, selected);
|
||||
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||
denied(game, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user