Offer Semuta on printed native battle cancellation costs
This commit is contained in:
1 parent
74768647f4
commit
bab8f3403d
14 files changed
+818
-14
No files matched your search
@@ -29,6 +29,27 @@ optional skill bands, combinations and public expansion starts remain open.
|
||||
Rules implementation continues; comprehensive assurance and administration
|
||||
remain deferred until all rules are implemented.
|
||||
|
||||
**Semuta / printed native battle-cancellation costs — 11 October.** One
|
||||
`karamaBattleDiscard` receipt now pauses the physical printed Karama before
|
||||
native capture, Auditor, quoted battle-income, Ixian-substitution or Basic
|
||||
Moritani-retention cancellation. Claim/pass restores the original response
|
||||
and cancels once; acquiring its cost cannot restore the canceled effect.
|
||||
Original quotes and signed physical facts remain authoritative. Auditor
|
||||
validation recognizes its saved response; no module, Worthless conversion,
|
||||
source ruling or public gate is opened.
|
||||
Targeted gaps fail before and pass after. Types/lint and 203 affected unit
|
||||
cases across 18 files pass. Forty-eight isolated runtime cases cover all five
|
||||
families, both Ix modes, enemy/ally cancelers, distinct/full-hand holders,
|
||||
claim/pass/four legal AI profiles and native cleanup/markets. Actual styled
|
||||
GameTable at390px completes all five Cancel→Commit paths. CHOAM11/10 wallets,
|
||||
canceled Ix board2Suboids/Tanks1Cyborg and Guild4/4→2/4 with only Worthless and
|
||||
Karama demonstrate original consequences; no inspection or keeper choice
|
||||
reopens. The first temporary harness omitted Tailwind PostCSS; it was corrected
|
||||
from the app config and all five styled paths plus the enlarged inspector
|
||||
were repeated. Earlier unstyled captures do not certify styled presentation.
|
||||
No existing game/store, deployment or mode gate changed. See
|
||||
[cost and ordering contract](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime).
|
||||
|
||||
**Semuta / native casualty and retention continuations — 11 October.**
|
||||
Mandatory loser discards now react while the original compulsory winner card
|
||||
remains reserved. Native Ixian casualties/substitution still finish before
|
||||
|
||||
@@ -1,5 +1,52 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 11 October 2026 — printed native battle-cancellation cost reactions
|
||||
|
||||
One shared `karamaBattleDiscard` receipt extends the existing response-discard
|
||||
pipeline to printed Karama canceling capture, Auditor, quoted battle income,
|
||||
Ixian substitution or Basic native Moritani retention. Accepted public cost is
|
||||
singleton; original response/native opportunity remains pending across neutral
|
||||
Semuta. Retirement restores saved controls and cancels once. Taking that cost
|
||||
cannot undo cancellation. Original quotes remain authoritative; the new kind
|
||||
signs battle/retention/Tech and physical force/leader/wallet/hand facts without
|
||||
changing old charity/Inflation JSON shapes. Auditor validation finds its saved
|
||||
response inside the new receipt. No native effect algorithm, ruling, module,
|
||||
Worthless conversion or public gate changed.
|
||||
|
||||
The native Moritani fail-before probe immediately discarded losing cards and
|
||||
cleared retention after the cost, without offering that Karama. Capture/income
|
||||
likewise skipped the cost; corrected Auditor setup reaches the same gap.
|
||||
Twenty meaningful new regressions cover eligible distinct holders, claim/pass,
|
||||
native finality and legal AI. Self-discard claim rows and forwarding/signature-
|
||||
length assertions were removed. Existing income/retention cases now retire the
|
||||
new cost before testing the later canceled-effect/card batch.
|
||||
|
||||
Types/lint and 203 unit cases across 18 files pass; final types pass after the
|
||||
existing income timing migration. Forty-eight runtime cases cover all five
|
||||
families, both Ix modes, enemy/ally cancelers, distinct/full-hand holders and
|
||||
four legal profiles. Original Tooth/Shield/allied batches and markets finish,
|
||||
with physical inventory, casualties and costs once. The initial fixture's
|
||||
inferred ordering was wrong: force income precedes Auditor, so its1 is already
|
||||
paid before audit cancellation. Canceled capture then gets ordinary Arrakeen
|
||||
Collection13→15; comparison uses the native baseline, not a frozen phase6
|
||||
wallet through phase7. Neither correction required a native rule change.
|
||||
|
||||
Styled real GameTable at390px completes all five Cancel with Karama→Commit
|
||||
paths. CHOAM wallets11/10 distinguish pre-Auditor paid from canceled income.
|
||||
Ix inspection shows board2Suboids, Tanks1Cyborg, reserves17 after canceled1/1;
|
||||
Tooth acknowledgements and original Shield choice finish. Guild4/4 claims
|
||||
the foreign cost, both played cards discard, and final hand is Worthless/
|
||||
Karama2/4 without another retention choice. No tested surface overflows;
|
||||
enlarged Semuta guidance is readable. The first temporary Vite harness omitted
|
||||
the app's Tailwind PostCSS plugin; it was corrected and all five styled paths
|
||||
repeated, without production dialog changes. Earlier unstyled captures are not
|
||||
styled-presentation certification.
|
||||
|
||||
Two disjoint fixture/test slices were integrated by one owner. Temporary
|
||||
probes/services/tabs removed; existing games, storage, user PDF and deployment
|
||||
untouched. Reference/tooltips and Semuta/retention contracts updated. Complete
|
||||
Semuta/mode and deployed acceptance remain unfinished.
|
||||
|
||||
## 11 October 2026 — native casualty and allied-retention Semuta continuations
|
||||
|
||||
Two disjoint native slices supply genuine setup fixtures and consumer-visible
|
||||
|
||||
@@ -121,6 +121,15 @@ played cards are reserved in this envelope, so no losing `battleResolved`
|
||||
batch precedes retention; that distinct pending-retention path stays gated.
|
||||
Advanced native Moritani and Nexus/Skills compositions are not opened.
|
||||
|
||||
An enemy or the defeated ally spending a printed Karama to cancel the proposed
|
||||
keep now produces an earlier singleton cost offer through `karamaBattleDiscard`.
|
||||
The original response/keep proposal remains pending across that reaction.
|
||||
Claim/pass then completes cancellation once and discards all original played
|
||||
IDs in the later allied batch. A distinct holder may acquire the activating
|
||||
cost, including the approved full-hand exchange, but it never joins that
|
||||
played pool or restores retention. Own discarded costs are not eligible.
|
||||
The original Moritani power owner still cannot cancel its own power.
|
||||
|
||||
Thirty-four isolated runtime cases cover keep either/none, claim/pass/four legal
|
||||
AI profiles, both preceding winner batches and original enemy/ally Karama
|
||||
allowance or cancellation. Actual GameTable at390px declines native retention,
|
||||
|
||||
@@ -269,6 +269,14 @@ For each batch retain actual former owner per entry, exact physical ID, a face s
|
||||
| Generic Hajr/Ghola/Harvester/Weather/Atomics (~13103) | One public played card from actor, one batch each. | Effects have already happened before the shared discard. `afterOrdinaryCard {owner,cardId,cardName,effect}` → log and normal finalization; do not reapply movement allowance, Ghola revival/tech/income, spice multiplier, weather setting or Wall casualties. A Ghola-created revival-income response must survive the pause. |
|
||||
| `resolveTruthAction` callback in `game/truthtrance.ts` (~460) | One public Truthtrance belonging to queued asker, **not the answering actor**. Multiple queued questions are successive events, not one batch. Unknown/save does not discard. | Definite answer, promise binding, public history and answer log already committed. `afterTruthtranceDiscard {queueHeadPlayer,queueHeadCard,questionBinding,remainingWindow}` activates the next queued question exactly once; do not answer/bind/history again. Detach the consumed head into this frame before exposing the pause: the saved-shipment-question validator requires a live queued Truthtrance still be held. Preserve the remaining queue privately in the continuation until Semuta ends. A generic top-level `truthtrance` early return must not prevent this nested claim. |
|
||||
|
||||
Printed Karama cancellation of native capture, Auditor, quoted battle income,
|
||||
Ixian substitution or Basic Moritani retention now uses `karamaBattleDiscard`.
|
||||
The original accepted response is detached, not canceled early. Only the
|
||||
public activating cost is fresh; claim/pass restores that response and calls
|
||||
the original cancellation once before its later native cleanup. A claimed
|
||||
cost cannot undo cancellation or enter the original losing-ally played pool.
|
||||
Worthless conversion and other cancellation families retain their boundaries.
|
||||
|
||||
## Special Karama stage inventory
|
||||
|
||||
`executeSpecialKaramaIntent` (~9579) already has a typed, validated declaration for each power. It is useful input to an **after-activation** executor, but cannot simply be called again after discarding: preparation assumes the activating physical card is still held. Every activating Karama face is public and belongs to its player. These are separate from any later discard caused by the power.
|
||||
|
||||
@@ -665,3 +665,41 @@ frames and original enemy/ally Karama allowance/cancellation. Real390px table
|
||||
declines native retention, commits and privately selects Maula Pistol from the
|
||||
two fresh cards, then reaches phase7 without re-offering retention or overflow.
|
||||
These are local bounded continuation proofs, not complete Semuta/mode acceptance.
|
||||
|
||||
**11 October 2026 — printed native battle-cancellation costs.** The shared
|
||||
response-discard pipeline now admits one `karamaBattleDiscard` receipt for
|
||||
printed Karama canceling native Harkonnen capture, CHOAM Auditor or quoted
|
||||
battle income, Ixian casualty substitution, or Basic native Moritani retention.
|
||||
Only the activating physical Karama is fresh. Its accepted cancellation,
|
||||
original response and pending native facts remain suspended until claim/pass;
|
||||
retirement restores the saved controls and calls the original cancellation
|
||||
once. Acquiring that cost cannot restore capture, inspection/payment, income,
|
||||
the declared exchange or the proposed kept card.
|
||||
|
||||
The original native quotes still validate the pending opportunity. Signature
|
||||
binding preserves its response, battle facts, physical counters, leaders,
|
||||
wallets and hand custody; old charity/Inflation signature shapes are unchanged.
|
||||
Auditor validation now recognizes the saved response inside this receipt,
|
||||
rather than requiring the detached live window. No Skills, Nexus, Homeworld,
|
||||
Worthless conversion, optional-module composition or public start is opened.
|
||||
|
||||
Forty-eight isolated runtime cases cover all five native families, both Ix
|
||||
modes, enemy/ally retention cancelers, distinct and full-hand defeated holders,
|
||||
claim/pass and four legal AI profiles. The cost is spent once, later native
|
||||
Tooth/Shield or allied-card batches finish, inventories remain physical and
|
||||
original costs/casualties stay committed. Canceled income remains unpaid;
|
||||
Auditor cancellation samples nothing. Its original income precedes the audit
|
||||
and already paid1 remains. Native capture cancellation reaches ordinary
|
||||
Arrakeen Collection13→15, not a second capture/bounty.
|
||||
|
||||
Actual styled GameTable at390px completes all five Cancel with Karama →
|
||||
Commit paths and original markets/cleanup. CHOAM wallets11/10 distinguish
|
||||
already allowed pre-Auditor income from canceled income. Actual Ix inspection
|
||||
shows board2Suboids, Tanks1Cyborg, reserves17 after the canceled exchange.
|
||||
A Guild4/4 holder claims the cost and ends with only its original Worthless
|
||||
and Karama2/4 after both played cards discard; retention is not re-offered.
|
||||
The updated Semuta inspector is readable. The first isolated harness lacked
|
||||
the app's Tailwind PostCSS plugin; that was corrected from `vite.config.ts`,
|
||||
and all five styled paths were repeated. Earlier unstyled screenshots do not
|
||||
certify styled presentation. No production dialog patch was needed.
|
||||
These are local changed-path proofs, not complete mode or deployed acceptance.
|
||||
@@ -40,7 +40,7 @@ export function richeseCardActionBlock(card: VisibleCard): string | null {
|
||||
if (definition?.card.effect === 'distrans')
|
||||
return 'Choose a recipient and card in the Distrans transfer panel.';
|
||||
if (definition?.card.effect === 'semutaDrug')
|
||||
return 'Use the neutral fresh-discard response in the Semuta development preview. Clean ordinary-card, paid Box, retired Ornithopter (before native Intrusion), Distrans, Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling CHOAM Charity, Inflation or Bene Gesserit Charity, battle-card and Ix-deck Thumper/Amal discards are connected, as are private normal-auction and Black Market Ixian allied replacements. Battle discards may pause before original Tech award, native Advanced capture, Face Dance, quoted CHOAM income or Auditor; original cleanup and the same opportunity resume once, using the current audited hand when relevant. Tech requires a supported fresh 3–6-seat CHOAM/optional-Ix profile. Native Ixian casualty substitution keeps its original before-Tooth order. Native Basic Moritani cleanup offers only unkept played cards after its original choice and Karama response. A Basic Richese/Moritani CHOAM/Ecaz profile also reacts to private Sabotage and Robbery overflow after clean shipment/movement arrivals. Clean reactive Baliset and La La La pause after cost without completing the stopped move or free revival. Proactive/final-Mentat Trip to Gamont preserves the exact ordinary/elite return or native No-Field reveal and capped recall, restoring its final owner window when applicable. The approved full-hand exchange spends Semuta without increasing the completed hand. Other pending rewards, producers and nested responses remain unfinished.';
|
||||
return 'Use the neutral fresh-discard response in the Semuta development preview. Clean ordinary-card, paid Box, retired Ornithopter (before native Intrusion), Distrans, Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling CHOAM Charity, Inflation, Bene Gesserit Charity or supported native battle powers, battle-card and Ix-deck Thumper/Amal discards are connected, as are private normal-auction and Black Market Ixian allied replacements. Acquiring a printed cancellation cost never restores its canceled effect. Battle discards may pause before original Tech award, native Advanced capture, Face Dance, quoted CHOAM income or Auditor; original cleanup and the same opportunity resume once, using the current audited hand when relevant. Tech requires a supported fresh 3–6-seat CHOAM/optional-Ix profile. Native Ixian casualty substitution keeps its original before-Tooth order. Native Basic Moritani cleanup offers only unkept played cards after its original choice and Karama response. A Basic Richese/Moritani CHOAM/Ecaz profile also reacts to private Sabotage and Robbery overflow after clean shipment/movement arrivals. Clean reactive Baliset and La La La pause after cost without completing the stopped move or free revival. Proactive/final-Mentat Trip to Gamont preserves the exact ordinary/elite return or native No-Field reveal and capped recall, restoring its final owner window when applicable. The approved full-hand exchange spends Semuta without increasing the completed hand. Other pending rewards, producers and nested responses remain unfinished.';
|
||||
return definition && definition.card.effect !== 'karama'
|
||||
? 'This Richese card’s effect is not implemented yet. You can inspect its rules.'
|
||||
: null;
|
||||
@@ -92,7 +92,7 @@ export function cardPresentation(card: VisibleCard): CardPresentation {
|
||||
richese.card.effect === 'juiceOfSapho'
|
||||
? 'Richese effect integration and verification are incomplete. Supported controls cover Once Around first or last while your bid remains open, bounded movement and battle-choice ordering, and aggressor during the shared pre-plan opportunity. Earlier bids stay committed. Later aggressor intervention and other phase or auction modes remain unfinished; expansion starts remain disabled.'
|
||||
: richese.card.effect === 'semutaDrug'
|
||||
? 'The explicit Semuta development preview offers neutral responses after clean public ordinary-card discards, paid Box disposal, retired Ornithopter movement (including a following Bene Gesserit Intrusion), Distrans, ordinary and queued definite Truthtrance answers, four Sapho scopes, unready pre-leader Residual Poison, printed Karama cancelling CHOAM Charity/Inflation/Bene Gesserit Charity or settling a normal-auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset, proactive/final-Mentat Trip to Gamont with an ordinary/elite force or concealed No-Field, accepted Jubba Cloak before storm protection, Ix-deck Thumper/Amal, three battle discard stages (including before original Tech award, native Advanced capture, Face Dance, quoted CHOAM income or Auditor) and private normal-auction or Black Market Ixian ally replacements. Tech requires a supported fresh 3–6-seat CHOAM/optional-Ix profile. Native Ixian casualty substitution keeps its original before-Tooth order. Native Basic Moritani cleanup offers only unkept played cards after the original choice and Karama response, preserving its kept card and unchanged original retention pool. The Basic Richese/Moritani CHOAM/Ecaz entry also adds private Sabotage and Robbery overflow after clean shipment/movement arrivals, preserving the original victim/draw and arrival suffix. Commitment is owner-only; the approved full-hand exchange spends Semuta without increasing your completed hand. Other pending rewards, producers, nested responses and normal expansion starts remain unfinished.'
|
||||
? 'The explicit Semuta development preview offers neutral responses after clean public ordinary-card discards, paid Box disposal, retired Ornithopter movement (including a following Bene Gesserit Intrusion), Distrans, ordinary and queued definite Truthtrance answers, four Sapho scopes, unready pre-leader Residual Poison, printed Karama cancelling CHOAM Charity/Inflation/Bene Gesserit Charity, native capture/Auditor/battle-income/Ix-substitution/Basic allied-retention powers, or settling a normal-auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset, proactive/final-Mentat Trip to Gamont with an ordinary/elite force or concealed No-Field, accepted Jubba Cloak before storm protection, Ix-deck Thumper/Amal, three battle discard stages (including before original Tech award, native Advanced capture, Face Dance, quoted CHOAM income or Auditor) and private normal-auction or Black Market Ixian ally replacements. Acquiring a printed cancellation cost never restores its canceled effect. Tech requires a supported fresh 3–6-seat CHOAM/optional-Ix profile. Native Ixian casualty substitution keeps its original before-Tooth order. Native Basic Moritani cleanup offers only unkept played cards after the original choice and Karama response, preserving its kept card and unchanged original retention pool. The Basic Richese/Moritani CHOAM/Ecaz entry also adds private Sabotage and Robbery overflow after clean shipment/movement arrivals, preserving the original victim/draw and arrival suffix. Commitment is owner-only; the approved full-hand exchange spends Semuta without increasing your completed hand. Other pending rewards, producers, nested responses and normal expansion starts remain unfinished.'
|
||||
: richese.card.effect === 'mirrorWeapon'
|
||||
? 'Mirror Weapon works in bounded CHOAM/Richese-deck classic-faction battles; copied Tooth and Stone choices resolve separately, and a winning physical Mirror may be kept even after copying Tooth or Artillery. This winner-retention treatment is a user-selected interpretation, not an official clarification. Combined modes and full Richese starts remain gated.'
|
||||
: richese.card.effect === 'stoneBurner'
|
||||
|
||||
+40
-10
@@ -1344,7 +1344,7 @@ export type Game = {
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard';
|
||||
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard';
|
||||
player: string;
|
||||
card: string;
|
||||
use: Extract<KaramaUse, { kind: 'cancel' }>;
|
||||
@@ -5140,11 +5140,18 @@ function residualPoisonDiscardSignature(g: Game, c: ResidualPoisonDiscardContinu
|
||||
})),
|
||||
});
|
||||
}
|
||||
function semutaBattleKaramaResponse(g: Game, response: ResponseWindow): boolean {
|
||||
return response.kind === 'capture' || response.kind === 'choamAudit' ||
|
||||
response.kind === 'choamBattleIncome' || response.kind === 'ixSubstitution' ||
|
||||
(!g.advanced && response.kind === 'moritaniRetention' &&
|
||||
g.moritaniRetention?.source === undefined);
|
||||
}
|
||||
type KaramaResponseDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' }
|
||||
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' }
|
||||
>;
|
||||
function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) {
|
||||
const battle = c.kind === 'karamaBattleDiscard';
|
||||
return JSON.stringify({
|
||||
player: c.player, card: c.card, use: c.use,
|
||||
parent: nullentropyParentSignature(g, c.resume,
|
||||
@@ -5160,10 +5167,15 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
|
||||
...(c.kind === 'karamaBgCharityDiscard' ? {
|
||||
claimed: getPlayer(g, c.use.response.owner).charityTurn === g.turn,
|
||||
} : {}),
|
||||
battleContext: battle ? g.lastBattleContext : undefined,
|
||||
retention: battle ? g.moritaniRetention : undefined,
|
||||
techTokens: battle ? g.techTokens : 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,
|
||||
physical: battle ? { forces: p.forces, reserves: p.reserves, tanks: p.tanks,
|
||||
elites: p.elites, leaders: p.leaders } : undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -5852,7 +5864,8 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
);
|
||||
} else if (continuation?.kind === 'karamaCharityDiscard' ||
|
||||
continuation?.kind === 'karamaInflationDiscard' ||
|
||||
continuation?.kind === 'karamaBgCharityDiscard') {
|
||||
continuation?.kind === 'karamaBgCharityDiscard' ||
|
||||
continuation?.kind === 'karamaBattleDiscard') {
|
||||
const c = continuation, entry = batch.entries[0];
|
||||
requireRule(
|
||||
!g.response &&
|
||||
@@ -5866,7 +5879,9 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
(c.kind === 'karamaBgCharityDiscard' &&
|
||||
c.use.response.kind === 'bgCharity' && g.phase === 2 &&
|
||||
g.players.some(p => p.id === c.use.response.owner &&
|
||||
p.faction === 'beneGesserit' && p.charityTurn === g.turn))) &&
|
||||
p.faction === 'beneGesserit' && p.charityTurn === g.turn)) ||
|
||||
(c.kind === 'karamaBattleDiscard' && g.phase === 6 && !g.battle &&
|
||||
semutaBattleKaramaResponse(g, c.use.response))) &&
|
||||
c.use.response.owner !== c.player &&
|
||||
c.resume.response?.kind === c.use.response.kind &&
|
||||
JSON.stringify(c.resume.response) === JSON.stringify(c.use.response) &&
|
||||
@@ -5877,8 +5892,15 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
entry.card.id === c.card && entry.card.effect === 'karama' &&
|
||||
g.players.some(p => p.id === c.player) &&
|
||||
c.stateSignature === karamaResponseDiscardSignature(g, c),
|
||||
'The spent Karama no longer matches its suspended charity or Inflation cancellation.',
|
||||
'The spent Karama no longer matches its suspended native cancellation.',
|
||||
);
|
||||
if (c.kind === 'karamaBattleDiscard') {
|
||||
if (c.use.response.kind === 'moritaniRetention')
|
||||
validateMoritaniCancellation(g, c.use.response.owner);
|
||||
else if (c.use.response.kind === 'ixSubstitution')
|
||||
ixSubstitutionCancellationQuote(g, c.use.response);
|
||||
else validateAftermathCancellation(g, c.use.response);
|
||||
}
|
||||
} else if (continuation?.kind === 'karamaPurchaseDiscard' ||
|
||||
continuation?.kind === 'karamaPaymentDiscard') {
|
||||
const c = continuation, entry = batch.entries[0];
|
||||
@@ -6683,6 +6705,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'karamaCharityDiscard' &&
|
||||
continuation.kind !== 'karamaInflationDiscard' &&
|
||||
continuation.kind !== 'karamaBgCharityDiscard' &&
|
||||
continuation.kind !== 'karamaBattleDiscard' &&
|
||||
continuation.kind !== 'karamaPurchaseDiscard' &&
|
||||
continuation.kind !== 'karamaPaymentDiscard' &&
|
||||
continuation.kind !== 'thumperDiscard' &&
|
||||
@@ -6760,8 +6783,11 @@ function semutaOfferSupported(
|
||||
: entries.length === 1 && entries[0].publicFace) &&
|
||||
(continuation.kind === 'karamaCharityDiscard' ||
|
||||
continuation.kind === 'karamaInflationDiscard' ||
|
||||
continuation.kind === 'karamaBgCharityDiscard'
|
||||
? ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '') &&
|
||||
continuation.kind === 'karamaBgCharityDiscard' ||
|
||||
continuation.kind === 'karamaBattleDiscard'
|
||||
? (continuation.kind === 'karamaBattleDiscard'
|
||||
? semutaBattleKaramaResponse(g, continuation.use.response)
|
||||
: ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) &&
|
||||
!continuation.resume.decision && !continuation.resume.pendingKarama &&
|
||||
!continuation.resume.phaseOpening && !g.pendingKarama &&
|
||||
(!g.response || JSON.stringify(g.response) === JSON.stringify(continuation.resume.response))
|
||||
@@ -6780,6 +6806,7 @@ function semutaOfferSupported(
|
||||
(continuation.kind === 'karamaCharityDiscard' ||
|
||||
continuation.kind === 'karamaInflationDiscard' ||
|
||||
continuation.kind === 'karamaBgCharityDiscard' ||
|
||||
continuation.kind === 'karamaBattleDiscard' ||
|
||||
(!continuation.resume.response && !continuation.resume.decision &&
|
||||
!continuation.resume.pendingKarama &&
|
||||
(continuation.kind === 'amalDiscard'
|
||||
@@ -7043,6 +7070,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
||||
} else if (next.kind === 'karamaCharityDiscard' ||
|
||||
next.kind === 'karamaInflationDiscard' ||
|
||||
next.kind === 'karamaBgCharityDiscard' ||
|
||||
next.kind === 'karamaBattleDiscard' ||
|
||||
next.kind === 'karamaPurchaseDiscard' ||
|
||||
next.kind === 'karamaPaymentDiscard') {
|
||||
g.response = next.resume.response;
|
||||
@@ -13244,12 +13272,13 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
|
||||
use.kind === 'cancel' &&
|
||||
(use.response.kind === 'choamCharity' ||
|
||||
use.response.kind === 'choamInflation' ||
|
||||
use.response.kind === 'bgCharity')) {
|
||||
use.response.kind === 'bgCharity' || semutaBattleKaramaResponse(g, use.response))) {
|
||||
const continuation: KaramaResponseDiscardContinuation = {
|
||||
kind: use.response.kind === 'choamCharity'
|
||||
? 'karamaCharityDiscard'
|
||||
: use.response.kind === 'choamInflation'
|
||||
? 'karamaInflationDiscard' : 'karamaBgCharityDiscard',
|
||||
? 'karamaInflationDiscard'
|
||||
: use.response.kind === 'bgCharity' ? 'karamaBgCharityDiscard' : 'karamaBattleDiscard',
|
||||
player: p.id, card: used.id, use,
|
||||
resume: { response: g.response, decision: g.decision,
|
||||
pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening },
|
||||
@@ -22919,7 +22948,8 @@ function auditorIntegrity(g: Game) {
|
||||
g.pendingNullentropy?.resume,
|
||||
g.pendingKull?.resume,
|
||||
g.pendingTreacheryDiscard?.continuation.kind === 'nullentropyDiscard' ||
|
||||
g.pendingTreacheryDiscard?.continuation.kind === 'truthtranceDiscard'
|
||||
g.pendingTreacheryDiscard?.continuation.kind === 'truthtranceDiscard' ||
|
||||
g.pendingTreacheryDiscard?.continuation.kind === 'karamaBattleDiscard'
|
||||
? g.pendingTreacheryDiscard.continuation.resume
|
||||
: undefined,
|
||||
g.pendingRicheseGift?.resume,
|
||||
|
||||
@@ -1179,6 +1179,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Fresh three-through-six-seat Basic/Advanced Richese games with CHOAM and optional Ix may retain original unused Tech Tokens under the existing native roster prerequisites. Mandatory and winner discards now offer Semuta before the original winner takes one loser-owned token. Claim or Continue leaves every token and income ledger in place until original cleanup finishes; the winner’s selected award still precedes Face Dance. Skills, Strongholds, other overlays and the separate Basic Moritani entry remain excluded.',
|
||||
'A mandatory loser-card discard may also offer Semuta while the original winner’s compulsory used card remains reserved. For native Ixians, claim or Continue settles the original casualties, then restores the one-for-one Suboid/Cyborg substitution and its Karama response. The winning used Poison Tooth is not discarded until substitution or decline finishes; only then is that physical Tooth a separate fresh target before optional winner cleanup.',
|
||||
'In the Basic native Moritani entry, winner compulsory/optional card discards may pause before the losing ally’s original retention choice. After that ally chooses one eligible played card or declines, native Karama finishes first; the completed unkept-card batch then offers Semuta. A kept or unrelated hand card is never a target. Multiple fresh cards require commitment and one private selection; taking a winner’s card does not add it to the original retention pool. Advanced Moritani, Nexus retention and unresolved casualty parents remain separate.',
|
||||
'A printed Karama canceling native Harkonnen capture, CHOAM Auditor or quoted battle income, Ixian casualty substitution, or Basic Moritani allied retention now offers that exact public cost before the original cancellation finishes. Claim or Continue restores the saved response and cancels once. Acquiring the cost card cannot restore a captive, inspect a hand, pay the canceled income, exchange casualties or keep the proposed allied card. Later native card cleanup and markets still finish; Worthless conversion, other cancellations and module combinations remain separate.',
|
||||
'An ordinary Ghola played during battle also offers its consumed card after revival. Claim or Continue preserves the revived leader or forces and the opposing sealed plan, then normal planning resumes. It cannot revive again by resuming the receipt.',
|
||||
'A completed paid Nullentropy Box search during battle offers only the used Box. Claim or Continue preserves the two-spice payment, private recovered card, already-shuffled pile and sealed opposing plan. The recovered card may be used in normal legal planning; the search is not repeated.',
|
||||
'A clean Distrans transfer during battle also offers only the used Distrans. The private card has already reached its recipient; claim or Continue cannot repeat the gift or change a sealed plan. A recipient holding Semuta may claim the used Distrans and keep the already-transferred card.',
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { isAuditorLeader } from '../game/cards';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
|
||||
export type SemutaBattleKaramaKind = 'capture' | 'choamAudit' | 'choamBattleIncome';
|
||||
export const reloadBattleKarama = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
export const battleKaramaPlayer = (game: Game, id: string) => game.players.find(p => p.id === id)!;
|
||||
|
||||
function take(game: Game, id: string) {
|
||||
for (const zone of [game.deck, game.discard, game.richeseCache!, ...game.players.map(p => p.hand)]) {
|
||||
const index = zone.findIndex(card => card.id === id || card.effect === id);
|
||||
if (index >= 0) return zone.splice(index, 1)[0];
|
||||
}
|
||||
throw new Error(`Missing physical ${id}`);
|
||||
}
|
||||
|
||||
export function battleKaramaInventory(game: Game) {
|
||||
const ids = [...game.deck, ...game.discard, ...game.richeseCache!, ...game.players.flatMap(p => p.hand)]
|
||||
.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length);
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function passBattleKaramaOffer(game: Game): Game {
|
||||
const event = game.pendingTreacheryDiscard!.batch.event;
|
||||
for (const seat of game.players) game = applyAction(game, seat.id, { type: 'semutaPass', event });
|
||||
return game;
|
||||
}
|
||||
|
||||
export function battleKaramaCostAction(card: string): Action {
|
||||
return { type: 'card', mode: 'cancel', card };
|
||||
}
|
||||
|
||||
export function settleBattleKaramaCost(game: Game, claim: boolean): Game {
|
||||
return claim
|
||||
? applyAction(game, 'r', { type: 'semutaCommit', event: game.pendingTreacheryDiscard!.batch.event })
|
||||
: passBattleKaramaOffer(game);
|
||||
}
|
||||
|
||||
// Complete any remaining original income window and native CHOAM market.
|
||||
export function finishBattleKarama(game: Game): Game {
|
||||
for (let step = 0; step < 12 && game.response?.kind === 'choamBattleIncome'; step++) {
|
||||
const seat = game.players.find(p => !game.response!.passed.includes(p.id))!;
|
||||
game = applyAction(game, seat.id, { type: 'passResponse' });
|
||||
}
|
||||
if (game.decision?.kind === 'choamMarket')
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', done: true });
|
||||
assert.equal(game.phase, 7);
|
||||
return game;
|
||||
}
|
||||
|
||||
export function semutaBattleKaramaFixture(kind: SemutaBattleKaramaKind) {
|
||||
const capture = kind === 'capture';
|
||||
let game = createGame('SEMUTABK', newPlayer('r', 'Richese', 'richese'), true,
|
||||
capture ? ['choam'] : ['choam', 'ix']);
|
||||
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer('e', capture ? 'Harkonnen' : 'CHOAM', capture ? 'harkonnen' : 'choam'));
|
||||
for (const seat of game.players) { seat.bot = 'Medium'; seat.ready = true; }
|
||||
game = initializeSemutaGameForAudit(game);
|
||||
for (let step = 0; game.status === 'setup' && step < 80; step++) {
|
||||
let next: Game | undefined;
|
||||
for (const seat of game.players) {
|
||||
const action = botActions(viewGame(game, seat.id))[0];
|
||||
if (action) { next = applyAction(game, seat.id, action); break; }
|
||||
}
|
||||
assert.ok(next, 'native setup must provide a legal choice');
|
||||
game = next;
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
|
||||
// Only later physical staging changes: every printed card and force remains conserved.
|
||||
game.deck.push(...game.players.flatMap(p => p.hand));
|
||||
for (const seat of game.players) {
|
||||
Object.assign(seat, { hand: [], spice: 10, forces: {}, reserves: 20, tanks: 0 });
|
||||
}
|
||||
const semuta = take(game, SEMUTA_DRUG_ID), karama = take(game, 'karama'), privateCard = take(game, 'hajr');
|
||||
const weapon = take(game, game.deck.find(card => card.kind === 'projectile')!.id);
|
||||
const defense = take(game, game.deck.find(card => card.kind === 'snooper')!.id);
|
||||
const winner = capture ? 'e' : 'a', loser = capture ? 'a' : 'e';
|
||||
battleKaramaPlayer(game, 'r').hand.push(semuta);
|
||||
battleKaramaPlayer(game, 'a').hand.push(karama, privateCard);
|
||||
battleKaramaPlayer(game, winner).hand.push(weapon);
|
||||
battleKaramaPlayer(game, loser).hand.push(defense);
|
||||
for (const id of ['a', 'e'])
|
||||
Object.assign(battleKaramaPlayer(game, id), { forces: { 'arrakeen:10': 5 }, reserves: 15 });
|
||||
Object.assign(game, { turn: 2, phase: 6, active: winner, order: [winner, loser, 'r'],
|
||||
storm: 18, response: null, decision: null, phaseOpening: null, stormPending: null,
|
||||
movementRemaining: [], ready: [] });
|
||||
const staged = reloadBattleKarama(game), inventory = battleKaramaInventory(game);
|
||||
const winnerLeader = battleKaramaPlayer(game, winner).leaders[0];
|
||||
const loserLeader = kind === 'choamAudit'
|
||||
? battleKaramaPlayer(game, loser).leaders.find(isAuditorLeader)!
|
||||
: battleKaramaPlayer(game, loser).leaders[0];
|
||||
assert.equal(winnerLeader.strength, capture ? 6 : 5);
|
||||
assert.equal(loserLeader.strength, capture ? 5 : kind === 'choamAudit' ? 2 : 4);
|
||||
|
||||
game = applyAction(game, winner, { type: 'chooseBattle', territory: 'arrakeen', target: loser });
|
||||
for (let step = 0; step < 40; step++) {
|
||||
if (game.response) {
|
||||
const seat = game.players.find(p => !game.response!.passed.includes(p.id))!;
|
||||
game = applyAction(game, seat.id, { type: 'passResponse' });
|
||||
} else if (game.battle?.preparation)
|
||||
game = applyAction(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
|
||||
else if (game.decision?.kind === 'fullPlanOffer')
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', decline: true });
|
||||
else break;
|
||||
}
|
||||
for (const id of [winner, loser])
|
||||
game = applyAction(game, id, { type: 'battlePreparationReady', event: game.battle!.preLeader!.event });
|
||||
game = applyAction(game, winner, { type: 'battlePlan', dial: 2, support: 2,
|
||||
leader: winnerLeader.id, weapon: weapon.id });
|
||||
game = applyAction(game, loser, { type: 'battlePlan', dial: 0, support: 0,
|
||||
leader: loserLeader.id, defense: defense.id });
|
||||
for (const id of [winner, loser]) game = applyAction(game, id, { type: 'traitorCall', call: false });
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
const earlierEvents = [game.pendingTreacheryDiscard!.batch.event];
|
||||
game = passBattleKaramaOffer(game);
|
||||
assert.equal(game.decision?.kind, 'battleCards');
|
||||
game = applyAction(game, winner, { type: 'decision', discard: [weapon.id] });
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
earlierEvents.push(game.pendingTreacheryDiscard!.batch.event);
|
||||
game = passBattleKaramaOffer(game);
|
||||
assert.equal(battleKaramaPlayer(game, 'r').hand[0].id, semuta.id);
|
||||
if (kind === 'choamAudit') {
|
||||
// Original force income precedes Auditor; allow it before opening the audit.
|
||||
for (let step = 0; game.response?.kind === 'choamBattleIncome' && step < 12; step++) {
|
||||
const seat = game.players.find(p => !game.response!.passed.includes(p.id))!;
|
||||
game = applyAction(game, seat.id, { type: 'passResponse' });
|
||||
}
|
||||
}
|
||||
const nativeOffer = reloadBattleKarama(game);
|
||||
if (capture) {
|
||||
assert.equal(game.decision?.kind, 'captureOffer');
|
||||
game = applyAction(game, 'e', { type: 'decision', accept: true });
|
||||
} else if (kind === 'choamAudit') {
|
||||
assert.equal(game.decision?.kind, 'choamAudit');
|
||||
game = applyAction(game, 'e', { type: 'decision', event: game.pendingAuditor!.event, audit: true });
|
||||
}
|
||||
assert.equal(game.response?.kind, kind);
|
||||
return { kind, staged, nativeOffer, originalResponse: reloadBattleKarama(game), game,
|
||||
spender: 'a', holder: 'r', winner, loser, karama: karama.id, semuta: semuta.id,
|
||||
privateCard: privateCard.id, weapon: weapon.id, defense: defense.id,
|
||||
winnerLeader: winnerLeader.id, loserLeader: loserLeader.id, bounty: loserLeader.strength,
|
||||
earlierEvents, inventory, cancel: battleKaramaCostAction(karama.id) };
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
semutaIxBattleFixture, semutaIxPlanSteps, semutaIxSubstitutionStep,
|
||||
semutaIxPlayer, type SemutaIxFixture,
|
||||
} from './fixture-semuta-ix-substitution';
|
||||
import {
|
||||
semutaMoritaniRetentionFixture, resolveSemutaMoritaniBattle,
|
||||
finishSemutaMoritaniWinner, chooseSemutaMoritaniRetention,
|
||||
type SemutaMoritaniFixture,
|
||||
} from './fixture-semuta-moritani-retention';
|
||||
|
||||
export type SemutaNativeKaramaStep = { actor: string; action: Action };
|
||||
export type SemutaNativeKaramaFixture = {
|
||||
game: Game; kind: 'ixSubstitution' | 'moritaniRetention';
|
||||
holder: string; spender: string; owner: string;
|
||||
karama: string; semuta: string; cards: string[];
|
||||
ix?: SemutaIxFixture; moritani?: SemutaMoritaniFixture;
|
||||
};
|
||||
export const semutaNativeKaramaReload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
export function semutaNativeKaramaAct(game: Game, step: SemutaNativeKaramaStep, actions?: SemutaNativeKaramaStep[]) {
|
||||
actions?.push(structuredClone(step));
|
||||
return applyAction(game, step.actor, step.action);
|
||||
}
|
||||
|
||||
/** Original lobby and conserved later battle; only the existing printed Karama changes hands. */
|
||||
export function semutaNativeIxKaramaFixture(advanced = false): SemutaNativeKaramaFixture {
|
||||
const ix = semutaIxBattleFixture(advanced);
|
||||
const richese = semutaIxPlayer(ix.game, ix.holder);
|
||||
const index = richese.hand.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(index >= 0);
|
||||
const [karama] = richese.hand.splice(index, 1);
|
||||
semutaIxPlayer(ix.game, ix.loser).hand.push(karama);
|
||||
return { game: ix.game, kind: 'ixSubstitution', holder: ix.holder,
|
||||
spender: ix.loser, owner: ix.winner, karama: karama.id, semuta: ix.semuta, cards: ix.cards, ix };
|
||||
}
|
||||
export function semutaNativeMoritaniKaramaFixture(
|
||||
options: { karamaHolder?: 'w' | 'l'; semutaHolder?: 'r' | 'l' } = {},
|
||||
): SemutaNativeKaramaFixture {
|
||||
const spender = options.karamaHolder ?? 'w', holder = options.semutaHolder ?? 'r';
|
||||
const moritani = semutaMoritaniRetentionFixture({ karamaHolder: spender, semutaHolder: holder });
|
||||
assert.ok(moritani.karama);
|
||||
return { game: moritani.game, kind: 'moritaniRetention', holder, spender,
|
||||
owner: 'm', karama: moritani.karama, semuta: moritani.semuta, cards: moritani.cards, moritani };
|
||||
}
|
||||
|
||||
/** Export exact legal reaction steps, including neutral later offers after Semuta was spent. */
|
||||
export function semutaNativeKaramaReactionSteps(game: Game, f: SemutaNativeKaramaFixture, claim = false): SemutaNativeKaramaStep[] {
|
||||
const pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending);
|
||||
const reaction = pending.reaction;
|
||||
if (reaction?.stage !== 'offer') throw Error('Expected the neutral fresh discard offer.');
|
||||
const event = pending.batch.event;
|
||||
if (claim) {
|
||||
assert.equal(pending.batch.entries.length, 1, 'This API commits the singleton printed cost.');
|
||||
return [{ actor: f.holder, action: { type: 'semutaCommit', event } }];
|
||||
}
|
||||
return game.players.filter(p => !reaction.passed.includes(p.id))
|
||||
.map(p => ({ actor: p.id, action: { type: 'semutaPass' as const, event } }));
|
||||
}
|
||||
export function semutaNativeKaramaReact(game: Game, f: SemutaNativeKaramaFixture, claim = false, actions?: SemutaNativeKaramaStep[]) {
|
||||
for (const step of semutaNativeKaramaReactionSteps(game, f, claim))
|
||||
game = semutaNativeKaramaAct(game, step, actions);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Native pre-cost actions: pass Snooper, declare the actual 1/1 exchange; or choose a played losing card. */
|
||||
export function semutaNativeKaramaPreCost(f: SemutaNativeKaramaFixture, actions?: SemutaNativeKaramaStep[]): Game {
|
||||
if (f.kind === 'moritaniRetention') {
|
||||
assert.ok(f.moritani);
|
||||
let game = resolveSemutaMoritaniBattle(f.moritani, actions);
|
||||
game = finishSemutaMoritaniWinner(game, [], actions);
|
||||
return chooseSemutaMoritaniRetention(game, f.moritani.weapon, actions);
|
||||
}
|
||||
assert.ok(f.ix);
|
||||
const ix = f.ix;
|
||||
let game = semutaNativeKaramaAct(f.game,
|
||||
{ actor: ix.winner, action: { type: 'chooseBattle', territory: ix.territory, target: ix.loser } }, actions);
|
||||
for (let count = 0; count < 40; count++) {
|
||||
let step: SemutaNativeKaramaStep;
|
||||
if (game.response) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
step = { actor: actor.id, action: { type: 'passResponse' } };
|
||||
} else if (game.battle?.preparation)
|
||||
step = { actor: game.battle.preparation.owner, action: { type: 'declineBattlePower' } };
|
||||
else if (game.decision?.kind === 'fullPlanOffer')
|
||||
step = { actor: game.decision.player, action: { type: 'decision', decline: true } };
|
||||
else break;
|
||||
game = semutaNativeKaramaAct(game, step, actions);
|
||||
}
|
||||
assert.ok(game.battle?.preLeader);
|
||||
for (const actor of [ix.winner, ix.loser])
|
||||
game = semutaNativeKaramaAct(game,
|
||||
{ actor, action: { type: 'battlePreparationReady', event: game.battle!.preLeader!.event } }, actions);
|
||||
for (const step of semutaIxPlanSteps(game, ix)) game = semutaNativeKaramaAct(game, step, actions);
|
||||
assert.equal(game.decision?.kind, 'poisonTooth');
|
||||
game = semutaNativeKaramaAct(game, { actor: ix.winner, action: { type: 'decision', activate: true } }, actions);
|
||||
for (const actor of [ix.winner, ix.loser])
|
||||
game = semutaNativeKaramaAct(game, { actor, action: { type: 'traitorCall', call: false } }, actions);
|
||||
assert.deepEqual(game.pendingTreacheryDiscard?.batch.entries.map(entry => entry.card.id), [ix.snooper]);
|
||||
game = semutaNativeKaramaReact(game, f, false, actions);
|
||||
assert.equal(game.decision?.kind, 'ixSubstitution');
|
||||
return semutaNativeKaramaAct(game, semutaIxSubstitutionStep(ix, true), actions);
|
||||
}
|
||||
export function semutaNativeKaramaCostStep(f: SemutaNativeKaramaFixture): SemutaNativeKaramaStep {
|
||||
return { actor: f.spender, action: { type: 'card', card: f.karama, mode: 'cancel' } };
|
||||
}
|
||||
export function semutaNativeKaramaCost(game: Game, f: SemutaNativeKaramaFixture, actions?: SemutaNativeKaramaStep[]): Game {
|
||||
assert.equal(game.response?.kind, f.kind);
|
||||
assert.equal(game.response?.owner, f.owner);
|
||||
return semutaNativeKaramaAct(game, semutaNativeKaramaCostStep(f), actions);
|
||||
}
|
||||
|
||||
/** Pass later fresh native batches; Ix still discards its used Tooth before optional Shield cleanup. */
|
||||
export function semutaNativeKaramaRemainder(game: Game, f: SemutaNativeKaramaFixture, actions?: SemutaNativeKaramaStep[]): Game {
|
||||
for (let count = 0; game.phase === 6 && count < 16; count++) {
|
||||
if (game.pendingTreacheryDiscard) {
|
||||
assert.notEqual(game.pendingTreacheryDiscard.batch.cause, `karama:${f.kind}`,
|
||||
'Retire the cost reaction before entering its native suffix.');
|
||||
game = semutaNativeKaramaReact(game, f, false, actions);
|
||||
} else if (f.kind === 'ixSubstitution' && game.decision?.kind === 'battleCards') {
|
||||
assert.ok(f.ix);
|
||||
assert.deepEqual(game.decision.cards, [f.ix.shield]);
|
||||
game = semutaNativeKaramaAct(game,
|
||||
{ actor: f.ix.winner, action: { type: 'decision', discard: [f.ix.shield] } }, actions);
|
||||
} else throw Error('The canceled native aftermath did not expose its original cleanup.');
|
||||
}
|
||||
assert.equal(game.phase, 7);
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
return game;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { battleKaramaInventory, battleKaramaPlayer, finishBattleKarama, reloadBattleKarama,
|
||||
semutaBattleKaramaFixture, settleBattleKaramaCost, type SemutaBattleKaramaKind } from './fixture-semuta-battle-karama';
|
||||
|
||||
const kinds: SemutaBattleKaramaKind[] = ['capture', 'choamAudit', 'choamBattleIncome'];
|
||||
|
||||
function battleQuantities(game: Game) {
|
||||
return game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces,
|
||||
reserves: p.reserves, tanks: p.tanks, leaders: p.leaders }));
|
||||
}
|
||||
|
||||
for (const kind of kinds) for (const claim of [false, true]) void test(
|
||||
`printed Karama canceling native ${kind} offers only its cost before ${claim ? 'claim' : 'all-seat pass'} and finishes once`, () => {
|
||||
const fixture = semutaBattleKaramaFixture(kind);
|
||||
const original = reloadBattleKarama(fixture.originalResponse);
|
||||
const native = reloadBattleKarama(original);
|
||||
delete native.semutaPreview;
|
||||
const nativeCanceled = applyAction(native, fixture.spender, fixture.cancel);
|
||||
const winner = battleKaramaPlayer(original, fixture.winner);
|
||||
const loser = battleKaramaPlayer(original, fixture.loser);
|
||||
assert.equal(winner.spice, 10 - 2 + fixture.bounty);
|
||||
assert.equal(loser.spice, kind === 'choamAudit' ? 11 : 10);
|
||||
assert.deepEqual(winner.forces, { 'arrakeen:10': 3 });
|
||||
assert.deepEqual(loser.forces, {});
|
||||
assert.equal(winner.tanks, 2);
|
||||
assert.equal(loser.tanks, 5);
|
||||
assert.equal(winner.reserves, 15);
|
||||
assert.equal(loser.reserves, 15);
|
||||
assert.equal(loser.leaders.find(l => l.id === fixture.loserLeader)!.dead, true);
|
||||
assert.equal(loser.leaders.find(l => l.id === fixture.loserLeader)!.deaths, 1);
|
||||
if (kind === 'capture') {
|
||||
assert.equal(fixture.nativeOffer.decision?.kind, 'captureOffer');
|
||||
assert.deepEqual(original.pendingCapture, { player: 'e', loser: 'a', territory: 'arrakeen' });
|
||||
} else if (kind === 'choamAudit') {
|
||||
assert.equal(fixture.nativeOffer.decision?.kind, 'choamAudit');
|
||||
assert.equal(original.pendingAuditor?.stage, 'response');
|
||||
assert.equal(original.pendingAuditor?.survived, false);
|
||||
assert.deepEqual(original.pendingAuditor?.usedCards, [fixture.weapon]);
|
||||
assert.deepEqual(battleKaramaPlayer(original, 'a').hand.map(c => c.id).sort(),
|
||||
[fixture.karama, fixture.privateCard].sort());
|
||||
} else assert.deepEqual(original.pendingChoamBattleIncome, { owner: 'e', amount: 1 });
|
||||
|
||||
const pending = applyAction(original, fixture.spender, fixture.cancel);
|
||||
// This is the missing producer on the baseline: the cost used to skip Semuta entirely.
|
||||
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'karamaBattleDiscard');
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
const receipt = pending.pendingTreacheryDiscard!;
|
||||
assert.equal(receipt.batch.cause, `karama:${kind}`);
|
||||
assert.equal(receipt.batch.entries.length, 1);
|
||||
assert.equal(receipt.batch.entries[0].card.id, fixture.karama);
|
||||
assert.equal(receipt.batch.entries[0].card.effect, 'karama');
|
||||
assert.equal(receipt.batch.entries[0].discardedBy, fixture.spender);
|
||||
assert.equal(receipt.batch.entries[0].publicFace, true);
|
||||
assert.equal(pending.discard.filter(c => c.id === fixture.karama).length, 1);
|
||||
assert.equal(battleKaramaPlayer(pending, fixture.spender).hand.some(c => c.id === fixture.karama), false);
|
||||
assert.equal(pending.response, null);
|
||||
assert.deepEqual(pending.pendingCapture, original.pendingCapture);
|
||||
assert.deepEqual(pending.pendingAuditor, original.pendingAuditor);
|
||||
assert.deepEqual(pending.pendingChoamBattleIncome, original.pendingChoamBattleIncome);
|
||||
assert.deepEqual(battleQuantities(pending), battleQuantities(original));
|
||||
assert.equal(pending.auditorInsight == null, true);
|
||||
assert.deepEqual(battleKaramaInventory(pending), fixture.inventory);
|
||||
assert.deepEqual(normalizeAutomaticGame(reloadBattleKarama(pending)), reloadBattleKarama(pending));
|
||||
assert.equal(viewGame(pending, fixture.holder).semutaReaction?.canCommit, true);
|
||||
for (const oldEvent of fixture.earlierEvents)
|
||||
assert.throws(() => applyAction(pending, fixture.holder, { type: 'semutaCommit', event: oldEvent }));
|
||||
assert.throws(() => applyAction(pending, fixture.spender, fixture.cancel));
|
||||
if (kind === 'capture') {
|
||||
assert.equal(pending.players.some(p => p.leaders.some(l => l.capturedBy || l.concealed)), false);
|
||||
assert.throws(() => applyAction(pending, 'e', { type: 'decision', mode: 'keep' }));
|
||||
} else if (kind === 'choamAudit') {
|
||||
assert.throws(() => applyAction(pending, 'a', { type: 'decision',
|
||||
event: original.pendingAuditor!.event, pay: false }));
|
||||
}
|
||||
|
||||
const event = receipt.batch.event;
|
||||
// Save at the physically discarded, still-unsettled boundary; a neutral pass must not retire it.
|
||||
let saved = applyAction(reloadBattleKarama(pending), fixture.spender, { type: 'semutaPass', event });
|
||||
assert.equal(saved.pendingTreacheryDiscard?.batch.event, event);
|
||||
assert.deepEqual(battleQuantities(saved), battleQuantities(original));
|
||||
assert.throws(() => applyAction(saved, fixture.spender, { type: 'semutaPass', event }));
|
||||
saved = reloadBattleKarama(saved);
|
||||
let resumed: Game;
|
||||
if (claim) resumed = settleBattleKaramaCost(saved, true);
|
||||
else {
|
||||
resumed = saved;
|
||||
for (const seat of resumed.players.filter(p => p.id !== fixture.spender))
|
||||
resumed = applyAction(resumed, seat.id, { type: 'semutaPass', event });
|
||||
}
|
||||
resumed = reloadBattleKarama(resumed);
|
||||
assert.equal(resumed.pendingTreacheryDiscard, null);
|
||||
assert.equal(resumed.pendingCapture == null, true);
|
||||
assert.equal(resumed.pendingAuditor == null, true);
|
||||
assert.equal(resumed.auditorInsight == null, true);
|
||||
assert.deepEqual(battleQuantities(resumed), battleQuantities(nativeCanceled));
|
||||
assert.equal(battleKaramaPlayer(resumed, fixture.holder).hand.filter(c => c.id === fixture.karama).length, Number(claim));
|
||||
assert.equal(resumed.discard.filter(c => c.id === fixture.karama).length, Number(!claim));
|
||||
assert.equal(resumed.discard.filter(c => c.id === fixture.semuta).length, Number(claim));
|
||||
assert.equal(battleKaramaPlayer(resumed, 'a').hand.some(c => c.id === fixture.privateCard), true);
|
||||
assert.deepEqual(battleKaramaInventory(resumed), fixture.inventory);
|
||||
assert.throws(() => applyAction(resumed, fixture.holder, { type: 'semutaCommit', event }));
|
||||
assert.throws(() => applyAction(resumed, fixture.spender, fixture.cancel));
|
||||
if (kind === 'choamAudit') {
|
||||
assert.equal(resumed.pendingChoamBattleIncome, null);
|
||||
assert.equal(resumed.decision?.kind, 'choamMarket');
|
||||
assert.throws(() => applyAction(resumed, 'e', { type: 'decision',
|
||||
event: original.pendingAuditor!.event, audit: true }));
|
||||
} else if (kind === 'choamBattleIncome') {
|
||||
assert.equal(resumed.pendingChoamBattleIncome, null);
|
||||
assert.equal(resumed.decision?.kind, 'choamMarket');
|
||||
} else {
|
||||
assert.equal(resumed.phase, 7);
|
||||
assert.equal(resumed.players.some(p => p.leaders.some(l => l.capturedBy || l.concealed)), false);
|
||||
assert.throws(() => applyAction(resumed, 'e', { type: 'decision', mode: 'execute' }));
|
||||
}
|
||||
|
||||
const finished = finishBattleKarama(resumed);
|
||||
assert.equal(finished.turn, 2);
|
||||
assert.equal(finished.pendingChoamBattleIncome == null, true);
|
||||
assert.equal(finished.auditorInsight == null, true);
|
||||
assert.equal(battleKaramaPlayer(finished, 'a').spice, kind === 'choamBattleIncome' ? 12 : 10);
|
||||
assert.equal(battleKaramaPlayer(finished, 'e').spice, kind === 'capture' ? 15 : kind === 'choamAudit' ? 11 : 10);
|
||||
assert.equal(battleKaramaPlayer(finished, 'r').spice, 10);
|
||||
assert.deepEqual(battleKaramaPlayer(finished, fixture.winner).forces, { 'arrakeen:10': 3 });
|
||||
assert.equal(battleKaramaPlayer(finished, fixture.winner).tanks, 2);
|
||||
assert.equal(battleKaramaPlayer(finished, fixture.loser).tanks, 5);
|
||||
assert.deepEqual(finished.players.map(p => p.leaders), original.players.map(p => p.leaders));
|
||||
assert.deepEqual(battleKaramaInventory(finished), fixture.inventory);
|
||||
for (const seat of finished.players)
|
||||
assert.equal(viewGame(finished, seat.id).auditorInsight == null, true);
|
||||
assert.throws(() => applyAction(finished, fixture.holder, { type: 'semutaCommit', event }));
|
||||
assert.throws(() => applyAction(finished, 'e', { type: 'decision', done: true }));
|
||||
if (claim) assert.throws(() => applyAction(finished, fixture.holder, fixture.cancel),
|
||||
'acquiring the printed cost cannot reopen its original canceled effect');
|
||||
});
|
||||
|
||||
for (const kind of kinds) void test(`all four AI profiles legally participate in the native ${kind} cost receipt`, () => {
|
||||
const fixture = semutaBattleKaramaFixture(kind);
|
||||
const pending = applyAction(fixture.originalResponse, fixture.spender, fixture.cancel);
|
||||
const native = reloadBattleKarama(fixture.originalResponse);
|
||||
delete native.semutaPreview;
|
||||
const nativeCanceled = applyAction(native, fixture.spender, fixture.cancel);
|
||||
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'karamaBattleDiscard');
|
||||
for (const profile of DIFFICULTIES) for (const id of ['a', 'r', 'e']) {
|
||||
const game = reloadBattleKarama(pending);
|
||||
battleKaramaPlayer(game, id).bot = profile;
|
||||
const action = botActions(viewGame(game, id))[0];
|
||||
assert.ok(action, `${profile} ${id} must have a neutral reaction action`);
|
||||
assert.ok(['semutaPass', 'semutaCommit'].includes(action.type));
|
||||
const after = applyAction(game, id, action);
|
||||
assert.deepEqual(battleKaramaInventory(after), fixture.inventory);
|
||||
assert.deepEqual(battleQuantities(after), battleQuantities(after.pendingTreacheryDiscard ? fixture.originalResponse : nativeCanceled));
|
||||
}
|
||||
});
|
||||
@@ -189,9 +189,14 @@ void test('Semuta preserves quoted CHOAM battle income through original allowanc
|
||||
assert.deepEqual(resumed.pendingChoamBattleIncome, state.pendingChoamBattleIncome);
|
||||
assert.equal(player(resumed, 'e').spice, beforeIncome);
|
||||
for (const cancel of [false, true]) {
|
||||
const settled = cancel
|
||||
let settled = cancel
|
||||
? applyAction(reload(resumed), 'r', { type: 'card', mode: 'cancel', card: karama.id })
|
||||
: applyAction(reload(resumed), 'r', { type: 'passResponse' });
|
||||
if (cancel) {
|
||||
assert.deepEqual(settled.pendingTreacheryDiscard!.batch.entries.map(entry => entry.card.id), [karama.id]);
|
||||
assert.deepEqual(settled.pendingChoamBattleIncome, state.pendingChoamBattleIncome);
|
||||
settled = pass(settled);
|
||||
}
|
||||
assert.equal(settled.pendingChoamBattleIncome, null);
|
||||
assert.equal(player(settled, 'e').spice, beforeIncome + Number(!cancel));
|
||||
assert.equal(player(settled, 'a').spice, paidBalance);
|
||||
|
||||
@@ -112,9 +112,13 @@ for (const holder of ['w', 'l'] as const) for (const cancel of [false, true]) {
|
||||
assert.equal(viewGame(response, 'w').semutaReaction, null);
|
||||
assert.equal(viewGame(response, 'w').moritaniRetentionCard?.id, f.weapon);
|
||||
rejected(response, 'l', { type: 'card', card: f.weapon, mode: 'discard' });
|
||||
const settled = cancel
|
||||
let settled = cancel
|
||||
? applyAction(reload(response), holder, { type: 'card', card: f.karama!, mode: 'cancel' })
|
||||
: allowSemutaMoritaniResponse(reload(response));
|
||||
if (cancel) {
|
||||
assert.deepEqual(settled.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), [f.karama]);
|
||||
settled = passSemutaMoritaniDiscard(settled);
|
||||
}
|
||||
assert.equal(settled.pendingTreacheryDiscard?.batch.cause, 'battle:moritani');
|
||||
assert.equal(settled.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.deepEqual(settled.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), cancel ? [f.weapon, f.defense] : [f.defense]);
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { semutaIxPlayer, semutaIxInventory, semutaIxPhysical } from './fixture-semuta-ix-substitution';
|
||||
import { semutaMoritaniPlayer, semutaMoritaniInventory, semutaMoritaniResources } from './fixture-semuta-moritani-retention';
|
||||
import {
|
||||
semutaNativeIxKaramaFixture, semutaNativeMoritaniKaramaFixture,
|
||||
semutaNativeKaramaPreCost, semutaNativeKaramaCost, semutaNativeKaramaCostStep,
|
||||
semutaNativeKaramaReact, semutaNativeKaramaRemainder,
|
||||
semutaNativeKaramaReload as reload, type SemutaNativeKaramaFixture,
|
||||
} from './fixture-semuta-native-karama';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function assertCostPause(game: Game, before: Game, f: SemutaNativeKaramaFixture) {
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
|
||||
assert.equal(pending.reaction?.stage, 'offer');
|
||||
assert.equal(pending.continuation.kind, 'karamaBattleDiscard');
|
||||
assert.equal(pending.batch.cause, `karama:${f.kind}`);
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ id: entry.card.id, player: entry.discardedBy, publicFace: entry.publicFace })),
|
||||
[{ id: f.karama, player: f.spender, publicFace: true }]);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.equal(game.players.find(p => p.id === f.spender)!.hand.some(card => card.id === f.karama), false);
|
||||
assert.equal(game.response, null, 'The old response is detached, not still open to another cancel or allow.');
|
||||
if (pending.continuation.kind !== 'karamaBattleDiscard')
|
||||
throw Error('The printed cost needs its exact typed cancellation continuation.');
|
||||
const continuation = pending.continuation;
|
||||
assert.equal(continuation.player, f.spender);
|
||||
assert.equal(continuation.card, f.karama);
|
||||
assert.equal(continuation.use.kind, 'cancel');
|
||||
assert.equal(continuation.use.response.kind, f.kind);
|
||||
assert.equal(continuation.use.response.owner, f.owner);
|
||||
assert.notEqual(f.owner, f.spender);
|
||||
assert.equal(game.phase, 6);
|
||||
const step = semutaNativeKaramaCostStep(f);
|
||||
rejected(game, step.actor, step.action);
|
||||
rejected(game, f.spender, { type: 'passResponse' });
|
||||
}
|
||||
function assertIxCounters(game: Game, f: SemutaNativeKaramaFixture) {
|
||||
assert.ok(f.ix);
|
||||
const ix = semutaIxPlayer(game, f.ix.winner), emperor = semutaIxPlayer(game, f.ix.loser);
|
||||
assert.deepEqual(ix.forces, { [f.ix.location]: 2 });
|
||||
assert.equal(ix.elites!.forces[f.ix.location] ?? 0, 0);
|
||||
assert.equal(ix.reserves, 17); assert.equal(ix.elites!.reserves, 6);
|
||||
assert.equal(ix.tanks, 1); assert.equal(ix.elites!.tanks, 1); assert.equal(ix.battleLosses, 1);
|
||||
assert.deepEqual(emperor.forces, {}); assert.equal(emperor.reserves, 15);
|
||||
assert.equal(emperor.tanks, 5); assert.equal(emperor.battleLosses, 5);
|
||||
const richese = semutaIxPlayer(game, f.holder);
|
||||
assert.deepEqual(richese.forces, {}); assert.equal(richese.reserves, 20); assert.equal(richese.tanks, 0);
|
||||
assert.equal(game.lastBattleContext!.winner, f.ix.winner);
|
||||
assert.equal(game.lastBattleContext!.result, 'normal');
|
||||
assert.deepEqual(semutaIxInventory(game), f.cards);
|
||||
}
|
||||
function assertMoritaniCounters(game: Game, f: SemutaNativeKaramaFixture) {
|
||||
assert.ok(f.moritani);
|
||||
const winner = semutaMoritaniPlayer(game, 'w'), loser = semutaMoritaniPlayer(game, 'l');
|
||||
assert.deepEqual(winner.forces, { 'arrakeen:10': 3 }); assert.equal(winner.reserves, 15);
|
||||
assert.equal(winner.tanks, 2); assert.equal(winner.battleLosses, 2);
|
||||
assert.deepEqual(loser.forces, {}); assert.equal(loser.reserves, 15);
|
||||
assert.equal(loser.tanks, 5); assert.equal(loser.battleLosses, 5);
|
||||
for (const id of ['r', 'm']) {
|
||||
const player = semutaMoritaniPlayer(game, id);
|
||||
assert.deepEqual(player.forces, {}); assert.equal(player.reserves, 20); assert.equal(player.tanks, 0);
|
||||
}
|
||||
assert.equal(winner.spice, semutaMoritaniPlayer(f.game, 'w').spice + f.moritani.loserStrength);
|
||||
for (const id of ['r', 'm', 'l']) assert.equal(semutaMoritaniPlayer(game, id).spice, semutaMoritaniPlayer(f.game, id).spice);
|
||||
assert.equal(loser.leaders.find(leader => leader.id === f.moritani!.loserLeader)!.dead, true);
|
||||
assert.equal(loser.hand.some(card => card.id === f.moritani!.unrelated), true);
|
||||
assert.equal(winner.hand.some(card => card.id === f.moritani!.winnerWeapon), true);
|
||||
assert.equal(winner.hand.some(card => card.id === f.moritani!.winnerDefense), true);
|
||||
assert.deepEqual(semutaMoritaniInventory(game), f.cards);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const claim of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Ix printed cancellation cost ${claim ? 'claim' : 'pass'} cannot restore the declared 1/1 substitution`, () => {
|
||||
const f = semutaNativeIxKaramaFixture(advanced); assert.ok(f.ix);
|
||||
assert.notEqual(f.holder, f.spender);
|
||||
const before = reload(semutaNativeKaramaPreCost(f));
|
||||
assert.equal(before.response?.kind, 'ixSubstitution'); assert.equal(before.response?.owner, f.ix.winner);
|
||||
assert.deepEqual(before.pendingIxSubstitution!.sources, { [f.ix.location]: 1 });
|
||||
assert.deepEqual(before.pendingIxSubstitution!.recover, { [f.ix.location]: 1 });
|
||||
const pending = reload(semutaNativeKaramaCost(before, f));
|
||||
assertCostPause(pending, before, f); assertIxCounters(pending, f);
|
||||
assert.deepEqual(pending.pendingIxSubstitution, before.pendingIxSubstitution);
|
||||
assert.deepEqual(pending.pendingWinnerDiscards, before.pendingWinnerDiscards);
|
||||
assert.equal(pending.discard.some(card => card.id === f.ix!.tooth), false);
|
||||
assert.equal(semutaIxPlayer(pending, f.owner).hand.some(card => card.id === f.ix!.tooth), true);
|
||||
const declaration: Action = { type: 'decision', sources: { [f.ix.location]: 1 }, recover: { [f.ix.location]: 1 } };
|
||||
rejected(pending, f.owner, declaration);
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
let game = reload(semutaNativeKaramaReact(reload(pending), f, claim));
|
||||
assert.equal(game.pendingIxSubstitution, null); assert.equal(game.pendingWinnerDiscards, null);
|
||||
assertIxCounters(game, f);
|
||||
assert.deepEqual(semutaIxPhysical(game), semutaIxPhysical(before));
|
||||
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'winnerMandatoryDiscard');
|
||||
assert.deepEqual(game.pendingTreacheryDiscard!.batch.entries.map(entry => entry.card.id), [f.ix.tooth]);
|
||||
assert.equal(game.discard.filter(card => card.id === f.ix!.tooth).length, 1);
|
||||
assert.notEqual(game.pendingTreacheryDiscard!.batch.event, event);
|
||||
if (claim) rejected(game, f.holder, { type: 'semutaCommit', event: game.pendingTreacheryDiscard!.batch.event });
|
||||
rejected(game, f.holder, { type: 'semutaCommit', event });
|
||||
rejected(game, f.holder, { type: 'semutaPass', event });
|
||||
rejected(game, f.owner, declaration);
|
||||
game = semutaNativeKaramaRemainder(reload(game), f);
|
||||
assertIxCounters(game, f);
|
||||
assert.deepEqual(semutaIxPhysical(game), semutaIxPhysical(before));
|
||||
assert.equal(game.discard.filter(card => card.id === f.ix!.snooper).length, 1);
|
||||
assert.equal(game.discard.filter(card => card.id === f.ix!.tooth).length, 1);
|
||||
assert.equal(game.discard.filter(card => card.id === f.ix!.shield).length, 1);
|
||||
assert.equal(semutaIxPlayer(game, f.owner).hand.length, 0);
|
||||
assert.equal(semutaIxPlayer(game, f.holder).hand.some(card => card.id === f.karama), claim);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, Number(!claim));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claim));
|
||||
assert.equal(game.lastBattleContext!.winnerDiscards!.completed, true);
|
||||
rejected(game, f.owner, declaration);
|
||||
const cost = semutaNativeKaramaCostStep(f); rejected(game, cost.actor, cost.action);
|
||||
rejected(game, f.holder, { type: 'card', card: f.karama, mode: 'cancel' });
|
||||
rejected(game, f.holder, { type: 'semutaCommit', event });
|
||||
});
|
||||
}
|
||||
|
||||
for (const spender of ['w', 'l'] as const) for (const holder of ['r', 'l'] as const)
|
||||
if (spender !== holder) for (const claim of [false, true]) {
|
||||
void test(`Basic allied retention canceled by ${spender}, cost ${claim ? 'claimed' : 'passed'} by ${holder}, never keeps the proposal or adds the cost to its played pool`, () => {
|
||||
const f = semutaNativeMoritaniKaramaFixture({ karamaHolder: spender, semutaHolder: holder }); assert.ok(f.moritani);
|
||||
const before = reload(semutaNativeKaramaPreCost(f));
|
||||
assert.equal(before.response?.kind, 'moritaniRetention'); assert.equal(before.response?.owner, 'm');
|
||||
assert.equal(before.moritaniRetention?.source, undefined); assert.equal(before.moritaniRetention?.keep, f.moritani.weapon);
|
||||
assert.deepEqual(before.moritaniRetention!.played, [f.moritani.weapon, f.moritani.defense]);
|
||||
const pending = reload(semutaNativeKaramaCost(before, f));
|
||||
assertCostPause(pending, before, f); assertMoritaniCounters(pending, f);
|
||||
assert.deepEqual(pending.moritaniRetention, before.moritaniRetention);
|
||||
for (const card of [f.moritani.weapon, f.moritani.defense]) {
|
||||
assert.equal(semutaMoritaniPlayer(pending, 'l').hand.some(held => held.id === card), true);
|
||||
assert.equal(pending.discard.some(held => held.id === card), false);
|
||||
}
|
||||
rejected(pending, 'l', { type: 'decision', keep: f.moritani.weapon });
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
let game = reload(semutaNativeKaramaReact(reload(pending), f, claim));
|
||||
assert.equal(game.moritaniRetention, null); assert.equal(game.response, null);
|
||||
assert.deepEqual(semutaMoritaniResources(game), semutaMoritaniResources(before));
|
||||
const later = game.pendingTreacheryDiscard; assert.ok(later);
|
||||
assert.equal(later.continuation.kind, 'battleCleanup');
|
||||
assert.equal(later.batch.cause, 'battle:moritani'); assert.notEqual(later.batch.event, event);
|
||||
assert.deepEqual(later.batch.entries.map(entry => entry.card.id), [f.moritani.weapon, f.moritani.defense]);
|
||||
if (later.continuation.kind !== 'battleCleanup') throw Error('Expected native Moritani cleanup.');
|
||||
assert.deepEqual(later.continuation.played, [f.moritani.weapon, f.moritani.defense]);
|
||||
assert.deepEqual(later.continuation.kept, []);
|
||||
assert.equal(later.batch.entries.some(entry => entry.card.id === f.karama), false);
|
||||
for (const card of [f.moritani.weapon, f.moritani.defense]) {
|
||||
assert.equal(semutaMoritaniPlayer(game, 'l').hand.some(held => held.id === card), false);
|
||||
assert.equal(game.discard.filter(held => held.id === card).length, 1);
|
||||
}
|
||||
if (claim) rejected(game, holder, { type: 'semutaCommit', event: later.batch.event });
|
||||
rejected(game, holder, { type: 'semutaCommit', event });
|
||||
rejected(game, holder, { type: 'semutaPass', event });
|
||||
rejected(game, 'l', { type: 'decision', keep: f.moritani.weapon });
|
||||
game = semutaNativeKaramaRemainder(reload(game), f);
|
||||
assertMoritaniCounters(game, f);
|
||||
assert.deepEqual(semutaMoritaniResources(game), semutaMoritaniResources(before));
|
||||
assert.equal(game.moritaniRetention, null); assert.equal(game.battle, null); assert.equal(game.decision, null);
|
||||
assert.equal(semutaMoritaniPlayer(game, holder).hand.some(card => card.id === f.karama), claim);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, Number(!claim));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claim));
|
||||
for (const card of [f.moritani.weapon, f.moritani.defense]) {
|
||||
assert.equal(game.discard.filter(held => held.id === card).length, 1);
|
||||
assert.equal(semutaMoritaniPlayer(game, 'l').hand.some(held => held.id === card), false);
|
||||
}
|
||||
rejected(game, holder, { type: 'card', card: f.karama, mode: 'cancel' });
|
||||
const cost = semutaNativeKaramaCostStep(f); rejected(game, cost.actor, cost.action);
|
||||
rejected(game, 'l', { type: 'decision', keep: f.moritani.weapon });
|
||||
rejected(game, holder, { type: 'semutaCommit', event });
|
||||
});
|
||||
}
|
||||
|
||||
void test('All four profiles legally retire native printed costs and their original cancellation aftermath', () => {
|
||||
for (const f of [semutaNativeIxKaramaFixture(), semutaNativeIxKaramaFixture(true), semutaNativeMoritaniKaramaFixture({ semutaHolder: 'l' })]) {
|
||||
const before = reload(semutaNativeKaramaPreCost(f)), pending = reload(semutaNativeKaramaCost(before, f));
|
||||
for (const profile of DIFFICULTIES) {
|
||||
let game = reload(pending);
|
||||
for (const player of game.players) player.bot = profile;
|
||||
for (let count = 0; game.phase === 6 && count < 64; count++) {
|
||||
let next: Game | undefined;
|
||||
for (const player of game.players) {
|
||||
const action = botActions(viewGame(game, player.id))[0];
|
||||
if (action) { next = applyAction(reload(game), player.id, action); break; }
|
||||
}
|
||||
assert.ok(next, `${profile} must provide a legal original continuation.`);
|
||||
game = next;
|
||||
}
|
||||
assert.equal(game.phase, 7); assert.equal(game.pendingTreacheryDiscard, null);
|
||||
assert.equal(game.response, null); assert.equal(game.pendingIxSubstitution == null, true);
|
||||
assert.equal(game.moritaniRetention == null, true);
|
||||
if (f.kind === 'ixSubstitution') {
|
||||
assertIxCounters(game, f); assert.deepEqual(semutaIxPhysical(game), semutaIxPhysical(before));
|
||||
} else {
|
||||
assertMoritaniCounters(game, f); assert.deepEqual(semutaMoritaniResources(game), semutaMoritaniResources(before));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user