Connect ordinary Semuta in Advanced Richese preview
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 04:15:28 +11:00
1 parent 3c1cbd071e
commit b93c5c4cf5
7 files changed
+287 -2

No files matched your search

+5
View File
@@ -1310,6 +1310,11 @@ export function GameTable({
<a href="/rules?topic=card-recruits#card-recruits">Recruits rules</a>
</p>
)}
{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.
</p>
)}
{g.semutaPreview && (
<p className="notice" role="status">
{g.homeworlds ? (
+10
View File
@@ -17,6 +17,16 @@ open mode gates.
## Current checkpoint and work
**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.
The separate marker retains existing Truthtrance and Sapho availability;
other discard producers and optional modules remain unfinished. Ninety related
cases pass, including a genuine public start with staged card and phase windows;
typecheck, lint and the production build pass. Full Advanced and deployed
acceptance remain open.
[Scope](SEMUTA_DISCARD_CONTINUATIONS.md#advanced-classic-richese-public-entry--29-october-2026).
**Three-member preview bot admission — 29 October. Prototyped / Partial.**
The three-member/four-stronghold group preview now lets all four bot profiles
propose admission to a pair in Basic and Advanced games, despite its unstamped
+19
View File
@@ -1,5 +1,24 @@
# Semuta discard producers and continuation inventory
## Advanced classic Richese public entry — 29 October 2026
**Prototyped / Partial.** A fresh explicitly selected Advanced Richese game
with the CHOAM deck, classic factions and optionally CHOAM can initialize the
existing Semuta reaction for supported ordinary-card discards. The holder can
claim or pass while the original effect and continuation remain committed. This
is an unfinished preview: other discard producers and nested continuations
retain their existing guarded scope, and no existing saved game is retrofitted. Expansion
decks beyond CHOAM, optional modules and other native factions remain outside
this public-entry profile.
The [focused journey](../tests/semuta-advanced-public.test.ts) uses public
Advanced start and normal setup; it then stages the native Movement, Revival,
Spice Blow or Storm window and transfers actual cards between custody zones.
The Ghola case also stages a conserved force in the tanks. It exercises Hajr,
Ghola, Harvester and Weather Control play with claim/pass; Hajr also checks
private reaction controls and legal bot responses. This is not a natural
auction, full Advanced game or complete rules certification.
## Native Residual Poison and Kull with independent cards — 29 October 2026
**Prototyped / Partial.** Supported fresh Basic independent-card games with
+27 -2
View File
@@ -2651,6 +2651,8 @@ export type Game = {
moritaniAssassinatePreview?: true;
/** Explicit development-only neutral Semuta reaction profile. */
semutaPreview?: true;
/** Fresh Advanced classic/Richese preview, limited to ordinary discards. */
semutaAdvancedOrdinaryPreview?: true;
/** Fresh Basic independent-card Semuta preview; scoped per discard source. */
semutaRecruitsPreview?: true;
moritaniAssassinate?: MoritaniAssassinateState;
@@ -7915,6 +7917,10 @@ function tupileDiscardSignature(g: Game, c: TupileDiscardContinuation) {
}
function treacheryDiscardIntegrity(g: Game) {
const discovery = g.discoveryEnabled === true && !!g.discoveries && discoveryModeSupported(g);
requireRule(g.semutaAdvancedOrdinaryPreview === undefined ||
g.semutaAdvancedOrdinaryPreview === true && !g.semutaPreview && !g.semutaRecruitsPreview &&
bareAdvancedClassicSemutaProfile(g),
'This Advanced ordinary Semuta preview no longer matches its original Richese profile.');
requireRule(g.semutaRecruitsPreview === undefined || g.semutaRecruitsPreview === true &&
basicIndependentRecruitsSemutaProfile(g) && !g.semutaPreview,
'This Recruits Semuta preview no longer matches its original Basic profile.');
@@ -9718,7 +9724,12 @@ function semutaOfferSupported(
continuation.kind === 'choamLaLaLaDiscard' || continuation.kind === 'choamBalisetDiscard' ||
continuation.kind === 'choamGamontDiscard' || continuation.kind === 'choamJubbaDiscard' ||
continuation.kind === 'karamaShipmentPriceDiscard';
return (g.semutaPreview === true || g.semutaRecruitsPreview === true &&
return (g.semutaPreview === true ||
g.semutaAdvancedOrdinaryPreview === true && bareAdvancedClassicSemutaProfile(g) &&
(!g.pendingTreacheryDiscard || !!g.pendingTreacheryDiscard.reaction) &&
continuation.kind === 'ordinaryCardDiscard' &&
['hajr', 'ghola', 'harvester', 'weather'].includes(continuation.effect) ||
g.semutaRecruitsPreview === true &&
(!g.pendingTreacheryDiscard || !!g.pendingTreacheryDiscard.reaction) &&
(independentNativeDiscard || continuation.kind === 'ordinaryCardDiscard' &&
['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes(continuation.effect) ||
@@ -10881,7 +10892,10 @@ function startModules(state: Game): Game {
if (g.homeworlds)
return g.expansions.length ? initializeHomeworldOccupationGameForAudit(g) : initializeHomeworldGameForAudit(g);
if (g.strongholdCards) return initializeStrongholdFactionsGameForAudit(g);
return initializeFactionExpansionsGame(g, bareBasicSemutaProfile(g));
const advancedOrdinarySemuta = bareAdvancedClassicSemutaProfile(g);
const initialized = initializeFactionExpansionsGame(g, bareBasicSemutaProfile(g));
if (advancedOrdinarySemuta) initialized.semutaAdvancedOrdinaryPreview = true;
return initialized;
}
/** Public consistency only; each module's genuine initializer validates its own envelope. */
function baseStartProfileBlock(g: Game): string | null {
@@ -13253,6 +13267,16 @@ function bareBasicSemutaProfile(g: Game): boolean {
!g.strongholdCards && !g.ecazTreachery && !g.discoveryEnabled &&
!g.discoveries && !g.discoveryStash && !g.greatMaker;
}
/** Fresh Advanced Richese starts with only the CHOAM deck and classic peers.
* The existing discard continuation decides which offers are supported. */
function bareAdvancedClassicSemutaProfile(g: Game): boolean {
return g.advanced === true && 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' || faction(p.faction).expansion === 'base') &&
!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens &&
!g.strongholdCards && !g.ecazTreachery && !g.discoveryEnabled &&
!g.discoveries && !g.discoveryStash && !g.greatMaker && !g.kullPreview && !g.nexusKullPreview;
}
/** Collection's printed bonus is Advanced-only. This fresh development entry
* retains original Ecaz/Richese setup without admitting unrelated E3 overlays. */
function bareAdvancedEcazSemutaProfile(g: Game): boolean {
@@ -47066,6 +47090,7 @@ export function viewGame(state: Game, id: string, now?: number) {
botsPending: g.botsPending ?? false,
semutaReaction: projectedSemutaReaction(g, me),
semutaPreview: g.semutaPreview === true,
semutaAdvancedOrdinaryPreview: g.semutaAdvancedOrdinaryPreview === true,
semutaRecruitsPreview: g.semutaRecruitsPreview === true,
automaticContinuationPending: (!!g.pendingTreacheryDiscard && !g.pendingTreacheryDiscard.reaction) || homeworldRevealPending(g) || homeworldShipmentAutomatic(g) || homeworldOccupiedIncomeAutomatic(g) || grummanCollectionAutomatic(g) || tupileAutomatic(g),
botNextActionAt: g.botNextActionAt ?? null,
+1
View File
@@ -1341,6 +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.',
'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.',
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, createGame, joinGame, newPlayer, viewGame } from '../game/engine';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' :
specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true};
},
});
const { GameTable } = await import('../components/game-table');
aliases.deregister();
void test('public Advanced Richese setup displays the limited ordinary Semuta notice', () => {
let game = createGame('ADVSEMUTAUI', newPlayer('a', 'Atreides', 'atreides'), true, ['choam']);
joinGame(game, newPlayer('r', 'Richese', 'richese'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
for (const player of game.players) game = applyAction(game, player.id, {type:'ready'});
game = applyAction(game, 'a', {type:'start', advancedPreview:true});
assert.equal(game.status, 'setup');
const view = viewGame(game, 'r');
assert.equal(view.semutaAdvancedOrdinaryPreview, true);
assert.equal(view.semutaPreview, false);
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, /Other discard opportunities remain unfinished/);
assert.doesNotMatch(html, /Semuta Drug development preview/);
assert.doesNotMatch(html, /Semuta Drug with Homeworlds development preview/);
});
+184
View File
@@ -0,0 +1,184 @@
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 { bribePromiseModeSupported } from '../game/bribe-promises';
import { revivalPromiseModeSupported } from '../game/revival-promises';
import { alliancePromiseModeSupported } from '../game/alliance-promises';
import { bidPromiseModeSupported } from '../game/bid-promises';
import { createSaphoTruthOrderFixture, playSaphoTruthOrder, finishTruthOrder,
finishOriginalTruthQuestions, inventory as saphoInventory } from './fixture-sapho-truth-order';
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)!;
function inventory(game: Game): string[] {
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...game.players.flatMap(player => player.hand)];
const ids = cards.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length);
return ids;
}
function take(game: Game, idOrEffect: string) {
const zones = [game.deck, game.discard, game.richeseCache!, ...game.players.map(player => player.hand)];
for (const zone of zones) {
const index = zone.findIndex(card => card.id === idOrEffect || card.effect === idOrEffect);
if (index >= 0) return zone.splice(index, 1)[0];
}
throw new Error(`Missing physical card ${idOrEffect}.`);
}
/** Use the actual public Advanced start and setup. For the specific discard
* window, transfer the two existing physical cards and stage Movement; this
* does not represent a natural auction or certify a complete game. */
function fixture() {
let game = createGame('ADVSEMUTAPUBLIC', newPlayer('a', 'Atreides', 'atreides'), true, ['choam']);
joinGame(game, newPlayer('r', 'Richese', 'richese'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
for (const player of game.players) game = act(game, player.id, {type:'ready'});
assert.throws(() => act(game, 'a', {type:'start'}), /Advanced.*preview/i);
game = act(game, 'a', {type:'start', advancedPreview:true});
assert.equal(game.status, 'setup');
assert.equal(game.semutaAdvancedOrdinaryPreview, true);
assert.equal(game.semutaPreview, undefined);
assert.equal(game.advanced, true);
for (let count = 0; game.status === 'setup' && count < 80; count++) {
let next: Game | undefined;
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(row => row.id === player.id)!.bot = 'Medium';
const action = botActions(view)[0];
if (action) { next = act(game, player.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 hajr = take(game, 'hajr');
seat(game, 'r').hand.push(semuta);
seat(game, 'a').hand.push(hajr);
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']});
// Advanced Hajr is available after the original Force Movement, not before.
game = act(game, 'a', {type:'move', from:'arrakeen:10', territory:'imperial_basin',
sector:9, amount:1, elite:0});
assert.equal(seat(game, 'a').moved, 1);
return {game, semuta, hajr, before};
}
for (const claim of [false, true]) void test(`public Advanced Richese offers Hajr to Semuta and ${claim ? 'claim' : 'pass'} resumes Movement`, () => {
const f = fixture(), original = structuredClone(f.game);
let game = act(f.game, 'a', {type:'card', card:f.hajr.id});
assert.deepEqual(f.game, original);
assert.ok(game.hajr.includes('a'));
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
assert.equal(pending.continuation.kind, 'ordinaryCardDiscard');
assert.equal(pending.reaction?.stage, 'offer');
const event = pending.batch.event;
const reaction = viewGame(game, 'r').semutaReaction; assert.ok(reaction?.canCommit);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'r');
view.players.find(player => player.id === 'r')!.bot = difficulty;
assert.ok(botActions(view).some(action => action.type === 'semutaCommit' || action.type === 'semutaPass'), difficulty);
}
if (claim) {
game = act(game, 'r', {type:'semutaCommit', event});
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = act(game, 'r', {type:'semutaSelect', event, card:f.hajr.id});
} else {
for (const actor of ['a','r','e']) game = act(game, actor, {type:'semutaPass', event});
}
assert.equal(game.pendingTreacheryDiscard, null);
assert.ok(game.hajr.includes('a'));
assert.equal(seat(game, 'r').hand.some(card => card.id === f.hajr.id), claim);
assert.equal(game.discard.some(card => card.id === f.hajr.id), !claim);
assert.deepEqual(inventory(game), f.before);
game = act(game, 'a', {type:'endMovement'});
assert.ok(!game.movementRemaining?.includes('a'));
});
for (const effect of ['ghola', 'harvester', 'weather'] as const) {
for (const claim of [false, true]) void test(`public Advanced Richese offers ${effect} to Semuta and ${claim ? 'claim' : 'pass'} preserves its effect`, () => {
const f = fixture();
let game = f.game;
// Keep actual card inventory conserved while replacing the staged Hajr.
game.deck.push(take(game, f.hajr.id));
const card = take(game, effect);
seat(game, 'a').hand.push(card);
assert.deepEqual(inventory(game), f.before);
if (effect === 'ghola') {
Object.assign(game, {phase:4, active:null});
const player = seat(game, 'a');
assert.ok(player.forces['arrakeen:10'] > 0);
player.forces['arrakeen:10']--;
player.tanks++;
} else if (effect === 'harvester') {
Object.assign(game, {phase:1, active:null, nexus:false});
game.spiceWindow = {territory:'hagga_basin', sector:12, amount:6, harvested:false};
game.spice['hagga_basin:12'] = 6;
} else {
Object.assign(game, {phase:0, active:null, stormPending:2, stormResolution:null,
stormCardWindowOpen:true});
}
const before = {reserves:seat(game, 'a').reserves, tanks:seat(game, 'a').tanks};
const action: Action = effect === 'ghola' ? {type:'card', card:card.id, amount:1, elite:0}
: effect === 'weather' ? {type:'card', card:card.id, amount:1}
: {type:'card', card:card.id};
game = act(game, 'a', action);
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
assert.equal(pending.continuation.kind, 'ordinaryCardDiscard');
assert.equal(pending.reaction?.stage, 'offer');
assert.equal(viewGame(game, 'r').semutaReaction?.canCommit, true);
if (effect === 'ghola') {
assert.equal(seat(game, 'a').tanks, before.tanks - 1);
assert.equal(seat(game, 'a').reserves, before.reserves + 1);
} else if (effect === 'harvester') {
assert.equal(game.spice['hagga_basin:12'], 12);
assert.equal(game.spiceWindow?.amount, 12);
assert.equal(game.spiceWindow?.harvested, true);
} else assert.equal(game.stormPending, 1);
const committed = structuredClone({phase:game.phase, stormPending:game.stormPending,
spice:game.spice, spiceWindow:game.spiceWindow, reserves:seat(game, 'a').reserves,
tanks:seat(game, 'a').tanks});
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:card.id});
} else for (const actor of ['a', 'r', 'e']) game = act(game, actor, {type:'semutaPass', event});
assert.equal(game.pendingTreacheryDiscard, null);
assert.deepEqual({phase:game.phase, stormPending:game.stormPending, spice:game.spice,
spiceWindow:game.spiceWindow, reserves:seat(game, 'a').reserves,
tanks:seat(game, 'a').tanks}, committed);
assert.equal(seat(game, 'r').hand.some(item => item.id === card.id), claim);
assert.equal(game.discard.some(item => item.id === card.id), !claim);
assert.deepEqual(inventory(game), f.before);
});
}
void test('Advanced ordinary Semuta entry preserves Sapho in a simultaneous Truthtrance order', () => {
const f = createSaphoTruthOrderFixture({advanced:true, mode:'first'});
assert.equal(f.game.semutaAdvancedOrdinaryPreview, true);
assert.equal(f.game.semutaPreview, undefined);
assert.ok(viewGame(f.game, f.owner).saphoTruthOrderChoices.some(option =>
option.card === f.card && option.mode === 'first'));
let game = playSaphoTruthOrder(f.game, f.owner, f.card, 'first');
assert.equal(game.pendingTreacheryDiscard ?? null, null);
game = finishTruthOrder(game);
assert.equal(game.truthtrance!.queue[0].player, f.owner);
game = finishOriginalTruthQuestions(game);
assert.equal(game.truthtrance, null);
assert.deepEqual(saphoInventory(game), f.originalCards);
});
void test('Advanced ordinary Semuta entry preserves existing bribe, revival, alliance and bid question admission', () => {
const {game} = fixture();
assert.equal(game.semutaAdvancedOrdinaryPreview, true);
assert.equal(game.semutaPreview, undefined);
for (const supported of [bribePromiseModeSupported, revivalPromiseModeSupported,
alliancePromiseModeSupported, bidPromiseModeSupported]) assert.equal(supported(game), true);
});