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

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

No files matched your search

+1 -1
View File
@@ -1312,7 +1312,7 @@ export function GameTable({
)}
{g.semutaAdvancedOrdinaryPreview && (
<p className="notice" role="status">
Advanced Semuta preview: supported ordinary Hajr, Ghola, Harvester and Weather Control discards can be claimed. Other discard opportunities remain unfinished.
Advanced Semuta preview: supported Hajr, Ghola, Harvester, Weather Control, completed Box, Distrans and simple Truthtrance discards can be claimed, along with supported battle cleanup. Other discard opportunities remain unfinished.
</p>
)}
{g.semutaPreview && (
+9
View File
@@ -17,6 +17,15 @@ open mode gates.
## Current checkpoint and work
**Advanced Richese battle and special Semuta — 29 October. Prototyped / Partial.**
The public Advanced preview now supports Semuta after clean paid Box searches,
Distrans, simple definite Truthtrance answers and supported loser, mandatory
winner and selected winner battle discards. Claim or pass resumes the committed
effect; existing Advanced action promises and Sapho controls remain available.
The 88 related tests, quick type/lint checks and production build pass. Other discard parents,
optional modules and full Advanced acceptance remain open. See the
[scope and evidence](SEMUTA_DISCARD_CONTINUATIONS.md#advanced-classic-battle-and-special-discards--29-october-2026).
**Advanced Richese ordinary Semuta — 29 October. Prototyped / Partial.**
Fresh Advanced games with Richese, the CHOAM deck and classic or CHOAM peers
offer Semuta after supported Hajr, Ghola, Harvester and Weather discards.
+13
View File
@@ -1,5 +1,18 @@
# Semuta discard producers and continuation inventory
## Advanced classic battle and special discards — 29 October 2026
**Prototyped / Partial.** The public Advanced classic/Richese preview extends
its limited Semuta marker to supported loser, mandatory winner and selected
winner battle discards, a completed paid Box search, Distrans and simple
definite Truthtrance answers. Claim or pass resumes each original committed
effect. Other discard parents and optional modules remain unfinished.
The focused [battle journeys](../tests/semuta-advanced-public-battle.test.ts)
and [special journeys](../tests/semuta-advanced-specials.test.ts) start real
Advanced games and stage conserved cards and relevant phase or battle state;
they exercise actual actions and do not certify full Advanced games.
## Advanced classic Richese public entry — 29 October 2026
**Prototyped / Partial.** A fresh explicitly selected Advanced Richese game
+9 -3
View File
@@ -9727,8 +9727,13 @@ function semutaOfferSupported(
return (g.semutaPreview === true ||
g.semutaAdvancedOrdinaryPreview === true && bareAdvancedClassicSemutaProfile(g) &&
(!g.pendingTreacheryDiscard || !!g.pendingTreacheryDiscard.reaction) &&
continuation.kind === 'ordinaryCardDiscard' &&
['hajr', 'ghola', 'harvester', 'weather'].includes(continuation.effect) ||
(continuation.kind === 'ordinaryCardDiscard' &&
['hajr', 'ghola', 'harvester', 'weather'].includes(continuation.effect) ||
continuation.kind === 'battleResolved' || continuation.kind === 'winnerMandatoryDiscard' ||
continuation.kind === 'battleCleanup' && continuation.source === 'winner' ||
continuation.kind === 'nullentropyDiscard' || continuation.kind === 'distransDiscard' ||
continuation.kind === 'truthtranceDiscard' && continuation.promise === null &&
(continuation.record.question.kind === 'fact' || continuation.record.question.kind === 'freeform')) ||
g.semutaRecruitsPreview === true &&
(!g.pendingTreacheryDiscard || !!g.pendingTreacheryDiscard.reaction) &&
(independentNativeDiscard || continuation.kind === 'ordinaryCardDiscard' &&
@@ -42874,7 +42879,8 @@ function applyActionInner(
`${p.name} played Distrans to give ${recipient.name} one private Treachery Card, then discarded Distrans. No spice, purchase bonus or recipient confirmation is required.`,
{ faction: p.faction, name: 'Distrans' },
);
if ((g.semutaPreview || g.semutaRecruitsPreview && basicIndependentRecruitsSemutaProfile(g)) && !g.pendingTreacheryDiscard) {
if ((g.semutaPreview || g.semutaAdvancedOrdinaryPreview && bareAdvancedClassicSemutaProfile(g) ||
g.semutaRecruitsPreview && basicIndependentRecruitsSemutaProfile(g)) && !g.pendingTreacheryDiscard) {
const continuation: DistransDiscardContinuation = {
kind: 'distransDiscard', owner: p.id, recipient: recipient.id,
transferred: result.transferred.id, card: result.discarded.id,
+1 -1
View File
@@ -1341,7 +1341,7 @@ export const RULE_TOPICS: RuleTopic[] = [
? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other public sources include ordinary cards, paid Box, Ornithopter, Distrans, definite ordinary Truthtrance answers including queued questions, supported shipment promises and battle answers before or after plan submission/reveal, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase or winning-bid payment before lot settlement, a completed CHOAM card sale after its bank income, clean Kulon Worthless disposal before its movement bonus, proactive or reactive La La La disposal before blocking free revival, proactive or reactive Baliset before restricting movement, proactive Trip to Gamont before its ordinary/elite return or native No-Field reveal and capped recall, accepted Jubba Cloak before storm protection, and three battle batches. A private normal-auction or Black Market Ixian ally replacement also pauses before drawing, without showing its face until a holder commits; original payment and seller income settle once. Cache purchases cannot use native Ixian allied replacement. Ordinary definite Truthtrance answers pause even when another question stays reserved or the answer bound a supported shipment or unsubmitted battle-plan promise. Submitted/revealed plans bind no new promise, but their answers still offer Semuta without changing the plan. Under the user-selected exchange ruling, Commit may replace Semuta even at your faction hand limit: acquisition and Semuta disposal settle atomically, without increasing your completed hand. Reserved incoming cards still need room. For multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, other reactive Worthless uses, inspected Residual Poison battles, nested responses, other pending rewards and wider interactions remain unfinished.'
: 'Reference component only: this Richese card’s game actions are not enabled.',
...(definition.card.effect === 'semutaDrug' ? [
'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. Other discard parents and optional-module combinations remain unfinished. Existing games are not retrofitted.',
'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 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.',
+153
View File
@@ -0,0 +1,153 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
function inventory(game: Game): string[] {
const ids = [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [],
...game.players.map(player => player.hand)].flat().map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length);
return ids;
}
function take(game: Game, predicate: (card: Game['deck'][number]) => boolean) {
for (const zone of [game.deck, game.discard, game.richeseCache!, ...game.players.map(player => player.hand)]) {
const index = zone.findIndex(predicate);
if (index >= 0) return zone.splice(index, 1)[0];
}
throw new Error('Missing original physical card.');
}
function setup(kind: 'loser' | 'mandatory' | 'selected') {
let game = createGame('ADVSEMUTABATTLE', newPlayer('a', 'Atreides', 'atreides'), true, ['choam']);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
joinGame(game, newPlayer('r', 'Richese', 'richese'));
for (const player of game.players) game = act(game, player.id, {type:'ready'});
game = act(game, 'a', {type:'start', advancedPreview:true});
assert.equal(game.semutaAdvancedOrdinaryPreview, true);
assert.equal(game.semutaPreview, undefined);
for (let n = 0; game.status === 'setup' && n < 100; n++) {
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next, 'Native setup must advance.');
game = act(game, next.actor, next.action);
}
assert.equal(game.status, 'playing');
const cards = inventory(game);
// Preserve physical inventory while positioning the exact battle cards and
// keep the discard free from unrelated earlier setup choices.
for (const player of game.players) game.deck.push(...player.hand.splice(0));
const semuta = take(game, card => card.id === SEMUTA_DRUG_ID);
const winnerWeapon = take(game, card => card.kind === 'projectile');
const loserDefense = take(game, card => card.kind === 'shield');
const hero = kind === 'mandatory' ? take(game, card => card.kind === 'hero') : null;
seat(game, 'r').hand.push(semuta);
seat(game, 'a').hand.push(winnerWeapon, ...(hero ? [hero] : []));
seat(game, 'e').hand.push(loserDefense);
assert.deepEqual(inventory(game), cards);
// Put conserved physical force groups in a clear shared territory. Emperor's
// existing Advanced elite counters stay in reserves; both armies are normal.
for (const id of ['a', 'e']) {
const player = seat(game, id);
player.reserves += Object.values(player.forces).reduce((total, amount) => total + amount, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((total, amount) => total + amount, 0);
player.elites.forces = {};
}
const amount = id === 'a' ? 6 : 2;
player.reserves -= amount;
player.forces['red_chasm:7'] = amount;
}
Object.assign(game, {phase:6, active:'a', order:['a','e','r'], storm:18, ready:[],
response:null, decision:null, phaseOpening:null, battle:null});
game = act(game, 'a', {type:'chooseBattle', territory:'red_chasm', target:'e'});
for (let n = 0; n < 80; n++) {
if (game.battle && !game.response && !game.decision && !game.phaseOpening &&
!game.battle.preparation && (!game.battle.preLeader || game.battle.preLeader.closed)) break;
const next = nextRicheseStrongholdsNativeStep(game);
assert.ok(next, 'The current native battle interaction must finish before preparation.');
game = act(game, next.actor, next.action);
}
assert.ok(game.battle && !game.battle.preparation && (!game.battle.preLeader || game.battle.preLeader.closed));
const winnerLeader = hero?.id ?? seat(game, 'a').leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((left, right) => right.strength - left.strength)[0]!.id;
const loserLeader = seat(game, 'e').leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((left, right) => left.strength - right.strength)[0]!.id;
game = act(game, 'a', {type:'battlePlan', dial:hero ? 5 : 1, support:hero ? 5 : 1,
leader:winnerLeader, weapon:winnerWeapon.id, defense:null});
game = act(game, 'e', {type:'battlePlan', dial:0, support:0,
leader:loserLeader, weapon:null, defense:loserDefense.id});
const voters = viewGame(game, 'a').battle!.traitorVoters;
assert.equal(voters.length, 2);
game = act(game, voters[0], {type:'traitorCall', call:false});
return {game, finalActor:voters[1], loserDefense, winnerWeapon, hero, cards};
}
function react(game: Game, claim: boolean, selected: string): Game {
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
const event = pending.batch.event;
if (claim) {
game = act(game, 'r', {type:'semutaCommit', event});
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = act(game, 'r', {type:'semutaSelect', event, card:selected});
} else {
for (const player of game.players) {
if (!game.pendingTreacheryDiscard) break;
game = act(game, player.id, {type:'semutaPass', event});
}
}
return game;
}
function advance(game: Game, until: (state: Game) => boolean): Game {
for (let n = 0; n < 400 && !until(game); n++) {
if (game.pendingTreacheryDiscard?.reaction) game = react(game, false, '');
else {
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next, `Native continuation needed at ${game.phase}/${game.decision?.kind}`);
game = act(game, next.actor, next.action);
}
}
assert.ok(until(game));
return game;
}
for (const kind of ['loser', 'mandatory', 'selected'] as const)
for (const claim of [false, true]) void test(`public Advanced Semuta ${kind} battle discard: ${claim ? 'claim' : 'pass'}`, () => {
const f = setup(kind);
let game = finishSemutaBattleOutcome(act(f.game, f.finalActor, {type:'traitorCall',call:false}));
if (kind !== 'loser') {
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'battle:mandatory');
game = react(game, false, f.loserDefense.id);
if (kind === 'selected') {
game = advance(game, state => state.decision?.kind === 'battleCards');
game = act(game, 'a', {type:'decision', discard:[f.winnerWeapon.id]});
}
}
const card = kind === 'loser' ? f.loserDefense : kind === 'mandatory' ? f.hero! : f.winnerWeapon;
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
assert.equal(pending.batch.cause, kind === 'loser' ? 'battle:mandatory' :
kind === 'mandatory' ? 'battle:winnerMandatory' : 'battle:winner');
assert.ok(pending.batch.entries.some(entry => entry.card.id === card.id));
const original = structuredClone(game), event = pending.batch.event;
for (const difficulty of DIFFICULTIES) {
const botGame = structuredClone(game);
seat(botGame, 'r').bot = difficulty;
const action = botActions(viewGame(botGame, 'r')).find(candidate =>
candidate.type === 'semutaCommit' || candidate.type === 'semutaPass');
assert.ok(action, difficulty);
assert.doesNotThrow(() => act(botGame, 'r', action), difficulty);
}
game = react(game, claim, card.id);
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(seat(game, 'r').hand.some(held => held.id === card.id), claim);
assert.equal(game.discard.some(discarded => discarded.id === card.id), !claim);
assert.equal(seat(game, 'e').tanks, seat(original, 'e').tanks);
for (const player of game.players) assert.equal(player.spice, seat(original, player.id).spice);
assert.deepEqual(inventory(game), f.cards);
assert.throws(() => act(game, 'r', {type:'semutaCommit', event}));
assert.equal(game.lastBattleContext?.event, original.lastBattleContext?.event);
game = advance(game, state => state.phase >= 7 && clean(state));
assert.deepEqual(inventory(game), f.cards);
});
@@ -34,7 +34,8 @@ void test('public Advanced Richese setup displays the limited ordinary Semuta no
const html = renderToStaticMarkup(createElement(GameTable, {
game:view, busy:false, send:async () => {}, onExit() {},
}));
assert.match(html, /Advanced Semuta preview: supported ordinary Hajr, Ghola, Harvester and Weather Control discards can be claimed/);
assert.match(html, /Advanced Semuta preview: supported Hajr, Ghola, Harvester, Weather Control, completed Box, Distrans and simple Truthtrance discards can be claimed/);
assert.match(html, /supported battle cleanup/);
assert.match(html, /Other discard opportunities remain unfinished/);
assert.doesNotMatch(html, /Semuta Drug development preview/);
assert.doesNotMatch(html, /Semuta Drug with Homeworlds development preview/);
+116
View File
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
type Kind = 'box' | 'distrans' | 'truth';
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
const player = (game: Game, id: string) => game.players.find(seat => seat.id === id)!;
function inventory(game: Game) {
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
...game.players.flatMap(seat => seat.hand)];
const ids = cards.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length);
return ids;
}
function take(game: Game, identity: string) {
for (const zone of [game.deck, game.discard, game.richeseCache!, ...game.players.map(seat => seat.hand)]) {
const index = zone.findIndex(card => card.id === identity || card.effect === identity);
if (index >= 0) return zone.splice(index, 1)[0];
}
throw new Error(`Missing physical card ${identity}.`);
}
/** Genuine Advanced public start and normal setup. To reach the selected clean
* special-card window, the phase and physical card custody are staged. No
* unopened game or saved room is altered, and card identities are conserved. */
function fixture(kind: Kind) {
let game = createGame('ADVSEMUTASPECIAL', newPlayer('a','Atreides','atreides'), true, ['choam']);
joinGame(game, newPlayer('r','Richese','richese'));
joinGame(game, newPlayer('e','Emperor','emperor'));
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
game = act(game, 'a', {type:'start',advancedPreview:true});
assert.equal(game.semutaAdvancedOrdinaryPreview, true);
assert.equal(game.semutaPreview, undefined);
for (let count = 0; game.status === 'setup' && count < 80; count++) {
let next: Game | undefined;
for (const seat of game.players) {
const view = viewGame(game, seat.id);
view.players.find(row => row.id === seat.id)!.bot = 'Medium';
const action = botActions(view)[0];
if (action) { next = act(game, seat.id, action); break; }
}
assert.ok(next, 'The genuine setup has a legal next step.');
game = next;
}
assert.equal(game.status, 'playing');
const before = inventory(game);
const semuta = take(game, SEMUTA_DRUG_ID);
const source = take(game, kind === 'box' ? 'nullentropyBox' : kind === 'truth' ? 'truthtrance' : 'distrans');
const extra = take(game, 'hajr');
player(game, 'r').hand.push(semuta);
player(game, 'a').hand.push(source);
if (kind === 'box') game.discard.push(extra);
else if (kind === 'distrans') player(game, 'a').hand.push(extra);
else game.deck.push(extra);
assert.deepEqual(inventory(game), before);
Object.assign(game, {turn:2, phase:5, active:'a', storm:18, ready:[], response:null,
decision:null, phaseOpening:null, stormPending:null, movementRemaining:['a','r','e']});
const beforeSpice = player(game, 'a').spice;
if (kind === 'box') {
game = act(game, 'a', {type:'card',card:source.id});
// The native Box selects a sole eligible discard without another prompt.
if (game.pendingNullentropy)
game = act(game, 'a', {type:'decision',event:game.pendingNullentropy.event,card:extra.id});
assert.equal(player(game,'a').spice,beforeSpice - 2);
assert.ok(player(game,'a').hand.some(card => card.id === extra.id));
} else if (kind === 'distrans') {
game = act(game,'a',{type:'card',card:source.id,target:'e',give:extra.id});
assert.ok(player(game,'e').hand.some(card => card.id === extra.id));
} else {
game = act(game,'a',{type:'card',card:source.id});
while (game.truthtrance?.stage === 'priority') {
const next = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(next);
game = act(game,next.id,{type:'truthPass'});
}
game = act(game,'a',{type:'truthAsk',question:{kind:'freeform',target:'e',
scope:'fact',text:'Do you intend to protect Arrakeen?'}});
game = act(game,'e',{type:'truthAnswer',answer:'yes'});
assert.equal(game.truthHistory?.at(-1)?.answer, 'yes');
}
return {game, source, extra, before, beforeSpice};
}
for (const kind of ['box','distrans','truth'] as const) for (const claim of [false,true])
void test(`Advanced public ${kind} Semuta ${claim ? 'claim' : 'pass'} preserves the committed original effect`, () => {
const f = fixture(kind), pending = f.game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
assert.equal(pending.continuation.kind, kind === 'box' ? 'nullentropyDiscard' :
kind === 'truth' ? 'truthtranceDiscard' : 'distransDiscard');
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [f.source.id]);
assert.equal(viewGame(f.game, 'r').semutaReaction?.canCommit, true);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(f.game, 'r');
view.players.find(seat => seat.id === 'r')!.bot = difficulty;
assert.ok(botActions(view).some(action => action.type === 'semutaCommit' || action.type === 'semutaPass'), difficulty);
}
let game = f.game;
if (claim) {
game = act(game,'r',{type:'semutaCommit',event:pending.batch.event});
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = act(game,'r',{type:'semutaSelect',event:pending.batch.event,card:f.source.id});
} else for (const seat of game.players) game = act(game,seat.id,{type:'semutaPass',event:pending.batch.event});
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(player(game,'r').hand.some(card => card.id === f.source.id),claim);
assert.equal(game.discard.some(card => card.id === f.source.id),!claim);
if (kind === 'box') {
assert.equal(player(game,'a').spice,f.beforeSpice - 2);
assert.ok(player(game,'a').hand.some(card => card.id === f.extra.id));
} else if (kind === 'distrans') assert.ok(player(game,'e').hand.some(card => card.id === f.extra.id));
else assert.equal(game.truthHistory?.at(-1)?.answer,'yes');
assert.deepEqual(inventory(game),f.before);
game = act(game,'a',{type:'endMovement'});
assert.ok(!game.movementRemaining?.includes('a'));
});