Offer Semuta on advisor, foresight and Face Dancer counters
This commit is contained in:
1 parent
2431370abb
commit
30c066726b
10 files changed
+1082
-9
No files matched your search
@@ -16,6 +16,42 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Semuta on information/advisor cancellation costs — 26 October. Prototyped / Partial.**
|
||||
Printed-role Karama stopping ordinary Basic Spiritual Advisors, Atreides Movement
|
||||
foresight, Advanced Fremen next-Storm knowledge or native Face Dancer replacement
|
||||
now offers its exact cost through `karamaTerminalDiscard`. Claim/pass finishes
|
||||
the original prevention once without placement, inspection, draw, replacement,
|
||||
attempt reset or shipment replay. Atreides retains its actual advisor-battle or
|
||||
Guild opening decision through the child cost. The original Basic paid entry
|
||||
waits in its signed queue, then continues once after advisor denial. Native Storm
|
||||
opening acknowledgements retain priority and finish before cancellation.
|
||||
[Source and boundaries](SEMUTA_SOURCE_UPDATE_20260907.md#terminal-information-and-advisor-cancellation-costs--26-october-2026).
|
||||
|
||||
Two disjoint source lanes, bounded native review and root integration pass
|
||||
**15 files,237/237**, types/lint/build and **16 native engine/GameTable SSR/real
|
||||
Semuta-callback journeys** on unchanged source. Twenty-five new cases cover four
|
||||
producers, both native Movement decisions, original allowed outcomes, opening
|
||||
priority, stale/underlying replies and all four minimal profiles. Genuine cache1
|
||||
Semuta and original dealt Karama precede every cost. Basic advisor82 source
|
||||
actions retain paid shipment2 and one deposited counter. Foresight71/77 Basic/
|
||||
Advanced, native BG77 and Guild75 preserve original queues/decks. Dancer124/134
|
||||
retains the original three and used attempt; Storm156 records five real opening
|
||||
ready acknowledgements before the same native forecast. Original45/57 cards stay
|
||||
physical. No played-state hand, force, phase, pool or control is installed.
|
||||
|
||||
First feedback repairs absent-versus-null opening assertions and test decision
|
||||
narrowing. Related233/237 identifies the ordinary advisor's still-queued paid
|
||||
entry. A focused qualifier now admits that exact committed physical source,
|
||||
keeps it signed/live during Semuta and lets native continuation drain it once;
|
||||
no retired shipment or payment is reconstructed. Native guards remain intact.
|
||||
Private source-bound report/logs:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-terminal-entry/`;
|
||||
journeys/HTML: adjacent `20261026-semuta-terminal-runtime/`.
|
||||
No saved-room/D1/server/reset/restart/deployment/recovery operation occurs.
|
||||
These are engine/SSR/callback results, not hydration, DOM/browser/mobile or
|
||||
deployed evidence. Complex Spiritual/queued-entry families, wider combinations,
|
||||
complete modes and final assurance remain unfinished.
|
||||
|
||||
**Semuta on Tleilaxu revival-income cancellation — 26 October. Prototyped / Partial.**
|
||||
Printed-role Karama stopping clean native ordinary/Ghola income now offers its
|
||||
exact used cost through `karamaIncomeDiscard`. Claim/pass denies the original
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 26 October 2026 — Semuta claims information and advisor cancellation costs
|
||||
|
||||
**Prototyped / Partial.** Exact printed-role Karama stopping ordinary Basic
|
||||
Spiritual Advisors, Atreides Movement foresight, Advanced Fremen next-Storm
|
||||
knowledge or native Face Dancer replacement now enters the existing Semuta
|
||||
protocol before denial. `karamaTerminalDiscard` binds the original response,
|
||||
knowledge, physical counters, queue and replacement attempt/pool. Native pure
|
||||
preflight supplies no second unspent cost. Claim/pass finishes once without a
|
||||
new placement, inspection, draw, replacement, attempt reset or shipment replay.
|
||||
[Source and limits](SEMUTA_SOURCE_UPDATE_20260907.md#terminal-information-and-advisor-cancellation-costs--26-october-2026).
|
||||
|
||||
Atreides may retain the native advisor-battle or Guild opening decision. Re-quote
|
||||
only its decision, park the exact original through Semuta, then restore it before
|
||||
denial without answering or initializing anything. Basic advisor cancellation
|
||||
retains the original paid shipment's queued physical entry; a focused qualifier
|
||||
validates that committed source without reconstructing its retired request.
|
||||
Native normalization consumes the entry once after prevention. Storm's original
|
||||
false opening must finish through real ready acknowledgements before cancellation;
|
||||
dispatcher priority remains. Existing persistent controls and minimal bots serve
|
||||
all four producers. Complex Spiritual/queued-entry families remain separate.
|
||||
|
||||
Two disjoint source lanes, bounded native review and root integration pass
|
||||
**15 files,237/237**, types/lint/build and **16 native engine/GameTable SSR/actual
|
||||
Semuta-callback journeys** on unchanged source. Twenty-five new cases cover both
|
||||
Movement decisions, original allowance, opening priority, relevant rejection and
|
||||
all four profiles. Real cache1 Semuta and original dealt Karama supply every cost:
|
||||
Basic advisor82 actions retain shipment2/one counter; foresight71/77 Basic/Advanced,
|
||||
BG77 and Guild75 retain original decks/decisions. Dancer124/134 keeps three
|
||||
original cards and used attempt; Storm156 includes five original opening ready
|
||||
actions. Original45/57 cards and native Traitor/Face Dancer pools remain physical;
|
||||
no played-state hand, force, wallet, phase, pool or control is installed.
|
||||
|
||||
Feedback repairs absent-versus-null opening assertions and one test-only type
|
||||
guard. Related233/237 exposes the ordinary advisor's deferred paid entry; root
|
||||
adds its precise signed source admission and native once-only continuation.
|
||||
No native gameplay guard is weakened. Private source-bound report/logs:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-terminal-entry/`;
|
||||
journeys/HTML: adjacent `20261026-semuta-terminal-runtime/`.
|
||||
No saved-room/D1/server/reset/restart/deployment/recovery operation occurs.
|
||||
These are engine/SSR/callback results; hydration, DOM/browser/mobile, broader
|
||||
combinations, full modes and deployed/final assurance remain open.
|
||||
|
||||
## 26 October 2026 — Semuta claims Tleilaxu revival-income cancellation costs
|
||||
|
||||
**Prototyped / Partial.** Printed-role Karama stopping clean native ordinary or
|
||||
|
||||
@@ -2,6 +2,81 @@
|
||||
|
||||
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.
|
||||
|
||||
## Terminal information and advisor cancellation costs — 26 October 2026
|
||||
|
||||
**Prototyped / Partial.** Printed-role Karama stopping ordinary Basic Spiritual
|
||||
Advisors, native Atreides Movement foresight, Advanced Fremen next-Storm knowledge
|
||||
or native Face Dancer replacement now offers its exact used cost before denial.
|
||||
The shared `karamaTerminalDiscard` retains the original typed response and uses
|
||||
the native cancellation validator as pure preflight. Its cost is already spent;
|
||||
do not supply it again as an unspent card. After claim/pass, retire the group,
|
||||
restore original controls and finish prevention once. Existing neutral persistent
|
||||
controls, private candidate choice and minimal profiles serve all four sources.
|
||||
This applies the existing printed Semuta timing, not a new publisher ruling.
|
||||
|
||||
The stopped advisor packet is not withdrawn, placed or given an arrival/Tech
|
||||
event. Its preceding actual shipment/payment and remaining turn remain intact.
|
||||
A [focused source qualifier](../game/semuta-terminal-arrival.ts) retains the
|
||||
single current Basic ordinary paid entry queued behind that advisor response.
|
||||
It checks the deposited physical typed group and original clock/turn owner;
|
||||
it does not reconstruct a retired shipment or quote/pay/withdraw again. Keep
|
||||
the signed queue live through the fresh cost; native continuation consumes it
|
||||
once after prevention. Other queued-entry families remain separate.
|
||||
Atreides and Fremen gain no new knowledge, draw or refill from cancellation;
|
||||
retain the actual already-prepared Spice/Storm source. The stopped Face Dancer
|
||||
replacement retains its original private pool and reserve order, while its
|
||||
declared once-per-turn attempt stays used. No producer action replays.
|
||||
|
||||
Atreides foresight may coexist with a native advisor-battle or Guild opening
|
||||
decision. Re-quote only that decision from the current public native board/order;
|
||||
do not compare or execute an initialization/refill. Suspend the exact decision
|
||||
while the cost is offered, then restore it before prevention without answering
|
||||
it. The quoted source and subtype-only signature bind the original decision,
|
||||
queue, counters, knowledge, attempt and pools; earlier receipt kinds retain their
|
||||
signature format. Other unfinished movement/arrival parents remain separate.
|
||||
|
||||
With the Ix deck, native post-Storm knowledge initially coexists with an
|
||||
`initialize:false` phase opening. The dispatcher gives that opening priority.
|
||||
Real ready acknowledgements must finish before a valid ordinary cancellation;
|
||||
do not clear, bypass or reinitialize the frame to manufacture a source. Advanced
|
||||
SpiritualSource, Ambassador and other complex accompaniment remain separate.
|
||||
|
||||
[Spiritual/foresight source](../tests/fixture-semuta-spiritual-foresight.ts) and
|
||||
[engine cases](../tests/semuta-spiritual-foresight-engine.test.ts),
|
||||
[Storm/replacement source](../tests/fixture-semuta-storm-dancer.ts) and
|
||||
[engine cases](../tests/semuta-storm-dancer-engine.test.ts) record the native paths.
|
||||
Wider producers/combinations, complete modes and final assurance remain open.
|
||||
|
||||
Frozen **15 files,237/237**, types/lint/build and **16 actual native engine/
|
||||
GameTable SSR/real Semuta-callback journeys** pass on unchanged source. Twenty-five
|
||||
new cases cover four producers, both Movement-opening decisions, original allowed
|
||||
outcomes, native opening priority, stale/underlying rejection and all four minimal
|
||||
profiles. Earlier assertions and evidence keep their original scope.
|
||||
|
||||
Native first-cache1 acquisition and original Karama dealing precede all costs.
|
||||
Basic advisor82 source actions include an actual two-spice one-force shipment
|
||||
and BG acceptance. Foresight71 Basic/77 Advanced stops at its original prepared
|
||||
deck; real BG77 or Guild75 setup yields the coexisting native decision. Basic/
|
||||
Advanced Dancer124/134 actually declares replacement of an original unrevealed
|
||||
candidate and retains its used attempt. Storm156 reaches the real second-Storm
|
||||
forecast, records its false opening and submits five actual ready actions before
|
||||
the same valid counter. Original45/57 Treachery cards and native Traitor/Face
|
||||
Dancer pools remain physical. No played-state hand, force, wallet, phase, pool
|
||||
or control is installed.
|
||||
|
||||
First feedback repairs missing-versus-null opening assertions and a test-only
|
||||
advisor-decision type guard. Related233/237 then exposes the original advisor
|
||||
shipment's deferred entry. The focused qualifier admits only its exact single
|
||||
current Basic ordinary physical entry, rather than treating it as an unrelated
|
||||
complex parent. Keep it signed and live during the cost; native continuation
|
||||
consumes it once afterward without replaying payment or placement. No native
|
||||
gameplay guard is weakened. Private source-bound report/logs:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-terminal-entry/`;
|
||||
journeys/HTML: adjacent `20261026-semuta-terminal-runtime/`.
|
||||
No saved-room/database/server/reset/restart/deployment/recovery operation occurs.
|
||||
Engine/SSR/callback evidence leaves hydration, DOM/browser/mobile, broader
|
||||
combinations, complete modes and exact-revision deployed acceptance open.
|
||||
|
||||
## Revival-income cancellation costs — 26 October 2026
|
||||
|
||||
**Prototyped / Partial.** Printed-role Karama stopping clean native Tleilaxu
|
||||
|
||||
+69
-9
@@ -1,4 +1,5 @@
|
||||
import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income';
|
||||
import { semutaTerminalArrivalSupported } from './semuta-terminal-arrival';
|
||||
import { traitorDecisionChoices, bindTraitorDecisionPromise, settleTraitorDecisionPromises,
|
||||
reconcileTraitorDecisionPromises as reconcileTraitorDecisions, validateTraitorDecisionPromise,
|
||||
TraitorDecisionError, type TraitorDecisionContext, type TraitorDecisionPromise } from './traitor-decision-promises';
|
||||
@@ -1575,7 +1576,7 @@ export type Game = {
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard';
|
||||
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard';
|
||||
player: string;
|
||||
card: string;
|
||||
use: Extract<KaramaUse, { kind: 'cancel' }>;
|
||||
@@ -5854,9 +5855,40 @@ function semutaAuctionAdvantageResponse(g: Game, response: ResponseWindow, playe
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/** These denied native branches change only knowledge, an unused packet or
|
||||
* the declared replacement attempt. Keep complex arrival/opening parents out. */
|
||||
function semutaTerminalKaramaResponse(g: Game, response: ResponseWindow, player: string,
|
||||
decision: Game['decision'] = g.decision): boolean {
|
||||
if (!['advisor','atreidesSpice','stormPeek','faceDancerReplacement'].includes(response.kind) ||
|
||||
g.battle || g.phaseOpening || g.pendingKarama || g.pendingRevival ||
|
||||
g.pendingShipment || g.pendingGuildTransport || g.pendingHomeworldShipment ||
|
||||
g.pendingSpiritualAdvisor || g.pendingAmbassador || g.pendingTerrorEntry ||
|
||||
g.pendingArrivalOverlap || !semutaTerminalArrivalSupported(g,response) ||
|
||||
g.pendingIxMove || g.pendingFremenMove || g.pendingChoamMove ||
|
||||
g.ornithopter || g.summonedWorm || g.wormRides.length > 0 ||
|
||||
g.choamMarket || g.pendingChoamMarketGhola) return false;
|
||||
if (response.kind === 'advisor' &&
|
||||
(response.spiritualAdvisorSource || response.advisorResume ||
|
||||
response.advisorFollowup || response.advisorRemaining !== undefined ||
|
||||
response.advisorAmbassadorEvent !== undefined)) return false;
|
||||
try {
|
||||
if (decision) {
|
||||
if (response.kind !== 'atreidesSpice') return false;
|
||||
const quote = movementPhaseQuote(() => quoteMovementPhaseStart(g));
|
||||
if (quote.kind !== 'initialize' ||
|
||||
JSON.stringify(quote.decision) !== JSON.stringify(decision)) return false;
|
||||
}
|
||||
// The cost is already physically spent. No spendingCard is supplied again.
|
||||
validateKaramaUse({...g,pendingTreacheryDiscard:null,response,decision},getPlayer(g,player),{kind:'cancel',response});
|
||||
return terminalCancellationQuote(g,response)?.kind === response.kind;
|
||||
} catch (error) {
|
||||
if (error instanceof RuleError) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
type KaramaResponseDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' }
|
||||
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' }
|
||||
>;
|
||||
function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) {
|
||||
const battle = c.kind === 'karamaBattleDiscard';
|
||||
@@ -5868,6 +5900,7 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
|
||||
const conversion = c.kind === 'karamaBgConversionCancelDiscard';
|
||||
const richese = c.kind === 'karamaRicheseAuctionDiscard' || c.kind === 'karamaRicheseGiftDiscard';
|
||||
const auctionAdvantage = c.kind === 'karamaAuctionAdvantageDiscard';
|
||||
const terminal = c.kind === 'karamaTerminalDiscard';
|
||||
return JSON.stringify({
|
||||
player: c.player, card: c.card, use: c.use,
|
||||
parent: nullentropyParentSignature(g, c.resume,
|
||||
@@ -5945,12 +5978,27 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
|
||||
active:g.active, ready:g.ready, homeworlds:g.homeworlds,
|
||||
inspection:g.homeworldAuctionInspection, occupation:g.homeworldOccupationHistory,
|
||||
} : undefined,
|
||||
terminal: terminal ? {
|
||||
active:g.active, ready:g.ready, remaining:g.movementRemaining,
|
||||
shipping:g.karamaShipping, aid:g.aid, guildGranted:g.guildTimingGranted,
|
||||
guildLocked:g.guildTimingLocked, saphoLast:g.saphoMovementLast,
|
||||
storm:g.storm, stormCard:g.stormCard, stormKnown:g.stormCardKnown,
|
||||
stormPending:g.stormPending, stormResolution:g.stormResolution,
|
||||
stormSource:g.stormMovementSource, stormDials:g.stormDials,
|
||||
arrivals:g.pendingArrivalReaction, alliedShipment:g.basicAlliedShipment,
|
||||
opening:g.phaseOpening, decision:g.decision, spiceDeck:g.spiceDeck, spiceDiscard:g.spiceDiscard,
|
||||
spiceKnown:g.spicePeekKnown, traitorReserve:g.traitorReserve,
|
||||
tech:g.techTokens, homeworlds:g.homeworlds, occupation:g.homeworldOccupationHistory,
|
||||
players:g.players.map(p => ({id:p.id, moved:p.moved, advisors:p.advisors,
|
||||
noField:p.noField, noFieldEvent:p.noFieldEvent, faceDancers:p.faceDancers,
|
||||
replaced:p.faceDancerReplacedTurn})),
|
||||
} : undefined,
|
||||
order: g.order,
|
||||
players: g.players.map(p => ({
|
||||
id: p.id, faction: p.faction, ally: p.ally,
|
||||
hand: p.hand.map(card => card.id), spice: p.spice,
|
||||
shipped: income || shipment || conversion ? p.shipped : undefined,
|
||||
physical: battle || worthless || income || shipment || conversion
|
||||
shipped: income || shipment || conversion || terminal ? p.shipped : undefined,
|
||||
physical: battle || worthless || income || shipment || conversion || terminal
|
||||
? { forces: p.forces, reserves: p.reserves, tanks: p.tanks,
|
||||
elites: p.elites, leaders: battle ? p.leaders : undefined,
|
||||
noField: shipment ? p.noField : undefined,
|
||||
@@ -6864,6 +6912,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
continuation?.kind === 'karamaRicheseAuctionDiscard' ||
|
||||
continuation?.kind === 'karamaRicheseGiftDiscard' ||
|
||||
continuation?.kind === 'karamaAuctionAdvantageDiscard' ||
|
||||
continuation?.kind === 'karamaTerminalDiscard' ||
|
||||
continuation?.kind === 'karamaShipmentDiscard' ||
|
||||
continuation?.kind === 'karamaBgConversionCancelDiscard') {
|
||||
const c = continuation, entry = batch.entries[0];
|
||||
@@ -6894,6 +6943,8 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
semutaRicheseKaramaResponse(g,c.use.response)) ||
|
||||
(c.kind === 'karamaAuctionAdvantageDiscard' &&
|
||||
semutaAuctionAdvantageResponse(g,c.use.response,c.player)) ||
|
||||
(c.kind === 'karamaTerminalDiscard' &&
|
||||
semutaTerminalKaramaResponse(g,c.use.response,c.player,c.resume.decision)) ||
|
||||
(c.kind === 'karamaShipmentDiscard' &&
|
||||
semutaGuildShipmentKaramaResponse(g, c.use.response)) ||
|
||||
(c.kind === 'karamaBgConversionCancelDiscard' &&
|
||||
@@ -6902,7 +6953,8 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
c.resume.response?.kind === c.use.response.kind &&
|
||||
JSON.stringify(c.resume.response) === JSON.stringify(c.use.response) &&
|
||||
(c.kind === 'karamaBgConversionCancelDiscard' ||
|
||||
(!c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening)) &&
|
||||
((c.kind === 'karamaTerminalDiscard' || !c.resume.decision) &&
|
||||
!c.resume.pendingKarama && !c.resume.phaseOpening)) &&
|
||||
batch.cause === `karama:${c.use.response.kind}` &&
|
||||
batch.entries.length === 1 &&
|
||||
entry.publicFace && entry.discardedBy === c.player &&
|
||||
@@ -7796,6 +7848,7 @@ function semutaOfferSupported(
|
||||
const richeseGiftDiscard = continuation.kind === 'karamaRicheseGiftDiscard';
|
||||
const richeseDiscard = richeseGiftDiscard || continuation.kind === 'karamaRicheseAuctionDiscard';
|
||||
const auctionAdvantageDiscard = continuation.kind === 'karamaAuctionAdvantageDiscard';
|
||||
const terminalDiscard = continuation.kind === 'karamaTerminalDiscard';
|
||||
const bgDiscard = continuation.kind === 'bgKaramaCostDiscard' ||
|
||||
continuation.kind === 'karamaBgConversionCancelDiscard';
|
||||
const ambassadorDiscard = continuation.kind === 'ambassador';
|
||||
@@ -7834,7 +7887,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'karamaChoamWorthlessDiscard' &&
|
||||
continuation.kind !== 'karamaEmperorShareDiscard' &&
|
||||
continuation.kind !== 'karamaIncomeDiscard' &&
|
||||
!richeseDiscard && !auctionAdvantageDiscard &&
|
||||
!richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard &&
|
||||
continuation.kind !== 'karamaShipmentDiscard' &&
|
||||
continuation.kind !== 'karamaShipmentPriceDiscard' &&
|
||||
continuation.kind !== 'specialKaramaDiscard' &&
|
||||
@@ -7871,7 +7924,7 @@ function semutaOfferSupported(
|
||||
continuation.promise.kind === 'shipment' ||
|
||||
continuation.promise.kind === 'movement' ||
|
||||
continuation.promise.kind === 'battle'))) return false;
|
||||
if (g.homeworlds && !bgDiscard && !richeseDiscard && !auctionAdvantageDiscard && continuation.kind !== 'kaitainDiscard' &&
|
||||
if (g.homeworlds && !bgDiscard && !richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && continuation.kind !== 'kaitainDiscard' &&
|
||||
continuation.kind !== 'tupileCleanupDiscard' &&
|
||||
continuation.kind !== 'karamaIncomeDiscard' &&
|
||||
continuation.kind !== 'karamaShipmentDiscard' &&
|
||||
@@ -7975,6 +8028,7 @@ function semutaOfferSupported(
|
||||
continuation.kind === 'karamaIncomeDiscard' ||
|
||||
richeseDiscard ||
|
||||
auctionAdvantageDiscard ||
|
||||
terminalDiscard ||
|
||||
continuation.kind === 'karamaShipmentDiscard'
|
||||
? (continuation.kind === 'karamaBattleDiscard'
|
||||
? semutaBattleKaramaResponse(g, continuation.use.response)
|
||||
@@ -7988,10 +8042,12 @@ function semutaOfferSupported(
|
||||
? semutaRicheseKaramaResponse(g,continuation.use.response)
|
||||
: auctionAdvantageDiscard
|
||||
? semutaAuctionAdvantageResponse(g,continuation.use.response,continuation.player)
|
||||
: terminalDiscard
|
||||
? semutaTerminalKaramaResponse(g,continuation.use.response,continuation.player,continuation.resume.decision)
|
||||
: continuation.kind === 'karamaShipmentDiscard'
|
||||
? semutaGuildShipmentKaramaResponse(g, continuation.use.response)
|
||||
: ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) &&
|
||||
!continuation.resume.decision && !continuation.resume.pendingKarama &&
|
||||
(terminalDiscard || !continuation.resume.decision) && !continuation.resume.pendingKarama &&
|
||||
!continuation.resume.phaseOpening && !g.pendingKarama &&
|
||||
(!g.response || JSON.stringify(g.response) === JSON.stringify(continuation.resume.response))
|
||||
: true) &&
|
||||
@@ -8023,6 +8079,7 @@ function semutaOfferSupported(
|
||||
continuation.kind === 'karamaIncomeDiscard' ||
|
||||
richeseDiscard ||
|
||||
auctionAdvantageDiscard ||
|
||||
terminalDiscard ||
|
||||
continuation.kind === 'karamaShipmentDiscard' ||
|
||||
((continuation.kind === 'ordinaryCardDiscard'
|
||||
? ordinaryDiscardIncomeMatches(g, continuation)
|
||||
@@ -8379,6 +8436,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
||||
next.kind === 'karamaRicheseAuctionDiscard' ||
|
||||
next.kind === 'karamaRicheseGiftDiscard' ||
|
||||
next.kind === 'karamaAuctionAdvantageDiscard' ||
|
||||
next.kind === 'karamaTerminalDiscard' ||
|
||||
next.kind === 'karamaShipmentDiscard' ||
|
||||
next.kind === 'karamaBgConversionCancelDiscard' ||
|
||||
next.kind === 'karamaPurchaseDiscard' ||
|
||||
@@ -15733,6 +15791,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
|
||||
semutaEmperorShareKaramaResponse(g, use.response) ||
|
||||
semutaIncomeKaramaResponse(g, use.response) || semutaRicheseKaramaResponse(g,use.response) ||
|
||||
semutaAuctionAdvantageResponse(g,use.response,p.id) ||
|
||||
semutaTerminalKaramaResponse(g,use.response,p.id) ||
|
||||
guildShipmentDiscard || bgConversionCancelDiscard)) {
|
||||
const continuation: KaramaResponseDiscardContinuation = {
|
||||
kind: bgConversionCancelDiscard ? 'karamaBgConversionCancelDiscard'
|
||||
@@ -15740,6 +15799,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
|
||||
: use.response.kind === 'richeseAuction' ? 'karamaRicheseAuctionDiscard'
|
||||
: use.response.kind === 'richeseGift' ? 'karamaRicheseGiftDiscard'
|
||||
: ['atreidesAuction','ixAuction','ixTechnology'].includes(use.response.kind) ? 'karamaAuctionAdvantageDiscard'
|
||||
: ['advisor','atreidesSpice','stormPeek','faceDancerReplacement'].includes(use.response.kind) ? 'karamaTerminalDiscard'
|
||||
: use.response.kind === 'choamCharity' ? 'karamaCharityDiscard'
|
||||
: use.response.kind === 'choamInflation'
|
||||
? 'karamaInflationDiscard'
|
||||
@@ -15760,7 +15820,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
|
||||
g.response = null;
|
||||
if (bgConversionCancelDiscard) {
|
||||
g.decision = null; g.pendingKarama = null; g.phaseOpening = null;
|
||||
}
|
||||
} else if (continuation.kind === 'karamaTerminalDiscard') g.decision = null;
|
||||
continuation.stateSignature = karamaResponseDiscardSignature(g, continuation);
|
||||
stageTreacheryDiscard(g, `karama:${use.response.kind}`, entries, continuation);
|
||||
return;
|
||||
|
||||
@@ -1291,6 +1291,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'When Semuta itself was proposed as the now-prevented allied gift, its donor may spend it to claim that counter’s used cost. Prevention first retains Semuta and restores the original parent; the same committed response then spends Semuta and takes the cost, without a second prevention or transfer to the ally. Passing keeps Semuta. Other commitments remain enforced.',
|
||||
'Printed-role Karama stopping Atreides auction inspection, Ixian auction preparation or Advanced Technology also offers its exact cost before prevention. Claim or Continue resumes the original lot or ordinary draw once, without new inspection, extra Ix choice or exchange. Technology stays used and its original unseen lot remains; the native Atreides peek follows. If outgoing Semuta was selected for that now-prevented exchange, only that reservation releases so Semuta can claim the counter’s cost. Other commitments and independent knowledge remain.',
|
||||
'Printed-role Karama stopping clean native Tleilaxu revival income offers that exact used cost before income prevention. The ordinary or Ghola revival has already completed: returned forces/leaders, actual payment, normal/free/elite allowance use and the free-income history stay unchanged. Claim or Continue withholds the original income once; taking its counter cannot repeat the revival or recover an already discarded Ghola. Ghola’s own earlier discard is a separate opportunity. Unfinished battle, Ambassador, market and arrival parents retain their separate boundaries.',
|
||||
'Printed-role Karama stopping ordinary Basic Spiritual Advisors, Atreides Movement foresight, Advanced Fremen Storm knowledge or native Face Dancer replacement also offers its exact used cost. Claim or Continue finishes that original prevention once. The paid shipment and remaining turn stay intact; the stopped free packet is not placed. Foresight gains no new knowledge or draw. An original Bene Gesserit stance or Guild opening choice waits unchanged until the Atreides cost finishes. A stopped replacement retains the original Face Dancers and reserve, with its attempt still used. Storm opening acknowledgements must finish before cancellation; they are never bypassed or repeated. Complex Spiritual and arrival parents remain separate.',
|
||||
'The explicit fresh Leader Skills entry composes Semuta with original all-fourteen-card native Basic Richese/CHOAM or Advanced E1/E2 setup, retaining original Tech and Advanced Stronghold prerequisites. Actual offers train original living discs; the native Auditor remains ineligible. A completed Hajr discard can resume the same Movement owner before an original private Mentat question. Mandatory loser disposal can resume trained Suk casualty rescue or Rihani’s normal private inspection and optional skilled exchange before winner cleanup. Winning compulsory Artillery stays held through the original skill, then becomes its own fresh Treachery discard. Traitor/Face Dancer movement is never a Semuta target; no rescue, question, inspection or exchange repeats. Basic Ix mixtures, E3, other module/Banker overlays and public starts stay separate.',
|
||||
'The fresh semuta --nexus-cards entry retains original native E1/E2 Nexus setup, optionally explicit Skills and original Tech/Advanced Strongholds. There is no starting Nexus hand: an actual qualifying closing draw follows a Nexus and settled alliance. CHOAM Secret Ally with CHOAM absent spends a Worthless and its Nexus for two bank spice before the public discard offer; CHOAM Betrayal with CHOAM seated commits one random actual hand-card discard without compensation before its private offer. Claim or Continue retires the original receipt once, without another payment or sample. Separate Nexus cards never become Treachery targets. Homeworlds, Discovery, E3, played-save conversion and public starts stay gated.',
|
||||
'The same original native Nexus entry offers the actual CHOAM Cunning fuel discard for Kulon, Gamont, Baliset, La La La and Jubba. Any eligible held Treachery card keeps its printed identity and private face; there is no fabricated named Worthless. Original cancellation retains fuel and the spent Nexus. After allowance, Commit or Continue resumes the selected effect and parent once: one range bonus, one typed force return and original closing owner, stopped ordinary movement/free revival, or one threatened-territory storm protection. Jubba protects CHOAM forces, not spice; unprotected forces and covered spice still suffer the original storm. Kull retains its separate guard.',
|
||||
@@ -1427,6 +1428,10 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/semuta-prevented-gift-reservation.test.ts',
|
||||
'tests/fixture-semuta-revival-income.ts',
|
||||
'tests/semuta-revival-income-engine.test.ts',
|
||||
'tests/fixture-semuta-spiritual-foresight.ts',
|
||||
'tests/semuta-spiritual-foresight-engine.test.ts',
|
||||
'tests/fixture-semuta-storm-dancer.ts',
|
||||
'tests/semuta-storm-dancer-engine.test.ts',
|
||||
'tests/semuta-homeworld-bg-charity.test.ts',
|
||||
'tests/semuta-homeworld-emperor-share.test.ts',
|
||||
'tests/semuta-homeworld-capture-karama.test.ts',
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Game, ResponseWindow } from './engine';
|
||||
import { location, validGameLocation } from './board';
|
||||
|
||||
/** A bare Basic advisor choice parks the original paid shipment's entry.
|
||||
* Validate its committed physical source, without quoting or replaying payment. */
|
||||
export function semutaTerminalArrivalSupported(g: Game, response: ResponseWindow): boolean {
|
||||
const arrivals = g.pendingArrivalReaction;
|
||||
if (!arrivals?.length) return true;
|
||||
if (g.advanced || response.kind !== 'advisor' || !Array.isArray(arrivals) ||
|
||||
arrivals.length !== 1 || g.phase !== 5) return false;
|
||||
const entry = arrivals[0];
|
||||
if (!entry || Object.keys(entry).sort().join(',') !==
|
||||
'amount,cause,elite,entrant,phase,resume,sector,territory,turn' ||
|
||||
entry.turn !== g.turn || entry.phase !== g.phase ||
|
||||
entry.cause !== 'shipment' || entry.resume !== 'none' ||
|
||||
!Number.isSafeInteger(entry.amount) || entry.amount <= 0 || entry.amount > 20 ||
|
||||
!Number.isSafeInteger(entry.elite) || entry.elite < 0 || entry.elite > entry.amount ||
|
||||
!validGameLocation(g,entry.territory,entry.sector)) return false;
|
||||
const entrant = g.players.find(p => p.id === entry.entrant);
|
||||
if (!entrant || entrant.id !== g.active || entrant.id === response.owner ||
|
||||
entrant.faction === 'beneGesserit' || entrant.faction === 'fremen' ||
|
||||
entrant.shipped !== true || !g.movementRemaining?.includes(entrant.id)) return false;
|
||||
const key = location(entry.territory,entry.sector), total = entrant.forces[key] ?? 0,
|
||||
elite = entrant.elites?.forces[key] ?? 0;
|
||||
return total >= entry.amount && elite >= entry.elite &&
|
||||
total - elite >= entry.amount - entry.elite;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaRicheseCacheKarama, nextSemutaRicheseCacheKarama,
|
||||
semutaRicheseCacheKaramaClean, semutaRicheseCacheKaramaInventory,
|
||||
semutaRicheseCacheKaramaPlayer,
|
||||
} from './fixture-semuta-richese-cache-karama';
|
||||
|
||||
export type SemutaSpiritualForesightStep = { actor: string; action: Action };
|
||||
export const actSemutaSpiritualForesight = actSemutaRicheseCacheKarama;
|
||||
export const semutaSpiritualForesightInventory = semutaRicheseCacheKaramaInventory;
|
||||
export const semutaSpiritualForesightPlayer = semutaRicheseCacheKaramaPlayer;
|
||||
export const semutaSpiritualForesightClean = semutaRicheseCacheKaramaClean;
|
||||
|
||||
/** Native setup chooses either the quiet Polar source or the real Arrakeen
|
||||
* advisor-battle opportunity. No force, stance or decision is installed. */
|
||||
export function nextSemutaSpiritualForesight(game: Game,
|
||||
openingDecision?: 'advisorBattle' | 'guildTiming'): SemutaSpiritualForesightStep | null {
|
||||
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
|
||||
const bg = game.players.find(player => player.faction === 'beneGesserit');
|
||||
if (game.status === 'setup' && game.setupStage === 'prediction') {
|
||||
assert.ok(bg);
|
||||
return { actor: bg.id, action: { type: 'predict', faction: 'emperor', turn: 10 } };
|
||||
}
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && game.advanced && bg && !bg.advisorSetup)
|
||||
return { actor: bg.id, action: { type: 'advisorSetup',
|
||||
territory: openingDecision === 'advisorBattle' ? 'arrakeen' : 'polar_sink',
|
||||
sector: openingDecision === 'advisorBattle' ? 10 : 0 } };
|
||||
if (game.decision?.kind === 'advisor' || game.decision?.kind === 'advisorBattle' || game.decision?.kind === 'intrusion')
|
||||
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
|
||||
}
|
||||
return nextSemutaRicheseCacheKarama(game);
|
||||
}
|
||||
export function advanceSemutaSpiritualForesight(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaSpiritualForesightStep[] = [], openingDecision?: 'advisorBattle' | 'guildTiming'): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original terminal source must reach its next native control');
|
||||
const next = nextSemutaSpiritualForesight(game, openingDecision);
|
||||
assert.ok(next, `Supply the original owned control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaSpiritualForesight(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original Spiritual/foresight programme did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** Real first-turn Semuta purchase precedes both terminal sources. The Basic
|
||||
* advisor path retains a committed Emperor shipment; foresight stops at the
|
||||
* native phase-five opening before a card can be inspected or drawn. */
|
||||
export function createSemutaSpiritualForesightFixture(options: {
|
||||
kind: 'advisor' | 'atreidesSpice'; advanced?: boolean; openingDecision?: 'advisorBattle' | 'guildTiming';
|
||||
}) {
|
||||
const { kind, openingDecision } = options, advanced = options.advanced ?? false;
|
||||
assert.ok(kind !== 'advisor' || !advanced,
|
||||
'This source is the ordinary Basic bare advisor response, not a new or legacy Advanced Spiritual frame');
|
||||
assert.ok(!openingDecision || advanced && kind === 'atreidesSpice',
|
||||
'Only actual Advanced foresight shares these native opening decisions');
|
||||
const richese = 'richese', holder = 'choam', bg = 'beneGesserit', atreides = 'atreides', spender = 'emperor';
|
||||
const fifthFaction = openingDecision === 'guildTiming' ? 'guild' as const : bg;
|
||||
const owner = kind === 'advisor' ? bg : atreides;
|
||||
let game = createGame('SEMUTASPIRITUALFORESIGHT', newPlayer(richese, 'Richese', 'richese'), advanced, ['choam']);
|
||||
for (const faction of [holder, fifthFaction, atreides, spender] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaSpiritualForesightStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaSpiritualForesight(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => {
|
||||
game = advanceSemutaSpiritualForesight(game, until, actions, openingDecision);
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
else act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
|
||||
entry: advanced ? 'original-undealt-semuta-preview' : 'public-basic-start' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaSpiritualForesightInventory(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').slice(0, 4); assert.equal(ordinary.length, 4);
|
||||
const selected = [...ordinary, printed];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game), cost = printed.id;
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.ok(semutaSpiritualForesightPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const front: Game['spiceDeck'] = [], spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
for (let position = 0; position < (advanced ? 2 : 1); position++) {
|
||||
const from = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(from >= 0);
|
||||
const card = game.spiceDeck.splice(from, 1)[0]; front.push(card);
|
||||
spiceOrder.push({ position, from, card: structuredClone(card) });
|
||||
}
|
||||
game.spiceDeck.unshift(...front);
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let beforeBid: Game | undefined, bidStep: SemutaSpiritualForesightStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaSpiritualForesightPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const next = nextSemutaSpiritualForesight(game, openingDecision); assert.ok(next);
|
||||
if (next.actor === holder && next.action.type === 'richeseBid' && next.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bidStep = structuredClone(next); bidAt = actions.length;
|
||||
}
|
||||
act(next.actor, next.action);
|
||||
}
|
||||
assert.ok(beforeBid && bidStep && bidAt >= 0, 'CHOAM must actually buy the original Semuta from cache');
|
||||
const acquired = structuredClone(game), acquisition = {
|
||||
before: beforeBid, bid: bidStep, steps: structuredClone(actions.slice(bidAt)), after: acquired,
|
||||
};
|
||||
assert.equal(semutaSpiritualForesightPlayer(beforeBid, holder).spice - semutaSpiritualForesightPlayer(game, holder).spice, 1);
|
||||
assert.equal(semutaSpiritualForesightPlayer(game, richese).spice - semutaSpiritualForesightPlayer(beforeBid, richese).spice, 1);
|
||||
reach(state => state.phase === 4 && semutaSpiritualForesightClean(state));
|
||||
const beforeMovement = structuredClone(game);
|
||||
let beforeShipment: Game | null = null;
|
||||
let shipment: { before: Game; action: Action; after: Game; steps: SemutaSpiritualForesightStep[] } | null = null;
|
||||
let advisorChoice: { before: Game; action: Action; after: Game } | null = null;
|
||||
if (kind === 'advisor') {
|
||||
reach(state => state.phase === 5 && state.active === spender && semutaSpiritualForesightClean(state));
|
||||
beforeShipment = structuredClone(game);
|
||||
const action: Action = { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 };
|
||||
const start = actions.length;
|
||||
act(spender, action);
|
||||
reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'advisor');
|
||||
assert.ok(game.decision?.kind === 'advisor');
|
||||
assert.equal(game.decision?.player, bg);
|
||||
assert.equal(game.decision?.shipment, spender);
|
||||
const after = structuredClone(game);
|
||||
shipment = { before: beforeShipment, action, after, steps: structuredClone(actions.slice(start)) };
|
||||
assert.equal(semutaSpiritualForesightPlayer(beforeShipment, spender).spice - semutaSpiritualForesightPlayer(game, spender).spice, 2);
|
||||
assert.equal(semutaSpiritualForesightPlayer(beforeShipment, spender).reserves - semutaSpiritualForesightPlayer(game, spender).reserves, 1);
|
||||
assert.equal(semutaSpiritualForesightPlayer(game, spender).forces['polar_sink:0'], 1);
|
||||
const accept: Action = { type: 'decision', accept: true, amount: 1 };
|
||||
act(bg, accept);
|
||||
advisorChoice = { before: after, action: accept, after: structuredClone(game) };
|
||||
assert.equal(game.response?.kind, 'advisor');
|
||||
assert.equal(game.response?.spiritualAdvisorSource, undefined);
|
||||
assert.equal(game.response?.advisorAmbassadorEvent, undefined);
|
||||
assert.equal(game.response?.advisorRevivalEvent, undefined);
|
||||
assert.ok(!game.pendingSpiritualAdvisor);
|
||||
} else {
|
||||
reach(state => state.phase === 5 && state.response?.kind === 'atreidesSpice');
|
||||
assert.equal(game.spicePeekKnown, false);
|
||||
assert.deepEqual(game.spiceDeck, beforeMovement.spiceDeck);
|
||||
assert.deepEqual(game.spiceDiscard, beforeMovement.spiceDiscard);
|
||||
}
|
||||
assert.equal(game.response?.kind, kind); assert.equal(game.response?.owner, owner);
|
||||
assert.equal(game.phaseOpening ?? null, null, 'The actual phase opening is complete, never cleared by staging');
|
||||
if (openingDecision) {
|
||||
assert.equal(game.decision?.kind, openingDecision, 'The original phase-five quote must genuinely create this decision');
|
||||
assert.equal(game.decision?.player, openingDecision === 'advisorBattle' ? bg : 'guild');
|
||||
if (game.decision?.kind === 'advisorBattle') assert.ok(game.decision.territories.includes('arrakeen'));
|
||||
} else assert.equal(game.decision, null, 'This actual source has no other owned decision parked alongside it');
|
||||
assert.ok(semutaSpiritualForesightPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaSpiritualForesightPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.deepEqual(semutaSpiritualForesightInventory(game), originalInventory);
|
||||
const source = structuredClone(game), action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
return {
|
||||
game, source, gameBeforeCost: source, action, kind, advanced, openingDecision,
|
||||
owner, holder, spender, richese, bg, atreides, cost, karama: cost, semuta: SEMUTA_DRUG_ID,
|
||||
originalResponse: structuredClone(game.response), originalDecision: structuredClone(game.decision),
|
||||
beforeMovement, beforeShipment, shipment, advisorChoice,
|
||||
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, dealOrder, spiceOrder,
|
||||
acquired, acquisition, originalInventory, inventory: originalInventory, originalSpice,
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
advanced
|
||||
? openingDecision === 'guildTiming'
|
||||
? 'Existing Advanced Semuta initializer on the original ready undealt R/CHOAM/Guild/Atreides/Emperor lobby. Original Guild membership naturally creates the phase-five Guild timing decision alongside Atreides foresight.'
|
||||
: openingDecision === 'advisorBattle'
|
||||
? 'Existing Advanced Semuta initializer on the original ready undealt R/CHOAM/BG/Atreides/Emperor lobby; BG really predicts Emperor turn10 and places its initial advisor in Arrakeen10 alongside native Atreides, naturally earning the phase-five advisorBattle decision.'
|
||||
: 'Existing Advanced Semuta initializer on the original ready undealt R/CHOAM/BG/Atreides/Emperor lobby; actual BG predicts Emperor turn10 and chooses native Polar Sink setup, avoiding an unrelated advisorBattle chooser.'
|
||||
: 'Actual public bare Basic R/CHOAM/BG/Atreides/Emperor ready/start, original prediction and force setup, with existing Semuta preview initialized before play.',
|
||||
'Only unopened original starting-card positions (Emperor genuinely receives printed Karama), one Basic/two Advanced unplayed land Spice positions and original scalar lotteries are selected. CHOAM really buys Semuta in the native first cache auction for one spice paid to Richese.',
|
||||
kind === 'advisor'
|
||||
? 'Actual original phase-five foresight is allowed, earlier combined turns end, then Emperor pays two spice to send one real reserve force to Polar Sink. BG accepts its genuine free accompaniment, opening the ordinary bare Basic advisor response. The original paid shipment remains committed; no newer Advanced Spiritual/Ambassador/revival source is manufactured.'
|
||||
: 'After the paid Semuta acquisition and original Revival, stop at the genuine phase-five Atreides Spice foresight response. Native phase-opening passes complete normally. No Spice Card is drawn, revealed, reordered or refilled between the prior Revival snapshot and this offered foresight.',
|
||||
openingDecision
|
||||
? `The actual ${openingDecision} decision remains unanswered alongside the native Atreides response. Its owner, counters, territories/order and complete saved decision are captured, never manufactured or cleared by the fixture.`
|
||||
: 'The default source retains its original null opening decision.',
|
||||
'No played hand, balance, force, stance, phase, readiness, queue, response or receipt is installed.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaSpiritualForesightFixture = ReturnType<typeof createSemutaSpiritualForesightFixture>;
|
||||
|
||||
export function passSemutaSpiritualForesight(game: Game, actions: SemutaSpiritualForesightStep[] = []): Game {
|
||||
const event = game.pendingTreacheryDiscard?.batch.event; assert.ok(event);
|
||||
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) {
|
||||
const pending: NonNullable<Game['pendingTreacheryDiscard']> = game.pendingTreacheryDiscard!;
|
||||
const reaction = pending.reaction; assert.ok(reaction?.stage === 'offer');
|
||||
const player = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(player);
|
||||
game = actSemutaSpiritualForesight(game, player.id, { type: 'semutaPass', event }, actions);
|
||||
}
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game;
|
||||
}
|
||||
export function claimSemutaSpiritualForesight(game: Game, fixture: Pick<SemutaSpiritualForesightFixture, 'holder' | 'cost'>,
|
||||
actions: SemutaSpiritualForesightStep[] = []): Game {
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.batch.entries.length === 1);
|
||||
const event = pending.batch.event; assert.equal(pending.batch.entries[0].card.id, fixture.cost);
|
||||
game = actSemutaSpiritualForesight(game, fixture.holder, { type: 'semutaCommit', event }, actions);
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = actSemutaSpiritualForesight(game, fixture.holder, { type: 'semutaSelect', event, card: fixture.cost }, actions);
|
||||
return game;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { actTruthMovement } from './fixture-truth-movement';
|
||||
import { nextSemutaRicheseCacheKarama } from './fixture-semuta-richese-cache-karama';
|
||||
import { truthFaceDancerTraitors } from './fixture-truthtrance-face-dancer';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type SemutaStormDancerStep = { actor: string; action: Action };
|
||||
export type SemutaStormDancerKind = 'stormPeek' | 'faceDancerReplacement';
|
||||
export const actSemutaStormDancer = actTruthMovement;
|
||||
export const semutaStormDancerPlayer = (game: Game, id: string) => {
|
||||
const player = game.players.find(p => p.id === id); assert.ok(player); return player;
|
||||
};
|
||||
export const semutaStormDancerClean = (game: Game) => !game.phaseOpening && !game.response &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export function semutaStormDancerInventory(game: Game) {
|
||||
const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...game.players.flatMap(p => p.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
|
||||
.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length); return ids;
|
||||
}
|
||||
export const semutaStormDancerTraitors = truthFaceDancerTraitors;
|
||||
/** Real setup/readiness controls. A fresh reaction stays caller-owned; no
|
||||
* optional replacement or cancellation is silently chosen. */
|
||||
export function nextSemutaStormDancerStep(game: Game): SemutaStormDancerStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance) return null;
|
||||
if (game.status === 'setup' && game.setupStage === 'forces') {
|
||||
const fremen = game.players.find(p => p.faction === 'fremen');
|
||||
if (fremen?.reserves === 20 && viewGame(game, fremen.id).setupPending.includes(fremen.id))
|
||||
return { actor: fremen.id, action: { type: 'fremenSetup', placements: { 'sietch_tabr:14': 10 } } };
|
||||
}
|
||||
return nextSemutaRicheseCacheKarama(game);
|
||||
}
|
||||
export function advanceSemutaStormDancer(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaStormDancerStep[] = []): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextSemutaStormDancerStep(game);
|
||||
assert.ok(next, 'Choose the original terminal-cost fresh reaction before continuing');
|
||||
game = actSemutaStormDancer(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The native Storm/Dancer programme did not reach its finite boundary');
|
||||
}
|
||||
export function passSemutaStormDancer(game: Game, actions: SemutaStormDancerStep[] = []): Game {
|
||||
const event = game.pendingTreacheryDiscard?.batch.event; assert.ok(event);
|
||||
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) {
|
||||
const pending: NonNullable<Game['pendingTreacheryDiscard']> = game.pendingTreacheryDiscard!;
|
||||
const reaction = pending.reaction; assert.ok(reaction?.stage === 'offer');
|
||||
const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaStormDancer(game, actor.id, { type: 'semutaPass', event }, actions);
|
||||
}
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game;
|
||||
}
|
||||
export function claimSemutaStormDancer(game: Game, holder: string,
|
||||
actions: SemutaStormDancerStep[] = []): Game {
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.batch.entries.length === 1);
|
||||
const event = pending.batch.event, card = pending.batch.entries[0].card.id;
|
||||
game = actSemutaStormDancer(game, holder, { type: 'semutaCommit', event }, actions);
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = actSemutaStormDancer(game, holder, { type: 'semutaSelect', event, card }, actions);
|
||||
return game;
|
||||
}
|
||||
/** Original dispatcher priority: finish a coexisting opening through real
|
||||
* ready actions before allowing this native response. Never initialize twice. */
|
||||
export function allowSemutaStormDancer(game: Game, actions: SemutaStormDancerStep[] = []): Game {
|
||||
const kind = game.response?.kind; assert.ok(kind === 'stormPeek' || kind === 'faceDancerReplacement');
|
||||
while (game.phaseOpening) {
|
||||
const actor = game.players.find(p => !game.phaseOpening!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaStormDancer(game, actor.id, { type: 'ready' }, actions);
|
||||
}
|
||||
for (let count = 0; game.response?.kind === kind && count < game.players.length; count++) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaStormDancer(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.kind, kind); return game;
|
||||
}
|
||||
|
||||
/** Actual paid first cache, original E Karama and native terminal opportunity.
|
||||
* Only unopened deal/Spice positions and original scalar lotteries are chosen.
|
||||
* No played custody, wallet, army, phase, pending frame or traitor pool is set. */
|
||||
export function createSemutaStormDancerFixture(options: { kind?: SemutaStormDancerKind; advanced?: boolean } = {}) {
|
||||
const kind = options.kind ?? 'faceDancerReplacement', advanced = options.advanced ?? kind === 'stormPeek';
|
||||
assert.ok(kind !== 'stormPeek' || advanced, 'Native next-Storm knowledge is Advanced only');
|
||||
const seller = 'richese', holder = 'choam', dancer = 'tleilaxu', fremen = 'fremen', spender = 'emperor';
|
||||
const owner = kind === 'stormPeek' ? fremen : dancer;
|
||||
let game = createGame('SEMUTASTORMDANCER', newPlayer(seller, 'Richese', 'richese'), advanced, ['choam', 'ix']);
|
||||
for (const faction of ['choam', 'tleilaxu', 'fremen', 'emperor'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: SemutaStormDancerStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaStormDancer(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaStormDancer(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 p of game.players) act(p.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
else act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
|
||||
entry: advanced ? 'explicit-original-semuta-initializer' : 'public-basic-start' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(p => !p.hand.length));
|
||||
const originalInventory = semutaStormDancerInventory(game); assert.equal(originalInventory.length, 57);
|
||||
const originalTraitors = semutaStormDancerTraitors(game);
|
||||
const printed = game.deck.find(card => card.effect === 'karama' && !card.id.startsWith('ix-'));
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind) && !card.effect).slice(0, 4);
|
||||
assert.ok(printed); assert.equal(ordinary.length, 4);
|
||||
const deal = [...ordinary.map(card => card.id), printed.id], cost = printed.id;
|
||||
const dealOrder = deal.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { card, position, from };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
let faceDancerDeal: { before: Game; steps: SemutaStormDancerStep[]; after: Game } | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 150; count++) {
|
||||
const next = nextSemutaStormDancerStep(game); assert.ok(next);
|
||||
const before = structuredClone(game), hadDancers = !!semutaStormDancerPlayer(game, dancer).faceDancers;
|
||||
act(next.actor, next.action);
|
||||
if (!hadDancers && semutaStormDancerPlayer(game, dancer).faceDancers)
|
||||
faceDancerDeal = { before, steps: [structuredClone(next)], after: structuredClone(game) };
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(faceDancerDeal);
|
||||
const afterSetup = structuredClone(game), originalFaceDancers = structuredClone(semutaStormDancerPlayer(game, dancer).faceDancers!);
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), deal);
|
||||
assert.equal(originalFaceDancers.length, 3); assert.ok(originalFaceDancers.every(card => !card.revealed));
|
||||
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 < (advanced ? 4 : 2); position++) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
|
||||
['hagga_basin', 'rock_outcroppings', 'broken_land', 'oh_gap'].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) });
|
||||
}
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let beforeBid: Game | undefined, bid: SemutaStormDancerStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaStormDancerPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const next = nextSemutaStormDancerStep(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 };
|
||||
assert.ok(semutaStormDancerPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.equal(semutaStormDancerPlayer(beforeBid, holder).spice - semutaStormDancerPlayer(game, holder).spice, 1);
|
||||
assert.equal(semutaStormDancerPlayer(game, seller).spice - semutaStormDancerPlayer(beforeBid, seller).spice, 1);
|
||||
let trigger: { before: Game; steps: SemutaStormDancerStep[]; after: Game };
|
||||
let opening: { before: Game; steps: SemutaStormDancerStep[]; after: Game } | null = null;
|
||||
let replacementAction: Action | null = null, candidate: string | null = null;
|
||||
if (kind === 'faceDancerReplacement') {
|
||||
reach(state => state.turn === 1 && state.phase === 8 && semutaStormDancerClean(state));
|
||||
candidate = semutaStormDancerPlayer(game, dancer).faceDancers!.find(card => !card.revealed)!.leader;
|
||||
replacementAction = { type: 'replaceFaceDancer', leader: candidate };
|
||||
trigger = transition(() => { act(dancer, replacementAction!); });
|
||||
assert.equal(semutaStormDancerPlayer(game, dancer).faceDancerReplacedTurn, game.turn);
|
||||
assert.equal(game.response?.intent, candidate);
|
||||
} else {
|
||||
reach(state => state.turn === 2 && state.phase === 0 && semutaStormDancerClean(state));
|
||||
trigger = transition(() => { reach(state => state.turn === 2 && state.response?.kind === 'stormPeek'); });
|
||||
assert.equal(game.phase, 1); assert.deepEqual(game.phaseOpening, { passed: [], initialize: false });
|
||||
const forecast = game.stormCard;
|
||||
opening = transition(() => {
|
||||
while (game.phaseOpening) {
|
||||
const actor = game.players.find(p => !game.phaseOpening!.passed.includes(p.id)); assert.ok(actor);
|
||||
act(actor.id, { type: 'ready' });
|
||||
}
|
||||
});
|
||||
assert.equal(game.stormCard, forecast); assert.equal(game.stormCardKnown, false);
|
||||
assert.equal(game.response?.kind, 'stormPeek');
|
||||
}
|
||||
assert.equal(game.response?.kind, kind); assert.equal(game.response?.owner, owner);
|
||||
assert.ok(!game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard && !game.pendingAmbassador && !game.battle);
|
||||
assert.ok(semutaStormDancerPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.deepEqual(semutaStormDancerPlayer(game, dancer).faceDancers, originalFaceDancers);
|
||||
assert.deepEqual(semutaStormDancerTraitors(game), originalTraitors);
|
||||
assert.deepEqual(semutaStormDancerInventory(game), originalInventory);
|
||||
const source = structuredClone(game), action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
return { game, source, beforeCost: source, kind, advanced, holder, spender, owner, seller, dancer, fremen,
|
||||
cost, karama: cost, card: cost, semuta: SEMUTA_DRUG_ID, action, candidate, replacementAction,
|
||||
lobby, setup, initialization, beforeStartingDeal, afterSetup, deal, dealOrder, faceDancerDeal, originalFaceDancers,
|
||||
originalSpice, spiceOrder, acquired, acquisition, trigger, opening, beforeOpening: opening?.before ?? null,
|
||||
coexistentOpening: opening?.before.phaseOpening ?? null, stormCard: game.stormCard,
|
||||
originalInventory, originalCards: originalInventory, originalTraitors,
|
||||
actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Native Richese/CHOAM/Tleilaxu/Fremen/Emperor uses the public bare Basic start or original explicit Advanced Semuta initializer, main47/cache10. Only unopened original deal/Spice positions and scalar lotteries are selected; Emperor actually receives its printed Karama and Tleilaxu its three original unrevealed Face Dancers.',
|
||||
'Fremen actually places ten starting normals in Sietch Tabr14. CHOAM buys the real Semuta through an original cache1 bid and payment before this later opportunity. No dealt hand, force, wallet, phase, traitor pool or paused source is assigned.',
|
||||
kind === 'faceDancerReplacement' ? 'At actual first Mentat, Tleilaxu really declares replacement of one originally dealt unrevealed candidate. The attempt is used before its native response; no reserve card has been shuffled/drawn and no Face Dancer replaced yet.' : 'Actually finish first Mentat and the original second Storm, including original CHOAM storm market closure. Capture native stormPeek plus initialize:false opening coexistence, then submit all real ready acknowledgements. Stop at the same valid original forecast counter after its opening has finished; no dispatcher priority, card, opening or pending response is installed.',
|
||||
'Stop before Emperor spends the original cancellation cost. Caller owns the exact fresh-cost Semuta claim/pass and original prevention; no new knowledge, replacement, draw or once-use reset is assumed.',
|
||||
] };
|
||||
}
|
||||
export type SemutaStormDancerFixture = ReturnType<typeof createSemutaStormDancerFixture>;
|
||||
@@ -0,0 +1,232 @@
|
||||
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 { quoteMovementPhaseStart } from '../game/movement-phase-quote';
|
||||
import {
|
||||
createSemutaSpiritualForesightFixture as fixture, actSemutaSpiritualForesight as act,
|
||||
passSemutaSpiritualForesight as pass, claimSemutaSpiritualForesight as claim,
|
||||
semutaSpiritualForesightInventory as inventory, semutaSpiritualForesightPlayer as player,
|
||||
type SemutaSpiritualForesightFixture,
|
||||
} from './fixture-semuta-spiritual-foresight';
|
||||
|
||||
const resources = (game: Game) => ({
|
||||
players: game.players.map(player => ({ id: player.id, spice: player.spice,
|
||||
reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites,
|
||||
moved: player.moved, shipped: player.shipped, revived: player.revived, ally: player.ally,
|
||||
advisors: player.advisors })),
|
||||
active: game.active, order: game.order, ready: game.ready,
|
||||
turn: game.turn, phase: game.phase, movementRemaining: game.movementRemaining,
|
||||
guildTimingGranted: game.guildTimingGranted, guildTimingLocked: game.guildTimingLocked,
|
||||
advisorPreparation: game.advisorPreparation,
|
||||
});
|
||||
const variants = [
|
||||
{ kind: 'advisor' as const, advanced: false },
|
||||
{ kind: 'atreidesSpice' as const, advanced: false },
|
||||
{ kind: 'atreidesSpice' as const, advanced: true },
|
||||
];
|
||||
const openingVariants = [
|
||||
{ kind: 'atreidesSpice' as const, advanced: true, openingDecision: 'advisorBattle' as const },
|
||||
{ kind: 'atreidesSpice' as const, advanced: true, openingDecision: 'guildTiming' as const },
|
||||
];
|
||||
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: SemutaSpiritualForesightFixture) {
|
||||
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
|
||||
assert.equal(f.setup.advanced, f.advanced); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.ok(f.acquisition.steps.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.ok(player(f.acquisition.after, f.holder).hand.some(card => card.id === f.semuta));
|
||||
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(f.game.phase, 5); assert.equal(f.game.turn, 1);
|
||||
assert.equal(f.game.response?.kind, f.kind); assert.equal(f.game.response?.owner, f.owner);
|
||||
assert.equal(f.game.phaseOpening ?? null, null); assert.deepEqual(f.game.decision, f.originalDecision);
|
||||
if (f.openingDecision) assert.equal(f.originalDecision?.kind, f.openingDecision);
|
||||
else assert.equal(f.originalDecision, null);
|
||||
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.sourceActionCount, f.actions.length);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
if (f.kind === 'advisor') {
|
||||
assert.ok(f.shipment && f.beforeShipment && f.advisorChoice);
|
||||
assert.equal(f.game.advanced, false);
|
||||
assert.equal(player(f.beforeShipment, f.spender).spice - player(f.game, f.spender).spice, 2);
|
||||
assert.equal(player(f.beforeShipment, f.spender).reserves - player(f.game, f.spender).reserves, 1);
|
||||
assert.equal(player(f.game, f.spender).forces['polar_sink:0'], 1);
|
||||
assert.equal(player(f.game, f.spender).shipped, true);
|
||||
assert.equal(player(f.game, f.spender).moved, 0);
|
||||
assert.equal(f.game.response?.spiritualAdvisorSource, undefined);
|
||||
assert.equal(f.game.response?.advisorAmbassadorEvent, undefined);
|
||||
assert.equal(f.game.response?.advisorRevivalEvent, undefined);
|
||||
assert.ok(!f.game.pendingSpiritualAdvisor);
|
||||
} else {
|
||||
assert.equal(f.game.spicePeekKnown, false);
|
||||
assert.deepEqual(f.game.spiceDeck, f.beforeMovement.spiceDeck);
|
||||
assert.deepEqual(f.game.spiceDiscard, f.beforeMovement.spiceDiscard);
|
||||
}
|
||||
}
|
||||
function open(f: SemutaSpiritualForesightFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.reaction?.stage, 'offer');
|
||||
assert.equal(pending.continuation.kind, 'karamaTerminalDiscard');
|
||||
assert.equal(pending.batch.cause, `karama:${f.kind}`);
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ id: entry.card.id, by: entry.discardedBy, public: entry.publicFace })),
|
||||
[{ id: f.cost, by: f.spender, public: true }]);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(game.response, null, 'The original terminal response waits for its cost reaction');
|
||||
assert.equal(game.decision, null, 'The coexisting native opening decision is parked while the cost is fresh');
|
||||
assert.deepEqual(game.pendingArrivalReaction, f.game.pendingArrivalReaction,
|
||||
'The original paid shipment entry waits through the cost response');
|
||||
if (pending.continuation.kind !== 'karamaTerminalDiscard') throw Error('Use the terminal cost continuation');
|
||||
assert.equal(pending.continuation.player, f.spender); assert.equal(pending.continuation.card, f.cost);
|
||||
assert.equal(pending.continuation.use.kind, 'cancel');
|
||||
assert.equal(pending.continuation.use.response.kind, f.kind);
|
||||
assert.equal(pending.continuation.use.response.owner, f.owner);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||
assert.deepEqual(pending.continuation.resume.decision, f.originalDecision);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(resources(game), resources(f.game));
|
||||
assert.deepEqual(game.spiceDeck, f.game.spiceDeck);
|
||||
assert.deepEqual(game.spiceDiscard, f.game.spiceDiscard);
|
||||
assert.equal(game.spicePeekKnown, f.game.spicePeekKnown);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
return game;
|
||||
}
|
||||
function prevented(game: Game, f: SemutaSpiritualForesightFixture, claimed: boolean) {
|
||||
assert.ok(!game.pendingTreacheryDiscard); assert.ok(!game.pendingKarama);
|
||||
assert.equal(game.response, null); assert.equal(game.phaseOpening ?? null, null);
|
||||
assert.deepEqual(game.decision, f.originalDecision);
|
||||
// The spender's physical cost is intentionally absent from its hand; all
|
||||
// native force/payment/phase source fields stay unchanged.
|
||||
assert.deepEqual(resources(game), resources(f.game));
|
||||
assert.deepEqual(game.spiceDeck, f.game.spiceDeck);
|
||||
assert.deepEqual(game.spiceDiscard, f.game.spiceDiscard);
|
||||
assert.equal(game.spicePeekKnown, f.game.spicePeekKnown);
|
||||
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.kind === 'advisor') {
|
||||
assert.ok(f.beforeShipment);
|
||||
assert.equal(f.game.pendingArrivalReaction?.length, 1);
|
||||
assert.equal(game.pendingArrivalReaction, null,
|
||||
'Native continuation consumes the original paid entry once after prevention');
|
||||
assert.deepEqual(player(game, f.owner), player(f.game, f.owner));
|
||||
assert.equal(player(game, f.spender).forces['polar_sink:0'], 1);
|
||||
assert.equal(player(game, f.spender).shipped, true);
|
||||
assert.equal(player(game, f.spender).moved, 0);
|
||||
assert.equal(player(f.beforeShipment, f.spender).spice - player(game, f.spender).spice, 2);
|
||||
assert.ok(!game.pendingSpiritualAdvisor);
|
||||
} else assert.equal(game.spicePeekKnown, false);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const variant of variants) for (const claimed of [false, true]) void test(
|
||||
`${variant.advanced ? 'Advanced' : 'Basic'} genuine ${variant.kind} cancellation offers its printed cost for Semuta ${claimed ? 'claim' : 'pass'}`, () => {
|
||||
const f = fixture(variant); nativeSource(f);
|
||||
const pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
const game = prevented(claimed ? claim(pending, f) : pass(pending), f, claimed);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event });
|
||||
reject(game, f.holder, { type: 'semutaPass', event });
|
||||
});
|
||||
|
||||
for (const variant of openingVariants) for (const claimed of [false, true]) void test(
|
||||
`Advanced foresight ${claimed ? 'claimed' : 'passed'} cost restores the actual unanswered ${variant.openingDecision} opening`, () => {
|
||||
const f = fixture(variant); nativeSource(f);
|
||||
const original = f.originalDecision; assert.ok(original);
|
||||
assert.equal(original.kind, variant.openingDecision);
|
||||
const beforeQuote = quoteMovementPhaseStart(f.game);
|
||||
assert.equal(beforeQuote.kind, 'initialize');
|
||||
if (beforeQuote.kind !== 'initialize') throw Error('The source must be its actual movement opening');
|
||||
assert.deepEqual(beforeQuote.decision, original);
|
||||
if (original.kind === 'advisorBattle') {
|
||||
assert.equal(original.player, f.bg);
|
||||
assert.ok(original.territories.includes('arrakeen'));
|
||||
assert.equal(player(f.afterSetup, f.bg).forces['arrakeen:10'], 1);
|
||||
assert.ok(player(f.afterSetup, f.bg).advisors?.arrakeen);
|
||||
} else {
|
||||
assert.equal(original.kind, 'guildTiming');
|
||||
assert.equal(original.player, 'guild');
|
||||
assert.equal(f.game.players.some(player => player.faction === 'beneGesserit'), false);
|
||||
assert.ok(f.game.players.some(player => player.id === original.player && player.faction === 'guild'));
|
||||
}
|
||||
const pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
const underlying: Action = original.kind === 'advisorBattle'
|
||||
? { type: 'decision', accept: false } : { type: 'decision', take: false };
|
||||
reject(pending, original.player, underlying);
|
||||
const game = prevented(claimed ? claim(pending, f) : pass(pending), f, claimed);
|
||||
assert.deepEqual(game.decision, original, 'Neither the used-cost child nor prevention answers or regenerates the native decision');
|
||||
const afterQuote = quoteMovementPhaseStart(game);
|
||||
assert.equal(afterQuote.kind, 'initialize');
|
||||
if (afterQuote.kind !== 'initialize') throw Error('The original movement opening must remain available');
|
||||
assert.deepEqual(afterQuote.decision, original);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event });
|
||||
});
|
||||
|
||||
for (const variant of variants) void test(
|
||||
`${variant.advanced ? 'Advanced' : 'Basic'} native ${variant.kind} allowance control earns the actual packet or peek without spending either card`, () => {
|
||||
const f = fixture(variant); nativeSource(f);
|
||||
let game = structuredClone(f.game);
|
||||
for (let count = 0; game.response?.kind === f.kind && count < game.players.length; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = act(game, actor.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(game.response, null); assert.ok(!game.pendingTreacheryDiscard);
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
assert.deepEqual(game.spiceDeck, f.game.spiceDeck); assert.deepEqual(game.spiceDiscard, f.game.spiceDiscard);
|
||||
if (f.kind === 'advisor') {
|
||||
assert.equal(player(game, f.owner).reserves, player(f.game, f.owner).reserves - 1);
|
||||
assert.equal(player(game, f.owner).forces['polar_sink:0'], (player(f.game, f.owner).forces['polar_sink:0'] ?? 0) + 1);
|
||||
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice);
|
||||
assert.deepEqual(player(game, f.spender), player(f.game, f.spender));
|
||||
} else {
|
||||
assert.equal(game.spicePeekKnown, true);
|
||||
assert.deepEqual(resources(game), resources(f.game));
|
||||
}
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('actual terminal-cost suspension rejects underlying replies and stale Semuta events without replaying the source', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
reject(pending, f.spender, f.action);
|
||||
reject(pending, f.owner, { type: 'passResponse' });
|
||||
reject(pending, pending.active!, { type: 'endMovement' });
|
||||
const game = prevented(claim(pending, f), f, true);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event });
|
||||
reject(game, f.holder, { type: 'semutaPass', event });
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal policies resolve each real terminal-cost source without repeating shipment or drawing Spice', () => {
|
||||
for (const variant of [...variants, ...openingVariants]) {
|
||||
const f = fixture(variant), pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(pending, 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(pending), f.holder, action);
|
||||
const claimed = action.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const selecting = viewGame(game, f.holder); selecting.players.find(player => player.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);
|
||||
prevented(game, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
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 {
|
||||
createSemutaStormDancerFixture as fixture, actSemutaStormDancer as act,
|
||||
passSemutaStormDancer as pass, claimSemutaStormDancer as claim, allowSemutaStormDancer as allow,
|
||||
semutaStormDancerPlayer as player, semutaStormDancerInventory as inventory,
|
||||
semutaStormDancerTraitors as traitors, type SemutaStormDancerFixture,
|
||||
} from './fixture-semuta-storm-dancer';
|
||||
|
||||
/** Knowledge source and native replacement/physical state, excluding the two
|
||||
* exact hands/discard positions which the Semuta exchange intentionally moves. */
|
||||
const fixed = (game: Game) => ({ turn: game.turn, phase: game.phase, order: game.order, active: game.active,
|
||||
ready: game.ready, phaseOpening: game.phaseOpening, deck: game.deck, richeseCache: game.richeseCache,
|
||||
richeseRemoved: game.richeseRemoved, storm: game.storm, stormCard: game.stormCard, stormCardKnown: game.stormCardKnown,
|
||||
stormPending: game.stormPending, stormResolution: game.stormResolution, stormMovementSource: game.stormMovementSource,
|
||||
spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard, traitorReserve: game.traitorReserve,
|
||||
players: game.players.map(p => ({ id: p.id, spice: p.spice, reserves: p.reserves, tanks: p.tanks,
|
||||
forces: p.forces, elites: p.elites, shipped: p.shipped, moved: p.moved, revived: p.revived,
|
||||
faceDancers: p.faceDancers, faceDancerReplacedTurn: p.faceDancerReplacedTurn })) });
|
||||
function reject(game: Game, actor: string, proposed: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, proposed)); assert.deepEqual(game, before);
|
||||
}
|
||||
function nativeSource(f: SemutaStormDancerFixture) {
|
||||
assert.equal(f.setup.advanced, f.advanced); assert.equal(f.game.advanced, f.advanced);
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(f.faceDancerDeal); assert.equal(player(f.afterSetup, f.dancer).faceDancers!.length, 3);
|
||||
assert.deepEqual(player(f.game, f.dancer).faceDancers, f.originalFaceDancers);
|
||||
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquired, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquired, f.seller).spice - player(f.acquisition.before, f.seller).spice, 1);
|
||||
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
assert.equal(f.game.response?.kind, f.kind); assert.equal(f.game.response?.owner, f.owner);
|
||||
assert.equal(f.game.phaseOpening, null);
|
||||
if (f.kind === 'faceDancerReplacement') {
|
||||
assert.equal(f.game.phase, 8); assert.equal(f.game.turn, 1); assert.ok(f.candidate && f.replacementAction);
|
||||
assert.deepEqual(f.replacementAction, { type: 'replaceFaceDancer', leader: f.candidate });
|
||||
assert.equal(player(f.trigger.before, f.dancer).faceDancerReplacedTurn, undefined);
|
||||
assert.equal(player(f.game, f.dancer).faceDancerReplacedTurn, f.game.turn);
|
||||
assert.deepEqual(f.game.traitorReserve, f.trigger.before.traitorReserve);
|
||||
assert.equal(f.game.response?.intent, f.candidate);
|
||||
} else {
|
||||
assert.equal(f.game.phase, 1); assert.equal(f.game.turn, 2); assert.equal(f.advanced, true);
|
||||
assert.ok(f.opening && f.beforeOpening);
|
||||
assert.deepEqual(f.coexistentOpening, { passed: [], initialize: false });
|
||||
assert.equal(f.opening.steps.length, f.game.players.length);
|
||||
assert.ok(f.opening.steps.every(step => step.action.type === 'ready'));
|
||||
assert.deepEqual(new Set(f.opening.steps.map(step => step.actor)), new Set(f.game.players.map(p => p.id)));
|
||||
assert.deepEqual(f.game.response, f.beforeOpening.response);
|
||||
assert.equal(f.game.stormCard, f.beforeOpening.stormCard); assert.equal(f.game.stormCardKnown, false);
|
||||
assert.equal(viewGame(f.game, f.owner).stormForecast, null);
|
||||
}
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 57);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory); assert.deepEqual(traitors(f.game), f.originalTraitors);
|
||||
}
|
||||
function open(f: SemutaStormDancerFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard; assert.ok(pending);
|
||||
assert.equal(pending.continuation.kind, 'karamaTerminalDiscard');
|
||||
if (pending.continuation.kind !== 'karamaTerminalDiscard') throw Error('Expected the exact terminal cancellation cost');
|
||||
assert.deepEqual(pending.continuation.use.response, f.game.response);
|
||||
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [f.cost]);
|
||||
assert.deepEqual(pending.batch.entries.map(entry => entry.discardedBy), [f.spender]);
|
||||
assert.equal(game.response, null); assert.equal(game.phaseOpening, 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.deepEqual(fixed(game), fixed(f.game)); assert.deepEqual(inventory(game), f.originalInventory);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true); return game;
|
||||
}
|
||||
function denied(game: Game, f: SemutaStormDancerFixture, claimed: boolean) {
|
||||
assert.ok(!game.pendingTreacheryDiscard); assert.equal(game.response, null); assert.equal(game.decision, null);
|
||||
assert.deepEqual(fixed(game), fixed(f.game));
|
||||
assert.deepEqual(inventory(game), f.originalInventory); assert.deepEqual(traitors(game), f.originalTraitors);
|
||||
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.kind === 'faceDancerReplacement') {
|
||||
assert.equal(player(game, f.owner).faceDancerReplacedTurn, game.turn);
|
||||
assert.deepEqual(player(game, f.owner).faceDancers, f.originalFaceDancers);
|
||||
assert.deepEqual(game.traitorReserve, f.game.traitorReserve);
|
||||
for (const card of f.originalFaceDancers) reject(game, f.owner, { type: 'replaceFaceDancer', leader: card.leader });
|
||||
} else {
|
||||
assert.equal(game.stormCardKnown, false); assert.equal(game.stormCard, f.stormCard);
|
||||
assert.equal(viewGame(game, f.owner).stormForecast, null);
|
||||
}
|
||||
reject(game, f.spender, f.action); return game;
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const claimed of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} native Face Dancer replacement cost Semuta ${claimed ? 'claim' : 'pass'} preserves original cards, reserve order and spent attempt`, () => {
|
||||
const f = fixture({ kind: 'faceDancerReplacement', advanced }); nativeSource(f);
|
||||
const offered = open(f), game = denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
const ready = act(game, f.game.players[0].id, { type: 'ready' });
|
||||
assert.equal(ready.phase, 8); assert.equal(ready.turn, 1);
|
||||
assert.deepEqual(player(ready, f.owner).faceDancers, f.originalFaceDancers);
|
||||
assert.deepEqual(ready.traitorReserve, f.game.traitorReserve);
|
||||
assert.equal(player(ready, f.owner).faceDancerReplacedTurn, 1);
|
||||
assert.deepEqual(ready.ready, [f.game.players[0].id]);
|
||||
},
|
||||
);
|
||||
|
||||
for (const claimed of [false, true]) void test(
|
||||
`Advanced native next-Storm cost Semuta ${claimed ? 'claim' : 'pass'} preserves the actual next card and denies its knowledge once`, () => {
|
||||
const f = fixture({ kind: 'stormPeek' }); nativeSource(f);
|
||||
const offered = open(f), game = denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
const ready = act(game, f.game.players[0].id, { type: 'ready' });
|
||||
assert.equal(ready.phase, 1); assert.equal(ready.turn, 2);
|
||||
assert.equal(ready.phaseOpening, null); assert.equal(ready.stormCard, f.stormCard); assert.equal(ready.stormCardKnown, false);
|
||||
assert.equal(viewGame(ready, f.owner).stormForecast, null);
|
||||
assert.deepEqual(ready.spiceDeck, f.game.spiceDeck); assert.deepEqual(ready.deck, f.game.deck);
|
||||
assert.deepEqual(ready.ready, [f.game.players[0].id]);
|
||||
},
|
||||
);
|
||||
|
||||
void test('the genuine next-Storm forecast retains native false-opening priority before its original counter', () => {
|
||||
const f = fixture({ kind: 'stormPeek' }); nativeSource(f); assert.ok(f.beforeOpening && f.opening);
|
||||
reject(f.beforeOpening, f.spender, f.action);
|
||||
assert.deepEqual(f.beforeOpening.phaseOpening, { passed: [], initialize: false });
|
||||
assert.ok(player(f.beforeOpening, f.holder).hand.some(card => card.id === f.semuta));
|
||||
assert.ok(player(f.beforeOpening, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.equal(f.opening.after.phaseOpening, null); assert.deepEqual(f.opening.after.response, f.beforeOpening.response);
|
||||
assert.equal(f.opening.after.stormCard, f.beforeOpening.stormCard);
|
||||
assert.deepEqual(f.opening.after.spiceDeck, f.beforeOpening.spiceDeck);
|
||||
});
|
||||
|
||||
void test('the original forecast and replacement allowance still resolve through their genuine native responses', () => {
|
||||
for (const options of [{ kind: 'stormPeek' as const },
|
||||
{ kind: 'faceDancerReplacement' as const, advanced: false }, { kind: 'faceDancerReplacement' as const, advanced: true }]) {
|
||||
const f = fixture(options), game = allow(structuredClone(f.game));
|
||||
assert.equal(game.response, null); assert.ok(!game.pendingTreacheryDiscard);
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
assert.deepEqual(inventory(game), f.originalInventory); assert.deepEqual(traitors(game), f.originalTraitors);
|
||||
if (f.kind === 'stormPeek') {
|
||||
assert.equal(game.stormCard, f.stormCard); assert.equal(game.stormCardKnown, true);
|
||||
assert.equal(viewGame(game, f.owner).stormForecast, f.stormCard);
|
||||
assert.deepEqual(game.deck, f.game.deck); assert.deepEqual(game.spiceDeck, f.game.spiceDeck);
|
||||
} else {
|
||||
assert.equal(player(game, f.owner).faceDancers!.filter((card, index) =>
|
||||
card.leader !== f.originalFaceDancers[index].leader).length, 1);
|
||||
assert.equal(player(game, f.owner).faceDancerReplacedTurn, game.turn);
|
||||
assert.ok(game.traitorReserve!.includes(f.candidate!));
|
||||
reject(game, f.owner, f.replacementAction!);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('terminal-cost windows reject stale, foreign and underlying replies without leaking another native opportunity', () => {
|
||||
for (const options of [{ kind: 'stormPeek' as const },
|
||||
{ kind: 'faceDancerReplacement' as const, advanced: false }, { kind: 'faceDancerReplacement' as const, advanced: true }]) {
|
||||
const f = fixture(options), 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.replacementAction ?? { type: 'ready' });
|
||||
const done = denied(pass(game), f, false);
|
||||
reject(done, f.holder, { type: 'semutaCommit', event });
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally answer native forecast and Basic or Advanced Face Dancer cancellation costs', () => {
|
||||
for (const options of [{ kind: 'stormPeek' as const },
|
||||
{ kind: 'faceDancerReplacement' as const, advanced: false }, { kind: 'faceDancerReplacement' as const, advanced: true }]) {
|
||||
const f = fixture(options), 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, difficulty);
|
||||
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