diff --git a/components/game-table.tsx b/components/game-table.tsx index eb56d51b..ec5013e4 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1377,7 +1377,9 @@ export function GameTable({ Semuta Drug development preview · bounded fresh-discard reactions, not complete Richese or certified expansion rules. Fresh bare Basic Richese starts enable these reactions with their selected native - families; older games are unchanged. Clean CHOAM and Ixian Ambassador + families; fresh CHOAM-deck Richese games with Leader Skills also enable + supported reactions in Basic and Advanced. Older games are unchanged. + Clean CHOAM and Ixian Ambassador discards, including BG copies, now pause after ordinary shipment or movement entry. CHOAM keeps its already-earned spice; Ixians draw their one replacement after the reaction. Other nested arrivals remain diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index b87499dc..7b9fcbd1 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,18 @@ open mode gates. ## Current checkpoint and work +**Public Semuta with Leader Skills — 29 October. Prototyped / Partial.** +Fresh Basic and Advanced CHOAM-deck Richese games with Leader Skills now use +the existing bounded Semuta reaction. Selected Tech and Advanced Strongholds +retain their normal prerequisites. Six genuine public starts and native setup +journeys cover Hajr, claim/pass and four legal bot profiles; only physical +card positions are staged. Two GameTable cases check the visible preview +notice. The older Advanced Skills fixture now earns its Hajr window through a +real movement. Five related files pass 20 cases, including nine new cases; +type checking, lint and the production build pass. Other discard producers, +module combinations and deployed acceptance remain open; played games are not +converted. [Scope](SEMUTA_SOURCE_UPDATE_20260907.md#public-richese-leader-skills-entry--29-october-2026). + **Sapho at Ecological Testing Station — 29 October. Prototyped / Partial.** Before either of two station occupants chooses, a holder can discard Sapho to change its own position first or last. The original storm source, physical diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 3369a44a..c1c38491 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,15 @@ # Rule decision index +## Public Semuta with Leader Skills — 29 October 2026 + +Fresh public Basic and Advanced Richese games with the CHOAM deck and Leader +Skills use the existing bounded Semuta continuation. Tech Tokens retain their +three-seat prerequisite; Stronghold Cards remain Advanced-only. The original +Skills initializer, offers and training remain authoritative. Existing games +are not converted. Supported discard sources remain partial; this admission +does not certify every Semuta interaction or expansion mode. +[Scope](SEMUTA_SOURCE_UPDATE_20260907.md#public-richese-leader-skills-entry--29-october-2026). + ## Sapho and Ecological Testing Station — 29 October 2026 The printed Juice of Sapho allows first or last in an ordered action. In a diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 6b24288a..41597a5d 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -1,5 +1,18 @@ # Semuta Drug source update and implementation boundary +## Public Richese Leader Skills entry — 29 October 2026 + +**Prototyped / Partial.** Fresh public Basic and Advanced games with Richese, +the CHOAM expansion deck, classic or CHOAM peers and selected Leader Skills +now use the existing Semuta development initializer. The original all14 +training, private offers and faction setup remain; no played game is converted. +Optional Tech Tokens require three through six seats, and Stronghold Cards +remain Advanced-only. Other expansion families, Nexus, Discoveries, Homeworlds, +independent cards and alternative alliance setups retain their existing paths. +The reaction only covers already-supported fresh-discard continuations. A +Semuta preview does not certify every printed discard opportunity, Leader Skill +interaction or full mode. + ## Basic Tech Tokens and Semuta — 29 October 2026 **Prototyped / Partial.** Fresh Basic Richese/classic tables with exactly the diff --git a/game/engine.ts b/game/engine.ts index 8b35ec27..52c0b7df 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -11013,6 +11013,18 @@ function lobbyModulesSelected(g: Game): boolean { return !!g.lobbyOptions?.leaderSkills || !!g.lobbyOptions?.ecazTreachery || !!g.lobbyOptions?.kullPreview || !!g.nexusCards || g.discoveryEnabled === true || !!g.homeworlds; } +/** Fresh public Richese + Skills entry, using the already connected Semuta + * continuation. Other family/module compositions retain their own entry. */ +function publicSemutaSkillsProfile(g: Game): boolean { + return g.status === 'lobby' && typeof g.advanced === 'boolean' && + g.players.length >= 2 && g.players.length <= 6 && + 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.techTokens || g.players.length >= 3) && (!g.strongholdCards || g.advanced) && + !g.twoPlayerControllers && !g.shortAllianceVictory && !g.coalitionPreview && + !g.homeworlds && !g.nexusCards && !g.discoveryEnabled && !g.ecazTreachery && + !g.leaderSkills && !g.kullPreview && !g.nexusKullPreview; +} /** Route a normal start through the existing genuine setup for the selected modules. */ function startModules(state: Game): Game { const options = state.lobbyOptions ?? {}; @@ -11038,7 +11050,8 @@ function startModules(state: Game): Game { return initializeKullGameForAudit(g); } if (options.ecazTreachery && options.leaderSkills) g.ecazTreachery = true; - if (options.leaderSkills) return initializeLeaderSkillsGameForAudit(g); + if (options.leaderSkills) return publicSemutaSkillsProfile(g) + ? initializeSemutaGameForAudit(g, true) : initializeLeaderSkillsGameForAudit(g); // Keep this selection visible to its initializer even when another module was // also selected; unsupported combinations must reject, not silently omit cards. if (options.ecazTreachery) return initializeEcazTreacheryGameForAudit(g); diff --git a/game/reference.ts b/game/reference.ts index 7db5538b..3f694fe7 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1352,12 +1352,13 @@ export const RULE_TOPICS: RuleTopic[] = [ ...(definition.card.effect === 'semutaDrug' ? [ 'In supported Basic Moritani arrivals, Atomics may discard excess cards from Moritani and its ally. Semuta can privately select one card discarded by another player from that fresh batch. Losses, hand limits and Aftermath stay committed; the arrival resumes after claim or pass.', '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 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.', 'Mandatory and winner battle discards also pause before quoted Advanced CHOAM force-payment income. Claim or Continue leaves the original amount unpaid through casualties and card cleanup, then opens the same allow/Karama response. Allow pays once; cancellation sends that income to the bank without refunding the original force payments. The original post-battle market still completes afterward.', 'Mandatory and winner battle discards also pause before a native Auditor opportunity. Claim or Continue preserves the original survival limit and opponent-used card identities, then eligibility and the full cancellation price use the current opposing hand. A card acquired from the other combatant can be eligible if the audited player did not use it. Payment or private inspection resumes once; only CHOAM receives the historical snapshot.', - 'Fresh three-through-six-seat Basic/Advanced Richese games with CHOAM and optional Ix may retain original unused Tech Tokens under the existing native roster prerequisites. Mandatory and winner discards now offer Semuta before the original winner takes one loser-owned token. Claim or Continue leaves every token and income ledger in place until original cleanup finishes; the winner’s selected award still precedes Face Dance. Advanced may additionally retain original unused Stronghold Cards at two through six seats; combining Tech requires three through six. The six separate public module cards remain unowned until the actual final Mentat assignment, using the board after effects such as Gamont. Neither Semuta claim nor its original closing CHOAM continuation awards a Stronghold Card early. Original Leader Skills require the explicit entry described below; other overlays and the separate Basic Moritani entry remain excluded.', + 'Fresh three-through-six-seat Basic/Advanced Richese games with CHOAM and optional Ix may retain original unused Tech Tokens under the existing native roster prerequisites. Mandatory and winner discards now offer Semuta before the original winner takes one loser-owned token. Claim or Continue leaves every token and income ledger in place until original cleanup finishes; the winner’s selected award still precedes Face Dance. Advanced may additionally retain original unused Stronghold Cards at two through six seats; combining Tech requires three through six. The six separate public module cards remain unowned until the actual final Mentat assignment, using the board after effects such as Gamont. Neither Semuta claim nor its original closing CHOAM continuation awards a Stronghold Card early. The selected public CHOAM-only Leader Skills path and the other bounded development entries retain their own scope; other overlays remain incomplete.', 'A mandatory loser-card discard may also offer Semuta while the original winner’s compulsory used card remains reserved. For native Ixians, claim or Continue settles the original casualties, then restores the one-for-one Suboid/Cyborg substitution and its Karama response. The winning used Poison Tooth is not discarded until substitution or decline finishes; only then is that physical Tooth a separate fresh target before optional winner cleanup.', 'In the Basic native Moritani entry, mandatory loser cards such as Cheap Hero are discarded first and may offer Semuta before winner cleanup and the losing ally’s original retention choice. After the ally chooses one eligible played card or declines, native Karama finishes first; the completed unkept-card batch offers Semuta separately. This split ordering is provisional. A kept or unrelated hand card is never a target. Multiple cards in the same fresh batch require commitment and one private selection; taking a discarded card does not add it to the retention pool. Advanced Moritani, Nexus retention and unresolved casualty parents remain separate.', 'A printed Karama canceling native Harkonnen capture, CHOAM Auditor or quoted battle income, Ixian casualty substitution, or Basic Moritani allied retention now offers that exact public cost before the original cancellation finishes. Claim or Continue restores the saved response and cancels once. Acquiring the cost card cannot restore a captive, inspect a hand, pay the canceled income, exchange casualties or keep the proposed allied card. Later native card cleanup and markets still finish; Worthless conversion, other cancellations and module combinations remain separate.', diff --git a/tests/fixture-semuta-skills.ts b/tests/fixture-semuta-skills.ts index cf7d3155..0538d47e 100644 --- a/tests/fixture-semuta-skills.ts +++ b/tests/fixture-semuta-skills.ts @@ -4,6 +4,7 @@ import { viewGame, type Action, type Game, type Player, } from '../game/engine'; import { TERRITORIES } from '../game/board'; +import { botGroundMoveAllowed, groundMoveRouteDistance } from '../game/bot-mobility'; import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; import { validateLeaderSkills } from '../game/leader-skills'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; @@ -145,6 +146,8 @@ function takeCard(game: Game, actor: string, identity: string): string { /** Genuine setup and original phase controls reach Movement. Only the conserved * board/hand position is staged: native forces return to their own reserves, * then five original counters per combatant and canonical cards change slots. + * Advanced also stages one conserved Atreides reserve counter at Polar Sink; + * its actual adjacent movement earns the immediate Hajr opportunity. * Wallets, printed leaders, skill training, turn, phase and module custody stay real. */ export function createSemutaSkillsMentatFixture(advanced = false, tech = false, strongholds = false): SemutaSkillsMentatFixture { let game = initializeSemutaSkillsGame(advanced, tech, strongholds); @@ -180,6 +183,28 @@ export function createSemutaSkillsMentatFixture(advanced = false, tech = false, } const semuta = takeCard(game, 'r', SEMUTA_DRUG_ID), hajr = takeCard(game, 'a', 'hajr'); const weapon = takeCard(game, 'e', 'Crysknife'), fallback = takeCard(game, 'e', 'Snooper'); + if (advanced) { + const atreides = semutaSkillsPlayer(game, 'a'); + assert.ok(atreides.reserves > 0); + atreides.reserves--; + atreides.forces['polar_sink:0'] = 1; + const own = viewGame(game, 'a'), seat = own.players.find(p => p.id === 'a')!; + const from = 'polar_sink:0'; + const destination = TERRITORIES.filter(place => place.type === 'sand' && + !game.players.some(p => Object.keys(p.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, seat, from, `${place.territory}:${place.sector}`) === 1 && + botGroundMoveAllowed(own, seat, from, `${place.territory}:${place.sector}`, 0)); + assert.ok(destination, 'The original staged force needs a clear adjacent movement.'); + 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 next = nextRicheseSkillsTechNativeStep(game); + game = applyAction(game, next.actor, next.action); + } + assert.equal(semutaSkillsPlayer(game, 'a').hajrWindow?.turn, game.turn); + assert.equal(semutaSkillsPlayer(game, 'a').hajrWindow?.move, semutaSkillsPlayer(game, 'a').moved); + } assertAdvancedNativeCustody(game); assert.deepEqual(semutaSkillsInventory(game), cards); for (const player of game.players) { diff --git a/tests/semuta-skills-public-controls.test.tsx b/tests/semuta-skills-public-controls.test.tsx new file mode 100644 index 00000000..09a3eeb3 --- /dev/null +++ b/tests/semuta-skills-public-controls.test.tsx @@ -0,0 +1,45 @@ +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 Skills setup displays the bounded Semuta notice`, () => { + let game = createGame('SEMUTASKILLSUI', 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:'leaderSkills', 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.leaderSkills, 'The selected original Skills module is present'); + const view = viewGame(game, 'r'); + assert.equal(view.semutaPreview, true); + const html = renderToStaticMarkup(createElement(GameTable, { + game:view, busy:false, send:async () => {}, onExit() {}, + })).replace(/\s+/g, ' '); + assert.match(html, /Semuta Drug development preview/); + assert.match(html, /bounded fresh-discard reactions/); + assert.match(html, /fresh CHOAM-deck Richese games with Leader Skills also enable supported reactions in Basic and Advanced/); + assert.match(html, /not complete Richese or certified expansion rules/); + assert.match(html, /fourteen original skills and native training/); + }, +); diff --git a/tests/semuta-skills-public-start.test.ts b/tests/semuta-skills-public-start.test.ts new file mode 100644 index 00000000..7ad96bb8 --- /dev/null +++ b/tests/semuta-skills-public-start.test.ts @@ -0,0 +1,161 @@ +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)); +const player = (game:Game, id:string) => { + const seat = game.players.find(candidate => candidate.id === id); assert.ok(seat); return seat; +}; + +/** Ordinary public lobby actions and real setup, including each player's + * actual skill offer and native legal leader selection. The optional modules + * are chosen before anyone readies; no saved game is converted. */ +function start(profile: typeof profiles[number]): Game { + let game = createGame('SEMUTASKILLSPUBLIC', 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:'leaderSkills', 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.ok(game.leaderSkills); + assert.equal(game.semutaPreview, true); + assert.equal(!!game.techTokens, profile.tech); + assert.equal(!!game.strongholdCards, profile.strongholds); + for (let n=0; game.status === 'setup' && n<250; 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 = nextRicheseSkillsTechNativeStep(game); + game = applyAction(game,next.actor,next.action); + } + } + assert.equal(game.status,'playing'); + assert.equal(game.leaderSkills?.assignments.length,game.players.length); + return game; +} + +/** Reach real Movement first; only the physical cards are rearranged afterward. + * Move each chosen card from its original deck/cache/hand/discard slot, and + * return a recipient's prior card to the same source when its hand is full. */ +function movement(profile: typeof profiles[number]) { + let game = start(profile); + for (let n=0; n<1500 && !(game.phase === 5 && !game.phaseOpening && !game.response && !game.decision); n++) { + assert.equal(game.turn,1); + const next = nextRicheseSkillsTechNativeStep(game); + game = applyAction(game,next.actor,next.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) { + // Earn Advanced Hajr's immediate window through an actual paid shipment + // to Polar Sink followed by the original legal adjacent Force Movement. + 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 next=nextRicheseSkillsTechNativeStep(game); + game=applyAction(game,next.actor,next.action); + } + assert.equal(game.phase,5); assert.equal(game.active,'a'); + const own=viewGame(game,'a'), seat=own.players.find(candidate=>candidate.id==='a')!; + const from='polar_sink:0'; + const destination=TERRITORIES.filter(place=>place.type==='sand' && + !game.players.some(candidate=>Object.keys(candidate.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,seat,from,`${place.territory}:${place.sector}`)===1 && + botGroundMoveAllowed(own,seat,from,`${place.territory}:${place.sector}`,0)); + assert.ok(destination,'The shipped counter must have a clear adjacent sand movement.'); + 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 next=nextRicheseSkillsTechNativeStep(game); + game=applyAction(game,next.actor,next.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 Skills start preserves the real Hajr to Semuta claim/pass 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 next = clone(pending); + if (claim) next = applyAction(next,'r',{type:'semutaCommit',event}); + else for (const seat of next.players) next = applyAction(next,seat.id,{type:'semutaPass',event}); + assert.equal(next.pendingTreacheryDiscard,null); + assert.equal(next.active,'a'); assert.equal(next.phase,5); + assert.equal(next.hajr.filter(id=>id==='a').length,1); + assert.equal(player(next,'r').hand.some(card=>card.id===f.hajr),claim); + assert.equal(player(next,'r').hand.some(card=>card.id===f.semuta),!claim); + assert.deepEqual(semutaSkillsInventory(next),f.inventory); + assert.deepEqual(next.leaderSkills,before.leaderSkills); + const original = clone(next); + assert.throws(()=>applyAction(next,'r',{type:'semutaCommit',event})); + assert.deepEqual(clone(next),original); + } + 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 original discard reaction legally`); + assert.doesNotThrow(()=>applyAction(clone(pending),'r',action)); + } + }); +} + +void test('a public Skills game without native Richese does not gain a Semuta preview', () => { + let game = createGame('SEMUTASKILLSOTHER',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:'leaderSkills',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.ok(game.leaderSkills); + assert.equal(game.semutaPreview,undefined); +});