Offer Semuta on native combat power cancellation costs

This commit is contained in:
Quintine committed 2026-10-26 02:41:22 +11:00
1 parent b3ea9f9c97
commit 3a01c8f1c0
11 files changed
+1562 -10

No files matched your search

+38
View File
@@ -16,6 +16,44 @@ open mode gates.
## Current checkpoint and work
**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
prevention. `karamaCombatDiscard` retains the original battle, declaration,
remaining checks, physical actors and pledge. Pure native preflight executes no
preparation, future plan, reveal or payment. Claim/pass restores the response and
finishes native cancellation once; subsequent choices use actual current hands
and native quotes. Existing persistent controls and minimal profiles serve all
six kinds. Live Nexus borrowed Voice keeps its original cancellation protocol.
[Source and boundaries](SEMUTA_SOURCE_UPDATE_20260907.md#native-combat-power-cancellation-costs--26-october-2026).
Three disjoint source lanes, bounded native/diff review and root integration pass
**15 files,176/176**, types/lint/build and **20 native engine/GameTable SSR/actual
Semuta-callback journeys through legal plan reveal** on unchanged source. Thirty
new cases cover ten sources, claim/pass, native allowances, exact remaining
preparation/checks, reduced plan legality, earned KH strength/real held-Traitor
protection, relevant rejection and all four minimal profiles. Actual cache1
Semuta and original dealt Karama precede each cost. Voice87/98 and Prescience88/99
Basic/Advanced actions include genuine BG2 arrival at original Atreides Arrakeen10.
Fedaykin94, Sardaukar99 and Fremen support99 use actual paid/free typed entry;
CHOAM aid203 adds a real Nexus alliance and pledge1, retained without early
spending/refund. KH163 earns seven battle losses with an actual unsupported
3.5-strength dial, then an actual turn2 opposing shipment creates its next battle.
Basic Cyborg100 pays for shipment, moves the original elite, and retains legal
dial2 before cancellation versus dial1 after. Original45/57 cards remain physical;
no played hand, force, alliance, loss/availability, wallet, clock or battle/control
is installed.
Feedback repairs the KH earning fixture's Advanced dial from7 to3.5 for seven
unsupported counters; the matching source assertion changes with it. Native
rules/guards stay intact. Private source-bound report/logs:
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-combat-proof/`;
journeys/HTML: adjacent `20261026-semuta-combat-runtime/`.
No saved-room/D1/server/reset/restart/deployment/recovery operation occurs.
These are engine/SSR/callback results; hydration, DOM/browser/mobile, borrowed
Voice and broader parent/combinations, complete modes and deployed/final
assurance remain unfinished.
**Semuta on native disaster cancellation costs — 26 October. Prototyped / Partial.**
Printed-role Karama stopping Fremen Storm protection, worm survival, allied worm
protection or Advanced additional placement now offers its exact used cost before
+40
View File
@@ -1,5 +1,45 @@
# Dune implementation status
## 26 October 2026 — Semuta claims native combat-power cancellation costs
**Prototyped / Partial.** Printed-role Karama stopping native Voice, ordinary
Prescience, KH, elite strength, Fremen support or CHOAM battle aid offers its exact
used cost before prevention. `karamaCombatDiscard` binds the battle, declaration,
remaining checks, physical actors and pledge; pure native cancellation preflight
executes no preparation, plan, reveal or payment. Claim/pass restores the response
and finishes prevention once. Current hands and native quotes govern the actual
next check/preparation and plan legality. A qualified existing full-plan offer is
parked without initialization; live Nexus borrowed Voice keeps its original
cancellation protocol. Existing controls/minimal bots serve all six kinds, and
older signature formats remain unchanged.
[Source and limits](SEMUTA_SOURCE_UPDATE_20260907.md#native-combat-power-cancellation-costs--26-october-2026).
Three disjoint source lanes, bounded native/diff review and root integration pass
**15 files,176/176**, types/lint/build and **20 native engine/GameTable SSR/actual
Semuta-callback journeys through legal plan reveal** on unchanged source. Thirty
new cases cover ten sources, applicable bands, claim/pass, allowances, reduced
plan legality, earned KH strength/real held-Traitor immunity, relevant rejection
and four minimal profiles. Real cache1 Semuta and original dealt Karama supply
every cost. Voice87/98 and Prescience88/99 Basic/Advanced actions use actual BG2
entry into original Atreides Arrakeen10 and original preparation/answer cursors.
Fedaykin94, Sardaukar99 and free support99 use actual paid/free typed groups;
CHOAM aid203 adds a real Nexus alliance and pledge1 retained without early
spending/refund. KH163 earns seven physical battle losses, then real turn2 entry
opens its next battle; an actual held Traitor proves the protection distinction.
Basic Cyborg100 pays for shipment and moves the original elite, proving native
dial2 versus canceled dial1. All45/57 cards remain physical; no played hand,
force, alliance, wallet, loss/availability, clock or battle/control is installed.
Feedback corrects the KH earning fixture's Advanced dial7 to3.5 for seven
unsupported counters and its matching assertion. No gameplay guard changes.
Private source-bound report/logs:
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-combat-proof/`;
journeys/HTML: adjacent `20261026-semuta-combat-runtime/`.
No saved-room/D1/server/reset/restart/deployment/recovery operation occurs.
These are engine/SSR/callback results; hydration, DOM/browser/mobile, direct
full-plan coexistence acceptance, live borrowed Voice, broader combinations,
complete modes and deployed/final assurance remain open.
## 26 October 2026 — Semuta claims native disaster cancellation costs
**Prototyped / Partial.** Printed-role Karama stopping native Fremen Storm
+89
View File
@@ -2,6 +2,95 @@
7 September 2026. Read `docs/SEMUTA_DRUG_ENGINE_AUDIT.md` in full, inspected the physical card again, checked current capacity/recovery code, and freshly searched publisher sources. No project runtime edits.
## Native combat-power cancellation costs — 26 October 2026
**Prototyped / Partial.** Printed-role Karama stopping native Voice, ordinary
Prescience, Kwisatz Haderach, elite strength, Fremen support or CHOAM battle aid
now offers its exact used cost. `karamaCombatDiscard` retains the unrevealed
battle, declared power, remaining checks, physical actors and original pledge.
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 those benefits.
The native [combat-response quote](../game/combat-response-quote.ts), reached
through the ordinary Karama validator, validates cancellation without applying
it, answering preparation or evaluating/sealing a future plan. The physical
cost is already spent and is not supplied as an unspent card. A subtype-only
snapshot binds the whole battle, physical pools, KH losses/identity, support
pledge, native roles and module records; earlier signature formats remain
unchanged. Other unfinished card, arrival and transport parents remain separate.
After claim/pass, retire the group and restore the exact response before native
prevention once. Canceled Voice removes the command while its already selected
next preparation remains. Canceled Prescience removes the question and pending
answer without disclosing one, then finishes preparation natively. A qualified
native full-plan offer that already coexists with Voice is parked unchanged;
do not initialize, answer or offer it again. KH/elite/support/aid denial marks
only the original battle's native block, then takes the original next check.
A newly claimed Karama can make that next response actionable. Later checks
and preparation receive real inputs; no declaration, ready action or cost replays.
No support or casualties are committed while Semuta is offered. A CHOAM pledge
was already reserved before its battle-aid response: keep that native reservation
through the cost, without spending or refunding it early. Actual plan legality
and support funding after denial use the native current quote. KH remains earned
and physically unchanged; cancellation suppresses its bonus and traitor protection
only in the original battle. Basic Cyborg cancellation retains physical types
and the independent Suboid rule. The live Nexus borrowed-Voice parent keeps its
existing cancellation path; its separate source-bound Semuta integration remains
open. This is not a blanket rejection of other Nexus context.
Three disjoint source lanes, bounded native/diff review and root integration pass
**15 files,176/176**, types/lint/build and **20 native engine/GameTable SSR/actual
Semuta-callback journeys through legal plan reveal** on unchanged source. Thirty
new cases cover ten native sources, claim/pass, positive allowances, reduced or
repriced plan legality, actual later preparation/checks, relevant stale/underlying
rejection and all four minimal profiles. Real cache1 acquisition and original
dealt Karama supply every cost; all original45/57 cards remain physical. No
played hand, force, alliance, wallet, loss/availability, clock or battle/control
is installed.
[Declaration sources](../tests/fixture-semuta-combat-declarations.ts) and
[cases](../tests/semuta-combat-declarations-engine.test.ts) use actual BG setup,
prediction, reserve shipment2/cost2 into original Atreides Arrakeen10, native
battle selection and both pre-leader ready inputs. Voice87/98 Basic/Advanced
actions retain the original next Prescience preparation. Prescience88/99 actually
declines Voice before declaring weapon; its original opponent answer remains
unanswered through the cost. Prevention asks no answer; allowance receives the
actual null weapon answer. Both later seal legal unarmed plans in the same battle.
[Force sources](../tests/fixture-semuta-combat-forces.ts) and
[cases](../tests/semuta-combat-forces-engine.test.ts) use actual Richese normal
shipment1/cost2 and Fremen/Emperor elite entry. Fedaykin94, Sardaukar99 and free
support99 actions retain original native strength, half-step dials and actual
later checks. Sardaukar's positive source faces Richese, respecting its Fremen
exception. CHOAM aid203 earns the real Nexus alliance and reserves pledge1 before
the response. Cancellation preserves that escrow but removes access to its
funding; native allowed/canceled plan proposals prove the difference without
charging support before outcome. An independent originally dealt Emperor Karama
keeps later checks explicit while Guild spends its distinct printed cost.
[Capability sources](../tests/fixture-semuta-combat-capabilities.ts) and
[cases](../tests/semuta-combat-capabilities-engine.test.ts) earn KH163 through
a real seven-counter battle loss, leaving original Arrakeen3. The actual turn2
opposing shipment creates a second battle with earned KH, including a companion
whose Traitor Emperor really holds. Allowed KH adds2 and prevents that real call;
cancellation removes both while retaining the seven-loss history and component.
Basic Cyborg100 actually pays for its original Polar Sink shipment and moves the
physical elite to the opposing group; dial2 is legal before prevention and dial1
after. The native typed pool and Suboid rule are unchanged.
Feedback repairs only the KH earning source's dial7/support0 proposal: seven
unsupported Advanced normals require strength3.5/support0. The source assertion
is corrected too; the actual seven casualties and native KH permission remain.
No gameplay guard is relaxed. Private source-bound report/logs:
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-combat-proof/`;
journeys/HTML: adjacent `20261026-semuta-combat-runtime/`.
No saved-room/database/server/reset/restart/deployment/recovery operation occurs.
Engine/SSR/callback evidence leaves hydration, DOM/browser/mobile, direct
coexisting full-plan acceptance, live borrowed Voice and other wider parents/
combinations, complete modes and exact-revision deployed acceptance open.
## Storm and worm cancellation costs — 26 October 2026
**Prototyped / Partial.** Printed-role Karama stopping native Fremen Storm
+63 -10
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';
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard' | 'karamaDisasterDiscard' | 'karamaCombatDiscard';
player: string;
card: string;
use: Extract<KaramaUse, { kind: 'cancel' }>;
@@ -5952,9 +5952,40 @@ function semutaDisasterKaramaResponse(g: Game, response: ResponseWindow, player:
throw error;
}
}
/** Native combat quotes validate the declared source without granting its
* benefit, answering preparation, revealing a plan or advancing the queue. */
function semutaCombatKaramaResponse(g: Game, response: ResponseWindow, player: string,
decision: Game['decision'] = g.decision): boolean {
if (!isCombatResponseKind(response.kind) || !g.battle ||
g.battle.nexusBorrowedVoice?.stage === 'declared' ||
g.phaseOpening || g.pendingKarama || g.truthtrance ||
g.pendingRevival || g.pendingShipment || g.pendingGuildTransport || g.pendingHomeworldShipment ||
g.pendingFremenMove || g.pendingIxMove || g.pendingMobileMove || g.pendingChoamMove ||
g.pendingSpiritualAdvisor || g.pendingAmbassador || g.pendingTerrorEntry ||
g.pendingArrivalOverlap || g.pendingArrivalReaction?.length || g.ornithopter ||
g.summonedWorm || g.wormRides.length > 0 || g.choamMarket || g.pendingChoamMarketGhola) return false;
// A declared Voice can already have finished ordinary preparation. Preserve
// that exact native full-plan offer; do not initialize or answer it again.
if (decision) {
const atreides = g.players.find(p => p.faction === 'atreides');
if (response.kind !== 'voice' || decision.kind !== 'fullPlanOffer' || !g.advanced ||
!atreides || decision.player !== atreides.id || atreides.specialKaramaUsed ||
g.battle.preparation || g.battle.fullPlanOffered !== true || Object.keys(g.battle.plans).length > 0)
return false;
}
if (g.decision && JSON.stringify(g.decision) !== JSON.stringify(decision)) return false;
const context: Game = {...g,pendingTreacheryDiscard:null,response,decision};
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' }
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard' | 'karamaDisasterDiscard' | 'karamaCombatDiscard' }
>;
function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) {
const battle = c.kind === 'karamaBattleDiscard';
@@ -5970,6 +6001,7 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
const movement = c.kind === 'karamaMovementDiscard';
const revival = c.kind === 'karamaRevivalDiscard';
const disaster = c.kind === 'karamaDisasterDiscard';
const combat = c.kind === 'karamaCombatDiscard';
return JSON.stringify({
player: c.player, card: c.card, use: c.use,
parent: nullentropyParentSignature(g, c.resume,
@@ -6115,12 +6147,22 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
advisors:p.advisors, noField:p.noField, noFieldEvent:p.noFieldEvent,
wormBlocked:p.fremenNexusWormBlockedTurn})),
} : undefined,
combat: combat ? {
battle:g.battle, aid:g.aid, lastBattle:g.lastBattle, lastContext:g.lastBattleContext,
battleOrder:g.battleOrder, orderUses:g.battleOrderUseEvents,
skills:g.leaderSkills, duke:g.dukeVidal, tech:g.techTokens, nexus:g.nexusCards,
homeworlds:g.homeworlds, occupation:g.homeworldOccupationHistory,
banker:g.spiceBankerIncome, bureaucrat:g.bureaucratPayments,
players:g.players.map(p => ({id:p.id, losses:p.battleLosses, kwisatz:p.kwisatz,
leaders:p.leaders, specialUsed:p.specialKaramaUsed, allySince:p.allySinceTurn,
advisors:p.advisors, noField:p.noField, noFieldEvent:p.noFieldEvent})),
} : 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 ? p.shipped : undefined,
physical: battle || worthless || income || shipment || conversion || terminal || movement || revival || disaster
shipped: income || shipment || conversion || terminal || movement || disaster || combat ? p.shipped : undefined,
physical: battle || worthless || income || shipment || conversion || terminal || movement || revival || disaster || combat
? { forces: p.forces, reserves: p.reserves, tanks: p.tanks,
elites: p.elites, leaders: battle ? p.leaders : undefined,
noField: shipment ? p.noField : undefined,
@@ -7038,6 +7080,7 @@ function treacheryDiscardIntegrity(g: Game) {
continuation?.kind === 'karamaMovementDiscard' ||
continuation?.kind === 'karamaRevivalDiscard' ||
continuation?.kind === 'karamaDisasterDiscard' ||
continuation?.kind === 'karamaCombatDiscard' ||
continuation?.kind === 'karamaShipmentDiscard' ||
continuation?.kind === 'karamaBgConversionCancelDiscard') {
const c = continuation, entry = batch.entries[0];
@@ -7076,6 +7119,8 @@ function treacheryDiscardIntegrity(g: Game) {
semutaRevivalKaramaResponse(g,c.use.response,c.player)) ||
(c.kind === 'karamaDisasterDiscard' &&
semutaDisasterKaramaResponse(g,c.use.response,c.player)) ||
(c.kind === 'karamaCombatDiscard' &&
semutaCombatKaramaResponse(g,c.use.response,c.player,c.resume.decision)) ||
(c.kind === 'karamaShipmentDiscard' &&
semutaGuildShipmentKaramaResponse(g, c.use.response)) ||
(c.kind === 'karamaBgConversionCancelDiscard' &&
@@ -7084,7 +7129,7 @@ function treacheryDiscardIntegrity(g: Game) {
c.resume.response?.kind === c.use.response.kind &&
JSON.stringify(c.resume.response) === JSON.stringify(c.use.response) &&
(c.kind === 'karamaBgConversionCancelDiscard' ||
((c.kind === 'karamaTerminalDiscard' || !c.resume.decision) &&
((c.kind === 'karamaTerminalDiscard' || c.kind === 'karamaCombatDiscard' || !c.resume.decision) &&
!c.resume.pendingKarama && !c.resume.phaseOpening)) &&
batch.cause === `karama:${c.use.response.kind}` &&
batch.entries.length === 1 &&
@@ -7983,6 +8028,7 @@ function semutaOfferSupported(
const movementDiscard = continuation.kind === 'karamaMovementDiscard';
const revivalDiscard = continuation.kind === 'karamaRevivalDiscard';
const disasterDiscard = continuation.kind === 'karamaDisasterDiscard';
const combatDiscard = continuation.kind === 'karamaCombatDiscard';
const bgDiscard = continuation.kind === 'bgKaramaCostDiscard' ||
continuation.kind === 'karamaBgConversionCancelDiscard';
const ambassadorDiscard = continuation.kind === 'ambassador';
@@ -8021,7 +8067,7 @@ function semutaOfferSupported(
continuation.kind !== 'karamaChoamWorthlessDiscard' &&
continuation.kind !== 'karamaEmperorShareDiscard' &&
continuation.kind !== 'karamaIncomeDiscard' &&
!richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && !disasterDiscard &&
!richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && !disasterDiscard && !combatDiscard &&
continuation.kind !== 'karamaShipmentDiscard' &&
continuation.kind !== 'karamaShipmentPriceDiscard' &&
continuation.kind !== 'specialKaramaDiscard' &&
@@ -8058,7 +8104,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 && continuation.kind !== 'kaitainDiscard' &&
if (g.homeworlds && !bgDiscard && !richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && !disasterDiscard && !combatDiscard && continuation.kind !== 'kaitainDiscard' &&
continuation.kind !== 'tupileCleanupDiscard' &&
continuation.kind !== 'karamaIncomeDiscard' &&
continuation.kind !== 'karamaShipmentDiscard' &&
@@ -8166,6 +8212,7 @@ function semutaOfferSupported(
movementDiscard ||
revivalDiscard ||
disasterDiscard ||
combatDiscard ||
continuation.kind === 'karamaShipmentDiscard'
? (continuation.kind === 'karamaBattleDiscard'
? semutaBattleKaramaResponse(g, continuation.use.response)
@@ -8187,10 +8234,12 @@ function semutaOfferSupported(
? semutaRevivalKaramaResponse(g,continuation.use.response,continuation.player)
: disasterDiscard
? semutaDisasterKaramaResponse(g,continuation.use.response,continuation.player)
: combatDiscard
? semutaCombatKaramaResponse(g,continuation.use.response,continuation.player,continuation.resume.decision)
: continuation.kind === 'karamaShipmentDiscard'
? semutaGuildShipmentKaramaResponse(g, continuation.use.response)
: ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) &&
(terminalDiscard || !continuation.resume.decision) && !continuation.resume.pendingKarama &&
(terminalDiscard || combatDiscard || !continuation.resume.decision) && !continuation.resume.pendingKarama &&
!continuation.resume.phaseOpening && !g.pendingKarama &&
(!g.response || JSON.stringify(g.response) === JSON.stringify(continuation.resume.response))
: true) &&
@@ -8226,6 +8275,7 @@ function semutaOfferSupported(
movementDiscard ||
revivalDiscard ||
disasterDiscard ||
combatDiscard ||
continuation.kind === 'karamaShipmentDiscard' ||
((continuation.kind === 'ordinaryCardDiscard'
? ordinaryDiscardIncomeMatches(g, continuation)
@@ -8256,7 +8306,7 @@ function semutaOfferSupported(
continuation.kind === 'ixAllyCard' ||
(continuation.kind === 'karamaIncomeDiscard' &&
continuation.use.response.kind === 'emperorIncome') || !g.richeseAuction) &&
(richeseGiftDiscard || continuation.kind === 'saphoAggressorDiscard' ||
(richeseGiftDiscard || combatDiscard || continuation.kind === 'saphoAggressorDiscard' ||
bgDiscard ||
continuation.kind === 'specialKaramaDiscard' ||
continuation.kind === 'residualPoisonDiscard' ||
@@ -8586,6 +8636,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
next.kind === 'karamaMovementDiscard' ||
next.kind === 'karamaRevivalDiscard' ||
next.kind === 'karamaDisasterDiscard' ||
next.kind === 'karamaCombatDiscard' ||
next.kind === 'karamaShipmentDiscard' ||
next.kind === 'karamaBgConversionCancelDiscard' ||
next.kind === 'karamaPurchaseDiscard' ||
@@ -15944,6 +15995,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
semutaMovementKaramaResponse(g,use.response,p.id) ||
semutaRevivalKaramaResponse(g,use.response,p.id) ||
semutaDisasterKaramaResponse(g,use.response,p.id) ||
semutaCombatKaramaResponse(g,use.response,p.id) ||
guildShipmentDiscard || bgConversionCancelDiscard)) {
const continuation: KaramaResponseDiscardContinuation = {
kind: bgConversionCancelDiscard ? 'karamaBgConversionCancelDiscard'
@@ -15955,6 +16007,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
: ['fremenMovement','ixMovement','mobileStronghold','guildTiming'].includes(use.response.kind) ? 'karamaMovementDiscard'
: ['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 === 'choamCharity' ? 'karamaCharityDiscard'
: use.response.kind === 'choamInflation'
? 'karamaInflationDiscard'
@@ -15975,7 +16028,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
g.response = null;
if (bgConversionCancelDiscard) {
g.decision = null; g.pendingKarama = null; g.phaseOpening = null;
} else if (continuation.kind === 'karamaTerminalDiscard') g.decision = null;
} else if (continuation.kind === 'karamaTerminalDiscard' || continuation.kind === 'karamaCombatDiscard') g.decision = null;
continuation.stateSignature = karamaResponseDiscardSignature(g, continuation);
stageTreacheryDiscard(g, `karama:${use.response.kind}`, entries, continuation);
return;
+7
View File
@@ -1293,6 +1293,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Printed-role Karama stopping clean native Tleilaxu revival income offers that exact used cost before income prevention. The ordinary or Ghola revival has already completed: returned forces/leaders, actual payment, normal/free/elite allowance use and the free-income history stay unchanged. Claim or Continue withholds the original income once; taking its counter cannot repeat the revival or recover an already discarded Ghola. Ghola’s own earlier discard is a separate opportunity. Unfinished battle, Ambassador, market and arrival parents retain their separate boundaries.',
'Printed-role Karama stopping 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.',
'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.',
@@ -1440,6 +1441,12 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/semuta-disaster-storm-engine.test.ts',
'tests/fixture-semuta-disaster-worm.ts',
'tests/semuta-disaster-worm-engine.test.ts',
'tests/fixture-semuta-combat-declarations.ts',
'tests/semuta-combat-declarations-engine.test.ts',
'tests/fixture-semuta-combat-forces.ts',
'tests/semuta-combat-forces-engine.test.ts',
'tests/fixture-semuta-combat-capabilities.ts',
'tests/semuta-combat-capabilities-engine.test.ts',
'tests/fixture-semuta-spiritual-foresight.ts',
'tests/semuta-spiritual-foresight-engine.test.ts',
'tests/fixture-semuta-storm-dancer.ts',
+299
View File
@@ -0,0 +1,299 @@
import assert from 'node:assert/strict';
import {
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game,
} from '../game/engine';
import { gameDistance, location, splitLocation, TERRITORIES } from '../game/board';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { actTruthMovement } from './fixture-truth-movement';
import { nextSemutaIxianAuctionStep } from './fixture-semuta-ixian-auction';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
export type SemutaCombatCapabilitiesKind = 'kwisatz' | 'ixElite';
export type SemutaCombatCapabilitiesStep = { actor: string; action: Action };
export const actSemutaCombatCapabilities = actTruthMovement;
export const semutaCombatCapabilitiesPlayer = (game: Game, id: string) => {
const player = game.players.find(p => p.id === id); assert.ok(player); return player;
};
export const semutaCombatCapabilitiesClean = (game: Game) => !game.response && !game.decision &&
!game.phaseOpening && !game.truthtrance && !game.pendingTreacheryDiscard;
export function semutaCombatCapabilitiesInventory(game: Game): string[] {
const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []), ...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'Every original main/cache card retains one physical zone');
return ids;
}
/** Native acknowledgements only. A legal Battle Plan is always caller-owned. */
export function nextSemutaCombatCapabilities(game: Game): SemutaCombatCapabilitiesStep | null {
if (game.truthtrance) return null;
const pending = game.pendingTreacheryDiscard;
if (pending) {
const reaction = pending.reaction;
if (reaction?.stage !== 'offer') return null;
const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor);
return { actor: actor.id, action: { type: 'semutaPass', event: pending.batch.event } };
}
if (game.phaseOpening) {
const actor = game.players.find(p => !game.phaseOpening!.passed.includes(p.id)); assert.ok(actor);
return { actor: actor.id, action: { type: 'ready' } };
}
if (game.response) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
return { actor: actor.id, action: { type: 'passResponse' } };
}
if (game.status === 'setup' && game.setupStage === 'traitors') {
const emperor = semutaCombatCapabilitiesPlayer(game, 'emperor');
const atreides = game.players.find(p => p.faction === 'atreides');
const held = emperor.traitorChoices.find(id => atreides?.leaders.some(leader => leader.id === id));
if (held) return { actor: emperor.id, action: { type: 'traitor', leader: held } };
}
const decision = game.decision;
if (decision?.kind === 'fullPlanOffer')
return { actor: decision.player, action: { type: 'decision', decline: true } };
if (decision?.kind === 'battleCards')
return { actor: decision.player, action: { type: 'decision', discard: [] } };
if (decision?.kind === 'ixSubstitution')
return { actor: decision.player, action: { type: 'decision', decline: true } };
if (decision?.kind === 'battleLosses')
return { actor: decision.player, action: { type: 'decision', choice: 0 } };
const battle = game.battle;
if (!decision && battle?.preLeader && !battle.preLeader.closed) {
const actor = [battle.attacker, battle.defender].find(id => !battle.preLeader!.ready.includes(id)); assert.ok(actor);
return { actor, action: { type: 'battlePreparationReady', event: battle.preLeader.event } };
}
if (!decision && battle?.preparation)
return { actor: battle.preparation.owner, action: { type: 'declineBattlePower' } };
if (!decision && battle?.revealed) {
const voter = viewGame(game, battle.attacker).battle!.traitorVoters.find(id => battle.traitorCalls[id] === undefined);
if (voter) return { actor: voter, action: { type: 'traitorCall', call: false } };
if (battle.outcomeWindow && !battle.outcomeWindow.closed) {
const actor = [battle.attacker, battle.defender].find(id => !battle.outcomeWindow!.ready.includes(id)); assert.ok(actor);
return { actor, action: { type: 'battleOutcomeReady', event: battle.outcomeWindow.event } };
}
}
if (!decision && battle && !battle.revealed) return null;
return nextSemutaIxianAuctionStep(game);
}
export function advanceSemutaCombatCapabilities(game: Game, until: (state: Game) => boolean,
actions: SemutaCombatCapabilitiesStep[] = []): Game {
for (let count = 0; count < 2400; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextSemutaCombatCapabilities(game);
assert.ok(next, `Supply the real owned control at ${game.turn}/${game.phase}/${game.decision?.kind}/${game.response?.kind}`);
game = actSemutaCombatCapabilities(game, next.actor, next.action, actions);
}
throw Error('The native combat capability programme did not reach its finite boundary');
}
export function passSemutaCombatCapabilities(game: Game, actions: SemutaCombatCapabilitiesStep[] = []): Game {
const event = game.pendingTreacheryDiscard?.batch.event; assert.ok(event);
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) {
const next = nextSemutaCombatCapabilities(game); assert.ok(next && next.action.type === 'semutaPass');
game = actSemutaCombatCapabilities(game, next.actor, next.action, actions);
}
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game;
}
export function claimSemutaCombatCapabilities(game: Game, holder: string,
actions: SemutaCombatCapabilitiesStep[] = []): Game {
const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.batch.entries.length === 1);
const event = pending.batch.event, card = pending.batch.entries[0].card.id;
game = actSemutaCombatCapabilities(game, holder, { type: 'semutaCommit', event }, actions);
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = actSemutaCombatCapabilities(game, holder, { type: 'semutaSelect', event, card }, actions);
return game;
}
export function allowSemutaCombatCapabilities(game: Game, actions: SemutaCombatCapabilitiesStep[] = []): Game {
const kind = game.response?.kind; assert.ok(kind === 'kwisatz' || kind === 'eliteStrength');
for (let count = 0; game.response?.kind === kind && count < game.players.length; count++) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
game = actSemutaCombatCapabilities(game, actor.id, { type: 'passResponse' }, actions);
}
assert.notEqual(game.response?.kind, kind); return game;
}
/** Finish only real preparation. Sealing plans remains a separate caller act. */
export function finishSemutaCombatCapabilities(game: Game, actions: SemutaCombatCapabilitiesStep[] = []): Game {
return advanceSemutaCombatCapabilities(game, state => !!state.battle &&
semutaCombatCapabilitiesClean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false, actions);
}
function leader(game: Game, owner: string, strongest = true) {
const choices = semutaCombatCapabilitiesPlayer(game, owner).leaders.filter(row => !row.dead && !row.capturedBy && !row.usedAt)
.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength);
assert.ok(choices[0]); return choices[0];
}
/** Original setup, unopened deals/Spice and paid cache1 only. KH is earned by
* actual seven-counter battle loss; Cyborgs actually ship and move to battle.
* No played hand, force, wallet, casualty, clock or pending control is installed. */
export function createSemutaCombatCapabilitiesFixture(options: { kind: SemutaCombatCapabilitiesKind }) {
const kind = options.kind, advanced = kind === 'kwisatz';
const richese = 'richese', holder = 'choam', owner = advanced ? 'atreides' : 'ixians', spender = 'emperor';
let game = createGame('SEMUTACOMBATCAPABILITIES', newPlayer(richese, 'Richese', 'richese'), advanced,
advanced ? ['choam'] : ['choam', 'ix']);
for (const faction of ['choam', advanced ? 'atreides' as const : 'ixians' as const, 'emperor'] as const)
joinGame(game, newPlayer(faction, faction, faction));
const actions: SemutaCombatCapabilitiesStep[] = [];
const act = (actor: string, action: Action) => { game = actSemutaCombatCapabilities(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaCombatCapabilities(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) };
};
if (!advanced) {
act(owner, { type: 'seatPosition', position: 5 });
act(spender, { type: 'seatPosition', position: 3 });
}
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));
const originalInventory = semutaCombatCapabilitiesInventory(game);
assert.equal(originalInventory.length, advanced ? 45 : 57);
const costCard = game.deck.find(card => card.effect === 'karama' && !card.id.startsWith('ix-'));
const shield = game.deck.find(card => card.kind === 'shield');
const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 3);
assert.ok(costCard && shield); assert.equal(ordinary.length, 3);
const selected = [ordinary[0].id, ordinary[1].id, shield.id, costCard.id,
...(!advanced ? [ordinary[2].id] : [])];
const dealOrder = selected.map((card, position) => {
const from = game.deck.findIndex(row => row.id === card); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { card, position, from };
});
const beforeStartingDeal = structuredClone(game), cost = costCard.id;
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
assert.ok(semutaCombatCapabilitiesPlayer(game, spender).hand.some(card => card.id === cost));
assert.ok(semutaCombatCapabilitiesPlayer(game, owner).hand.some(card => card.id === shield.id));
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(), front: Game['spiceDeck'] = [];
for (let index = 0; index < (advanced ? 4 : 2); index++) {
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
front.push(game.spiceDeck.splice(at, 1)[0]);
}
game.spiceDeck.unshift(...front);
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
let beforeBid: Game | undefined, bidAt = -1;
for (let count = 0; count < 900 && !semutaCombatCapabilitiesPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
const next = nextSemutaCombatCapabilities(game); assert.ok(next);
if (next.actor === holder && next.action.type === 'richeseBid' && next.action.amount === 1) {
beforeBid = structuredClone(game); bidAt = actions.length;
}
act(next.actor, next.action);
}
assert.ok(beforeBid && bidAt >= 0);
const acquired = structuredClone(game), acquisition = { before: beforeBid,
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
assert.equal(semutaCombatCapabilitiesPlayer(beforeBid, holder).spice - semutaCombatCapabilitiesPlayer(game, holder).spice, 1);
assert.equal(semutaCombatCapabilitiesPlayer(game, richese).spice - semutaCombatCapabilitiesPlayer(beforeBid, richese).spice, 1);
const shipments: ReturnType<typeof transition>[] = [], moves: ReturnType<typeof transition>[] = [];
let earningBattle: { before: Game; steps: SemutaCombatCapabilitiesStep[]; after: Game } | null = null;
let territory = 'imperial_basin', sector = 9;
if (!advanced) {
const candidate = TERRITORIES.filter(row => row.type === 'sand').flatMap(row => row.sectors.map(s => location(row.id, s)))
.find(key => splitLocation(key).sector !== game.storm && gameDistance(game, 'polar_sink:0', key,
point => splitLocation(point).sector === game.storm) === 1);
assert.ok(candidate, 'The native Cyborg needs a real adjacent desert destination');
({ territory, sector } = splitLocation(candidate));
}
let ownerTurn = false, opponentTurn = false;
reach(state => state.turn === 1 && state.phase === 5 && semutaCombatCapabilitiesClean(state));
while (game.turn === 1 && game.phase === 5) {
if (!semutaCombatCapabilitiesClean(game)) {
const next = nextSemutaCombatCapabilities(game); assert.ok(next); act(next.actor, next.action); continue;
}
const actor = game.active; assert.ok(actor);
if (actor === spender && !opponentTurn) {
shipments.push(transition(() => {
act(spender, { type: 'ship', territory, sector, amount: 1, elite: 0, allyPayment: 0 });
reach(semutaCombatCapabilitiesClean);
}));
opponentTurn = true;
}
if (actor === owner && !ownerTurn) {
if (!advanced) {
assert.ok(opponentTurn, 'The native Emperor ships before the Cyborg moves into its group');
shipments.push(transition(() => {
act(owner, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 1, allyPayment: 0 });
reach(semutaCombatCapabilitiesClean);
}));
}
moves.push(transition(() => {
act(owner, advanced
? { type: 'move', from: 'arrakeen:10', amount: 7, elite: 0, territory, sector }
: { type: 'move', forces: { 'polar_sink:0': 1 }, eliteForces: { 'polar_sink:0': 1 }, territory, sector });
reach(semutaCombatCapabilitiesClean);
}));
ownerTurn = true;
}
act(actor, { type: 'endMovement' });
}
assert.ok(ownerTurn && opponentTurn);
const choose = () => {
const actor = game.active; assert.ok(actor === owner || actor === spender);
act(actor, { type: 'chooseBattle', territory, target: actor === owner ? spender : owner });
};
reach(state => state.phase === 6 && !state.battle && semutaCombatCapabilitiesClean(state));
if (advanced) {
const before = structuredClone(game), start = actions.length;
assert.equal(semutaCombatCapabilitiesPlayer(game, owner).battleLosses, 0);
choose(); game = finishSemutaCombatCapabilities(game, actions);
const strong = leader(game, owner), weak = leader(game, spender, false);
assert.equal(strong.strength, 5);
act(owner, { type: 'battlePlan', leader: strong.id, dial: 3.5, support: 0 });
act(spender, { type: 'battlePlan', leader: weak.id, dial: 0, support: 0 });
reach(state => !state.battle && !state.pendingTreacheryDiscard);
assert.equal(game.lastBattleContext?.winner, owner);
assert.equal(semutaCombatCapabilitiesPlayer(game, owner).battleLosses, 7);
assert.equal(semutaCombatCapabilitiesPlayer(game, owner).tanks, 7);
assert.equal(semutaCombatCapabilitiesPlayer(game, owner).forces['arrakeen:10'], 3);
earningBattle = { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
reach(state => state.turn === 2 && state.phase === 5 && state.active === spender && semutaCombatCapabilitiesClean(state));
territory = 'arrakeen'; sector = 10;
shipments.push(transition(() => {
act(spender, { type: 'ship', territory, sector, amount: 1, elite: 0, allyPayment: 0 });
reach(semutaCombatCapabilitiesClean);
}));
act(spender, { type: 'endMovement' });
reach(state => state.phase === 6 && !state.battle && semutaCombatCapabilitiesClean(state));
}
const beforeBattle = structuredClone(game), openingAt = actions.length;
choose();
const responseKind = advanced ? 'kwisatz' as const : 'eliteStrength' as const;
reach(state => state.response?.kind === responseKind && state.response.owner === owner);
assert.ok(game.battle && !game.battle.revealed);
assert.equal(game.battle.territory, territory);
assert.ok(semutaCombatCapabilitiesPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
assert.ok(semutaCombatCapabilitiesPlayer(game, spender).hand.some(card => card.id === cost));
if (!advanced) {
assert.equal(semutaCombatCapabilitiesPlayer(game, owner).elites?.forces[location(territory, sector)], 1);
assert.equal(viewGame(game, owner).battle?.ownForces?.eliteStrength, 2);
}
const traitorLeader = advanced ? semutaCombatCapabilitiesPlayer(game, owner).leaders.find(row =>
semutaCombatCapabilitiesPlayer(game, spender).traitors.includes(row.id) && !row.dead) : undefined;
if (advanced) assert.ok(traitorLeader, 'The original dealt Emperor Traitor supplies a real KH protection test');
const ownerLeader = traitorLeader ?? leader(game, owner), opponentLeader = leader(game, spender, false);
const ownerPlan: Action = { type: 'battlePlan', leader: ownerLeader.id, dial: 0, support: 0 };
const opponentPlan: Action = { type: 'battlePlan', leader: opponentLeader.id, dial: 0, support: 0 };
const allowedPlan: Action = advanced ? { ...ownerPlan, kwisatz: true } : { ...ownerPlan, dial: 2 };
const canceledPlan: Action = advanced ? { ...ownerPlan, kwisatz: false } : { ...ownerPlan, dial: 1 };
assert.deepEqual(semutaCombatCapabilitiesInventory(game), originalInventory);
return { game, kind, responseKind, advanced, holder, spender, owner, richese, cost,
semuta: SEMUTA_DRUG_ID, action: { type: 'card', card: cost, mode: 'cancel' } as Action,
originalResponse: structuredClone(game.response!), originalBattle: structuredClone(game.battle!),
beforeBattle, openingSteps: structuredClone(actions.slice(openingAt)), ownerPlan, opponentPlan, allowedPlan, canceledPlan,
combatant: owner, opponent: spender,
ownerLeader: ownerLeader.id, opponentLeader: opponentLeader.id, territory, sector,
earningBattle, shipments, moves, setup, lobby, beforeStartingDeal, afterSetup, dealOrder,
acquired, acquisition, originalInventory, originalSpice, actions, sourceActionCount: actions.length,
staging: [
'Only original unopened main/Spice positions and native scalar lotteries are selected. Original setup deals the printed Karama to Emperor and the real Shield to the capability owner; CHOAM buys the original cache Semuta for one spice.',
advanced ? 'Actually move seven original Atreides counters out of Arrakeen, ship the opposing Emperor counter, allow native preparation, seal unarmed plans, decline both Traitor calls and finish the shared outcome. All seven dialed counters really enter Tanks; a later actual Arrakeen shipment creates the earned-KH battle.'
: 'Original lobby circle choices give Emperor its combined turn first. Emperor actually ships the opposing desert counter; Ix actually pays to ship one Cyborg to Polar Sink, then moves that physical Cyborg into the occupied adjacent desert. Original HMS setup and every remaining native choice are retained.',
'Stop before the actual capability counter cost. No battle, casualty, skill, hand, wallet, clock or pending control is installed. Later Semuta/preparation/plan choices remain real caller actions.',
] };
}
export type SemutaCombatCapabilitiesFixture = ReturnType<typeof createSemutaCombatCapabilitiesFixture>;
+224
View File
@@ -0,0 +1,224 @@
import assert from 'node:assert/strict';
import {
createGame, initializeSemutaGameForAudit, joinGame, newPlayer,
type Action, type Game, type Plan,
} from '../game/engine';
import { gameTerritories } from '../game/board';
import { isAdvisor } from '../game/advisors';
import { quoteCombatResponse } from '../game/combat-response-quote';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
import {
actSemutaSpiritualForesight, nextSemutaSpiritualForesight,
semutaSpiritualForesightClean, semutaSpiritualForesightInventory, semutaSpiritualForesightPlayer,
} from './fixture-semuta-spiritual-foresight';
import { passSemutaFremenGuildMovement, claimSemutaFremenGuildMovement } from './fixture-semuta-fremen-guild-movement';
export type SemutaCombatDeclarationsKind = 'voice' | 'prescience';
export type SemutaCombatDeclarationsStep = { actor: string; action: Action };
export const actSemutaCombatDeclarations = actSemutaSpiritualForesight;
export const passSemutaCombatDeclarations = passSemutaFremenGuildMovement;
export const claimSemutaCombatDeclarations = claimSemutaFremenGuildMovement;
export const semutaCombatDeclarationsPlayer = semutaSpiritualForesightPlayer;
export const semutaCombatDeclarationsInventory = semutaSpiritualForesightInventory;
export const semutaCombatDeclarationsClean = semutaSpiritualForesightClean;
/** Original preparation controls only; declaration counters and fresh costs
* remain explicit. No plan or battle result is chosen by the native driver. */
export function nextSemutaCombatDeclarations(game: Game): SemutaCombatDeclarationsStep | null {
if (game.pendingTreacheryDiscard || game.truthtrance ||
game.response?.kind === 'voice' || game.response?.kind === 'prescience') return null;
if (!game.response && !game.phaseOpening && game.battle) {
if (game.decision?.kind === 'fullPlanOffer') return { actor: game.decision.player,
action: { type: 'decision', decline: true } };
const b = game.battle;
if (!game.decision && b.preLeader && !b.preLeader.closed) {
const actor = [b.attacker, b.defender].find(player => !b.preLeader!.ready.includes(player)); assert.ok(actor);
return { actor, action: { type: 'battlePreparationReady', event: b.preLeader.event } };
}
if (!game.decision && b.preparation) {
if (b.preparation.kind === 'prescienceAnswer') {
assert.equal(b.prescience?.field, 'weapon');
return { actor: b.preparation.owner, action: { type: 'prescienceAnswer', value: null } };
}
assert.ok(b.preparation.kind === 'voice' || b.preparation.kind === 'prescience');
return { actor: b.preparation.owner, action: { type: 'declineBattlePower' } };
}
if (!game.decision) return null;
}
return nextSemutaSpiritualForesight(game);
}
export function advanceSemutaCombatDeclarations(game: Game, until: (state: Game) => boolean,
actions: SemutaCombatDeclarationsStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextSemutaCombatDeclarations(game);
assert.ok(step, 'Choose the original declaration, fresh cost or private plan before continuing');
game = actSemutaCombatDeclarations(game, step.actor, step.action, actions);
}
throw Error('The original combat-declaration programme did not reach its finite boundary');
}
/** Pass only the original declared combat advantage. Its next preparation or
* pending opponent answer remains a separate original control. */
export function allowSemutaCombatDeclarations(game: Game,
actions: SemutaCombatDeclarationsStep[] = []): Game {
const kind = game.response?.kind; assert.ok(kind === 'voice' || kind === 'prescience');
for (let count = 0; count < game.players.length && game.response?.kind === kind; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actSemutaCombatDeclarations(game, actor.id, { type: 'passResponse' }, actions);
}
assert.notEqual(game.response?.kind, kind); return game;
}
/** Continue native preparation to legal private-plan readiness. In particular,
* an allowed Prescience gets its real null weapon answer; a prevented one does
* not. No plans, payments, result acknowledgements or fresh discards are made. */
export function finishSemutaCombatDeclarations(game: Game,
actions: SemutaCombatDeclarationsStep[] = []): Game {
for (let count = 0; count < 40; count++) {
assert.equal(game.pendingTreacheryDiscard ?? null, null, 'Resolve the actual fresh cost first');
assert.ok(game.battle && !game.battle.revealed);
assert.ok(game.response?.kind !== 'voice' && game.response?.kind !== 'prescience',
'Resolve the original declared power first');
if (!game.response && !game.decision && !game.battle.preparation &&
(!game.battle.preLeader || game.battle.preLeader.closed)) return game;
const step = nextSemutaCombatDeclarations(game); assert.ok(step);
game = actSemutaCombatDeclarations(game, step.actor, step.action, actions);
}
throw Error('The original combat declaration did not reach private-plan readiness');
}
/** Genuine Basic/public or original Advanced Semuta setup, paid cache1, and
* direct BG fighter shipment into Atreides' native Arrakeen garrison. Every
* setup, stance, battle, shared readiness and declaration is an actual action. */
export function createSemutaCombatDeclarationsFixture(options: {
kind: SemutaCombatDeclarationsKind; advanced?: boolean;
}) {
const kind = options.kind, advanced = options.advanced ?? false;
const richese = 'richese', holder = 'choam', bg = 'beneGesserit', atreides = 'atreides', spender = 'emperor';
const owner = kind === 'voice' ? bg : atreides;
let game = createGame('SEMUTACOMBATDECLARATIONS', newPlayer(richese, 'Richese', 'richese'), advanced, ['choam']);
for (const faction of [holder, bg, atreides, spender] as const) joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: SemutaCombatDeclarationsStep[] = [];
const act = (actor: string, action: Action) => { game = actSemutaCombatDeclarations(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaCombatDeclarations(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 = semutaCombatDeclarationsInventory(game); assert.equal(originalInventory.length, 45);
assert.equal(game.deck.length, 35); assert.equal(game.richeseCache!.length, 10);
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 4);
assert.equal(ordinary.length, 4);
const 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);
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal);
assert.equal(semutaCombatDeclarationsPlayer(game, bg).forces['polar_sink:0'], 1);
assert.equal(semutaCombatDeclarationsPlayer(game, atreides).forces['arrakeen:10'], 10);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
for (let position = 0; position < (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: SemutaCombatDeclarationsStep | undefined, bidAt = -1;
for (let count = 0; count < 900 && !semutaCombatDeclarationsPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
const step = nextSemutaCombatDeclarations(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(semutaCombatDeclarationsPlayer(beforeBid, holder).spice - semutaCombatDeclarationsPlayer(game, holder).spice, 1);
assert.equal(semutaCombatDeclarationsPlayer(game, richese).spice - semutaCombatDeclarationsPlayer(beforeBid, richese).spice, 1);
reach(state => state.turn === 1 && state.phase === 5 && state.active === bg && semutaCombatDeclarationsClean(state));
const shipmentAction: Action = { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2, elite: 0, allyPayment: 0 };
const shipment = transition(() => { act(bg, shipmentAction); reach(semutaCombatDeclarationsClean); });
assert.equal(semutaCombatDeclarationsPlayer(shipment.before, bg).spice - semutaCombatDeclarationsPlayer(game, bg).spice, 2);
assert.equal(semutaCombatDeclarationsPlayer(game, bg).forces['arrakeen:10'], 2);
assert.equal(isAdvisor(semutaCombatDeclarationsPlayer(game, bg), 'arrakeen'), false);
act(bg, { type: 'endMovement' });
reach(state => state.turn === 1 && state.phase === 6 && !state.battle && semutaCombatDeclarationsClean(state));
const beforeBattle = structuredClone(game), chooser = game.active!;
assert.ok(chooser === bg || chooser === atreides);
const battleAction: Action = { type: 'chooseBattle', territory: 'arrakeen', target: chooser === bg ? atreides : bg };
const battle = transition(() => {
act(chooser, battleAction);
reach(state => !state.response && !state.decision && state.battle?.preparation?.kind === 'voice');
});
const readiness = transition(() => {
assert.ok(game.battle?.preLeader);
for (const actor of [game.battle.attacker, game.battle.defender])
act(actor, { type: 'battlePreparationReady', event: game.battle!.preLeader!.event });
});
assert.equal(game.battle?.preLeader?.closed, true);
let declinedVoice: ReturnType<typeof transition> | null = null;
if (kind === 'prescience') declinedVoice = transition(() => { act(bg, { type: 'declineBattlePower' }); });
const beforeDeclaration = structuredClone(game);
const nativeAction: Action = kind === 'voice'
? { type: 'voice', kind: 'projectile', must: false } : { type: 'prescience', field: 'weapon' };
const declaration = transition(() => { act(owner, nativeAction); });
assert.equal(game.response?.kind, kind); 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.deepEqual(game.battle?.plans, {}); assert.equal(game.battle?.revealed, false);
if (kind === 'voice') {
assert.deepEqual(game.battle?.voice, { target: atreides, kind: 'projectile', must: false });
assert.deepEqual(game.battle?.preparation, { kind: 'prescience', owner: atreides, beneficiary: atreides });
} else {
assert.deepEqual(game.battle?.prescience, { player: atreides, field: 'weapon' });
assert.deepEqual(game.battle?.preparation, { kind: 'prescienceAnswer', owner: bg, beneficiary: atreides });
}
const source = structuredClone(game), originalResponse = structuredClone(game.response!), originalBattle = structuredClone(game.battle!);
const input = { ...game, territoryIds: gameTerritories(game).map(territory => territory.id) };
const expected = quoteCombatResponse(input, { kind: 'response', response: { kind, owner }, canceled: true });
const allowed = quoteCombatResponse(input, { kind: 'response', response: { kind, owner }, canceled: false });
const plans: Record<string, Plan> = {};
for (const actor of [atreides, bg]) {
const leader = semutaCombatDeclarationsPlayer(game, actor).leaders.find(leader => !leader.dead); assert.ok(leader);
plans[actor] = { dial: actor === atreides ? 1 : 0, support: actor === atreides && advanced ? 1 : 0,
leader: leader.id, weapon: null, defense: null };
}
assert.ok(semutaCombatDeclarationsPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
assert.ok(semutaCombatDeclarationsPlayer(game, spender).hand.some(card => card.id === cost));
assert.deepEqual(semutaCombatDeclarationsInventory(game), originalInventory);
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
return { game, source, beforeCost: source, kind, advanced, richese, holder, spender, owner, bg, atreides,
cost, semuta: SEMUTA_DRUG_ID, action, nativeAction, originalResponse, originalBattle, expected, allowed, plans,
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, deal, dealOrder, originalSpice, spiceOrder,
acquired, acquisition, shipment, shipmentAction, beforeBattle, chooser, battleAction, battle, readiness,
declinedVoice, beforeDeclaration, declaration, originalInventory, actions, sourceActions: actions,
sourceActionCount: actions.length,
staging: [
'Public bare Basic start or original Advanced Semuta initializer on the genuinely ready undealt R/C/BG/A/E lobby. Only unopened original starting-card/Spice positions and scalar lotteries are selected; original main35/cache10 remain physical. Emperor really receives printed Karama, and CHOAM pays1 for original cache Semuta.',
'BG makes its actual prediction and (Advanced) Polar Sink advisorSetup. During its real turn BG pays2 to ship two original reserves directly as fighters into Atreides native Arrakeen10. No stance or force is installed; earlier optional native choices are actually declined.',
'At the original first Battle phase the native chooser selects that actual battle. Both combatants really ready the shared Richese pre-leader event, leaving private plans empty.',
kind === 'voice' ? 'BG declares a real ban on projectile weapons. Stop at native Voice response, retaining the original next Prescience preparation owned by Atreides.' :
'BG actually declines its earlier Voice. Atreides declares ordinary weapon Prescience. Stop at its native response with the original BG prescienceAnswer unsubmitted and no inspected value.',
'No played hand, wallet, force, clock, battle, declaration or snapshot is installed. Later preparation/full-plan offer is answered only through current original controls; the supplied unarmed plans are real legal continuation actions, not preinstalled commitments.',
] };
}
export type SemutaCombatDeclarationsFixture = ReturnType<typeof createSemutaCombatDeclarationsFixture>;
+216
View File
@@ -0,0 +1,216 @@
import assert from 'node:assert/strict';
import { createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { TERRITORIES } from '../game/board';
import { quoteCombatResponse } from '../game/combat-response-quote';
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 SemutaCombatForcesKind = 'eliteStrength' | 'fremenSupport' | 'choamBattleAid';
export type SemutaCombatForcesStep = { actor: string; action: Action };
export const actSemutaCombatForces = actSemutaDisasterWorm;
export const passSemutaCombatForces = passSemutaDisasterWorm;
export const claimSemutaCombatForces = claimSemutaDisasterWorm;
export const semutaCombatForcesPlayer = semutaDisasterWormPlayer;
export const semutaCombatForcesInventory = semutaDisasterWormInventory;
export const semutaCombatForcesClean = semutaDisasterWormClean;
export const semutaCombatForcesPhysical = (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 })),
aid: game.aid, spice: game.spice,
});
/** Native controls only; no plan, new card play or fresh reaction is selected.
* The sole real mutual CHOAM alliance earns a positive one-spice pledge. */
export function nextSemutaCombatForces(game: Game): SemutaCombatForcesStep | null {
if (game.pendingTreacheryDiscard || game.truthtrance) return null;
if (!game.phaseOpening && !game.response && game.decision?.kind === 'choamBattleFunding')
return { actor: game.decision.player, action: { type: 'decision', amount: 1 } };
return nextSemutaDisasterWorm(game);
}
export const nextSemutaCombatForcesStep = nextSemutaCombatForces;
export function advanceSemutaCombatForces(game: Game, until: (state: Game) => boolean,
actions: SemutaCombatForcesStep[] = []): Game {
for (let count = 0; count < 2200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextSemutaCombatForces(game);
assert.ok(step, `Supply the original private plan/fresh control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actSemutaCombatForces(game, step.actor, step.action, actions);
}
throw Error('The native force-power programme did not reach its finite boundary');
}
/** Allow this one actual check; later checks remain available to finish. */
export function allowSemutaCombatForces(game: Game, actions: SemutaCombatForcesStep[] = []): Game {
const response = game.response; assert.ok(response);
assert.ok(response.kind === 'eliteStrength' || response.kind === 'fremenSupport' || response.kind === 'choamBattleAid');
for (let count = 0; game.response?.kind === response.kind && game.response.owner === response.owner && count < game.players.length; count++) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
game = actSemutaCombatForces(game, actor.id, { type: 'passResponse' }, actions);
}
return game;
}
/** Complete only native remaining checks/preparation, then stop before any
* private plan is sealed or support is paid. */
export function finishSemutaCombatForces(game: Game, actions: SemutaCombatForcesStep[] = []): Game {
return advanceSemutaCombatForces(game, state => !!state.battle && !state.battle.revealed &&
semutaCombatForcesClean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false, actions);
}
/** Genuine Advanced native elites/support/aid. Semuta is paid cache1; all
* physical counters and the CHOAM alliance arise through original actions. */
export function createSemutaCombatForcesFixture(options: {
kind?: SemutaCombatForcesKind; eliteOwner?: 'fremen' | 'emperor';
} = {}) {
const kind = options.kind ?? 'eliteStrength', eliteOwner = options.eliteOwner ?? 'fremen';
const richese = 'richese', holder = 'choam', fremen = 'fremen', emperor = 'emperor', spender = 'guild';
const combatant = kind === 'fremenSupport' ? fremen : kind === 'choamBattleAid' ? emperor : eliteOwner;
const owner = kind === 'choamBattleAid' ? holder : combatant, opponent = richese;
const territory = 'the_great_flat', sector = 15, location = `${territory}:${sector}`;
let game = createGame('SEMUTACOMBATFORCES', newPlayer(richese, 'Richese', 'richese'), true, ['choam']);
for (const faction of ['choam', 'fremen', 'emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: SemutaCombatForcesStep[] = [];
const act = (actor: string, action: Action) => { game = actSemutaCombatForces(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaCombatForces(game, until, actions); };
const transition = (run: () => void) => {
const before = structuredClone(game), at = actions.length; run();
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
};
for (const p of game.players) act(p.id, { type: 'ready' });
const lobby = structuredClone(game);
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
const setup = structuredClone(game), initialization = { before: lobby, after: setup, entry: 'original-undealt-advanced-semuta-preview' };
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
assert.ok(game.players.every(p => p.hand.length === 0));
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
const originalInventory = semutaCombatForcesInventory(game); assert.equal(originalInventory.length, 45);
const karamas = game.deck.filter(card => card.effect === 'karama'); assert.equal(karamas.length, 2);
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 3); assert.equal(ordinary.length, 3);
const selected = [ordinary[0], ordinary[1], ordinary[2], karamas[1], karamas[0]];
const dealOrder = selected.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), cost = karamas[0].id, independentKarama = karamas[1].id;
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
assert.equal(semutaCombatForcesPlayer(game, fremen).elites?.reserves, 3);
assert.equal(semutaCombatForcesPlayer(game, emperor).elites?.reserves, 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];
const 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 the original still-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: SemutaCombatForcesStep | undefined, bidAt = -1;
for (let count = 0; count < 900 && !semutaCombatForcesPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
const step = nextSemutaCombatForces(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(semutaCombatForcesPlayer(beforeBid, holder).spice - semutaCombatForcesPlayer(game, holder).spice, 1);
let alliance: { before: Game; steps: SemutaCombatForcesStep[]; after: Game } | null = null;
if (kind === 'choamBattleAid') {
reach(state => state.turn === 2 && state.phase === 1 && semutaCombatForcesClean(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 && semutaCombatForcesClean(state));
alliance = transition(() => {
act(holder, { type: 'alliance', target: emperor }); act(emperor, { type: 'alliance', target: holder });
});
assert.equal(semutaCombatForcesPlayer(game, holder).ally, emperor);
assert.equal(semutaCombatForcesPlayer(game, emperor).ally, holder);
}
const turn = kind === 'choamBattleAid' ? 2 : 1;
reach(state => state.turn === turn && state.phase === 5 && semutaCombatForcesClean(state));
const shipments: { actor: string; amount: number; elite: number; cost: number; before: Game;
steps: SemutaCombatForcesStep[]; after: Game }[] = [];
for (let count = 0; game.turn === turn && game.phase === 5 && count < 160; count++) {
if (semutaCombatForcesClean(game) && (game.active === combatant || game.active === opponent) &&
!semutaCombatForcesPlayer(game, game.active!).shipped) {
const actor = game.active!, elite = Number(actor === combatant), paid = actor === fremen ? 0 : 2;
const shipment = transition(() => {
act(actor, { type: 'ship', territory, sector, amount: 1, elite, allyPayment: 0 });
reach(semutaCombatForcesClean);
});
assert.equal(semutaCombatForcesPlayer(shipment.before, actor).spice - semutaCombatForcesPlayer(game, actor).spice, paid);
shipments.push({ actor, amount: 1, elite, cost: paid, ...shipment });
} else {
const step = nextSemutaCombatForces(game); assert.ok(step); act(step.actor, step.action);
}
}
assert.equal(shipments.length, 2);
assert.equal(semutaCombatForcesPlayer(game, combatant).forces[location], 1);
assert.equal(semutaCombatForcesPlayer(game, combatant).elites?.forces[location], 1);
assert.equal(semutaCombatForcesPlayer(game, opponent).forces[location], 1);
reach(state => state.turn === turn && state.phase === 6 && semutaCombatForcesClean(state) && !!state.active);
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices.find(candidate =>
candidate.territory === territory && [candidate.attacker, candidate.defender].includes(combatant) &&
[candidate.attacker, candidate.defender].includes(opponent));
assert.ok(choice); assert.equal(choice.chooser, game.active);
const battleAction: Action = { type: 'chooseBattle', territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker };
act(choice.chooser, battleAction);
const earlierControls = transition(() => { reach(state => state.response?.kind === kind && state.response.owner === owner); });
assert.ok(game.battle && !game.battle.revealed && game.response);
assert.equal(game.response.kind, kind); assert.equal(game.response.owner, owner);
assert.ok(!game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard);
assert.deepEqual(game.battle.plans, {});
const originalResponse = structuredClone(game.response), originalBattle = structuredClone(game.battle);
const expected = quoteCombatResponse({ status: game.status, phase: game.phase, advanced: game.advanced,
turn: game.turn, territoryIds: TERRITORIES.map(t => t.id), players: game.players, battle: game.battle },
{ kind: 'response', response: { kind, owner }, canceled: true });
const ownLeader = semutaCombatForcesPlayer(game, combatant).leaders.filter(l => !l.dead)
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
const otherLeader = semutaCombatForcesPlayer(game, opponent).leaders.filter(l => !l.dead)
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
assert.ok(ownLeader && otherLeader);
const allowedPlan: Action = { type: 'battlePlan', leader: ownLeader.id, dial: 2,
support: combatant === fremen ? 0 : 1, allyPayment: kind === 'choamBattleAid' ? 1 : 0, weapon: null, defense: null };
const canceledPlan: Action = { ...allowedPlan,
...(kind === 'eliteStrength' ? { dial: 1 } : kind === 'fremenSupport' ? { support: 1 } : { allyPayment: 0 }) };
const opponentPlan: Action = { type: 'battlePlan', leader: otherLeader.id, dial: 0, support: 0,
allyPayment: 0, weapon: null, defense: null };
const source = structuredClone(game), action: Action = { type: 'card', card: cost, mode: 'cancel' };
assert.ok(semutaCombatForcesPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
assert.ok(semutaCombatForcesPlayer(game, spender).hand.some(card => card.id === cost));
assert.deepEqual(semutaCombatForcesInventory(game), originalInventory);
return { game, source, beforeCost: source, kind, eliteOwner, advanced: true as const, holder, spender, owner,
recipient: combatant, combatant, opponent, richese, fremen, emperor, guild: spender, cost, karama: cost,
independentKarama, semuta: SEMUTA_DRUG_ID, action, originalResponse, originalBattle, expected,
originalPhysical: structuredClone(semutaCombatForcesPhysical(game)), territory, sector, location, turn,
allowedPlan, canceledPlan, opponentPlan, ownLeader: ownLeader.id, opponentLeader: otherLeader.id,
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, deal: selected.map(card => card.id), dealOrder,
originalInventory, originalSpice, spiceOrder, acquisition, acquired, alliance, shipments, beforeBattle,
battleAction, choice, earlierControls, originalAid: structuredClone(game.aid),
actions, steps: actions, sourceActions: actions, sourceActionCount: actions.length,
staging: [
'Original Advanced Semuta preview initializer runs on the genuinely ready undealt R/C/F/E/G lobby, main35/cache10 and no optional modules. Only unopened starting-card/Spice positions and scalar lotteries are selected. No Basic-to-Advanced conversion.',
'Guild and Emperor each receive a distinct original printed Karama. CHOAM really buys cache Semuta for one. Fremen really places ten normals in Sietch Tabr and retains three original reserve Fedaykin; Emperor retains five original reserve Sardaukar.',
'Richese has no starting garrison: it actually pays two to ship one normal to Great Flat15. The actual opposing combatant really ships one original elite there: Fremen free entry, or Emperor paid two. Native unused turns, Guild timing and income responses complete normally.',
kind === 'choamBattleAid' ? 'First-turn movement is really skipped. An original turn2 worm on the empty first Funeral pile earns Nexus; CHOAM and Emperor actually propose/accept alliance. Both real turn2 shipments and the original battle declaration earn native CHOAM funding1. Its earlier Emperor elite check is actually allowed before the aid response.' : kind === 'fremenSupport' ? 'At the actual first-turn unarmed/unsealed battle, the earlier Fedaykin strength check receives real passes before the free-support response.' : 'The first actual unrevealed battle reaches its native positive elite-strength check. Emperor faces Richese, never Fremen; the printed against-Fremen exclusion is preserved.',
'Stop before Guild spends its originally dealt printed Karama. No played hand, wallet, force, elite, phase, alliance, plan, battle, response or power queue is installed. Helpers finish native remaining checks and stop before private plans or support payments.',
],
};
}
export type SemutaCombatForcesFixture = ReturnType<typeof createSemutaCombatForcesFixture>;
@@ -0,0 +1,196 @@
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 { maxCombatDial } from '../game/combat';
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
import {
createSemutaCombatCapabilitiesFixture as fixture, actSemutaCombatCapabilities as act,
passSemutaCombatCapabilities as pass, claimSemutaCombatCapabilities as claim,
allowSemutaCombatCapabilities as allow, finishSemutaCombatCapabilities as finish,
semutaCombatCapabilitiesPlayer as player, semutaCombatCapabilitiesInventory as inventory,
type SemutaCombatCapabilitiesFixture,
} from './fixture-semuta-combat-capabilities';
const variants = [{ kind: 'kwisatz' as const }, { kind: 'ixElite' as const }];
const physical = (game: Game) => ({
turn: game.turn, phase: game.phase, active: game.active, order: game.order, ready: game.ready,
storm: game.storm, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
movementRemaining: game.movementRemaining, pendingArrivalReaction: game.pendingArrivalReaction,
players: game.players.map(p => ({ id: p.id, spice: p.spice, reserves: p.reserves, tanks: p.tanks,
forces: p.forces, elites: p.elites, leaders: p.leaders, moved: p.moved, shipped: p.shipped,
battleLosses: p.battleLosses, kwisatz: p.kwisatz })),
});
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: SemutaCombatCapabilitiesFixture) {
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', f.advanced ? 'atreides' : 'ixians', 'emperor']);
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(f.shipments.length, 2); assert.equal(f.moves.length, 1);
for (const shipment of f.shipments) {
const step = shipment.steps.find(row => row.action.type === 'ship'); assert.ok(step);
assert.equal(step.action.amount, 1);
assert.equal(player(shipment.before, step.actor).reserves - player(shipment.after, step.actor).reserves, 1);
assert.ok(player(shipment.before, step.actor).spice > player(shipment.after, step.actor).spice,
'Every counter in this battle source is paid by its original native shipment');
}
if (f.advanced) {
assert.ok(f.earningBattle); assert.equal(f.game.turn, 2);
assert.equal(player(f.earningBattle.before, f.owner).battleLosses, 0);
assert.ok(f.moves[0].steps.some(step => step.actor === f.owner && step.action.type === 'move' && step.action.amount === 7));
assert.ok(f.earningBattle.steps.some(step => step.actor === f.owner && step.action.type === 'battlePlan' &&
step.action.dial === 3.5 && step.action.support === 0));
assert.equal(f.earningBattle.steps.filter(step => step.action.type === 'traitorCall' && step.action.call === false).length, 2);
assert.equal(f.earningBattle.steps.filter(step => step.action.type === 'battleOutcomeReady').length, 2);
assert.equal(f.earningBattle.after.lastBattleContext?.winner, f.owner);
assert.equal(player(f.earningBattle.after, f.owner).battleLosses, 7);
assert.equal(player(f.earningBattle.after, f.owner).tanks, 7);
assert.equal(player(f.game, f.owner).forces['arrakeen:10'], 3);
assert.equal(player(f.game, f.owner).battleLosses, 7);
assert.ok(player(f.game, f.spender).traitors.includes(f.ownerLeader), 'The opponent really holds the dealt leader Traitor');
} else {
assert.equal(f.earningBattle, null); assert.equal(f.game.turn, 1);
assert.ok(f.actions.some(step => step.action.type === 'decision' && step.action.card ===
player(f.afterSetup, f.owner).hand.find(card => card.kind === 'shield')?.id));
assert.ok(f.moves[0].steps.some(step => step.actor === f.owner && step.action.type === 'move' &&
JSON.stringify(step.action.forces) === JSON.stringify({ 'polar_sink:0': 1 }) &&
JSON.stringify(step.action.eliteForces) === JSON.stringify({ 'polar_sink:0': 1 })));
assert.equal(viewGame(f.game, f.owner).battle?.ownForces?.elite, 1);
assert.equal(viewGame(f.game, f.owner).battle?.ownForces?.eliteStrength, 2);
}
assert.equal(f.game.phase, 6); assert.equal(f.game.response?.kind, f.responseKind);
assert.equal(f.game.response?.owner, f.owner); assert.equal(f.game.battle?.revealed, false);
assert.deepEqual(f.game.battle?.plans, {}); assert.deepEqual(f.game.battle, f.originalBattle);
assert.deepEqual(f.game.response, f.originalResponse); assert.equal(f.game.phaseOpening ?? null, null);
assert.equal(f.game.pendingTreacheryDiscard ?? null, null); assert.equal(f.game.decision ?? null, null);
assert.equal(f.originalInventory.length, f.advanced ? 45 : 57);
assert.deepEqual(inventory(f.game), f.originalInventory); assert.equal(f.sourceActionCount, f.actions.length);
}
function open(f: SemutaCombatCapabilitiesFixture) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaCombatDiscard');
if (pending.continuation.kind !== 'karamaCombatDiscard') throw Error('Use the original combat capability cost');
assert.equal(pending.batch.cause, `karama:${f.responseKind}`); 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.response ?? 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 beforePlans(state: Game, f: SemutaCombatCapabilitiesFixture, canceled: boolean) {
assert.equal(state.pendingTreacheryDiscard ?? null, null); assert.deepEqual(physical(state), physical(f.game));
assert.equal(state.battle?.event, f.originalBattle.event); assert.equal(state.battle?.revealed, false);
assert.deepEqual(state.battle?.plans, {});
const steps: { actor: string; action: Action }[] = [], game = finish(state, steps);
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
assert.equal(game.battle?.event, f.originalBattle.event); assert.deepEqual(game.battle?.powerChecks, []);
assert.equal(game.battle?.preLeader?.closed, true); assert.equal(game.battle?.preparation, undefined);
assert.deepEqual(game.battle?.plans, {}); assert.deepEqual(physical(game), physical(f.game));
assert.ok(steps.every(step => step.action.type !== 'battlePlan' && step.action.type !== 'card'));
if (f.advanced) {
assert.equal(game.battle?.kwisatzBlocked ?? false, canceled);
assert.equal(player(game, f.owner).battleLosses, 7);
if (canceled) reject(game, f.owner, { ...f.ownerPlan, kwisatz: true });
} else {
assert.equal(game.battle?.eliteBlocked?.includes(f.owner) ?? false, canceled);
const forces = viewGame(game, f.owner).battle!.ownForces!;
assert.equal(forces.elite, 1); assert.equal(forces.eliteStrength, canceled ? 1 : 2);
assert.equal(maxCombatDial(forces), canceled ? 1 : 2);
if (canceled) reject(game, f.owner, { ...f.ownerPlan, dial: 2 });
}
const sealed = act(structuredClone(game), f.owner,
f.advanced ? { ...f.ownerPlan, kwisatz: !canceled } : { ...f.ownerPlan, dial: canceled ? 1 : 2 });
assert.equal(sealed.battle?.revealed, false); assert.ok(sealed.battle?.plans[f.owner]);
assert.deepEqual(inventory(sealed), f.originalInventory); return game;
}
function prevented(state: Game, f: SemutaCombatCapabilitiesFixture, claimed: boolean) {
const game = beforePlans(state, f, true);
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
assert.deepEqual(inventory(game), f.originalInventory); return game;
}
for (const variant of variants) for (const claimed of [false, true]) void test(
`${variant.kind === 'kwisatz' ? 'Advanced earned KH' : 'Basic native Cyborg'} used cancellation cost Semuta ${claimed ? 'claim' : 'pass'} parks the original battle and resumes one check`, () => {
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 });
},
);
void test('earned KH allowance keeps its actual two-strength bonus and protects the genuinely held Traitor; prevention removes both', () => {
const f = fixture({ kind: 'kwisatz' }); nativeSource(f);
for (const canceled of [false, true]) {
let game = canceled ? prevented(pass(open(f)), f, false) : beforePlans(allow(structuredClone(f.game)), f, false);
game = act(game, f.owner, { ...f.ownerPlan, kwisatz: !canceled });
game = act(game, f.spender, f.opponentPlan);
assert.equal(game.battle?.revealed, true); assert.equal(game.battle?.plans[f.owner].kwisatz ?? false, !canceled);
if (canceled) {
const called = act(structuredClone(game), f.spender, { type: 'traitorCall', call: true });
assert.equal(called.battle?.traitorCalls[f.spender], true);
} else reject(game, f.spender, { type: 'traitorCall', call: true });
const voters = viewGame(game, f.owner).battle!.traitorVoters;
for (const voter of voters) game = act(game, voter, { type: 'traitorCall', call: false });
assert.ok(game.battle?.outcomeWindow && !game.battle.outcomeWindow.closed);
const quote = quoteStrongholdFactionsBattle(game); assert.ok(quote.scores);
const strength = player(game, f.owner).leaders.find(row => row.id === f.ownerLeader)!.strength;
assert.equal(quote.scores[game.battle!.attacker === f.owner ? 'attacker' : 'defender'], strength + (canceled ? 0 : 2));
assert.equal(player(game, f.owner).battleLosses, 7);
}
});
void test('Basic Cyborg allowance retains a legal two-strength dial after actual paid shipment and movement', () => {
const f = fixture({ kind: 'ixElite' }); nativeSource(f);
const game = beforePlans(allow(structuredClone(f.game)), f, false);
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
});
void test('fresh KH and Cyborg cost reactions reject stale foreign and underlying controls without moving the battle cursor', () => {
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.owner, f.ownerPlan);
reject(game, f.spender, f.opponentPlan); reject(game, f.owner, { type: 'declineBattlePower' });
reject(game, f.owner, { type: 'battlePreparationReady', event: f.originalBattle.event });
assert.deepEqual(game.battle, f.originalBattle); assert.deepEqual(physical(game), physical(f.game));
prevented(pass(game), f, false);
}
});
void test('all four minimal profiles legally answer the actual earned-KH and Basic Cyborg used-cost opportunities', () => {
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);
prevented(game, f, claimed);
}
}
});
@@ -0,0 +1,201 @@
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 {
createSemutaCombatDeclarationsFixture as fixture, actSemutaCombatDeclarations as act,
passSemutaCombatDeclarations as pass, claimSemutaCombatDeclarations as claim,
allowSemutaCombatDeclarations as allow, finishSemutaCombatDeclarations as finish,
semutaCombatDeclarationsPlayer as player, semutaCombatDeclarationsInventory as inventory,
type SemutaCombatDeclarationsFixture,
} from './fixture-semuta-combat-declarations';
const variants = [
{ kind: 'voice' as const, advanced: false },
{ kind: 'voice' as const, advanced: true },
{ kind: 'prescience' as const, advanced: false },
{ kind: 'prescience' as const, advanced: true },
];
const physical = (game: Game) => ({
turn: game.turn, phase: game.phase, storm: game.storm, order: game.order, active: game.active, ready: game.ready,
movementRemaining: game.movementRemaining, spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
pendingArrivalReaction: game.pendingArrivalReaction, pendingAmbassador: game.pendingAmbassador,
pendingTerrorEntry: game.pendingTerrorEntry,
players: game.players.map(player => ({ id: player.id, spice: player.spice,
reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites,
leaders: player.leaders, shipped: player.shipped, moved: player.moved, revived: player.revived, ally: player.ally })),
});
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function nativeSource(f: SemutaCombatDeclarationsFixture) {
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, f.advanced); assert.equal(f.setup.semutaPreview, true);
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
assert.deepEqual(f.game.players.map(player => player.faction), ['richese', 'choam', 'beneGesserit', 'atreides', 'emperor']);
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
assert.equal(player(f.afterSetup, f.bg).forces['polar_sink:0'], 1);
assert.equal(player(f.afterSetup, f.atreides).forces['arrakeen:10'], 10);
if (f.advanced) assert.ok(f.actions.some(step => step.actor === f.bg && step.action.type === 'advisorSetup' &&
step.action.territory === 'polar_sink' && step.action.sector === 0));
assert.ok(f.actions.some(step => step.actor === f.bg && step.action.type === 'predict'));
assert.equal(f.acquisition.price, 1);
assert.ok(f.acquisition.steps.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
assert.equal(player(f.shipment.before, f.bg).spice - player(f.shipment.after, f.bg).spice, 2);
assert.equal(player(f.shipment.after, f.bg).reserves, player(f.shipment.before, f.bg).reserves - 2);
assert.equal(player(f.shipment.after, f.bg).forces['arrakeen:10'], 2);
assert.equal(player(f.game, f.bg).forces['arrakeen:10'], 2);
assert.equal(player(f.game, f.bg).advisors?.arrakeen, undefined);
assert.equal(player(f.game, f.atreides).forces['arrakeen:10'], 10);
assert.deepEqual(f.battle.steps[0], { actor: f.chooser, action: f.battleAction });
assert.equal(f.readiness.steps.length, 2);
assert.ok(f.readiness.steps.every(step => step.action.type === 'battlePreparationReady' && step.action.event === f.originalBattle.event));
assert.deepEqual(new Set(f.readiness.steps.map(step => step.actor)), new Set([f.bg, f.atreides]));
assert.equal(f.originalBattle.preLeader?.closed, true);
assert.deepEqual(f.declaration.steps, [{ actor: f.owner, action: f.nativeAction }]);
assert.equal(f.game.phase, 6); assert.equal(f.game.turn, 1);
assert.equal(f.game.response?.kind, f.kind); assert.equal(f.game.response?.owner, f.owner);
assert.equal(f.game.phaseOpening ?? null, null); assert.equal(f.game.decision ?? null, null);
assert.equal(f.game.pendingTreacheryDiscard ?? null, null); assert.equal(f.game.battle?.territory, 'arrakeen');
assert.equal(f.game.battle?.revealed, false); assert.deepEqual(f.game.battle?.plans, {});
assert.deepEqual(f.game.battle, f.originalBattle);
if (f.kind === 'voice') {
assert.equal(f.declinedVoice, null);
assert.deepEqual(f.game.battle?.voice, { target: f.atreides, kind: 'projectile', must: false });
assert.deepEqual(f.game.battle?.preparation, { kind: 'prescience', owner: f.atreides, beneficiary: f.atreides });
} else {
assert.ok(f.declinedVoice);
assert.deepEqual(f.declinedVoice.steps, [{ actor: f.bg, action: { type: 'declineBattlePower' } }]);
assert.equal(f.game.battle?.voice, undefined);
assert.deepEqual(f.game.battle?.prescience, { player: f.atreides, field: 'weapon' });
assert.equal(Object.hasOwn(f.game.battle!.prescience!, 'value'), false);
assert.deepEqual(f.game.battle?.preparation, { kind: 'prescienceAnswer', owner: f.bg, beneficiary: f.atreides });
}
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 45);
assert.deepEqual(inventory(f.game), f.originalInventory);
}
function open(f: SemutaCombatDeclarationsFixture) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaCombatDiscard');
assert.equal(pending.reaction?.stage, 'offer'); assert.equal(pending.batch.cause, `karama:${f.kind}`);
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
[{ card: f.cost, by: f.spender, face: true }]);
if (pending.continuation.kind !== 'karamaCombatDiscard') throw Error('Use the original combat-declaration cost');
assert.equal(pending.continuation.player, f.spender); assert.equal(pending.continuation.card, f.cost);
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
assert.deepEqual(game.battle, f.originalBattle); assert.deepEqual(physical(game), physical(f.game));
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
assert.deepEqual(inventory(game), f.originalInventory); return game;
}
function prevented(game: Game, f: SemutaCombatDeclarationsFixture, claimed: boolean) {
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
assert.equal(game.response ?? null, null);
assert.deepEqual(physical(game), physical(f.game));
const battle = structuredClone(f.originalBattle), patch = f.expected.patches;
if (f.expected.powerChecks !== undefined) battle.powerChecks = structuredClone(f.expected.powerChecks);
if (patch.deleteVoice) delete battle.voice;
if (patch.deletePrescience) delete battle.prescience;
if (patch.deletePreparation) delete battle.preparation;
if (patch.fullPlanOffered) battle.fullPlanOffered = true;
assert.deepEqual(game.battle, battle);
assert.deepEqual(game.decision ?? null, f.expected.decision);
if (f.kind === 'voice') {
assert.equal(game.battle?.voice, undefined);
assert.deepEqual(game.battle?.preparation, f.originalBattle.preparation, 'The original next Prescience belongs to Atreides');
} else {
assert.equal(game.battle?.prescience, undefined); assert.equal(game.battle?.preparation, undefined);
}
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
assert.deepEqual(inventory(game), f.originalInventory); return game;
}
function revealOriginalBattle(game: Game, f: SemutaCombatDeclarationsFixture, preventedPower: boolean) {
const steps: { actor: string; action: Action }[] = [];
game = finish(game, steps);
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.battle?.preparation, undefined);
assert.equal(game.battle?.event, f.originalBattle.event); assert.equal(game.battle?.revealed, false);
assert.deepEqual(game.battle?.plans, {}); assert.equal(game.battle?.preLeader?.closed, true);
assert.deepEqual(physical(game), physical(f.game));
if (f.kind === 'prescience' && !preventedPower) {
assert.ok(steps.some(step => step.actor === f.bg && step.action.type === 'prescienceAnswer' && step.action.value === null));
assert.equal(game.battle?.prescience?.value, null);
} else assert.equal(steps.some(step => step.action.type === 'prescienceAnswer'), false);
if (f.kind === 'voice') assert.equal(game.battle?.voice !== undefined, !preventedPower);
const first = f.originalBattle.attacker, second = f.originalBattle.defender;
game = act(game, first, { type: 'battlePlan', ...f.plans[first] });
assert.equal(game.battle?.event, f.originalBattle.event); assert.equal(game.battle?.revealed, false);
assert.deepEqual(game.battle?.plans[first], f.plans[first]);
assert.equal(game.battle?.plans[second], undefined);
game = act(game, second, { type: 'battlePlan', ...f.plans[second] });
assert.equal(game.battle?.event, f.originalBattle.event); assert.equal(game.battle?.revealed, true);
assert.deepEqual(game.battle?.plans[first], f.plans[first]); assert.deepEqual(game.battle?.plans[second], f.plans[second]);
assert.equal(game.pendingTreacheryDiscard ?? null, null);
assert.deepEqual(inventory(game), f.originalInventory); return game;
}
for (const variant of variants) for (const claimed of [false, true]) void test(
`${variant.advanced ? 'Advanced' : 'Basic'} native ${variant.kind} used cancellation cost ${claimed ? 'claim' : 'pass'} preserves the original battle and resumes preparation 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 });
revealOriginalBattle(game, f, true);
});
void test('native Voice and ordinary Prescience allowances retain the actual declaration and its next original control', () => {
for (const variant of variants) {
const f = fixture(variant); nativeSource(f);
const game = allow(structuredClone(f.game));
assert.equal(game.response ?? null, null); assert.equal(game.pendingTreacheryDiscard ?? null, null);
assert.deepEqual(game.battle, f.originalBattle); assert.deepEqual(physical(game), physical(f.game));
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));
revealOriginalBattle(game, f, false);
}
});
void test('fresh combat costs reject stale and underlying answers while both original preparation cursors stay parked', () => {
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.owner, f.nativeAction);
reject(game, f.atreides, { type: 'battlePlan', ...f.plans[f.atreides] });
reject(game, f.kind === 'voice' ? f.atreides : f.bg, f.kind === 'voice'
? { type: 'declineBattlePower' } : { type: 'prescienceAnswer', value: null });
assert.deepEqual(game.battle, f.originalBattle); assert.deepEqual(physical(game), physical(f.game));
prevented(pass(game), f, false);
}
});
void test('all four minimal policies answer the actual Basic and Advanced combat-declaration costs 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);
}
}
});
+189
View File
@@ -0,0 +1,189 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createSemutaCombatForcesFixture as fixture, actSemutaCombatForces as act,
passSemutaCombatForces as pass, claimSemutaCombatForces as claim,
allowSemutaCombatForces as allow, finishSemutaCombatForces as finish,
semutaCombatForcesPlayer as player, semutaCombatForcesInventory as inventory,
semutaCombatForcesPhysical as physical, type SemutaCombatForcesFixture,
} from './fixture-semuta-combat-forces';
const variants = [
{ kind: 'eliteStrength' as const, eliteOwner: 'fremen' as const },
{ kind: 'eliteStrength' as const, eliteOwner: 'emperor' as const },
{ kind: 'fremenSupport' as const },
{ kind: 'choamBattleAid' as const },
];
const clock = (game: Game) => ({ turn: game.turn, phase: game.phase, active: game.active,
order: game.order, ready: game.ready, storm: game.storm, spiceDeck: game.spiceDeck,
spiceDiscard: game.spiceDiscard, movementRemaining: game.movementRemaining });
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: SemutaCombatForcesFixture) {
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', 'fremen', 'emperor', 'guild']);
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
assert.ok(player(f.afterSetup, f.emperor).hand.some(card => card.id === f.independentKarama && card.effect === 'karama'));
assert.notEqual(f.cost, f.independentKarama);
assert.equal(player(f.afterSetup, f.fremen).forces['sietch_tabr:14'], 10);
assert.equal(player(f.afterSetup, f.fremen).elites?.reserves, 3);
assert.equal(player(f.afterSetup, f.emperor).elites?.reserves, 5);
assert.equal(Object.keys(player(f.afterSetup, f.richese).forces).length, 0,
'The opposing Richese garrison must be earned by its own actual shipment');
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.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
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 === shipment.elite));
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(player(f.game, f.combatant).elites?.forces[f.location], 1);
assert.equal(player(f.game, f.opponent).forces[f.location], 1);
assert.equal(player(f.game, f.fremen).forces['sietch_tabr:14'], 10);
assert.equal(f.game.phase, 6); assert.equal(f.game.turn, f.turn);
assert.equal(f.game.battle?.revealed, false); assert.deepEqual(f.game.battle?.plans, {});
assert.deepEqual(f.game.battle, f.originalBattle); assert.deepEqual(f.game.response, f.originalResponse);
assert.equal(f.game.response?.kind, f.kind); assert.equal(f.game.response?.owner, f.owner);
assert.equal(f.game.phaseOpening ?? null, null); assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
assert.equal(f.game.decision ?? null, null);
assert.equal(player(f.game, f.spender).hand.filter(card => card.id === f.cost).length, 1);
assert.equal(player(f.game, f.holder).hand.filter(card => card.id === f.semuta).length, 1);
if (f.combatant === f.emperor) assert.notEqual(f.opponent, f.fremen,
'Sardaukar never earn their native double-strength check against Fremen');
if (f.kind === 'fremenSupport') {
assert.equal(f.game.battle?.eliteBlocked?.includes(f.combatant) ?? false, false);
assert.ok(f.earlierControls.steps.some(step => step.action.type === 'passResponse'));
}
if (f.kind === 'choamBattleAid') {
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'));
assert.equal(player(f.game, f.holder).ally, f.emperor); assert.equal(player(f.game, f.emperor).ally, f.holder);
assert.deepEqual(f.game.aid[f.holder], { recipient: f.emperor, amount: 1 });
assert.ok(f.earlierControls.steps.some(step => step.actor === f.holder && step.action.type === 'decision' && step.action.amount === 1));
assert.ok(f.earlierControls.steps.some(step => step.action.type === 'passResponse'));
assert.equal(f.game.battle?.eliteBlocked?.includes(f.emperor) ?? false, false);
} else assert.equal(f.alliance, null);
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
assert.equal(f.sourceActionCount, f.actions.length);
}
function open(f: SemutaCombatForcesFixture) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaCombatDiscard');
if (pending.continuation.kind !== 'karamaCombatDiscard') throw Error('Expected the original combat-power cost');
assert.equal(pending.batch.cause, `karama:${f.kind}`); 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), f.originalPhysical);
assert.deepEqual(clock(game), clock(f.game)); assert.equal(game.response ?? 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 beforePlans(state: Game, f: SemutaCombatForcesFixture, canceled: boolean) {
assert.equal(state.pendingTreacheryDiscard ?? null, null);
assert.deepEqual(physical(state), f.originalPhysical);
assert.deepEqual(state.battle?.plans, {}); assert.equal(state.battle?.revealed, false);
if (canceled) {
assert.deepEqual(state.battle?.powerChecks, f.expected.powerChecks);
assert.deepEqual(state.response, f.expected.response);
if (f.kind === 'eliteStrength') assert.deepEqual(state.battle?.eliteBlocked, [f.combatant]);
if (f.kind === 'fremenSupport') assert.equal(state.battle?.fremenSupportBlocked, true);
if (f.kind === 'choamBattleAid') assert.equal(state.battle?.choamAidBlocked, true);
}
const steps: { actor: string; action: Action }[] = [], game = finish(state, steps);
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
assert.equal(game.phase, 6); assert.equal(game.turn, f.turn); assert.equal(game.battle?.event, f.originalBattle.event);
assert.equal(game.battle?.revealed, false); assert.deepEqual(game.battle?.plans, {});
assert.deepEqual(game.battle?.powerChecks, []); assert.deepEqual(physical(game), f.originalPhysical);
assert.deepEqual(clock(game), clock(f.game));
if (f.kind === 'eliteStrength') assert.equal(game.battle?.eliteBlocked?.includes(f.combatant) ?? false, canceled);
if (f.kind === 'fremenSupport') assert.equal(game.battle?.fremenSupportBlocked, canceled);
if (f.kind === 'choamBattleAid') assert.equal(game.battle?.choamAidBlocked, canceled);
if (f.kind === 'eliteStrength' && f.combatant === f.fremen) {
assert.equal(game.battle?.fremenSupportBlocked, false);
assert.ok(steps.some(step => step.action.type === 'passResponse'), 'The independent support window really receives a pass');
}
assert.ok(steps.every(step => step.action.type !== 'battlePlan' && step.action.type !== 'card'));
if (f.kind === 'choamBattleAid') {
assert.equal(viewGame(game, f.combatant).aid.available, canceled ? 0 : 1);
assert.deepEqual(game.aid, f.originalAid, 'The prior real pledge remains escrowed before any plan payment');
}
if (canceled) reject(game, f.combatant, f.allowedPlan);
const plan = canceled ? f.canceledPlan : f.allowedPlan;
const sealed = act(structuredClone(game), f.combatant, plan);
assert.equal(sealed.battle?.plans[f.combatant]?.dial, plan.dial);
assert.equal(sealed.battle?.plans[f.combatant]?.support, plan.support);
assert.equal(sealed.battle?.plans[f.combatant]?.allyPayment ?? 0, plan.allyPayment);
assert.equal(sealed.battle?.revealed, false); assert.deepEqual(physical(sealed), f.originalPhysical);
assert.deepEqual(inventory(sealed), f.originalInventory); return game;
}
function denied(state: Game, f: SemutaCombatForcesFixture, claimed: boolean) {
const game = beforePlans(state, f, true);
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
assert.ok(player(game, f.emperor).hand.some(card => card.id === f.independentKarama));
reject(game, f.spender, f.action); assert.deepEqual(inventory(game), f.originalInventory); return game;
}
for (const variant of variants) for (const claimed of [false, true]) void test(
`Advanced native ${variant.kind === 'eliteStrength' ? `${variant.eliteOwner} ` : ''}${variant.kind} cost Semuta ${claimed ? 'claim' : 'pass'} resumes one combat check before plans`, () => {
const f = fixture(variant); nativeSource(f); const offered = open(f);
denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
},
);
void test('native force-power allowance preserves positive Fedaykin Sardaukar free support and pledged CHOAM aid plans', () => {
for (const variant of variants) {
const f = fixture(variant); nativeSource(f);
const game = beforePlans(allow(structuredClone(f.game)), f, false);
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
}
});
void test('native force-power cost reactions reject stale foreign and underlying declarations before flags or plans change', () => {
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.combatant, f.allowedPlan);
reject(game, f.opponent, f.opponentPlan); 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 elite support and CHOAM battle-aid 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);
}
}
});