Offer Semuta on native Advanced advisor conversion costs
This commit is contained in:
1 parent
40c31a46e4
commit
06b0b354d0
12 files changed
+1255
-8
No files matched your search
@@ -16,6 +16,45 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Semuta on native Advanced advisor conversions — 26 October. Prototyped / Partial.**
|
||||
`karamaAdvisorFlipDiscard` now offers the exact printed-role cancellation cost
|
||||
for native preparation, ordinary incoming-group matching and mandatory conversion
|
||||
after a ground move. Pure native validation retains original preparation/auction,
|
||||
uncommitted matching or committed departure/automatic queue. Claim/pass applies
|
||||
only conversion denial and resumes the original source once. Existing persistent
|
||||
controls/minimal bots serve it; no stance, movement, payment or phase is pre-applied.
|
||||
|
||||
Three disjoint source lanes and root integration pass **15 files,172/172**,
|
||||
types/lint/build and **eight native engine/GameTable SSR/actual Semuta-callback
|
||||
journeys** on unchanged source. Eighteen new cases cover four native sources,
|
||||
claim/pass, allowance, exact boundaries, relevant rejection and four profiles.
|
||||
Every source uses original Advanced R/C/BG/A/E setup, BG's actual advisorSetup
|
||||
beside native Atreides10, cache1 CHOAM Semuta and original Emperor Karama. All45
|
||||
cards remain physical. Preparation66 preserves the current phase3 auction and
|
||||
stamps only Arrakeen's denied preparation. Matching87 in both directions retains
|
||||
both original groups/unused move after denial, then actually moves the paid
|
||||
Basin9 fighter to Basin10 with wallet3/reserves18. Ground departure87 moves the
|
||||
actual Atreides10 to Basin10 first; denial retains BG's original continuously-alone
|
||||
hold, drains the exact queued arrival once and really ends A's turn to Collection7.
|
||||
Allowance actually converts the stance or commits the original matching group.
|
||||
No played hand, force, stance, wallet, clock, receipt or outcome is installed.
|
||||
|
||||
Feedback retains the native preparation resume tag and qualifies only the exact
|
||||
nine-field deposited ground-arrival packet for suspension behind its mandatory
|
||||
conversion. Moving before shipping correctly consumes the unused shipment; no
|
||||
native move/cancellation calculation changes. A bounded code lookup found no
|
||||
wholly absent Basic primitive; that is not Basic completion certification and
|
||||
does not reopen user-selected Cunning or deferred Kull policies. Transport,
|
||||
Ambassador, gather, spiritual, other automatic producers and wider parents retain
|
||||
separate Semuta follow-ups; complete modes/final assurance remain open.
|
||||
|
||||
Private source-bound report/logs:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-advisor-proof/`;
|
||||
journeys/HTML: adjacent `20261026-semuta-advisor-runtime/`. No room/D1/server,
|
||||
reset/restart, deployment or recovery operation occurs. These are engine, SSR
|
||||
and component-callback results; DOM/hydration, browser/mobile and deployed/final
|
||||
acceptance remain open. [Source and scope](SEMUTA_SOURCE_UPDATE_20260907.md#native-advanced-advisor-conversion-costs--26-october-2026).
|
||||
|
||||
**Semuta on native markets and Fremen allied revival — 26 October. Prototyped / Partial.**
|
||||
Fresh printed-role cancellation costs now pause through `karamaMarketDiscard`
|
||||
for CHOAM sales, Richese Black Market and native Ixian ally replacement, and
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 26 October 2026 — Semuta on native Advanced advisor conversion costs
|
||||
|
||||
**Prototyped / Partial.** One typed used-cost continuation connects native
|
||||
advisor preparation, ordinary incoming matching and mandatory ground conversion.
|
||||
Pure native validation retains original controls, physical groups and movement;
|
||||
claim/pass restores and denies once. Preparation keeps the current auction,
|
||||
matching keeps the unused move, and a completed departure keeps its original
|
||||
arrival and continuously-alone hold. Existing persistent controls/minimal bots
|
||||
serve it. Original preview gates and provisional native duration remain.
|
||||
[Source and scope](SEMUTA_SOURCE_UPDATE_20260907.md#native-advanced-advisor-conversion-costs--26-october-2026).
|
||||
|
||||
Three disjoint source lanes and root integration pass **15 files,172/172**,
|
||||
types/lint/build and **eight native engine/GameTable SSR/actual Semuta-callback
|
||||
journeys** on unchanged source. Eighteen new cases cover four original Advanced
|
||||
R/C/BG/A/E sources, claim/pass, native allowance, relevant rejection and four
|
||||
profiles. Original BG advisorSetup, native A10, actual cache1 and dealt Karama
|
||||
supply all45-card sources. Preparation66 keeps the actual phase3 auction and
|
||||
stamps territory denial. Both matching87 sources preserve groups/unused move,
|
||||
then actually reposition the paid Basin9 fighter to Basin10, wallet3/reserves18.
|
||||
Ground departure87 preserves completed A10 movement and queued entry, retains
|
||||
the original hold, drains entry once and really ends A's turn to Collection7.
|
||||
No played hand, force, stance, wallet, clock, receipt or result is installed.
|
||||
|
||||
Feedback retains the original preparation resume tag, qualifies only the exact
|
||||
nine-field deposited ground arrival and fixes the fixture's post-move shipment
|
||||
flag to spent. Native calculations stay intact. A bounded lookup found no wholly
|
||||
absent Basic primitive, without certifying Basic or reopening explicit Cunning/
|
||||
Kull policies. Other advisor parents/modules and complete modes remain open.
|
||||
Private source-bound report/logs:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-advisor-proof/`;
|
||||
journeys/HTML: adjacent `20261026-semuta-advisor-runtime/`. No room/D1/server,
|
||||
reset/restart, deployment or recovery operation occurs. DOM/hydration,
|
||||
browser/mobile and deployed/final assurance remain open.
|
||||
|
||||
## 26 October 2026 — Semuta on native market and allied revival costs
|
||||
|
||||
**Prototyped / Partial.** Two typed used-cost continuations add CHOAM sale,
|
||||
|
||||
@@ -2,6 +2,91 @@
|
||||
|
||||
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.
|
||||
|
||||
## Native Advanced advisor conversion costs — 26 October 2026
|
||||
|
||||
**Prototyped / Partial.** `karamaAdvisorFlipDiscard` offers the fresh printed-role
|
||||
Karama used to prevent native advisor preparation, ordinary incoming-group
|
||||
matching or mandatory conversion after committed ground movement. Existing
|
||||
persistent neutral controls, private claim/pass and minimal legal profiles serve
|
||||
these sources. This applies existing discard timing and conversion rules; it
|
||||
adds no publisher ruling or mode admission.
|
||||
|
||||
Native [preparation](../game/advisor-preparation-window.ts),
|
||||
[matching](../game/advisor-matching.ts) and
|
||||
[automatic conversion](../game/advisor-automatic-flip.ts) validators own source
|
||||
legality. The cost adapter supplies no already-spent card as another held cost,
|
||||
and executes no stance change, group transfer, allowance or phase opening.
|
||||
Native preparation retains its original `preparation` resume tag; its live
|
||||
auction is source context, not a sale to settle. The subtype signature binds
|
||||
original preparation/matching/automatic records, queued arrival, current
|
||||
auction/order, physical groups/stances and movement counters. Older signature
|
||||
formats retain their bytes.
|
||||
|
||||
After claim/pass the fresh group retires, the original response restores and
|
||||
native denial runs once:
|
||||
|
||||
- Preparation records its original territory denial while keeping advisors,
|
||||
the pre-shipment clock and any current ordinary auction intact. It neither
|
||||
starts Shipment nor closes another territory's future preparation by analogy.
|
||||
- Ordinary incoming matching retains both resident and incoming groups, their
|
||||
types, previous shipment/payment and unused movement. The original denial key
|
||||
prevents the same conversion request; the player can choose an actual legal
|
||||
alternate move. Allowance commits the original typed group once.
|
||||
- A mandatory ground conversion retains movement already completed, its normal
|
||||
counter usage and native continuously-alone hold. The original deposited
|
||||
arrival resumes once after conversion denial/allowance; no transfer or move
|
||||
replays. A real endMovement later advances the same original turn once.
|
||||
|
||||
The focused [arrival qualifier](../game/semuta-advisor-arrival.ts) admits only
|
||||
one original nine-field ground entry with the current native mover, turn/phase,
|
||||
spent shipment/movement count and actual deposited normal/elite cohort. It does
|
||||
not create an arrival or reprice a move. The existing continuously-alone
|
||||
prevention duration remains provisional under the 14 October recommended
|
||||
policy, as recorded in [the native source](ADVISOR_AUTOMATIC_FLIPS.md#source-and-provisional-duration--24-october-2026).
|
||||
|
||||
Three disjoint source lanes, bounded native/diff review and root integration pass
|
||||
**15 files,172/172**, types/lint/build and **eight native engine/GameTable SSR/
|
||||
actual Semuta-callback journeys** on unchanged source. Eighteen new cases cover
|
||||
four sources, claim/pass, native allowance, exact original control boundaries,
|
||||
real alternate movement and phase continuation, relevant rejection and four
|
||||
minimal profiles. Each source uses original Advanced R/C/BG/A/E setup with
|
||||
exact CHOAM, no optional modules, BG's actual Arrakeen advisor beside native
|
||||
Atreides10, CHOAM's real cache1 Semuta and original Emperor Karama. All45 cards
|
||||
remain physical; no played hand, force, stance, wallet, clock, receipt or outcome
|
||||
is installed.
|
||||
|
||||
Preparation66 occurs during actual phase3 Bidding, preserving its auction and
|
||||
recording only native Arrakeen denial. Matching87 in each direction earns its
|
||||
separate BG fighter through paid1-counter Basin9 shipment2. Denial retains the
|
||||
unused move, then actually repositions that fighter to Basin10, keeping the
|
||||
Arrakeen advisor and BG wallet3/reserves18. Ground departure87 physically moves
|
||||
Atreides10 Arrakeen10→Basin10; its pending arrival remains behind the original
|
||||
mandatory conversion. Denial keeps the existing hold and drains that arrival,
|
||||
then a real A endMovement reaches Collection7 without another conversion.
|
||||
|
||||
Feedback preserves the original preparation resume tag, admits only the native
|
||||
ground-arrival packet and corrects the fixture's shipment flag: moving first
|
||||
consumes its unused shipment. Native rule calculations remain unchanged. A
|
||||
bounded checklist/code lookup found no wholly absent Basic primitive; this is
|
||||
not a complete Basic audit or certification. Explicit Cunning and deferred Kull
|
||||
user policies remain. Transport, Ambassador, gather, spiritual and non-ground
|
||||
automatic parents, broader combinations, complete modes and final assurance
|
||||
remain open.
|
||||
|
||||
Private source-bound report/logs:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-advisor-proof/`;
|
||||
journeys/HTML: adjacent `20261026-semuta-advisor-runtime/`. No room/D1/server,
|
||||
reset/restart, deployment or recovery operation occurs. These are engine, SSR
|
||||
and actual component callbacks; DOM/hydration, browser/mobile and deployed/final
|
||||
acceptance remain open.
|
||||
|
||||
Evidence: [preparation source](../tests/fixture-semuta-advisor-preparation.ts),
|
||||
[preparation cases](../tests/semuta-advisor-preparation-engine.test.ts),
|
||||
[matching sources](../tests/fixture-semuta-advisor-matching.ts),
|
||||
[matching cases](../tests/semuta-advisor-matching-engine.test.ts),
|
||||
[ground source](../tests/fixture-semuta-advisor-alone.ts) and
|
||||
[ground cases](../tests/semuta-advisor-alone-engine.test.ts).
|
||||
|
||||
## Native market and allied revival-grant cancellation costs — 26 October 2026
|
||||
|
||||
**Prototyped / Partial.** `karamaMarketDiscard` now offers fresh printed-role
|
||||
|
||||
+64
-8
@@ -1,6 +1,7 @@
|
||||
import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income';
|
||||
import { semutaTerminalArrivalSupported } from './semuta-terminal-arrival';
|
||||
import { semutaDisasterArrivalSupported } from './semuta-disaster-arrival';
|
||||
import { semutaAdvisorArrivalSupported } from './semuta-advisor-arrival';
|
||||
import { traitorDecisionChoices, bindTraitorDecisionPromise, settleTraitorDecisionPromises,
|
||||
reconcileTraitorDecisionPromises as reconcileTraitorDecisions, validateTraitorDecisionPromise,
|
||||
TraitorDecisionError, type TraitorDecisionContext, type TraitorDecisionPromise } from './traitor-decision-promises';
|
||||
@@ -1577,7 +1578,7 @@ export type Game = {
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard' | 'karamaDisasterDiscard' | 'karamaCombatDiscard' | 'karamaBonusDiscard' | 'karamaNoFieldDiscard' | 'karamaAlliedTraitorDiscard' | 'karamaEcazMoritaniDiscard' | 'karamaMarketDiscard' | 'karamaFremenGrantDiscard';
|
||||
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard' | 'karamaDisasterDiscard' | 'karamaCombatDiscard' | 'karamaBonusDiscard' | 'karamaNoFieldDiscard' | 'karamaAlliedTraitorDiscard' | 'karamaEcazMoritaniDiscard' | 'karamaMarketDiscard' | 'karamaFremenGrantDiscard' | 'karamaAdvisorFlipDiscard';
|
||||
player: string;
|
||||
card: string;
|
||||
use: Extract<KaramaUse, { kind: 'cancel' }>;
|
||||
@@ -6069,9 +6070,40 @@ function semutaMarketGrantKaramaResponse(g: Game, response: ResponseWindow, play
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/** Ordinary Advanced advisor conversion sources retain their native timing:
|
||||
* preparation, uncommitted matching or an already completed ground movement. */
|
||||
function semutaAdvisorFlipKaramaResponse(g: Game, response: ResponseWindow, player: string): boolean {
|
||||
if (response.kind !== 'advisorFlip' || !g.advanced ||
|
||||
response.owner !== byFaction(g,'beneGesserit')?.id ||
|
||||
g.battle || g.decision || g.phaseOpening || g.pendingKarama || g.truthtrance ||
|
||||
g.pendingExchange || g.pendingNullentropy || g.pendingRicheseGift || g.pendingRichesePurchaseIncome ||
|
||||
g.pendingRevival || g.pendingShipment || g.pendingGuildTransport || g.pendingHomeworldShipment ||
|
||||
g.pendingFremenMove || g.pendingIxMove || g.pendingMobileMove || g.pendingChoamMove ||
|
||||
g.pendingSpiritualAdvisor || g.pendingAmbassador || g.pendingTerrorEntry || g.pendingArrivalOverlap ||
|
||||
!semutaAdvisorArrivalSupported(g,response) || g.ornithopter || g.summonedWorm || g.wormRides.length > 0 ||
|
||||
g.currentAuctionSale || g.choamMarket || g.pendingChoamMarketGhola || g.pendingIxAlly ||
|
||||
response.advisorGatherMatchingSource || response.advisorTransportMatchingSource ||
|
||||
response.advisorAmbassadorMatchingSource || response.spiritualAdvisorSource ||
|
||||
(response.advisorResume !== undefined && !(response.advisorResume === 'preparation' && response.advisorPreparationSource)) ||
|
||||
(!!g.pendingAdvisorMatching !== !!response.advisorMatchingSource) ||
|
||||
(!!g.pendingAutomaticAdvisorFlips?.length !== !!response.automaticAdvisorFlip)) return false;
|
||||
if ((g.auction || g.richeseAuction) && !response.advisorPreparationSource) return false;
|
||||
if (response.automaticAdvisorFlip &&
|
||||
g.pendingAutomaticAdvisorFlips?.some(source => source.producer !== 'groundMovement')) return false;
|
||||
const context: Game = {...g,pendingTreacheryDiscard:null,response};
|
||||
try {
|
||||
// Native source quotes validate denial without requiring the spent card,
|
||||
// changing the stance, committing an arrival or resuming the mover.
|
||||
validateKaramaUse(context,getPlayer(context,player),{kind:'cancel',response});
|
||||
return true;
|
||||
} 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' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard' | 'karamaDisasterDiscard' | 'karamaCombatDiscard' | 'karamaBonusDiscard' | 'karamaNoFieldDiscard' | 'karamaAlliedTraitorDiscard' | 'karamaEcazMoritaniDiscard' | 'karamaMarketDiscard' | 'karamaFremenGrantDiscard' }
|
||||
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard' | 'karamaDisasterDiscard' | 'karamaCombatDiscard' | 'karamaBonusDiscard' | 'karamaNoFieldDiscard' | 'karamaAlliedTraitorDiscard' | 'karamaEcazMoritaniDiscard' | 'karamaMarketDiscard' | 'karamaFremenGrantDiscard' | 'karamaAdvisorFlipDiscard' }
|
||||
>;
|
||||
function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) {
|
||||
const battle = c.kind === 'karamaBattleDiscard';
|
||||
@@ -6092,6 +6124,7 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
|
||||
c.kind === 'karamaAlliedTraitorDiscard';
|
||||
const e3 = c.kind === 'karamaEcazMoritaniDiscard';
|
||||
const marketGrant = c.kind === 'karamaMarketDiscard' || c.kind === 'karamaFremenGrantDiscard';
|
||||
const advisor = c.kind === 'karamaAdvisorFlipDiscard';
|
||||
return JSON.stringify({
|
||||
player: c.player, card: c.card, use: c.use,
|
||||
parent: nullentropyParentSignature(g, c.resume,
|
||||
@@ -6294,12 +6327,24 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
|
||||
moved:p.moved, advisors:p.advisors, noField:p.noField, noFieldEvent:p.noFieldEvent,
|
||||
allySince:p.allySinceTurn})),
|
||||
} : undefined,
|
||||
advisor: advisor ? {
|
||||
preparation:g.advisorPreparation, matching:g.pendingAdvisorMatching, denied:g.advisorMatchingDenied,
|
||||
automatic:g.pendingAutomaticAdvisorFlips, active:g.active, ready:g.ready,
|
||||
remaining:g.movementRemaining, auction:g.auction, round:g.richeseBidding,
|
||||
lot:g.richeseAuction, ix:g.ixAuction, deck:g.deck, aid:g.aid,
|
||||
movement:g.movementPromises, shipment:g.shipmentPromises,
|
||||
arrivals:g.pendingArrivalReaction, intrusion:g.decision,
|
||||
homeworlds:g.homeworlds, skills:g.leaderSkills, tech:g.techTokens,
|
||||
players:g.players.map(p => ({id:p.id, moved:p.moved, advisors:p.advisors,
|
||||
noField:p.noField, noFieldEvent:p.noFieldEvent, flipBlocked:p.advisorFlipMovementBlocked,
|
||||
hajr:p.hajrPlays, window:p.hajrWindow, leaders:p.leaders, kwisatz:p.kwisatz})),
|
||||
} : 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 || terminal || movement || disaster || combat || benefit || e3 || marketGrant ? p.shipped : undefined,
|
||||
physical: battle || worthless || income || shipment || conversion || terminal || movement || revival || disaster || combat || benefit || e3 || marketGrant
|
||||
shipped: income || shipment || conversion || terminal || movement || disaster || combat || benefit || e3 || marketGrant || advisor ? p.shipped : undefined,
|
||||
physical: battle || worthless || income || shipment || conversion || terminal || movement || revival || disaster || combat || benefit || e3 || marketGrant || advisor
|
||||
? { forces: p.forces, reserves: p.reserves, tanks: p.tanks,
|
||||
elites: p.elites, leaders: battle ? p.leaders : undefined,
|
||||
noField: shipment ? p.noField : undefined,
|
||||
@@ -7228,6 +7273,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
continuation?.kind === 'karamaEcazMoritaniDiscard' ||
|
||||
continuation?.kind === 'karamaMarketDiscard' ||
|
||||
continuation?.kind === 'karamaFremenGrantDiscard' ||
|
||||
continuation?.kind === 'karamaAdvisorFlipDiscard' ||
|
||||
continuation?.kind === 'karamaShipmentDiscard' ||
|
||||
continuation?.kind === 'karamaBgConversionCancelDiscard') {
|
||||
const c = continuation, entry = batch.entries[0];
|
||||
@@ -7277,6 +7323,8 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
(((c.kind === 'karamaMarketDiscard' && ['choamSale','richeseBlackMarket','ixAllyCard'].includes(c.use.response.kind)) ||
|
||||
(c.kind === 'karamaFremenGrantDiscard' && c.use.response.kind === 'fremenRevivalGrant')) &&
|
||||
semutaMarketGrantKaramaResponse(g,c.use.response,c.player)) ||
|
||||
(c.kind === 'karamaAdvisorFlipDiscard' &&
|
||||
semutaAdvisorFlipKaramaResponse(g,c.use.response,c.player)) ||
|
||||
(c.kind === 'karamaShipmentDiscard' &&
|
||||
semutaGuildShipmentKaramaResponse(g, c.use.response)) ||
|
||||
(c.kind === 'karamaBgConversionCancelDiscard' &&
|
||||
@@ -8191,6 +8239,7 @@ function semutaOfferSupported(
|
||||
const benefitDiscard = bonusDiscard || noFieldDiscard || alliedTraitorDiscard;
|
||||
const e3Discard = continuation.kind === 'karamaEcazMoritaniDiscard';
|
||||
const marketGrantDiscard = continuation.kind === 'karamaMarketDiscard' || continuation.kind === 'karamaFremenGrantDiscard';
|
||||
const advisorDiscard = continuation.kind === 'karamaAdvisorFlipDiscard';
|
||||
const saleCostDiscard = continuation.kind === 'karamaMarketDiscard' && continuation.use.response.kind === 'choamSale';
|
||||
const replacementCostDiscard = continuation.kind === 'karamaMarketDiscard' && continuation.use.response.kind === 'ixAllyCard';
|
||||
const auctionCostDiscard = replacementCostDiscard || continuation.kind === 'karamaMarketDiscard' && continuation.use.response.kind === 'richeseBlackMarket';
|
||||
@@ -8236,7 +8285,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'karamaChoamWorthlessDiscard' &&
|
||||
continuation.kind !== 'karamaEmperorShareDiscard' &&
|
||||
continuation.kind !== 'karamaIncomeDiscard' &&
|
||||
!richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && !disasterDiscard && !combatDiscard && !benefitDiscard && !e3Discard && !marketGrantDiscard &&
|
||||
!richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && !disasterDiscard && !combatDiscard && !benefitDiscard && !e3Discard && !marketGrantDiscard && !advisorDiscard &&
|
||||
continuation.kind !== 'karamaShipmentDiscard' &&
|
||||
continuation.kind !== 'karamaShipmentPriceDiscard' &&
|
||||
continuation.kind !== 'specialKaramaDiscard' &&
|
||||
@@ -8273,7 +8322,7 @@ function semutaOfferSupported(
|
||||
continuation.promise.kind === 'shipment' ||
|
||||
continuation.promise.kind === 'movement' ||
|
||||
continuation.promise.kind === 'battle'))) return false;
|
||||
if (g.homeworlds && !bgDiscard && !richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && !disasterDiscard && !combatDiscard && !benefitDiscard && !e3Discard && !marketGrantDiscard && continuation.kind !== 'kaitainDiscard' &&
|
||||
if (g.homeworlds && !bgDiscard && !richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && !disasterDiscard && !combatDiscard && !benefitDiscard && !e3Discard && !marketGrantDiscard && !advisorDiscard && continuation.kind !== 'kaitainDiscard' &&
|
||||
continuation.kind !== 'tupileCleanupDiscard' &&
|
||||
continuation.kind !== 'karamaIncomeDiscard' &&
|
||||
continuation.kind !== 'karamaShipmentDiscard' &&
|
||||
@@ -8385,6 +8434,7 @@ function semutaOfferSupported(
|
||||
benefitDiscard ||
|
||||
e3Discard ||
|
||||
marketGrantDiscard ||
|
||||
advisorDiscard ||
|
||||
continuation.kind === 'karamaShipmentDiscard'
|
||||
? (continuation.kind === 'karamaBattleDiscard'
|
||||
? semutaBattleKaramaResponse(g, continuation.use.response)
|
||||
@@ -8408,6 +8458,8 @@ function semutaOfferSupported(
|
||||
? semutaDisasterKaramaResponse(g,continuation.use.response,continuation.player)
|
||||
: combatDiscard
|
||||
? semutaCombatKaramaResponse(g,continuation.use.response,continuation.player,continuation.resume.decision)
|
||||
: advisorDiscard
|
||||
? semutaAdvisorFlipKaramaResponse(g,continuation.use.response,continuation.player)
|
||||
: marketGrantDiscard
|
||||
? semutaMarketGrantKaramaResponse(g,continuation.use.response,continuation.player)
|
||||
: e3Discard
|
||||
@@ -8457,6 +8509,7 @@ function semutaOfferSupported(
|
||||
benefitDiscard ||
|
||||
e3Discard ||
|
||||
marketGrantDiscard ||
|
||||
advisorDiscard ||
|
||||
continuation.kind === 'karamaShipmentDiscard' ||
|
||||
((continuation.kind === 'ordinaryCardDiscard'
|
||||
? ordinaryDiscardIncomeMatches(g, continuation)
|
||||
@@ -8469,7 +8522,7 @@ function semutaOfferSupported(
|
||||
: !continuation.resume.phaseOpening)))) &&
|
||||
(richeseGiftDiscard || continuation.kind === 'karamaShipmentDiscard' ||
|
||||
continuation.kind === 'specialKaramaDiscard' || bgDiscard || !g.karamaShipping) &&
|
||||
(auctionCostDiscard || bonusDiscard || auctionAdvantageDiscard || richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || (continuation.kind === 'ixAllyCard'
|
||||
(advisorDiscard || auctionCostDiscard || bonusDiscard || auctionAdvantageDiscard || richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || (continuation.kind === 'ixAllyCard'
|
||||
? !!g.currentAuctionSale && continuation.sale.origin === g.currentAuctionSale.origin &&
|
||||
(continuation.sale.origin === 'normal'
|
||||
? !!g.auction && !g.richeseAuction
|
||||
@@ -8483,7 +8536,7 @@ function semutaOfferSupported(
|
||||
? !!g.currentAuctionSale && (g.currentAuctionSale.origin === 'normal'
|
||||
? !!g.auction && !g.richeseAuction : !!g.richeseAuction && !g.auction)
|
||||
: (continuation.kind === 'saphoAuctionDiscard' && continuation.cyclic === 'normal') || !g.auction)) &&
|
||||
(auctionCostDiscard || bonusDiscard || auctionAdvantageDiscard || richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' ||
|
||||
(advisorDiscard || auctionCostDiscard || bonusDiscard || auctionAdvantageDiscard || richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' ||
|
||||
continuation.kind === 'ixAllyCard' ||
|
||||
(continuation.kind === 'karamaIncomeDiscard' &&
|
||||
continuation.use.response.kind === 'emperorIncome') || !g.richeseAuction) &&
|
||||
@@ -8824,6 +8877,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
||||
next.kind === 'karamaEcazMoritaniDiscard' ||
|
||||
next.kind === 'karamaMarketDiscard' ||
|
||||
next.kind === 'karamaFremenGrantDiscard' ||
|
||||
next.kind === 'karamaAdvisorFlipDiscard' ||
|
||||
next.kind === 'karamaShipmentDiscard' ||
|
||||
next.kind === 'karamaBgConversionCancelDiscard' ||
|
||||
next.kind === 'karamaPurchaseDiscard' ||
|
||||
@@ -16199,6 +16253,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
|
||||
semutaBonusNoFieldTraitorResponse(g,use.response,p.id) ||
|
||||
semutaEcazMoritaniKaramaResponse(g,use.response,p.id) ||
|
||||
semutaMarketGrantKaramaResponse(g,use.response,p.id) ||
|
||||
semutaAdvisorFlipKaramaResponse(g,use.response,p.id) ||
|
||||
guildShipmentDiscard || bgConversionCancelDiscard)) {
|
||||
const continuation: KaramaResponseDiscardContinuation = {
|
||||
kind: bgConversionCancelDiscard ? 'karamaBgConversionCancelDiscard'
|
||||
@@ -16217,6 +16272,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
|
||||
: ['ecazPlacement','moritaniPlacement','moritaniDuke','ecazCollection','ecazOccupy','moritaniAlliance'].includes(use.response.kind) ? 'karamaEcazMoritaniDiscard'
|
||||
: ['choamSale','richeseBlackMarket','ixAllyCard'].includes(use.response.kind) ? 'karamaMarketDiscard'
|
||||
: use.response.kind === 'fremenRevivalGrant' ? 'karamaFremenGrantDiscard'
|
||||
: use.response.kind === 'advisorFlip' ? 'karamaAdvisorFlipDiscard'
|
||||
: use.response.kind === 'choamCharity' ? 'karamaCharityDiscard'
|
||||
: use.response.kind === 'choamInflation'
|
||||
? 'karamaInflationDiscard'
|
||||
|
||||
@@ -1297,6 +1297,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Karama stopping Harkonnen’s extra card after a purchase, a Richese No-Field declaration or Harkonnen’s Traitor call for an ally also offers its used cost. Claim or Continue keeps the original purchase paid and prevents only the bonus draw. A denied No-Field moves no token or forces and charges no shipment; the original ordinary shipment or independent Ambassador reserve grant remains available. An allied Traitor cancellation preserves revealed plans and earlier votes, changes only Harkonnen’s effective call, then finishes the original battle once. The physical Traitor is never a Semuta target. Supported Ambassador cases are Basic; broader combinations remain unfinished.',
|
||||
'Karama stopping native Ecaz or Moritani placement, Moritani’s earned Duke acquisition, Ecaz Occupy or Enemy of My Enemy also offers its used cost. Claim or Continue keeps earlier payments, physical tokens, the selected battle lead and paid entry intact, then applies the original denial once. Moritani may still reveal or decline its Terror token. Ecaz’s shared-stronghold Collection bonus is Advanced-only: a restricted fresh development setup offers this cost before ordinary collection settles, withholding only the canceled bonus. Basic collection has no such bonus; public Advanced Semuta starts and wider combinations remain guarded.',
|
||||
'Karama stopping a native CHOAM sale, Richese Black Market, Ixian ally replacement or Fremen allied free-revival grant also offers its fresh used cost. Claim or Continue applies the original denial once: a canceled sale keeps its unsold card and earns nothing; Black Market returns through its unbid continuation; replacement keeps the original paid card and skips the exchange; the ally retains intrinsic revival rates and completed returns. Allowance still uses the original sale, auction, draw or grant controls. Only the used Treachery cost is fresh at this response.',
|
||||
'Advanced advisor preparation, ordinary incoming-group matching and mandatory conversion after a ground move also offer the used Karama cost. Claim or Continue preserves the original auction or movement boundary, applies only native conversion denial and resumes once. Matching keeps the uncommitted groups and unused move; a completed departure keeps its transferred forces and queued arrival. The existing continuously-alone hold is provisional. Other advisor producers and broader parents remain unfinished.',
|
||||
'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.',
|
||||
'Printed-role Karama stopping ordinary native Fremen range, Cyborg range, Advanced Guild timing or HMS movement/collection also offers its exact used cost. Claim or Continue completes the original denial once. Fremen and Ixian forces remain in place with their move unused and current-use restriction intact; choose a legal replacement. Guild follows the original remaining queue under its native timing lock. A canceled HMS route neither relocates nor collects; only after the fresh cost finishes does the original Storm turn begin, including its normal Advanced card handling. No declaration, payment, movement, collection or turn opening repeats. Card-flight and other complex parents retain their separate boundaries.',
|
||||
'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.',
|
||||
@@ -1468,6 +1469,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/semuta-fremen-grant-cost-engine.test.ts',
|
||||
'tests/fixture-semuta-ix-ally-cost.ts',
|
||||
'tests/semuta-ix-ally-cost-engine.test.ts',
|
||||
'game/semuta-advisor-arrival.ts',
|
||||
'tests/fixture-semuta-advisor-preparation.ts',
|
||||
'tests/semuta-advisor-preparation-engine.test.ts',
|
||||
'tests/fixture-semuta-advisor-matching.ts',
|
||||
'tests/semuta-advisor-matching-engine.test.ts',
|
||||
'tests/fixture-semuta-advisor-alone.ts',
|
||||
'tests/semuta-advisor-alone-engine.test.ts',
|
||||
'tests/fixture-semuta-spiritual-foresight.ts',
|
||||
'tests/semuta-spiritual-foresight-engine.test.ts',
|
||||
'tests/fixture-semuta-storm-dancer.ts',
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Game, ResponseWindow } from './engine';
|
||||
import { location, validGameLocation } from './board';
|
||||
|
||||
/** A completed ground move keeps its original arrival behind mandatory
|
||||
* conversion. This validates that deposited group without moving it again. */
|
||||
export function semutaAdvisorArrivalSupported(g: Game, response: ResponseWindow): boolean {
|
||||
const arrivals = g.pendingArrivalReaction;
|
||||
if (!arrivals?.length) return true;
|
||||
const source = response.automaticAdvisorFlip;
|
||||
if (response.kind !== 'advisorFlip' || source?.producer !== 'groundMovement' ||
|
||||
!g.advanced || g.phase !== 5 || !Array.isArray(arrivals) || arrivals.length !== 1) return false;
|
||||
const entry = arrivals[0];
|
||||
if (!entry || Object.keys(entry).sort().join(',') !==
|
||||
'amount,cause,elite,entrant,phase,resume,sector,territory,turn' ||
|
||||
entry.turn !== g.turn || entry.phase !== g.phase ||
|
||||
entry.cause !== 'movement' || entry.resume !== 'none' ||
|
||||
entry.entrant !== source.mover || entry.entrant !== g.active ||
|
||||
!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 mover = g.players.find(p => p.id === source.mover);
|
||||
if (!mover || mover.shipped !== true || mover.moved !== source.move ||
|
||||
!g.movementRemaining?.includes(mover.id)) return false;
|
||||
const key = location(entry.territory,entry.sector), total = mover.forces[key] ?? 0,
|
||||
elite = mover.elites?.forces[key] ?? 0;
|
||||
return total >= entry.amount && elite >= entry.elite &&
|
||||
total - elite >= entry.amount - entry.elite;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaCombatCapabilities, nextSemutaCombatCapabilities,
|
||||
semutaCombatCapabilitiesPlayer, semutaCombatCapabilitiesInventory, semutaCombatCapabilitiesClean,
|
||||
passSemutaCombatCapabilities, claimSemutaCombatCapabilities,
|
||||
} from './fixture-semuta-combat-capabilities';
|
||||
|
||||
export type SemutaAdvisorAloneStep = { actor: string; action: Action };
|
||||
export const actSemutaAdvisorAlone = actSemutaCombatCapabilities;
|
||||
export const semutaAdvisorAlonePlayer = semutaCombatCapabilitiesPlayer;
|
||||
export const semutaAdvisorAloneInventory = semutaCombatCapabilitiesInventory;
|
||||
export const semutaAdvisorAloneClean = semutaCombatCapabilitiesClean;
|
||||
export const passSemutaAdvisorAlone = passSemutaCombatCapabilities;
|
||||
export const claimSemutaAdvisorAlone = claimSemutaCombatCapabilities;
|
||||
export const semutaAdvisorAlonePhysical = (game: Game) => ({
|
||||
turn: game.turn, phase: game.phase, storm: game.storm, active: game.active, order: game.order,
|
||||
ready: game.ready, movementRemaining: game.movementRemaining, spice: game.spice,
|
||||
players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces, reserves: p.reserves,
|
||||
tanks: p.tanks, elites: p.elites, leaders: p.leaders, ally: p.ally, shipped: p.shipped, moved: p.moved })),
|
||||
});
|
||||
|
||||
/** Original setup and native quiet controls only. The BG start choice declines
|
||||
* preparation, keeping the actual setup advisor beside Atreides' garrison. */
|
||||
export function nextSemutaAdvisorAlone(game: Game): SemutaAdvisorAloneStep | null {
|
||||
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
|
||||
if (game.status === 'setup' && game.setupStage === 'prediction')
|
||||
return { actor: 'beneGesserit', action: { type: 'predict', faction: 'atreides', turn: 10 } };
|
||||
const bg = game.players.find(p => p.faction === 'beneGesserit');
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && bg && !bg.advisorSetup)
|
||||
return { actor: bg.id, action: { type: 'advisorSetup', territory: 'arrakeen', sector: 10 } };
|
||||
const d = game.decision;
|
||||
if (d?.kind === 'advisorBattle' || d?.kind === 'advisor' || d?.kind === 'intrusion')
|
||||
return { actor: d.player, action: { type: 'decision', accept: false } };
|
||||
}
|
||||
return nextSemutaCombatCapabilities(game);
|
||||
}
|
||||
export const nextSemutaAdvisorAloneStep = nextSemutaAdvisorAlone;
|
||||
export function advanceSemutaAdvisorAlone(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaAdvisorAloneStep[] = []): Game {
|
||||
for (let count = 0; count < 2200; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextSemutaAdvisorAlone(game);
|
||||
assert.ok(next, `Supply the real advisor control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaAdvisorAlone(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The native alone-advisor source did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaAdvisorAlone(game: Game, actions: SemutaAdvisorAloneStep[] = []): Game {
|
||||
assert.ok(game.response?.kind === 'advisorFlip');
|
||||
assert.equal(game.response.automaticAdvisorFlip?.producer, 'groundMovement');
|
||||
for (let count = 0; game.response?.kind === 'advisorFlip' && count < game.players.length; count++) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaAdvisorAlone(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.kind, 'advisorFlip'); return game;
|
||||
}
|
||||
/** Finish the original automatic-conversion suffix only. Leave Atreides'
|
||||
* current combined turn, shipment and later endMovement to the caller. */
|
||||
export function finishSemutaAdvisorAlone(game: Game, actions: SemutaAdvisorAloneStep[] = []): Game {
|
||||
assert.ok(!game.pendingTreacheryDiscard);
|
||||
return advanceSemutaAdvisorAlone(game, state => semutaAdvisorAloneClean(state) &&
|
||||
!state.pendingAutomaticAdvisorFlips?.length && !state.pendingArrivalReaction?.length, actions);
|
||||
}
|
||||
|
||||
/** Original Advanced R/C/BG/A/E setup, paid cache1 and a real ten-counter
|
||||
* Atreides departure. No played army, stance, hold or response is installed. */
|
||||
export function createSemutaAdvisorAloneFixture() {
|
||||
const richese = 'richese', holder = 'choam', owner = 'beneGesserit', mover = 'atreides', spender = 'emperor';
|
||||
const territory = 'arrakeen', sector = 10, from = 'arrakeen:10', to = 'imperial_basin:10', amount = 10;
|
||||
let game = createGame('SEMUTAADVISORALONE', newPlayer(richese, 'Richese', 'richese'), true, ['choam']);
|
||||
for (const faction of ['choam', 'beneGesserit', 'atreides', 'emperor'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaAdvisorAloneStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaAdvisorAlone(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorAlone(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), at = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||
};
|
||||
for (const p of game.players) act(p.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(p => p.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaAdvisorAloneInventory(game); assert.equal(originalInventory.length, 45);
|
||||
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile' || card.kind === 'poison').slice(0, 4);
|
||||
assert.equal(ordinary.length, 4); const selected = [...ordinary, printed];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { card: card.id, position, from };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game), cost = printed.id;
|
||||
const setupChoices = transition(() => { reach(state => state.status === 'playing'); });
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(semutaAdvisorAlonePlayer(game, mover).forces[from], amount);
|
||||
assert.equal(semutaAdvisorAlonePlayer(game, owner).forces[from], 1);
|
||||
assert.ok(semutaAdvisorAlonePlayer(game, owner).advisors?.[territory]);
|
||||
assert.ok(semutaAdvisorAlonePlayer(game, spender).hand.some(card => card.id === cost));
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let beforeBid: Game | undefined, bid: SemutaAdvisorAloneStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 1000 && !semutaAdvisorAlonePlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const next = nextSemutaAdvisorAlone(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.equal(semutaAdvisorAlonePlayer(beforeBid, holder).spice - semutaAdvisorAlonePlayer(game, holder).spice, 1);
|
||||
const beforeMovement = structuredClone(game), movementAt = actions.length;
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === mover && semutaAdvisorAloneClean(state));
|
||||
const movementOpening = { before: beforeMovement, steps: structuredClone(actions.slice(movementAt)), after: structuredClone(game) };
|
||||
assert.equal(semutaAdvisorAlonePlayer(game, mover).forces[from], amount);
|
||||
assert.equal(semutaAdvisorAlonePlayer(game, mover).moved, 0);
|
||||
assert.ok(semutaAdvisorAlonePlayer(game, owner).advisors?.[territory]);
|
||||
const moveAction: Action = { type: 'move', from, amount, elite: 0, territory: 'imperial_basin', sector };
|
||||
const departure = transition(() => { act(mover, moveAction); });
|
||||
assert.ok(game.response?.kind === 'advisorFlip');
|
||||
const automatic = game.response.automaticAdvisorFlip; assert.ok(automatic?.producer === 'groundMovement');
|
||||
assert.equal(automatic.owner, owner); assert.equal(automatic.mover, mover); assert.equal(automatic.move, 1);
|
||||
assert.deepEqual(automatic.forces, { [from]: 1 }); assert.equal(automatic.territory, territory);
|
||||
assert.equal(semutaAdvisorAlonePlayer(game, owner).advisors?.[territory]?.aloneFlipHold, automatic.event);
|
||||
assert.equal(semutaAdvisorAlonePlayer(game, mover).forces[from] ?? 0, 0);
|
||||
assert.equal(semutaAdvisorAlonePlayer(game, mover).forces[to], amount);
|
||||
assert.equal(semutaAdvisorAlonePlayer(game, mover).moved, 1);
|
||||
assert.deepEqual(semutaAdvisorAloneInventory(game), originalInventory);
|
||||
return { game, kind: 'advisorFlip' as const, advanced: true as const, richese, holder, owner, mover, spender,
|
||||
cost, karama: cost, semuta: SEMUTA_DRUG_ID,
|
||||
action: { type: 'card', card: cost, mode: 'cancel' } as Action,
|
||||
originalResponse: structuredClone(game.response), originalAutomaticSource: structuredClone(automatic),
|
||||
originalQueue: structuredClone(game.pendingAutomaticAdvisorFlips!),
|
||||
originalPhysical: structuredClone(semutaAdvisorAlonePhysical(game)),
|
||||
originalStance: structuredClone(semutaAdvisorAlonePlayer(game, owner).advisors!),
|
||||
originalMovement: { active: game.active, moved: semutaAdvisorAlonePlayer(game, mover).moved,
|
||||
shipped: semutaAdvisorAlonePlayer(game, mover).shipped, forces: structuredClone(semutaAdvisorAlonePlayer(game, mover).forces) },
|
||||
territory, sector, from, to, amount, moveAction, initial, lobby, setup, setupChoices, beforeStartingDeal,
|
||||
afterSetup, dealOrder, deal: selected.map(card => card.id), originalInventory, originalSpice, spiceOrder,
|
||||
acquisition, acquired, cache: acquisition, movementOpening, departure,
|
||||
actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original undealt Advanced Semuta initializer on genuine ready R/C/BG/A/E with CHOAM only and no optional modules. Original setup predicts, selects private Traitors and actually places BG’s one advisor in Arrakeen10 beside Atreides’ original ten.',
|
||||
'Only unopened starting-card/Spice positions and scoped native scalar lotteries are selected. Emperor receives the original printed Karama, and CHOAM really pays cache1 for Semuta. Native BG preparation is declined; unused movement turns end through actual controls.',
|
||||
'Atreides actually moves all ten original Arrakeen counters to empty Imperial Basin10. The completed move index1 leaves BG alone and opens the original mandatory groundMovement conversion with an actual UUID hold and exact one-counter footprint.',
|
||||
'Stop before the real cancellation cost. No played force, stance, hold, hand, wallet, clock, movement, source queue or response is installed. Only the original automatic outcome and movement suffix are tested.',
|
||||
] };
|
||||
}
|
||||
export type SemutaAdvisorAloneFixture = ReturnType<typeof createSemutaAdvisorAloneFixture>;
|
||||
@@ -0,0 +1,187 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { advisorMatchingKey, validateAdvisorMatching } from '../game/advisor-matching';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaDisasterWorm, passSemutaDisasterWorm, claimSemutaDisasterWorm,
|
||||
semutaDisasterWormPlayer, semutaDisasterWormInventory,
|
||||
} from './fixture-semuta-disaster-worm';
|
||||
import { nextSemutaRicheseCacheKarama } from './fixture-semuta-richese-cache-karama';
|
||||
|
||||
export type SemutaAdvisorMatchingDirection = 'fightersToAdvisors' | 'advisorsToFighters';
|
||||
export type SemutaAdvisorMatchingStep = { actor: string; action: Action };
|
||||
export const actSemutaAdvisorMatching = actSemutaDisasterWorm;
|
||||
export const passSemutaAdvisorMatching = passSemutaDisasterWorm;
|
||||
export const claimSemutaAdvisorMatching = claimSemutaDisasterWorm;
|
||||
export const semutaAdvisorMatchingPlayer = semutaDisasterWormPlayer;
|
||||
export const semutaAdvisorMatchingInventory = semutaDisasterWormInventory;
|
||||
export const semutaAdvisorMatchingClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingAdvisorMatching && !game.pendingRevival;
|
||||
export const semutaAdvisorMatchingPhysical = (game: Game) => game.players.map(p => ({ id: p.id, spice: p.spice,
|
||||
forces: p.forces, advisors: p.advisors, elites: p.elites, reserves: p.reserves, tanks: p.tanks,
|
||||
shipped: p.shipped, moved: p.moved, ally: p.ally }));
|
||||
|
||||
/** Decline the original preparation and other optional stances through actual
|
||||
* choices. Matching/automatic counters and fresh groups stay caller-owned. */
|
||||
export function nextSemutaAdvisorMatching(game: Game): SemutaAdvisorMatchingStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance || game.response?.kind === 'advisorFlip') return null;
|
||||
if (!game.phaseOpening && !game.response) {
|
||||
if (game.status === 'setup' && game.setupStage === 'prediction')
|
||||
return { actor: 'beneGesserit', action: { type: 'predict', faction: 'atreides', turn: 10 } };
|
||||
const bg = game.players.find(p => p.faction === 'beneGesserit');
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && bg && !bg.advisorSetup &&
|
||||
viewGame(game, bg.id).setupPending.includes(bg.id))
|
||||
return { actor: bg.id, action: { type: 'advisorSetup', territory: 'arrakeen', sector: 10 } };
|
||||
if (game.decision?.kind === 'advisorBattle' || game.decision?.kind === 'advisor' || game.decision?.kind === 'intrusion')
|
||||
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
|
||||
}
|
||||
return nextSemutaRicheseCacheKarama(game);
|
||||
}
|
||||
export const nextSemutaAdvisorMatchingStep = nextSemutaAdvisorMatching;
|
||||
export function advanceSemutaAdvisorMatching(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaAdvisorMatchingStep[] = []): Game {
|
||||
for (let count = 0; count < 1700; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaAdvisorMatching(game);
|
||||
assert.ok(step, `Choose the native matching/fresh response at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaAdvisorMatching(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original advisor-matching source did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaAdvisorMatching(game: Game, actions: SemutaAdvisorMatchingStep[] = []): Game {
|
||||
const event = game.response?.advisorMatchingSource?.event; assert.ok(event);
|
||||
for (let count = 0; game.response?.advisorMatchingSource?.event === event && count < game.players.length; count++) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaAdvisorMatching(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.advisorMatchingSource?.event, event); return game;
|
||||
}
|
||||
/** Complete only the original allowed/canceled move's native suffix and stop
|
||||
* at BG's same movement boundary. No new declaration or turn end is chosen. */
|
||||
export function finishSemutaAdvisorMatching(game: Game, actions: SemutaAdvisorMatchingStep[] = []): Game {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null, 'Choose the original fresh cost first');
|
||||
return advanceSemutaAdvisorMatching(game, semutaAdvisorMatchingClean, actions);
|
||||
}
|
||||
|
||||
/** Original Advanced R/C/BG/A/E setup, actual cache1 purchase and paid separate
|
||||
* fighter group. Both directions earn their real uncommitted matching source. */
|
||||
export function createSemutaAdvisorMatchingFixture(options: { direction?: SemutaAdvisorMatchingDirection } = {}) {
|
||||
const direction = options.direction ?? 'fightersToAdvisors', seller = 'richese', holder = 'choam';
|
||||
const owner = 'beneGesserit', atreides = 'atreides', spender = 'emperor';
|
||||
let game = createGame('SEMUTAADVISORMATCHING', newPlayer(seller, 'Richese', 'richese'), true, ['choam']);
|
||||
for (const faction of ['choam', 'beneGesserit', 'atreides', 'emperor'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaAdvisorMatchingStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaAdvisorMatching(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorMatching(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), at = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||
};
|
||||
for (const p of game.players) act(p.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
|
||||
entry: 'original-undealt-advanced-semuta-preview' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(p => p.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaAdvisorMatchingInventory(game); assert.equal(originalInventory.length, 45);
|
||||
const printed = game.deck.find(card => card.effect === 'karama'), shield = game.deck.find(card => card.kind === 'shield');
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 3); assert.equal(ordinary.length, 3);
|
||||
assert.ok(printed && shield);
|
||||
const selected = [ordinary[0], ordinary[1], shield, ordinary[2], printed], dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
let advisorSetup: { before: Game; steps: SemutaAdvisorMatchingStep[]; after: Game } | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const step = nextSemutaAdvisorMatching(game); assert.ok(step);
|
||||
const before = step.action.type === 'advisorSetup' ? structuredClone(game) : null;
|
||||
act(step.actor, step.action);
|
||||
if (before) advisorSetup = { before, steps: [structuredClone(step)], after: structuredClone(game) };
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(advisorSetup);
|
||||
const afterSetup = structuredClone(game), cost = printed.id;
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(semutaAdvisorMatchingPlayer(game, owner).forces['arrakeen:10'], 1);
|
||||
assert.equal(isAdvisor(semutaAdvisorMatchingPlayer(game, owner), 'arrakeen'), true);
|
||||
assert.equal(semutaAdvisorMatchingPlayer(game, atreides).forces['arrakeen:10'], 10);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
for (const [position, face] of ['funeral_plain', 'hagga_basin'].entries()) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && card.territory === face && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
spiceOrder.push({ position, from, card: structuredClone(card) });
|
||||
}
|
||||
let beforeBid: Game | undefined, bid: SemutaAdvisorMatchingStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaAdvisorMatchingPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaAdvisorMatching(game); assert.ok(step);
|
||||
if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
|
||||
}
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
assert.ok(beforeBid && bid && bidAt >= 0);
|
||||
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||
assert.equal(semutaAdvisorMatchingPlayer(beforeBid, holder).spice - semutaAdvisorMatchingPlayer(game, holder).spice, 1);
|
||||
reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'advisorBattle');
|
||||
const declinedPreparation = transition(() => {
|
||||
act(owner, { type: 'decision', accept: false });
|
||||
reach(state => state.phase === 5 && state.active === owner && semutaAdvisorMatchingClean(state));
|
||||
});
|
||||
const shipment = transition(() => {
|
||||
act(owner, { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(semutaAdvisorMatchingClean);
|
||||
});
|
||||
assert.equal(semutaAdvisorMatchingPlayer(shipment.before, owner).spice - semutaAdvisorMatchingPlayer(game, owner).spice, 2);
|
||||
const beforeDeclaration = structuredClone(game), bg = semutaAdvisorMatchingPlayer(game, owner);
|
||||
assert.equal(bg.forces['arrakeen:10'], 1); assert.equal(bg.forces['imperial_basin:9'], 1);
|
||||
assert.equal(isAdvisor(bg, 'arrakeen'), true); assert.equal(isAdvisor(bg, 'imperial_basin'), false);
|
||||
assert.equal(bg.shipped, true); assert.equal(bg.moved, 0); assert.equal(bg.reserves, 18);
|
||||
const origin = direction === 'fightersToAdvisors' ? 'imperial_basin' : 'arrakeen';
|
||||
const destination = direction === 'fightersToAdvisors' ? 'arrakeen' : 'imperial_basin';
|
||||
const sourceLocation = direction === 'fightersToAdvisors' ? 'imperial_basin:9' : 'arrakeen:10';
|
||||
const destinationLocation = direction === 'fightersToAdvisors' ? 'arrakeen:10' : 'imperial_basin:9';
|
||||
const nativeAction: Action = { type: 'move', from: sourceLocation, amount: 1, elite: 0,
|
||||
territory: destination, sector: direction === 'fightersToAdvisors' ? 10 : 9 };
|
||||
const declaration = transition(() => { act(owner, nativeAction); });
|
||||
assert.ok(game.response?.advisorMatchingSource && game.pendingAdvisorMatching);
|
||||
assert.equal(game.response.kind, 'advisorFlip'); assert.equal(game.response.owner, owner);
|
||||
const originalResponse = structuredClone(game.response), originalPending = structuredClone(game.pendingAdvisorMatching);
|
||||
assert.equal(originalPending.intent.kind, 'movement');
|
||||
if (originalPending.intent.kind !== 'movement') throw Error('Expected the original ordinary ground-movement order');
|
||||
const receipt = structuredClone(game.response.advisorMatchingSource), originalOrder = structuredClone(originalPending.intent.order);
|
||||
validateAdvisorMatching(game, receipt); assert.deepEqual(originalPending.receipt, receipt);
|
||||
assert.deepEqual(semutaAdvisorMatchingPhysical(game), semutaAdvisorMatchingPhysical(beforeDeclaration));
|
||||
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
const replacementAction: Action = { type: 'move', from: 'imperial_basin:9', amount: 1, elite: 0,
|
||||
territory: 'imperial_basin', sector: 10 };
|
||||
const source = structuredClone(game), denialKey = advisorMatchingKey(receipt);
|
||||
assert.deepEqual(semutaAdvisorMatchingInventory(game), originalInventory);
|
||||
return { game, source, beforeCost: source, advanced: true as const, kind: 'advisorMatching' as const, direction,
|
||||
owner, bg: owner, holder, spender, emperor: spender, atreides, seller, richese: seller, recipient: owner,
|
||||
cost, karama: cost, semuta: SEMUTA_DRUG_ID, action, nativeAction, actor: owner,
|
||||
replacementActor: owner, replacementAction, ordinaryAction: replacementAction,
|
||||
originalResponse, originalPending, pending: originalPending, receipt, originalOrder, order: originalOrder, denialKey,
|
||||
originalDenied: [...(game.advisorMatchingDenied ?? [])], originalPhysical: structuredClone(semutaAdvisorMatchingPhysical(game)),
|
||||
origin, destination, sourceLocation, destinationLocation, from: sourceLocation, to: destinationLocation,
|
||||
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, advisorSetup,
|
||||
deal: selected.map(card => card.id), dealOrder, originalInventory, originalSpice, spiceOrder,
|
||||
acquisition, acquired, declinedPreparation, shipment, beforeDeclaration, declaration,
|
||||
actions, steps: actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original Advanced Semuta initializer on the genuinely ready undealt R/C/BG/A/E lobby, exact CHOAM/main35/cache10 and no modules. Only original unopened starting-card/Spice positions and scalar lotteries are selected. Emperor really receives printed Karama; CHOAM really pays cache1 for Semuta.',
|
||||
'BG actually predicts, then actually places its native advisor at Arrakeen10 beside Atreides’ original ten. Real first Storm, both Spice blows, Charity, auctions and Revival execute; original advisor preparation is actually declined.',
|
||||
'During its actual first turn BG really pays two for one original reserve into empty Basin9 as fighter. Its original Arrakeen advisor remains. The owned ordinary one-counter ground move really declares the selected opposite-stance arrival and earns the native advisorMatchingSource before any transfer, payment or movement use.',
|
||||
'Stop before Emperor spends its originally dealt printed Karama. No hand, force, stance, wallet, clock, allowance, denial key, response or uncommitted order is installed. A canceled source can use the genuinely earned Basin fighter’s same-type sector9→10 reposition as its actual alternate move; no denied conversion is forced.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaAdvisorMatchingFixture = ReturnType<typeof createSemutaAdvisorMatchingFixture>;
|
||||
@@ -0,0 +1,165 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { quoteAdvisorPreparation } from '../game/advisor-preparation-window';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaSpiritualForesight, nextSemutaSpiritualForesight,
|
||||
semutaSpiritualForesightClean, semutaSpiritualForesightInventory, semutaSpiritualForesightPlayer,
|
||||
} from './fixture-semuta-spiritual-foresight';
|
||||
import {
|
||||
passSemutaFremenGuildMovement, claimSemutaFremenGuildMovement,
|
||||
} from './fixture-semuta-fremen-guild-movement';
|
||||
|
||||
export type SemutaAdvisorPreparationStep = { actor: string; action: Action };
|
||||
export const actSemutaAdvisorPreparation = actSemutaSpiritualForesight;
|
||||
export const passSemutaAdvisorPreparation = passSemutaFremenGuildMovement;
|
||||
export const claimSemutaAdvisorPreparation = claimSemutaFremenGuildMovement;
|
||||
export const semutaAdvisorPreparationPlayer = semutaSpiritualForesightPlayer;
|
||||
export const semutaAdvisorPreparationInventory = semutaSpiritualForesightInventory;
|
||||
export const semutaAdvisorPreparationClean = semutaSpiritualForesightClean;
|
||||
|
||||
/** Resolve native setup/auction controls, retaining the actual occupied
|
||||
* Arrakeen setup choice. A declared flip or its fresh cost stays caller-owned. */
|
||||
export function nextSemutaAdvisorPreparation(game: Game): SemutaAdvisorPreparationStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance || game.response?.kind === 'advisorFlip') return null;
|
||||
return nextSemutaSpiritualForesight(game, 'advisorBattle');
|
||||
}
|
||||
export function advanceSemutaAdvisorPreparation(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaAdvisorPreparationStep[] = []): Game {
|
||||
for (let count = 0; count < 1200; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaAdvisorPreparation(game);
|
||||
assert.ok(step, 'Choose the original advisor declaration or fresh discard before continuing');
|
||||
game = actSemutaAdvisorPreparation(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original advisor-preparation programme did not reach its finite boundary');
|
||||
}
|
||||
/** Allow only this original whole-territory declaration. No auction bid,
|
||||
* readiness acknowledgment, shipment or future movement is submitted. */
|
||||
export function allowSemutaAdvisorPreparation(game: Game,
|
||||
actions: SemutaAdvisorPreparationStep[] = []): Game {
|
||||
assert.equal(game.response?.kind, 'advisorFlip');
|
||||
assert.ok(game.response?.advisorPreparationSource);
|
||||
for (let count = 0; game.response?.kind === 'advisorFlip' && count < game.players.length; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actSemutaAdvisorPreparation(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.equal(game.response ?? null, null);
|
||||
return finishSemutaAdvisorPreparation(game);
|
||||
}
|
||||
/** Native direct preparation has no movement tail. Its original auction is
|
||||
* already the next control; do not advance it merely to manufacture a suffix. */
|
||||
export function finishSemutaAdvisorPreparation(game: Game,
|
||||
_actions: SemutaAdvisorPreparationStep[] = []): Game {
|
||||
assert.equal(game.phase, 3); assert.ok(game.auction);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(game.pendingKarama ?? null, null);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.phaseOpening ?? null, null);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Original undealt Advanced Semuta initializer, actual BG Arrakeen setup,
|
||||
* native first Storm/Spice, and paid cache1 acquisition. Only unopened card/
|
||||
* Spice positions and the existing scalar lottery are selected; no played
|
||||
* hand, board counter, stance, wallet, clock or control is installed. */
|
||||
export function createSemutaAdvisorPreparationFixture() {
|
||||
const richese = 'richese', holder = 'choam', owner = 'beneGesserit', atreides = 'atreides', spender = 'emperor';
|
||||
let game = createGame('SEMUTAADVISORPREPARATION', newPlayer(richese, 'Richese', 'richese'), true, ['choam']);
|
||||
for (const faction of [holder, owner, atreides, spender] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaAdvisorPreparationStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaAdvisorPreparation(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorPreparation(game, until, actions); };
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
|
||||
entry: 'original-undealt-semuta-preview' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); 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 = semutaAdvisorPreparationInventory(game);
|
||||
assert.equal(originalInventory.length, 45); assert.equal(game.deck.length, 35); assert.equal(game.richeseCache!.length, 10);
|
||||
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 4);
|
||||
assert.equal(ordinary.length, 4);
|
||||
const selected = [...ordinary, printed], cost = printed.id;
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(semutaAdvisorPreparationPlayer(game, owner).forces['arrakeen:10'], 1);
|
||||
assert.equal(isAdvisor(semutaAdvisorPreparationPlayer(game, owner), 'arrakeen'), true);
|
||||
assert.equal(semutaAdvisorPreparationPlayer(game, atreides).forces['arrakeen:10'], 10);
|
||||
assert.ok(semutaAdvisorPreparationPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
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 < 2; position++) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); spiceOrder.push({ position, from, card: structuredClone(card) });
|
||||
}
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let beforeBid: Game | undefined, bid: SemutaAdvisorPreparationStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaAdvisorPreparationPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaAdvisorPreparation(game); assert.ok(step);
|
||||
if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
|
||||
}
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
assert.ok(beforeBid && bid && bidAt >= 0, 'CHOAM really buys the original cache Semuta for one spice');
|
||||
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||
assert.equal(semutaAdvisorPreparationPlayer(beforeBid, holder).spice - semutaAdvisorPreparationPlayer(game, holder).spice, 1);
|
||||
assert.equal(semutaAdvisorPreparationPlayer(game, richese).spice - semutaAdvisorPreparationPlayer(beforeBid, richese).spice, 1);
|
||||
reach(state => state.turn === 1 && state.phase === 3 && semutaAdvisorPreparationClean(state) && !!state.auction);
|
||||
assert.equal(game.advisorPreparation?.firstShipment, null);
|
||||
assert.deepEqual(game.advisorPreparation?.deniedTerritories, []);
|
||||
assert.ok(game.players.every(player => !player.shipped && player.moved === 0));
|
||||
const beforeDeclaration = structuredClone(game), nativeAction: Action = { type: 'prepareAdvisors', territory: 'arrakeen' };
|
||||
const preparation = quoteAdvisorPreparation(game, owner, 'arrakeen'), start = actions.length;
|
||||
act(owner, nativeAction);
|
||||
const source = structuredClone(game), declaration = { before: beforeDeclaration,
|
||||
action: nativeAction, steps: structuredClone(actions.slice(start)), after: source };
|
||||
assert.equal(game.response?.kind, 'advisorFlip'); assert.equal(game.response?.owner, owner);
|
||||
assert.equal(game.response?.advisorResume, 'preparation');
|
||||
assert.deepEqual(game.response?.advisorPreparationSource, preparation);
|
||||
assert.equal(game.response?.advisors, false); assert.equal(game.response?.location, 'arrakeen');
|
||||
assert.equal(isAdvisor(semutaAdvisorPreparationPlayer(game, owner), 'arrakeen'), true);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.phaseOpening ?? null, null);
|
||||
assert.equal(game.decision ?? null, null); assert.equal(game.pendingKarama ?? null, null);
|
||||
assert.ok(semutaAdvisorPreparationPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaAdvisorPreparationPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.deepEqual(semutaAdvisorPreparationInventory(game), originalInventory);
|
||||
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
return { game, source, beforeCost: source, advanced: true as const, kind: 'advisorFlip' as const,
|
||||
richese, holder, owner, bg: owner, atreides, spender, cost, semuta: SEMUTA_DRUG_ID, action, nativeAction,
|
||||
originalResponse: structuredClone(game.response!), preparation,
|
||||
originalPreparation: structuredClone(game.advisorPreparation!), originalHistory: structuredClone(game.log),
|
||||
territory: 'arrakeen', sector: 10, location: 'arrakeen:10',
|
||||
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, dealOrder, spiceOrder, originalSpice,
|
||||
beforeDeclaration, declaration, acquired, acquisition, originalInventory,
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original Advanced Semuta initializer on a ready, undealt Richese/CHOAM/BG/Atreides/Emperor lobby, with only CHOAM expansion and no optional modules.',
|
||||
'Select original unopened main-card positions so Emperor is dealt its actual printed Karama, and original unopened ordinary first-Spice positions. Physical inventory is unchanged.',
|
||||
'Actually predict, choose private Traitors, and place the native Advanced starting BG advisor in Arrakeen10 alongside the original Atreides10. First Storm, both Spice blows and all Charity/auction controls are real actions.',
|
||||
'CHOAM actually buys original cache Semuta for one spice. After its original responses reach a clean normal auction, BG declares whole-territory preparation before any shipment. No played hand, force, stance, wallet, clock or pending control is installed.',
|
||||
'Stop at the live native advisorFlip preparation response before the Emperor spends its original printed Karama; finish helpers leave the interrupted auction untouched.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaAdvisorPreparationFixture = ReturnType<typeof createSemutaAdvisorPreparationFixture>;
|
||||
@@ -0,0 +1,160 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { fighterCount, isAdvisor } from '../game/advisors';
|
||||
import { quoteAutomaticAdvisorFlips } from '../game/advisor-automatic-flip';
|
||||
import {
|
||||
createSemutaAdvisorAloneFixture as fixture, actSemutaAdvisorAlone as act,
|
||||
passSemutaAdvisorAlone as pass, claimSemutaAdvisorAlone as claim,
|
||||
allowSemutaAdvisorAlone as allow, finishSemutaAdvisorAlone as finish,
|
||||
advanceSemutaAdvisorAlone as advance, semutaAdvisorAloneClean as clean,
|
||||
semutaAdvisorAlonePlayer as player, semutaAdvisorAloneInventory as inventory,
|
||||
semutaAdvisorAlonePhysical as physical, type SemutaAdvisorAloneFixture,
|
||||
} from './fixture-semuta-advisor-alone';
|
||||
|
||||
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: SemutaAdvisorAloneFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.deepEqual(f.setup.expansions, ['choam']); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', 'beneGesserit', 'atreides', 'emperor']);
|
||||
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.ok(f.setupChoices.steps.some(step => step.actor === f.owner && step.action.type === 'predict'));
|
||||
assert.ok(f.setupChoices.steps.some(step => step.actor === f.owner && step.action.type === 'advisorSetup' &&
|
||||
step.action.territory === f.territory && step.action.sector === f.sector));
|
||||
assert.equal(player(f.afterSetup, f.mover).forces[f.from], 10);
|
||||
assert.equal(player(f.afterSetup, f.owner).forces[f.from], 1);
|
||||
assert.ok(player(f.afterSetup, f.owner).advisors?.[f.territory]);
|
||||
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
|
||||
assert.ok(f.movementOpening.steps.some(step => step.actor === f.owner && step.action.type === 'decision' && step.action.accept === false));
|
||||
assert.deepEqual(f.departure.steps, [{ actor: f.mover, action: f.moveAction }]);
|
||||
assert.equal(player(f.departure.before, f.mover).forces[f.from], 10);
|
||||
assert.equal(player(f.departure.before, f.mover).moved, 0);
|
||||
assert.equal(player(f.departure.before, f.mover).shipped, false);
|
||||
assert.equal(player(f.game, f.mover).forces[f.from] ?? 0, 0); assert.equal(player(f.game, f.mover).forces[f.to], 10);
|
||||
assert.equal(player(f.game, f.mover).moved, 1); assert.equal(player(f.game, f.mover).shipped, true);
|
||||
assert.equal(f.game.active, f.mover); assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 5);
|
||||
assert.equal(f.originalAutomaticSource.producer, 'groundMovement');
|
||||
assert.equal(f.originalAutomaticSource.mover, f.mover); assert.equal(f.originalAutomaticSource.move, 1);
|
||||
assert.equal(f.originalAutomaticSource.owner, f.owner); assert.equal(f.originalAutomaticSource.territory, f.territory);
|
||||
assert.deepEqual(f.originalAutomaticSource.forces, { [f.from]: 1 });
|
||||
assert.deepEqual(f.originalQueue, [f.originalAutomaticSource]);
|
||||
assert.equal(player(f.game, f.owner).advisors?.[f.territory]?.aloneFlipHold, f.originalAutomaticSource.event);
|
||||
assert.equal(isAdvisor(player(f.game, f.owner), f.territory), true);
|
||||
assert.equal(fighterCount(player(f.game, f.owner), f.territory), 0);
|
||||
assert.equal(f.game.response?.kind, 'advisorFlip'); assert.deepEqual(f.game.response, f.originalResponse);
|
||||
assert.equal(f.game.pendingTreacheryDiscard ?? null, null); assert.equal(f.game.decision ?? null, null);
|
||||
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function open(f: SemutaAdvisorAloneFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaAdvisorFlipDiscard');
|
||||
if (pending.continuation.kind !== 'karamaAdvisorFlipDiscard') throw Error('Use the original mandatory ground flip cost');
|
||||
assert.equal(pending.batch.cause, 'karama:advisorFlip'); assert.equal(pending.reaction?.stage, 'offer');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||
[{ card: f.cost, by: f.spender, face: true }]);
|
||||
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue);
|
||||
assert.deepEqual(player(game, f.owner).advisors, f.originalStance);
|
||||
assert.deepEqual(physical(game), f.originalPhysical);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function settled(state: Game, f: SemutaAdvisorAloneFixture, prevented: boolean) {
|
||||
const steps: { actor: string; action: Action }[] = [], game = finish(state, steps);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.response ?? null, null);
|
||||
assert.equal(game.pendingAutomaticAdvisorFlips, undefined); assert.equal(game.pendingArrivalReaction?.length ?? 0, 0);
|
||||
assert.deepEqual(physical(game), f.originalPhysical);
|
||||
assert.equal(game.active, f.originalMovement.active); assert.equal(player(game, f.mover).moved, f.originalMovement.moved);
|
||||
assert.equal(player(game, f.mover).shipped, f.originalMovement.shipped);
|
||||
assert.deepEqual(player(game, f.mover).forces, f.originalMovement.forces);
|
||||
assert.equal(isAdvisor(player(game, f.owner), f.territory), prevented);
|
||||
assert.equal(fighterCount(player(game, f.owner), f.territory), prevented ? 0 : 1);
|
||||
if (prevented) {
|
||||
assert.deepEqual(player(game, f.owner).advisors, f.originalStance);
|
||||
assert.equal(player(game, f.owner).advisors?.[f.territory]?.aloneFlipHold, f.originalAutomaticSource.event);
|
||||
} else assert.equal(player(game, f.owner).advisors?.[f.territory], undefined);
|
||||
assert.deepEqual(quoteAutomaticAdvisorFlips(game), [], 'The completed occurrence cannot reopen as another mandatory request');
|
||||
assert.ok(steps.every(step => !['move', 'ship', 'endMovement', 'card'].includes(step.action.type)));
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function canceled(state: Game, f: SemutaAdvisorAloneFixture, claimed: boolean) {
|
||||
const game = settled(state, f, true);
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
reject(game, f.spender, f.action); reject(game, f.mover, f.moveAction);
|
||||
return game;
|
||||
}
|
||||
function continueMovement(state: Game, f: SemutaAdvisorAloneFixture, prevented: boolean) {
|
||||
const steps: { actor: string; action: Action }[] = [];
|
||||
let game = act(state, f.mover, { type: 'endMovement' }, steps);
|
||||
game = advance(game, current => current.phase >= 6 && current.phase <= 7 && clean(current), steps);
|
||||
assert.equal(steps.filter(step => step.actor === f.mover && step.action.type === 'endMovement').length, 1);
|
||||
assert.ok(steps.every(step => !['move', 'ship', 'card', 'prepareAdvisors'].includes(step.action.type)));
|
||||
assert.equal(game.pendingAutomaticAdvisorFlips, undefined); assert.equal(game.response?.automaticAdvisorFlip, undefined);
|
||||
assert.equal(player(game, f.mover).forces[f.to], 10); assert.equal(player(game, f.mover).forces[f.from] ?? 0, 0);
|
||||
assert.equal(player(game, f.mover).moved, 1); assert.equal(player(game, f.owner).forces[f.from], 1);
|
||||
assert.equal(isAdvisor(player(game, f.owner), f.territory), prevented);
|
||||
if (prevented) assert.equal(player(game, f.owner).advisors?.[f.territory]?.aloneFlipHold, f.originalAutomaticSource.event);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
|
||||
for (const claimed of [false, true]) void test(
|
||||
`Advanced native ground-departure mandatory advisor conversion cost Semuta ${claimed ? 'claim' : 'pass'} preserves the real move and continuously-alone footprint hold`, () => {
|
||||
const f = fixture(); nativeSource(f); const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
const game = canceled(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event });
|
||||
continueMovement(game, f, true);
|
||||
},
|
||||
);
|
||||
|
||||
void test('native mandatory ground conversion allowance turns the actual BG advisor into a fighter without replaying Atreides departure', () => {
|
||||
const f = fixture(); nativeSource(f); const game = settled(allow(structuredClone(f.game)), f, false);
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
continueMovement(game, f, false);
|
||||
});
|
||||
|
||||
void test('fresh automatic ground-conversion cost rejects stale and underlying actions with the exact original queue still held', () => {
|
||||
const f = fixture(), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
|
||||
reject(game, f.owner, { type: 'passResponse' }); reject(game, f.mover, f.moveAction);
|
||||
reject(game, f.mover, { type: 'endMovement' }); reject(game, f.owner, { type: 'prepareAdvisors', territory: f.territory });
|
||||
reject(game, f.owner, { type: 'decision', accept: true });
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue);
|
||||
assert.deepEqual(player(game, f.owner).advisors, f.originalStance); assert.deepEqual(physical(game), f.originalPhysical);
|
||||
canceled(pass(game), f, false);
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally answer the real ground-departure mandatory-conversion used cost', () => {
|
||||
const f = fixture(), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty);
|
||||
assert.deepEqual(own, before); assert.ok(action.type === 'semutaPass' || action.type === 'semutaCommit');
|
||||
assert.equal(action.event, event);
|
||||
let game = act(structuredClone(offered), f.holder, action);
|
||||
const claimed = action.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const selected = botActions(selecting)[0]; assert.ok(selected);
|
||||
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
|
||||
game = act(game, f.holder, selected);
|
||||
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||
canceled(game, f, claimed);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
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 { advisorMatchingKey, validateAdvisorMatching } from '../game/advisor-matching';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import {
|
||||
createSemutaAdvisorMatchingFixture as fixture, actSemutaAdvisorMatching as act,
|
||||
passSemutaAdvisorMatching as pass, claimSemutaAdvisorMatching as claim,
|
||||
allowSemutaAdvisorMatching as allow, finishSemutaAdvisorMatching as finish,
|
||||
semutaAdvisorMatchingPlayer as player, semutaAdvisorMatchingInventory as inventory,
|
||||
semutaAdvisorMatchingPhysical as physical, type SemutaAdvisorMatchingFixture,
|
||||
} from './fixture-semuta-advisor-matching';
|
||||
|
||||
const directions = ['fightersToAdvisors', 'advisorsToFighters'] as const;
|
||||
const clock = (game: Game) => ({ turn: game.turn, phase: game.phase, active: game.active, ready: game.ready,
|
||||
order: game.order, movementRemaining: game.movementRemaining, storm: game.storm,
|
||||
spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard });
|
||||
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: SemutaAdvisorMatchingFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.equal(f.advisorSetup.steps[0].actor, f.owner);
|
||||
assert.deepEqual(f.advisorSetup.steps[0].action, { type: 'advisorSetup', territory: 'arrakeen', sector: 10 });
|
||||
assert.equal(player(f.afterSetup, f.owner).forces['arrakeen:10'], 1);
|
||||
assert.equal(isAdvisor(player(f.afterSetup, f.owner), 'arrakeen'), true);
|
||||
assert.equal(player(f.afterSetup, f.atreides).forces['arrakeen:10'], 10);
|
||||
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquired, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquired, f.seller).spice - player(f.acquisition.before, f.seller).spice, 1);
|
||||
assert.equal(f.declinedPreparation.before.decision?.kind, 'advisorBattle');
|
||||
assert.deepEqual(f.declinedPreparation.steps[0], { actor: f.owner, action: { type: 'decision', accept: false } });
|
||||
assert.equal(player(f.shipment.before, f.owner).spice - player(f.shipment.after, f.owner).spice, 2);
|
||||
assert.ok(f.shipment.steps.some(step => step.actor === f.owner && step.action.type === 'ship' && step.action.amount === 1));
|
||||
const bg = player(f.game, f.owner);
|
||||
assert.equal(bg.forces['arrakeen:10'], 1); assert.equal(bg.forces['imperial_basin:9'], 1);
|
||||
assert.equal(isAdvisor(bg, 'arrakeen'), true); assert.equal(isAdvisor(bg, 'imperial_basin'), false);
|
||||
assert.equal(bg.shipped, true); assert.equal(bg.moved, 0); assert.equal(bg.reserves, 18); assert.equal(bg.spice, 3);
|
||||
assert.equal(f.game.phase, 5); assert.equal(f.game.turn, 1); assert.equal(f.game.active, f.owner);
|
||||
assert.equal(f.game.response?.kind, 'advisorFlip'); assert.deepEqual(f.game.response, f.originalResponse);
|
||||
assert.deepEqual(f.game.pendingAdvisorMatching, f.originalPending);
|
||||
assert.equal(f.originalPending.intent.kind, 'movement'); assert.equal(f.originalPending.signature, JSON.stringify(f.originalPending.intent));
|
||||
assert.deepEqual(f.originalPending.receipt, f.receipt); assert.deepEqual(f.receipt.group, { [f.sourceLocation]: 1 });
|
||||
assert.equal(f.receipt.owner, f.owner); assert.equal(f.receipt.kind, 'movement'); assert.equal(f.receipt.move, 0);
|
||||
assert.equal(f.receipt.origin, f.origin); assert.equal(f.receipt.destination, f.destination);
|
||||
assert.equal(f.receipt.advisors, f.direction === 'fightersToAdvisors');
|
||||
assert.equal(f.denialKey, advisorMatchingKey(f.receipt)); validateAdvisorMatching(f.game, f.receipt);
|
||||
assert.deepEqual(physical(f.game), physical(f.beforeDeclaration));
|
||||
assert.deepEqual(f.declaration.steps, [{ actor: f.owner, action: f.nativeAction }]);
|
||||
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
assert.ok(player(f.game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function open(f: SemutaAdvisorMatchingFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaAdvisorFlipDiscard');
|
||||
if (pending.continuation.kind !== 'karamaAdvisorFlipDiscard') throw Error('Expected the native incoming matching cost');
|
||||
assert.equal(pending.batch.cause, 'karama:advisorFlip'); assert.equal(pending.reaction?.stage, 'offer');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||
[{ card: f.cost, by: f.spender, face: true }]);
|
||||
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||
assert.deepEqual(game.pendingAdvisorMatching, f.originalPending);
|
||||
assert.deepEqual(game.advisorMatchingDenied ?? [], f.originalDenied);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game));
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function denied(state: Game, f: SemutaAdvisorMatchingFixture, claimed: boolean) {
|
||||
const game = finish(state);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingAdvisorMatching ?? null, null);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game));
|
||||
assert.deepEqual(game.advisorMatchingDenied, [...f.originalDenied, f.denialKey]);
|
||||
assert.equal(player(game, f.owner).moved, 0); assert.equal(player(game, f.owner).shipped, true);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
reject(game, f.owner, f.nativeAction); reject(game, f.spender, f.action);
|
||||
const alternative = finish(act(structuredClone(game), f.replacementActor, f.replacementAction));
|
||||
assert.equal(alternative.pendingAdvisorMatching ?? null, null); assert.equal(alternative.response ?? null, null);
|
||||
assert.equal(alternative.phase, 5); assert.equal(alternative.active, f.owner);
|
||||
assert.equal(player(alternative, f.owner).forces['imperial_basin:9'] ?? 0, 0);
|
||||
assert.equal(player(alternative, f.owner).forces['imperial_basin:10'], 1);
|
||||
assert.equal(player(alternative, f.owner).forces['arrakeen:10'], 1);
|
||||
assert.equal(isAdvisor(player(alternative, f.owner), 'arrakeen'), true);
|
||||
assert.equal(isAdvisor(player(alternative, f.owner), 'imperial_basin'), false);
|
||||
assert.equal(player(alternative, f.owner).moved, 1); assert.equal(player(alternative, f.owner).shipped, true);
|
||||
assert.equal(player(alternative, f.owner).spice, 3); assert.equal(player(alternative, f.owner).reserves, 18);
|
||||
assert.deepEqual(alternative.advisorMatchingDenied, [...f.originalDenied, f.denialKey]);
|
||||
for (const p of game.players.filter(p => p.id !== f.owner)) {
|
||||
assert.equal(player(alternative, p.id).spice, p.spice); assert.deepEqual(player(alternative, p.id).forces, p.forces);
|
||||
assert.equal(player(alternative, p.id).reserves, p.reserves); assert.equal(player(alternative, p.id).tanks, p.tanks);
|
||||
}
|
||||
reject(alternative, f.owner, f.replacementAction); assert.deepEqual(inventory(alternative), f.originalInventory); return game;
|
||||
}
|
||||
|
||||
for (const direction of directions) for (const claimed of [false, true]) void test(
|
||||
`Advanced native ${direction} matching cost Semuta ${claimed ? 'claim' : 'pass'} preserves both groups and permits the real alternate move`, () => {
|
||||
const f = fixture({ direction }); nativeSource(f); const offered = open(f);
|
||||
denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
},
|
||||
);
|
||||
|
||||
void test('native matching allowance changes only the selected incoming group and commits the original ground move once', () => {
|
||||
for (const direction of directions) {
|
||||
const f = fixture({ direction }); nativeSource(f); const game = finish(allow(structuredClone(f.game)));
|
||||
assert.equal(game.pendingAdvisorMatching ?? null, null); assert.equal(game.response ?? null, null);
|
||||
assert.equal(game.phase, 5); assert.equal(game.active, f.owner);
|
||||
assert.equal(player(game, f.owner).forces[f.sourceLocation] ?? 0, 0);
|
||||
assert.equal(player(game, f.owner).forces[f.destinationLocation], 2);
|
||||
assert.equal(isAdvisor(player(game, f.owner), f.destination), f.receipt.advisors);
|
||||
assert.equal(player(game, f.owner).moved, 1); assert.equal(player(game, f.owner).shipped, true);
|
||||
assert.equal(player(game, f.owner).spice, 3); assert.equal(player(game, f.owner).reserves, 18);
|
||||
assert.equal(player(game, f.atreides).forces['arrakeen:10'], 10);
|
||||
assert.deepEqual(game.advisorMatchingDenied ?? [], f.originalDenied);
|
||||
for (const p of f.game.players.filter(p => p.id !== f.owner)) {
|
||||
assert.equal(player(game, p.id).spice, p.spice); assert.deepEqual(player(game, p.id).forces, p.forces);
|
||||
assert.equal(player(game, p.id).reserves, p.reserves); assert.equal(player(game, p.id).tanks, p.tanks);
|
||||
}
|
||||
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));
|
||||
reject(game, f.owner, f.nativeAction); assert.deepEqual(inventory(game), f.originalInventory);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native matching cost rejects stale foreign and underlying movement replies before original denial or transfer', () => {
|
||||
for (const direction of directions) {
|
||||
const f = fixture({ direction }), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
|
||||
reject(game, f.owner, f.nativeAction); reject(game, f.owner, f.replacementAction);
|
||||
reject(game, f.owner, { type: 'passResponse' }); reject(game, f.owner, { type: 'endMovement' });
|
||||
const done = denied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event });
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally claim or pass native incoming advisor-matching costs in both directions', () => {
|
||||
for (const direction of directions) {
|
||||
const f = fixture({ direction }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const proposed = botActions(own)[0]; assert.ok(proposed, 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
createSemutaAdvisorPreparationFixture as fixture, actSemutaAdvisorPreparation as act,
|
||||
passSemutaAdvisorPreparation as pass, claimSemutaAdvisorPreparation as claim,
|
||||
allowSemutaAdvisorPreparation as allow, finishSemutaAdvisorPreparation as finish,
|
||||
semutaAdvisorPreparationPlayer as player, semutaAdvisorPreparationInventory as inventory,
|
||||
type SemutaAdvisorPreparationFixture,
|
||||
} from './fixture-semuta-advisor-preparation';
|
||||
|
||||
const physical = (game: Game) => ({
|
||||
turn: game.turn, phase: game.phase, storm: game.storm, order: game.order, active: game.active, ready: game.ready,
|
||||
auction: game.auction, richeseAuction: game.richeseAuction, richeseBidding: game.richeseBidding,
|
||||
movementRemaining: game.movementRemaining, spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
|
||||
players: game.players.map(player => ({ id: player.id, spice: player.spice, reserves: player.reserves,
|
||||
tanks: player.tanks, forces: player.forces, elites: player.elites, leaders: player.leaders,
|
||||
shipped: player.shipped, moved: player.moved, revived: player.revived, ally: player.ally })),
|
||||
});
|
||||
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: SemutaAdvisorPreparationFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(player => player.faction), ['richese', 'choam', 'beneGesserit', 'atreides', 'emperor']);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'predict'));
|
||||
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'advisorSetup' &&
|
||||
step.action.territory === f.territory && step.action.sector === f.sector));
|
||||
assert.equal(player(f.afterSetup, f.owner).forces[f.location], 1);
|
||||
assert.equal(player(f.afterSetup, f.atreides).forces[f.location], 10);
|
||||
assert.equal(isAdvisor(player(f.afterSetup, f.owner), f.territory), true);
|
||||
assert.equal(f.acquisition.price, 1);
|
||||
assert.ok(f.acquisition.steps.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
|
||||
assert.equal(f.game.phase, 3); assert.equal(f.game.turn, 1); assert.ok(f.game.auction);
|
||||
assert.deepEqual(f.originalPreparation, { turn: 1, firstShipment: null, deniedTerritories: [] });
|
||||
assert.ok(f.game.players.every(player => !player.shipped && player.moved === 0));
|
||||
assert.deepEqual(f.declaration.steps, [{ actor: f.owner, action: f.nativeAction }]);
|
||||
assert.deepEqual(f.game.response, f.originalResponse);
|
||||
assert.equal(f.game.response?.advisorResume, 'preparation');
|
||||
assert.deepEqual(f.game.response?.advisorPreparationSource, f.preparation);
|
||||
assert.equal(isAdvisor(player(f.game, f.owner), f.territory), true);
|
||||
assert.equal(f.game.phaseOpening ?? null, null); assert.equal(f.game.decision ?? null, null);
|
||||
assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 45);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
function open(f: SemutaAdvisorPreparationFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaAdvisorFlipDiscard');
|
||||
assert.equal(pending.reaction?.stage, 'offer'); assert.equal(pending.batch.cause, 'karama:advisorFlip');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||
[{ card: f.cost, by: f.spender, face: true }]);
|
||||
if (pending.continuation.kind !== 'karamaAdvisorFlipDiscard') throw Error('Use the original native advisor-flip cost');
|
||||
assert.equal(pending.continuation.player, f.spender); assert.equal(pending.continuation.card, f.cost);
|
||||
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.deepEqual(game.advisorPreparation, f.originalPreparation);
|
||||
assert.equal(isAdvisor(player(game, f.owner), f.territory), true);
|
||||
assert.deepEqual(physical(game), physical(f.game));
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function prevented(game: Game, f: SemutaAdvisorPreparationFixture, claimed: boolean) {
|
||||
game = finish(game);
|
||||
assert.deepEqual(physical(game), physical(f.game));
|
||||
assert.equal(isAdvisor(player(game, f.owner), f.territory), true);
|
||||
assert.deepEqual(player(game, f.owner).advisors, player(f.game, f.owner).advisors);
|
||||
assert.deepEqual(game.advisorPreparation, { ...f.originalPreparation, deniedTerritories: [f.territory] });
|
||||
assert.equal(player(game, f.owner).advisorFlipMovementBlocked, undefined);
|
||||
assert.equal(game.log.filter(entry => entry.text.includes('advisor preparation in Arrakeen was prevented')).length,
|
||||
f.originalHistory.filter(entry => entry.text.includes('advisor preparation in Arrakeen was prevented')).length + 1);
|
||||
assert.ok(viewGame(game, f.owner).advisorPreparation?.blocked?.includes('Karama'));
|
||||
reject(game, f.owner, f.nativeAction);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
|
||||
for (const claimed of [false, true]) void test(
|
||||
`Advanced native advisor preparation used cancellation cost ${claimed ? 'claim' : 'pass'} denies only its territory and preserves the original auction`, () => {
|
||||
const f = fixture(); nativeSource(f);
|
||||
const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
const game = prevented(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event });
|
||||
const before = structuredClone(game); assert.equal(finish(game), game); assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('Advanced native advisor preparation allowance actually converts the original whole group without advancing its auction', () => {
|
||||
const f = fixture(); nativeSource(f);
|
||||
const game = allow(structuredClone(f.game));
|
||||
assert.deepEqual(physical(game), physical(f.game));
|
||||
assert.deepEqual(game.advisorPreparation, f.originalPreparation);
|
||||
assert.equal(isAdvisor(player(game, f.owner), f.territory), false);
|
||||
assert.equal(player(game, f.owner).forces[f.location], 1);
|
||||
assert.equal(player(game, f.atreides).forces[f.location], 10);
|
||||
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);
|
||||
});
|
||||
|
||||
void test('fresh advisor preparation costs reject stale and underlying controls while the original pre-shipment source stays parked', () => {
|
||||
const f = fixture(), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||
reject(game, f.spender, { type: 'semutaCommit', event });
|
||||
reject(game, f.spender, f.action); reject(game, f.owner, { type: 'passResponse' });
|
||||
reject(game, f.owner, f.nativeAction);
|
||||
assert.ok(game.auction);
|
||||
reject(game, game.auction.active!, { type: 'bid', amount: 0, allyPayment: 0 });
|
||||
assert.deepEqual(game.advisorPreparation, f.originalPreparation);
|
||||
assert.deepEqual(physical(game), physical(f.game));
|
||||
prevented(pass(game), f, false);
|
||||
});
|
||||
|
||||
void test('a changed advisor preparation source rejects before its physical Karama is spent', () => {
|
||||
const f = fixture();
|
||||
const game = structuredClone(f.game); assert.ok(game.response?.advisorPreparationSource);
|
||||
game.response.advisorPreparationSource.turn++;
|
||||
reject(game, f.spender, f.action);
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.equal(game.discard.some(card => card.id === f.cost), false);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
});
|
||||
|
||||
void test('all four minimal policies answer the actual Advanced advisor preparation cost legally', () => {
|
||||
const f = fixture(), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(offered, f.holder); own.players.find(player => player.id === f.holder)!.bot = difficulty;
|
||||
const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty);
|
||||
assert.deepEqual(own, before);
|
||||
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass'); assert.equal(action.event, event);
|
||||
let game = act(structuredClone(offered), f.holder, action);
|
||||
const claimed = action.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const choosing = viewGame(game, f.holder); choosing.players.find(player => player.id === f.holder)!.bot = difficulty;
|
||||
const selected = botActions(choosing)[0]; assert.ok(selected);
|
||||
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
|
||||
game = act(game, f.holder, selected);
|
||||
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||
prevented(game, f, claimed);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user