Pause printed CHOAM power cancellation costs for Semuta
This commit is contained in:
1 parent
bb6835e6c3
commit
f68c556f8a
8 files changed
+244
-10
No files matched your search
@@ -1174,6 +1174,9 @@ export function GameTable({
|
||||
Betrayal and the five ordinary Cunning fuel discards can react before
|
||||
their original effects or receipts finish. Cunning fuel keeps its
|
||||
printed identity and private face; Kull retains its separate guard.
|
||||
A printed Karama canceling those native powers also reacts before the
|
||||
original cancellation: CHOAM retains its fuel, and any played Nexus
|
||||
remains spent. Taking Karama cannot restore the denied effect.
|
||||
Homeworlds, Discovery and other overlays stay guarded.
|
||||
Native Moritani
|
||||
support is Basic only: clean shipment/movement Sabotage and Robbery
|
||||
|
||||
@@ -29,6 +29,26 @@ 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 CHOAM cancellation cost — 11 October.**
|
||||
`karamaChoamWorthlessDiscard` reuses the original response-cost path and
|
||||
Worthless cancellation quote for five ordinary printed/Cunning powers.
|
||||
Public printed Karama now pauses before native denial; CHOAM fuel stays held
|
||||
and Cunning's Nexus stays spent. Claim/pass restores the saved response,
|
||||
cancels once and resumes the same native movement/revival/storm/Mentat suffix.
|
||||
Actual before-cutover Kulon canceled without a cost offer; after-cutover its
|
||||
pending native receipt survives all views and a real claim leaves range denied.
|
||||
Types/lint and179 affected unit cases across10 files pass. Thirty native
|
||||
reaction cases plus one actual Basic case cover five families, claim/pass,
|
||||
full hands and four legal profiles. Styled390px Gamont Cancel→Commit preserves
|
||||
the Emperor's elite at Arrakeen/reserves4 and CHOAM's Hajr; only original
|
||||
Finish assigns Emperor Arrakeen at claim2/turn3. Jubba Cancel→Commit keeps
|
||||
Maula Pistol; original Continue loses6 CHOAM/4 opposing counters and both
|
||||
covered spice piles, reaching native market once. Acquired Karama inspector
|
||||
and current warning were inspected. All own probes/service/tab removed.
|
||||
Existing games/stores/PDF, deployment, Kull, unsupported parents and public
|
||||
gates unchanged; no assurance campaign. See
|
||||
[native cost contract](NEXUS_CHOAM_RUNTIME.md#printed-native-cancellation-cost--11-october-2026).
|
||||
|
||||
**Semuta / original CHOAM Cunning fuel — 11 October.** The five ordinary
|
||||
Cunning effects now reuse original Worthless continuations with a completed
|
||||
native event binding owner/card/effect/targets and parent flags. Canonical
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 11 October 2026 — printed CHOAM-power cancellation cost through Semuta
|
||||
|
||||
Actual native Cunning Kulon canceled immediately while its printed Karama
|
||||
entered discard without a neutral opportunity. New
|
||||
`karamaChoamWorthlessDiscard` extends the existing response-cost union/factory,
|
||||
bounded admission, validator, state signature and original retirement path.
|
||||
Original Worthless cancellation quote remains authoritative; native printed
|
||||
and Cunning Kulon/Gamont/Baliset/La La La/Jubba reuse the same exact declaration
|
||||
and ordinary reactive parent. Kull and unrelated arrival/flight sources stay
|
||||
excluded. Old unrelated signatures omit the new optional source facts.
|
||||
|
||||
The public printed cancellation cost is committed first. Native CHOAM's
|
||||
declared fuel remains held, original Cunning history remains pending and its
|
||||
Nexus remains spent. Semuta claim/pass restores the actual saved response,
|
||||
cancels once and resumes one original movement/free-revival/storm/Mentat suffix.
|
||||
Taking Karama cannot claim the retained fuel or restore the denied effect.
|
||||
New signature facts bind declaration, source receipt and affected counters;
|
||||
unrelated leader serialization is not added to this nonbattle source.
|
||||
|
||||
Eight consumer cases reuse genuine original five-effect fixtures, checking
|
||||
claim/pass/full hands, retained printed/Nexus fuel, one source spend and the
|
||||
native outcome against original cancellation without Semuta. No whole-save,
|
||||
private-paired-state or recovery campaign. Types/lint and179 affected unit
|
||||
cases across10 files pass. Thirty actual native reaction cases plus one
|
||||
Basic Kulon claim cover all five families and four legal profiles; all cost
|
||||
views project while the original receipt remains pending.
|
||||
|
||||
Styled390px actual controls prove Gamont declaration→Cancel→Commit without
|
||||
returning the Emperor elite: it remains at Arrakeen/reserves4, CHOAM retains
|
||||
Hajr, Richese4/4 acquires Karama. Original Finish, not acquisition, assigns
|
||||
Arrakeen to Emperor at claim2/turn3. Jubba cancellation retains Maula Pistol
|
||||
through claim; original Continue loses all6 CHOAM/4 opposing counters and
|
||||
both crossed spice piles, reaching original market once. Actual acquired
|
||||
Karama inspector and updated warning were inspected; settled frames390px.
|
||||
|
||||
All temporary scripts/client/server, own service and tab removed. Existing
|
||||
games/stores, user PDF, provider/deployment and public gates untouched.
|
||||
No new module admission or source ruling; comprehensive assurance remains due.
|
||||
|
||||
## 11 October 2026 — five original Cunning fuel discards through Semuta
|
||||
|
||||
Fresh native Nexus admission made five previously excluded `pending.nexusEvent`
|
||||
|
||||
@@ -44,6 +44,37 @@ Own probes/service/tab are removed; existing games/stores/PDF and deployment
|
||||
are untouched. Kull, unrelated parents/modules and complete-mode/public gates
|
||||
remain separate. No comprehensive custody/privacy/recovery campaign is claimed.
|
||||
|
||||
### Printed native cancellation cost — 11 October 2026
|
||||
|
||||
A distinct printed Karama canceling any of those five native printed or Cunning
|
||||
powers now offers its own exact public discard before cancellation completes.
|
||||
`karamaChoamWorthlessDiscard` preserves the original response and live
|
||||
declaration/ordinary movement, free-revival, storm or closing-Mentat parent.
|
||||
The original cancellation quote remains authoritative; state signature fields
|
||||
bind the current declaration, source receipt and affected physical counters.
|
||||
Old unrelated response-cost signatures are unchanged.
|
||||
|
||||
During the cost offer, CHOAM's declared card remains held and a Cunning receipt
|
||||
remains pending with its Nexus spent. Claim/pass restores the original response,
|
||||
cancels once and resumes the same native suffix. The original fuel is not a
|
||||
Semuta target: acquiring the cancellation Karama cannot grant range, return a
|
||||
force, stop movement/free revival, protect the storm or refund Nexus.
|
||||
Printed source retains its held Nexus as well as its named Worthless.
|
||||
|
||||
Actual before-cutover Kulon canceled immediately without offering its spent
|
||||
Karama. Afterward the cost pauses and all views work. Thirty native runtime
|
||||
reactions cover five families, claim/pass/full hands and four legal profiles;
|
||||
an additional actual Basic Kulon claim retains fuel and leaves range denied.
|
||||
The affected unit union passes179/179 across10 files; types/lint pass.
|
||||
Styled390px Gamont declaration→Cancel→Commit preserves the Emperor's elite
|
||||
at Arrakeen/reserves4 and CHOAM's Hajr; only original Finish assigns Arrakeen
|
||||
to Emperor at claim2/turn3. Jubba cancellation→Commit retains Maula Pistol;
|
||||
original Continue loses all6 CHOAM/4 opposing counters, removes both covered
|
||||
spice piles and reaches native market. Actual acquired Karama inspector and
|
||||
updated preview guidance were inspected. No Kull, unrelated source parent,
|
||||
new module/public gate, saved-game change, deployment or assurance campaign.
|
||||
|
||||
|
||||
## Physical cost and timing
|
||||
|
||||
An unallied CHOAM player holding the CHOAM Nexus chooses one actual held Treachery Card and one supported effect. The card need not have the corresponding Worthless name. The engine preserves its original identity, ownership and any reservations; it does not manufacture a named Worthless card or grant a turn-long conversion ability.
|
||||
|
||||
@@ -137,6 +137,15 @@ Cancellation retains that fuel and leaves the played Nexus spent. Kull and
|
||||
other competing parents remain separate. See
|
||||
[five-effect evidence](NEXUS_CHOAM_RUNTIME.md#original-five-effect-semuta-composition--11-october-2026).
|
||||
|
||||
Printed Karama canceling one of the five ordinary native CHOAM printed or
|
||||
Cunning powers also offers that distinct public cancellation cost before the
|
||||
original denial completes. The declared CHOAM card remains held throughout,
|
||||
and a played Nexus stays spent. Claim/pass restores the saved response,
|
||||
cancels the effect once and completes its original ordinary movement,
|
||||
free-revival, storm or closing-Mentat suffix. Taking Karama neither claims
|
||||
CHOAM's retained fuel nor restores the denied effect.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+44
-10
@@ -1344,7 +1344,7 @@ export type Game = {
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard';
|
||||
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard';
|
||||
player: string;
|
||||
card: string;
|
||||
use: Extract<KaramaUse, { kind: 'cancel' }>;
|
||||
@@ -5151,12 +5151,23 @@ function semutaBattleKaramaResponse(g: Game, response: ResponseWindow): boolean
|
||||
(!g.advanced && response.kind === 'moritaniRetention' &&
|
||||
g.moritaniRetention?.source === undefined);
|
||||
}
|
||||
/** Keep the same ordinary reactive parents admitted by the five printed cost continuations. */
|
||||
function semutaChoamKaramaResponse(g: Game, response: ResponseWindow): boolean {
|
||||
const pending = g.pendingChoamWorthless;
|
||||
if (response.kind !== 'choamWorthless' || !pending || pending.effect === 'kull' ||
|
||||
pending.owner !== response.owner || g.battle || g.ornithopter ||
|
||||
g.pendingArrivalOverlap || g.ecologicalStorm) return false;
|
||||
const movement = g.pendingChoamMove;
|
||||
return !movement || (!movement.source && !movement.ornithopterEvent &&
|
||||
!movement.ornithopterRange && !movement.discoveryFlight);
|
||||
}
|
||||
type KaramaResponseDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' }
|
||||
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' }
|
||||
>;
|
||||
function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) {
|
||||
const battle = c.kind === 'karamaBattleDiscard';
|
||||
const worthless = c.kind === 'karamaChoamWorthlessDiscard';
|
||||
return JSON.stringify({
|
||||
player: c.player, card: c.card, use: c.use,
|
||||
parent: nullentropyParentSignature(g, c.resume,
|
||||
@@ -5175,12 +5186,17 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
|
||||
battleContext: battle ? g.lastBattleContext : undefined,
|
||||
retention: battle ? g.moritaniRetention : undefined,
|
||||
techTokens: battle ? g.techTokens : undefined,
|
||||
choamWorthless: worthless ? {
|
||||
declaration: g.pendingChoamWorthless, movement: g.pendingChoamMove,
|
||||
revival: g.pendingRevival, storm: g.stormResolution,
|
||||
mentat: g.choamMentatPending, nexus: g.nexusChoamHistory?.at(-1),
|
||||
} : 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,
|
||||
physical: battle || worthless ? { forces: p.forces, reserves: p.reserves, tanks: p.tanks,
|
||||
elites: p.elites, leaders: battle ? p.leaders : undefined } : undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -5942,7 +5958,8 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
} else if (continuation?.kind === 'karamaCharityDiscard' ||
|
||||
continuation?.kind === 'karamaInflationDiscard' ||
|
||||
continuation?.kind === 'karamaBgCharityDiscard' ||
|
||||
continuation?.kind === 'karamaBattleDiscard') {
|
||||
continuation?.kind === 'karamaBattleDiscard' ||
|
||||
continuation?.kind === 'karamaChoamWorthlessDiscard') {
|
||||
const c = continuation, entry = batch.entries[0];
|
||||
requireRule(
|
||||
!g.response &&
|
||||
@@ -5958,7 +5975,9 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
g.players.some(p => p.id === c.use.response.owner &&
|
||||
p.faction === 'beneGesserit' && p.charityTurn === g.turn)) ||
|
||||
(c.kind === 'karamaBattleDiscard' && g.phase === 6 && !g.battle &&
|
||||
semutaBattleKaramaResponse(g, c.use.response))) &&
|
||||
semutaBattleKaramaResponse(g, c.use.response)) ||
|
||||
(c.kind === 'karamaChoamWorthlessDiscard' &&
|
||||
semutaChoamKaramaResponse(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) &&
|
||||
@@ -5978,6 +5997,13 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
ixSubstitutionCancellationQuote(g, c.use.response);
|
||||
else validateAftermathCancellation(g, c.use.response);
|
||||
}
|
||||
if (c.kind === 'karamaChoamWorthlessDiscard') {
|
||||
try { quoteChoamWorthlessCancellation(g, c.use.response); }
|
||||
catch (error) {
|
||||
if (error instanceof ChoamWorthlessCancellationError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else if (continuation?.kind === 'karamaPurchaseDiscard' ||
|
||||
continuation?.kind === 'karamaPaymentDiscard') {
|
||||
const c = continuation, entry = batch.entries[0];
|
||||
@@ -6775,6 +6801,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'karamaInflationDiscard' &&
|
||||
continuation.kind !== 'karamaBgCharityDiscard' &&
|
||||
continuation.kind !== 'karamaBattleDiscard' &&
|
||||
continuation.kind !== 'karamaChoamWorthlessDiscard' &&
|
||||
continuation.kind !== 'karamaPurchaseDiscard' &&
|
||||
continuation.kind !== 'karamaPaymentDiscard' &&
|
||||
continuation.kind !== 'thumperDiscard' &&
|
||||
@@ -6854,10 +6881,13 @@ function semutaOfferSupported(
|
||||
(continuation.kind === 'karamaCharityDiscard' ||
|
||||
continuation.kind === 'karamaInflationDiscard' ||
|
||||
continuation.kind === 'karamaBgCharityDiscard' ||
|
||||
continuation.kind === 'karamaBattleDiscard'
|
||||
continuation.kind === 'karamaBattleDiscard' ||
|
||||
continuation.kind === 'karamaChoamWorthlessDiscard'
|
||||
? (continuation.kind === 'karamaBattleDiscard'
|
||||
? semutaBattleKaramaResponse(g, continuation.use.response)
|
||||
: ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) &&
|
||||
: continuation.kind === 'karamaChoamWorthlessDiscard'
|
||||
? semutaChoamKaramaResponse(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))
|
||||
@@ -6879,6 +6909,7 @@ function semutaOfferSupported(
|
||||
continuation.kind === 'karamaInflationDiscard' ||
|
||||
continuation.kind === 'karamaBgCharityDiscard' ||
|
||||
continuation.kind === 'karamaBattleDiscard' ||
|
||||
continuation.kind === 'karamaChoamWorthlessDiscard' ||
|
||||
(!continuation.resume.response && !continuation.resume.decision &&
|
||||
!continuation.resume.pendingKarama &&
|
||||
(continuation.kind === 'amalDiscard'
|
||||
@@ -7143,6 +7174,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
||||
next.kind === 'karamaInflationDiscard' ||
|
||||
next.kind === 'karamaBgCharityDiscard' ||
|
||||
next.kind === 'karamaBattleDiscard' ||
|
||||
next.kind === 'karamaChoamWorthlessDiscard' ||
|
||||
next.kind === 'karamaPurchaseDiscard' ||
|
||||
next.kind === 'karamaPaymentDiscard') {
|
||||
g.response = next.resume.response;
|
||||
@@ -13355,13 +13387,15 @@ 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' || semutaBattleKaramaResponse(g, use.response))) {
|
||||
use.response.kind === 'bgCharity' || semutaBattleKaramaResponse(g, use.response) ||
|
||||
semutaChoamKaramaResponse(g, use.response))) {
|
||||
const continuation: KaramaResponseDiscardContinuation = {
|
||||
kind: use.response.kind === 'choamCharity'
|
||||
? 'karamaCharityDiscard'
|
||||
: use.response.kind === 'choamInflation'
|
||||
? 'karamaInflationDiscard'
|
||||
: use.response.kind === 'bgCharity' ? 'karamaBgCharityDiscard' : 'karamaBattleDiscard',
|
||||
: use.response.kind === 'bgCharity' ? 'karamaBgCharityDiscard'
|
||||
: use.response.kind === 'choamWorthless' ? 'karamaChoamWorthlessDiscard' : 'karamaBattleDiscard',
|
||||
player: p.id, card: used.id, use,
|
||||
resume: { response: g.response, decision: g.decision,
|
||||
pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening },
|
||||
|
||||
@@ -1183,6 +1183,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'The explicit fresh Leader Skills entry composes Semuta with original all-fourteen-card native Basic Richese/CHOAM or Advanced E1/E2 setup, retaining original Tech and Advanced Stronghold prerequisites. Actual offers train original living discs; the native Auditor remains ineligible. A completed Hajr discard can resume the same Movement owner before an original private Mentat question. Mandatory loser disposal can resume trained Suk casualty rescue or Rihani’s normal private inspection and optional skilled exchange before winner cleanup. Winning compulsory Artillery stays held through the original skill, then becomes its own fresh Treachery discard. Traitor/Face Dancer movement is never a Semuta target; no rescue, question, inspection or exchange repeats. Basic Ix mixtures, E3, other module/Banker overlays and public starts stay separate.',
|
||||
'The fresh semuta --nexus-cards entry retains original native E1/E2 Nexus setup, optionally explicit Skills and original Tech/Advanced Strongholds. There is no starting Nexus hand: an actual qualifying closing draw follows a Nexus and settled alliance. CHOAM Secret Ally with CHOAM absent spends a Worthless and its Nexus for two bank spice before the public discard offer; CHOAM Betrayal with CHOAM seated commits one random actual hand-card discard without compensation before its private offer. Claim or Continue retires the original receipt once, without another payment or sample. Separate Nexus cards never become Treachery targets. Homeworlds, Discovery, E3, played-save conversion and public starts stay gated.',
|
||||
'The same original native Nexus entry offers the actual CHOAM Cunning fuel discard for Kulon, Gamont, Baliset, La La La and Jubba. Any eligible held Treachery card keeps its printed identity and private face; there is no fabricated named Worthless. Original cancellation retains fuel and the spent Nexus. After allowance, Commit or Continue resumes the selected effect and parent once: one range bonus, one typed force return and original closing owner, stopped ordinary movement/free revival, or one threatened-territory storm protection. Jubba protects CHOAM forces, not spice; unprotected forces and covered spice still suffer the original storm. Kull retains its separate guard.',
|
||||
'A distinct printed Karama canceling those five ordinary native CHOAM printed or Cunning powers also offers its public cost before the original denial completes. CHOAM retains the declared fuel; a Cunning Nexus remains spent. Claim or Continue restores the same saved response and one original movement, free-revival, storm or closing-Mentat suffix. Acquiring Karama cannot return the canceled force, grant range, stop the original request, protect the storm or restore Nexus. The retained CHOAM fuel is not a discard candidate; Kull and unsupported arrival/flight parents 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.',
|
||||
@@ -1249,6 +1250,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/semuta-nexus-betrayal.test.ts',
|
||||
'tests/semuta-nexus-cunning-proactive.test.ts',
|
||||
'tests/semuta-nexus-cunning-reactive.test.ts',
|
||||
'tests/semuta-choam-karama.test.ts',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
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 {choamPowerAction,choamPowerPlays} from '../game/choam-power-options';
|
||||
import {CHOAM_NEXUS_EFFECTS} from '../game/nexus-choam';
|
||||
import {createSemutaNexusCunningProactive,createSemutaNexusCunningStart,gamePlayer,holdSemutaNexusCunningCard,reactSemutaNexusCunning,semutaNexusCunningInventory,type SemutaNexusCunningFixture} from './fixture-semuta-nexus-cunning';
|
||||
import {createSemutaNexusCunningReactive,quoteSemutaNexusCunningReactive} from './fixture-semuta-nexus-cunning-reactive';
|
||||
|
||||
type Effect='kulon'|'gamont'|'baliset'|'laLaLa'|'jubba';
|
||||
function fixture(effect:Effect,printed=false){
|
||||
const options={advanced:true,fullHand:true,skills:effect==='gamont',tech:effect==='gamont',strongholds:effect==='gamont'};
|
||||
let game:Game, action:Action, source:SemutaNexusCunningFixture, selection:Record<string,unknown>;
|
||||
if(effect==='kulon'||effect==='gamont'){
|
||||
const f=createSemutaNexusCunningProactive(effect,{...options,elite:effect==='gamont'});
|
||||
game=f.game;action=f.action;source=f;selection=f.selection;
|
||||
}else{
|
||||
const f=createSemutaNexusCunningReactive(effect,{start:createSemutaNexusCunningStart(options),karama:true});
|
||||
game=f.game;action=quoteSemutaNexusCunningReactive(game,f);source=f.start;selection=f.selection;
|
||||
}
|
||||
let fuel=source.cost;
|
||||
if(printed){
|
||||
fuel=holdSemutaNexusCunningCard(game,source.owner,c=>c.kind==='worthless'&&c.name===CHOAM_NEXUS_EFFECTS[effect],source.staging);
|
||||
const view=viewGame(game,source.owner);
|
||||
const play=choamPowerPlays(view,effect).find(p=>p.source==='printed'&&p.card.id===fuel.id);
|
||||
assert.ok(play);
|
||||
const selected=choamPowerAction(view,play,selection);assert.ok(selected);action=selected;
|
||||
}
|
||||
const karama=gamePlayer(game,source.other).hand.find(c=>c.effect==='karama');assert.ok(karama);
|
||||
return {game,action,source,fuel,karama:karama.id,cancel:{type:'card',mode:'cancel',card:karama.id},printed};
|
||||
}
|
||||
function originalOutcome(game:Game){
|
||||
return {
|
||||
phase:game.phase,turn:game.turn,active:game.active,ready:game.ready,
|
||||
decision:game.decision,response:game.response,pendingMove:game.pendingChoamMove,pendingRevival:game.pendingRevival,
|
||||
storm:game.storm,stormResolution:game.stormResolution,blocked:game.choamWorthlessBlocked,
|
||||
range:game.choamMovement,restrictions:game.choamBaliset,revivalRules:game.revivalRules,mentat:game.choamMentatPending,
|
||||
players:game.players.map(p=>({id:p.id,spice:p.spice,forces:p.forces,reserves:p.reserves,tanks:p.tanks,elites:p.elites,moved:p.moved,revived:p.revived,free:p.freeForcesRevived})),
|
||||
};
|
||||
}
|
||||
for(const effect of ['kulon','gamont','baliset','laLaLa','jubba'] as const)void test(`Semuta claims or passes printed Karama before original Cunning ${effect} cancellation and one native suffix`,()=>{
|
||||
const f=fixture(effect), beforeWallets=f.game.players.map(p=>p.spice), cards=semutaNexusCunningInventory(f.game);
|
||||
const declared=applyAction(f.game,f.source.owner,f.action);
|
||||
const ordinary=structuredClone(declared);delete ordinary.semutaPreview;
|
||||
const baseline=applyAction(ordinary,f.source.other,f.cancel);
|
||||
const pending=applyAction(declared,f.source.other,f.cancel);
|
||||
const entry=pending.pendingTreacheryDiscard!.batch.entries[0];
|
||||
assert.equal(entry.card.id,f.karama);assert.equal(entry.card.effect,'karama');assert.equal(entry.publicFace,true);assert.equal(entry.discardedBy,f.source.other);
|
||||
assert.equal(pending.nexusChoamHistory!.at(-1)!.stage,'pending');
|
||||
assert.ok(gamePlayer(pending,f.source.owner).hand.some(c=>c.id===f.fuel.id));
|
||||
assert.ok(!pending.discard.some(c=>c.id===f.fuel.id));
|
||||
assert.equal(viewGame(pending,f.source.holder).semutaReaction!.canCommit,true);
|
||||
assert.throws(()=>applyAction(pending,f.source.owner,{type:'decision',decline:true}));
|
||||
for(const claim of [false,true]){
|
||||
const done=reactSemutaNexusCunning(pending,f.source,claim);
|
||||
assert.deepEqual(originalOutcome(done),originalOutcome(baseline));
|
||||
assert.equal(done.nexusChoamHistory!.at(-1)!.stage,'canceled');
|
||||
assert.equal(done.nexusCards!.cards!.hands[f.source.owner],null);
|
||||
assert.equal(done.nexusCards!.cards!.discard.filter(c=>c==='choam').length,1);
|
||||
assert.ok(gamePlayer(done,f.source.owner).hand.some(c=>c.id===f.fuel.id));
|
||||
assert.equal(gamePlayer(done,f.source.holder).hand.length,4);
|
||||
assert.equal(gamePlayer(done,f.source.holder).hand.some(c=>c.id===f.karama),claim);
|
||||
assert.equal(gamePlayer(done,f.source.holder).hand.some(c=>c.id===f.source.semuta),!claim);
|
||||
assert.equal(done.discard.filter(c=>c.id===f.karama).length,Number(!claim));
|
||||
assert.deepEqual(semutaNexusCunningInventory(done),cards);
|
||||
assert.deepEqual(done.players.map(p=>p.spice),baseline.players.map(p=>p.spice));
|
||||
assert.throws(()=>applyAction(done,f.source.other,f.cancel));
|
||||
assert.throws(()=>applyAction(done,f.source.holder,{type:'semutaCommit',event:pending.pendingTreacheryDiscard!.batch.event}));
|
||||
}
|
||||
assert.deepEqual(pending.players.map(p=>p.spice),beforeWallets,'Only the printed cost is committed before the native suffix');
|
||||
});
|
||||
for(const effect of ['kulon','jubba'] as const)void test(`Printed ${effect} retains its named fuel and held Nexus while cancellation Karama reacts`,()=>{
|
||||
const f=fixture(effect,true), declared=applyAction(f.game,f.source.owner,f.action);
|
||||
const ordinary=structuredClone(declared);delete ordinary.semutaPreview;
|
||||
const baseline=applyAction(ordinary,f.source.other,f.cancel), pending=applyAction(declared,f.source.other,f.cancel);
|
||||
const done=reactSemutaNexusCunning(pending,f.source,true);
|
||||
assert.deepEqual(originalOutcome(done),originalOutcome(baseline));
|
||||
assert.equal(done.nexusCards!.cards!.hands[f.source.owner],'choam');
|
||||
assert.ok(gamePlayer(done,f.source.owner).hand.some(c=>c.id===f.fuel.id));
|
||||
assert.ok(gamePlayer(done,f.source.holder).hand.some(c=>c.id===f.karama));
|
||||
assert.ok(!done.discard.some(c=>c.id===f.fuel.id));
|
||||
assert.deepEqual(semutaNexusCunningInventory(done),semutaNexusCunningInventory(f.game));
|
||||
});
|
||||
void test('All four legal Semuta profiles finish the printed native cancellation cost without claiming retained Cunning fuel',()=>{
|
||||
const f=fixture('baliset'), pending=applyAction(applyAction(f.game,f.source.owner,f.action),f.source.other,f.cancel);
|
||||
for(const difficulty of ['Easy','Medium','Hard','Brutal'] as const){
|
||||
const own=viewGame(pending,f.source.holder);own.players.find(p=>p.id===f.source.holder)!.bot=difficulty;
|
||||
const action=botActions(own)[0];assert.equal(action.type,'semutaCommit');
|
||||
const done=applyAction(pending,f.source.holder,action);
|
||||
assert.ok(gamePlayer(done,f.source.holder).hand.some(c=>c.id===f.karama));
|
||||
assert.ok(gamePlayer(done,f.source.owner).hand.some(c=>c.id===f.fuel.id));
|
||||
assert.equal(done.nexusChoamHistory!.at(-1)!.stage,'canceled');
|
||||
assert.equal(gamePlayer(done,f.source.target).moved,1);
|
||||
assert.deepEqual(semutaNexusCunningInventory(done),semutaNexusCunningInventory(f.game));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user