Enable Semuta in public Homeworld games
Verify and publish checkpoint container / container (push) Successful in 11m13s

This commit is contained in:
Quintine committed 2026-10-29 09:36:52 +11:00
1 parent 98ead3a4b5
commit 49f9ebdea0
11 files changed
+526 -6

No files matched your search

@@ -0,0 +1,43 @@
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 Homeworld setup displays its bounded Semuta preview`, () => {
let game = createGame('SEMUTAHOMEWORLDUI', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
game = applyAction(game, game.host, {type:'homeworlds', 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.ok(game.homeworlds);
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 Homeworlds development preview/);
assert.match(html, /Sapho can also reorder supported simultaneous Truthtrance questions; the same question queue resumes after the Semuta reaction/);
assert.match(html, /Other Homeworld producers and Basic unresolved Tupile loss remain guarded/);
assert.match(html, /Not complete or certified Homeworld rules/);
},
);
+124
View File
@@ -0,0 +1,124 @@
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 { nativeShipmentSources } from '../game/homeworld-options';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextSemutaGuildIncomeNativeStep, semutaGuildIncomeInventory } from './fixture-semuta-guild-income';
const clone = (g:Game):Game => JSON.parse(JSON.stringify(g));
function player(g:Game,id:string) {
const p=g.players.find(row=>row.id===id); assert.ok(p); return p;
}
function next(g:Game) {
if (!g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard) {
const ordered = g.phase===2 ? g.charityOrder : g.phase===4 ? g.revivalOrder : null;
if (ordered) {
const actor=ordered.remaining[0]; assert.ok(actor);
return {actor,action:{type:'ready'}};
}
}
return nextSemutaGuildIncomeNativeStep(g);
}
/** Public lobby selection, genuine setup and phase actions through Movement.
* We reorder original unplayed Spice cards to avoid incidental worms and move
* only conserved physical Hajr/Semuta cards after reaching the real turn. */
function fixture(advanced:boolean) {
let g=createGame('SEMUTAWORLDSPUBLIC',newPlayer('r','Richese','richese'),advanced,['choam']);
joinGame(g,newPlayer('a','Atreides','atreides'));
joinGame(g,newPlayer('e','Emperor','emperor'));
g=applyAction(g,'r',{type:'homeworlds',enabled:true});
for (const p of g.players) g=applyAction(g,p.id,{type:'ready'});
g=applyAction(g,'r',{type:'start',...(advanced ? {advancedPreview:true} : {})});
assert.equal(g.status,'setup'); assert.equal(g.semutaPreview,true); assert.ok(g.homeworlds);
for (let n=0;g.status==='setup' && n<250;n++) {
const step=next(g); g=applyAction(g,step.actor,step.action);
}
assert.equal(g.status,'playing');
for (const pos of [0,1]) {
const index=g.spiceDeck.findIndex((card,i)=>i>=pos && 'territory' in card && !card.discovery);
assert.ok(index>=pos); g.spiceDeck.splice(pos,0,g.spiceDeck.splice(index,1)[0]);
}
for (let n=0;n<1500 && !(g.phase===5 && !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard);n++) {
assert.equal(g.turn,1); const step=next(g); g=applyAction(g,step.actor,step.action);
}
assert.equal(g.phase,5);
for (let n=0;g.active!=='a' && n<3;n++) g=applyAction(g,g.active!,{type:'endMovement'});
assert.equal(g.active,'a');
const inventory=semutaGuildIncomeInventory(g);
const moveCard=(id:string,identity:string)=>{
const target=player(g,id), zones=[g.deck,g.discard,g.richeseCache!,...g.players.map(p=>p.hand)];
const source=zones.find(zone=>zone.some(c=>c.id===identity || c.effect===identity)); assert.ok(source);
const index=source.findIndex(c=>c.id===identity || c.effect===identity), [card]=source.splice(index,1);
if (target.hand.length>=4) source.push(target.hand.pop()!);
target.hand.push(card); return card.id;
};
const semuta=moveCard('r',SEMUTA_DRUG_ID), hajr=moveCard('a','hajr');
assert.deepEqual(semutaGuildIncomeInventory(g),inventory);
if (advanced) {
const sources=nativeShipmentSources(viewGame(g,'a'),1,0); assert.ok(sources);
g=applyAction(g,'a',{type:'ship',territory:'polar_sink',sector:0,amount:1,elite:0,allyPayment:0,homeworldSources:sources});
for (let n=0;(g.phaseOpening || g.response || g.decision || g.pendingTreacheryDiscard) && n<100;n++) {
const step=next(g); g=applyAction(g,step.actor,step.action);
}
const own=viewGame(g,'a'), actor=own.players.find(p=>p.id==='a')!, from='polar_sink:0';
const destination=TERRITORIES.filter(t=>t.type==='sand' &&
!g.players.some(p=>Object.keys(p.forces).some(key=>key.startsWith(`${t.id}:`))) &&
!Object.keys(g.spice).some(key=>key.startsWith(`${t.id}:`) && g.spice[key]>0))
.flatMap(t=>t.sectors.map(sector=>({territory:t.id,sector})))
.find(t=>groundMoveRouteDistance(own,actor,from,`${t.territory}:${t.sector}`)===1 &&
botGroundMoveAllowed(own,actor,from,`${t.territory}:${t.sector}`,0));
assert.ok(destination);
g=applyAction(g,'a',{type:'move',from,...destination,amount:1,elite:0});
for (let n=0;(g.phaseOpening || g.response || g.decision || g.pendingTreacheryDiscard) && n<100;n++) {
const step=next(g); g=applyAction(g,step.actor,step.action);
}
assert.equal(player(g,'a').hajrWindow?.turn,g.turn);
assert.equal(player(g,'a').hajrWindow?.move,player(g,'a').moved);
}
return {g,semuta,hajr,inventory};
}
for (const advanced of [false,true]) void test(
`${advanced?'Advanced':'Basic'} public Richese Homeworld start retains Hajr and Semuta claim/pass`,()=>{
const f=fixture(advanced), before=clone(f.g);
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');
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 p of result.players) result=applyAction(result,p.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(c=>c.id===f.hajr),claim);
assert.equal(player(result,'r').hand.some(c=>c.id===f.semuta),!claim);
assert.deepEqual(result.homeworlds,before.homeworlds);
assert.deepEqual(result.homeworldOccupationHistory,before.homeworldOccupationHistory);
assert.deepEqual(semutaGuildIncomeInventory(result),f.inventory);
const original=clone(result); assert.throws(()=>applyAction(result,'r',{type:'semutaCommit',event}));
assert.deepEqual(clone(result),original);
}
for (const level of DIFFICULTIES) {
const view=viewGame(pending,'r'); view.players.find(p=>p.id==='r')!.bot=level;
const action=botActions(view).find(a=>a.type==='semutaCommit' || a.type==='semutaPass');
assert.ok(action,`${level} can answer the original discard reaction`);
assert.doesNotThrow(()=>applyAction(clone(pending),'r',action));
}
},
);
void test('public Homeworlds without native Richese do not gain the Semuta preview',()=>{
let g=createGame('SEMUTAWORLDSOTHER',newPlayer('c','CHOAM','choam'),false,['choam']);
joinGame(g,newPlayer('a','Atreides','atreides')); joinGame(g,newPlayer('e','Emperor','emperor'));
g=applyAction(g,'c',{type:'homeworlds',enabled:true});
for (const p of g.players) g=applyAction(g,p.id,{type:'ready'});
g=applyAction(g,'c',{type:'start'});
assert.equal(g.status,'setup'); assert.ok(g.homeworlds); assert.equal(g.semutaPreview,undefined);
});
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { createSemutaHomeworldSaphoFixture } from './fixture-semuta-homeworld-sapho';
import { createSemutaHomeworldBattleSaphoFixture, prepareSemutaHomeworldBattleSapho } from './fixture-semuta-homeworld-battle-sapho';
import { reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share';
import { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding';
import { finishOriginalTruthQuestions, finishTruthOrder, playSaphoTruthOrder, truthOrderFrame } from './fixture-sapho-truth-order';
const SAPHO = 'richese-juice-of-sapho';
const copy = (game: Game): Game => JSON.parse(JSON.stringify(game));
/** Existing Homeworld fixtures create the real finite auction or native
* pre-plan battle. Card hands alone are staged by moving original physical
* components between their existing zones; no invoice, battle or phase is
* fabricated. Both declarations and every later answer are real actions. */
function source(parent: 'auction' | 'battle', mode: 'first' | 'last') {
const original = parent === 'auction'
? createSemutaHomeworldSaphoFixture('onceAround', mode, false)
: createSemutaHomeworldBattleSaphoFixture('aggressor', 'first', false);
let game = copy(original.game);
if (parent === 'battle') game = prepareSemutaHomeworldBattleSapho(game);
const inventory = biddingPhysicalIds(game);
for (const seat of game.players) game.deck.push(...seat.hand.splice(0));
const holders = game.order.filter(id => id !== original.holder);
assert.ok(holders.length >= 2);
const owner = mode === 'first' ? holders.at(-1)! : holders[0];
const other = mode === 'first' ? holders[0] : holders.at(-1)!;
moveSemutaEmperorCard(game, SAPHO, owner);
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, original.holder);
const truths = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
...(game.richeseRemoved ?? []), ...game.players.flatMap(p => p.hand)]
.filter(card => card.effect === 'truthtrance').map(card => card.id);
assert.ok(truths.length >= 2);
const first = moveSemutaEmperorCard(game, truths[0], owner);
const second = moveSemutaEmperorCard(game, truths[1], other);
assert.deepEqual(biddingPhysicalIds(game), inventory);
const originalParent = {turn:game.turn, phase:game.phase, active:game.active,
auction:copyValue(game.auction), richeseAuction:copyValue(game.richeseAuction),
battle:copyValue(game.battle), order:[...game.order]};
for (const [actor, card] of [[owner,first],[other,second]])
game = applyAction(game, actor, {type:'card', card});
while (game.truthtrance?.stage === 'priority') {
const seat = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(seat);
game = applyAction(game, seat.id, {type:'truthPass'});
}
const frame = truthOrderFrame(game);
assert.deepEqual([...frame.holders].sort(), [owner,other].sort());
assert.equal(mode === 'first' ? game.truthtrance!.queue.at(-1)?.player : game.truthtrance!.queue[0].player, owner);
return {game, owner, other, holder:original.holder, inventory, originalParent};
}
function copyValue<T>(value: T): T {
return value === undefined ? value : JSON.parse(JSON.stringify(value));
}
for (const parent of ['auction', 'battle'] as const) for (const mode of ['first', 'last'] as const)
void test(`Basic Homeworld ${parent} preserves its original parent through Sapho ${mode} and Semuta`, () => {
const f = source(parent, mode), prior = copy(f.game), frame = truthOrderFrame(prior);
assert.ok(viewGame(prior, f.owner).saphoTruthOrderChoices.some(row => row.card === SAPHO && row.mode === mode));
const pending = playSaphoTruthOrder(copy(prior), f.owner, SAPHO, mode);
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'saphoTruthOrderDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.truthtrance, null);
for (const claim of [false, true]) {
let next = reactSemutaHomeworldChoam(copy(pending), f.holder, claim);
assert.equal(next.pendingTreacheryDiscard, null);
assert.equal(truthOrderFrame(next).event, frame.event);
assert.deepEqual(copyValue(next.auction), f.originalParent.auction);
assert.deepEqual(copyValue(next.richeseAuction), f.originalParent.richeseAuction);
assert.deepEqual(copyValue(next.battle), f.originalParent.battle);
next = finishTruthOrder(next);
assert.equal(mode === 'first' ? next.truthtrance!.queue[0].player : next.truthtrance!.queue.at(-1)?.player, f.owner);
next = finishOriginalTruthQuestions(next);
assert.equal(next.truthtrance, null);
assert.equal(next.turn, f.originalParent.turn); assert.equal(next.phase, f.originalParent.phase);
assert.equal(next.active, f.originalParent.active); assert.deepEqual(next.order, f.originalParent.order);
assert.deepEqual(copyValue(next.auction), f.originalParent.auction);
assert.deepEqual(copyValue(next.richeseAuction), f.originalParent.richeseAuction);
assert.deepEqual(copyValue(next.battle), f.originalParent.battle);
assert.deepEqual(biddingPhysicalIds(next), f.inventory);
}
});
+100
View File
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, 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 { createSemutaHomeworldSaphoFixture } from './fixture-semuta-homeworld-sapho';
import { reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import { moveSemutaEmperorCard, semutaEmperorPlayer } from './fixture-semuta-emperor-share';
import { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding';
import { finishOriginalTruthQuestions, finishTruthOrder, playSaphoTruthOrder, truthOrderFrame } from './fixture-sapho-truth-order';
const copy = (game: Game): Game => JSON.parse(JSON.stringify(game));
const SAPHO = 'richese-juice-of-sapho';
const player = semutaEmperorPlayer;
/** Genuine native Homeworld/Semuta setup and real Movement boundary. The
* unique printed Sapho and Semuta plus two original Truthtrances move between
* conserved card zones after setup to reach a rare simultaneous order; this is
* not evidence of their natural purchase or initial distribution. */
function fixture(advanced: boolean, mode: 'first' | 'last') {
const source = createSemutaHomeworldSaphoFixture('movement', mode, advanced);
let game = copy(source.game);
assert.ok(game.homeworlds?.custody && game.semutaPreview);
const inventory = biddingPhysicalIds(game);
for (const seat of game.players) game.deck.push(...seat.hand.splice(0));
const order = game.order.filter(id => id !== source.holder);
assert.ok(order.length >= 2);
const owner = mode === 'first' ? order.at(-1)! : order[0];
const other = mode === 'first' ? order[0] : order.at(-1)!;
moveSemutaEmperorCard(game, SAPHO, owner);
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, source.holder);
const originals = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
...(game.richeseRemoved ?? []), ...game.players.flatMap(p => p.hand)]
.filter(card => card.effect === 'truthtrance').map(card => card.id);
assert.ok(originals.length >= 2);
const truth = moveSemutaEmperorCard(game, originals[0], owner);
const otherTruth = moveSemutaEmperorCard(game, originals[1], other);
assert.deepEqual(biddingPhysicalIds(game), inventory);
const parent = {turn:game.turn, phase:game.phase, active:game.active,
order:[...game.order], movementRemaining:structuredClone(game.movementRemaining)};
for (const [actor, card] of [[owner, truth], [other, otherTruth]])
game = applyAction(game, actor, {type:'card', card});
while (game.truthtrance?.stage === 'priority') {
const seat = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(seat);
game = applyAction(game, seat.id, {type:'truthPass'});
}
const frame = truthOrderFrame(game);
assert.deepEqual([...frame.holders].sort(), [owner, other].sort());
assert.equal(mode === 'first' ? game.truthtrance!.queue.at(-1)?.player : game.truthtrance!.queue[0].player, owner);
return {game, owner, other, holder:source.holder, inventory, parent, truth, otherTruth};
}
for (const advanced of [false, true]) for (const mode of ['first', 'last'] as const) void test(
`${advanced ? 'Advanced' : 'Basic'} ${mode} Homeworld Truth order resumes after Semuta claim or pass`, () => {
const f = fixture(advanced, mode), prior = copy(f.game), frame = truthOrderFrame(prior);
assert.ok(viewGame(prior, f.owner).saphoTruthOrderChoices.some(choice => choice.card === SAPHO && choice.mode === mode));
assert.deepEqual(viewGame(prior, f.other).saphoTruthOrderChoices, []);
assert.throws(() => applyAction(copy(prior), f.owner,
{type:'saphoTruthOrder', event:'stale', revision:frame.revision, card:SAPHO, mode}));
const pending = playSaphoTruthOrder(copy(prior), f.owner, SAPHO, mode);
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'saphoTruthOrderDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.pendingTreacheryDiscard?.batch.entries[0].card.id, SAPHO);
assert.equal(pending.truthtrance, null);
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
const event = pending.pendingTreacheryDiscard!.batch.event;
for (const claim of [false, true]) {
let next = reactSemutaHomeworldChoam(copy(pending), f.holder, claim);
assert.equal(next.pendingTreacheryDiscard, null);
assert.equal(player(next, f.holder).hand.some(card => card.id === SAPHO), claim);
assert.equal(player(next, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
const resumed = truthOrderFrame(next);
assert.equal(resumed.event, frame.event);
assert.ok(resumed.passed.includes(f.owner));
assert.throws(() => applyAction(copy(next), f.holder, {type:'semutaCommit', event}));
next = finishTruthOrder(next);
assert.equal(mode === 'first' ? next.truthtrance!.queue[0].player : next.truthtrance!.queue.at(-1)?.player, f.owner);
next = finishOriginalTruthQuestions(next);
assert.equal(next.truthtrance, null);
assert.equal(next.turn, f.parent.turn); assert.equal(next.phase, f.parent.phase);
assert.equal(next.active, f.parent.active); assert.deepEqual(next.order, f.parent.order);
assert.deepEqual(next.movementRemaining, f.parent.movementRemaining);
assert.deepEqual(biddingPhysicalIds(next), f.inventory);
}
},
);
void test('all bot profiles can legally answer the Homeworld Sapho Semuta offer', () => {
const f = fixture(false, 'first');
const pending = playSaphoTruthOrder(copy(f.game), f.owner, SAPHO, 'first');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
for (const difficulty of DIFFICULTIES) {
const view = viewGame(pending, f.holder);
view.players.find(seat => seat.id === f.holder)!.bot = difficulty;
const action = botActions(view).find(candidate => candidate.type === 'semutaCommit' || candidate.type === 'semutaPass');
assert.ok(action, `${difficulty} must resolve its own neutral opportunity`);
assert.doesNotThrow(() => applyAction(copy(pending), f.holder, action));
}
});
@@ -0,0 +1,112 @@
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 { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
import { semutaSkillsInventory } from './fixture-semuta-skills';
import { finishOriginalTruthQuestions, finishTruthOrder } from './fixture-sapho-truth-order';
const SAPHO = 'richese-juice-of-sapho';
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;
};
function step(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);
}
/** Public CHOAM/Richese lobby, original setup and native Movement. Only the
* order of two unopened ordinary Spice Cards and actual card locations are
* staged. Every held card returns to its original deck before selecting four
* existing physical cards, preserving the complete inventory. */
function fixture(module: 'leaderSkills' | 'discoveries', advanced: boolean) {
let game = createGame('SEMUTATRUTHPUBLIC', 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,enabled:true});
for (const p of game.players) game = applyAction(game,p.id,{type:'ready'});
game = applyAction(game,game.host,{type:'start',...(advanced ? {advancedPreview:true} : {})});
assert.equal(game.semutaPreview,true);
for (let n=0; game.status==='setup' && n<300; n++) {
if (game.setupStage==='leaderSkills') {
const actor=Object.keys(game.leaderSkills!.offers)[0];
const offer=game.leaderSkills!.offers[actor], own=viewGame(game,actor).leaderSkills!;
const skill=offer.cards.find(id=>!own.unavailableSkills?.[id]);
const leader=own.eligibleLeaders[0]; assert.ok(skill && leader);
game=applyAction(game,actor,{type:'leaderSkill',event:offer.event,skill,leader:leader.id});
} else { const next=step(game); game=applyAction(game,next.actor,next.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<1600 && !(game.phase===5 && !game.phaseOpening && !game.response && !game.decision);n++) {
assert.equal(game.turn,1); const next=step(game); game=applyAction(game,next.actor,next.action);
}
assert.equal(game.phase,5); assert.ok(!game.phaseOpening && !game.response && !game.decision);
const inventory=semutaSkillsInventory(game);
for (const p of game.players) game.deck.push(...p.hand.splice(0));
const give=(id:string,identity:string) => {
const zones=[game.deck,game.discard,game.richeseCache!,...game.players.map(p=>p.hand)];
const source=zones.find(zone=>zone.some(card=>card.id===identity || card.effect===identity));
assert.ok(source,`Missing original ${identity}`);
const index=source.findIndex(card=>card.id===identity || card.effect===identity);
const [card]=source.splice(index,1); seat(game,id).hand.push(card); return card.id;
};
const others=game.order.filter(id=>id==='a' || id==='e'); assert.equal(others.length,2);
const holder=others[1], other=others[0];
give('r',SEMUTA_DRUG_ID); give(holder,SAPHO);
const truth=give(holder,'truthtrance'), otherTruth=give(other,'truthtrance');
assert.deepEqual(semutaSkillsInventory(game),inventory);
const parent={turn:game.turn,phase:game.phase,active:game.active,order:[...game.order],
movementRemaining:structuredClone(game.movementRemaining)};
for (const [id,card] of [[holder,truth],[other,otherTruth]])
game=applyAction(game,id,{type:'card',card});
while (game.truthtrance?.stage==='priority') {
const actor=game.players.find(p=>!game.truthtrance!.passed.includes(p.id)); assert.ok(actor);
game=applyAction(game,actor.id,{type:'truthPass'});
}
assert.equal(game.truthtrance?.stage,'order');
return {game,holder,other,truth,otherTruth,inventory,parent};
}
for (const selectedModule of ['leaderSkills','discoveries'] as const) for (const advanced of [false,true])
void test(`${advanced?'Advanced':'Basic'} public ${selectedModule} preserves Sapho Truth-order through Semuta`,()=>{
const f=fixture(selectedModule,advanced);
const frame=f.game.truthtrance!.order!;
assert.ok(viewGame(f.game,f.holder).saphoTruthOrderChoices.some(choice=>choice.card===SAPHO && choice.mode==='first'));
const action={type:'saphoTruthOrder',event:frame.event,revision:frame.revision,card:SAPHO,mode:'first'};
const pending=applyAction(copy(f.game),f.holder,action);
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind,'saphoTruthOrderDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage,'offer');
assert.equal(pending.truthtrance,null);
const event=pending.pendingTreacheryDiscard!.batch.event;
for (const level of DIFFICULTIES) {
const own=viewGame(pending,'r'); own.players.find(p=>p.id==='r')!.bot=level;
const bot=botActions(own).find(row=>row.type==='semutaCommit' || row.type==='semutaPass');
assert.ok(bot,`${level} can answer this neutral reaction`);
assert.doesNotThrow(()=>applyAction(copy(pending),'r',bot));
}
for (const claim of [false,true]) {
let game=copy(pending);
if (claim) game=applyAction(game,'r',{type:'semutaCommit',event});
else for (const p of game.players) game=applyAction(game,p.id,{type:'semutaPass',event});
assert.equal(game.pendingTreacheryDiscard,null);
assert.equal(seat(game,'r').hand.some(card=>card.id===SAPHO),claim);
assert.equal(seat(game,'r').hand.some(card=>card.id===SEMUTA_DRUG_ID),!claim);
game=finishTruthOrder(game);
assert.equal(game.truthtrance!.queue[0].player,f.holder);
assert.deepEqual(game.order,f.parent.order); assert.equal(game.turn,f.parent.turn);
assert.equal(game.phase,f.parent.phase); assert.equal(game.active,f.parent.active);
assert.deepEqual(game.movementRemaining,f.parent.movementRemaining);
game=finishOriginalTruthQuestions(game);
assert.equal(game.truthtrance,null);
assert.deepEqual(semutaSkillsInventory(game),f.inventory);
}
});