Enable Kull during Basic battle preparation
Verify and publish checkpoint container / container (push) Failing after 7m45s
Verify and publish checkpoint container / container (push) Failing after 7m45s
This commit is contained in:
1 parent
6bf165a6aa
commit
91ce096818
8 files changed
+371
-8
No files matched your search
@@ -57,6 +57,35 @@ reaches an actual worm Nexus but does not claim a Kull interruption there.
|
||||
|
||||
Source audit: 2026-09-06; opt-in preview contract updated 30 September 2026. This is an engineering and product-policy record, not a rules-completion claim. The source audit in [CHOAM_REMAINING_RULES.md](CHOAM_REMAINING_RULES.md) remains applicable. The user-approved counter and BG choices supersede the original proposal; publisher-source uncertainty and full expansion gates remain.
|
||||
|
||||
## Basic Kull and Semuta during battle preparation — 28 October 2026
|
||||
|
||||
**Prototyped / Partial.** In the supported native Basic CHOAM/Richese printed
|
||||
Kull and Semuta preview, CHOAM may use a held Kull against a printed Karama
|
||||
attempt to cancel a declared Voice or Prescience. This covers a clean original
|
||||
battle before plans are sealed, with no live Truthtrance promise or competing
|
||||
parent. It retains the actual battle event, combatants, preparation, submitted
|
||||
readiness and the existing response ownership; no leader, plan or result is
|
||||
created by the interruption.
|
||||
|
||||
If CHOAM declines, the original Karama continues through its native payment
|
||||
and prevention path. If CHOAM declares Kull and the other players allow it,
|
||||
the exact Kull is discarded and offered through the neutral Semuta response;
|
||||
claim or pass leaves the attempted Karama held and the original battle power
|
||||
in place. A different legal Karama may instead counter Kull: its physical
|
||||
discard receives the Semuta opportunity before the original attempt resumes.
|
||||
The original attempt's own eventual discard remains a separate native event.
|
||||
Semuta cannot recover its holder's own discard, and no cost is paid or
|
||||
battle power settled twice.
|
||||
|
||||
The Kull frame binds the original Karama and source response; the new
|
||||
battle-bound continuation also preserves the battle and its original controls
|
||||
while the fresh discard is pending. Older non-battle receipts retain their
|
||||
existing signature and behavior. This priority and the distinct counter are
|
||||
provisional digital ordering, not a publisher ruling for the combination.
|
||||
Advanced, battle promises, sealed plans, optional modules and other nested
|
||||
parents remain outside this particular admission. It does not certify complete
|
||||
rules, full-game or deployed behavior.
|
||||
|
||||
## Basic Richese and Kull/Semuta — 27 October 2026
|
||||
|
||||
**Prototyped / Partial.** The fresh Basic printed-Kull opt-in includes
|
||||
|
||||
+13
-3
@@ -16,6 +16,16 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Kull during Basic battles with Richese — 28 October. Prototyped / Partial.**
|
||||
CHOAM may interrupt a Karama attempting to cancel Voice or Prescience before
|
||||
battle plans. Success, decline and a distinct counter retain the original
|
||||
battle and its separate Semuta discards. Seven related offline files pass 47
|
||||
cases, including genuine setup, shipments, Voice and Prescience, Semuta
|
||||
claim/pass and continuation to revealed plans. Source setup selects unopened
|
||||
card and Spice positions. Nested ordering is provisional; other battle
|
||||
parents, optional modules and deployed acceptance remain open.
|
||||
[Scope](CHOAM_KULL_DESIGN.md#basic-kull-and-semuta-during-battle-preparation--28-october-2026).
|
||||
|
||||
**Semuta after Nullentropy Box during Basic auctions — 28 October. Prototyped /
|
||||
Partial.** A completed paid Box search in a clean ordinary, Once Around or
|
||||
Silent cache lot may offer the discarded Box to Semuta. Claim or pass keeps
|
||||
@@ -28,9 +38,9 @@ parents, optional modules and deployed acceptance remain open.
|
||||
**In progress: Gitea migration — 28 October.** The local `origin` now tracks
|
||||
`https://git.juiced.computer/admin/Dune`; its imported `main` is an ancestor of
|
||||
the local checkout. Git push authentication and an Actions runner are not yet
|
||||
available. The integration owner is preparing a Gitea-native container workflow
|
||||
that preserves build, isolated verification and gated image publication; registry
|
||||
selection and runner installation are pending the user's response. Existing NAS
|
||||
available. A committed Gitea-native container workflow preserves build,
|
||||
isolated verification and gated image publication; registry selection and
|
||||
runner installation are pending the user's response. Existing NAS
|
||||
deployment and its persistent storage remain untouched.
|
||||
|
||||
**Semuta before Basic Moritani ally retention — 28 October. Prototyped /
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Rule decision index
|
||||
|
||||
## Kull and Semuta in Basic battle preparation — 28 October 2026
|
||||
|
||||
The printed Kull power interrupts another player's Karama activation. In the
|
||||
Basic CHOAM/Richese preview, apply it to clean Voice and Prescience cancellation
|
||||
before plans, with the already-selected distinct counter and Semuta discard
|
||||
ordering. That nested serialization remains a provisional software policy, not
|
||||
a publisher ruling. [Scope](CHOAM_KULL_DESIGN.md#basic-kull-and-semuta-during-battle-preparation--28-october-2026).
|
||||
|
||||
## Semuta after a Box search during Basic auctions — 28 October 2026
|
||||
|
||||
Semuta has no printed general bidding-phase exclusion. A completed Box search
|
||||
|
||||
@@ -2,6 +2,31 @@
|
||||
|
||||
Read-only engine audit, 2026-09-07. Read the complete `docs/SEMUTA_DRUG_ENGINE_AUDIT.md`, then inspected every current `discard(...)` call, direct Treachery-discard mutation and `draw(g)` caller in `game/engine.ts`, plus the Truthtrance discard callback and pure Box/Distrans results. Names below are integration anchors; approximate current lines are navigation aids only. This report adopts no additional source ruling and changes no project file.
|
||||
|
||||
## Kull and a Basic Voice or Prescience response — 28 October 2026
|
||||
|
||||
**Prototyped / Partial.** A clean Basic native CHOAM/Richese battle preparation
|
||||
can suspend a printed Karama cancellation of a declared Voice or Prescience
|
||||
for Kull and its Semuta response. The existing Kull frame retains the original
|
||||
card, actor, response and battle event. Its battle-bound fresh-discard receipt
|
||||
preserves preparation, shared readiness and other live battle fields while
|
||||
the ordinary Semuta claim or pass is pending.
|
||||
|
||||
A successful Kull offers its own physical discard and leaves the attempted
|
||||
Karama unspent; the original declared power remains. A distinct Karama counter
|
||||
offers that counter's own discard and then resumes the original cancellation
|
||||
once. Declining Kull likewise resumes that cancellation without a Kull cost;
|
||||
the resumed printed cost follows its own native Semuta opportunity. Claiming
|
||||
a fresh card affects custody rather than undoing the settled interception or
|
||||
counter. No suspended response, leader, plan, physical force or payment is
|
||||
replayed.
|
||||
|
||||
This serialization is provisional, not a publisher-specified nested priority.
|
||||
The admission is limited to Basic battles with no sealed plans, live battle
|
||||
promises or competing parent and the existing bare preview profile; Advanced
|
||||
and other effects or optional-module combinations remain separate. The older
|
||||
non-battle receipt signature is unchanged. Complete modes and deployed
|
||||
acceptance remain open. [Scope](CHOAM_KULL_DESIGN.md#basic-kull-and-semuta-during-battle-preparation--28-october-2026).
|
||||
|
||||
## Completed Box search during Basic bidding — 28 October 2026
|
||||
|
||||
**Prototyped / Partial.** In the supported bare Basic Semuta profile, a
|
||||
|
||||
+21
-5
@@ -1773,6 +1773,8 @@ export type Game = {
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
declaration?: NonNullable<Game['pendingChoamWorthless']>;
|
||||
use?: Extract<KaramaUse, { kind: 'cancel' }>;
|
||||
/** New pre-plan battle reactions bind the original live battle. */
|
||||
battleBinding?: true;
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
@@ -6873,8 +6875,18 @@ type KullSemutaDiscardContinuation = Extract<
|
||||
>;
|
||||
/** These native parents can be suspended without replaying another physical
|
||||
* transaction. More deeply nested response families retain their own gates. */
|
||||
function kullSemutaParentSupported(g: Game): boolean {
|
||||
return !g.truthtrance && !g.battle && !g.pendingRicheseGift && !g.pendingNullentropy &&
|
||||
function kullSemutaParentSupported(g: Game, frame?: NonNullable<Game['pendingKull']>): boolean {
|
||||
const battle = g.battle;
|
||||
const use = frame?.intent.kind === 'ordinary' ? frame.intent.use : undefined;
|
||||
const battleSupported = !battle || !!frame && !g.advanced && g.phase === 6 && !battle.revealed &&
|
||||
!!battle.event && (!battle.preLeader || battle.preLeader.event === battle.event &&
|
||||
Array.isArray(battle.preLeader.ready) && new Set(battle.preLeader.ready).size === battle.preLeader.ready.length &&
|
||||
battle.preLeader.ready.every(id => id === battle.attacker || id === battle.defender)) &&
|
||||
Object.keys(battle.plans).length === 0 && !battle.truthPromises?.some(promise => !promise.released) &&
|
||||
use?.kind === 'cancel' && ['voice', 'prescience'].includes(use.response.kind) &&
|
||||
JSON.stringify(use.response) === JSON.stringify(frame.resume.response) &&
|
||||
!frame.resume.decision && !frame.resume.pendingKarama && !frame.resume.phaseOpening;
|
||||
return battleSupported && !g.truthtrance && !g.pendingRicheseGift && !g.pendingNullentropy &&
|
||||
!g.pendingAmbassador && !g.pendingIxAlly && !g.pendingRevival && !g.pendingShipment &&
|
||||
!g.pendingGuildTransport && !g.pendingHomeworldShipment && !g.pendingTerrorEntry &&
|
||||
!g.pendingEcazPlacement && !g.pendingMoritaniPlacement &&
|
||||
@@ -6890,6 +6902,7 @@ function kullSemutaDiscardSignature(g: Game, c: KullSemutaDiscardContinuation):
|
||||
parent: nullentropyParentSignature(g, c.resume, c.frame.event),
|
||||
active: g.active, order: g.order, auction: g.auction, richeseAuction: g.richeseAuction,
|
||||
restrictions: g.kullRestrictions, discard: g.discard.map(card => card.id),
|
||||
...(c.battleBinding ? {battle:g.battle} : {}),
|
||||
players: g.players.map(player => ({id:player.id, hand:player.hand.map(card => card.id),
|
||||
spice:player.spice, reserves:player.reserves, forces:player.forces, tanks:player.tanks})) });
|
||||
}
|
||||
@@ -9234,7 +9247,8 @@ function semutaOfferSupported(
|
||||
) {
|
||||
if (continuation.kind === 'kullSuccessDiscard' || continuation.kind === 'kullCounterDiscard')
|
||||
return g.semutaPreview === true && g.advanced === false && bareBasicSemutaProfile(g) &&
|
||||
!!byFaction(g, 'richese') && kullSemutaParentSupported(g) && entries.length === 1 &&
|
||||
!!byFaction(g, 'richese') && kullSemutaParentSupported(kullSource(g, continuation.frame), continuation.frame) &&
|
||||
(!!g.battle === !!continuation.battleBinding) && entries.length === 1 &&
|
||||
entries[0].publicFace && entries[0].discardedBy === continuation.player &&
|
||||
entries[0].card.id === continuation.card;
|
||||
// These completed public costs retain the original ordinary lot through the
|
||||
@@ -18743,7 +18757,7 @@ function kullCostPromisesAllow(g: Game, p: Player, card: Card, fuelSource: 'prin
|
||||
}
|
||||
function kullCostCards(g: Game, p: Player, source: 'printed' | 'nexus' = 'printed'): Card[] {
|
||||
const original = g.pendingKull ? kullSource(g, g.pendingKull) : g;
|
||||
if (g.semutaPreview && (!kullSemutaParentSupported(original) || source !== 'printed')) return [];
|
||||
if (g.semutaPreview && (!kullSemutaParentSupported(original, g.pendingKull ?? undefined) || source !== 'printed')) return [];
|
||||
if (source === 'nexus' && (!g.nexusKullPreview || p.faction !== 'choam' || p.ally ||
|
||||
(g.pendingKull?.selection?.source !== 'nexus' && g.nexusCards?.cards?.hands[p.id] !== 'choam')))
|
||||
return [];
|
||||
@@ -19025,13 +19039,14 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
|
||||
if (g.semutaPreview && !g.advanced && g.pendingKull?.stage === 'counter' &&
|
||||
use.kind === 'cancel' && use.response.kind === 'choamWorthless' && use.response.intent === 'Kull Wahad') {
|
||||
const declaration = g.pendingChoamWorthless;
|
||||
requireRule(declaration && kullSemutaParentSupported(kullSource(g, g.pendingKull)),
|
||||
requireRule(declaration && kullSemutaParentSupported(kullSource(g, g.pendingKull), g.pendingKull),
|
||||
'This Kull counter cannot suspend its current parent for Semuta.');
|
||||
const continuation: KullSemutaDiscardContinuation = {
|
||||
kind: 'kullCounterDiscard', player: p.id, card: used.id,
|
||||
frame: structuredClone(g.pendingKull), declaration: structuredClone(declaration), use,
|
||||
resume: { response: g.response, decision: g.decision,
|
||||
pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening },
|
||||
...(g.battle ? {battleBinding:true as const} : {}),
|
||||
stateSignature: '',
|
||||
};
|
||||
g.pendingKull = null; g.pendingChoamWorthless = null;
|
||||
@@ -36116,6 +36131,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
kind: 'kullSuccessDiscard', player: choam.id, card: used.id, frame,
|
||||
resume: { response: g.response, decision: g.decision,
|
||||
pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening },
|
||||
...(g.battle ? {battleBinding:true as const} : {}),
|
||||
stateSignature: '',
|
||||
};
|
||||
g.response = null; g.decision = null; g.pendingKarama = null; g.phaseOpening = null;
|
||||
|
||||
@@ -4854,6 +4854,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Winning overbids whose Karama payment would become unplayable remain deferred. To avoid accepting a bid that the preview cannot settle, a non-CHOAM unfunded Karama-dependent bid is refused privately before commitment, independently of CHOAM’s hidden Kull ownership. CHOAM self-activations are outside interception; normal non-preview bidding is unchanged. An already unfunded winning activation remains guarded. Holding Karama alone is not a reaction trigger. This unfinished-composition fence does not restart bidding, award the lot free, supply new funding or change numeric UI bid limits.',
|
||||
'A live Truthtrance battle or shipment promise also constrains original attempts, Kull costs and counters. A cost that cannot be proved compatible is unavailable before disposal; CHOAM may decline instead. Proven supported cancellations remain available. Pending Kull cannot release a suspended promise, and its counter choices exclude unproven costs so legal AI can progress. Unsupported continuations remain an incomplete preview composition, not a publisher ban or permission to release the promise.',
|
||||
'The different-card counter and before-conversion custody are explicitly selected development policy, not a located publisher clarification. This printed-only profile still excludes Nexus. The separate Nexus Kull profile adds one bounded Cunning interaction without changing older games; Some nested Richese/Semuta parents, optional modules, unpayable-auction recovery and full CHOAM acceptance remain outside this printed preview. Nexus cards do not receive blanket immunity to Karama.',
|
||||
'In supported Basic CHOAM/Richese battles, Kull may also interrupt a printed Karama attempting to cancel a declared Voice or Prescience before plans are sealed. The same battle and original response remain in place. A successful Kull or a distinct counter offers its discarded card to Semuta before the original battle continues; this nested ordering is provisional.',
|
||||
],
|
||||
example: 'Bene Gesserit attempts to use Baliset as Karama. CHOAM declares Kull. Baliset cannot also counter Kull; a different eligible Karama may. If Kull succeeds, Baliset stays held; in a new Advanced attempt BG cannot repeat that specific Karama effect this phase but may use another legal effect. If a distinct counter prevents Kull, the original Baliset attempt proceeds to its normal conversion response.',
|
||||
related: ['choam-worthless', 'nexus-choam-kull', 'faction-choam', 'card-karama', 'advanced-beneGesserit', 'special-karama', 'bidding', 'choam-karama', 'nexus-cards', 'implementation-checklist'],
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { advance as advanceKullSemuta, inventory, player } from './fixture-kull-richese-semuta';
|
||||
import { nextSemutaRicheseCacheKarama } from './fixture-semuta-richese-cache-karama';
|
||||
|
||||
export { inventory, player };
|
||||
export const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
|
||||
|
||||
/** Public Basic setup with native CHOAM/Richese. Original unopened starting
|
||||
* cards and a safe unopened Spice position are selected. Richese purchases the
|
||||
* physical Semuta for one spice; Emperor and BG or Atreides actually ship into
|
||||
* the same unoccupied stronghold. No played force, purse, phase or battle is installed. */
|
||||
export function createKullBattleSemutaFixture(options: { power?: 'voice' | 'prescience'; preReady?: boolean } = {}) {
|
||||
const power = options.power ?? 'voice';
|
||||
const owner = power === 'voice' ? 'beneGesserit' : 'atreides';
|
||||
let game = createGame('KULLBATTLESEMUTA', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam', 'ix']);
|
||||
for (const faction of ['richese', 'emperor', owner, 'harkonnen'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
game = act(game, 'choam', { type: 'moduleOption', module: 'kullPreview', enabled: true });
|
||||
for (const seat of game.players) game = act(game, seat.id, { type: 'ready' });
|
||||
game = act(game, game.host, { type: 'start' });
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.ok(game.semutaPreview && game.kullPreview);
|
||||
const originalCards = inventory(game), selected: Game['deck'] = [];
|
||||
for (const kind of ['kull', 'filler', 'karama', 'filler', 'karama'] as const) {
|
||||
const index = game.deck.findIndex(card => kind === 'kull' ? card.id === 'ix-kull-wahad' :
|
||||
kind === 'karama' ? card.effect === 'karama' : card.kind === 'worthless' && card.id !== 'ix-kull-wahad');
|
||||
assert.ok(index >= 0, `The original unopened ${kind} must exist.`);
|
||||
selected.push(game.deck.splice(index, 1)[0]);
|
||||
}
|
||||
game.deck.unshift(...selected);
|
||||
game = advanceKullSemuta(game, state => state.status === 'playing');
|
||||
const [kull, , karama, , counter] = selected.map(card => card.id);
|
||||
assert.ok(player(game, 'choam').hand.some(card => card.id === kull));
|
||||
assert.ok(player(game, 'emperor').hand.some(card => card.id === karama));
|
||||
assert.ok(player(game, 'harkonnen').hand.some(card => card.id === counter));
|
||||
const land = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
|
||||
assert.ok(land >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(land, 1)[0]);
|
||||
game = advanceKullSemuta(game, state => player(state, 'richese').hand.some(card => card.id === SEMUTA_DRUG_ID) &&
|
||||
!state.response && !state.decision && !state.phaseOpening && !state.pendingTreacheryDiscard);
|
||||
assert.ok(player(game, 'richese').hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
let target: { territory: string; sector: number } | null = null;
|
||||
for (let count = 0; count < 1000 && game.phase !== 6; count++) {
|
||||
if (game.phase === 5 && !game.response && !game.decision && !game.phaseOpening &&
|
||||
!game.pendingTreacheryDiscard && !game.pendingKull && game.active) {
|
||||
const actor = game.active, seat = player(game, actor);
|
||||
if ((actor === 'emperor' || actor === owner) && !seat.shipped) {
|
||||
if (!target) {
|
||||
const territory = TERRITORIES.find(t => t.type === 'stronghold' && t.sectors.some(s => s !== game.storm) &&
|
||||
game.players.every(p => !Object.keys(p.forces).some(key => key.startsWith(`${t.id}:`))));
|
||||
assert.ok(territory, 'A safe empty printed stronghold must be available.');
|
||||
target = { territory: territory.id, sector: territory.sectors.find(sector => sector !== game.storm)! };
|
||||
}
|
||||
game = act(game, actor, { type: 'ship', territory: target.territory, sector: target.sector,
|
||||
amount: 1, elite: 0, allyPayment: 0 });
|
||||
} else game = act(game, actor, { type: 'endMovement' });
|
||||
continue;
|
||||
}
|
||||
const next = nextSemutaRicheseCacheKarama(game);
|
||||
assert.ok(next, `Complete the actual source control at ${game.phase}/${game.decision?.kind}.`);
|
||||
game = act(game, next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.phase, 6); assert.ok(target);
|
||||
assert.equal(player(game, 'emperor').forces[`${target.territory}:${target.sector}`], 1);
|
||||
assert.equal(player(game, owner).forces[`${target.territory}:${target.sector}`], 1);
|
||||
// A native chooser owns this real battle; other seats' unspent choices are
|
||||
// resolved by the same production policy before making the selection.
|
||||
for (let count = 0; count < 30 && !game.battle; count++) {
|
||||
const view = game.active ? viewGame(game, game.active) : null;
|
||||
const targets = view?.battleChoices?.find(choice => choice.territory === target!.territory &&
|
||||
(choice.chooser ?? choice.attacker) === game.active &&
|
||||
[choice.attacker, choice.defender].includes(game.active!));
|
||||
if (targets) {
|
||||
const opponent = game.active === 'emperor' ? owner : 'emperor';
|
||||
game = act(game, game.active!, { type: 'chooseBattle', territory: target.territory, target: opponent });
|
||||
break;
|
||||
}
|
||||
const next = nextSemutaRicheseCacheKarama(game); assert.ok(next);
|
||||
game = act(game, next.actor, next.action);
|
||||
}
|
||||
assert.ok(game.battle);
|
||||
for (let count = 0; count < 30 && game.battle?.preparation?.kind !== power; count++) {
|
||||
const next = nextSemutaRicheseCacheKarama(game); assert.ok(next);
|
||||
game = act(game, next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.battle?.preparation?.kind, power);
|
||||
if (options.preReady) {
|
||||
const pre = game.battle!.preLeader; assert.ok(pre && !pre.closed);
|
||||
game = act(game, 'emperor', { type: 'battlePreparationReady', event: pre.event });
|
||||
assert.deepEqual(game.battle!.preLeader!.ready, ['emperor']);
|
||||
assert.equal(game.battle!.preLeader!.closed, false);
|
||||
}
|
||||
game = act(game, owner, power === 'voice'
|
||||
? { type: 'voice', kind: 'shield', must: false }
|
||||
: { type: 'prescience', field: 'dial' });
|
||||
assert.equal(game.response?.kind, power);
|
||||
assert.deepEqual(inventory(game), originalCards);
|
||||
return { game, originalCards, target, kull, karama, counter, semuta: SEMUTA_DRUG_ID,
|
||||
holder: 'richese', reactor: 'choam', owner, actor: 'emperor', counterOwner: 'harkonnen', power };
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { act, createKullBattleSemutaFixture, inventory, player } from './fixture-kull-battle-semuta';
|
||||
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
type Fixture = ReturnType<typeof createKullBattleSemutaFixture>;
|
||||
const held = (game: Game, id: string, card: string) => player(game, id).hand.some(item => item.id === card);
|
||||
|
||||
function attempt(f: Fixture) {
|
||||
const game = act(reload(f.game), f.actor, { type: 'card', mode: 'cancel', card: f.karama });
|
||||
assert.equal(game.pendingKull?.stage, 'offer');
|
||||
const choice = viewGame(game, f.reactor).kullReaction; assert.ok(choice);
|
||||
assert.ok(choice.plays.some(row => row.source === 'printed' && row.card.id === f.kull && !row.blocked),
|
||||
`Kull is missing in the actual Voice frame: ${JSON.stringify({ preLeader: game.battle?.preLeader,
|
||||
preparation: game.battle?.preparation, frame: game.pendingKull?.intent, resume: game.pendingKull?.resume })}`);
|
||||
assert.ok(held(game, f.actor, f.karama));
|
||||
return game;
|
||||
}
|
||||
|
||||
function declare(f: Fixture, game: Game) {
|
||||
const choice = viewGame(game, f.reactor).kullReaction; assert.ok(choice);
|
||||
const next = act(game, f.reactor, { type: 'kullDecision', event: choice.event, source: 'printed', card: f.kull });
|
||||
assert.equal(next.pendingKull?.stage, 'counter');
|
||||
return next;
|
||||
}
|
||||
|
||||
function passResponsesUntilDiscard(game: Game) {
|
||||
for (let count = 0; count < 16 && !game.pendingTreacheryDiscard; count++) {
|
||||
assert.ok(game.response, 'The native Kull counter must finish before its discard.');
|
||||
const actor = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(actor);
|
||||
game = act(game, actor.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
return game;
|
||||
}
|
||||
|
||||
function react(game: Game, f: Fixture, expectedCard: string, claim: boolean) {
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
|
||||
const event = pending.batch.event;
|
||||
const hadSemuta = held(game, f.holder, f.semuta);
|
||||
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [expectedCard]);
|
||||
if (claim) {
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
game = act(game, f.holder, { type: 'semutaCommit', event });
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = act(game, f.holder, { type: 'semutaSelect', event, card: expectedCard });
|
||||
} else {
|
||||
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) {
|
||||
const reaction = game.pendingTreacheryDiscard!.reaction; assert.ok(reaction?.stage === 'offer');
|
||||
const actor = game.players.find(row => !reaction.passed.includes(row.id)); assert.ok(actor);
|
||||
game = act(game, actor.id, { type: 'semutaPass', event });
|
||||
}
|
||||
}
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event);
|
||||
assert.equal(held(game, f.holder, expectedCard), claim);
|
||||
assert.equal(held(game, f.holder, f.semuta), claim ? false : hadSemuta);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
assert.throws(() => act(game, f.holder, { type: 'semutaCommit', event }));
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Finish only native responses and readiness, then seal real no-card plans.
|
||||
* This proves the same battle remains actionable without replaying its setup. */
|
||||
function continueBattle(game: Game, f: Fixture, powerApplies: boolean) {
|
||||
for (let count = 0; game.response && count < 16; count++) {
|
||||
const actor = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(actor);
|
||||
game = act(game, actor.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
assert.ok(game.battle);
|
||||
assert.equal(game.battle.territory, f.target.territory);
|
||||
if (f.power === 'voice') assert.equal(!!game.battle.voice, powerApplies);
|
||||
else assert.equal(!!game.battle.prescience, powerApplies);
|
||||
for (let count = 0; game.battle?.preparation && count < 4; count++) {
|
||||
const preparation = game.battle.preparation;
|
||||
game = act(game, preparation.owner, preparation.kind === 'prescienceAnswer'
|
||||
? { type: 'prescienceAnswer', value: 0 }
|
||||
: { type: 'declineBattlePower' });
|
||||
}
|
||||
const event = game.battle!.event;
|
||||
for (const actor of [f.actor, f.owner]) {
|
||||
if (game.battle?.preLeader && !game.battle.preLeader.closed && !game.battle.preLeader.ready.includes(actor))
|
||||
game = act(game, actor, { type: 'battlePreparationReady', event });
|
||||
}
|
||||
for (const actor of [f.actor, f.owner]) {
|
||||
const leader = player(game, actor).leaders.find(row => !row.dead && !row.capturedBy); assert.ok(leader);
|
||||
game = act(game, actor, { type: 'battlePlan', leader: leader.id, dial: 0, support: 0 });
|
||||
}
|
||||
assert.ok(game.battle?.plans[f.actor] && game.battle.plans[f.owner]);
|
||||
assert.equal(game.battle.event, event);
|
||||
for (const actor of [f.actor, f.owner]) game = act(game, actor, { type: 'traitorCall', call: false });
|
||||
assert.equal(game.battle?.revealed, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const claim of [false, true]) {
|
||||
void test(`Basic Kull stops Prescience Karama with one combatant ready and Semuta ${claim ? 'claims' : 'passes'} Kull`, () => {
|
||||
const f = createKullBattleSemutaFixture({ power: 'prescience', preReady: true });
|
||||
assert.deepEqual(f.game.battle!.preLeader!.ready, [f.actor]);
|
||||
const pending = passResponsesUntilDiscard(declare(f, attempt(f)));
|
||||
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'kullSuccessDiscard');
|
||||
assert.deepEqual(pending.battle!.preLeader!.ready, [f.actor]);
|
||||
const game = react(pending, f, f.kull, claim);
|
||||
assert.ok(held(game, f.actor, f.karama));
|
||||
assert.deepEqual(game.battle!.preLeader!.ready, [f.actor]);
|
||||
continueBattle(game, f, true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const claim of [false, true]) {
|
||||
void test(`Basic Prescience Kull counter preserves readiness and Semuta ${claim ? 'claims' : 'passes'} the counter`, () => {
|
||||
const f = createKullBattleSemutaFixture({ power: 'prescience', preReady: true });
|
||||
const declared = declare(f, attempt(f));
|
||||
let game = act(declared, f.counterOwner, { type: 'card', mode: 'cancel', card: f.counter });
|
||||
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'kullCounterDiscard');
|
||||
assert.deepEqual(game.battle!.preLeader!.ready, [f.actor]);
|
||||
game = react(game, f, f.counter, claim);
|
||||
if (game.pendingTreacheryDiscard) game = react(game, f, f.karama, !claim);
|
||||
assert.equal(game.pendingKull, null);
|
||||
assert.equal(held(game, f.actor, f.karama), false);
|
||||
assert.deepEqual(game.battle!.preLeader!.ready, [f.actor]);
|
||||
continueBattle(game, f, false);
|
||||
});
|
||||
}
|
||||
|
||||
for (const claim of [false, true]) {
|
||||
void test(`Basic Kull stops battle Karama and Semuta ${claim ? 'claims' : 'passes'} Kull`, () => {
|
||||
const f = createKullBattleSemutaFixture();
|
||||
const pending = passResponsesUntilDiscard(declare(f, attempt(f)));
|
||||
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'kullSuccessDiscard');
|
||||
assert.ok(held(pending, f.actor, f.karama));
|
||||
const game = react(pending, f, f.kull, claim);
|
||||
assert.ok(held(game, f.actor, f.karama));
|
||||
assert.equal(game.pendingKull, null);
|
||||
assert.ok(viewGame(game, f.actor).karamaBlocked);
|
||||
continueBattle(game, f, true);
|
||||
});
|
||||
}
|
||||
|
||||
void test('declining Kull permits the original battle Karama and its fresh Semuta reaction', () => {
|
||||
const f = createKullBattleSemutaFixture();
|
||||
let game = attempt(f);
|
||||
const event = game.pendingKull!.event;
|
||||
game = act(game, f.reactor, { type: 'kullDecision', event, decline: true });
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'karamaCombatDiscard');
|
||||
game = react(game, f, f.karama, true);
|
||||
assert.ok(held(game, f.reactor, f.kull));
|
||||
assert.equal(game.pendingKull, null);
|
||||
continueBattle(game, f, false);
|
||||
});
|
||||
|
||||
for (const claim of [false, true]) {
|
||||
void test(`a distinct counter stops battle Kull, then Semuta ${claim ? 'claims' : 'passes'} the counter`, () => {
|
||||
const f = createKullBattleSemutaFixture();
|
||||
const declared = declare(f, attempt(f));
|
||||
assert.ok(viewGame(declared, f.counterOwner).responseControls?.cancelCards.includes(f.counter));
|
||||
let game = act(declared, f.counterOwner, { type: 'card', mode: 'cancel', card: f.counter });
|
||||
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'kullCounterDiscard');
|
||||
assert.ok(held(game, f.reactor, f.kull));
|
||||
game = react(game, f, f.counter, claim);
|
||||
assert.ok(held(game, f.reactor, f.kull));
|
||||
// The original attempted Karama is a separate fresh discard if its native
|
||||
// combat cost resumes. It cannot reuse the already-spent Semuta card.
|
||||
if (game.pendingTreacheryDiscard) game = react(game, f, f.karama, !claim);
|
||||
assert.equal(game.pendingKull, null);
|
||||
assert.equal(held(game, f.actor, f.karama), false);
|
||||
continueBattle(game, f, false);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user