Enable Semuta in public Discovery games
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 09:31:40 +11:00
1 parent b2b9a024bd
commit 98ead3a4b5
11 files changed
+436 -8

No files matched your search

@@ -75,6 +75,9 @@ for (const advanced of [false, true]) void test(
let game = applyAction(prior, f.holder, {type:'card', card:CARD, scope:option.scope, event:option.event, mode:option.mode});
assert.deepEqual(game.order, prior.order);
assert.deepEqual(game.ecologicalStorm?.queue?.order, [...f.occupants].reverse());
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'sapho:station');
const discardEvent = game.pendingTreacheryDiscard!.batch.event;
for (const player of game.players) game = applyAction(game, player.id, {type:'semutaPass', event:discardEvent});
assert.equal(game.decision?.player, f.holder);
assert.equal(game.discard.filter(card => card.id === CARD).length, 1);
for (const delta of [1, -1]) {
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, createGame, joinGame, newPlayer, viewGame } from '../game/engine';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' :
specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true};
},
});
const { GameTable } = await import('../components/game-table');
aliases.deregister();
for (const advanced of [false, true]) void test(
`public ${advanced ? 'Advanced' : 'Basic'} Richese Discovery setup displays its bounded Semuta preview`, () => {
let game = createGame('SEMUTADISCOVERYUI', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
game = applyAction(game, game.host, {type:'moduleOption', module:'discoveries', enabled:true});
for (const player of game.players) game = applyAction(game, player.id, {type:'ready'});
game = applyAction(game, game.host, {type:'start', ...(advanced ? {advancedPreview:true} : {})});
assert.equal(game.status, 'setup');
assert.equal(game.discoveryEnabled, true);
assert.equal(game.semutaPreview, true);
const html = renderToStaticMarkup(createElement(GameTable, {
game:viewGame(game, 'r'), busy:false, send:async () => {}, onExit() {},
})).replace(/\s+/g, ' ');
assert.match(html, /Semuta Drug with Discoveries preview/);
assert.match(html, /Other discard opportunities remain unfinished\./);
},
);
+156
View File
@@ -0,0 +1,156 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { TERRITORIES } from '../game/board';
import { botGroundMoveAllowed, groundMoveRouteDistance } from '../game/bot-mobility';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
import { semutaSkillsInventory } from './fixture-semuta-skills';
const profiles = [
{advanced:false, tech:false, strongholds:false},
{advanced:false, tech:true, strongholds:false},
{advanced:true, tech:false, strongholds:false},
{advanced:true, tech:true, strongholds:false},
{advanced:true, tech:false, strongholds:true},
{advanced:true, tech:true, strongholds:true},
];
const clone = (game: Game): Game => JSON.parse(JSON.stringify(game));
function player(game: Game, id: string) {
const seat = game.players.find(candidate => candidate.id === id);
assert.ok(seat); return seat;
}
function next(game: Game) {
if (!game.phaseOpening && !game.response && !game.decision && game.phase === 2 && game.charityOrder) {
const actor = game.charityOrder.remaining[0]; assert.ok(actor);
return {actor, action:{type:'ready'}};
}
return nextRicheseSkillsTechNativeStep(game);
}
/** Genuine public lobby and faction setup. Only unopened non-Discovery Spice
* Card order and later physical card slots are controlled; no game clock,
* Discovery token, wallet, force, or module owner is fabricated. */
function movement(profile: typeof profiles[number]) {
let game = createGame('SEMUTADISCOVERYPUBLIC', newPlayer('r','Richese','richese'), profile.advanced, ['choam']);
joinGame(game, newPlayer('a','Atreides','atreides'));
joinGame(game, newPlayer('e','Emperor','emperor'));
game = applyAction(game, game.host, {type:'moduleOption',module:'discoveries',enabled:true});
if (profile.tech) game = applyAction(game,game.host,{type:'techTokens',enabled:true});
if (profile.strongholds) game = applyAction(game,game.host,{type:'strongholdCards',enabled:true});
for (const seat of game.players) game = applyAction(game,seat.id,{type:'ready'});
game = applyAction(game,game.host,{type:'start',...(profile.advanced ? {advancedPreview:true} : {})});
assert.equal(game.status,'setup');
assert.equal(game.semutaPreview,true);
assert.equal(game.discoveryEnabled,true);
assert.equal(game.discoveries?.tokens.length,8);
assert.equal(!!game.techTokens,profile.tech);
assert.equal(!!game.strongholdCards,profile.strongholds);
for (let n=0;game.status==='setup' && n<250;n++) {
const action=next(game); assert.ok(action);
game=applyAction(game,action.actor,action.action);
}
assert.equal(game.status,'playing');
for (const position of [0,1]) {
const index=game.spiceDeck.findIndex((card,i)=>i>=position && 'territory' in card && !card.discovery);
assert.ok(index>=position);
game.spiceDeck.splice(position,0,game.spiceDeck.splice(index,1)[0]);
}
for (let n=0;n<1500 && !(game.phase===5 && !game.phaseOpening && !game.response && !game.decision);n++) {
assert.equal(game.turn,1);
const action=next(game); assert.ok(action);
game=applyAction(game,action.actor,action.action);
}
assert.equal(game.phase,5);
assert.ok(!game.phaseOpening && !game.response && !game.decision);
for (let n=0;game.active!=='a' && n<3;n++)
game=applyAction(game,game.active!,{type:'endMovement'});
assert.equal(game.active,'a');
const inventory=semutaSkillsInventory(game);
const give = (id:string, identity:string) => {
const target=player(game,id);
const zones=[game.deck,game.discard,game.richeseCache!,...game.players.map(seat=>seat.hand)];
const source=zones.find(zone=>zone.some(card=>card.id===identity || card.effect===identity));
assert.ok(source,`Original ${identity} must remain in a physical card slot`);
const index=source.findIndex(card=>card.id===identity || card.effect===identity);
const [card]=source.splice(index,1);
if (target.hand.length>=4) source.push(target.hand.pop()!);
target.hand.push(card); return card.id;
};
const semuta=give('r',SEMUTA_DRUG_ID), hajr=give('a','hajr');
assert.deepEqual(semutaSkillsInventory(game),inventory);
if (profile.advanced) {
// Advanced Hajr requires an actual completed Force Movement immediately
// before its discard, so make a native paid shipment and adjacent move.
game=applyAction(game,'a',{type:'ship',territory:'polar_sink',sector:0,amount:1,elite:0,allyPayment:0});
for (let n=0;(game.phaseOpening || game.response || game.decision) && n<100;n++) {
const action=next(game); assert.ok(action);
game=applyAction(game,action.actor,action.action);
}
const own=viewGame(game,'a'), actor=own.players.find(candidate=>candidate.id==='a')!;
const from='polar_sink:0';
const destination=TERRITORIES.filter(place=>place.type==='sand' &&
!game.players.some(seat=>Object.keys(seat.forces).some(key=>key.startsWith(`${place.id}:`))) &&
!Object.keys(game.spice).some(key=>key.startsWith(`${place.id}:`) && game.spice[key]>0))
.flatMap(place=>place.sectors.map(sector=>({territory:place.id,sector})))
.find(place=>groundMoveRouteDistance(own,actor,from,`${place.territory}:${place.sector}`)===1 &&
botGroundMoveAllowed(own,actor,from,`${place.territory}:${place.sector}`,0));
assert.ok(destination);
game=applyAction(game,'a',{type:'move',from,...destination,amount:1,elite:0});
for (let n=0;(game.phaseOpening || game.response || game.decision) && n<100;n++) {
const action=next(game); assert.ok(action);
game=applyAction(game,action.actor,action.action);
}
assert.equal(player(game,'a').hajrWindow?.turn,game.turn);
assert.equal(player(game,'a').hajrWindow?.move,player(game,'a').moved);
}
return {game,semuta,hajr,inventory};
}
for (const profile of profiles) {
const label=`${profile.advanced?'Advanced':'Basic'}${profile.tech?' Tech':''}${profile.strongholds?' Strongholds':''}`;
void test(`${label} public Discovery start retains the native Hajr and Semuta continuation`,()=>{
const f=movement(profile), before=clone(f.game);
const pending=applyAction(clone(before),'a',{type:'card',card:f.hajr});
assert.equal(pending.pendingTreacheryDiscard?.batch.cause,'ordinary:hajr');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage,'offer');
assert.equal(pending.active,'a'); assert.equal(pending.phase,5);
const event=pending.pendingTreacheryDiscard!.batch.event;
for (const claim of [false,true]) {
let result=clone(pending);
if (claim) result=applyAction(result,'r',{type:'semutaCommit',event});
else for (const seat of result.players) result=applyAction(result,seat.id,{type:'semutaPass',event});
assert.equal(result.pendingTreacheryDiscard,null);
assert.equal(result.active,'a'); assert.equal(result.phase,5);
assert.equal(result.hajr.filter(id=>id==='a').length,1);
assert.equal(player(result,'r').hand.some(card=>card.id===f.hajr),claim);
assert.equal(player(result,'r').hand.some(card=>card.id===f.semuta),!claim);
assert.deepEqual(result.discoveries,before.discoveries);
assert.deepEqual(semutaSkillsInventory(result),f.inventory);
const unchanged=clone(result);
assert.throws(()=>applyAction(result,'r',{type:'semutaCommit',event}));
assert.deepEqual(clone(result),unchanged);
}
for (const level of DIFFICULTIES) {
const own=viewGame(pending,'r');
own.players.find(seat=>seat.id==='r')!.bot=level;
const action=botActions(own).find(candidate=>candidate.type==='semutaCommit' || candidate.type==='semutaPass');
assert.ok(action,`${level} must answer the actual reaction`);
assert.doesNotThrow(()=>applyAction(clone(pending),'r',action));
}
});
}
void test('public Discovery without native Richese does not gain a Semuta preview',()=>{
let game=createGame('SEMUTADISCOVERYOTHER',newPlayer('c','CHOAM','choam'),false,['choam']);
joinGame(game,newPlayer('a','Atreides','atreides'));
joinGame(game,newPlayer('e','Emperor','emperor'));
game=applyAction(game,game.host,{type:'moduleOption',module:'discoveries',enabled:true});
for (const seat of game.players) game=applyAction(game,seat.id,{type:'ready'});
game=applyAction(game,game.host,{type:'start'});
assert.equal(game.status,'setup');
assert.equal(game.discoveryEnabled,true);
assert.equal(game.semutaPreview,undefined);
});
+113
View File
@@ -0,0 +1,113 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, initializeSemutaGameForAudit, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries';
import { createDiscoveryStorm, createStormSource, discoveryStormDistance } from '../game/discovery-storm';
import { discoveryStormActions } from '../game/discovery-storm-options';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { semutaShrineLobby } from './fixture-semuta-shrine';
import { advanceTestingStationContest } from './fixture-testing-station-contest';
import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share';
import { reactSemutaSpecial } from './fixture-semuta-special-karama';
const SAPHO = 'richese-juice-of-sapho';
const STATION = 'ecological-testing-station';
const copy = (game: Game): Game => JSON.parse(JSON.stringify(game));
const seat = (game: Game, id: string) => {
const player = game.players.find(row => row.id === id); assert.ok(player); return player;
};
const cards = (game: Game) => [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
...(game.richeseRemoved ?? []), ...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
/** Genuine opt-in Richese/Discovery setup to first Storm. The original physical
* station and two counters are staged at a controlled pending storm boundary;
* actual unique Sapho and Semuta move from their original card zones. This is
* not a natural Discovery reveal or full-game claim. */
function fixture(advanced: boolean, mode: 'first' | 'last') {
let game = initializeSemutaGameForAudit(semutaShrineLobby(advanced));
game = advanceTestingStationContest(game, state => state.status === 'playing');
assert.equal(game.phase, 0); assert.equal(game.semutaPreview, true);
const inventory = cards(game);
assert.equal(inventory.filter(id => id === SAPHO).length, 1);
assert.equal(inventory.filter(id => id === SEMUTA_DRUG_ID).length, 1);
for (const player of game.players) game.deck.push(...player.hand.splice(0));
const token = game.discoveries!.tokens.find(row => row.face === STATION);
assert.ok(token && token.status === 'supply');
const placement = Object.values(DISCOVERY_CARD_PLACEMENTS).find(row => row.type === token.type);
assert.ok(placement);
Object.assign(token, {status:'placed', territory:placement.territory, sector:placement.sector, revealedTurn:game.turn});
validateDiscoveryState(game.discoveries!);
for (const id of ['e', 'g']) {
const player = seat(game, id); assert.ok(player.reserves > 0);
player.reserves--; player.forces[`${STATION}:0`] = (player.forces[`${STATION}:0`] ?? 0) + 1;
}
const order = game.order.filter(id => id === 'e' || id === 'g');
assert.equal(order.length, 2);
const owner = order[mode === 'first' ? 1 : 0];
moveSemutaEmperorCard(game, SAPHO, owner);
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
assert.deepEqual(cards(game), inventory);
game.stormPending = 3;
game.stormMovementSource = createStormSource(game.turn, 'dials', 3);
game.ready = game.players.map(player => player.id);
game.phaseOpening = null; game.stormResolution = null;
game.ecologicalStorm = createDiscoveryStorm(game, 'semuta-station-original');
game.decision = {kind:'ecologicalStorm', player:game.ecologicalStorm.owner, event:game.ecologicalStorm.event};
return {game, owner, order, inventory};
}
for (const advanced of [false, true]) for (const mode of ['first', 'last'] as const) void test(
`${advanced ? 'Advanced' : 'Basic'} ${mode} Sapho station choice resumes after Semuta claim or pass`, () => {
const f = fixture(advanced, mode);
const option = viewGame(f.game, f.owner).saphoOptions.find(row => row.scope === 'testingStation' && row.mode === mode);
assert.ok(option, 'The physical Sapho is eligible for the original station queue');
const action = {type:'card', card:SAPHO, scope:option.scope, event:option.event, mode:option.mode};
const pending = applyAction(copy(f.game), f.owner, action);
assert.equal(pending.pendingTreacheryDiscard?.batch.cause, 'sapho:station');
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'saphoStationDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(pending.ecologicalStorm?.queue?.order, [...f.order].reverse());
assert.equal(pending.ecologicalStorm?.queue?.choices.length, 0);
assert.equal(pending.stormPending, 3); assert.equal(pending.storm, f.game.storm);
assert.equal(pending.decision, null, 'The original station choice waits behind the reaction');
const pendingEvent = pending.pendingTreacheryDiscard!.batch.event;
assert.throws(() => applyAction(copy(pending), f.owner, action));
for (const claim of [false, true]) {
let resumed = reactSemutaSpecial(copy(pending), 'r', claim);
assert.equal(resumed.pendingTreacheryDiscard, null);
assert.equal(resumed.decision?.kind, 'ecologicalStorm');
assert.equal(resumed.decision?.player, resumed.ecologicalStorm?.owner);
assert.equal(resumed.decision?.event, resumed.ecologicalStorm?.event);
assert.equal(seat(resumed, 'r').hand.some(card => card.id === SAPHO), claim);
assert.equal(seat(resumed, 'r').hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
assert.throws(() => applyAction(copy(resumed), 'r', {type:'semutaCommit', event:pendingEvent}));
for (const delta of [1, -1]) {
const owner = resumed.ecologicalStorm!.owner;
const choice = discoveryStormActions(viewGame(resumed, owner)).find(row => row.delta === delta);
assert.ok(choice);
resumed = applyAction(resumed, owner, choice);
}
assert.equal(resumed.ecologicalStorm?.stage, 'complete');
assert.equal(discoveryStormDistance(resumed.ecologicalStorm!), 3);
assert.equal(resumed.phase, 1);
assert.deepEqual(cards(resumed), f.inventory);
}
},
);
void test('all four bot profiles can legally respond to the parked Sapho station discard', () => {
const f = fixture(false, 'first');
const option = viewGame(f.game, f.owner).saphoOptions.find(row => row.scope === 'testingStation' && row.mode === 'first');
assert.ok(option);
const pending = applyAction(f.game, f.owner, {type:'card', card:SAPHO,
scope:option.scope, event:option.event, mode:option.mode});
for (const difficulty of DIFFICULTIES) {
const view = viewGame(pending, 'r');
view.players.find(player => player.id === 'r')!.bot = difficulty;
const action = botActions(view).find(row => row.type === 'semutaCommit' || row.type === 'semutaPass');
assert.ok(action, `${difficulty} must offer a legal Semuta response`);
assert.doesNotThrow(() => applyAction(copy(pending), 'r', action));
}
});