Support Basic Richese Kull and Semuta preview
This commit is contained in:
1 parent
6d50829c0c
commit
a07c6d8e50
13 files changed
+480
-14
No files matched your search
@@ -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});
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user