Extend Advanced Semuta discard continuations
Verify and publish checkpoint container / container (push) Successful in 10m54s

This commit is contained in:
Quintine committed 2026-10-29 04:20:17 +11:00
1 parent b93c5c4cf5
commit b2bc3f2f01
8 files changed
+304 -6

No files matched your search

+153
View File
@@ -0,0 +1,153 @@
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 { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
function inventory(game: Game): string[] {
const ids = [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [],
...game.players.map(player => player.hand)].flat().map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length);
return ids;
}
function take(game: Game, predicate: (card: Game['deck'][number]) => boolean) {
for (const zone of [game.deck, game.discard, game.richeseCache!, ...game.players.map(player => player.hand)]) {
const index = zone.findIndex(predicate);
if (index >= 0) return zone.splice(index, 1)[0];
}
throw new Error('Missing original physical card.');
}
function setup(kind: 'loser' | 'mandatory' | 'selected') {
let game = createGame('ADVSEMUTABATTLE', newPlayer('a', 'Atreides', 'atreides'), true, ['choam']);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
joinGame(game, newPlayer('r', 'Richese', 'richese'));
for (const player of game.players) game = act(game, player.id, {type:'ready'});
game = act(game, 'a', {type:'start', advancedPreview:true});
assert.equal(game.semutaAdvancedOrdinaryPreview, true);
assert.equal(game.semutaPreview, undefined);
for (let n = 0; game.status === 'setup' && n < 100; n++) {
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next, 'Native setup must advance.');
game = act(game, next.actor, next.action);
}
assert.equal(game.status, 'playing');
const cards = inventory(game);
// Preserve physical inventory while positioning the exact battle cards and
// keep the discard free from unrelated earlier setup choices.
for (const player of game.players) game.deck.push(...player.hand.splice(0));
const semuta = take(game, card => card.id === SEMUTA_DRUG_ID);
const winnerWeapon = take(game, card => card.kind === 'projectile');
const loserDefense = take(game, card => card.kind === 'shield');
const hero = kind === 'mandatory' ? take(game, card => card.kind === 'hero') : null;
seat(game, 'r').hand.push(semuta);
seat(game, 'a').hand.push(winnerWeapon, ...(hero ? [hero] : []));
seat(game, 'e').hand.push(loserDefense);
assert.deepEqual(inventory(game), cards);
// Put conserved physical force groups in a clear shared territory. Emperor's
// existing Advanced elite counters stay in reserves; both armies are normal.
for (const id of ['a', 'e']) {
const player = seat(game, id);
player.reserves += Object.values(player.forces).reduce((total, amount) => total + amount, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((total, amount) => total + amount, 0);
player.elites.forces = {};
}
const amount = id === 'a' ? 6 : 2;
player.reserves -= amount;
player.forces['red_chasm:7'] = amount;
}
Object.assign(game, {phase:6, active:'a', order:['a','e','r'], storm:18, ready:[],
response:null, decision:null, phaseOpening:null, battle:null});
game = act(game, 'a', {type:'chooseBattle', territory:'red_chasm', target:'e'});
for (let n = 0; n < 80; n++) {
if (game.battle && !game.response && !game.decision && !game.phaseOpening &&
!game.battle.preparation && (!game.battle.preLeader || game.battle.preLeader.closed)) break;
const next = nextRicheseStrongholdsNativeStep(game);
assert.ok(next, 'The current native battle interaction must finish before preparation.');
game = act(game, next.actor, next.action);
}
assert.ok(game.battle && !game.battle.preparation && (!game.battle.preLeader || game.battle.preLeader.closed));
const winnerLeader = hero?.id ?? seat(game, 'a').leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((left, right) => right.strength - left.strength)[0]!.id;
const loserLeader = seat(game, 'e').leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((left, right) => left.strength - right.strength)[0]!.id;
game = act(game, 'a', {type:'battlePlan', dial:hero ? 5 : 1, support:hero ? 5 : 1,
leader:winnerLeader, weapon:winnerWeapon.id, defense:null});
game = act(game, 'e', {type:'battlePlan', dial:0, support:0,
leader:loserLeader, weapon:null, defense:loserDefense.id});
const voters = viewGame(game, 'a').battle!.traitorVoters;
assert.equal(voters.length, 2);
game = act(game, voters[0], {type:'traitorCall', call:false});
return {game, finalActor:voters[1], loserDefense, winnerWeapon, hero, cards};
}
function react(game: Game, claim: boolean, selected: string): Game {
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
const event = pending.batch.event;
if (claim) {
game = act(game, 'r', {type:'semutaCommit', event});
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = act(game, 'r', {type:'semutaSelect', event, card:selected});
} else {
for (const player of game.players) {
if (!game.pendingTreacheryDiscard) break;
game = act(game, player.id, {type:'semutaPass', event});
}
}
return game;
}
function advance(game: Game, until: (state: Game) => boolean): Game {
for (let n = 0; n < 400 && !until(game); n++) {
if (game.pendingTreacheryDiscard?.reaction) game = react(game, false, '');
else {
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next, `Native continuation needed at ${game.phase}/${game.decision?.kind}`);
game = act(game, next.actor, next.action);
}
}
assert.ok(until(game));
return game;
}
for (const kind of ['loser', 'mandatory', 'selected'] as const)
for (const claim of [false, true]) void test(`public Advanced Semuta ${kind} battle discard: ${claim ? 'claim' : 'pass'}`, () => {
const f = setup(kind);
let game = finishSemutaBattleOutcome(act(f.game, f.finalActor, {type:'traitorCall',call:false}));
if (kind !== 'loser') {
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'battle:mandatory');
game = react(game, false, f.loserDefense.id);
if (kind === 'selected') {
game = advance(game, state => state.decision?.kind === 'battleCards');
game = act(game, 'a', {type:'decision', discard:[f.winnerWeapon.id]});
}
}
const card = kind === 'loser' ? f.loserDefense : kind === 'mandatory' ? f.hero! : f.winnerWeapon;
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
assert.equal(pending.batch.cause, kind === 'loser' ? 'battle:mandatory' :
kind === 'mandatory' ? 'battle:winnerMandatory' : 'battle:winner');
assert.ok(pending.batch.entries.some(entry => entry.card.id === card.id));
const original = structuredClone(game), event = pending.batch.event;
for (const difficulty of DIFFICULTIES) {
const botGame = structuredClone(game);
seat(botGame, 'r').bot = difficulty;
const action = botActions(viewGame(botGame, 'r')).find(candidate =>
candidate.type === 'semutaCommit' || candidate.type === 'semutaPass');
assert.ok(action, difficulty);
assert.doesNotThrow(() => act(botGame, 'r', action), difficulty);
}
game = react(game, claim, card.id);
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(seat(game, 'r').hand.some(held => held.id === card.id), claim);
assert.equal(game.discard.some(discarded => discarded.id === card.id), !claim);
assert.equal(seat(game, 'e').tanks, seat(original, 'e').tanks);
for (const player of game.players) assert.equal(player.spice, seat(original, player.id).spice);
assert.deepEqual(inventory(game), f.cards);
assert.throws(() => act(game, 'r', {type:'semutaCommit', event}));
assert.equal(game.lastBattleContext?.event, original.lastBattleContext?.event);
game = advance(game, state => state.phase >= 7 && clean(state));
assert.deepEqual(inventory(game), f.cards);
});
@@ -34,7 +34,8 @@ 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 ordinary Hajr, Ghola, Harvester and Weather Control discards can be claimed/);
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, /supported battle cleanup/);
assert.match(html, /Other discard opportunities remain unfinished/);
assert.doesNotMatch(html, /Semuta Drug development preview/);
assert.doesNotMatch(html, /Semuta Drug with Homeworlds development preview/);
+116
View File
@@ -0,0 +1,116 @@
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';
type Kind = 'box' | 'distrans' | 'truth';
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) {
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
...game.players.flatMap(seat => seat.hand)];
const ids = cards.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length);
return ids;
}
function take(game: Game, identity: string) {
for (const zone of [game.deck, game.discard, game.richeseCache!, ...game.players.map(seat => seat.hand)]) {
const index = zone.findIndex(card => card.id === identity || card.effect === identity);
if (index >= 0) return zone.splice(index, 1)[0];
}
throw new Error(`Missing physical card ${identity}.`);
}
/** Genuine Advanced public start and normal setup. To reach the selected clean
* special-card window, the phase and physical card custody are staged. No
* unopened game or saved room is altered, and card identities are conserved. */
function fixture(kind: Kind) {
let game = createGame('ADVSEMUTASPECIAL', newPlayer('a','Atreides','atreides'), true, ['choam']);
joinGame(game, newPlayer('r','Richese','richese'));
joinGame(game, newPlayer('e','Emperor','emperor'));
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
game = act(game, 'a', {type:'start',advancedPreview:true});
assert.equal(game.semutaAdvancedOrdinaryPreview, true);
assert.equal(game.semutaPreview, undefined);
for (let count = 0; game.status === 'setup' && count < 80; count++) {
let next: Game | undefined;
for (const seat of game.players) {
const view = viewGame(game, seat.id);
view.players.find(row => row.id === seat.id)!.bot = 'Medium';
const action = botActions(view)[0];
if (action) { next = act(game, seat.id, action); break; }
}
assert.ok(next, 'The genuine setup has a legal next step.');
game = next;
}
assert.equal(game.status, 'playing');
const before = inventory(game);
const semuta = take(game, SEMUTA_DRUG_ID);
const source = take(game, kind === 'box' ? 'nullentropyBox' : kind === 'truth' ? 'truthtrance' : 'distrans');
const extra = take(game, 'hajr');
player(game, 'r').hand.push(semuta);
player(game, 'a').hand.push(source);
if (kind === 'box') game.discard.push(extra);
else if (kind === 'distrans') player(game, 'a').hand.push(extra);
else game.deck.push(extra);
assert.deepEqual(inventory(game), before);
Object.assign(game, {turn:2, phase:5, active:'a', storm:18, ready:[], response:null,
decision:null, phaseOpening:null, stormPending:null, movementRemaining:['a','r','e']});
const beforeSpice = player(game, 'a').spice;
if (kind === 'box') {
game = act(game, 'a', {type:'card',card:source.id});
// The native Box selects a sole eligible discard without another prompt.
if (game.pendingNullentropy)
game = act(game, 'a', {type:'decision',event:game.pendingNullentropy.event,card:extra.id});
assert.equal(player(game,'a').spice,beforeSpice - 2);
assert.ok(player(game,'a').hand.some(card => card.id === extra.id));
} else if (kind === 'distrans') {
game = act(game,'a',{type:'card',card:source.id,target:'e',give:extra.id});
assert.ok(player(game,'e').hand.some(card => card.id === extra.id));
} else {
game = act(game,'a',{type:'card',card:source.id});
while (game.truthtrance?.stage === 'priority') {
const next = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(next);
game = act(game,next.id,{type:'truthPass'});
}
game = act(game,'a',{type:'truthAsk',question:{kind:'freeform',target:'e',
scope:'fact',text:'Do you intend to protect Arrakeen?'}});
game = act(game,'e',{type:'truthAnswer',answer:'yes'});
assert.equal(game.truthHistory?.at(-1)?.answer, 'yes');
}
return {game, source, extra, before, beforeSpice};
}
for (const kind of ['box','distrans','truth'] as const) for (const claim of [false,true])
void test(`Advanced public ${kind} Semuta ${claim ? 'claim' : 'pass'} preserves the committed original effect`, () => {
const f = fixture(kind), pending = f.game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
assert.equal(pending.continuation.kind, kind === 'box' ? 'nullentropyDiscard' :
kind === 'truth' ? 'truthtranceDiscard' : 'distransDiscard');
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [f.source.id]);
assert.equal(viewGame(f.game, 'r').semutaReaction?.canCommit, true);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(f.game, 'r');
view.players.find(seat => seat.id === 'r')!.bot = difficulty;
assert.ok(botActions(view).some(action => action.type === 'semutaCommit' || action.type === 'semutaPass'), difficulty);
}
let game = f.game;
if (claim) {
game = act(game,'r',{type:'semutaCommit',event:pending.batch.event});
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = act(game,'r',{type:'semutaSelect',event:pending.batch.event,card:f.source.id});
} else for (const seat of game.players) game = act(game,seat.id,{type:'semutaPass',event:pending.batch.event});
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(player(game,'r').hand.some(card => card.id === f.source.id),claim);
assert.equal(game.discard.some(card => card.id === f.source.id),!claim);
if (kind === 'box') {
assert.equal(player(game,'a').spice,f.beforeSpice - 2);
assert.ok(player(game,'a').hand.some(card => card.id === f.extra.id));
} else if (kind === 'distrans') assert.ok(player(game,'e').hand.some(card => card.id === f.extra.id));
else assert.equal(game.truthHistory?.at(-1)?.answer,'yes');
assert.deepEqual(inventory(game),f.before);
game = act(game,'a',{type:'endMovement'});
assert.ok(!game.movementRemaining?.includes('a'));
});