Support Basic Richese Kull and Semuta preview

This commit is contained in:
Quintine committed 2026-10-27 20:23:57 +11:00
1 parent 6d50829c0c
commit a07c6d8e50
13 files changed
+480 -14

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { actKullPurpose, kullPurposeClean, kullPurposePlayer, nextKullPurposeStep,
type KullPurposeStep } from './fixture-kull-purpose';
export const act = actKullPurpose;
export const player = kullPurposePlayer;
export const clean = kullPurposeClean;
/** Includes both decks and all visible physical card zones used here. */
export function inventory(game: Game): string[] {
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
...(game.richeseRemoved ?? []), ...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index) ?? [])].map(card => card.id).sort();
assert.equal(new Set(cards).size,cards.length,'Each original Treachery card occupies one physical zone');
return cards;
}
/** Native choices only. The original Richese cache auction is deliberately
* Silent: Richese pays one, every other eligible player submits zero. */
function next(game: Game): KullPurposeStep | null {
const pending = game.pendingTreacheryDiscard, reaction = pending?.reaction;
if (pending && reaction?.stage === 'offer') {
const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor);
return {actor:actor.id,action:{type:'semutaPass',event:pending.batch.event}};
}
if (!game.phaseOpening && !game.response && game.decision?.kind === 'richeseCache')
return {actor:game.decision.player,action:{type:'decision',event:game.richeseBidding!.event,
card:SEMUTA_DRUG_ID,method:'silent'}};
const lot = game.richeseAuction;
if (lot && !lot.outcome && !game.phaseOpening && !game.response && !game.decision) {
assert.equal(lot.method,'silent');
const actor = lot.eligible.find(id => !Object.hasOwn(lot.sealed,id)); assert.ok(actor);
return {actor,action:{type:'richeseBid',event:lot.event,amount:actor === 'richese' ? 1 : 0,allyPayment:0}};
}
return nextKullPurposeStep(game);
}
export function advance(game: Game, until: (state: Game) => boolean, actions: KullPurposeStep[] = []): Game {
for (let count=0;count<1800;count++) {
if (until(game)) return game;
assert.notEqual(game.status,'finished','Original Kull/Semuta programme ended before its requested window');
const step=next(game); assert.ok(step,'Resolve the current native choice before continuing');
game=act(game,step.actor,step.action,actions);
}
throw Error('Original Kull/Semuta programme did not reach the requested window');
}
/** Public Basic opt-in. Original card order and a safe unopened Spice position
* are selected before use; Richese actually buys Semuta in its native auction.
* No played hand, spice, board force, pending event or phase is installed. */
export function createKullRicheseSemutaFixture() {
let game=createGame('KULLSEMUTA',newPlayer('choam','CHOAM','choam'),false,['choam','ix']);
for (const id of ['richese','emperor','harkonnen'] as const) joinGame(game,newPlayer(id,id,id));
const actions:KullPurposeStep[]=[];
const send=(actor:string,action:Action)=>{game=act(game,actor,action,actions);};
const reach=(until:(state:Game)=>boolean)=>{game=advance(game,until,actions);};
send('choam',{type:'moduleOption',module:'kullPreview',enabled:true});
for (const p of game.players) send(p.id,{type:'ready'});
const lobby=structuredClone(game);
send('choam',{type:'start'});
assert.equal(game.status,'setup');
assert.equal(game.kullPreview,true);
assert.equal(game.semutaPreview,true,'Native Richese must retain its Semuta discard opportunity');
assert.ok(game.players.every(p=>p.hand.length===0));
const originalCards=inventory(game), selected:Game['deck']=[];
for (const kind of ['kull','filler','karama','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,`Missing unopened ${kind}`); selected.push(game.deck.splice(index,1)[0]);
}
game.deck.unshift(...selected);
reach(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));
assert.ok(game.richeseCache?.some(card=>card.id===SEMUTA_DRUG_ID));
assert.deepEqual(inventory(game),originalCards);
const index=game.spiceDeck.findIndex(card=>'territory' in card && !card.discovery);
assert.ok(index>=0); game.spiceDeck.unshift(game.spiceDeck.splice(index,1)[0]);
reach(state=>player(state,'richese').hand.some(card=>card.id===SEMUTA_DRUG_ID) && clean(state));
const acquired=structuredClone(game);
assert.ok(actions.some(step=>step.actor==='richese' && step.action.type==='richeseBid' && step.action.amount===1));
reach(state=>state.phase===5 && state.active==='emperor' && clean(state));
assert.deepEqual(inventory(game),originalCards);
return {game,lobby,acquired,actions,originalCards,kull,karama,counter,semuta:SEMUTA_DRUG_ID,
actor:'emperor',reactor:'choam',counterOwner:'harkonnen',holder:'richese'};
}
export type KullRicheseSemutaFixture=ReturnType<typeof createKullRicheseSemutaFixture>;
export const attempt=(f:KullRicheseSemutaFixture):Action=>
({type:'card',mode:'shipment',card:f.karama,target:f.actor});
+7
View File
@@ -87,6 +87,13 @@ export function nextSemutaNexusCunningStep(game: Game): SemutaNexusCunningStep {
if (!game.phaseOpening && !game.response &&
(game.decision?.kind === 'choamMarket' || game.decision?.kind === 'choamMentat'))
return { actor: game.decision.player, action: { type: 'decision', done: true } };
// Fresh Advanced games use the actual ordered Charity and Revival queues;
// the older generic native fixture assumed simultaneous ready actions.
if (game.status === 'playing' && !game.phaseOpening && !game.response && !game.decision) {
const actor = game.phase === 2 ? game.charityOrder?.remaining[0]
: game.phase === 4 ? game.revivalOrder?.remaining[0] : undefined;
if (actor) return { actor, action: { type: 'ready' } };
}
return nextRicheseSkillsTechNativeStep(game);
}
export function advanceSemutaNexusCunning(
+148
View File
@@ -0,0 +1,148 @@
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 { act, advance, attempt, clean, createKullRicheseSemutaFixture, inventory, player,
type KullRicheseSemutaFixture } from './fixture-kull-richese-semuta';
const held=(game:Game,id:string,card:string)=>player(game,id).hand.some(row=>row.id===card);
function pendingKull(f:KullRicheseSemutaFixture) {
const pending=act(f.game,f.actor,attempt(f));
assert.equal(pending.pendingKull?.stage,'offer');
assert.ok(held(pending,f.actor,f.karama));
const reaction=viewGame(pending,f.reactor).kullReaction; assert.ok(reaction);
assert.ok(reaction.plays.some(row=>row.card.id===f.kull && !row.blocked));
return pending;
}
function declare(f:KullRicheseSemutaFixture,game:Game) {
const reaction=viewGame(game,f.reactor).kullReaction; assert.ok(reaction);
const declared=act(game,f.reactor,{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull});
assert.equal(declared.pendingKull?.stage,'counter');
return declared;
}
function fresh(game:Game,card:string,discardedBy:string) {
const pending=game.pendingTreacheryDiscard; assert.ok(pending,'A fresh public discard must offer Semuta');
assert.equal(pending.reaction?.stage,'offer');
assert.deepEqual(pending.batch.entries.map(entry=>({id:entry.card.id,discardedBy:entry.discardedBy,publicFace:entry.publicFace})),
[{id:card,discardedBy,publicFace:true}]);
assert.equal(game.discard.filter(row=>row.id===card).length,1);
return pending.batch.event;
}
function react(game:Game,f:KullRicheseSemutaFixture,claim:boolean) {
const pending=game.pendingTreacheryDiscard; assert.ok(pending);
const event=pending.batch.event,card=pending.batch.entries[0].card.id;
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});
} 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,'Retire exactly the selected fresh batch');
return game;
}
function assertSemutaResult(game:Game,f:KullRicheseSemutaFixture,card:string,claim:boolean) {
assert.equal(held(game,f.holder,card),claim);
assert.equal(held(game,f.holder,f.semuta),!claim);
assert.equal(game.discard.filter(row=>row.id===card).length,Number(!claim));
assert.equal(game.discard.filter(row=>row.id===f.semuta).length,Number(claim));
assert.deepEqual(inventory(game),f.originalCards);
}
for (const claim of [false,true]) void test(`Basic native Richese may ${claim?'claim':'pass'} the successfully played Kull`,()=>{
const f=createKullRicheseSemutaFixture();
const declared=declare(f,pendingKull(f));
const pending=advance(declared,state=>!!state.pendingTreacheryDiscard);
const event=fresh(pending,f.kull,f.reactor);
assert.ok(held(pending,f.actor,f.karama),'The prevented original is still held');
assert.equal(pending.karamaShipping,null);
const before=structuredClone(pending);
assert.throws(()=>act(pending,f.actor,attempt(f)));
assert.deepEqual(pending,before);
let game=react(pending,f,claim);
game=advance(game,clean);
assert.equal(game.pendingKull,null);
assert.equal(game.karamaShipping,null);
assert.ok(held(game,f.actor,f.karama));
assert.ok(viewGame(game,f.actor).karamaBlocked);
assertSemutaResult(game,f,f.kull,claim);
assert.throws(()=>act(game,f.holder,{type:'semutaCommit',event}));
});
for (const claim of [false,true]) void test(`Basic native Richese may ${claim?'claim':'pass'} the distinct Kull-counter Karama`,()=>{
const f=createKullRicheseSemutaFixture();
const declared=declare(f,pendingKull(f));
assert.ok(viewGame(declared,f.counterOwner).responseControls?.cancelCards.includes(f.counter));
const pending=act(declared,f.counterOwner,{type:'card',mode:'cancel',card:f.counter});
const event=fresh(pending,f.counter,f.counterOwner);
assert.ok(held(pending,f.actor,f.karama),'The original Karama remains reserved until the counter completes');
assert.ok(held(pending,f.reactor,f.kull),'The canceled Kull remains held');
assert.equal(pending.karamaShipping,null);
let game=react(pending,f,claim);
game=advance(game,clean);
assert.equal(game.pendingKull,null);
assert.equal(game.karamaShipping?.owner,f.actor);
assert.ok(held(game,f.reactor,f.kull));
assert.equal(game.discard.filter(row=>row.id===f.karama).length,1);
assertSemutaResult(game,f,f.counter,claim);
assert.throws(()=>act(game,f.holder,{type:'semutaCommit',event}));
});
void test('all minimal bot profiles choose a legal Semuta reaction to Kull',()=>{
const f=createKullRicheseSemutaFixture();
const pending=advance(declare(f,pendingKull(f)),state=>!!state.pendingTreacheryDiscard);
fresh(pending,f.kull,f.reactor);
for (const difficulty of DIFFICULTIES) {
const view=viewGame(pending,f.holder);
view.players.find(row=>row.id===f.holder)!.bot=difficulty;
const action=botActions(view)[0]; assert.ok(action,difficulty);
assert.ok(['semutaPass','semutaCommit'].includes(action.type),difficulty);
let game=act(pending,f.holder,action);
if (game.pendingTreacheryDiscard?.reaction?.stage==='select')
game=act(game,f.holder,{type:'semutaSelect',event:pending.pendingTreacheryDiscard!.batch.event,card:f.kull});
game=advance(game,clean);
assert.equal(game.pendingKull,null);
assert.equal(game.karamaShipping,null);
assert.ok(held(game,f.actor,f.karama));
assert.deepEqual(inventory(game),f.originalCards);
}
});
for (const holdsSemuta of [false,true]) void test(`a nested Ornithopter parent with ${holdsSemuta?'held':'unheld'} Semuta hides Kull and preserves decline`,()=>{
const f=createKullRicheseSemutaFixture();
const source=structuredClone(f.game);
// Controlled parent supplement: move the original physical Ornithopter from
// Richese's cache into the active player's legal unfinished flight slot.
// The exact phase, actor, counters and other physical cards are native.
const index=source.richeseCache!.findIndex(card=>card.name==='Ornithopter');
assert.ok(index>=0);
const card=source.richeseCache!.splice(index,1)[0];
source.ornithopter={event:'controlled-kull-semuta-flight',player:f.actor,card,turn:source.turn,
mode:'range3',startingMove:player(source,f.actor).moved,completed:0};
if (!holdsSemuta) {
// A conserved controlled transfer gives the same public parent without a
// Semuta holder; the eligibility gate must never inspect that hidden fact.
const hand=player(source,f.holder).hand;
const semuta=hand.findIndex(row=>row.id===f.semuta); assert.ok(semuta>=0);
source.richeseCache!.push(hand.splice(semuta,1)[0]);
}
const pending=act(source,f.actor,attempt(f));
assert.equal(pending.pendingKull?.stage,'offer');
const reaction=viewGame(pending,f.reactor).kullReaction; assert.ok(reaction);
assert.equal(reaction.canDecline,true);
assert.deepEqual(reaction.plays,[],'Unsupported parent blocks the public Kull play independent of hidden Semuta ownership');
assert.ok(held(pending,f.reactor,f.kull));
const declined=act(pending,f.reactor,{type:'kullDecision',event:reaction.event,decline:true});
const resumed=advance(declined,clean);
assert.equal(resumed.pendingKull,null);
assert.equal(resumed.karamaShipping?.owner,f.actor);
assert.equal(resumed.discard.filter(row=>row.id===f.karama).length,1);
assert.deepEqual(resumed.ornithopter,source.ornithopter);
assert.ok(held(resumed,f.reactor,f.kull));
});
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { act, advance, clean, createKullRicheseSemutaFixture, inventory, player } from './fixture-kull-richese-semuta';
void test('Semuta may recover Kull before a suspended CHOAM Worthless effect resumes', () => {
const f = createKullRicheseSemutaFixture();
let game = structuredClone(f.acquired);
// Controlled physical supplement: transfer the original unopened La La La
// from the deck to CHOAM, without creating a card or staging a response.
const index = game.deck.findIndex(card => card.name === 'La La La');
assert.ok(index >= 0, 'The original La La La is still in the deck');
const card = game.deck.splice(index, 1)[0];
player(game, f.reactor).hand.push(card);
assert.deepEqual(inventory(game), f.originalCards);
game = advance(game, state => state.phase === 4 && clean(state));
game = act(game, f.reactor, { type: 'card', mode: 'choam', card: card.id, target: f.actor });
assert.equal(game.pendingChoamWorthless?.effect, 'laLaLa');
assert.equal(game.response?.kind, 'choamWorthless');
game = act(game, 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));
game = act(game, f.reactor, { type: 'kullDecision', event: reaction.event, source: 'printed', card: f.kull });
game = advance(game, state => !!state.pendingTreacheryDiscard);
const pending = game.pendingTreacheryDiscard;
assert.ok(pending);
assert.equal(pending.continuation.kind, 'kullSuccessDiscard');
assert.equal(game.pendingChoamWorthless?.effect, 'laLaLa');
assert.ok(player(game, f.actor).hand.some(held => held.id === f.karama));
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
game = act(game, f.holder, { type: 'semutaCommit', event: pending.batch.event });
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = act(game, f.holder, { type: 'semutaSelect', event: pending.batch.event, card: f.kull });
game = advance(game, clean);
assert.equal(game.pendingKull, null);
assert.ok(player(game, f.holder).hand.some(held => held.id === f.kull));
assert.ok(player(game, f.actor).hand.some(held => held.id === f.karama));
assert.ok(game.revivalRules?.freeBlocked?.includes(f.actor));
assert.deepEqual(inventory(game), f.originalCards);
});
@@ -32,6 +32,7 @@ void test('public host lobby shows the explicitly selected Kull preview and its
assert.match(html,/Kull Wahad · Basic development preview/);
assert.match(html,/does not support structured Truthtrance questions about bids/);
assert.match(html,/defers unfunded non-CHOAM Karama-dependent bids/);
assert.match(html,/When Richese is seated, structured bribe commitments are also unavailable/);
const off = act(f.lobby,f.lobby.host,{type:'moduleOption',module:'kullPreview',enabled:false});
const unselected = viewGame(off,off.host);
assert.equal(unselected.lobbyModules?.kullPreview,false);
+1 -2
View File
@@ -11,7 +11,7 @@ const held = (g: Game,id: string,card: string) => player(g,id).hand.some(c => c.
function reject(g: Game,id: string,action: Action) {
const before = structuredClone(g); assert.throws(() => act(g,id,action)); assert.deepEqual(g,before);
}
function createLobby(advanced = false, expansions: string[] = ['choam','ix'], last: 'guild'|'richese' = 'guild') {
function createLobby(advanced = false, expansions: string[] = ['choam','ix'], last: 'guild' = 'guild') {
const game = createGame('KULLLOBBY',newPlayer('choam','CHOAM','choam'),advanced,expansions);
joinGame(game,newPlayer('emperor','Emperor','emperor'));
joinGame(game,newPlayer(last,last,last));
@@ -52,7 +52,6 @@ void test('incompatible opt-in starts reject without modifying their original lo
for (const config of [
{advanced:true,expansions:['choam','ix'],last:'guild' as const,module:false},
{advanced:false,expansions:['choam'],last:'guild' as const,module:false},
{advanced:false,expansions:['choam','ix'],last:'richese' as const,module:false},
{advanced:false,expansions:['choam','ix'],last:'guild' as const,module:true},
]) {
let g = createLobby(config.advanced,config.expansions,config.last);