Connect ordinary Semuta in Advanced Richese preview
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
3c1cbd071e
commit
b93c5c4cf5
7 files changed
+287
-2
No files matched your search
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user