Enable Semuta in public Discovery games
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
b2b9a024bd
commit
98ead3a4b5
11 files changed
+436
-8
No files matched your search
@@ -1372,6 +1372,15 @@ export function GameTable({
|
||||
Other Homeworld producers and Basic unresolved Tupile loss remain
|
||||
guarded. Not complete or certified Homeworld rules.{' '}
|
||||
</>
|
||||
) : g.discoveryEnabled ? (
|
||||
<>
|
||||
Semuta Drug with Discoveries preview · supported fresh discards,
|
||||
including Hajr, Shrine card-role costs and Juice of Sapho at the
|
||||
Testing Station, offer the existing neutral reaction. The original
|
||||
card effect and Discovery continuation remain in place. Other
|
||||
discard opportunities remain unfinished. This is not complete
|
||||
or certified expansion rules.{' '}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Semuta Drug development preview · bounded fresh-discard reactions,
|
||||
|
||||
@@ -17,6 +17,20 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**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
|
||||
prerequisites. The existing Sapho Testing Station reorder now parks its next
|
||||
choice during the neutral reaction and resumes it once after claim or pass.
|
||||
Genuine public setup and native Hajr journeys cover six module profiles, with
|
||||
conserved card staging; a separate genuine opt-in setup stages conserved
|
||||
station positions and cards for both ordering modes. Two GameTable cases check
|
||||
the visible preview notice. Nine related files pass 46 cases, including 14 new
|
||||
cases; type checking, lint and the production build pass. Other discard sources,
|
||||
combinations, the station's existing intervention race and deployed acceptance
|
||||
remain open. Existing games are not converted.
|
||||
[Scope](SEMUTA_SOURCE_UPDATE_20260907.md#public-richese-discovery-entry--29-october-2026).
|
||||
|
||||
**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
|
||||
|
||||
@@ -9,8 +9,9 @@ both occupants still adjust the cumulative distance once. The reordered frame
|
||||
has a new event and a signed record of the original order. Unchanged frames
|
||||
retain their original signatures. A position already held, a completed choice,
|
||||
and another use after a recovered Sapho are not offered in this first version.
|
||||
Semuta reaction profiles are guarded until that separate discard continuation
|
||||
is connected. The ordering is a provisional online interpretation of Sapho's
|
||||
In the supported Semuta/Discovery profile, the committed reorder pauses for
|
||||
the neutral discard reaction; claim or pass resumes the same next station
|
||||
choice. The ordering is a provisional online interpretation of Sapho's
|
||||
printed first/last effect; no combined publisher clarification is claimed.
|
||||
The current occupant may make its choice before the other occupant plays Sapho,
|
||||
including when an automated seat acts first. There is no neutral pre-choice
|
||||
|
||||
+12
-2
@@ -1,5 +1,15 @@
|
||||
# Rule decision index
|
||||
|
||||
## Public Semuta with Discoveries — 29 October 2026
|
||||
|
||||
Fresh Basic and Advanced CHOAM-deck Richese games with Discoveries use the
|
||||
existing bounded Semuta continuation, preserving normal Tech and Advanced
|
||||
Stronghold prerequisites. A Sapho reorder at the Testing Station now parks
|
||||
its original next choice through the neutral Semuta reaction and resumes it
|
||||
once. Existing games are not converted; other discard producers and optional
|
||||
combinations remain incomplete.
|
||||
[Scope](SEMUTA_SOURCE_UPDATE_20260907.md#public-richese-discovery-entry--29-october-2026).
|
||||
|
||||
## Public Semuta with Leader Skills — 29 October 2026
|
||||
|
||||
Fresh public Basic and Advanced Richese games with the CHOAM deck and Leader
|
||||
@@ -17,8 +27,8 @@ two-occupant Ecological Testing Station queue, an occupant can provisionally
|
||||
change its position before either choice. Both original adjustments still
|
||||
apply once to the original revealed storm source; physical storm order is not
|
||||
changed. Already-held positions, completed choices and a second recovered
|
||||
Sapho use remain outside this first version. Semuta reaction profiles remain
|
||||
guarded until their discard continuation is connected. This is an online
|
||||
Sapho use remain outside this first version. The later Semuta/Discovery
|
||||
continuation above preserves the ordered choice after claim or pass. This is an online
|
||||
priority interpretation, not an explicit combined publisher ruling.
|
||||
[Scope](DISCOVERY_PROTOTYPE.md#sapho-at-ecological-testing-station--29-october-2026).
|
||||
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# Semuta Drug source update and implementation boundary
|
||||
|
||||
## Public Richese Discovery entry — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** Fresh public Basic and Advanced Richese games with
|
||||
the CHOAM deck and Discoveries use the existing bounded Semuta continuation.
|
||||
Original Discovery components, setup and eligible Tech/Advanced Strongholds
|
||||
remain. Skills, other decks, Homeworlds, Nexus and independent cards retain
|
||||
their separate paths. A committed Sapho order change at Ecological Testing
|
||||
Station offers its physical discard; claim or pass restores the same station
|
||||
choice and both cumulative adjustments. Existing games are not converted.
|
||||
Other discard producers and full rules acceptance remain unfinished.
|
||||
|
||||
## Public Richese Leader Skills entry — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** Fresh public Basic and Advanced games with Richese,
|
||||
|
||||
+72
-3
@@ -1802,6 +1802,15 @@ export type Game = {
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'saphoStationDiscard';
|
||||
player: string;
|
||||
card: string;
|
||||
event: string;
|
||||
mode: 'first' | 'last';
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'saphoAuctionDiscard';
|
||||
cyclic?: 'normal' | 'blackMarket';
|
||||
@@ -6458,6 +6467,17 @@ type SaphoExtortionDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'saphoExtortionDiscard' }
|
||||
>;
|
||||
type SaphoStationDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'saphoStationDiscard' }
|
||||
>;
|
||||
function saphoStationDiscardSignature(g: Game, c: SaphoStationDiscardContinuation) {
|
||||
return JSON.stringify({player:c.player, card:c.card, event:c.event, mode:c.mode,
|
||||
parent:nullentropyParentSignature(g,c.resume,c.event), turn:g.turn, phase:g.phase,
|
||||
order:g.order, ready:g.ready, storm:g.storm, stormPending:g.stormPending,
|
||||
source:g.stormMovementSource, station:g.ecologicalStorm, discoveries:g.discoveries,
|
||||
players:g.players.map(p=>({id:p.id, faction:p.faction, forces:p.forces, advisors:p.advisors}))});
|
||||
}
|
||||
type SaphoCharityDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'saphoCharityDiscard' }
|
||||
@@ -8360,6 +8380,12 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
c.stateSignature === saphoExtortionDiscardSignature(g, c),
|
||||
'The used Juice of Sapho no longer matches its current Extortion payer order.');
|
||||
moritaniExtortionIntegrity(g);
|
||||
} else if (continuation?.kind === 'saphoStationDiscard') {
|
||||
const c = continuation;
|
||||
requireRule(batch.cause === 'sapho:station' &&
|
||||
semutaOfferSupported(g, c, batch.entries) &&
|
||||
c.stateSignature === saphoStationDiscardSignature(g, c),
|
||||
'The used Juice of Sapho no longer matches its Testing Station choice.');
|
||||
} else if (continuation?.kind === 'saphoMovementDiscard') {
|
||||
const c = continuation, entry = batch.entries[0];
|
||||
requireRule(
|
||||
@@ -9594,6 +9620,19 @@ function semutaOfferSupported(
|
||||
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
entries: FreshDiscardBatch['entries'],
|
||||
) {
|
||||
if (continuation.kind === 'saphoStationDiscard') {
|
||||
const frame = g.ecologicalStorm, decision = continuation.resume.decision, receipt = frame?.sapho;
|
||||
return g.semutaPreview === true && g.discoveryEnabled === true && !!g.discoveries &&
|
||||
discoveryModeSupported(g) && g.status === 'playing' && g.phase === 0 &&
|
||||
frame?.stage === 'choose' && frame.queue?.choices.length === 0 &&
|
||||
receipt?.event === continuation.event && receipt.holder === continuation.player &&
|
||||
receipt.mode === continuation.mode && decision?.kind === 'ecologicalStorm' &&
|
||||
decision.player === frame.owner && decision.event === frame.event && !g.decision &&
|
||||
!g.response && !g.pendingKarama && !g.phaseOpening && !continuation.resume.response &&
|
||||
!continuation.resume.pendingKarama && !continuation.resume.phaseOpening &&
|
||||
entries.length === 1 && entries[0].publicFace && entries[0].discardedBy === continuation.player &&
|
||||
entries[0].card.id === continuation.card && entries[0].card.effect === 'juiceOfSapho';
|
||||
}
|
||||
if (continuation.kind === 'saphoArrivalOrderDiscard') {
|
||||
const order = g.pendingArrivalOverlap?.saphoOrder;
|
||||
return g.semutaPreview === true && bareBasicSemutaProfile(g) && g.phase === 5 &&
|
||||
@@ -10364,6 +10403,11 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
||||
g.decision = next.resume.decision;
|
||||
g.pendingKarama = next.resume.pendingKarama;
|
||||
g.phaseOpening = next.resume.phaseOpening;
|
||||
} else if (next.kind === 'saphoStationDiscard') {
|
||||
g.response = next.resume.response;
|
||||
g.decision = next.resume.decision;
|
||||
g.pendingKarama = next.resume.pendingKarama;
|
||||
g.phaseOpening = next.resume.phaseOpening;
|
||||
} else if (next.kind === 'saphoMovementDiscard') {
|
||||
g.response = next.resume.response;
|
||||
g.decision = next.resume.decision;
|
||||
@@ -11025,6 +11069,17 @@ function publicSemutaSkillsProfile(g: Game): boolean {
|
||||
!g.homeworlds && !g.nexusCards && !g.discoveryEnabled && !g.ecazTreachery &&
|
||||
!g.leaderSkills && !g.kullPreview && !g.nexusKullPreview;
|
||||
}
|
||||
/** Fresh public Discovery entry for the existing native Richese Semuta profile. */
|
||||
function publicSemutaDiscoveryProfile(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 === true && !g.ecazTreachery &&
|
||||
!g.leaderSkills && !g.kullPreview && !g.nexusKullPreview && discoveryModuleProfile(g);
|
||||
}
|
||||
/** Route a normal start through the existing genuine setup for the selected modules. */
|
||||
function startModules(state: Game): Game {
|
||||
const options = state.lobbyOptions ?? {};
|
||||
@@ -11061,7 +11116,8 @@ function startModules(state: Game): Game {
|
||||
return pair && pair.every(id => g.players.some(p => p.faction === id))
|
||||
? initializePairedNexusGameForAudit(g) : initializeNexusGameForAudit(g);
|
||||
}
|
||||
if (g.discoveryEnabled) return initializeDiscoveryGameForAudit(g);
|
||||
if (g.discoveryEnabled) return publicSemutaDiscoveryProfile(g)
|
||||
? initializeSemutaGameForAudit(g) : initializeDiscoveryGameForAudit(g);
|
||||
if (g.homeworlds)
|
||||
return g.expansions.length ? initializeHomeworldOccupationGameForAudit(g) : initializeHomeworldGameForAudit(g);
|
||||
if (g.strongholdCards) return initializeStrongholdFactionsGameForAudit(g);
|
||||
@@ -29173,7 +29229,7 @@ function saphoExtortionDecision(g: Game) {
|
||||
}
|
||||
function saphoTestingStationDecision(g: Game) {
|
||||
const station = g.ecologicalStorm, decision = g.decision;
|
||||
return g.phase === 0 && !g.pendingTreacheryDiscard && !g.semutaPreview &&
|
||||
return g.phase === 0 && !g.pendingTreacheryDiscard &&
|
||||
!g.semutaAdvancedOrdinaryPreview && !g.semutaRecruitsPreview &&
|
||||
station?.stage === 'choose' && decision?.kind === 'ecologicalStorm' &&
|
||||
decision.player === station.owner && decision.event === station.event;
|
||||
@@ -29546,11 +29602,24 @@ function playSapho(g: Game, p: Player, action: Action) {
|
||||
const frame = nexusRule(() => reorderDiscoveryStorm(g, g.ecologicalStorm!, {
|
||||
holder: p.id, event: option.event, mode: option.mode, nextEvent: crypto.randomUUID(),
|
||||
}));
|
||||
discard(g, p, 'richese-juice-of-sapho');
|
||||
const used = discard(g, p, 'richese-juice-of-sapho');
|
||||
g.ecologicalStorm = frame;
|
||||
g.decision = {kind:'ecologicalStorm', player:frame.owner, event:frame.event};
|
||||
log(g, `${p.name} discarded Juice of Sapho to take the Testing Station choice ${option.mode}. Both occupants retain one adjustment and the original storm movement remains.`,
|
||||
{faction:p.faction,name:'Juice of Sapho'});
|
||||
if (g.semutaPreview) {
|
||||
const continuation: SaphoStationDiscardContinuation = {
|
||||
kind:'saphoStationDiscard', player:p.id, card:used.id, event:option.event, mode:option.mode,
|
||||
resume:{response:g.response, decision:g.decision, pendingKarama:g.pendingKarama, phaseOpening:g.phaseOpening},
|
||||
stateSignature:'',
|
||||
};
|
||||
const entries = [{card:used, discardedBy:p.id, publicFace:true}];
|
||||
g.decision = null;
|
||||
requireRule(semutaOfferSupported(g, continuation, entries),
|
||||
'This Testing Station discard cannot safely resume its Semuta reaction.');
|
||||
continuation.stateSignature = saphoStationDiscardSignature(g, continuation);
|
||||
stageTreacheryDiscard(g, 'sapho:station', entries, continuation);
|
||||
}
|
||||
} else if (option.scope === 'battleAggressor') {
|
||||
const b = g.battle!;
|
||||
const next = saphoAggressorRule(() => changeSaphoAggressor(b.saphoAggressor,
|
||||
|
||||
+2
-1
@@ -511,7 +511,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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.',
|
||||
'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. The current occupant can act before another player intervenes, including an automated seat. The initial prototype excludes Semuta reaction profiles and replay of a recovered Sapho; this online priority is provisional.',
|
||||
'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.',
|
||||
'During ordered Advanced Revival, take your unfinished opportunity first or last, then discard Sapho. If another player has started, provisional queued timing lets that player finish unchanged. At a clean boundary, you may instead pause your own started turn and resume last, retaining used allowances. Pending requests must finish first. Completed turns and already-held positions cannot be repeated. Basic and older concurrent Revival phases have no ordered scope.',
|
||||
'When two or more players declare simultaneous Truthtrances in a quiet unrevealed battle, their public question-order window can use Sapho to move the holder’s group first or last. The current battle and committed plans remain in place. In the supported Basic Semuta preview, the discard and any exchange finish before the reordered questions resume.',
|
||||
@@ -1353,6 +1353,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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 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 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.',
|
||||
|
||||
@@ -75,6 +75,9 @@ for (const advanced of [false, true]) void test(
|
||||
let game = applyAction(prior, f.holder, {type:'card', card:CARD, scope:option.scope, event:option.event, mode:option.mode});
|
||||
assert.deepEqual(game.order, prior.order);
|
||||
assert.deepEqual(game.ecologicalStorm?.queue?.order, [...f.occupants].reverse());
|
||||
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'sapho:station');
|
||||
const discardEvent = game.pendingTreacheryDiscard!.batch.event;
|
||||
for (const player of game.players) game = applyAction(game, player.id, {type:'semutaPass', event:discardEvent});
|
||||
assert.equal(game.decision?.player, f.holder);
|
||||
assert.equal(game.discard.filter(card => card.id === CARD).length, 1);
|
||||
for (const delta of [1, -1]) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
for (const advanced of [false, true]) void test(
|
||||
`public ${advanced ? 'Advanced' : 'Basic'} Richese Discovery setup displays its bounded Semuta preview`, () => {
|
||||
let game = createGame('SEMUTADISCOVERYUI', 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:'discoveries', 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.equal(game.discoveryEnabled, true);
|
||||
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 Discoveries preview/);
|
||||
assert.match(html, /Other discard opportunities remain unfinished\./);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,156 @@
|
||||
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));
|
||||
function player(game: Game, id: string) {
|
||||
const seat = game.players.find(candidate => candidate.id === id);
|
||||
assert.ok(seat); return seat;
|
||||
}
|
||||
function next(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);
|
||||
}
|
||||
|
||||
/** Genuine public lobby and faction setup. Only unopened non-Discovery Spice
|
||||
* Card order and later physical card slots are controlled; no game clock,
|
||||
* Discovery token, wallet, force, or module owner is fabricated. */
|
||||
function movement(profile: typeof profiles[number]) {
|
||||
let game = createGame('SEMUTADISCOVERYPUBLIC', 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:'discoveries',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.equal(game.semutaPreview,true);
|
||||
assert.equal(game.discoveryEnabled,true);
|
||||
assert.equal(game.discoveries?.tokens.length,8);
|
||||
assert.equal(!!game.techTokens,profile.tech);
|
||||
assert.equal(!!game.strongholdCards,profile.strongholds);
|
||||
for (let n=0;game.status==='setup' && n<250;n++) {
|
||||
const action=next(game); assert.ok(action);
|
||||
game=applyAction(game,action.actor,action.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<1500 && !(game.phase===5 && !game.phaseOpening && !game.response && !game.decision);n++) {
|
||||
assert.equal(game.turn,1);
|
||||
const action=next(game); assert.ok(action);
|
||||
game=applyAction(game,action.actor,action.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) {
|
||||
// Advanced Hajr requires an actual completed Force Movement immediately
|
||||
// before its discard, so make a native paid shipment and adjacent move.
|
||||
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 action=next(game); assert.ok(action);
|
||||
game=applyAction(game,action.actor,action.action);
|
||||
}
|
||||
const own=viewGame(game,'a'), actor=own.players.find(candidate=>candidate.id==='a')!;
|
||||
const from='polar_sink:0';
|
||||
const destination=TERRITORIES.filter(place=>place.type==='sand' &&
|
||||
!game.players.some(seat=>Object.keys(seat.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,actor,from,`${place.territory}:${place.sector}`)===1 &&
|
||||
botGroundMoveAllowed(own,actor,from,`${place.territory}:${place.sector}`,0));
|
||||
assert.ok(destination);
|
||||
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 action=next(game); assert.ok(action);
|
||||
game=applyAction(game,action.actor,action.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 Discovery start retains the native Hajr and Semuta 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 result=clone(pending);
|
||||
if (claim) result=applyAction(result,'r',{type:'semutaCommit',event});
|
||||
else for (const seat of result.players) result=applyAction(result,seat.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(card=>card.id===f.hajr),claim);
|
||||
assert.equal(player(result,'r').hand.some(card=>card.id===f.semuta),!claim);
|
||||
assert.deepEqual(result.discoveries,before.discoveries);
|
||||
assert.deepEqual(semutaSkillsInventory(result),f.inventory);
|
||||
const unchanged=clone(result);
|
||||
assert.throws(()=>applyAction(result,'r',{type:'semutaCommit',event}));
|
||||
assert.deepEqual(clone(result),unchanged);
|
||||
}
|
||||
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 actual reaction`);
|
||||
assert.doesNotThrow(()=>applyAction(clone(pending),'r',action));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('public Discovery without native Richese does not gain a Semuta preview',()=>{
|
||||
let game=createGame('SEMUTADISCOVERYOTHER',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:'discoveries',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.equal(game.discoveryEnabled,true);
|
||||
assert.equal(game.semutaPreview,undefined);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, initializeSemutaGameForAudit, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries';
|
||||
import { createDiscoveryStorm, createStormSource, discoveryStormDistance } from '../game/discovery-storm';
|
||||
import { discoveryStormActions } from '../game/discovery-storm-options';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { semutaShrineLobby } from './fixture-semuta-shrine';
|
||||
import { advanceTestingStationContest } from './fixture-testing-station-contest';
|
||||
import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share';
|
||||
import { reactSemutaSpecial } from './fixture-semuta-special-karama';
|
||||
|
||||
const SAPHO = 'richese-juice-of-sapho';
|
||||
const STATION = 'ecological-testing-station';
|
||||
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;
|
||||
};
|
||||
const cards = (game: Game) => [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
|
||||
...(game.richeseRemoved ?? []), ...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
|
||||
|
||||
/** Genuine opt-in Richese/Discovery setup to first Storm. The original physical
|
||||
* station and two counters are staged at a controlled pending storm boundary;
|
||||
* actual unique Sapho and Semuta move from their original card zones. This is
|
||||
* not a natural Discovery reveal or full-game claim. */
|
||||
function fixture(advanced: boolean, mode: 'first' | 'last') {
|
||||
let game = initializeSemutaGameForAudit(semutaShrineLobby(advanced));
|
||||
game = advanceTestingStationContest(game, state => state.status === 'playing');
|
||||
assert.equal(game.phase, 0); assert.equal(game.semutaPreview, true);
|
||||
const inventory = cards(game);
|
||||
assert.equal(inventory.filter(id => id === SAPHO).length, 1);
|
||||
assert.equal(inventory.filter(id => id === SEMUTA_DRUG_ID).length, 1);
|
||||
for (const player of game.players) game.deck.push(...player.hand.splice(0));
|
||||
const token = game.discoveries!.tokens.find(row => row.face === STATION);
|
||||
assert.ok(token && token.status === 'supply');
|
||||
const placement = Object.values(DISCOVERY_CARD_PLACEMENTS).find(row => row.type === token.type);
|
||||
assert.ok(placement);
|
||||
Object.assign(token, {status:'placed', territory:placement.territory, sector:placement.sector, revealedTurn:game.turn});
|
||||
validateDiscoveryState(game.discoveries!);
|
||||
for (const id of ['e', 'g']) {
|
||||
const player = seat(game, id); assert.ok(player.reserves > 0);
|
||||
player.reserves--; player.forces[`${STATION}:0`] = (player.forces[`${STATION}:0`] ?? 0) + 1;
|
||||
}
|
||||
const order = game.order.filter(id => id === 'e' || id === 'g');
|
||||
assert.equal(order.length, 2);
|
||||
const owner = order[mode === 'first' ? 1 : 0];
|
||||
moveSemutaEmperorCard(game, SAPHO, owner);
|
||||
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
|
||||
assert.deepEqual(cards(game), inventory);
|
||||
game.stormPending = 3;
|
||||
game.stormMovementSource = createStormSource(game.turn, 'dials', 3);
|
||||
game.ready = game.players.map(player => player.id);
|
||||
game.phaseOpening = null; game.stormResolution = null;
|
||||
game.ecologicalStorm = createDiscoveryStorm(game, 'semuta-station-original');
|
||||
game.decision = {kind:'ecologicalStorm', player:game.ecologicalStorm.owner, event:game.ecologicalStorm.event};
|
||||
return {game, owner, order, inventory};
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const mode of ['first', 'last'] as const) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} ${mode} Sapho station choice resumes after Semuta claim or pass`, () => {
|
||||
const f = fixture(advanced, mode);
|
||||
const option = viewGame(f.game, f.owner).saphoOptions.find(row => row.scope === 'testingStation' && row.mode === mode);
|
||||
assert.ok(option, 'The physical Sapho is eligible for the original station queue');
|
||||
const action = {type:'card', card:SAPHO, scope:option.scope, event:option.event, mode:option.mode};
|
||||
const pending = applyAction(copy(f.game), f.owner, action);
|
||||
assert.equal(pending.pendingTreacheryDiscard?.batch.cause, 'sapho:station');
|
||||
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'saphoStationDiscard');
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.deepEqual(pending.ecologicalStorm?.queue?.order, [...f.order].reverse());
|
||||
assert.equal(pending.ecologicalStorm?.queue?.choices.length, 0);
|
||||
assert.equal(pending.stormPending, 3); assert.equal(pending.storm, f.game.storm);
|
||||
assert.equal(pending.decision, null, 'The original station choice waits behind the reaction');
|
||||
const pendingEvent = pending.pendingTreacheryDiscard!.batch.event;
|
||||
assert.throws(() => applyAction(copy(pending), f.owner, action));
|
||||
for (const claim of [false, true]) {
|
||||
let resumed = reactSemutaSpecial(copy(pending), 'r', claim);
|
||||
assert.equal(resumed.pendingTreacheryDiscard, null);
|
||||
assert.equal(resumed.decision?.kind, 'ecologicalStorm');
|
||||
assert.equal(resumed.decision?.player, resumed.ecologicalStorm?.owner);
|
||||
assert.equal(resumed.decision?.event, resumed.ecologicalStorm?.event);
|
||||
assert.equal(seat(resumed, 'r').hand.some(card => card.id === SAPHO), claim);
|
||||
assert.equal(seat(resumed, 'r').hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
|
||||
assert.throws(() => applyAction(copy(resumed), 'r', {type:'semutaCommit', event:pendingEvent}));
|
||||
for (const delta of [1, -1]) {
|
||||
const owner = resumed.ecologicalStorm!.owner;
|
||||
const choice = discoveryStormActions(viewGame(resumed, owner)).find(row => row.delta === delta);
|
||||
assert.ok(choice);
|
||||
resumed = applyAction(resumed, owner, choice);
|
||||
}
|
||||
assert.equal(resumed.ecologicalStorm?.stage, 'complete');
|
||||
assert.equal(discoveryStormDistance(resumed.ecologicalStorm!), 3);
|
||||
assert.equal(resumed.phase, 1);
|
||||
assert.deepEqual(cards(resumed), f.inventory);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
void test('all four bot profiles can legally respond to the parked Sapho station discard', () => {
|
||||
const f = fixture(false, 'first');
|
||||
const option = viewGame(f.game, f.owner).saphoOptions.find(row => row.scope === 'testingStation' && row.mode === 'first');
|
||||
assert.ok(option);
|
||||
const pending = applyAction(f.game, f.owner, {type:'card', card:SAPHO,
|
||||
scope:option.scope, event:option.event, mode:option.mode});
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(pending, 'r');
|
||||
view.players.find(player => player.id === 'r')!.bot = difficulty;
|
||||
const action = botActions(view).find(row => row.type === 'semutaCommit' || row.type === 'semutaPass');
|
||||
assert.ok(action, `${difficulty} must offer a legal Semuta response`);
|
||||
assert.doesNotThrow(() => applyAction(copy(pending), 'r', action));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user