Extend Advanced Semuta to CHOAM and Residual Poison
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 04:22:56 +11:00
1 parent b2bc3f2f01
commit 3a27c29a03
8 files changed
+302 -10

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import { basicSemutaAmbassadorInventory as inventory, basicSemutaAmbassadorPlayer as seat } from './fixture-basic-semuta-ambassador';
import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share';
type Kind = 'sale' | 'kulon' | 'laLaLa' | 'baliset' | 'gamont' | 'jubba';
const names: Record<Kind, string> = {sale:'Baliset',kulon:'Kulon',laLaLa:'La La La',
baliset:'Baliset',gamont:'Trip to Gamont',jubba:'Jubba Cloak'};
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
/** Native public Advanced setup; the relevant physical cards and later
* phase/board position are explicitly staged. The power, cancellation queue,
* payment, Semuta and continuation are all original actions. */
function fixture(kind: Kind) {
let game = createGame('ADVSEMUTACHOAM', newPlayer('c','CHOAM','choam'), true, ['choam']);
for (const faction of ['richese','atreides'] as const)
joinGame(game,newPlayer(faction,faction,faction));
for (const p of game.players) game = act(game,p.id,{type:'ready'});
game = act(game,'c',{type:'start',advancedPreview:true});
assert.equal(game.status,'setup'); assert.equal(game.semutaAdvancedOrdinaryPreview,true);
for (let n=0; game.status==='setup' && n<160; n++) {
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
game = act(game,next.actor,next.action);
}
assert.equal(game.status,'playing');
const cards = inventory(game);
const card = [game.deck,game.discard,game.richeseCache!,...game.players.map(p=>p.hand)]
.flat().find(item=>item.name===names[kind]); assert.ok(card);
moveSemutaEmperorCard(game,SEMUTA_DRUG_ID,'richese');
moveSemutaEmperorCard(game,card.id,'c');
assert.deepEqual(inventory(game),cards);
if (kind==='sale') {
for (let n=0;game.decision?.kind!=='choamMarket' && n<160;n++) {
const next=nextRicheseStrongholdsNativeStep(game); assert.ok(next);
game=act(game,next.actor,next.action);
}
assert.equal(game.decision?.kind,'choamMarket');
return {game, card:card.id, cards, action:{type:'decision',mode:'sell',card:card.id} as Action};
}
for (const p of game.players) {
p.reserves += Object.values(p.forces).reduce((sum,n)=>sum+n,0);
p.forces={};
}
seat(game,'c').reserves--;seat(game,'c').forces['red_chasm:7']=1;
seat(game,'atreides').reserves--;seat(game,'atreides').forces['imperial_basin:10']=1;
Object.assign(game,{turn:2,phase:kind==='laLaLa'?4:kind==='gamont'?8:kind==='jubba'?0:5,
storm:6,active:'c',ready:[],response:null,decision:null,phaseOpening:null,
choamMarket:null,movementRemaining:['c','richese','atreides']});
for (const p of game.players) {p.shipped=false;p.moved=0;}
if (kind==='jubba') {
game.stormPending=1;
game.stormResolution={from:6,distance:1,traversed:0,pending:[]};
game.decision={kind:'choamStorm',player:'c',
territories:[{territory:'red_chasm',amount:1,sectors:[7]}],protected:[]};
}
const selection: Partial<Record<'target'|'territory'|'from'|'elite',string|number>> =
kind==='laLaLa' ? {target:'atreides'} : kind==='baliset' ? {target:'atreides',territory:'red_chasm'} :
kind==='gamont' ? {target:'atreides',from:'imperial_basin:10',elite:0} :
kind==='jubba' ? {territory:'red_chasm'} : {};
return {game,card:card.id,cards,action:{type:'card',mode:'choam',card:card.id,...selection} as Action};
}
function offer(f: ReturnType<typeof fixture>) {
let game=act(f.game,'c',f.action);
for (let n=0;game.response && n<32;n++) {
const p=game.players.find(row=>!game.response!.passed.includes(row.id));assert.ok(p);
game=act(game,p.id,{type:'passResponse'});
}
assert.equal(game.response??null,null);
return game;
}
for (const kind of ['sale','kulon','laLaLa','baliset','gamont','jubba'] as const)
for (const claim of [false,true]) void test(`Advanced public CHOAM ${kind}: Semuta ${claim?'claim':'pass'} preserves the printed effect`,()=>{
const f=fixture(kind), before=structuredClone(f.game), pending=offer(f);
assert.deepEqual(f.game,before);
const batch=pending.pendingTreacheryDiscard; assert.ok(batch?.reaction);
assert.equal(batch.reaction.stage,'offer');
assert.deepEqual(batch.batch.entries.map(entry=>entry.card.id),[f.card]);
assert.equal(viewGame(pending,'richese').semutaReaction?.canCommit,true);
assert.deepEqual(viewGame(pending,'richese').semutaReaction?.candidates,[]);
assert.equal(viewGame(pending,'atreides').semutaReaction?.canCommit,false);
assert.deepEqual(viewGame(pending,'atreides').semutaReaction?.candidates,[]);
if (kind==='sale') assert.equal(seat(pending,'c').spice,seat(f.game,'c').spice+2);
if (kind==='kulon') for (const difficulty of DIFFICULTIES) {
const view=viewGame(pending,'richese');view.players.find(p=>p.id==='richese')!.bot=difficulty;
assert.ok(botActions(view).some(action=>action.type==='semutaCommit'||action.type==='semutaPass'));
}
const event=batch.batch.event;
let done=pending;
if (claim) {
done=act(done,'richese',{type:'semutaCommit',event});
if(done.pendingTreacheryDiscard?.reaction?.stage==='select')
done=act(done,'richese',{type:'semutaSelect',event,card:f.card});
} else for (const p of pending.players) done=act(done,p.id,{type:'semutaPass',event});
assert.equal(done.pendingTreacheryDiscard,null);
assert.equal(seat(done,'richese').hand.some(card=>card.id===f.card),claim);
assert.equal(done.discard.some(card=>card.id===f.card),!claim);
assert.equal(done.discard.some(card=>card.id===SEMUTA_DRUG_ID),claim);
assert.deepEqual(inventory(done),f.cards);
if (kind==='sale') {
assert.equal(seat(done,'c').spice,seat(f.game,'c').spice+2);
assert.equal(done.decision?.kind,'choamMarket');
} else if (kind==='kulon') assert.equal(viewGame(done,'c').choamMovementBonus,1);
else if (kind==='laLaLa') assert.equal(viewGame(done,'atreides').revival.freeBlocked,true);
else if (kind==='baliset') assert.ok(viewGame(done,'atreides').balisetRestrictions.some(row=>row.territory==='red_chasm'));
else if (kind==='gamont') {
assert.equal(seat(done,'atreides').reserves,seat(f.game,'atreides').reserves+1);
assert.equal(seat(done,'atreides').forces['imperial_basin:10']??0,0);
} else assert.equal(seat(done,'c').forces['red_chasm:7'],1);
assert.throws(()=>act(done,'richese',{type:'semutaCommit',event}));
});
@@ -34,7 +34,7 @@ void test('public Advanced Richese setup displays the limited ordinary Semuta no
const html = renderToStaticMarkup(createElement(GameTable, {
game:view, busy:false, send:async () => {}, onExit() {},
}));
assert.match(html, /Advanced Semuta preview: supported Hajr, Ghola, Harvester, Weather Control, completed Box, Distrans and simple Truthtrance discards can be claimed/);
assert.match(html, /Advanced Semuta preview: supported Hajr, Ghola, Harvester, Weather Control, completed Box, Distrans, simple Truthtrance, Residual Poison and CHOAM sale or Worthless discards can be claimed/);
assert.match(html, /supported battle cleanup/);
assert.match(html, /Other discard opportunities remain unfinished/);
assert.doesNotMatch(html, /Semuta Drug development preview/);
@@ -0,0 +1,144 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
const player = (game: Game, id: string) => game.players.find(seat => seat.id === id)!;
function inventory(game: Game): string[] {
const cards = [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [],
...game.players.map(seat => seat.hand)].flat().map(card => card.id).sort();
assert.equal(new Set(cards).size, cards.length);
return cards;
}
/** A genuine Advanced public start followed by native setup. Only the later
* card custody, board counters and battle phase are staged; Poison, readiness,
* Semuta and battle resolution use the actual actions. */
function fixture() {
let game = createGame('ADVSEMUTARESIDUAL', newPlayer('e', 'Emperor', 'emperor'), true, ['choam']);
for (const [id, faction] of [['a', 'atreides'], ['r', 'richese'], ['g', 'guild']] as const)
joinGame(game, newPlayer(id, faction, faction));
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
game = act(game, 'e', {type:'start', advancedPreview:true});
assert.equal(game.semutaAdvancedOrdinaryPreview, true);
assert.equal(game.semutaPreview, undefined);
for (let count = 0; game.status === 'setup' && count < 150; count++) {
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
game = act(game, next.actor, next.action);
}
assert.equal(game.status, 'playing');
const original = inventory(game);
for (const seat of game.players) game.deck.push(...seat.hand.splice(0));
const take = (predicate: (card: Game['deck'][number]) => boolean) => {
for (const zone of [game.deck, game.discard, game.richeseCache!]) {
const index = zone.findIndex(predicate);
if (index >= 0) return zone.splice(index, 1)[0];
}
throw Error('Missing an original physical card.');
};
const poison = take(card => card.effect === 'residualPoison');
const semuta = take(card => card.id === SEMUTA_DRUG_ID);
player(game, 'e').hand.push(poison);
player(game, 'g').hand.push(semuta);
for (const id of ['e', 'a']) {
const seat = player(game, id);
seat.reserves += Object.values(seat.forces).reduce((total, amount) => total + amount, 0);
seat.forces = {};
if (seat.elites) {
seat.elites.reserves += Object.values(seat.elites.forces).reduce((total, amount) => total + amount, 0);
seat.elites.forces = {};
}
seat.reserves -= 3;
seat.forces['red_chasm:7'] = 3;
}
Object.assign(game, {turn:2, phase:6, storm:18, active:'e', order:['e','a','r','g'],
ready:[], response:null, decision:null, phaseOpening:null, battle:null, movementRemaining:[]});
assert.deepEqual(inventory(game), original);
game = act(game, 'e', {type:'chooseBattle', territory:'red_chasm', target:'a'});
for (let count = 0; count < 80; count++) {
if (game.battle?.preLeader && !game.battle.preLeader.closed && !game.response &&
!game.decision && !game.phaseOpening && !game.battle.preparation) break;
if (game.response) {
const next = game.players.find(seat => !game.response!.passed.includes(seat.id)); assert.ok(next);
game = act(game, next.id, {type:'passResponse'});
} else if (game.decision?.kind === 'fullPlanOffer') {
game = act(game, game.decision.player, {type:'decision', decline:true});
} else if (game.battle?.preparation) {
game = act(game, game.battle.preparation.owner, {type:'declineBattlePower'});
} else {
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
game = act(game, next.actor, next.action);
}
}
assert.ok(game.battle?.preLeader && !game.battle.preLeader.closed);
const quote = viewGame(game, 'e').residualPoison;
assert.ok(quote && !quote.blocked && quote.event && quote.target);
return {game, poison:poison.id, event:game.battle.event!, original,
action:{type:'card', card:poison.id, target:quote.target, event:quote.event} as Action};
}
function finishBattle(state: Game): Game {
let game = state;
const battleEvent = game.battle!.event!;
for (const id of ['e', 'a']) if (!game.battle!.preLeader!.ready.includes(id))
game = act(game, id, {type:'battlePreparationReady', event:battleEvent});
const emperor = player(game, 'e').leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => b.strength - a.strength)[0]!;
const atreides = player(game, 'a').leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => a.strength - b.strength)[0]!;
game = act(game, 'e', {type:'battlePlan', dial:3, support:3, leader:emperor.id, weapon:null, defense:null});
game = act(game, 'a', {type:'battlePlan', dial:0, support:0, leader:atreides.id, weapon:null, defense:null});
for (let count = 0; count < 400 && !(game.phase >= 7 && !game.response && !game.decision && !game.phaseOpening); count++) {
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
game = act(game, next.actor, next.action);
}
assert.ok(game.phase >= 7 && !game.response && !game.decision && !game.phaseOpening);
return game;
}
for (const ready of ['owner', 'opponent'] as const) for (const claim of [false, true])
void test(`Advanced public Residual Poison ${ready}-ready Semuta ${claim ? 'claim' : 'pass'} resumes battle`, () => {
const f = fixture(), readyPlayer = ready === 'owner' ? 'e' : 'a';
let game = act(f.game, readyPlayer, {type:'battlePreparationReady', event:f.event});
const before = structuredClone(game);
const dead = new Set(player(game, 'a').leaders.filter(leader => leader.dead).map(leader => leader.id));
game = act(game, 'e', f.action);
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
assert.equal(pending.continuation.kind, 'residualPoisonDiscard');
assert.deepEqual(pending.batch.entries.map(entry => [entry.card.id, entry.discardedBy, entry.publicFace]),
[[f.poison, 'e', true]]);
assert.deepEqual(game.battle!.preLeader!.ready, [readyPlayer]);
const victims = player(game, 'a').leaders.filter(leader => leader.dead && !dead.has(leader.id));
assert.equal(victims.length, 1);
for (const difficulty of DIFFICULTIES) {
const botGame = structuredClone(game);
player(botGame, 'g').bot = difficulty;
const action = botActions(viewGame(botGame, 'g')).find(candidate =>
candidate.type === 'semutaCommit' || candidate.type === 'semutaPass');
assert.ok(action, difficulty);
assert.doesNotThrow(() => act(botGame, 'g', action), difficulty);
}
const event = pending.batch.event;
if (claim) {
game = act(game, 'g', {type:'semutaCommit', event});
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = act(game, 'g', {type:'semutaSelect', event, card:f.poison});
} else for (const seat of game.players) game = act(game, seat.id, {type:'semutaPass', event});
assert.equal(game.pendingTreacheryDiscard, null);
assert.deepEqual(game.battle!.preLeader!.ready, ready === 'owner' ? [] : ['a']);
assert.deepEqual(game.battle!.plans, before.battle!.plans);
assert.equal(player(game, 'a').leaders.find(leader => leader.id === victims[0].id)?.dead, true);
assert.equal(player(game, 'g').hand.some(card => card.id === f.poison), claim);
assert.equal(game.discard.some(card => card.id === SEMUTA_DRUG_ID), claim);
assert.equal(game.discard.some(card => card.id === f.poison), !claim);
assert.deepEqual(inventory(game), f.original);
assert.throws(() => act(game, 'g', {type:'semutaCommit', event}));
game = finishBattle(game);
assert.equal(player(game, 'a').leaders.find(leader => leader.id === victims[0].id)?.dead, true);
assert.deepEqual(inventory(game), f.original);
});