Allow bounded Kull cancellation continuations
Verify and publish checkpoint container / container (push) Successful in 10m22s

This commit is contained in:
Quintine committed 2026-10-29 01:46:28 +11:00
1 parent 07f1f1f2ae
commit 6e1b95d2fc
9 files changed
+569 -7

No files matched your search

+29 -2
View File
@@ -1,5 +1,31 @@
# Kull Wahad: source limits and Karama interruption design
## Basic whole-answer cancellation continuations — 29 October 2026
**Prototyped / Partial.** In the supported Basic printed-Kull profile, a
player with a binding whole-turn answer may use a physical Karama to cancel
Voice, Prescience, Basic Ixian cyborg strength or clean Guild shipment income
when the detached quote retains a legal completion. The original and a
distinct counter use the same known-parent quote; Kull success keeps the
original response open. An answer naming that exact printed Voice or
Prescience cancellation still governs the original window. A payment-only
CHOAM answer can be quoted across the reopened battle response without
choosing another player's response or battle plan.
The combat quote stops before any later foreign response or decision. A Guild
income quote admits a plain pending shipment entry only when no Ecaz or
Moritani can react; an accompanying advisor decision, queued automatic
advisor flip or other arrival remains guarded. Other cancellation parents, pending successor decisions, broader
profiles and completion witnesses are still conservative fences, not printed
restrictions. The existing online ordering is provisional. Genuine game tests
select unopened cards; two exact-window cases recycle the same physical
Truthtrance in a controlled fixture. The automatic-advisor-queue fence was
reviewed against its native continuation but has no direct gameplay fixture yet.
[Voice and Prescience](../tests/kull-whole-cancel-owner-guard.test.ts),
[Guild and Ixian strength](../tests/kull-whole-common-cancel.test.ts),
[CHOAM commitments](../tests/kull-whole-choam-cancel-cost.test.ts),
[arrival decision guard](../tests/kull-whole-guild-arrival-guard.test.ts).
## Basic whole-turn answers with Kull — 29 October 2026
**Prototyped / Partial.** Combined whole-turn Truthtrance questions are
@@ -12,8 +38,9 @@ and auction preflight uses a detached deterministic quote: an unknown auction
face is opaque, future lots remain in their original custody, and foreign
choices and draws are not executed to establish a witness.
The quote is deliberately conservative: cancellation parents and a purchase
with an outstanding positive phase-wide bid answer are fenced while the
The quote was initially conservative about cancellation parents; the later
section above opens several of them. A purchase
with an outstanding positive phase-wide bid answer is fenced while the
actor has a whole-turn answer, as are unproved counter continuations. A
completion depending on recovering the spent Karama or on Semuta at a later
discard may also be rejected. These are prototype limits, not printed
+12
View File
@@ -17,6 +17,18 @@ open mode gates.
## Current checkpoint and work
**Basic Kull cancellations with whole-turn answers — 29 October. Prototyped / Partial.**
Voice, Prescience, Basic Ixian cyborg strength and clean Guild income
cancellations can now retain a binding whole-turn answer through the supported
Kull continuation. Exact Voice/Prescience cancellation answers still constrain
the printed card; CHOAM's own payment-only answer can survive the reopened
response. A separate advisor decision and unproved later effects stay guarded.
Eleven related files pass 47 cases; typecheck, lint and the production build
pass. The automatic-advisor queue guard has no direct gameplay fixture yet.
Broader cancellations, profiles and complete rules
remain unfinished.
[Scope](CHOAM_KULL_DESIGN.md#basic-whole-answer-cancellation-continuations--29-october-2026).
**Basic whole-turn Truthtrance with printed Kull — 29 October. Prototyped / Partial.**
Combined turn-action answers now work in the supported bare Basic Kull profile,
including Richese's Semuta. The interrupted answer survives supported Kull and
+8
View File
@@ -1,5 +1,13 @@
# Rule decision index
## Basic whole-answer cancellation continuations — 29 October 2026
The supported Basic printed-Kull profile now quotes selected Voice,
Prescience, Ixian strength and clean Guild income cancellations alongside
whole-turn answers. Other parents and independent pending decisions remain
fenced; this is a provisional software boundary, not a printed rule.
[Scope](CHOAM_KULL_DESIGN.md#basic-whole-answer-cancellation-continuations--29-october-2026).
## Basic whole-turn answers with Kull — 29 October 2026
The supported bare Basic profile admits combined whole-turn answers through
+78 -4
View File
@@ -19253,18 +19253,50 @@ function kullCostPromisesAllow(g: Game, p: Player, card: Card, fuelSource: 'prin
projected.decision = null;
projected.pendingKarama = null;
let wholeSource = projected;
if (wholePromises.length && g.pendingKull?.intent.kind === 'ordinary' &&
g.pendingKull.intent.use.kind === 'cancel') {
// Kull success leaves the original response open; prevention resumes the
// original cancellation from that same response. Keep it for either quote.
wholeSource = structuredClone(projected);
wholeSource.response = structuredClone(source.response);
wholeSource.decision = structuredClone(source.decision);
wholeSource.phaseOpening = structuredClone(source.phaseOpening);
}
if (!success && wholePromises.length && g.pendingKull) {
if (g.pendingKull.intent.kind !== 'ordinary') return false;
const original = getPlayer(projected, g.pendingKull.owner);
const original = getPlayer(wholeSource, g.pendingKull.owner);
const attempted = original.hand.find(row => row.id === g.pendingKull!.card);
if (!attempted) return false;
const resumed = projectKullOrdinaryOutcome(projected, original, attempted, g.pendingKull.intent.use);
const resumed = projectKullOrdinaryOutcome(wholeSource, original, attempted, g.pendingKull.intent.use);
if (!resumed) return false;
wholeSource = resumed;
}
let wholeCompletion = !wholePromises.length || !!turnActionCompletion(wholeSource, p.id);
if (!wholeCompletion && success && wholeSource.response &&
(wholeSource.response.kind === 'voice' || wholeSource.response.kind === 'prescience') &&
!wholeSource.decision && !wholeSource.phaseOpening && !battlePromises.length && !shipmentPromises.length &&
!(['travelPromises','movementPromises','movementExpressionPromises','bidPromises','phaseBidPromises',
'revivalPromises','leaderRevivalPromises','revivalExpressionPromises','bribePromises',
'bribeExpressionPromises'] as const).some(key => wholeSource[key]?.some(row =>
row.player === p.id && !row.fulfilled && !row.released)) &&
wholePromises.every(promise => {
let found = false;
for (const branch of turnActionRule(() => turnActionBranches(promise))) {
found = true;
if (branch.some(leaf => leaf.kind !== 'payment')) return false;
}
return found;
})) {
// The reopened Voice/Prescience response cannot change the payer's
// wallet or recipient. Quote a payment-only answer after that response,
// without choosing either player's response or a later battle plan.
const afterResponse = structuredClone(wholeSource);
afterResponse.response = null;
wholeCompletion = !!turnActionCompletion(afterResponse, p.id);
}
if ((battlePromises.length && !findReachableBattlePlan(projected, owner, { promises: battlePromises })) ||
(shipmentPromises.length && !findShipmentCompletion(projected, owner, shipmentPromises)) ||
(wholePromises.length && !turnActionCompletion(wholeSource, p.id)))
!wholeCompletion)
return false;
}
return true;
@@ -19497,10 +19529,52 @@ function resumeKullAttempt(g: Game, prevented: boolean): void {
spendKarama(g, p, p.hand.find(card => card.id === frame.card)!, frame.intent.use, true);
}
}
/** Quote only known shipment-price and ordinary-auction effects without
/** Quote known shipment-price, ordinary-auction and bounded cancellation effects without
* executing a foreign response, drawing a card or exposing a hidden lot.
* Other parent effects need their own deterministic whole-answer quotation. */
function projectKullOrdinaryOutcome(g: Game, player: Player, card: Card, use: KaramaUse): Game | null {
if (use.kind === 'cancel') {
const response = use.response;
// A separately owned arrival/phase decision remains at the table after
// this response; do not erase it to manufacture a completion witness.
if (!g.response || g.decision || g.phaseOpening ||
JSON.stringify(g.response) !== JSON.stringify(response)) return null;
const battleWindow = karamaBattleCancelOpportunity(g, player.id);
const trial = structuredClone(g), owner = getPlayer(trial, player.id);
if (!owner.hand.some(held => held.id === card.id)) return null;
owner.hand = owner.hand.filter(held => held.id !== card.id);
if (battleWindow && card.kind === 'special' && card.effect === 'karama')
recordNativeTurnActionWindowCard(trial, player.id, 'karamaBattleCancel', battleWindow.source);
if (response.kind === 'voice' || response.kind === 'prescience' || response.kind === 'eliteStrength') {
const quote = currentCombatResponseQuote(trial, {kind:'response',
response:{kind:response.kind, owner:response.owner}, canceled:true});
// A later power/choice belongs to its original owner and cannot be
// assumed as part of this deterministic cancellation quote.
if (quote.response || quote.decision) return null;
commitCombatResponseQuote(trial, quote);
} else {
// A plain queued shipment entry has no remaining effect without Ecaz
// or Moritani. Any other queued entry may own a separate decision.
const arrivals = g.pendingArrivalReaction ?? [];
const inertArrivals = !arrivals.length || (!byFaction(g, 'ecaz') && !byFaction(g, 'moritani') &&
arrivals.every(entry => entry.cause === 'shipment' && entry.resume === 'none' &&
entry.turn === g.turn && entry.phase === g.phase &&
Object.keys(entry).every(key => ['entrant','territory','sector','amount','elite','cause','resume','turn','phase'].includes(key))));
if (!semutaEmperorShareKaramaResponse(g, response) &&
!(response.kind === 'guildIncome' && response.source === undefined &&
semutaIncomeKaramaResponse(g, response) && !g.pendingArrivalOverlap &&
inertArrivals && !g.pendingSpiritualAdvisor && !g.pendingAutomaticAdvisorFlips?.length)) return null;
}
if (battleWindow) {
const original = liveTurnActionPromises(trial.turnActionPromises ?? [], player.id, trial.turn);
trial.turnActionPromises = turnActionRule(() => closeTurnActionWindowCardPlay(
trial.turnActionPromises ?? [], player.id, trial.turn, 'karamaBattleCancel', battleWindow.source));
if (original.some(row => trial.turnActionPromises?.find(next => next.event === row.event)?.released)) return null;
}
trial.truthtrance = null; trial.response = null; trial.decision = null;
trial.pendingKarama = null; trial.phaseOpening = null; trial.pendingKull = null;
return trial;
}
if (use.kind !== 'shipment' && use.kind !== 'purchase' && use.kind !== 'auctionPayment') return null;
const window = windowCardOpportunity(g, use.kind === 'shipment' ? 'karamaShipment' : 'karamaAuction');
const lot = use.kind === 'shipment' ? null : bidOpportunity(g);
+1 -1
View File
@@ -3316,7 +3316,7 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [
'In a quiet Basic Storm, Spice Blow, Charity, Bidding, Revival or Shipment and Movement window, ask whether a player will use a named native leader in their first later battle this turn, if one occurs. Storm must have a pending movement after the dials; Spice Blow must be before the draw. The answer waits for an actual battle; if none occurs, it expires. This separate provisional question currently supports classic factions without optional modules and uses only currently available leaders for its answer.',
'Choose Combine printed card plays, native battle powers and this turn’s other actions. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of original-window card plays, native power declarations, printed Hajr/Ghola use, current battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.',
'The supported bare Basic printed-Kull profile also permits these questions, including with Richese and Semuta. The answer remains bound while Kull or its counter is resolving; some Karama uses remain guarded until their effects can be quoted without assuming hidden or foreign outcomes.',
'The supported bare Basic printed-Kull profile also permits these questions, including with Richese and Semuta. The answer remains bound while Kull or its counter is resolving. Voice, Prescience, Ixian cyborg strength and clean Guild income cancellations can use a proven continuation; other Karama uses remain guarded until their effects can be quoted without assuming hidden or foreign outcomes.',
'A final-alliance clause names another current participant during this original quiet native Nexus. Existing invitations and reciprocal formation remain authoritative. Offers, temporary membership and your own Ready do not settle the clause; final actual membership is fixed only when the Nexus closes.',
'Whole No can permit a temporary paid pair and later require a break. Own Ready must preserve a completion after final membership is fixed; remaining payment or physical-action alternatives keep their later deadlines. Definite answers, including constants, uniformly reopen own Nexus readiness; later ordinary payments require confirmation again.',
'Missing foreign consent can offer I don’t know and save or change the question; it never invents a definite Yes. Unknown requires an otherwise funded native completion, so a known unaffordable branch stays No. Foreign withdrawal can release impossibility without vetoing the other player’s rights.',
+163
View File
@@ -0,0 +1,163 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { TERRITORIES } from '../game/board';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { windowCardOpportunity } from '../game/window-card-play';
import { act, advance, clean, inventory, player } from './fixture-kull-richese-semuta';
import { nextSemutaRicheseCacheKarama } from './fixture-semuta-richese-cache-karama';
/** A genuine five-seat Basic game. Select only physical unopened starting
* cards and a safe unopened Spice card; the Truth answer, cache purchase,
* shipment, battle, parent power, and Kull actions are all native actions. */
function source(power:'voice'|'prescience', answer:'yes'|'no') {
const owner=power==='voice'?'beneGesserit':'atreides';
let game=createGame('KULLWHOLECANCEL',newPlayer('choam','CHOAM','choam'),false,['choam','ix']);
for (const id of ['richese','emperor',owner,'harkonnen'] as const) joinGame(game,newPlayer(id,id,id));
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,'choam',{type:'start'});
assert.equal(game.status,'setup'); assert.ok(game.kullPreview && game.semutaPreview);
const originalCards=inventory(game), selected:Game['deck']=[];
for (const kind of ['kull','filler','karama','filler','karama','truth'] as const) {
const index=game.deck.findIndex(card=>kind==='kull'?card.id==='ix-kull-wahad':
kind==='truth'?card.effect==='truthtrance':kind==='karama'?card.effect==='karama':
card.kind==='worthless' && card.id!=='ix-kull-wahad');
assert.ok(index>=0); selected.push(game.deck.splice(index,1)[0]);
}
game.deck.unshift(...selected);
game=advance(game,state=>state.status==='playing');
const [kull,,karama,,counter,truth]=selected.map(card=>card.id);
for (const [id,card] of [['choam',kull],['emperor',karama],['harkonnen',counter],['harkonnen',truth]] as const)
assert.ok(player(game,id).hand.some(row=>row.id===card));
const safe=game.spiceDeck.findIndex(card=>'territory' in card && !card.discovery); assert.ok(safe>=0);
game.spiceDeck.unshift(game.spiceDeck.splice(safe,1)[0]);
game=advance(game,state=>state.phase===2 && clean(state) && state.choamCharity?.turn===state.turn);
game=act(game,'harkonnen',{type:'card',card:truth});
while (game.truthtrance?.stage==='priority') {
const seat=game.players.find(row=>!game.truthtrance!.passed.includes(row.id)); assert.ok(seat);
game=act(game,seat.id,{type:'truthPass'});
}
game=act(game,'harkonnen',{type:'truthAsk',question:{kind:'turnActions',target:'emperor',turn:game.turn,
mixed:{kind:'action',action:{kind:'payment',recipient:'richese',minimum:1}}}});
assert.ok(viewGame(game,'emperor').truthTurnActionAnswers?.includes(answer));
game=act(game,'emperor',{type:'truthAnswer',answer});
const commitment=game.turnActionPromises?.at(-1); assert.ok(commitment);
game=advance(game,state=>player(state,'richese').hand.some(card=>card.id===SEMUTA_DRUG_ID) && clean(state));
let target:{territory:string;sector:number}|null=null;
for (let count=0;count<1000 && game.phase!==6;count++) {
if (game.phase===5 && clean(game) && game.active) {
const actor=game.active, seat=player(game,actor);
if ((actor==='emperor'||actor===owner) && !seat.shipped) {
if (!target) {
const territory=TERRITORIES.find(row=>row.type==='stronghold' && row.sectors.some(s=>s!==game.storm) &&
game.players.every(p=>!Object.keys(p.forces).some(key=>key.startsWith(`${row.id}:`))));
assert.ok(territory);
target={territory:territory.id,sector:territory.sectors.find(s=>s!==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);
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);
for (let count=0;count<30 && !game.battle;count++) {
const view=game.active?viewGame(game,game.active):null;
const choice=view?.battleChoices?.find(row=>row.territory===target!.territory &&
(row.chooser??row.attacker)===game.active && [row.attacker,row.defender].includes(game.active!));
if (choice) {
game=act(game,game.active!,{type:'chooseBattle',territory:target.territory,
target:game.active==='emperor'?owner:'emperor'});
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);
game=act(game,owner,power==='voice'?{type:'voice',kind:'shield',must:false}:{type:'prescience',field:'dial'});
assert.equal(game.response?.kind,power);
assert.ok(game.turnActionPromises?.some(row=>row.event===commitment.event && !row.released));
assert.deepEqual(inventory(game),originalCards);
return {game,kull,karama,counter,truth,event:commitment.event,owner,originalCards};
}
function passDiscard(game:Game):Game {
const pending=game.pendingTreacheryDiscard; assert.ok(pending?.reaction?.stage==='offer');
const event=pending.batch.event;
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 seat=game.players.find(row=>!reaction.passed.includes(row.id)); assert.ok(seat);
game=act(game,seat.id,{type:'semutaPass',event});
}
assert.notEqual(game.pendingTreacheryDiscard?.batch.event,event);
return game;
}
for (const power of ['voice','prescience'] as const) for (const outcome of ['decline','success','counter'] as const)
void test(`Basic whole payment Yes survives ${power} cancellation and Kull ${outcome}`,()=>{
const f=source(power,'yes'); let game=f.game;
assert.ok(viewGame(game,'emperor').responseControls?.cancelCards.includes(f.karama));
game=act(game,'emperor',{type:'card',mode:'cancel',card:f.karama});
const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction);
game=outcome==='decline'?act(game,'choam',{type:'kullDecision',event:reaction.event,decline:true}):
act(game,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull});
if (outcome==='counter') game=act(game,'harkonnen',{type:'card',mode:'cancel',card:f.counter});
if (outcome==='success') game=advance(game,state=>!!state.pendingTreacheryDiscard);
while (game.pendingTreacheryDiscard) game=passDiscard(game);
assert.ok(game.turnActionPromises?.some(row=>row.event===f.event && !row.released));
if (outcome==='success') assert.ok(player(game,'emperor').hand.some(card=>card.id===f.karama));
else assert.ok(!player(game,'emperor').hand.some(card=>card.id===f.karama));
assert.deepEqual(inventory(game),f.originalCards);
});
void test('Basic whole payment No remains binding when a Voice cancellation resolves',()=>{
const f=source('voice','no'); let game=f.game;
assert.ok(viewGame(game,'emperor').responseControls?.cancelCards.includes(f.karama));
game=act(game,'emperor',{type:'card',mode:'cancel',card:f.karama});
const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction);
game=act(game,'choam',{type:'kullDecision',event:reaction.event,decline:true});
while (game.pendingTreacheryDiscard) game=passDiscard(game);
assert.ok(game.turnActionPromises?.some(row=>row.event===f.event && !row.released));
assert.deepEqual(inventory(game),f.originalCards);
});
for (const answer of ['yes','no'] as const)
void test(`the exact Voice cancellation whole-answer ${answer} governs the printed Karama through Kull`,()=>{
const f=source('voice','no'); let game=structuredClone(f.game);
// Recycle the fixture's original physical Truthtrance to ask at the actual
// response; the preceding payment commitment and native battle stay real.
const index=game.discard.findIndex(card=>card.id===f.truth); assert.ok(index>=0);
player(game,'harkonnen').hand.push(game.discard.splice(index,1)[0]);
const window=windowCardOpportunity(game,'karamaBattleCancel'); assert.ok(window);
game=act(game,'harkonnen',{type:'card',card:f.truth});
while (game.truthtrance?.stage==='priority') {
const seat=game.players.find(row=>!game.truthtrance!.passed.includes(row.id)); assert.ok(seat);
game=act(game,seat.id,{type:'truthPass'});
}
game=act(game,'harkonnen',{type:'truthAsk',question:{kind:'turnActions',target:'emperor',turn:game.turn,
mixed:{kind:'action',action:{kind:'windowCardPlay',effect:'karamaBattleCancel',source:window.source}}}});
assert.ok(viewGame(game,'emperor').truthTurnActionAnswers?.includes(answer));
game=act(game,'emperor',{type:'truthAnswer',answer});
const promise=game.turnActionPromises?.at(-1); assert.ok(promise);
while (game.pendingTreacheryDiscard) game=passDiscard(game);
if (answer==='no') {
assert.ok(!viewGame(game,'emperor').responseControls?.cancelCards.includes(f.karama));
assert.throws(()=>act(game,'emperor',{type:'card',mode:'cancel',card:f.karama}));
} else {
assert.ok(viewGame(game,'emperor').responseControls?.cancelCards.includes(f.karama));
game=act(game,'emperor',{type:'card',mode:'cancel',card:f.karama});
const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction);
game=act(game,'choam',{type:'kullDecision',event:reaction.event,decline:true});
while (game.pendingTreacheryDiscard) game=passDiscard(game);
assert.equal(game.turnActionPromises?.find(row=>row.event===promise.event)?.fulfilled,true);
}
assert.deepEqual(inventory(game),f.originalCards);
});
+118
View File
@@ -0,0 +1,118 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { TERRITORIES } from '../game/board';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { act, advance, clean, inventory, player } from './fixture-kull-richese-semuta';
import { nextSemutaRicheseCacheKarama } from './fixture-semuta-richese-cache-karama';
/** Native five-seat Basic setup, actual Truthtrance answer, cache purchase,
* shipments and battle. Only unopened starting cards and one safe Spice
* position are selected; no wallet, force, response or promise is staged. */
function source(power:'voice'|'prescience') {
const owner=power==='voice'?'beneGesserit':'atreides';
let game=createGame('KULLWHOLECHOAM',newPlayer('choam','CHOAM','choam'),false,['choam','ix']);
for (const id of ['richese','emperor',owner,'harkonnen'] as const) joinGame(game,newPlayer(id,id,id));
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,'choam',{type:'start'});
assert.equal(game.status,'setup'); assert.ok(game.kullPreview && game.semutaPreview);
const originalCards=inventory(game), selected:Game['deck']=[];
for (const kind of ['kull','filler','karama','filler','karama','truth'] as const) {
const index=game.deck.findIndex(card=>kind==='kull'?card.id==='ix-kull-wahad':
kind==='truth'?card.effect==='truthtrance':kind==='karama'?card.effect==='karama':
card.kind==='worthless' && card.id!=='ix-kull-wahad');
assert.ok(index>=0); selected.push(game.deck.splice(index,1)[0]);
}
game.deck.unshift(...selected);
game=advance(game,state=>state.status==='playing');
const [kull,,karama,,counter,truth]=selected.map(card=>card.id);
for (const [id,card] of [['choam',kull],['emperor',karama],['harkonnen',counter],['harkonnen',truth]] as const)
assert.ok(player(game,id).hand.some(row=>row.id===card));
const safe=game.spiceDeck.findIndex(card=>'territory' in card && !card.discovery); assert.ok(safe>=0);
game.spiceDeck.unshift(game.spiceDeck.splice(safe,1)[0]);
game=advance(game,state=>state.phase===2 && clean(state) && state.choamCharity?.turn===state.turn);
game=act(game,'harkonnen',{type:'card',card:truth});
while (game.truthtrance?.stage==='priority') {
const seat=game.players.find(row=>!game.truthtrance!.passed.includes(row.id)); assert.ok(seat);
game=act(game,seat.id,{type:'truthPass'});
}
game=act(game,'harkonnen',{type:'truthAsk',question:{kind:'turnActions',target:'choam',turn:game.turn,
mixed:{kind:'action',action:{kind:'payment',recipient:'richese',minimum:1}}}});
assert.ok(viewGame(game,'choam').truthTurnActionAnswers?.includes('yes'));
game=act(game,'choam',{type:'truthAnswer',answer:'yes'});
const commitment=game.turnActionPromises?.at(-1); assert.ok(commitment);
game=advance(game,state=>player(state,'richese').hand.some(card=>card.id===SEMUTA_DRUG_ID) && clean(state));
let target:{territory:string;sector:number}|null=null;
for (let count=0;count<1000 && game.phase!==6;count++) {
if (game.phase===5 && clean(game) && game.active) {
const actor=game.active, seat=player(game,actor);
if ((actor==='emperor'||actor===owner) && !seat.shipped) {
if (!target) {
const territory=TERRITORIES.find(row=>row.type==='stronghold' && row.sectors.some(s=>s!==game.storm) &&
game.players.every(p=>!Object.keys(p.forces).some(key=>key.startsWith(`${row.id}:`))));
assert.ok(territory);
target={territory:territory.id,sector:territory.sectors.find(s=>s!==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);
game=act(game,next.actor,next.action);
}
assert.equal(game.phase,6); assert.ok(target);
for (let count=0;count<30 && !game.battle;count++) {
const view=game.active?viewGame(game,game.active):null;
const choice=view?.battleChoices?.find(row=>row.territory===target!.territory &&
(row.chooser??row.attacker)===game.active && [row.attacker,row.defender].includes(game.active!));
if (choice) {
game=act(game,game.active!,{type:'chooseBattle',territory:target.territory,
target:game.active==='emperor'?owner:'emperor'});
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);
game=act(game,owner,power==='voice'?{type:'voice',kind:'shield',must:false}:{type:'prescience',field:'dial'});
assert.equal(game.response?.kind,power);
const row=game.turnActionPromises?.find(promise=>promise.event===commitment.event); assert.ok(row);
assert.ok(!row.fulfilled && !row.released);
assert.deepEqual(inventory(game),originalCards);
return {game,kull,karama,counter,event:commitment.event,originalCards};
}
function passDiscard(game:Game):Game {
const pending=game.pendingTreacheryDiscard; assert.ok(pending?.reaction?.stage==='offer');
const event=pending.batch.event;
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 seat=game.players.find(row=>!reaction.passed.includes(row.id)); assert.ok(seat);
game=act(game,seat.id,{type:'semutaPass',event});
}
assert.notEqual(game.pendingTreacheryDiscard?.batch.event,event);
return game;
}
for (const power of ['voice','prescience'] as const) for (const countered of [false,true])
void test(`CHOAM's live whole answer survives Kull ${countered?'counter':'success'} against ${power} cancellation`,()=>{
const f=source(power); let game=f.game;
assert.ok(viewGame(game,'emperor').responseControls?.cancelCards.includes(f.karama));
game=act(game,'emperor',{type:'card',mode:'cancel',card:f.karama});
const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction);
assert.ok(reaction.plays.some(play=>play.source==='printed' && play.card.id===f.kull));
game=act(game,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull});
if (countered) game=act(game,'harkonnen',{type:'card',mode:'cancel',card:f.counter});
else game=advance(game,state=>!!state.pendingTreacheryDiscard);
while (game.pendingTreacheryDiscard) game=passDiscard(game);
const promise=game.turnActionPromises?.find(row=>row.event===f.event); assert.ok(promise);
assert.ok(!promise.released && !promise.fulfilled);
assert.equal(player(game,'emperor').hand.some(card=>card.id===f.karama),!countered);
if (!countered) assert.equal(game.response?.kind,power);
assert.deepEqual(inventory(game),f.originalCards);
});
+92
View File
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { TERRITORIES } from '../game/board';
import { act, advance, clean, createKullNativeIxFixture, inventory, player } from './fixture-kull-native-ix';
import { nextKullPurposeStep } from './fixture-kull-purpose';
/** Start from the native public five-seat setup before Spice Blow. The fixture
* selects original unopened cards but all questions, auctions, shipments and
* battle responses below are real actions. */
function answered(target:'ixians'|'emperor') {
const f=createKullNativeIxFixture('ixians');
let game=f.early;
game=act(game,'guild',{type:'card',card:f.truth});
while (game.truthtrance?.stage==='priority') {
const seat=game.players.find(row=>!game.truthtrance!.passed.includes(row.id)); assert.ok(seat);
game=act(game,seat.id,{type:'truthPass'});
}
const recipient=target==='ixians'?'emperor':'ixians';
game=act(game,'guild',{type:'truthAsk',question:{kind:'turnActions',target,turn:game.turn,
mixed:{kind:'action',action:{kind:'payment',recipient,minimum:1}}}});
assert.ok(viewGame(game,target).truthTurnActionAnswers?.includes('yes'));
game=act(game,target,{type:'truthAnswer',answer:'yes'});
const row=game.turnActionPromises?.at(-1); assert.ok(row);
return {...f,game,event:row.event};
}
function offer(game:Game,actor:string,card:string) {
assert.ok(viewGame(game,actor).responseControls?.cancelCards.includes(card));
game=act(game,actor,{type:'card',mode:'cancel',card});
const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction);
return {game,event:reaction.event};
}
for (const outcome of ['decline','success','counter'] as const)
void test(`whole payment answer survives real Guild income cancellation and Kull ${outcome}`,()=>{
const f=answered('ixians');
let game=advance(f.game,state=>state.phase===5 && state.active==='ixians' && clean(state));
game=act(game,'ixians',{type:'ship',territory:'polar_sink',sector:0,amount:1,elite:0,allyPayment:0});
assert.equal(game.response?.kind,'guildIncome');
const choice=offer(game,'ixians',f.karama); game=choice.game;
game=outcome==='decline'?act(game,'choam',{type:'kullDecision',event:choice.event,decline:true}):
act(game,'choam',{type:'kullDecision',event:choice.event,source:'printed',card:f.kull});
if (outcome==='counter') game=act(game,'emperor',{type:'card',mode:'cancel',card:f.counter});
if (outcome==='success') game=advance(game,state=>!state.pendingKull && state.response?.kind!=='choamWorthless');
assert.ok(game.turnActionPromises?.some(row=>row.event===f.event && !row.released));
assert.equal(player(game,'ixians').hand.some(card=>card.id===f.karama),outcome==='success');
assert.deepEqual(inventory(game),f.originalCards);
});
void test('whole payment answer survives real Basic Ixian cyborg-strength cancellation and Kull decline',()=>{
const f=answered('emperor'); let game=f.game;
let target:{territory:string;sector:number}|null=null;
for (let count=0;count<1200 && game.phase!==6;count++) {
if (game.phase===5 && clean(game) && game.active) {
const actor=game.active, seat=player(game,actor);
if ((actor==='emperor'||actor==='ixians') && !seat.shipped) {
if (!target) {
const territory=TERRITORIES.find(row=>row.type==='stronghold' && row.sectors.some(s=>s!==game.storm) &&
game.players.every(p=>!Object.keys(p.forces).some(key=>key.startsWith(`${row.id}:`))));
assert.ok(territory);
target={territory:territory.id,sector:territory.sectors.find(s=>s!==game.storm)!};
}
game=act(game,actor,{type:'ship',territory:target.territory,sector:target.sector,amount:1,
elite:actor==='ixians'?1:0,allyPayment:0});
} else game=act(game,actor,{type:'endMovement'});
continue;
}
const next=nextKullPurposeStep(game); assert.ok(next);
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,'ixians').elites?.forces[`${target.territory}:${target.sector}`],1);
for (let count=0;count<30 && !game.battle;count++) {
const view=game.active?viewGame(game,game.active):null;
const choice=view?.battleChoices?.find(row=>row.territory===target!.territory &&
(row.chooser??row.attacker)===game.active && [row.attacker,row.defender].includes(game.active!));
if (choice) {
game=act(game,game.active!,{type:'chooseBattle',territory:target.territory,
target:game.active==='emperor'?'ixians':'emperor'});
break;
}
const next=nextKullPurposeStep(game); assert.ok(next);
game=act(game,next.actor,next.action);
}
assert.equal(game.response?.kind,'eliteStrength');
const choice=offer(game,'emperor',f.counter); game=choice.game;
game=act(game,'choam',{type:'kullDecision',event:choice.event,decline:true});
assert.ok(game.turnActionPromises?.some(row=>row.event===f.event && !row.released));
assert.ok(!player(game,'emperor').hand.some(card=>card.id===f.counter));
assert.deepEqual(inventory(game),f.originalCards);
});
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { act, advance, clean, inventory, player } from './fixture-kull-native-ix';
/** Genuine public Basic setup and movement; only unopened card and Spice
* order is selected. The live whole answer and both overlapping response
* windows arise through original actions, not installed pending state. */
function source() {
let game=createGame('KULLBGGUILD',newPlayer('choam','CHOAM','choam'),false,['choam','ix']);
for (const id of ['emperor','beneGesserit','guild','harkonnen'] as const)
joinGame(game,newPlayer(id,id,id));
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,'choam',{type:'start'});
assert.equal(game.status,'setup'); assert.ok(game.kullPreview);
const originalCards=inventory(game), selected:Game['deck']=[];
for (const kind of ['kull','karama','filler','truth','karama','filler'] as const) {
const index=game.deck.findIndex(card=>kind==='kull'?card.id==='ix-kull-wahad':
kind==='truth'?card.effect==='truthtrance':kind==='karama'?card.effect==='karama':
card.kind==='worthless' && card.id!=='ix-kull-wahad');
assert.ok(index>=0); selected.push(game.deck.splice(index,1)[0]);
}
game.deck.unshift(...selected);
game=advance(game,state=>state.status==='playing');
const [,karama,,truth]=selected.map(card=>card.id);
assert.ok(player(game,'emperor').hand.some(card=>card.id===karama));
assert.ok(player(game,'guild').hand.some(card=>card.id===truth));
const safe=game.spiceDeck.findIndex(card=>'territory' in card && !card.discovery); assert.ok(safe>=0);
game.spiceDeck.unshift(game.spiceDeck.splice(safe,1)[0]);
game=advance(game,state=>state.phase===2 && clean(state) && state.choamCharity?.turn===state.turn);
game=act(game,'guild',{type:'card',card:truth});
while (game.truthtrance?.stage==='priority') {
const seat=game.players.find(row=>!game.truthtrance!.passed.includes(row.id)); assert.ok(seat);
game=act(game,seat.id,{type:'truthPass'});
}
game=act(game,'guild',{type:'truthAsk',question:{kind:'turnActions',target:'emperor',turn:game.turn,
mixed:{kind:'action',action:{kind:'payment',recipient:'harkonnen',minimum:1}}}});
assert.ok(viewGame(game,'emperor').truthTurnActionAnswers?.includes('yes'));
game=act(game,'emperor',{type:'truthAnswer',answer:'yes'});
const promise=game.turnActionPromises?.at(-1); assert.ok(promise);
game=advance(game,state=>state.phase===5 && state.active==='emperor' && clean(state));
game=act(game,'emperor',{type:'ship',territory:'polar_sink',sector:0,amount:1,elite:0,allyPayment:0});
assert.equal(game.response?.kind,'guildIncome');
assert.equal(game.decision?.kind,'advisor');
assert.ok(!promise.released);
return {game,karama,event:promise.event,originalCards};
}
void test('live whole-answer preflight fences Guild income cancellation without consuming a simultaneous BG arrival decision',()=>{
const f=source(); let game=f.game;
const before=structuredClone(game);
assert.ok(!viewGame(game,'emperor').responseControls?.cancelCards.includes(f.karama));
assert.throws(()=>act(game,'emperor',{type:'card',mode:'cancel',card:f.karama}));
assert.deepEqual(game,before);
assert.equal(game.pendingKull ?? null,null);
assert.deepEqual(game.decision,before.decision);
assert.ok(game.turnActionPromises?.some(row=>row.event===f.event && !row.released));
for (let count=0;game.response?.kind==='guildIncome' && count<game.players.length;count++) {
const seat=game.players.find(row=>!game.response!.passed.includes(row.id)); assert.ok(seat);
game=act(game,seat.id,{type:'passResponse'});
}
assert.equal(game.response,null);
assert.deepEqual(game.decision,before.decision);
game=act(game,'beneGesserit',{type:'decision',accept:false});
assert.equal(game.decision,null);
assert.deepEqual(inventory(game),f.originalCards);
});