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

+2
View File
@@ -1344,6 +1344,8 @@ export function GameTable({
Final Ornithopter disposal keeps the completed original groups;
finite Once Around and clean unstarted movement Sapho costs keep
the changed queue before its original next actor continues.
Sapho can also reorder supported simultaneous Truthtrance questions;
the same question queue resumes after the Semuta reaction.
Accepted losing-card, mandatory winning-card and selected winner
disposal are separate fresh events. Recovery keeps the original
casualties and paid native deployments; combat does not repeat.
+15 -2
View File
@@ -17,6 +17,19 @@ open mode gates.
## Current checkpoint and work
**Public Semuta with Homeworlds — 29 October. Prototyped / Partial.**
Fresh Basic and Advanced CHOAM-deck Richese games with Homeworlds now use the
existing bounded Semuta reaction. Supported Sapho ordering of simultaneous
Truthtrances continues through Semuta in Homeworlds and in the previously
enabled Skills and Discovery profiles. Genuine public setup and native Hajr
journeys cover both rules modes with conserved card staging; separate real
Homeworld Movement, auction and pre-plan battle parents exercise the original
question queue after claim or pass. Two GameTable cases check the preview.
Nine related files pass 90 cases, including 18 new cases; type checking, lint
and the production build pass. Other discard sources, combinations and
deployed acceptance remain open; existing games are not converted.
[Scope](SEMUTA_SOURCE_UPDATE_20260907.md#public-richese-homeworld-entry--29-october-2026).
**Public Semuta with Discoveries — 29 October. Prototyped / Partial.**
Fresh Basic and Advanced CHOAM-deck Richese games with Discoveries now use the
existing bounded Semuta reaction, with normal Tech and Advanced Stronghold
@@ -52,8 +65,8 @@ staged printed card; separate genuine public Richese/Discovery starts stage
conserved station pieces and move the original Sapho from the cache. Seven
related files pass 40 cases, including 12 new cases; type checking, lint and
the production build pass. The ordering is provisional: an automated current
occupant may act before another player intervenes. Semuta reaction profiles,
recovered Sapho replay and deployed acceptance remain open. Two stale Richese
occupant may act before another player intervenes. The later Semuta continuation
above extends this scope; recovered Sapho replay and deployed acceptance remain open. Two stale Richese
Nexus guide statements were also corrected against existing runtime evidence.
[Scope](DISCOVERY_PROTOTYPE.md#sapho-at-ecological-testing-station--29-october-2026).
+11
View File
@@ -1,5 +1,16 @@
# Rule decision index
## Public Semuta with Homeworlds — 29 October 2026
Fresh Basic and Advanced Richese games with the CHOAM deck and Homeworlds use
the existing bounded Semuta continuation. A supported Sapho reorder of
simultaneous Truthtrance questions pauses for that reaction and restores the
same question queue. Existing games are not converted; other discard sources
and module combinations remain incomplete.
[The same source-bound Truthtrance continuation also restores Sapho ordering
in the selected public Skills and Discovery profiles.](SEMUTA_SOURCE_UPDATE_20260907.md#public-richese-homeworld-entry--29-october-2026)
[Scope](SEMUTA_SOURCE_UPDATE_20260907.md#public-richese-homeworld-entry--29-october-2026).
## Public Semuta with Discoveries — 29 October 2026
Fresh Basic and Advanced CHOAM-deck Richese games with Discoveries use the
+14
View File
@@ -1,5 +1,19 @@
# Semuta Drug source update and implementation boundary
## Public Richese Homeworld entry — 29 October 2026
**Prototyped / Partial.** Fresh public Basic and Advanced games with Richese,
the CHOAM expansion deck, classic or CHOAM peers and Homeworlds now use the
existing Semuta preview. The original Homeworld occupation setup and physical
components remain. Skills, Tech, Strongholds, Nexus, Discoveries, other decks
and independent cards retain separate paths. A supported Sapho reorder of
simultaneous Truthtrance questions now offers its discard to Semuta; claim or
pass restores the original question queue without asking or paying twice.
The same supported continuation restores Sapho ordering in the selected public
Skills and Discovery profiles enabled in the two preceding checkpoints.
Existing games are not converted. Other discard sources, interactions and full
mode acceptance remain unfinished.
## Public Richese Discovery entry — 29 October 2026
**Prototyped / Partial.** Fresh public Basic and Advanced Richese games with
+19 -3
View File
@@ -8019,6 +8019,17 @@ function semutaHomeworldRoster(g: Game): boolean {
!g.discoveryEnabled && !g.discoveries && !g.discoveryStash &&
!g.greatMaker && !g.ecazTreachery;
}
/** Existing bare and fresh public CHOAM-only Semuta profiles may retain
* Sapho's neutral simultaneous-Truth ordering in their supported parents. */
function semutaTruthOrderProfile(g: Game): boolean {
if (bareBasicSemutaProfile(g) || semutaHomeworldRoster(g)) return true;
return g.semutaPreview === true && g.expansions.length === 1 && g.expansions[0] === 'choam' &&
!!byFaction(g, 'richese') && g.players.every(p => p.faction === 'richese' ||
p.faction === 'choam' || CLASSIC_FACTIONS[p.faction] === true) &&
!g.homeworlds && !g.nexusCards && !g.ecazTreachery && !g.kullPreview && !g.nexusKullPreview &&
(g.discoveryEnabled === true && !!g.discoveries && !g.leaderSkills && discoveryModeSupported(g) ||
!!g.leaderSkills && !g.discoveryEnabled && ordinaryLeaderSkillModeSupported(g));
}
type TupileDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'tupileCleanupDiscard' }
@@ -9815,6 +9826,7 @@ function semutaOfferSupported(
continuation.kind !== 'saphoRevivalDiscard' &&
continuation.kind !== 'saphoCharityDiscard' &&
continuation.kind !== 'saphoExtortionDiscard' &&
!(continuation.kind === 'saphoTruthOrderDiscard' && semutaHomeworldRoster(g)) &&
continuation.kind !== 'battleResolved' &&
continuation.kind !== 'winnerMandatoryDiscard' &&
continuation.kind !== 'battleCleanup' &&
@@ -10086,7 +10098,7 @@ function semutaOfferSupported(
(richeseGiftDiscard || combatDiscard || alliedTraitorDiscard || occupyDiscard || continuation.kind === 'saphoAggressorDiscard' ||
(continuation.kind === 'saphoTruthOrderDiscard' && !!continuation.battleEvent &&
g.phase === 6 && g.battle?.event === continuation.battleEvent && !g.battle.revealed && !g.battle.preparation &&
bareBasicSemutaProfile(g)) ||
semutaTruthOrderProfile(g)) ||
bgDiscard ||
continuation.kind === 'specialKaramaDiscard' ||
continuation.kind === 'residualPoisonDiscard' ||
@@ -11118,8 +11130,12 @@ function startModules(state: Game): Game {
}
if (g.discoveryEnabled) return publicSemutaDiscoveryProfile(g)
? initializeSemutaGameForAudit(g) : initializeDiscoveryGameForAudit(g);
if (g.homeworlds)
if (g.homeworlds) {
if (g.status === 'lobby' && !g.twoPlayerControllers && !g.shortAllianceVictory &&
!g.coalitionPreview && semutaHomeworldRoster(g))
return initializeSemutaGameForAudit(g);
return g.expansions.length ? initializeHomeworldOccupationGameForAudit(g) : initializeHomeworldGameForAudit(g);
}
if (g.strongholdCards) return initializeStrongholdFactionsGameForAudit(g);
const advancedOrdinarySemuta = bareAdvancedClassicSemutaProfile(g);
const initialized = initializeFactionExpansionsGame(g, bareBasicSemutaProfile(g));
@@ -29342,7 +29358,7 @@ function saphoTruthOrderSemutaSupported(g: Game,p: Player,card: string,mode: 'fi
}
function saphoTruthOrderChoices(g: Game,p: Player): {card:string;mode:'first'|'last'}[] {
const w = g.truthtrance;
if (w?.stage !== 'order' || !w.order || (g.semutaPreview && !bareBasicSemutaProfile(g)) ||
if (w?.stage !== 'order' || !w.order || (g.semutaPreview && !semutaTruthOrderProfile(g)) ||
printedRolePlayBlock(g,p.id,'juiceOfSapho')) return [];
const card = p.hand.find(row => row.id === 'richese-juice-of-sapho' && row.effect === 'juiceOfSapho');
if (!card || !richeseCardDefinition(card) || p.hand.filter(row => row.id === card.id).length !== 1 ||
+2 -1
View File
@@ -509,7 +509,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'If you recover the physical Sapho after choosing Last, you may use it again to choose First, including after suspending your own started turn. This provisionally replaces your own Last protection without restoring spent actions. Completed turns stay complete and a different started current actor finishes first. Another holder cannot replace your protected Last.',
'Between completed battles, choose first or last among remaining battle choosers. The priority lasts for the rest of the Battle phase. You still need a real unresolved battle. Others may choose battles against you before your own turn; last is not immunity. This scope changes the chooser, not each battle’s aggressor or tie advantage.',
'In a fresh Richese battle, use unreserved Sapho at a clean boundary before both combatants finish final battle actions, including after sealed plans and revealed declarations. Habbanya still precedes ordinary aggressor tie priority. Participants, plans, promises, weapon choices and votes do not swap or replay. Older battles retain their pre-plan opportunity.',
'When two or more players declared Truthtrance in a supported clean parent, each queued holder may continue or use their held Sapho to move their entire pending question group first or last. Physical storm order and each holder’s card order stay unchanged. Supported Basic Semuta claim/pass restores the same question queue. Clean ordinary auction lots also preserve their standing bid and remaining clock. Richese cache Once Around and Silent lots preserve their original bidding and funding; Black Market, other special auctions, battle and pending-parent combinations remain unfinished.',
'When two or more players declared Truthtrance in a supported clean parent, each queued holder may continue or use their held Sapho to move their entire pending question group first or last. Physical storm order and each holder’s card order stay unchanged. Supported Semuta claim/pass restores the same question queue, including the selected Homeworld, Leader Skills and Discovery profiles. Clean ordinary auction lots also preserve their standing bid and remaining clock. Richese cache Once Around and Silent lots preserve their original bidding and funding; Black Market, other special auctions and pending-parent combinations remain unfinished.',
'During a clean native Extortion payer decision, use Sapho to take your optional choice first/last among unanswered non-Moritani players. Earlier declines, collected bank5 and the one-payment3 limit remain. The next payer event changes even when that player stays next. You may decline instead of paying.',
'Before either of two Ecological Testing Station occupants chooses, use Sapho to take your adjustment first or last if that changes the order. Each occupant still makes one choice against the cumulative storm distance. Physical storm order and its original source stay unchanged. In supported Semuta games, claim or pass resumes the same next choice. The current occupant can act before another player intervenes, including an automated seat. Replay of a recovered Sapho remains unfinished; this online priority is provisional.',
'During ordered Advanced Charity, take your remaining turn first or last, then discard Sapho. An accepted current claim provisionally protects that actor while another holder reorders only later unstarted turns. At a clean boundary, the holder may pause its own claimed turn and resume last without repeating the claim or payment. Completed turns stay closed and physical First Player order is unchanged. Basic and older concurrent phases have no ordered scope.',
@@ -1354,6 +1354,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'In fresh Basic games with Richese, classic opponents, the CHOAM deck and Tech Tokens, the existing Semuta preview is also enabled. Other module combinations remain separate; existing started games are not retrofitted.',
'Fresh Basic and Advanced Richese games with the CHOAM deck and Leader Skills now use the existing bounded Semuta reaction. Optional Tech Tokens require three seats; Advanced may also select Stronghold Cards. Supported discards retain their original continuations, while other discard sources remain unfinished. Existing games are not converted.',
'Fresh Basic and Advanced Richese games with the CHOAM deck and Discoveries also use the bounded Semuta reaction. Shrine role costs and a committed Sapho Testing Station reorder retain their original effects and continue after claim or pass. Other producers and combinations remain unfinished; existing games are not converted.',
'Fresh Basic and Advanced Richese games with the CHOAM deck and Homeworlds also use the bounded Semuta reaction. A supported Sapho change to simultaneous Truthtrance order pauses for Semuta, then resumes the same questions. Other discard sources and module combinations remain unfinished; existing games are not converted.',
'Fresh Advanced Richese preview games with only the CHOAM expansion deck and classic or CHOAM opponents enable Semuta reactions for supported ordinary Hajr, Ghola, Harvester and Weather Control discards, completed Box and Distrans, simple definite Truthtrance answers, clean Residual Poison, completed CHOAM sales, supported printed CHOAM Worthless costs and supported battle cleanup. Other discard parents and optional-module combinations remain unfinished. Existing games are not retrofitted.',
'Mandatory and winner battle discards also pause before native Advanced Harkonnen capture. Claim or Continue completes the original casualties and card cleanup before the capture offer; it does not sample a captive early or repeat capture. Other pending rewards remain outside this bounded path.',
'Mandatory and winner battle discards also pause before native Tleilaxu Face Dance. Claim or Continue preserves the original winner’s casualties and card cleanup, then restores the same private Face Dancer opportunity. Replacement, leader death and force sources remain native; the claimed Treachery card stays with its claimant, and no Face Dancer card becomes a Semuta target.',
@@ -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);
}
});