Offer Semuta on bonus No-Field and allied Traitor cancellation costs

This commit is contained in:
Quintine committed 2026-10-26 03:10:26 +11:00
1 parent 3a01c8f1c0
commit 12e3a448c6
11 files changed
+1659 -12

No files matched your search

+41
View File
@@ -16,6 +16,47 @@ open mode gates.
## Current checkpoint and work
**Semuta on bonus, No-Field and allied Traitor costs — 26 October. Prototyped / Partial.**
Printed-role Karama stopping Harkonnen's bonus card, Richese No-Field or the
Harkonnen spectator-ally Traitor call now offers its exact used cost. Three
source-specific continuations retain the paid acquisition, unpaid declaration
or revealed battle until claim/pass. Native validators and original wrappers
remain authoritative. Denial skips only the bonus, releases only the original
marker request, or changes only Harkonnen's effective vote; original tails and
actual outstanding choices finish once. Existing controls/minimal profiles
serve all three. Basic Ambassador grants retain their original live entries;
Advanced Ambassador admission is unchanged.
[Source and boundaries](SEMUTA_SOURCE_UPDATE_20260907.md#bonus-no-field-and-allied-traitor-cancellation-costs--26-october-2026).
Three disjoint source lanes, bounded native/diff review and root integration pass
**15 files,223/223**, types/lint/build and **26 native engine/GameTable SSR/actual
Semuta-callback journeys**, including replacement shipments and full allied
battle cleanup, on unchanged source. Thirty-five new cases cover thirteen native
sources, claim/pass, positive allowances, paid retention, zero/one bonus draws,
unused ordinary versus independent marker grants, early/last voter branches,
real held-Traitor outcomes, relevant rejection and four minimal profiles. Real
cache1 Semuta and original dealt Karama precede every cost. Bonus49/53 Basic/
Advanced normal, Black Market111 and Basic Ambassador88 keep the original paid
card; the Ambassador uses actual placements costing1+2, Ecaz-token alliance and
a separate Emperor entrant before Harkonnen buys3. No-Field own55/60, allied125/137
and Basic Ambassador158 retain private stock; real replacements cost2 ordinarily
or0 independently. Allied calls156/173 early and158/175 last use a real H/E Nexus
alliance, paid cohorts and Harkonnen's actual held CHOAM Traitor. Denial yields
the native CHOAM normal win; allowance yields Emperor's native Traitor win.
All45 cards remain physical; no played hand, force, wallet, alliance, identity,
clock, pending control or outcome is installed.
Feedback finishes each actual placement response before the next declaration,
corrects the Polar Sink shipment assertion to2, and adds only source-qualified
Ambassador cost admission to the discard invariant. Native cancellation legality
remains intact. Private source-bound report/logs:
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-benefits-proof/`;
journeys/HTML: adjacent `20261026-semuta-benefits-runtime/`.
No saved-room/D1/server/reset/restart/deployment/recovery operation occurs.
These are engine/SSR/callback results; hydration, DOM/browser/mobile, Advanced
Ambassador and wider parents/combinations, complete modes and deployed/final
assurance remain unfinished.
**Semuta on native combat-power cancellation costs — 26 October. Prototyped / Partial.**
Printed-role Karama stopping Voice, ordinary Prescience, KH, elite strength,
Fremen support or CHOAM battle aid now offers its exact used cost before
+41
View File
@@ -1,5 +1,46 @@
# Dune implementation status
## 26 October 2026 — Semuta claims bonus, No-Field and allied Traitor costs
**Prototyped / Partial.** Printed-role Karama stopping Harkonnen's bonus card,
Richese No-Field or the Harkonnen spectator-ally Traitor call offers its exact used
cost before native prevention. Three typed continuations retain original paid
acquisition, unpaid shipment or revealed battle/votes. Pure validation executes
no bonus draw, shipment or outcome. Claim/pass restores the response and finishes
native denial once; original suffixes and outstanding inputs remain native.
Exact Basic Ambassador wrappers retain entry/purchase/marker receipts without
ordinary-turn replay. Existing controls/minimal bots serve all three sources;
older signature formats and Advanced Ambassador admission remain unchanged.
[Source and limits](SEMUTA_SOURCE_UPDATE_20260907.md#bonus-no-field-and-allied-traitor-cancellation-costs--26-october-2026).
Three disjoint source lanes, bounded native/diff review and root integration pass
**15 files,223/223**, types/lint/build and **26 native engine/GameTable SSR/actual
Semuta-callback journeys**, including physical replacements and allied battle
completion, on unchanged source. Thirty-five new cases cover thirteen sources,
claim/pass, native allowance, paid retention, zero/one bonus draws, unpaid marker
stock and actual replacement, early/last voters, real held-Traitor outcomes,
relevant rejection and four minimal profiles. Real cache1 Semuta and original
dealt Karama supply every cost. Normal bonus49/53 Basic/Advanced, Black Market111
and Basic Ambassador88 retain original paid cards. Actual placements cost1+2,
Ecaz-token alliance and an independent Emperor entrant earn Harkonnen's paid3
Ambassador purchase. No-Field own55/60, allied125/137 and Basic Ambassador158 retain
stock before denial, then actually replace with physical1 for ordinary2 or free0.
Allied H/E calls156/173 early and158/175 last preserve paid cohorts, Nexus pact and
Harkonnen's real CHOAM Traitor: prevention gives the native CHOAM normal win,
allowance the Emperor Traitor win. All45 cards remain physical; no played hand,
force, wallet, alliance, identity, clock, pending control or outcome is installed.
Feedback finishes actual placement responses before the next declaration, fixes
the Polar Sink price assertion to2 and admits only exact source-qualified
Ambassador cost parents through the discard invariant. Native cancellation
legality remains intact. Private source-bound report/logs:
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-benefits-proof/`;
journeys/HTML: adjacent `20261026-semuta-benefits-runtime/`.
No saved-room/D1/server/reset/restart/deployment/recovery operation occurs.
These are engine/SSR/callback results; hydration, DOM/browser/mobile, Advanced
Ambassador and broader parents/combinations, complete modes and deployed/final
assurance remain open.
## 26 October 2026 — Semuta claims native combat-power cancellation costs
**Prototyped / Partial.** Printed-role Karama stopping native Voice, ordinary
+94
View File
@@ -2,6 +2,100 @@
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.
## Bonus, No-Field and allied Traitor cancellation costs — 26 October 2026
**Prototyped / Partial.** Three source-specific used-cost continuations now
offer printed-role Karama before native prevention: `karamaBonusDiscard` for
Harkonnen's bonus Treachery card, `karamaNoFieldDiscard` for Richese's marker
declaration and `karamaAlliedTraitorDiscard` for Harkonnen's spectator-ally call.
Existing persistent neutral controls, private claim/pass and minimal profiles
serve these sources. This applies the existing fresh-discard timing, not a new
publisher ruling or a change to the native benefits.
The ordinary Karama validator remains pure preflight, without supplying the
already spent cost as an unspent card. Native [auction continuation](../game/auction-continuation-quote.ts)
validates paid-card bonus sources; its Ambassador wrapper validates the original
used Richese token, paid card and purchase receipt instead of fabricating an
auction. Native [No-Field cancellation](../game/karama-no-field-cancellation.ts)
validates the unpaid declaration without rechecking an allowed shipment's price,
funding or capacity. Its independent Ambassador wrapper retains that separate
grant rather than imposing ordinary active-player/unused-shipment prerequisites.
The allied Traitor source retains the revealed battle, real held identity and
submitted calls. No bonus draw, shipment, vote reset or outcome is pre-applied.
After claim/pass, retire the fresh group, restore its response and finish native
prevention once. Bonus denial keeps the paid card, payment and seller/Emperor
income, skips the bonus and resumes the exact auction or entry suffix. Ordinary
own/allied No-Field denial keeps stock, private token/event and unused shipment,
then marks the original native restriction. Independent Guild Ambassador denial
instead marks that entry's marker request blocked and returns its chooser; its
ordinary incoming shipment was already spent, while the separate reserve grant
remains available. Neither source quotes, withdraws, pays or delivers again.
Allied Harkonnen denial changes only that effective call to false. Earlier
votes and revealed plans remain. If other voters remain, their actual inputs
precede the native final window; if all voted, that window opens directly.
Actual native quotes/settlement determine normal versus Traitor results and
physical cleanup once. The held Traitor is not discarded, transferred or made
a candidate: only the new physical Karama cost is fresh. A subtype-only snapshot
binds the original acquisition/entry/shipment/battle, native roles and physical
pools. Other receipt formats retain their bytes. Source-qualified Ambassador
admission changes no Advanced preview gate; current Ambassador sources are Basic.
Three disjoint source lanes, bounded native/diff review and root integration pass
**15 files,223/223**, types/lint/build and **26 native engine/GameTable SSR/actual
Semuta-callback journeys**, including actual replacement shipments and allied
battle cleanup, on unchanged source. Thirty-five new cases cover thirteen native
sources, claim/pass, positive allowance, exact paid acquisition/draw/entry/vote
suffixes, real held-Traitor outcomes, relevant rejection and four minimal profiles.
Real cache1 Semuta and original dealt Karama precede every cost; all45 original
main/cache cards remain physical. No played hand, force, wallet, alliance,
identity, clock, pending control or outcome is installed.
[Bonus sources](../tests/fixture-semuta-harkonnen-bonus.ts) and
[cases](../tests/semuta-harkonnen-bonus-engine.test.ts) use normal49/53 Basic/
Advanced source actions, Advanced Black Market111 or Basic Richese Ambassador88.
The normal card stays delivered; the Black Market seller retains its paid1 and
original declaration suffix; the independent Harkonnen beneficiary retains its
paid3 card while Emperor's original income is already completed. The Ambassador
source actually places Ecaz and Richese tokens for native prices1+2, forms the
printed Harkonnen/Ecaz alliance and uses a distinct non-ally Emperor entrant.
Denial draws0; native allowance draws exactly the original next card1.
[No-Field sources](../tests/fixture-semuta-no-field-cost.ts) and
[cases](../tests/semuta-no-field-cost-engine.test.ts) use own55/60 Basic/Advanced,
allied125/137 and Basic independent Ambassador158 source actions. Allied offers
use a real Nexus alliance and actual private token consent. The independent
source has actual lobby circles, paid Guild token placement, Nexus alliance,
Richese Polar Sink1 shipment for2 and Emperor Carthag1 entry for1. Its ordinary
Richese turn was already spent; the original free Guild grant remains separate.
Every denied source then makes a real physical1 replacement, costing2 ordinarily
or0 independently. Positive allowance actually deploys/reveals its native token,
deploys the allied five forces from reserves or keeps the independent marker hidden,
with original payments once and no ordinary-turn replay.
[Allied-call sources](../tests/fixture-semuta-allied-traitor.ts) and
[cases](../tests/semuta-allied-traitor-engine.test.ts) use actual Harkonnen/Emperor
Nexus alliance, paid opposing groups and CHOAM leader3 truly held among Harkonnen's
original four Traitors. Early156/173 Basic/Advanced actions leave two real voters;
last158/175 has their prior inputs already submitted. Claim/pass keeps the original
plans and votes until native denial, then completes only the remaining votes and
two actual final ready actions. Native settlement gives CHOAM the normal win;
allowance gives Emperor the Traitor win and original revelations. Physical
Traitor custody never changes, and it never becomes a Semuta candidate.
Feedback repairs source sequencing between real Ecaz placement responses and the
Polar Sink price assertion. The fresh-discard invariant gains only the two exact
Ambassador source qualifiers, with native entry/purchase/marker validation still
required. Generic saved-response walkers retain the original response through the
child; native cancellation legality is unchanged. Private source-bound report/logs:
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-benefits-proof/`;
journeys/HTML: adjacent `20261026-semuta-benefits-runtime/`.
No saved-room/database/server/reset/restart/deployment/recovery operation occurs.
Engine/SSR/callback evidence leaves hydration, DOM/browser/mobile, Advanced
Ambassador and wider parents/combinations, complete modes and exact-revision
deployed acceptance open.
## Native combat-power cancellation costs — 26 October 2026
**Prototyped / Partial.** Printed-role Karama stopping native Voice, ordinary
+84 -12
View File
@@ -1577,7 +1577,7 @@ export type Game = {
stateSignature: string;
}
| {
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard' | 'karamaDisasterDiscard' | 'karamaCombatDiscard';
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard' | 'karamaDisasterDiscard' | 'karamaCombatDiscard' | 'karamaBonusDiscard' | 'karamaNoFieldDiscard' | 'karamaAlliedTraitorDiscard';
player: string;
card: string;
use: Extract<KaramaUse, { kind: 'cancel' }>;
@@ -5983,9 +5983,35 @@ function semutaCombatKaramaResponse(g: Game, response: ResponseWindow, player: s
throw error;
}
}
/** These native benefits retain their paid acquisition, unpaid shipment or
* revealed-vote parent. Validation never executes the original suffix. */
function semutaBonusNoFieldTraitorResponse(g: Game, response: ResponseWindow, player: string): boolean {
const bonus = response.kind === 'harkonnenBonus', noField = response.kind === 'richeseNoField',
traitor = response.kind === 'harkonnenTraitor';
if ((!bonus && !noField && !traitor) || g.decision || g.phaseOpening || g.pendingKarama || g.truthtrance ||
g.pendingExchange || g.pendingNullentropy || g.pendingRicheseGift || g.pendingRichesePurchaseIncome ||
g.pendingRevival || g.pendingGuildTransport || g.pendingHomeworldShipment ||
g.pendingFremenMove || g.pendingIxMove || g.pendingMobileMove || g.pendingChoamMove ||
g.pendingSpiritualAdvisor || g.pendingTerrorEntry || g.pendingArrivalOverlap ||
g.pendingArrivalReaction?.length || g.ornithopter || g.summonedWorm || g.wormRides.length > 0 ||
g.choamMarket || g.pendingChoamMarketGhola) return false;
const ambassador = bonus && response.source === 'ambassador' ||
noField && response.source === 'ambassadorNoField';
if ((response.source !== undefined && !ambassador) || !!g.pendingAmbassador !== ambassador ||
(!!g.pendingShipment !== noField) || (!!g.battle !== traitor) ||
((!bonus || ambassador) && (g.auction || g.richeseAuction || g.currentAuctionSale))) return false;
const context: Game = {...g,pendingTreacheryDiscard:null,response};
try {
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' }
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard' | 'karamaDisasterDiscard' | 'karamaCombatDiscard' | 'karamaBonusDiscard' | 'karamaNoFieldDiscard' | 'karamaAlliedTraitorDiscard' }
>;
function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) {
const battle = c.kind === 'karamaBattleDiscard';
@@ -6002,6 +6028,8 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
const revival = c.kind === 'karamaRevivalDiscard';
const disaster = c.kind === 'karamaDisasterDiscard';
const combat = c.kind === 'karamaCombatDiscard';
const benefit = c.kind === 'karamaBonusDiscard' || c.kind === 'karamaNoFieldDiscard' ||
c.kind === 'karamaAlliedTraitorDiscard';
return JSON.stringify({
player: c.player, card: c.card, use: c.use,
parent: nullentropyParentSignature(g, c.resume,
@@ -6157,12 +6185,29 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
leaders:p.leaders, specialUsed:p.specialKaramaUsed, allySince:p.allySinceTurn,
advisors:p.advisors, noField:p.noField, noFieldEvent:p.noFieldEvent})),
} : undefined,
benefit: benefit ? {
shipment:g.pendingShipment, offer:g.richeseAllyOffer, opportunity:g.richeseAllyOpportunity,
allyBlocked:g.richeseAllyBlocked, active:g.active, ready:g.ready, remaining:g.movementRemaining,
auction:g.auction, sale:g.currentAuctionSale, ix:g.ixAuction, ixAlly:g.pendingIxAlly,
round:g.richeseBidding, lot:g.richeseAuction, cache:g.richeseCache, deck:g.deck,
ambassador:g.pendingAmbassador, arrivals:g.pendingArrivalReaction, overlap:g.pendingArrivalOverlap,
battle:g.battle, order:g.battleOrder, last:g.lastBattleContext, aid:g.aid,
traitorReserve:g.traitorReserve, nexus:g.nexusCards,
homeworlds:g.homeworlds, occupation:g.homeworldOccupationHistory,
occupiedBonus:g.pendingHomeworldOccupiedBonus,
skills:g.leaderSkills, duke:g.dukeVidal, tech:g.techTokens,
banker:g.spiceBankerIncome, bureaucrat:g.bureaucratPayments,
players:g.players.map(p => ({id:p.id, leaders:p.leaders, kwisatz:p.kwisatz,
losses:p.battleLosses, traitors:p.traitors, revealed:p.revealedTraitors,
noField:p.noField, noFieldEvent:p.noFieldEvent, noFieldBlocked:p.noFieldBlockedTurn,
advisors:p.advisors, specialUsed:p.specialKaramaUsed, allySince:p.allySinceTurn})),
} : 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 ? p.shipped : undefined,
physical: battle || worthless || income || shipment || conversion || terminal || movement || revival || disaster || combat
shipped: income || shipment || conversion || terminal || movement || disaster || combat || benefit ? p.shipped : undefined,
physical: battle || worthless || income || shipment || conversion || terminal || movement || revival || disaster || combat || benefit
? { forces: p.forces, reserves: p.reserves, tanks: p.tanks,
elites: p.elites, leaders: battle ? p.leaders : undefined,
noField: shipment ? p.noField : undefined,
@@ -6723,6 +6768,9 @@ function treacheryDiscardIntegrity(g: Game) {
const ambassadorTerror =
(continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics') &&
continuation.entry.cause === 'ambassador';
const ambassadorCost =
(continuation?.kind === 'karamaBonusDiscard' && continuation.use.response.source === 'ambassador') ||
(continuation?.kind === 'karamaNoFieldDiscard' && continuation.use.response.source === 'ambassadorNoField');
requireRule(
(g.status === 'playing' || (truth && g.status === 'setup')) &&
pending.sequence === sequence &&
@@ -6739,7 +6787,7 @@ function treacheryDiscardIntegrity(g: Game) {
(!g.pendingRicheseGift || box || truth || continuation?.kind === 'karamaRicheseGiftDiscard') &&
!g.pendingKarama &&
(!g.pendingNullentropy || continuation?.kind === 'karamaRicheseGiftDiscard') &&
(!g.pendingAmbassador || box || truth || ambassadorTerror || continuation?.kind === 'karamaRicheseGiftDiscard') &&
(!g.pendingAmbassador || box || truth || ambassadorTerror || ambassadorCost || continuation?.kind === 'karamaRicheseGiftDiscard') &&
(!g.pendingIxAlly || box || truth || continuation?.kind === 'karamaRicheseGiftDiscard'),
'The saved discard does not match its current continuation.',
);
@@ -7081,6 +7129,9 @@ function treacheryDiscardIntegrity(g: Game) {
continuation?.kind === 'karamaRevivalDiscard' ||
continuation?.kind === 'karamaDisasterDiscard' ||
continuation?.kind === 'karamaCombatDiscard' ||
continuation?.kind === 'karamaBonusDiscard' ||
continuation?.kind === 'karamaNoFieldDiscard' ||
continuation?.kind === 'karamaAlliedTraitorDiscard' ||
continuation?.kind === 'karamaShipmentDiscard' ||
continuation?.kind === 'karamaBgConversionCancelDiscard') {
const c = continuation, entry = batch.entries[0];
@@ -7121,6 +7172,10 @@ function treacheryDiscardIntegrity(g: Game) {
semutaDisasterKaramaResponse(g,c.use.response,c.player)) ||
(c.kind === 'karamaCombatDiscard' &&
semutaCombatKaramaResponse(g,c.use.response,c.player,c.resume.decision)) ||
(((c.kind === 'karamaBonusDiscard' && c.use.response.kind === 'harkonnenBonus') ||
(c.kind === 'karamaNoFieldDiscard' && c.use.response.kind === 'richeseNoField') ||
(c.kind === 'karamaAlliedTraitorDiscard' && c.use.response.kind === 'harkonnenTraitor')) &&
semutaBonusNoFieldTraitorResponse(g,c.use.response,c.player)) ||
(c.kind === 'karamaShipmentDiscard' &&
semutaGuildShipmentKaramaResponse(g, c.use.response)) ||
(c.kind === 'karamaBgConversionCancelDiscard' &&
@@ -8029,6 +8084,12 @@ function semutaOfferSupported(
const revivalDiscard = continuation.kind === 'karamaRevivalDiscard';
const disasterDiscard = continuation.kind === 'karamaDisasterDiscard';
const combatDiscard = continuation.kind === 'karamaCombatDiscard';
const bonusDiscard = continuation.kind === 'karamaBonusDiscard';
const noFieldDiscard = continuation.kind === 'karamaNoFieldDiscard';
const alliedTraitorDiscard = continuation.kind === 'karamaAlliedTraitorDiscard';
const benefitDiscard = bonusDiscard || noFieldDiscard || alliedTraitorDiscard;
const ambassadorBenefitDiscard = (bonusDiscard && continuation.use.response.source === 'ambassador') ||
(noFieldDiscard && continuation.use.response.source === 'ambassadorNoField');
const bgDiscard = continuation.kind === 'bgKaramaCostDiscard' ||
continuation.kind === 'karamaBgConversionCancelDiscard';
const ambassadorDiscard = continuation.kind === 'ambassador';
@@ -8067,7 +8128,7 @@ function semutaOfferSupported(
continuation.kind !== 'karamaChoamWorthlessDiscard' &&
continuation.kind !== 'karamaEmperorShareDiscard' &&
continuation.kind !== 'karamaIncomeDiscard' &&
!richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && !disasterDiscard && !combatDiscard &&
!richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && !disasterDiscard && !combatDiscard && !benefitDiscard &&
continuation.kind !== 'karamaShipmentDiscard' &&
continuation.kind !== 'karamaShipmentPriceDiscard' &&
continuation.kind !== 'specialKaramaDiscard' &&
@@ -8104,7 +8165,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 && continuation.kind !== 'kaitainDiscard' &&
if (g.homeworlds && !bgDiscard && !richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && !disasterDiscard && !combatDiscard && !benefitDiscard && continuation.kind !== 'kaitainDiscard' &&
continuation.kind !== 'tupileCleanupDiscard' &&
continuation.kind !== 'karamaIncomeDiscard' &&
continuation.kind !== 'karamaShipmentDiscard' &&
@@ -8213,6 +8274,7 @@ function semutaOfferSupported(
revivalDiscard ||
disasterDiscard ||
combatDiscard ||
benefitDiscard ||
continuation.kind === 'karamaShipmentDiscard'
? (continuation.kind === 'karamaBattleDiscard'
? semutaBattleKaramaResponse(g, continuation.use.response)
@@ -8236,6 +8298,8 @@ function semutaOfferSupported(
? semutaDisasterKaramaResponse(g,continuation.use.response,continuation.player)
: combatDiscard
? semutaCombatKaramaResponse(g,continuation.use.response,continuation.player,continuation.resume.decision)
: benefitDiscard
? semutaBonusNoFieldTraitorResponse(g,continuation.use.response,continuation.player)
: continuation.kind === 'karamaShipmentDiscard'
? semutaGuildShipmentKaramaResponse(g, continuation.use.response)
: ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) &&
@@ -8276,6 +8340,7 @@ function semutaOfferSupported(
revivalDiscard ||
disasterDiscard ||
combatDiscard ||
benefitDiscard ||
continuation.kind === 'karamaShipmentDiscard' ||
((continuation.kind === 'ordinaryCardDiscard'
? ordinaryDiscardIncomeMatches(g, continuation)
@@ -8288,7 +8353,7 @@ function semutaOfferSupported(
: !continuation.resume.phaseOpening)))) &&
(richeseGiftDiscard || continuation.kind === 'karamaShipmentDiscard' ||
continuation.kind === 'specialKaramaDiscard' || bgDiscard || !g.karamaShipping) &&
(auctionAdvantageDiscard || richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || (continuation.kind === 'ixAllyCard'
(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
@@ -8302,11 +8367,11 @@ function semutaOfferSupported(
? !!g.currentAuctionSale && (g.currentAuctionSale.origin === 'normal'
? !!g.auction && !g.richeseAuction : !!g.richeseAuction && !g.auction)
: (continuation.kind === 'saphoAuctionDiscard' && continuation.cyclic === 'normal') || !g.auction)) &&
(auctionAdvantageDiscard || richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' ||
(bonusDiscard || auctionAdvantageDiscard || richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' ||
continuation.kind === 'ixAllyCard' ||
(continuation.kind === 'karamaIncomeDiscard' &&
continuation.use.response.kind === 'emperorIncome') || !g.richeseAuction) &&
(richeseGiftDiscard || combatDiscard || continuation.kind === 'saphoAggressorDiscard' ||
(richeseGiftDiscard || combatDiscard || alliedTraitorDiscard || continuation.kind === 'saphoAggressorDiscard' ||
bgDiscard ||
continuation.kind === 'specialKaramaDiscard' ||
continuation.kind === 'residualPoisonDiscard' ||
@@ -8329,9 +8394,9 @@ function semutaOfferSupported(
(!!g.stormResolution && !g.pendingChoamMove &&
!g.pendingArrivalOverlap && !g.pendingRevival &&
!g.ornithopter && !g.ecologicalStorm)) &&
(richeseGiftDiscard || (!g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy &&
(richeseGiftDiscard || ambassadorBenefitDiscard || (!g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy &&
!g.pendingAmbassador && !g.pendingTerrorEntry)) &&
(richeseGiftDiscard || continuation.kind === 'karamaShipmentDiscard' ||
(richeseGiftDiscard || noFieldDiscard || continuation.kind === 'karamaShipmentDiscard' ||
continuation.kind === 'specialKaramaDiscard' ||
bgDiscard ||
(!g.pendingShipment && !g.pendingHomeworldShipment)) &&
@@ -8637,6 +8702,9 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
next.kind === 'karamaRevivalDiscard' ||
next.kind === 'karamaDisasterDiscard' ||
next.kind === 'karamaCombatDiscard' ||
next.kind === 'karamaBonusDiscard' ||
next.kind === 'karamaNoFieldDiscard' ||
next.kind === 'karamaAlliedTraitorDiscard' ||
next.kind === 'karamaShipmentDiscard' ||
next.kind === 'karamaBgConversionCancelDiscard' ||
next.kind === 'karamaPurchaseDiscard' ||
@@ -15996,6 +16064,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
semutaRevivalKaramaResponse(g,use.response,p.id) ||
semutaDisasterKaramaResponse(g,use.response,p.id) ||
semutaCombatKaramaResponse(g,use.response,p.id) ||
semutaBonusNoFieldTraitorResponse(g,use.response,p.id) ||
guildShipmentDiscard || bgConversionCancelDiscard)) {
const continuation: KaramaResponseDiscardContinuation = {
kind: bgConversionCancelDiscard ? 'karamaBgConversionCancelDiscard'
@@ -16008,6 +16077,9 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
: ['choamRevival','revivalLimit','revivalDiscount','earlyRevival','foreignGhola'].includes(use.response.kind) ? 'karamaRevivalDiscard'
: ['stormProtection','wormPlacement','wormAllyProtection','wormSurvival'].includes(use.response.kind) ? 'karamaDisasterDiscard'
: isCombatResponseKind(use.response.kind) ? 'karamaCombatDiscard'
: use.response.kind === 'harkonnenBonus' ? 'karamaBonusDiscard'
: use.response.kind === 'richeseNoField' ? 'karamaNoFieldDiscard'
: use.response.kind === 'harkonnenTraitor' ? 'karamaAlliedTraitorDiscard'
: use.response.kind === 'choamCharity' ? 'karamaCharityDiscard'
: use.response.kind === 'choamInflation'
? 'karamaInflationDiscard'
+7
View File
@@ -1294,6 +1294,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Printed-role Karama stopping a clean native CHOAM revival benefit, Tleilaxu granted force limit or discount, early leader revival or Advanced foreign Ghola also offers its exact used cost. The original request stays unpaid and its counters or leader stay in the Tanks through the offer. Claim or Continue finishes the original prevention once: native rules may abandon the request, reprice it, open its remaining response or complete an affordable return. Unfunded repricing returns nothing and consumes no allowance. Any income follows the actual return once; taking Karama cannot repeat the request. Other unfinished parents remain separate.',
'Printed-role Karama stopping native Fremen Storm protection, worm survival, allied worm protection or Advanced additional-worm placement offers its exact used cost before prevention. Claim or Continue finishes that cancellation once. Storm entry keeps its already-deposited cohort and queued arrival through the offer; denial then kills that original group without shipping again. Storm traversal, devouring, later survival responses, Spice draws and rides remain native. A summoned worm retains its own saved parent until native completion. Other unfinished parents retain their separate boundaries.',
'Printed-role Karama stopping native Voice, ordinary Prescience, Kwisatz Haderach, elite strength, Fremen support or CHOAM battle aid offers its exact used cost while the original battle waits. Claim or Continue finishes that prevention once, then native preparation or the next power check resumes. The offer does not answer Prescience, seal or reveal plans, charge support, spend a reserved pledge or change the forces. Taking Karama does not restore the canceled benefit. The separate live Nexus borrowed-Voice parent retains its existing cancellation path; wider combinations remain unfinished.',
'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.',
'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.',
@@ -1447,6 +1448,12 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/semuta-combat-forces-engine.test.ts',
'tests/fixture-semuta-combat-capabilities.ts',
'tests/semuta-combat-capabilities-engine.test.ts',
'tests/fixture-semuta-harkonnen-bonus.ts',
'tests/semuta-harkonnen-bonus-engine.test.ts',
'tests/fixture-semuta-no-field-cost.ts',
'tests/semuta-no-field-cost-engine.test.ts',
'tests/fixture-semuta-allied-traitor.ts',
'tests/semuta-allied-traitor-engine.test.ts',
'tests/fixture-semuta-spiritual-foresight.ts',
'tests/semuta-spiritual-foresight-engine.test.ts',
'tests/fixture-semuta-storm-dancer.ts',
+215
View File
@@ -0,0 +1,215 @@
import assert from 'node:assert/strict';
import {
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game,
} from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
import {
actSemutaCombatCapabilities, nextSemutaCombatCapabilities,
semutaCombatCapabilitiesPlayer, semutaCombatCapabilitiesInventory, semutaCombatCapabilitiesClean,
passSemutaCombatCapabilities, claimSemutaCombatCapabilities,
} from './fixture-semuta-combat-capabilities';
export type SemutaAlliedTraitorStep = { actor: string; action: Action };
export const actSemutaAlliedTraitor = actSemutaCombatCapabilities;
export const semutaAlliedTraitorPlayer = semutaCombatCapabilitiesPlayer;
export const semutaAlliedTraitorInventory = semutaCombatCapabilitiesInventory;
export const semutaAlliedTraitorClean = semutaCombatCapabilitiesClean;
export const passSemutaAlliedTraitor = passSemutaCombatCapabilities;
export const claimSemutaAlliedTraitor = claimSemutaCombatCapabilities;
/** Native controls and explicit declines only. Sealing plans and the allied
* positive Traitor declaration remain caller-owned source acts. */
export function nextSemutaAlliedTraitor(game: Game): SemutaAlliedTraitorStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
const fremen = game.players.find(p => p.faction === 'fremen');
if (game.status === 'setup' && game.setupStage === 'forces' && fremen?.reserves === 20 &&
viewGame(game, fremen.id).setupPending.includes(fremen.id))
return { actor: fremen.id, action: { type: 'fremenSetup', placements: { 'sietch_tabr:14': 10 } } };
const decision = game.decision;
if (decision?.kind === 'wormRide' || decision?.kind === 'wormPlacement')
return { actor: decision.player, action: { type: 'decision', accept: false } };
if (decision?.kind === 'wormProtection')
return { actor: decision.player, action: { type: 'decision', accept: true } };
if (decision?.kind === 'choamBattleFunding')
return { actor: decision.player, action: { type: 'decision', amount: 0 } };
}
return nextSemutaCombatCapabilities(game);
}
export const nextSemutaAlliedTraitorStep = nextSemutaAlliedTraitor;
export function advanceSemutaAlliedTraitor(game: Game, until: (state: Game) => boolean,
actions: SemutaAlliedTraitorStep[] = []): Game {
for (let count = 0; count < 2600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextSemutaAlliedTraitor(game);
assert.ok(next, `Supply the actual owned allied-Traitor control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actSemutaAlliedTraitor(game, next.actor, next.action, actions);
}
throw Error('The native allied-Traitor programme did not reach its finite boundary');
}
export function allowSemutaAlliedTraitor(game: Game, actions: SemutaAlliedTraitorStep[] = []): Game {
assert.equal(game.response?.kind, 'harkonnenTraitor');
for (let count = 0; game.response?.kind === 'harkonnenTraitor' && count < game.players.length; count++) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
game = actSemutaAlliedTraitor(game, actor.id, { type: 'passResponse' }, actions);
}
assert.notEqual(game.response?.kind, 'harkonnenTraitor'); return game;
}
/** Finish only this original battle: missing voters, the shared final window
* and actual cleanup decisions. No new battle, return or card play is made. */
export function finishSemutaAlliedTraitor(game: Game, actions: SemutaAlliedTraitorStep[] = []): Game {
assert.ok(game.battle && !game.pendingTreacheryDiscard);
return advanceSemutaAlliedTraitor(game, state => !state.battle &&
!state.pendingTreacheryDiscard && !state.response && !state.decision, actions);
}
/** Original R/C/H/E/F setup and paid cache1. A real turn2 worm earns the
* Harkonnen/Emperor alliance; both combat cohorts actually ship. The opposing
* leader is selected from Harkonnen's original four physical Traitor cards. */
export function createSemutaAlliedTraitorFixture(options: { advanced?: boolean; last?: boolean } = {}) {
const advanced = options.advanced ?? false, last = options.last ?? false;
const richese = 'richese', holder = 'choam', owner = 'harkonnen', beneficiary = 'emperor', spender = beneficiary;
const fremen = 'fremen', territory = 'the_great_flat', sector = 15, location = `${territory}:${sector}`;
let game = createGame('SEMUTAALLIEDTRAITOR', newPlayer(richese, 'Richese', 'richese'), advanced, ['choam']);
for (const faction of ['choam', 'harkonnen', 'emperor', 'fremen'] as const)
joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: SemutaAlliedTraitorStep[] = [];
const act = (actor: string, action: Action) => { game = actSemutaAlliedTraitor(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAlliedTraitor(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);
if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
else act(game.host, { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.semutaPreview, true);
assert.ok(game.players.every(p => p.hand.length === 0));
const originalInventory = semutaAlliedTraitorInventory(game); assert.equal(originalInventory.length, 45);
const costCard = game.deck.find(card => card.effect === 'karama'); assert.ok(costCard);
const ordinary = game.deck.filter(card => card.kind === 'projectile' || card.kind === 'poison').slice(0, 5);
assert.equal(ordinary.length, 5);
const selected = [ordinary[0], ordinary[1], ordinary[2], ordinary[3], costCard, ordinary[4]];
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 = costCard.id;
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
assert.ok(semutaAlliedTraitorPlayer(game, spender).hand.some(card => card.id === cost));
assert.equal(semutaAlliedTraitorPlayer(game, owner).traitors.length, 4);
const candidates = game.players.filter(p => ![owner, beneficiary].includes(p.id)).flatMap(p =>
p.leaders.filter(leader => semutaAlliedTraitorPlayer(game, owner).traitors.includes(leader.id))
.map(leader => ({ player: p.id, leader }))).sort((a, b) => b.leader.strength - a.leader.strength);
const target = candidates[0]; assert.ok(target, 'Use an opposing leader Harkonnen actually received as a Traitor');
const opponent = target.player, traitorId = target.leader.id;
const beneficiaryLeader = semutaAlliedTraitorPlayer(game, beneficiary).leaders
.toSorted((a, b) => a.strength - b.strength)[0]; assert.ok(beneficiaryLeader);
assert.ok(target.leader.strength > beneficiaryLeader.strength,
'Without the allied Traitor, the actual unarmed opposing plan wins normally');
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder: { turn: number; position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
const orderSpice = (faces: string[]) => {
for (let position = 0; position < faces.length; position++) {
const face = faces[position], from = game.spiceDeck.findIndex((card, index) => index >= position &&
(face === 'worm' ? 'worm' in card && !card.suppressed && !card.thumper && !card.greatMaker
: 'territory' in card && card.territory === face && !card.discovery));
assert.ok(from >= position, `Choose an original unopened ${face} Spice Card`);
const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
spiceOrder.push({ turn: game.turn, position, from, card: structuredClone(card) });
}
};
orderSpice(['funeral_plain', 'hagga_basin']);
let beforeBid: Game | undefined, bid: SemutaAlliedTraitorStep | undefined, bidAt = -1;
for (let count = 0; count < 1000 && !semutaAlliedTraitorPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
const next = nextSemutaAlliedTraitor(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(semutaAlliedTraitorPlayer(beforeBid, holder).spice - semutaAlliedTraitorPlayer(game, holder).spice, 1);
reach(state => state.turn === 2 && state.phase === 1 && semutaAlliedTraitorClean(state) &&
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0);
orderSpice(['worm', 'the_great_flat', 'broken_land']);
reach(state => state.turn === 2 && state.phase === 1 && state.nexus && !state.spiceWindow &&
!state.spiceResolution && semutaAlliedTraitorClean(state));
const alliance = transition(() => {
act(owner, { type: 'alliance', target: beneficiary }); act(beneficiary, { type: 'alliance', target: owner });
});
assert.equal(semutaAlliedTraitorPlayer(game, owner).ally, beneficiary);
assert.equal(semutaAlliedTraitorPlayer(game, beneficiary).ally, owner);
reach(state => state.turn === 2 && state.phase === 5 && semutaAlliedTraitorClean(state));
const shipments: { actor: string; cost: number; before: Game; steps: SemutaAlliedTraitorStep[]; after: Game }[] = [];
for (let count = 0; game.turn === 2 && game.phase === 5 && count < 200; count++) {
const actor = game.active;
if (semutaAlliedTraitorClean(game) && actor && [beneficiary, opponent].includes(actor) &&
!semutaAlliedTraitorPlayer(game, actor).shipped) {
const shipment = transition(() => {
act(actor, { type: 'ship', territory, sector, amount: 1, elite: 0, allyPayment: 0 });
reach(semutaAlliedTraitorClean);
});
const paid = actor === fremen ? 0 : 2;
assert.equal(semutaAlliedTraitorPlayer(shipment.before, actor).spice - semutaAlliedTraitorPlayer(game, actor).spice, paid);
shipments.push({ actor, cost: paid, ...shipment });
} else {
const next = nextSemutaAlliedTraitor(game); assert.ok(next); act(next.actor, next.action);
}
}
assert.equal(shipments.length, 2);
assert.equal(semutaAlliedTraitorPlayer(game, beneficiary).forces[location], 1);
assert.equal(semutaAlliedTraitorPlayer(game, opponent).forces[location], 1);
reach(state => state.phase === 6 && semutaAlliedTraitorClean(state) && !!state.active);
const beforeBattle = structuredClone(game), chooser = game.active!;
const choice = viewGame(game, chooser).battleChoices.find(row => row.territory === territory &&
[row.attacker, row.defender].includes(beneficiary) && [row.attacker, row.defender].includes(opponent));
assert.ok(choice);
const battleAction: Action = { type: 'chooseBattle', territory,
target: choice.attacker === chooser ? choice.defender : choice.attacker };
act(chooser, battleAction);
reach(state => !!state.battle && semutaAlliedTraitorClean(state) && !state.battle.preparation &&
state.battle.preLeader?.closed !== false);
assert.ok(game.battle && !game.battle.revealed && ![game.battle.attacker, game.battle.defender].includes(owner));
const plans: Record<string, Action> = {
[beneficiary]: { type: 'battlePlan', leader: beneficiaryLeader.id, dial: 0, support: 0, weapon: null, defense: null },
[opponent]: { type: 'battlePlan', leader: traitorId, dial: 0, support: 0, weapon: null, defense: null },
};
const reveal = transition(() => {
for (const actor of [game.battle!.attacker, game.battle!.defender]) act(actor, plans[actor]);
reach(state => !!state.battle?.revealed && semutaAlliedTraitorClean(state));
});
const voters = viewGame(game, owner).battle!.traitorVoters;
assert.ok(voters.includes(owner)); assert.equal(voters.length, 3);
const votes = transition(() => {
if (last) for (const voter of voters.filter(id => id !== owner)) act(voter, { type: 'traitorCall', call: false });
act(owner, { type: 'traitorCall', call: true });
});
assert.ok(game.response?.kind === 'harkonnenTraitor'); assert.equal(game.response.owner, owner);
assert.equal(game.battle?.traitorCalls[owner], true); assert.ok(!game.battle?.outcomeWindow);
assert.ok(semutaAlliedTraitorPlayer(game, spender).hand.some(card => card.id === cost));
assert.ok(semutaAlliedTraitorPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
assert.deepEqual(semutaAlliedTraitorInventory(game), originalInventory);
return { game, kind: 'harkonnenTraitor' as const, advanced, last, holder, spender, owner, beneficiary, opponent,
combatant: beneficiary, richese, fremen, cost, karama: cost, semuta: SEMUTA_DRUG_ID, traitorId,
action: { type: 'card', card: cost, mode: 'cancel' } as Action,
originalBattle: structuredClone(game.battle!), originalResponse: structuredClone(game.response!),
plans, territory, sector, location, voters, initial, lobby, setup, beforeStartingDeal, afterSetup,
dealOrder, deal: selected.map(card => card.id), originalInventory, originalSpice, spiceOrder,
acquisition, acquired, cache: acquisition, alliance, shipments, beforeBattle, chooser, battleAction, reveal, votes,
actions, steps: actions, sourceActionCount: actions.length,
staging: [
'Original R/C/H/E/F undealt setup, unopened deals/Spice and scoped native scalar lotteries only. Emperor receives the printed Karama; Harkonnen retains its four original Traitors; CHOAM pays actual cache1 for Semuta.',
'Original turn1 movement is unused. A real turn2 worm at the empty Funeral pile opens Nexus, where Harkonnen and Emperor genuinely propose/accept alliance. Both competing normal cohorts actually ship to Great Flat15.',
'The opposing leader is selected from Harkonnen’s actual dealt identities. Real shared preparation and unarmed plans reveal before Harkonnen’s actual spectator-ally Traitor declaration. Early/last variants preserve which other voters truly answered.',
'No played card, force, wallet, clock, ally, Traitor, plan, vote, battle or pending control is installed. Stop before Emperor spends the actual cancellation cost.',
] };
}
export type SemutaAlliedTraitorFixture = ReturnType<typeof createSemutaAlliedTraitorFixture>;
+289
View File
@@ -0,0 +1,289 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game,
} from '../game/engine';
import { AMBASSADOR_EFFECTS } from '../game/ecaz-ambassadors';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
import {
actSemutaRicheseCacheKarama, nextSemutaRicheseCacheKarama,
semutaRicheseCacheKaramaClean, semutaRicheseCacheKaramaInventory, semutaRicheseCacheKaramaPlayer,
} from './fixture-semuta-richese-cache-karama';
import { passSemutaFremenGuildMovement, claimSemutaFremenGuildMovement } from './fixture-semuta-fremen-guild-movement';
export type SemutaHarkonnenBonusKind = 'normal' | 'blackMarket' | 'ambassador';
export type SemutaHarkonnenBonusStep = { actor: string; action: Action };
export const actSemutaHarkonnenBonus = actSemutaRicheseCacheKarama;
export const passSemutaHarkonnenBonus = passSemutaFremenGuildMovement;
export const claimSemutaHarkonnenBonus = claimSemutaFremenGuildMovement;
export const semutaHarkonnenBonusPlayer = semutaRicheseCacheKaramaPlayer;
export const semutaHarkonnenBonusInventory = semutaRicheseCacheKaramaInventory;
export const semutaHarkonnenBonusClean = semutaRicheseCacheKaramaClean;
/** Bonus/entry choices stay explicit; normal setup and no-spending native
* controls retain their existing owners and priorities. */
export function nextSemutaHarkonnenBonus(game: Game): SemutaHarkonnenBonusStep | null {
if (game.pendingTreacheryDiscard || game.truthtrance || game.response?.kind === 'harkonnenBonus') return null;
if (!game.response && !game.phaseOpening && game.decision?.kind === 'ecazAmbassador') return null;
const step = nextSemutaRicheseCacheKarama(game);
if (step?.action.type === 'stormDial' && game.turn === 1)
return { actor: step.actor, action: { ...step.action, amount: 0 } };
return step;
}
export function advanceSemutaHarkonnenBonus(game: Game, until: (state: Game) => boolean,
actions: SemutaHarkonnenBonusStep[] = []): Game {
for (let count = 0; count < 2000; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextSemutaHarkonnenBonus(game); assert.ok(step, 'Choose the actual paid bonus, Ambassador or fresh-cost control');
game = actSemutaHarkonnenBonus(game, step.actor, step.action, actions);
}
throw Error('The genuine Harkonnen bonus source did not reach its finite boundary');
}
/** Allow the original bonus and its one native suffix, never buy another card. */
export function allowSemutaHarkonnenBonus(game: Game, actions: SemutaHarkonnenBonusStep[] = []): Game {
assert.equal(game.response?.kind, 'harkonnenBonus');
for (let count = 0; count < game.players.length && game.response?.kind === 'harkonnenBonus'; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actSemutaHarkonnenBonus(game, actor.id, { type: 'passResponse' }, actions);
}
assert.notEqual(game.response?.kind, 'harkonnenBonus'); return game;
}
/** The native bonus callback already retires its sale/entry. Preserve the next
* normal lot, compulsory Richese declaration or entrant's unfinished turn. */
export function finishSemutaHarkonnenBonus(game: Game, kind: SemutaHarkonnenBonusKind,
_actions: SemutaHarkonnenBonusStep[] = []): Game {
void _actions; // This boundary deliberately submits no additional action.
assert.equal(game.pendingTreacheryDiscard ?? null, null);
assert.notEqual(game.response?.kind, 'harkonnenBonus');
assert.equal(game.currentAuctionSale ?? null, null);
if (kind === 'normal') {
assert.equal(game.phase, 3); assert.ok(game.auction); assert.equal(game.auction.index, 1);
} else if (kind === 'blackMarket') {
assert.equal(game.phase, 3); assert.equal(game.richeseAuction ?? null, null);
assert.equal(game.decision?.kind, 'richeseDeclaration');
} else {
assert.equal(game.phase, 5); assert.equal(game.pendingAmbassador ?? null, null);
assert.equal(game.pendingArrivalReaction?.length ?? 0, 0);
assert.equal(game.active, 'emperor');
}
return game;
}
/** Select only the nine native Fisher–Yates packet draws at original force
* initialization. The Richese marker is actually dealt, never installed. */
function withOriginalRichesePacket<T>(run: () => T): T {
const pool = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
const desired = ['richese', ...pool.filter(effect => effect !== 'richese')], current: string[] = [...pool];
const rolls: number[] = [];
for (let index = current.length - 1; index > 0; index--) {
const selected = current.indexOf(desired[index]); assert.ok(selected >= 0 && selected <= index);
rolls.push(Math.floor((selected + 0.5) * 0x100000000 / (index + 1)));
[current[index], current[selected]] = [current[selected], current[index]];
}
assert.deepEqual(current, desired);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Real starting Harkonnen two-card deal and paid acquisition. Basic Ambassador
* uses actual Ecaz alliance consent before an independent entrant triggers a
* Richese purchase for allied Harkonnen; Advanced E3 admission stays closed. */
export function createSemutaHarkonnenBonusFixture(options: {
kind: SemutaHarkonnenBonusKind; advanced?: boolean;
}) {
const kind = options.kind, advanced = options.advanced ?? kind === 'blackMarket';
assert.ok(kind !== 'blackMarket' || advanced, 'The native Black Market is Advanced');
assert.ok(kind !== 'ambassador' || !advanced, 'Advanced Ecaz Semuta starts remain outside the original preview gate');
const richese = 'richese', holder = 'choam', owner = 'harkonnen', spender = 'emperor', ecaz = 'ecaz';
let game = createGame('SEMUTAHARKONNENBONUS', newPlayer(richese, 'Richese', 'richese'), advanced,
kind === 'ambassador' ? ['choam', 'ecaz'] : ['choam']);
const roster = kind === 'ambassador' ? [holder, ecaz, owner, spender] as const : [holder, owner, spender] as const;
for (const faction of roster) joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: SemutaHarkonnenBonusStep[] = [];
const act = (actor: string, action: Action) => { game = actSemutaHarkonnenBonus(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaHarkonnenBonus(game, until, actions); };
const transition = (run: () => void) => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
else act(game.host, { type: 'start' });
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
entry: advanced ? 'original-undealt-semuta-preview' : 'public-basic-start' };
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.equal(game.semutaPreview, true);
assert.ok(game.players.every(player => player.hand.length === 0));
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
const originalInventory = semutaHarkonnenBonusInventory(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 projectile = game.deck.find(card => card.kind === 'projectile' && !card.effect); assert.ok(projectile);
const ordinary = [projectile, ...game.deck.filter(card => card.id !== projectile.id && !card.effect &&
['projectile', 'poison', 'shield', 'snooper'].includes(card.kind))].slice(0, game.players.length);
assert.equal(ordinary.length, game.players.length);
const deal = [...ordinary.map(card => card.id), printed.id], cost = printed.id;
const dealOrder = deal.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { card, from, position };
});
const beforeStartingDeal = structuredClone(game);
let packetCreation: ReturnType<typeof transition> | null = null;
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const step = nextSemutaHarkonnenBonus(game); assert.ok(step);
const lastTraitor = kind === 'ambassador' && game.setupStage === 'traitors' && step.action.type === 'traitor' &&
game.players.filter(player => player.traitorChoices.length > 0).length === 1;
if (lastTraitor) packetCreation = transition(() => {
game = withOriginalRichesePacket(() => applyAction(game, step.actor, step.action));
actions.push(structuredClone(step));
});
else act(step.actor, step.action);
}
assert.equal(game.status, 'playing'); const afterSetup = structuredClone(game);
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal);
assert.equal(semutaHarkonnenBonusPlayer(game, owner).hand.length, 2);
assert.ok(semutaHarkonnenBonusPlayer(game, spender).hand.some(card => card.id === cost));
if (kind === 'ambassador') {
assert.ok(packetCreation);
assert.equal(game.ecazAmbassadors?.tokens.find(token => token.effect === 'richese')?.zone, 'supply');
}
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
for (let position = 0; position < (advanced && kind === 'blackMarket' ? 4 : advanced ? 2 : 1); position++) {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
['hagga_basin', 'rock_outcroppings', 'broken_land', 'oh_gap'].includes(card.territory));
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
spiceOrder.push({ position, from, card: structuredClone(card) });
}
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
let beforeBid: Game | undefined, bid: SemutaHarkonnenBonusStep | undefined, bidAt = -1;
for (let count = 0; count < 900 && !semutaHarkonnenBonusPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
const step = nextSemutaHarkonnenBonus(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(semutaHarkonnenBonusPlayer(beforeBid, holder).spice - semutaHarkonnenBonusPlayer(game, holder).spice, 1);
assert.equal(semutaHarkonnenBonusPlayer(game, richese).spice - semutaHarkonnenBonusPlayer(beforeBid, richese).spice, 1);
let blackMarket: ReturnType<typeof transition> | null = null;
let placements: ReturnType<typeof transition> | null = null;
let alliance: ReturnType<typeof transition> | null = null;
let arrival: ReturnType<typeof transition> | null = null;
let nativeAction: Action, purchase: ReturnType<typeof transition>, purchased: string;
if (kind === 'ambassador') {
reach(state => state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
placements = transition(() => {
for (const [effect, territory] of [['ecaz', 'sietch_tabr'], ['richese', 'habbanya_ridge_sietch']] as const) {
const token = game.ecazAmbassadors!.tokens.find(token => token.effect === effect && token.zone === 'supply'); assert.ok(token);
act(ecaz, { type: 'decision', token: token.id, territory });
assert.equal(game.response?.kind, 'ecazPlacement');
const quotedCost = game.pendingEcazPlacement?.cost; assert.ok(quotedCost !== undefined);
const balance = semutaHarkonnenBonusPlayer(game, ecaz).spice;
for (let count = 0; game.response?.kind === 'ecazPlacement' && count < game.players.length; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
act(actor.id, { type: 'passResponse' });
}
assert.equal(game.response ?? null, null);
assert.equal(game.decision?.kind, 'ecazPlacement');
assert.equal(semutaHarkonnenBonusPlayer(game, ecaz).spice, balance - quotedCost);
assert.equal(game.ecazAmbassadors!.tokens.find(candidate => candidate.id === token.id)?.location, territory);
}
act(ecaz, { type: 'decision', decline: true });
});
reach(state => state.phase === 5 && state.active === owner && semutaHarkonnenBonusClean(state));
alliance = transition(() => {
act(owner, { type: 'ship', territory: 'sietch_tabr', sector: 14, amount: 1, elite: 0, allyPayment: 0 });
assert.equal(game.pendingAmbassador?.stage, 'offer');
act(ecaz, { type: 'decision', event: game.pendingAmbassador!.event, trigger: true, choice: 'alliance', beneficiary: ecaz });
assert.equal(game.pendingAmbassador?.stage, 'allianceReply');
act(owner, { type: 'decision', event: game.pendingAmbassador!.event, accept: true });
if (game.pendingAmbassador?.stage === 'loan')
act(ecaz, { type: 'decision', event: game.pendingAmbassador.event, loan: false });
assert.equal(game.pendingAmbassador ?? null, null);
});
assert.equal(semutaHarkonnenBonusPlayer(game, owner).ally, ecaz);
assert.equal(semutaHarkonnenBonusPlayer(game, ecaz).ally, owner);
act(owner, { type: 'endMovement' });
reach(state => state.phase === 5 && state.active === spender && semutaHarkonnenBonusClean(state));
arrival = transition(() => { act(spender, { type: 'ship', territory: 'habbanya_ridge_sietch', sector: 17,
amount: 1, elite: 0, allyPayment: 0 }); });
assert.equal(game.pendingAmbassador?.stage, 'offer');
purchased = game.deck[0].id;
nativeAction = { type: 'decision', event: game.pendingAmbassador!.event, trigger: true, beneficiary: owner };
purchase = transition(() => {
act(ecaz, nativeAction);
reach(state => state.response?.kind === 'harkonnenBonus');
});
} else {
if (kind === 'blackMarket') {
reach(state => state.turn === 2 && state.phase === 3 && !state.response && !state.phaseOpening &&
state.decision?.kind === 'richeseBlackMarket');
const card = semutaHarkonnenBonusPlayer(game, richese).hand.find(card => card.kind === 'projectile'); assert.ok(card);
blackMarket = transition(() => { act(richese, { type: 'decision', event: game.richeseBidding!.event,
card: card.id, method: 'normal', direction: 'clockwise' }); });
reach(state => semutaHarkonnenBonusClean(state) && state.richeseAuction?.source === 'blackMarket' &&
state.richeseAuction.active === owner && state.richeseAuction.bid === 0);
purchased = game.richeseAuction!.cardId;
nativeAction = { type: 'richeseBid', event: game.richeseAuction!.event, amount: 1, allyPayment: 0 };
} else {
reach(state => semutaHarkonnenBonusClean(state) && !!state.auction && state.auction.active === owner && state.auction.bid === 0);
assert.equal(game.auction!.index, 0); assert.ok(game.auction!.cards.length >= 2);
purchased = game.auction!.cards[0].id;
nativeAction = { type: 'bid', amount: 1, allyPayment: 0 };
}
purchase = transition(() => {
act(owner, nativeAction);
reach(state => state.response?.kind === 'harkonnenBonus');
});
}
const paid = kind === 'ambassador' ? 3 : 1;
assert.equal(game.response?.kind, 'harkonnenBonus'); assert.equal(game.response?.owner, owner);
assert.equal(game.phaseOpening ?? null, null); assert.equal(game.decision ?? null, null);
assert.equal(game.pendingTreacheryDiscard ?? null, null);
assert.equal(semutaHarkonnenBonusPlayer(purchase.before, owner).spice - semutaHarkonnenBonusPlayer(game, owner).spice, paid);
assert.equal(semutaHarkonnenBonusPlayer(game, owner).hand.length, semutaHarkonnenBonusPlayer(purchase.before, owner).hand.length + 1);
assert.equal(semutaHarkonnenBonusPlayer(game, owner).hand.filter(card => card.id === purchased).length, 1);
assert.notEqual(purchased, cost);
const purchasedCard = structuredClone(semutaHarkonnenBonusPlayer(game, owner).hand.find(card => card.id === purchased)!);
const source = structuredClone(game), originalResponse = structuredClone(game.response!);
const originalSale = structuredClone(game.currentAuctionSale ?? null), originalAuction = structuredClone(game.auction ?? null);
const originalEntry = structuredClone(game.pendingAmbassador ?? null), originalLot = structuredClone(game.richeseAuction ?? null);
const originalRound = structuredClone(game.richeseBidding ?? null), originalDeck = game.deck.map(card => card.id);
const bonus = game.deck[0]; assert.ok(bonus);
assert.ok(semutaHarkonnenBonusPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
assert.ok(semutaHarkonnenBonusPlayer(game, spender).hand.some(card => card.id === cost));
assert.deepEqual(semutaHarkonnenBonusInventory(game), originalInventory);
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
return { game, source, beforeCost: source, kind, advanced, richese, holder, spender, owner, buyer: owner, ecaz,
cost, semuta: SEMUTA_DRUG_ID, action, nativeAction, originalResponse,
paid, purchased, purchasedCard, bonus: structuredClone(bonus), originalDeck,
originalSale, originalAuction, originalEntry, originalLot, originalRound,
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, deal, dealOrder, packetCreation,
originalSpice, spiceOrder, acquired, acquisition, blackMarket, placements, alliance, arrival, purchase,
originalInventory, actions, sourceActions: actions, sourceActionCount: actions.length,
staging: [
'Public Basic start or original Advanced Semuta initializer on a ready undealt R/C/H/E lobby; Basic Ambassador additionally seats Ecaz under its existing native gate. Original main35/cache10 remain physical. Only unopened original deal/Spice positions and scalar lotteries are selected. Harkonnen really receives two starting cards, Emperor printed Karama, and CHOAM buys cache Semuta for1.',
kind === 'normal' ? 'Harkonnen makes a real positive bid1 on the first native normal lot. Other seats pass; native delivery/debit and Emperor income settle before the original Harkonnen bonus response. The bonus has not drawn.' :
kind === 'blackMarket' ? 'After actual first-turn completion, Richese offers its original dealt projectile at the real turn2 Advanced Black Market. Harkonnen bids1; the native seller/payment/income continuations finish before bonus. No purchase or auction frame is installed.' :
'The original nine-roll Ambassador packet genuinely includes Richese. At first Revival Ecaz declares reusable Ecaz at Tabr14, actually passes its native counter, pays1, and regains its placement chooser. It declares Richese at Habbanya17, actually passes that counter and pays the next native price2 before finishing placement. Harkonnen ships1 to Tabr, actually accepts Ecaz’s printed alliance and declines any real optional Duke loan. Independent non-ally Emperor next ships1 to Habbanya; Ecaz triggers Richese for allied Harkonnen. Harkonnen pays3 and draws its paid card; original Emperor income settles before bonus. Advanced E3 Semuta admission remains unchanged.',
'Stop before the separate original printed cancellation cost. The purchased card and native wallets already committed, and the exact next deck card is still undrawn. Claim/pass resumes denial plus the original sale/entry tail once; normal allowance alone draws its one physical bonus.',
] };
}
export type SemutaHarkonnenBonusFixture = ReturnType<typeof createSemutaHarkonnenBonusFixture>;
+275
View File
@@ -0,0 +1,275 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
import { quoteNoFieldCancellation } from '../game/karama-no-field-cancellation';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
import {
actSemutaDisasterWorm, nextSemutaDisasterWorm, passSemutaDisasterWorm, claimSemutaDisasterWorm,
semutaDisasterWormPlayer, semutaDisasterWormInventory, semutaDisasterWormClean,
} from './fixture-semuta-disaster-worm';
export type SemutaNoFieldCostKind = 'own' | 'allied' | 'ambassador';
export type SemutaNoFieldCostStep = { actor: string; action: Action };
export const actSemutaNoFieldCost = actSemutaDisasterWorm;
export const passSemutaNoFieldCost = passSemutaDisasterWorm;
export const claimSemutaNoFieldCost = claimSemutaDisasterWorm;
export const semutaNoFieldCostPlayer = semutaDisasterWormPlayer;
export const semutaNoFieldCostInventory = semutaDisasterWormInventory;
export const semutaNoFieldCostClean = semutaDisasterWormClean;
export const semutaNoFieldCostPhysical = (game: Game) => ({
players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces, elites: p.elites,
reserves: p.reserves, tanks: p.tanks, shipped: p.shipped, moved: p.moved, ally: p.ally,
noField: p.noField, noFieldEvent: p.noFieldEvent })),
aid: game.aid, spice: game.spice,
});
/** Fresh groups and the independent Ambassador chooser remain caller-owned. */
export function nextSemutaNoFieldCost(game: Game): SemutaNoFieldCostStep | null {
if (game.pendingTreacheryDiscard || game.truthtrance) return null;
if (!game.response && !game.phaseOpening) {
const ecaz = game.players.find(p => p.faction === 'ecaz');
if (game.status === 'setup' && game.setupStage === 'forces' && ecaz?.reserves === 20 &&
viewGame(game, ecaz.id).setupPending.includes(ecaz.id))
return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:9': 6 } } };
if (game.decision?.kind === 'ecazAmbassador') return null;
if (game.decision?.kind === 'ecazPlacement')
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
}
return nextSemutaDisasterWorm(game);
}
export const nextSemutaNoFieldCostStep = nextSemutaNoFieldCost;
export function advanceSemutaNoFieldCost(game: Game, until: (state: Game) => boolean,
actions: SemutaNoFieldCostStep[] = []): Game {
for (let count = 0; count < 2000; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextSemutaNoFieldCost(game);
assert.ok(step, `Choose the native fresh/Ambassador control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actSemutaNoFieldCost(game, step.actor, step.action, actions);
}
throw Error('The native No-Field cost source did not reach its finite boundary');
}
/** Allow this exact No-Field response only, then leave original shipment
* permission/rate responses and independent arrival to finish. */
export function allowSemutaNoFieldCost(game: Game, actions: SemutaNoFieldCostStep[] = []): Game {
assert.equal(game.response?.kind, 'richeseNoField');
for (let count = 0; game.response?.kind === 'richeseNoField' && count < game.players.length; count++) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
game = actSemutaNoFieldCost(game, actor.id, { type: 'passResponse' }, actions);
}
assert.notEqual(game.response?.kind, 'richeseNoField'); return game;
}
/** Drains only native original responses/permissions. A denied independent
* grant stops at its restored chooser without choosing the reserve fallback. */
export function finishSemutaNoFieldCost(game: Game, actions: SemutaNoFieldCostStep[] = []): Game {
return advanceSemutaNoFieldCost(game, state => semutaNoFieldCostClean(state) ||
!state.response && !state.phaseOpening && !state.pendingTreacheryDiscard &&
state.decision?.kind === 'ecazAmbassador', actions);
}
function originalGuildPacket() {
const pool = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
const supply: Exclude<AmbassadorEffect, 'ecaz'>[] = ['guild', 'atreides', 'emperor', 'fremen', 'harkonnen'];
const desired = [...supply, ...pool.filter(effect => !supply.includes(effect))], current = [...pool], rolls: number[] = [];
for (let i = current.length - 1; i > 0; i--) {
const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
[current[i], current[j]] = [current[j], current[i]];
}
assert.deepEqual(current, desired); assert.equal(rolls.length, 9); return { supply, rolls };
}
function createOriginalGuildPacket(game: Game, step: SemutaNoFieldCostStep, rolls: number[], actions: SemutaNoFieldCostStep[]) {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1)
array[0] = cursor < rolls.length ? rolls[cursor++] : 0xffffffff;
else Reflect.apply(original, crypto, [array]);
return array;
};
try {
const next = applyAction(game, step.actor, step.action);
assert.equal(cursor, rolls.length); actions.push(structuredClone(step)); return next;
} finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Genuine own/allied Basic+Advanced and Basic independent Guild grant.
* Advanced Ambassador substitution retains its existing Basic-only gate. */
export function createSemutaNoFieldCostFixture(options: {
kind?: SemutaNoFieldCostKind; advanced?: boolean;
} = {}) {
const kind = options.kind ?? 'own', advanced = options.advanced ?? false;
assert.ok(kind !== 'ambassador' || !advanced,
'Native Guild Ambassador No-Field substitution requires the existing bare Basic profile');
const owner = 'richese', holder = 'choam', emperor = 'emperor', spender = 'guild', ecaz = 'ecaz';
const recipient = kind === 'allied' ? emperor : owner, territory = kind === 'ambassador' ? 'sietch_tabr' : 'imperial_basin';
const sector = kind === 'ambassador' ? 14 : 9, location = `${territory}:${sector}`;
let game = createGame('SEMUTANOFIELDCOST', newPlayer(owner, 'Richese', 'richese'), advanced,
kind === 'ambassador' ? ['choam', 'ecaz'] : ['choam']);
for (const faction of ['choam', 'emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
if (kind === 'ambassador') joinGame(game, newPlayer(ecaz, 'Ecaz', 'ecaz'));
const initial = structuredClone(game), actions: SemutaNoFieldCostStep[] = [];
const act = (actor: string, action: Action) => { game = actSemutaNoFieldCost(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaNoFieldCost(game, until, actions); };
const transition = (run: () => void) => {
const before = structuredClone(game), at = actions.length; run();
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
};
const circles = kind === 'ambassador' ? transition(() => {
act(holder, { type: 'seatPosition', position: 6 });
act(owner, { type: 'seatPosition', position: 2 });
act(holder, { type: 'seatPosition', position: 1 });
}) : null;
for (const p of game.players) act(p.id, { type: 'ready' });
const lobby = structuredClone(game);
if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
else act(game.host, { type: 'start' });
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
entry: advanced ? 'original-undealt-advanced-semuta-preview' : 'public-basic-start' };
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.equal(game.semutaPreview, true);
assert.ok(game.players.every(p => p.hand.length === 0));
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
const originalInventory = semutaNoFieldCostInventory(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').slice(0, 3); assert.equal(ordinary.length, 3);
const shield = game.deck.find(card => card.kind === 'shield'); assert.ok(shield);
const selected = [ordinary[0], ordinary[1], ordinary[2], printed, ...(kind === 'ambassador' ? [shield] : [])];
const dealOrder = selected.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
const beforeStartingDeal = structuredClone(game), packet = kind === 'ambassador' ? originalGuildPacket() : null;
let packetCreation: { before: Game; steps: SemutaNoFieldCostStep[]; after: Game } | null = null;
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const step = nextSemutaNoFieldCost(game); assert.ok(step);
if (packet && game.setupStage === 'traitors' && step.action.type === 'traitor' &&
game.players.filter(p => p.traitorChoices.length).length === 1) {
const before = structuredClone(game);
game = createOriginalGuildPacket(game, step, packet.rolls, actions);
packetCreation = { before, steps: [structuredClone(step)], after: structuredClone(game) };
} else act(step.actor, step.action);
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game), cost = printed.id;
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
const originalNoField = structuredClone(semutaNoFieldCostPlayer(game, owner).noField); assert.ok(originalNoField);
const token = originalNoField.tokens.find(token => token.value === 5)!;
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder: { turn: number; position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
const orderSpice = (faces: string[]) => {
for (let position = 0; position < faces.length; position++) {
const face = faces[position], from = game.spiceDeck.findIndex((card, index) => index >= position &&
(face === 'worm' ? 'worm' in card && !card.suppressed && !card.thumper && !card.greatMaker
: 'territory' in card && card.territory === face && !card.discovery));
assert.ok(from >= position, `The original unopened Spice stock needs ${face}`);
const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
spiceOrder.push({ turn: game.turn, position, from, card: structuredClone(card) });
}
};
orderSpice(advanced ? ['funeral_plain', 'hagga_basin'] : ['funeral_plain']);
let beforeBid: Game | undefined, bid: SemutaNoFieldCostStep | undefined, bidAt = -1;
for (let count = 0; count < 900 && !semutaNoFieldCostPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
const step = nextSemutaNoFieldCost(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(semutaNoFieldCostPlayer(beforeBid, holder).spice - semutaNoFieldCostPlayer(game, holder).spice, 1);
let placement: { before: Game; steps: SemutaNoFieldCostStep[]; after: Game } | null = null;
let guildToken: string | null = null;
if (kind === 'ambassador') {
assert.ok(packetCreation);
guildToken = game.ecazAmbassadors!.tokens.find(token => token.effect === 'guild')!.id;
reach(state => state.decision?.kind === 'ecazPlacement' && !state.response && !state.phaseOpening);
placement = transition(() => {
act(ecaz, { type: 'decision', token: guildToken!, territory: 'carthag' });
reach(state => state.decision?.kind === 'ecazPlacement' && !state.response && !state.phaseOpening);
act(ecaz, { type: 'decision', decline: true });
});
assert.equal(semutaNoFieldCostPlayer(placement.before, ecaz).spice - semutaNoFieldCostPlayer(game, ecaz).spice, 1);
}
let alliance: { before: Game; steps: SemutaNoFieldCostStep[]; after: Game } | null = null;
if (kind !== 'own') {
reach(state => state.turn === 2 && state.phase === 1 && semutaNoFieldCostClean(state) &&
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0);
orderSpice(['worm', 'the_great_flat', ...(advanced ? ['broken_land'] : [])]);
reach(state => state.turn === 2 && state.phase === 1 && state.nexus && !state.spiceWindow &&
!state.spiceResolution && semutaNoFieldCostClean(state));
const ally = kind === 'ambassador' ? ecaz : emperor;
alliance = transition(() => {
act(owner, { type: 'alliance', target: ally }); act(ally, { type: 'alliance', target: owner });
});
assert.equal(semutaNoFieldCostPlayer(game, owner).ally, ally); assert.equal(semutaNoFieldCostPlayer(game, ally).ally, owner);
}
const turn = kind === 'own' ? 1 : 2;
let priorShipment: { before: Game; steps: SemutaNoFieldCostStep[]; after: Game } | null = null;
let entrantShipment: { before: Game; steps: SemutaNoFieldCostStep[]; after: Game } | null = null;
if (kind === 'ambassador') {
reach(state => state.turn === turn && state.phase === 5 && state.active === owner && semutaNoFieldCostClean(state));
priorShipment = transition(() => {
act(owner, { type: 'ship', amount: 1, elite: 0, allyPayment: 0, territory: 'polar_sink', sector: 0 });
reach(semutaNoFieldCostClean); act(owner, { type: 'endMovement' });
});
reach(state => state.turn === turn && state.phase === 5 && state.active === emperor && semutaNoFieldCostClean(state));
entrantShipment = transition(() => {
act(emperor, { type: 'ship', amount: 1, elite: 0, allyPayment: 0, territory: 'carthag', sector: 11 });
reach(state => !state.response && state.pendingAmbassador?.stage === 'offer');
});
act(ecaz, { type: 'decision', event: game.pendingAmbassador!.event, trigger: true, beneficiary: owner });
assert.equal(game.pendingAmbassador?.stage, 'ship'); assert.equal(game.pendingAmbassador.effect, 'guild');
} else reach(state => state.turn === turn && state.phase === 5 && state.active === recipient &&
(semutaNoFieldCostClean(state) || kind === 'allied' && !state.response && !state.phaseOpening &&
!state.pendingTreacheryDiscard && state.decision?.kind === 'richeseAllyOpportunity' && state.decision.player === owner));
const beforeDeclaration = structuredClone(game), tokenEvent = semutaNoFieldCostPlayer(game, owner).noFieldEvent!;
const entry = structuredClone(game.pendingAmbassador ?? null);
const nativeAction: Action = kind === 'own'
? { type: 'ship', noField: token.id, event: tokenEvent, territory, sector, allyPayment: 0 }
: kind === 'allied'
? { type: 'offerRicheseNoField', token: token.id, event: tokenEvent, payer: recipient, territory, sector }
: { type: 'decision', event: entry!.event, amount: 1, noField: token.id, noFieldEvent: tokenEvent, territory, sector };
const declaration = transition(() => {
act(owner, nativeAction);
if (kind === 'allied') act(recipient, { type: 'decision', event: game.richeseAllyOffer!.event, accept: true, elite: 0 });
});
assert.ok(game.response && game.pendingShipment);
assert.equal(game.response.kind, 'richeseNoField'); assert.equal(game.response.owner, owner);
assert.equal(game.response.source ?? null, kind === 'ambassador' ? 'ambassadorNoField' : null);
assert.deepEqual(semutaNoFieldCostPhysical(game), semutaNoFieldCostPhysical(beforeDeclaration));
assert.deepEqual(semutaNoFieldCostPlayer(game, owner).noField, originalNoField);
const expected = kind === 'ambassador' ? null : quoteNoFieldCancellation(game, game.response);
const source = structuredClone(game), originalResponse = structuredClone(game.response), originalShipment = structuredClone(game.pendingShipment);
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
const replacementAction: Action = kind === 'ambassador'
? { type: 'decision', event: entry!.event, amount: 1, elite: 0, territory, sector }
: { type: 'ship', amount: 1, elite: 0, allyPayment: 0, territory, sector };
assert.ok(semutaNoFieldCostPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
assert.ok(semutaNoFieldCostPlayer(game, spender).hand.some(card => card.id === cost));
assert.deepEqual(semutaNoFieldCostInventory(game), originalInventory);
return { game, source, beforeCost: source, kind, advanced, owner, holder, spender, recipient, emperor, guild: spender,
ecaz: kind === 'ambassador' ? ecaz : null, cost, karama: cost, semuta: SEMUTA_DRUG_ID, action, nativeAction,
replacementAction, fallbackAction: replacementAction, replacementActor: recipient, token, tokenEvent, entry,
originalResponse, originalShipment, originalPhysical: structuredClone(semutaNoFieldCostPhysical(game)), expected,
territory, sector, location, turn, shipmentCost: kind === 'ambassador' ? 0 : 2, amount: kind === 'allied' ? 5 : 1,
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, deal: selected.map(card => card.id), dealOrder,
originalInventory, originalNoField, originalSpice, spiceOrder, acquisition, acquired, packetCreation, circles,
guildToken, placement, alliance, priorShipment, entrantShipment, beforeDeclaration, declaration,
actions, steps: actions, sourceActions: actions, sourceActionCount: actions.length,
staging: [
advanced ? 'Original Advanced Semuta initializer on a genuinely ready undealt R/C/E/G lobby, main35/cache10, no modules.' : 'Actual public Basic R/C/E/G ready/start, adding original Ecaz/E3 only for the independent Ambassador source; main35/cache10 and no modules.',
'Only original unopened starting-card/Spice positions and scalar lotteries are selected. Guild really receives printed Karama; CHOAM really pays one for cache Semuta. Richese retains its original three token identities, physical reserves and selection event before the declaration.',
kind === 'allied' ? 'A real turn2 natural worm on the empty first Funeral pile creates Nexus. Richese and Emperor actually propose/accept alliance, then Richese privately offers its original five-marker during Emperor’s unused turn; Emperor really consents to five normals and its own two-spice fee.' : kind === 'ambassador' ? 'Basic-only native route: three public lobby circle choices put Richese before Emperor after the actual second Storm. Select the original nine-roll Guild packet, pay for its Carthag token, earn real Richese/Ecaz Nexus alliance. Richese really pays one for ordinary Polar entry and ends its turn. Emperor really pays one into Carthag, Ecaz really triggers Guild for allied Richese, then Richese really declares its original concealed marker for the independent free grant. Existing Advanced Ambassador/No-Field gate is preserved.' : 'At the original unused first movement turn, Richese really declares its original five-marker to Basin9 for the native one-marker two-spice fee.',
'Stop at the native unpaid No-Field response before Guild spends its originally dealt printed Karama. No played hand, force, wallet, clock, alliance, token, shipment, Ambassador entry or response is assigned. Native denial runs without an allowed budget/capacity trial; actual ordinary/independent physical fallback remains caller-owned.',
],
};
}
export type SemutaNoFieldCostFixture = ReturnType<typeof createSemutaNoFieldCostFixture>;
+195
View File
@@ -0,0 +1,195 @@
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 { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
import {
createSemutaAlliedTraitorFixture as fixture, actSemutaAlliedTraitor as act,
passSemutaAlliedTraitor as pass, claimSemutaAlliedTraitor as claim,
allowSemutaAlliedTraitor as allow, finishSemutaAlliedTraitor as finish,
advanceSemutaAlliedTraitor as advance, semutaAlliedTraitorPlayer as player,
semutaAlliedTraitorInventory as inventory, semutaAlliedTraitorClean as clean,
type SemutaAlliedTraitorFixture,
} from './fixture-semuta-allied-traitor';
const variants = [
{ advanced: false, last: false }, { advanced: false, last: true },
{ advanced: true, last: false }, { advanced: true, last: true },
];
const physical = (game: Game) => ({
turn: game.turn, phase: game.phase, storm: game.storm, active: game.active, order: game.order,
ready: game.ready, spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
movementRemaining: game.movementRemaining, aid: game.aid,
players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces, elites: p.elites,
reserves: p.reserves, tanks: p.tanks, leaders: p.leaders, traitors: p.traitors,
revealedTraitors: p.revealedTraitors, ally: p.ally, shipped: p.shipped, moved: p.moved, battleLosses: p.battleLosses })),
});
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: SemutaAlliedTraitorFixture) {
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, f.advanced); assert.equal(f.setup.semutaPreview, true);
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', 'harkonnen', 'emperor', 'fremen']);
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
assert.equal(player(f.afterSetup, f.owner).hand.length, 2);
assert.equal(player(f.afterSetup, f.owner).traitors.length, 4);
assert.ok(player(f.afterSetup, f.owner).traitors.includes(f.traitorId));
assert.ok(player(f.afterSetup, f.opponent).leaders.some(leader => leader.id === f.traitorId));
assert.deepEqual(player(f.game, f.owner).traitors, player(f.afterSetup, f.owner).traitors);
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
assert.equal(Object.keys(player(f.afterSetup, f.richese).forces).length, 0,
'Richese never supplies a manufactured starting garrison');
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
assert.equal(f.alliance.before.turn, 2); assert.equal(f.alliance.before.phase, 1); assert.equal(f.alliance.before.nexus, true);
assert.equal(f.alliance.steps.length, 2); assert.ok(f.alliance.steps.every(step => step.action.type === 'alliance'));
assert.equal(player(f.game, f.owner).ally, f.beneficiary); assert.equal(player(f.game, f.beneficiary).ally, f.owner);
assert.ok(f.spiceOrder.some(row => row.turn === 2 && 'worm' in row.card));
assert.equal(f.shipments.length, 2);
for (const shipment of f.shipments) {
assert.ok(shipment.steps.some(step => step.actor === shipment.actor && step.action.type === 'ship' &&
step.action.amount === 1 && step.action.elite === 0));
assert.equal(player(shipment.before, shipment.actor).spice - player(shipment.after, shipment.actor).spice, shipment.cost);
assert.equal(player(shipment.before, shipment.actor).reserves - player(shipment.after, shipment.actor).reserves, 1);
assert.equal(player(shipment.after, shipment.actor).forces[f.location], 1);
}
assert.equal(f.reveal.steps.filter(step => step.action.type === 'battlePlan').length, 2);
assert.equal(f.game.phase, 6); assert.equal(f.game.turn, 2); assert.equal(f.game.battle?.revealed, true);
assert.equal(f.game.battle?.plans[f.opponent].leader, f.traitorId);
assert.equal([f.game.battle?.attacker, f.game.battle?.defender].includes(f.owner), false);
assert.deepEqual(new Set(f.voters), new Set([f.beneficiary, f.opponent, f.owner]));
assert.equal(f.votes.steps.length, f.last ? 3 : 1);
assert.deepEqual(f.votes.steps.at(-1), { actor: f.owner, action: { type: 'traitorCall', call: true } });
assert.equal(f.game.battle?.traitorCalls[f.owner], true);
assert.equal(Object.keys(f.game.battle!.traitorCalls).length, f.last ? 3 : 1);
assert.equal(f.game.battle?.outcomeWindow, undefined); assert.equal(f.game.lastBattleContext, undefined);
assert.ok(f.game.response?.kind === 'harkonnenTraitor'); assert.equal(f.game.response.owner, f.owner);
assert.deepEqual(f.game.battle, f.originalBattle); 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: SemutaAlliedTraitorFixture) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaAlliedTraitorDiscard');
if (pending.continuation.kind !== 'karamaAlliedTraitorDiscard') throw Error('Use the actual allied Traitor cost');
assert.equal(pending.batch.cause, 'karama:harkonnenTraitor'); 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.battle, f.originalBattle); assert.deepEqual(physical(game), physical(f.game));
assert.equal(game.battle?.traitorCalls[f.owner], true); assert.equal(game.response ?? null, null);
assert.deepEqual(viewGame(game, f.holder).battle?.traitorSubmitted, viewGame(f.game, f.holder).battle?.traitorSubmitted);
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
assert.deepEqual(inventory(game), f.originalInventory); return game;
}
function canceled(state: Game, f: SemutaAlliedTraitorFixture, claimed: boolean) {
assert.equal(state.pendingTreacheryDiscard ?? null, null); assert.equal(state.response ?? null, null);
assert.deepEqual(physical(state), physical(f.game)); assert.equal(state.battle?.event, f.originalBattle.event);
assert.deepEqual(state.battle?.plans, f.originalBattle.plans);
assert.deepEqual(state.battle?.traitorCalls, { ...f.originalBattle.traitorCalls, [f.owner]: false });
assert.equal(!!state.battle?.outcomeWindow, f.last,
'Only the all-voters branch advances to the real final battle window');
assert.equal(state.lastBattleContext, undefined);
assert.equal(player(state, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(player(state, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
assert.equal(state.discard.filter(card => card.id === f.cost).length, Number(!claimed));
assert.equal(player(state, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
assert.equal(state.discard.filter(card => card.id === f.semuta).length, Number(claimed));
assert.deepEqual(player(state, f.owner).traitors, player(f.game, f.owner).traitors);
reject(state, f.spender, f.action);
assert.deepEqual(inventory(state), f.originalInventory); return state;
}
function votesThenOutcome(state: Game, f: SemutaAlliedTraitorFixture, prevented: boolean) {
const votes: { actor: string; action: Action }[] = [];
const ready = advance(state, game => !!game.battle?.outcomeWindow && clean(game), votes);
assert.equal(ready.battle?.event, f.originalBattle.event); assert.equal(ready.battle?.revealed, true);
assert.deepEqual(ready.battle?.plans, f.originalBattle.plans);
assert.equal(ready.battle?.traitorCalls[f.owner], !prevented);
assert.equal(votes.filter(step => step.action.type === 'traitorCall').length, f.last ? 0 : 2);
assert.ok(votes.every(step => step.action.type === 'traitorCall' && step.action.call === false && step.actor !== f.owner));
assert.deepEqual(physical(ready), physical(f.game));
const quote = quoteStrongholdFactionsBattle(ready);
assert.equal(quote.result, prevented ? 'normal' : 'traitor');
assert.equal(quote.winner, prevented ? f.opponent : f.beneficiary);
assert.deepEqual(quote.revelations, prevented ? [] : [{ player: f.owner, identity: f.traitorId }]);
const steps: { actor: string; action: Action }[] = [], game = finish(ready, steps);
assert.equal(game.battle ?? null, null); assert.equal(game.pendingTreacheryDiscard ?? null, null);
assert.equal(game.lastBattleContext?.event, f.originalBattle.event);
assert.equal(game.lastBattleContext?.result, quote.result); assert.equal(game.lastBattleContext?.winner, quote.winner);
assert.equal(steps.filter(step => step.action.type === 'battleOutcomeReady').length, 2);
assert.ok(steps.every(step => !['battlePlan', 'traitorCall', 'ship', 'move', 'chooseBattle', 'card'].includes(step.action.type)));
const winner = quote.winner!, loser = winner === f.opponent ? f.beneficiary : f.opponent;
assert.equal(player(game, winner).forces[f.location], 1); assert.equal(player(game, loser).forces[f.location] ?? 0, 0);
assert.equal(player(game, loser).tanks - player(f.game, loser).tanks, 1);
assert.equal(player(game, f.opponent).leaders.find(leader => leader.id === f.traitorId)?.dead, !prevented);
assert.deepEqual(player(game, f.owner).traitors, player(f.game, f.owner).traitors);
assert.deepEqual(inventory(game), f.originalInventory);
reject(game, f.owner, { type: 'traitorCall', call: true });
reject(game, f.beneficiary, { type: 'battleOutcomeReady', event: f.originalBattle.event });
return game;
}
for (const variant of variants) for (const claimed of [false, true]) void test(
`${variant.advanced ? 'Advanced' : 'Basic'} native allied Harkonnen ${variant.last ? 'last' : 'early'} call cost Semuta ${claimed ? 'claim' : 'pass'} preserves revealed plans and changes only that effective vote`, () => {
const f = fixture(variant); nativeSource(f); const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
const game = canceled(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event });
votesThenOutcome(game, f, true);
},
);
void test('native allied Traitor allowance waits missing voters then settles the real held identity as a Traitor win', () => {
for (const variant of variants) {
const f = fixture(variant); nativeSource(f); const game = allow(structuredClone(f.game));
assert.deepEqual(physical(game), physical(f.game)); assert.equal(game.battle?.traitorCalls[f.owner], true);
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
votesThenOutcome(game, f, false);
}
});
void test('fresh allied-call costs reject stale and underlying controls while the original declaration remains true', () => {
for (const variant of variants) {
const f = fixture(variant), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
reject(game, f.holder, { type: 'semutaSelect', event, card: f.traitorId });
reject(game, f.owner, { type: 'traitorCall', call: false }); reject(game, f.owner, { type: 'passResponse' });
reject(game, f.beneficiary, f.plans[f.beneficiary]); reject(game, f.opponent, f.plans[f.opponent]);
for (const voter of f.voters) reject(game, voter, { type: 'traitorCall', call: false });
reject(game, f.beneficiary, { type: 'battleOutcomeReady', event: f.originalBattle.event });
assert.deepEqual(game.battle, f.originalBattle); assert.deepEqual(physical(game), physical(f.game));
canceled(pass(game), f, false);
}
});
void test('all four minimal profiles legally answer original Basic and Advanced allied-Traitor used costs', () => {
for (const variant of variants) {
const f = fixture(variant), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
for (const difficulty of DIFFICULTIES) {
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty);
assert.deepEqual(own, before); assert.ok(action.type === 'semutaPass' || action.type === 'semutaCommit');
assert.equal(action.event, event);
let game = act(structuredClone(offered), f.holder, action);
const claimed = action.type === 'semutaCommit';
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
const selected = botActions(selecting)[0]; assert.ok(selected);
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
game = act(game, f.holder, selected);
} else if (game.pendingTreacheryDiscard) game = pass(game);
const ready = canceled(game, f, claimed);
const final = advance(ready, state => !!state.battle?.outcomeWindow && clean(state));
assert.equal(quoteStrongholdFactionsBattle(final).result, 'normal');
assert.equal(final.battle?.traitorCalls[f.owner], false);
}
}
});
+205
View File
@@ -0,0 +1,205 @@
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 {
createSemutaHarkonnenBonusFixture as fixture, actSemutaHarkonnenBonus as act,
passSemutaHarkonnenBonus as pass, claimSemutaHarkonnenBonus as claim,
allowSemutaHarkonnenBonus as allow, finishSemutaHarkonnenBonus as finish,
semutaHarkonnenBonusPlayer as player, semutaHarkonnenBonusInventory as inventory,
type SemutaHarkonnenBonusFixture,
} from './fixture-semuta-harkonnen-bonus';
const variants = [
{ kind: 'normal' as const, advanced: false },
{ kind: 'normal' as const, advanced: true },
{ kind: 'blackMarket' as const, advanced: true },
{ kind: 'ambassador' as const, advanced: false },
];
const resources = (game: Game) => ({ turn: game.turn, phase: game.phase, storm: game.storm,
spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
players: game.players.map(player => ({ id: player.id, spice: player.spice, bribes: player.bribes,
forces: player.forces, reserves: player.reserves, tanks: player.tanks, elites: player.elites,
leaders: player.leaders, ally: player.ally, allySinceTurn: player.allySinceTurn,
shipped: player.shipped, moved: player.moved, revived: player.revived })) });
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: SemutaHarkonnenBonusFixture) {
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.semutaPreview, true); assert.equal(f.game.advanced, f.advanced);
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
assert.equal(player(f.afterSetup, f.owner).hand.length, 2);
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
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(player(f.purchase.before, f.owner).spice - player(f.game, f.owner).spice, f.paid);
assert.equal(player(f.game, f.owner).hand.length, player(f.purchase.before, f.owner).hand.length + 1);
assert.equal(player(f.game, f.owner).hand.filter(card => card.id === f.purchased).length, 1);
assert.equal(f.game.deck[0].id, f.bonus.id); assert.equal(player(f.game, f.owner).hand.some(card => card.id === f.bonus.id), false);
assert.notEqual(f.purchased, f.cost); assert.notEqual(f.bonus.id, f.cost);
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.game.response?.kind, 'harkonnenBonus'); assert.equal(f.game.response?.owner, f.owner);
assert.equal(f.game.decision ?? null, null); assert.equal(f.game.phaseOpening ?? null, null);
assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
if (f.kind === 'ambassador') {
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 5); assert.equal(f.paid, 3);
assert.ok(f.packetCreation && f.placements && f.alliance && f.arrival && f.originalEntry);
assert.equal(f.placements.steps.filter(step => step.actor === f.ecaz && step.action.type === 'decision' &&
typeof step.action.token === 'string').length, 2);
assert.ok(f.placements.steps.some(step => step.action.type === 'passResponse'));
assert.equal(player(f.placements.before, f.ecaz).spice - player(f.placements.after, f.ecaz).spice, 3,
'Two actually allowed placements charge their successive native prices1+2');
assert.equal(f.placements.after.ecazAmbassadors?.tokens.find(token => token.effect === 'ecaz')?.location, 'sietch_tabr');
assert.equal(f.placements.after.ecazAmbassadors?.tokens.find(token => token.effect === 'richese')?.location, 'habbanya_ridge_sietch');
assert.equal(f.originalSale, null); assert.equal(f.originalAuction, null);
assert.equal(f.originalEntry.effect, 'richese'); assert.equal(f.originalEntry.entrant, f.spender);
assert.equal(f.originalEntry.owner, f.ecaz); assert.equal(f.originalEntry.beneficiary, f.owner);
assert.equal(f.originalEntry.stage, 'bonus');
assert.deepEqual(f.originalEntry.purchaseReceipt, { buyer: f.owner, card: f.purchased,
amount: 3, emperor: f.spender, stage: 'bonus' });
assert.equal(f.game.response?.source, 'ambassador'); assert.equal(f.game.response?.intent, f.originalEntry.event);
assert.equal(f.game.active, f.spender); assert.equal(player(f.game, f.spender).shipped, true);
assert.equal(player(f.game, f.spender).moved, 0);
assert.ok(f.alliance.steps.some(step => step.actor === f.owner && step.action.type === 'ship' && step.action.territory === 'sietch_tabr'));
assert.ok(f.alliance.steps.some(step => step.actor === f.ecaz && step.action.type === 'decision' && step.action.choice === 'alliance'));
assert.ok(f.alliance.steps.some(step => step.actor === f.owner && step.action.type === 'decision' && step.action.accept === true));
assert.equal(player(f.game, f.owner).ally, f.ecaz); assert.equal(player(f.game, f.ecaz).ally, f.owner);
assert.equal(player(f.game, f.spender).ally, null);
assert.equal(player(f.game, f.owner).forces['sietch_tabr:14'], 1);
assert.equal(player(f.game, f.spender).forces['habbanya_ridge_sietch:17'], 1);
assert.equal(player(f.purchase.before, f.spender).spice + 3, player(f.game, f.spender).spice,
'The separate original Emperor income has already completed');
} else {
assert.equal(f.game.phase, 3); assert.equal(f.paid, 1); assert.equal(f.originalEntry, null);
assert.ok(f.originalSale); assert.equal(f.originalSale.winner, f.owner);
assert.equal(f.originalSale.amount, 1); assert.equal(f.originalSale.free, false);
assert.equal(f.originalSale.origin, f.kind); assert.notEqual(f.game.response?.source, 'ambassador');
if (f.kind === 'normal') {
assert.equal(f.game.turn, 1); assert.equal(f.blackMarket, null); assert.ok(f.originalAuction);
assert.equal(f.originalAuction.index, 0); assert.equal(f.originalAuction.cards[0].id, f.purchased);
assert.ok(f.purchase.steps.some(step => step.actor === f.owner && step.action.type === 'bid' && step.action.amount === 1));
} else {
assert.equal(f.game.turn, 2); assert.equal(f.game.advanced, true); assert.ok(f.blackMarket && f.originalLot);
assert.equal(f.originalLot.source, 'blackMarket'); assert.equal(f.originalLot.cardId, f.purchased);
assert.ok(f.blackMarket.steps.some(step => step.actor === f.richese && step.action.type === 'decision' && step.action.card === f.purchased));
assert.ok(player(f.blackMarket.before, f.richese).hand.some(card => card.id === f.purchased));
assert.equal(player(f.game, f.richese).hand.some(card => card.id === f.purchased), false);
assert.ok(f.purchase.steps.some(step => step.actor === f.owner && step.action.type === 'richeseBid' && step.action.amount === 1));
}
}
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 45);
assert.deepEqual(inventory(f.game), f.originalInventory);
}
function open(f: SemutaHarkonnenBonusFixture) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaBonusDiscard');
assert.equal(pending.reaction?.stage, 'offer'); assert.equal(pending.batch.cause, 'karama:harkonnenBonus');
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.notEqual(pending.batch.entries[0].card.id, f.purchased);
if (pending.continuation.kind !== 'karamaBonusDiscard') throw Error('Use the separate original bonus cancellation 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(resources(game), resources(f.game));
assert.deepEqual(game.currentAuctionSale ?? null, f.originalSale);
assert.deepEqual(game.auction ?? null, f.originalAuction); assert.deepEqual(game.richeseAuction ?? null, f.originalLot);
assert.deepEqual(game.richeseBidding ?? null, f.originalRound); assert.deepEqual(game.pendingAmbassador ?? null, f.originalEntry);
assert.deepEqual(game.deck.map(card => card.id), f.originalDeck);
assert.equal(player(game, f.owner).hand.filter(card => card.id === f.purchased).length, 1);
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 originalTail(game: Game, f: SemutaHarkonnenBonusFixture, bonus: boolean) {
game = finish(game, f.kind);
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
assert.notEqual(game.response?.kind, 'harkonnenBonus');
assert.deepEqual(resources(game), resources(f.game));
assert.deepEqual(game.deck.map(card => card.id), bonus ? f.originalDeck.slice(1) : f.originalDeck);
assert.equal(player(game, f.owner).hand.filter(card => card.id === f.purchased).length, 1);
assert.equal(player(game, f.owner).hand.length, player(f.game, f.owner).hand.length + Number(bonus));
assert.equal(player(game, f.owner).hand.filter(card => card.id === f.bonus.id).length, Number(bonus));
assert.equal(game.discard.some(card => card.id === f.purchased), false);
if (f.kind === 'normal') {
assert.equal(game.auction?.index, f.originalAuction!.index + 1);
assert.deepEqual(game.auction?.cards.map(card => card.id), f.originalAuction!.cards.map(card => card.id));
} else if (f.kind === 'blackMarket') {
assert.equal(game.decision?.kind, 'richeseDeclaration'); assert.equal(game.decision?.player, f.richese);
assert.equal(game.richeseBidding?.blackMarketSold, true);
} else {
assert.equal(game.pendingAmbassador ?? null, null); assert.equal(game.active, f.spender);
assert.deepEqual(game.movementRemaining, f.game.movementRemaining);
assert.deepEqual(game.ecazAmbassadors, f.game.ecazAmbassadors);
}
assert.deepEqual(inventory(game), f.originalInventory);
const snapshot = structuredClone(game); assert.deepEqual(finish(game, f.kind), snapshot);
return game;
}
function prevented(game: Game, f: SemutaHarkonnenBonusFixture, claimed: boolean) {
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
return originalTail(game, f, false);
}
for (const variant of variants) for (const claimed of [false, true]) void test(
`${variant.advanced ? 'Advanced' : 'Basic'} native ${variant.kind} Harkonnen bonus cost ${claimed ? 'claim' : 'pass'} preserves the paid acquisition and resumes its original tail once`, () => {
const f = fixture(variant); 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 });
reject(game, f.spender, f.action);
});
void test('native normal, Black Market and Ambassador bonus allowances draw exactly the original next card after payment', () => {
for (const variant of variants) {
const f = fixture(variant); nativeSource(f);
const game = originalTail(allow(structuredClone(f.game)), f, true);
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
}
});
void test('fresh bonus costs reject stale, foreign and underlying actions without redrawing, refunding or retiring the source early', () => {
for (const variant of variants) {
const f = fixture(variant), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
reject(game, f.spender, { type: 'semutaCommit', event });
reject(game, f.spender, f.action); reject(game, f.owner, { type: 'passResponse' });
reject(game, f.kind === 'ambassador' ? f.ecaz : f.owner, f.nativeAction);
assert.deepEqual(game.pendingAmbassador ?? null, f.originalEntry);
assert.deepEqual(game.currentAuctionSale ?? null, f.originalSale);
assert.deepEqual(game.deck.map(card => card.id), f.originalDeck);
prevented(pass(game), f, false);
}
});
void test('all four minimal policies answer every original paid Harkonnen bonus cancellation source legally', () => {
for (const variant of variants) {
const f = fixture(variant), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
for (const difficulty of DIFFICULTIES) {
const own = viewGame(offered, f.holder); own.players.find(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);
}
}
});
+213
View File
@@ -0,0 +1,213 @@
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 {
createSemutaNoFieldCostFixture as fixture, actSemutaNoFieldCost as act,
passSemutaNoFieldCost as pass, claimSemutaNoFieldCost as claim,
allowSemutaNoFieldCost as allow, finishSemutaNoFieldCost as finish,
semutaNoFieldCostPlayer as player, semutaNoFieldCostInventory as inventory,
semutaNoFieldCostPhysical as physical, type SemutaNoFieldCostFixture,
} from './fixture-semuta-no-field-cost';
const variants = [
{ kind: 'own' as const, advanced: false }, { kind: 'own' as const, advanced: true },
{ kind: 'allied' as const, advanced: false }, { kind: 'allied' as const, advanced: true },
{ kind: 'ambassador' as const, advanced: false },
];
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: SemutaNoFieldCostFixture) {
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, f.advanced); assert.equal(f.setup.semutaPreview, true);
assert.ok(f.beforeStartingDeal.players.every(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.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.owner).spice - player(f.acquisition.before, f.owner).spice, 1);
assert.deepEqual(player(f.game, f.owner).noField, f.originalNoField);
assert.deepEqual(f.originalNoField.tokens.map(token => token.value).sort((a, b) => a - b), [0, 3, 5]);
assert.equal(f.token.value, 5); assert.equal(f.originalNoField.lastShipped, null); assert.equal(f.originalNoField.deployed, null);
assert.equal(f.game.response?.kind, 'richeseNoField'); assert.equal(f.game.response?.owner, f.owner);
assert.deepEqual(f.game.response, f.originalResponse); assert.deepEqual(f.game.pendingShipment, f.originalShipment);
assert.equal(f.game.phase, 5); assert.equal(f.game.turn, f.turn); assert.equal(f.game.phaseOpening ?? null, null);
assert.equal(f.game.decision ?? null, null); assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
assert.deepEqual(physical(f.game), physical(f.beforeDeclaration));
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));
if (f.kind === 'own') {
assert.equal(f.turn, 1); assert.equal(f.alliance, null); assert.equal(f.entry, null);
assert.equal(f.originalShipment.noField?.tokenId, f.token.id); assert.equal(f.originalShipment.cost, 2);
assert.equal(player(f.game, f.owner).reserves, 20); assert.equal(player(f.game, f.owner).shipped, false);
} else {
assert.equal(f.turn, 2); assert.ok(f.alliance); assert.equal(f.alliance.steps.length, 2);
assert.ok(f.alliance.steps.every(step => step.action.type === 'alliance'));
const ally = f.kind === 'ambassador' ? f.ecaz! : f.recipient;
assert.equal(player(f.game, f.owner).ally, ally); assert.equal(player(f.game, ally).ally, f.owner);
}
if (f.kind === 'allied') {
assert.equal(f.declaration.steps.length, 2); assert.equal(f.declaration.steps[0].action.type, 'offerRicheseNoField');
assert.equal(f.declaration.steps[1].actor, f.recipient); assert.equal(f.declaration.steps[1].action.accept, true);
assert.equal(f.originalShipment.alliedNoField?.tokenId, f.token.id); assert.equal(f.originalShipment.amount, 5);
assert.equal(f.originalShipment.elite, 0); assert.equal(f.originalShipment.cost, 2);
assert.equal(player(f.game, f.recipient).reserves, 20); assert.equal(player(f.game, f.recipient).shipped, false);
}
if (f.kind === 'ambassador') {
assert.equal(f.advanced, false); assert.ok(f.ecaz && f.packetCreation && f.placement && f.entry);
assert.equal(f.placement.steps[0].actor, f.ecaz); assert.equal(f.placement.steps[0].action.token, f.guildToken);
assert.equal(player(f.placement.before, f.ecaz).spice - player(f.placement.after, f.ecaz).spice, 1);
assert.ok(f.priorShipment && f.entrantShipment);
assert.ok(f.priorShipment.steps.some(step => step.actor === f.owner && step.action.type === 'ship' && step.action.amount === 1));
assert.equal(player(f.priorShipment.before, f.owner).spice - player(f.priorShipment.after, f.owner).spice, 2);
assert.equal(player(f.entrantShipment.before, f.emperor).spice - player(f.entrantShipment.after, f.emperor).spice, 1);
assert.equal(player(f.game, f.owner).forces['polar_sink:0'], 1); assert.equal(player(f.game, f.owner).reserves, 19);
assert.equal(player(f.game, f.owner).shipped, true); assert.equal(player(f.game, f.emperor).forces['carthag:11'], 1);
assert.equal(f.game.active, f.emperor); assert.equal(f.entry.effect, 'guild'); assert.equal(f.entry.stage, 'ship');
assert.equal(f.originalResponse.source, 'ambassadorNoField'); assert.equal(f.originalShipment.source, 'ambassador');
assert.equal(f.originalShipment.ambassadorMarker?.mode, 'own'); assert.equal(f.originalShipment.cost, 0);
assert.equal(f.originalShipment.ambassadorEvent, f.entry.event); assert.equal(f.originalShipment.ambassadorMarker?.tokenId, f.token.id);
}
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
assert.equal(f.sourceActionCount, f.actions.length);
}
function open(f: SemutaNoFieldCostFixture) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaNoFieldDiscard');
if (pending.continuation.kind !== 'karamaNoFieldDiscard') throw Error('Expected the original unpaid No-Field cost');
assert.equal(pending.batch.cause, 'karama:richeseNoField'); 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.pendingShipment, f.originalShipment); assert.deepEqual(game.pendingAmbassador ?? null, f.entry);
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game));
assert.equal(player(game, f.owner).noFieldBlockedTurn ?? null, player(f.game, f.owner).noFieldBlockedTurn ?? null);
assert.deepEqual(game.richeseAllyBlocked ?? null, f.game.richeseAllyBlocked ?? null);
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
assert.deepEqual(inventory(game), f.originalInventory); return game;
}
function denied(state: Game, f: SemutaNoFieldCostFixture, claimed: boolean) {
const game = finish(state);
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingShipment ?? null, null);
assert.equal(game.response ?? null, null); assert.deepEqual(physical(game), f.originalPhysical);
assert.deepEqual(clock(game), clock(f.game)); assert.deepEqual(player(game, f.owner).noField, f.originalNoField);
assert.equal(player(game, f.owner).noFieldEvent, f.tokenEvent);
if (f.kind === 'own') {
assert.equal(player(game, f.owner).noFieldBlockedTurn, game.turn); assert.equal(game.decision ?? null, null);
} else if (f.kind === 'allied') {
assert.deepEqual(game.richeseAllyBlocked, { turn: game.turn, recipient: f.recipient });
assert.equal(game.decision ?? null, null);
} else {
assert.equal(game.pendingAmbassador?.event, f.entry!.event); assert.equal(game.pendingAmbassador?.stage, 'ship');
assert.equal(game.pendingAmbassador?.guildMarkerBlocked, true); assert.equal(game.pendingAmbassador?.guildMarkerRequest, undefined);
assert.equal(game.decision?.kind, 'ecazAmbassador'); assert.equal(game.decision?.player, f.owner);
assert.equal(player(game, f.owner).noFieldBlockedTurn ?? null, player(f.game, f.owner).noFieldBlockedTurn ?? null);
}
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 replacement = finish(act(structuredClone(game), f.replacementActor, f.replacementAction));
assert.equal(replacement.pendingShipment ?? null, null); assert.equal(replacement.pendingAmbassador ?? null, null);
assert.equal(replacement.response ?? null, null); assert.equal(replacement.decision ?? null, null);
assert.equal(replacement.phase, 5); assert.equal(replacement.active, f.game.active);
assert.equal(player(replacement, f.recipient).forces[f.location], 1);
assert.equal(player(replacement, f.recipient).reserves, player(f.game, f.recipient).reserves - 1);
assert.equal(player(replacement, f.recipient).elites?.reserves, player(f.game, f.recipient).elites?.reserves);
assert.equal(player(replacement, f.recipient).spice, player(f.game, f.recipient).spice - f.shipmentCost);
assert.equal(player(replacement, f.guild).spice, player(f.game, f.guild).spice + f.shipmentCost);
assert.equal(player(replacement, f.recipient).moved, player(f.game, f.recipient).moved);
assert.equal(player(replacement, f.recipient).shipped, true);
assert.deepEqual(player(replacement, f.owner).noField, f.originalNoField);
if (f.kind === 'ambassador') {
assert.equal(player(replacement, f.emperor).forces['carthag:11'], 1);
assert.equal(player(replacement, f.emperor).spice, player(f.game, f.emperor).spice);
assert.equal(player(replacement, f.owner).forces['polar_sink:0'], 1);
}
reject(replacement, f.replacementActor, f.replacementAction);
assert.deepEqual(inventory(replacement), f.originalInventory); return game;
}
for (const variant of variants) for (const claimed of [false, true]) void test(
`${variant.advanced ? 'Advanced' : 'Basic'} native ${variant.kind} No-Field cost Semuta ${claimed ? 'claim' : 'pass'} preserves unpaid stock and earns a real replacement`, () => {
const f = fixture(variant); nativeSource(f); const offered = open(f);
denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
},
);
void test('native own allied and independent No-Field allowances commit the original token forces and payment exactly once', () => {
for (const variant of variants) {
const f = fixture(variant); nativeSource(f); let game = finish(allow(structuredClone(f.game)));
assert.equal(game.pendingShipment ?? null, null); assert.equal(game.pendingAmbassador ?? null, null);
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
assert.equal(game.phase, 5); assert.equal(game.active, f.game.active);
assert.equal(player(game, f.recipient).spice, player(f.game, f.recipient).spice - f.shipmentCost);
assert.equal(player(game, f.guild).spice, player(f.game, f.guild).spice + f.shipmentCost);
assert.equal(player(game, f.owner).noField!.lastShipped, f.token.id);
assert.notEqual(player(game, f.owner).noFieldEvent, f.tokenEvent);
assert.equal(player(game, f.recipient).moved, player(f.game, f.recipient).moved);
if (f.kind === 'allied') {
assert.equal(player(game, f.owner).noField!.deployed, null);
assert.equal(player(game, f.recipient).forces[f.location], 5);
assert.equal(player(game, f.recipient).reserves, player(f.game, f.recipient).reserves - 5);
assert.equal(player(game, f.owner).reserves, player(f.game, f.owner).reserves);
assert.equal(player(game, f.recipient).shipped, true);
} else {
assert.deepEqual(player(game, f.owner).noField!.deployed, { tokenId: f.token.id, controller: f.owner,
location: { territory: f.territory, sector: f.sector } });
assert.deepEqual(player(game, f.owner).forces, player(f.game, f.owner).forces);
assert.equal(player(game, f.owner).reserves, player(f.game, f.owner).reserves);
assert.equal(player(game, f.owner).shipped, true);
if (f.kind === 'own') {
game = act(game, f.owner, { type: 'revealNoField', event: player(game, f.owner).noFieldEvent, token: f.token.id });
assert.equal(player(game, f.owner).forces[f.location], 5);
assert.equal(player(game, f.owner).reserves, player(f.game, f.owner).reserves - 5);
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice - 2);
}
}
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 No-Field cost reactions reject stale foreign and underlying replies without selecting or consuming a marker', () => {
for (const variant of variants) {
const f = fixture(variant), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
reject(game, f.owner, f.nativeAction); reject(game, f.recipient, f.replacementAction);
reject(game, f.owner, { type: 'passResponse' }); reject(game, f.owner, { type: 'ready' });
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 own allied and independent No-Field cancellation costs', () => {
for (const variant of variants) {
const f = fixture(variant), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
for (const difficulty of DIFFICULTIES) {
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
const 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);
}
}
});