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
@@ -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