Enable Basic Kull with Ecaz and Moritani

This commit is contained in:
Quintine committed 2026-10-27 20:13:04 +11:00
1 parent 69a9330810
commit 6d50829c0c
13 files changed
+503 -16

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { territory } from '../game/board';
import { actKullPurpose, kullPurposeClean, kullPurposePlayer, nextKullPurposeStep,
type KullPurposeStep } from './fixture-kull-purpose';
import { publicKullInventory } from './fixture-public-choam-kull';
export const act = actKullPurpose;
export const clean = kullPurposeClean;
export const player = kullPurposePlayer;
export const inventory = publicKullInventory;
export type E3NativeRoster = 'ecaz' | 'moritani' | 'both';
export type E3NativeTarget = 'ecaz' | 'moritani';
/** Make only ordinary native decisions, declining optional placements and
* effects unless the caller has stopped at the requested original control. */
function step(game: Game): KullPurposeStep | null {
if (!game.phaseOpening && !game.response && game.decision) {
const d = game.decision;
if (d.kind === 'moritaniSetup') return { actor: d.player,
action: { type: 'decision', territory: 'carthag', sector: territory('carthag').sectors[0] } };
if (d.kind === 'ecazPlacement' || d.kind === 'moritaniPlacement')
return { actor: d.player, action: { type: 'decision', decline: true } };
if (d.kind === 'ecazAmbassador') return { actor: d.player,
action: { type: 'decision', event: game.pendingAmbassador!.event, decline: true } };
}
return nextKullPurposeStep(game);
}
export function advance(game: Game, until: (state: Game) => boolean, actions: KullPurposeStep[] = []): Game {
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status,'finished','The native E3 Kull programme ended before the requested window');
const next = step(game); assert.ok(next,`A native continuation is required at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
game = act(game,next.actor,next.action,actions);
}
throw Error('The native E3 Kull programme did not reach its finite boundary');
}
/** Real public Basic start; only original unopened Treachery and Spice
* positions are chosen before native deal. No force, wallet, token or phase is
* staged. Every subsequent placement and cancellation uses native actions. */
export function createKullNativeE3Fixture(roster: E3NativeRoster, target: E3NativeTarget = roster === 'moritani' ? 'moritani' : 'ecaz') {
assert.ok(roster === 'both' || roster === target);
let game = createGame('KULLE3',newPlayer('choam','CHOAM','choam'),false,['choam','ix','ecaz']);
const natives = roster === 'both' ? ['ecaz','moritani'] as const : [roster] as const;
for (const faction of natives) joinGame(game,newPlayer(faction,faction,faction));
for (const faction of ['emperor','guild'] as const) joinGame(game,newPlayer(faction,faction,faction));
const actions: KullPurposeStep[] = [];
const send = (actor: string, action: Action) => { game = act(game,actor,action,actions); };
const reach = (predicate: (state: Game) => boolean) => { game = advance(game,predicate,actions); };
send('choam',{type:'moduleOption',module:'kullPreview',enabled:true});
for (const seat of game.players) send(seat.id,{type:'ready'});
const lobby = structuredClone(game);
send('choam',{type:'start'});
assert.equal(game.status,'setup'); assert.equal(game.kullPreview,true);
const setup = structuredClone(game), originalCards = game.deck.map(card => card.id).sort();
const kinds = roster === 'both' ? ['kull','truth','filler','karama','karama'] : ['kull','truth','karama','karama'];
const chosen: Game['deck'] = [];
for (const kind of kinds) {
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); chosen.push(game.deck.splice(index,1)[0]);
}
game.deck.unshift(...chosen);
const beforeDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), kull = 'ix-kull-wahad';
const karama = player(game,'emperor').hand.find(card => card.effect === 'karama')?.id;
const counter = player(game,'guild').hand.find(card => card.effect === 'karama')?.id;
const asker = roster === 'moritani' ? 'moritani' : 'ecaz';
const truth = player(game,asker).hand.find(card => card.effect === 'truthtrance')?.id;
assert.ok(karama && counter && truth && karama !== counter);
assert.ok(player(game,'choam').hand.some(card => card.id === kull));
assert.deepEqual(inventory(game),originalCards);
if (roster !== 'moritani') assert.ok(game.ecazAmbassadors && game.ecazOccupyPreview);
if (roster !== 'ecaz') assert.ok(game.moritaniTerror);
const spice = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
assert.ok(spice >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(spice,1)[0]);
reach(state => state.phase <= 1 && clean(state));
const early = structuredClone(game);
const phase = target === 'ecaz' ? 4 : 8;
const decision = target === 'ecaz' ? 'ecazPlacement' : 'moritaniPlacement';
reach(state => state.phase === phase && state.decision?.kind === decision && !state.response && !state.phaseOpening);
const beforePlacement = structuredClone(game);
const token = target === 'ecaz' ? game.ecazAmbassadors!.tokens.find(row => row.effect === 'ecaz' && row.zone === 'supply')
: game.moritaniTerror!.tokens.find(row => row.status === 'available');
assert.ok(token);
send(target,{type:'decision',token:token.id,territory:'carthag'});
assert.equal(game.response?.kind,decision);
assert.deepEqual(inventory(game),originalCards);
return { game, early, beforePlacement, lobby, setup, beforeDeal, afterSetup, actions, originalCards,
actor:'emperor', reactor:'choam', counterOwner:'guild', asker, target, token:token.id,
kull, karama, counter, truth };
}
export type KullNativeE3Fixture = ReturnType<typeof createKullNativeE3Fixture>;
+4 -1
View File
@@ -19,10 +19,13 @@ void test('Basic printed Kull supports the shared bribe family in its declared n
assert.equal(quote({...basic,kullPreview:false}),true);
assert.equal(quote({...basic,advanced:true,kullPreview:true}),false);
assert.equal(quote({...basic,expansions:['choam'],kullPreview:true}),false);
assert.equal(quote({...basic,expansions:['choam','ix','ecaz'],kullPreview:true}),false);
assert.equal(quote({...basic,expansions:['choam','ix','ecaz'],kullPreview:true}),true);
assert.equal(quote({...basic,players:[{faction:'emperor'},{faction:'guild'}],kullPreview:true}),false);
assert.equal(quote({...basic,players:[{faction:'choam'},{faction:'ixians'}],kullPreview:true,mobileStronghold:{}}),true);
assert.equal(quote({...basic,players:[{faction:'choam'},{faction:'tleilaxu'}],kullPreview:true}),true);
assert.equal(quote({...basic,players:[{faction:'choam'},{faction:'ecaz'}],expansions:['choam','ix','ecaz'],kullPreview:true}),true);
assert.equal(quote({...basic,players:[{faction:'choam'},{faction:'moritani'}],expansions:['choam','ix','ecaz'],kullPreview:true}),true);
assert.equal(quote({...basic,players:[{faction:'choam'},{faction:'ecaz'}],kullPreview:true}),false);
assert.equal(quote({...basic,players:[{faction:'choam'},{faction:'richese'}],kullPreview:true}),false);
assert.equal(quote({...basic,players:[{faction:'choam'},{faction:'emperor'},{faction:'emperor'}],kullPreview:true}),false);
for (const option of ['semutaPreview','nexusKullPreview','richeseBetrayalPreview','guildBetrayalPreview',
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { act, advance, clean, createKullBribeFixture, inventory, player } from './fixture-kull-bribe';
/** Build the question and both distinct Karama costs from the original
* unopened public setup. Emperor voluntarily retains either four or eight
* spice before answering; no hand, force, promise or phase is installed. */
function pendingCounter(retained: 4 | 8) {
const f = createKullBribeFixture();
let game = act(f.early, 'emperor', { type: 'bribe', target: 'guild', amount: 10 - retained });
assert.equal(player(game, 'emperor').spice, retained);
assert.ok(player(game, 'emperor').hand.some(card => card.id === f.karama));
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' });
}
assert.equal(game.truthtrance?.stage, 'ask');
game = act(game, 'guild', { type: 'truthAsk', question: {
kind: 'movement', target: 'emperor', territory: 'carthag', minimum: 8,
} });
assert.ok(viewGame(game, 'emperor').truthMovementAnswers?.includes('yes'));
game = act(game, 'emperor', { type: 'truthAnswer', answer: 'yes' });
const accepted = structuredClone(game.movementPromises);
game = advance(game, state => state.phase === 2 && state.response?.kind === 'choamCharity');
// Harkonnen attempts the original cancellation with its own physical
// Karama; CHOAM declares its unopened-deal Kull, and Emperor may counter
// only with its different physical card.
game = act(game, 'harkonnen', { type: 'card', mode: 'cancel', card: f.counter });
assert.equal(game.pendingKull?.stage, 'offer');
assert.deepEqual(game.movementPromises, accepted);
const reaction = viewGame(game, 'choam').kullReaction;
assert.ok(reaction);
assert.ok(reaction.plays.some(row => row.card.id === f.kull && !row.blocked));
game = act(game, 'choam', { type: 'kullDecision', event: reaction.event, source: 'printed', card: f.kull });
// If no responder can legally counter, native continuation immediately
// settles Kull instead of exposing an empty counter window.
if (retained === 8) assert.equal(game.pendingKull?.stage, 'counter');
assert.deepEqual(game.movementPromises, accepted);
return { f, game, accepted };
}
function finishMovement(game: Game) {
game = advance(game, state => state.phase === 5 && state.active === 'emperor' && clean(state));
// Preserve the required Karama rate when eight forces cannot be shipped for
// the four remaining spice without it; ordinary cash payment works with eight.
if (player(game, 'emperor').spice === 4) {
const card = player(game, 'emperor').hand.find(row => row.effect === 'karama');
assert.ok(card);
game = advance(act(game, 'emperor', { type: 'card', mode: 'shipment', card: card.id, target: 'emperor' }), clean);
}
game = advance(act(game, 'emperor', { type: 'ship', territory: 'arrakeen', sector: 10,
amount: 8, elite: 0, allyPayment: 0 }), clean);
game = advance(act(game, 'emperor', { type: 'move', from: 'arrakeen:10', territory: 'carthag',
sector: 11, amount: 8, elite: 0 }), clean);
assert.equal(game.movementPromises?.[0].fulfilled, true);
return game;
}
void test('a distinct Kull counter cannot spend the only Karama needed by its own movement Yes', () => {
const { f, game, accepted } = pendingCounter(4);
assert.equal(game.pendingKull, null,
'With no legal counter the Kull response settles automatically');
assert.deepEqual(game.movementPromises, accepted);
const settled = advance(game, clean);
assert.equal(settled.pendingKull, null);
assert.equal(settled.movementPromises?.[0].released, undefined);
assert.ok(player(settled, 'emperor').hand.some(card => card.id === f.karama));
assert.deepEqual(inventory(finishMovement(settled)), f.originalCards);
});
void test('the distinct counter remains legal when cash independently funds the promised movement', () => {
const { f, game, accepted } = pendingCounter(8);
assert.ok(viewGame(game, 'emperor').responseControls?.cancelCards.includes(f.karama));
let settled = act(game, 'emperor', { type: 'card', mode: 'cancel', card: f.karama });
settled = advance(settled, clean);
assert.equal(settled.pendingKull, null);
assert.deepEqual(settled.movementPromises, accepted);
assert.equal(settled.discard.filter(card => card.id === f.karama).length, 1);
assert.deepEqual(inventory(finishMovement(settled)), f.originalCards);
});
@@ -0,0 +1,89 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { act, advance, clean, createKullBribeFixture, inventory, player } from './fixture-kull-bribe';
/** Real public Basic CHOAM/Emperor/Guild/Harkonnen game, selected unopened
* cards and Spice position, and a genuine ordinary bribe. Harkonnen starts
* with ten forces in Carthag, ten reserves, ten Spice and a physical Karama.
* Paying seven leaves only the Karama-discounted five-force city shipment
* available to complete a fifteen-force move in this phase. */
function movementSource() {
const f = createKullBribeFixture();
let game = act(f.game, 'harkonnen', { type: 'bribe', target: 'guild', amount: 7 });
assert.equal(player(game, 'harkonnen').spice, 3);
game = act(game, f.asker, { type: 'card', card: f.truth });
while (game.truthtrance?.stage === 'priority') {
const passer = game.players.find(p => !game.truthtrance!.passed.includes(p.id));
assert.ok(passer);
game = act(game, passer.id, { type: 'truthPass' });
}
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'movement', target: 'harkonnen', territory: 'imperial_basin', minimum: 15,
} });
assert.deepEqual(viewGame(game, 'harkonnen').truthMovementAnswers, ['yes', 'no']);
game = act(game, 'harkonnen', { type: 'truthAnswer', answer: 'yes' });
assert.equal(game.movementPromises?.[0].answer, true);
game = advance(game, g => g.phase === 5 && g.active === 'harkonnen' && clean(g));
assert.ok(player(game, 'harkonnen').hand.some(card => card.id === f.counter));
assert.deepEqual(inventory(game), f.originalCards);
return { f, game };
}
function offer(f: ReturnType<typeof createKullBribeFixture>, game: Game): Game {
game = act(game, 'harkonnen', { type: 'card', mode: 'shipment', card: f.counter, target: 'harkonnen' });
assert.equal(game.pendingKull?.stage, 'offer');
assert.ok(player(game, 'harkonnen').hand.some(card => card.id === f.counter));
assert.equal(game.movementPromises?.[0].released, undefined);
return game;
}
function declare(f: ReturnType<typeof createKullBribeFixture>, game: Game): Game {
const reaction = viewGame(game, 'choam').kullReaction; assert.ok(reaction);
return act(game, 'choam', { type: 'kullDecision', event: reaction.event, source: 'printed', card: f.kull });
}
function finishMove(f: ReturnType<typeof createKullBribeFixture>, game: Game): Game {
game = act(game, 'harkonnen', { type: 'ship', territory: 'carthag', sector: 11, amount: 5, elite: 0, allyPayment: 0 });
game = advance(game, clean);
assert.equal(player(game, 'harkonnen').spice, 0);
assert.equal(player(game, 'harkonnen').forces['carthag:11'], 15);
game = act(game, 'harkonnen', { type: 'move', from: 'carthag:11', territory: 'imperial_basin', sector: 9, amount: 15, elite: 0 });
assert.equal(game.movementPromises?.[0].fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
return game;
}
void test('a Kull preview offers the legal movement Yes using a future Karama and actual decline', () => {
const { f, game } = movementSource();
const pending = offer(f, game);
const reaction = viewGame(pending, 'choam').kullReaction; assert.ok(reaction);
const declined = act(pending, 'choam', { type: 'kullDecision', event: reaction.event, decline: true });
assert.equal(declined.karamaShipping?.owner, 'harkonnen');
assert.equal(declined.movementPromises?.[0].released, undefined);
finishMove(f, declined);
});
void test('a distinct Karama counter preserves the original movement route and card custody', () => {
const { f, game } = movementSource();
const declared = declare(f, offer(f, game));
assert.ok(viewGame(declared, 'emperor').responseControls?.cancelCards?.includes(f.karama));
const countered = act(declared, 'emperor', { type: 'card', mode: 'cancel', card: f.karama });
const resumed = advance(countered, clean);
assert.equal(resumed.karamaShipping?.owner, 'harkonnen');
assert.equal(resumed.movementPromises?.[0].released, undefined);
assert.equal(resumed.discard.filter(card => card.id === f.karama).length, 1);
assert.ok(player(resumed, 'choam').hand.some(card => card.id === f.kull));
finishMove(f, resumed);
});
void test('successful Kull preserves the original Karama and releases an impossible movement promise', () => {
const { f, game } = movementSource();
const declared = declare(f, offer(f, game));
const blocked = advance(declared, clean);
assert.equal(blocked.karamaShipping, null);
assert.ok(player(blocked, 'harkonnen').hand.some(card => card.id === f.counter));
assert.equal(blocked.discard.filter(card => card.id === f.kull).length, 1);
assert.equal(blocked.movementPromises?.[0].released, true);
assert.deepEqual(inventory(blocked), f.originalCards);
});
+116
View File
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { bribePromiseModeSupported } from '../game/bribe-promises';
import { act, advance, inventory, player, createKullNativeE3Fixture,
type E3NativeRoster, type E3NativeTarget, type KullNativeE3Fixture } from './fixture-kull-native-e3';
const held = (g: Game, playerId: string, card: string) => player(g,playerId).hand.some(row => row.id === card);
const finished = (g: Game) => !g.pendingKull && !g.response && !g.phaseOpening &&
!g.pendingEcazPlacement && !g.pendingMoritaniPlacement;
function offer(f: KullNativeE3Fixture, state: Game): Game {
const cards = viewGame(state,f.actor).responseControls?.cancelCards ?? [];
assert.ok(cards.includes(f.karama),'The original Emperor holds a usable physical Karama');
const game = act(state,f.actor,{type:'card',mode:'cancel',card:f.karama});
assert.equal(game.pendingKull?.stage,'offer');
const reaction = viewGame(game,f.reactor).kullReaction; assert.ok(reaction);
assert.ok(reaction.plays.some(row => row.card.id === f.kull && !row.blocked));
assert.ok(held(game,f.actor,f.karama));
return game;
}
function declaration(f: KullNativeE3Fixture, state: Game): Game {
const reaction = viewGame(state,f.reactor).kullReaction; assert.ok(reaction);
return act(state,f.reactor,{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull});
}
function placementResult(f: KullNativeE3Fixture, state: Game, placed: boolean) {
if (f.target === 'ecaz') {
const token = state.ecazAmbassadors?.tokens.find(row => row.id === f.token); assert.ok(token);
assert.equal(token.zone,placed ? 'placed' : 'supply');
assert.equal(token.location,placed ? 'carthag' : null);
} else {
const token = state.moritaniTerror?.tokens.find(row => row.id === f.token); assert.ok(token);
assert.equal(token.status,placed ? 'placed' : 'available');
assert.equal(token.location,placed ? 'carthag' : null);
}
}
for (const [roster,target] of [['ecaz','ecaz'],['moritani','moritani'],
['both','ecaz'],['both','moritani']] as const satisfies readonly (readonly [E3NativeRoster,E3NativeTarget])[]) {
void test(`Basic ${roster} Kull preserves real ${target} placement through each counter outcome`, () => {
const f = createKullNativeE3Fixture(roster,target);
assert.ok(f.actions.some(row => row.actor === f.lobby.host && row.action.type === 'start'));
assert.equal(bribePromiseModeSupported(f.game),true);
const pending = offer(f,f.game), reaction = viewGame(pending,f.reactor).kullReaction!;
const decline = advance(act(pending,f.reactor,{type:'kullDecision',event:reaction.event,decline:true}),finished);
placementResult(f,decline,false);
assert.ok(held(decline,f.reactor,f.kull));
assert.equal(decline.discard.filter(row => row.id === f.karama).length,1);
assert.deepEqual(inventory(decline),f.originalCards);
const declared = declaration(f,pending);
const success = advance(declared,finished);
placementResult(f,success,true);
assert.ok(held(success,f.actor,f.karama));
assert.equal(success.discard.filter(row => row.id === f.kull).length,1);
assert.deepEqual(inventory(success),f.originalCards);
const cards = viewGame(declared,f.counterOwner).responseControls?.cancelCards ?? [];
assert.ok(cards.includes(f.counter),'Guild holds a distinct counter to Kull');
const countered = advance(act(declared,f.counterOwner,{type:'card',mode:'cancel',card:f.counter}),finished);
placementResult(f,countered,false);
assert.ok(held(countered,f.reactor,f.kull));
assert.equal(countered.discard.filter(row => row.id === f.counter).length,1);
assert.equal(countered.discard.filter(row => row.id === f.karama).length,1);
assert.deepEqual(inventory(countered),f.originalCards);
});
}
void test('the four minimal CHOAM bot profiles can take a native Ecaz Kull reaction', () => {
const f = createKullNativeE3Fixture('ecaz'), pending = offer(f,f.game);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(pending,f.reactor);
view.players.find(row => row.id === f.reactor)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action,difficulty);
assert.deepEqual(action,{type:'kullDecision',event:view.kullReaction!.event,source:'printed',card:f.kull});
assert.equal(act(pending,f.reactor,action).pendingKull?.stage,'counter');
}
});
void test('a native Ecaz Charity-funded bribe survives Kull decline, block and counter', () => {
const f = createKullNativeE3Fixture('ecaz');
const purse = player(f.early,f.actor).spice;
assert.ok(purse > 0);
let game = act(f.early,f.actor,{type:'bribe',target:f.counterOwner,amount:purse});
assert.equal(player(game,f.actor).spice,0);
game = act(game,f.asker,{type:'card',card:f.truth});
while (game.truthtrance?.stage === 'priority') {
const passer = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(passer);
game = act(game,passer.id,{type:'truthPass'});
}
game = act(game,f.asker,{type:'truthAsk',question:{kind:'bribe',target:f.actor,
turn:game.turn,recipient:f.counterOwner,minimum:1}});
assert.ok(viewGame(game,f.actor).truthBribeAnswers?.includes('yes'));
game = act(game,f.actor,{type:'truthAnswer',answer:'yes'});
const promise = structuredClone(game.bribePromises);
game = advance(game,state => state.phase === 2 && state.response?.kind === 'choamCharity');
const pending = offer(f,game), reaction = viewGame(pending,f.reactor).kullReaction!;
assert.equal(pending.pendingKull?.resume.response?.kind,'choamCharity');
assert.equal(viewGame(pending,f.actor).bribePromiseGuide,null);
const declined = act(pending,f.reactor,{type:'kullDecision',event:reaction.event,decline:true});
const declared = declaration(f,pending);
const countered = act(declared,f.counterOwner,{type:'card',mode:'cancel',card:f.counter});
for (const branch of [declined,declared,countered]) {
const ready = advance(branch,state => !state.pendingKull && !state.response && !state.phaseOpening && !state.decision);
assert.deepEqual(ready.bribePromises,promise);
const guide = viewGame(ready,f.actor).bribePromiseGuide; assert.ok(guide);
assert.equal(guide.action.type,'charity');
const charity = act(ready,f.actor,guide.action);
assert.equal(player(charity,f.actor).charityTurn,charity.turn);
const paid = act(charity,f.actor,{type:'bribe',target:f.counterOwner,amount:1});
assert.equal(player(paid,f.counterOwner).bribes,purse+1);
assert.ok(paid.bribePromises?.at(-1)?.fulfilled);
assert.deepEqual(inventory(paid),f.originalCards);
}
});