Support Sapho ordering at Testing Station
Verify and publish checkpoint container / container (push) Successful in 10m32s
Verify and publish checkpoint container / container (push) Successful in 10m32s
This commit is contained in:
1 parent
220d78c779
commit
1972622c11
11 files changed
+445
-9
No files matched your search
@@ -0,0 +1,74 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
chooseDiscoveryStorm,
|
||||
discoveryStormSaphoModes,
|
||||
discoveryStormSignature,
|
||||
finishDiscoveryStorm,
|
||||
reorderDiscoveryStorm,
|
||||
validateDiscoveryStorm,
|
||||
} from '../game/discovery-storm';
|
||||
import {createTestingStationContestFixture} from './fixture-testing-station-contest';
|
||||
|
||||
// Real revealed station, original storm source and both occupants. The helper
|
||||
// tests ordering only; physical Sapho ownership and disposal belong to engine.
|
||||
const source = () => createTestingStationContestFixture().game;
|
||||
|
||||
void test('legacy station signature stays unchanged and offers only meaningful two-seat moves', () => {
|
||||
const game = source(), frame = game.ecologicalStorm!;
|
||||
const [first, second] = frame.queue!.order;
|
||||
assert.equal(frame.signature, JSON.stringify([frame.event, frame.turn, frame.owner,
|
||||
frame.token, frame.from, frame.base, frame.delta, frame.stage,
|
||||
frame.queue!.order, frame.queue!.choices]));
|
||||
assert.deepEqual(discoveryStormSaphoModes(game, frame, first), ['last']);
|
||||
assert.deepEqual(discoveryStormSaphoModes(game, frame, second), ['first']);
|
||||
assert.deepEqual(discoveryStormSaphoModes(game, frame, 'nonoccupant'), []);
|
||||
});
|
||||
|
||||
for (const mode of ['first', 'last'] as const) void test(
|
||||
`Sapho ${mode} binds a fresh station event and preserves both cumulative choices`, () => {
|
||||
const game = source(), original = structuredClone(game.ecologicalStorm!);
|
||||
const holder = original.queue!.order[mode === 'first' ? 1 : 0];
|
||||
const frame = reorderDiscoveryStorm(game, original,
|
||||
{holder, event: original.event, mode, nextEvent: 'sapho-fresh-event'});
|
||||
assert.deepEqual(original, game.ecologicalStorm);
|
||||
assert.deepEqual(frame.queue!.order, [...original.queue!.order].reverse());
|
||||
assert.equal(frame.owner, frame.queue!.order[0]);
|
||||
assert.deepEqual(frame.sapho, {event: original.event, nextEvent: 'sapho-fresh-event',
|
||||
originalOrder: original.queue!.order, holder, mode});
|
||||
assert.deepEqual(discoveryStormSaphoModes(game, frame, holder), []);
|
||||
assert.throws(() => chooseDiscoveryStorm(game, frame, frame.owner, original.event, 0));
|
||||
let next = chooseDiscoveryStorm(game, frame, frame.owner, frame.event, 0);
|
||||
assert.equal(next.event, 'sapho-fresh-event:1');
|
||||
validateDiscoveryStorm(game, next);
|
||||
next = chooseDiscoveryStorm(game, next, next.owner, next.event, 0);
|
||||
assert.equal(next.stage, 'traversal');
|
||||
validateDiscoveryStorm(game, next);
|
||||
next = finishDiscoveryStorm(next);
|
||||
validateDiscoveryStorm(game, next);
|
||||
assert.deepEqual(next.sapho, frame.sapho);
|
||||
assert.deepEqual(next.queue!.choices.map(choice => choice.owner), frame.queue!.order);
|
||||
},
|
||||
);
|
||||
|
||||
void test('stale, ineffective, replayed and tampered station reorder requests fail closed', () => {
|
||||
const game = source(), frame = game.ecologicalStorm!;
|
||||
const [first, second] = frame.queue!.order, before = structuredClone(frame);
|
||||
for (const input of [
|
||||
{holder: first, event: 'stale', mode: 'last' as const, nextEvent: 'fresh'},
|
||||
{holder: first, event: frame.event, mode: 'first' as const, nextEvent: 'fresh'},
|
||||
{holder: second, event: frame.event, mode: 'first' as const, nextEvent: frame.event},
|
||||
{holder: 'other', event: frame.event, mode: 'last' as const, nextEvent: 'fresh'},
|
||||
]) assert.throws(() => reorderDiscoveryStorm(game, frame, input));
|
||||
assert.deepEqual(frame, before);
|
||||
const reordered = reorderDiscoveryStorm(game, frame,
|
||||
{holder: first, event: frame.event, mode: 'last', nextEvent: 'fresh'});
|
||||
assert.throws(() => reorderDiscoveryStorm(game, reordered,
|
||||
{holder: second, event: 'fresh', mode: 'last', nextEvent: 'again'}));
|
||||
const changed = structuredClone(reordered);
|
||||
changed.sapho!.originalOrder.reverse();
|
||||
changed.signature = discoveryStormSignature(changed);
|
||||
assert.throws(() => validateDiscoveryStorm(game, changed));
|
||||
const afterFirst = chooseDiscoveryStorm(game, frame, first, frame.event, 0);
|
||||
assert.deepEqual(discoveryStormSaphoModes(game, afterFirst, second), []);
|
||||
});
|
||||
@@ -105,6 +105,14 @@ export function nextTestingStationContestStep(
|
||||
game.decision?.kind === 'ecazAmbassador'
|
||||
)
|
||||
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
|
||||
// Fresh Advanced Charity and Revival are ordered. Finish only the actual
|
||||
// current opportunity; the reused Banker stepper's generic Charity fallback
|
||||
// otherwise chooses lobby order, which need not match the current queue.
|
||||
if (game.status === 'playing' && clean(game)) {
|
||||
const actor = game.phase === 2 ? game.charityOrder?.remaining[0]
|
||||
: game.phase === 4 ? game.revivalOrder?.remaining[0] : undefined;
|
||||
if (actor) return {actor, action: {type:'ready'}};
|
||||
}
|
||||
return nextSpiceBankerIncomeNativeStep(game);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries';
|
||||
import { createDiscoveryStorm, createStormSource, discoveryStormDistance } from '../game/discovery-storm';
|
||||
import { discoveryStormActions } from '../game/discovery-storm-options';
|
||||
import { advanceTestingStationContest } from './fixture-testing-station-contest';
|
||||
|
||||
const CARD = 'richese-juice-of-sapho';
|
||||
const STATION = 'ecological-testing-station';
|
||||
function seat(game: Game, id: string) {
|
||||
const player = game.players.find(candidate => candidate.id === id);
|
||||
assert.ok(player); return player;
|
||||
}
|
||||
function physicalCards(game: Game) {
|
||||
return [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
|
||||
...(game.richeseRemoved ?? []), ...game.players.flatMap(player => player.hand)];
|
||||
}
|
||||
|
||||
/** A real lobby selects CHOAM expansion and Discovery through public actions,
|
||||
* starts and completes the original faction setup. Only after setup, the
|
||||
* revealed physical station, two ordinary reserve forces, Storm source and
|
||||
* its unchosen queue are staged to reach the rare contested boundary; one
|
||||
* existing physical Sapho moves from Richese's cache into its intended owner.
|
||||
* This is not a natural full game, purchase, reveal, or collection proof. */
|
||||
function fixture(advanced: boolean) {
|
||||
let game = createGame('SAPHOSTATIONPUBLIC', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']);
|
||||
for (const faction of ['atreides', 'emperor'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
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.deepEqual(game.expansions, ['choam']);
|
||||
assert.ok(game.discoveries && game.richeseCache);
|
||||
const startInventory = physicalCards(game).map(card => card.id).sort();
|
||||
assert.equal(startInventory.filter(id => id === CARD).length, 1);
|
||||
game = advanceTestingStationContest(game, state => state.status === 'playing');
|
||||
assert.equal(game.phase, 0);
|
||||
|
||||
const token = game.discoveries!.tokens.find(candidate => candidate.face === STATION);
|
||||
assert.ok(token && token.status === 'supply');
|
||||
const position = Object.values(DISCOVERY_CARD_PLACEMENTS).find(candidate => candidate.type === token.type);
|
||||
assert.ok(position);
|
||||
Object.assign(token, {status:'placed', territory:position.territory, sector:position.sector, revealedTurn:game.turn});
|
||||
validateDiscoveryState(game.discoveries!);
|
||||
for (const id of ['atreides', 'emperor']) {
|
||||
const player = seat(game, id); assert.ok(player.reserves > 0);
|
||||
player.reserves--;
|
||||
player.forces[`${STATION}:0`] = (player.forces[`${STATION}:0`] ?? 0) + 1;
|
||||
}
|
||||
const occupants = game.order.filter(id => id === 'atreides' || id === 'emperor');
|
||||
assert.equal(occupants.length, 2);
|
||||
const holder = occupants[1];
|
||||
const index = game.richeseCache!.findIndex(card => card.id === CARD); assert.ok(index >= 0);
|
||||
const [sapho] = game.richeseCache!.splice(index, 1);
|
||||
seat(game, holder).hand.push(sapho);
|
||||
assert.deepEqual(physicalCards(game).map(card => card.id).sort(), startInventory);
|
||||
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, 'public-station-setup');
|
||||
game.decision = {kind:'ecologicalStorm', player:game.ecologicalStorm.owner, event:game.ecologicalStorm.event};
|
||||
return {game, holder, occupants, startInventory};
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} public Richese/Discovery start reaches staged conserved Sapho station use`, () => {
|
||||
const f = fixture(advanced), prior = structuredClone(f.game);
|
||||
const option = viewGame(prior, f.holder).saphoOptions.find(row => row.scope === 'testingStation' && row.mode === 'first');
|
||||
assert.ok(option, 'The existing physical Sapho can take the pending station choice first');
|
||||
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.decision?.player, f.holder);
|
||||
assert.equal(game.discard.filter(card => card.id === CARD).length, 1);
|
||||
for (const delta of [1, -1]) {
|
||||
const actor = game.ecologicalStorm!.owner;
|
||||
const action = discoveryStormActions(viewGame(game, actor)).find(row => row.delta === delta);
|
||||
assert.ok(action);
|
||||
game = applyAction(game, actor, action);
|
||||
}
|
||||
assert.equal(game.ecologicalStorm?.stage, 'complete');
|
||||
assert.equal(discoveryStormDistance(game.ecologicalStorm!), 3);
|
||||
assert.deepEqual(physicalCards(game).map(card => card.id).sort(), f.startInventory);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,132 @@
|
||||
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, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { discoveryStormActions } from '../game/discovery-storm-options';
|
||||
import { discoveryStormDistance } from '../game/discovery-storm';
|
||||
import { stormSectorAfter } from '../game/disaster-rules';
|
||||
import { createTestingStationContestFixture } from './fixture-testing-station-contest';
|
||||
|
||||
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 { JuiceOfSapho, SaphoOrderButton } = await import('../components/juice-of-sapho');
|
||||
aliases.deregister();
|
||||
|
||||
const card = 'richese-juice-of-sapho';
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
const originals = new Map<boolean, ReturnType<typeof createTestingStationContestFixture>>();
|
||||
function fixture(advanced: boolean, place: 'first' | 'second') {
|
||||
let original = originals.get(advanced);
|
||||
if (!original) {
|
||||
original = createTestingStationContestFixture({advanced});
|
||||
originals.set(advanced, original);
|
||||
}
|
||||
const game = structuredClone(original.game);
|
||||
const holder = original.order[place === 'first' ? 0 : 1];
|
||||
const target = game.players.find(player => player.id === holder)!;
|
||||
// The original fixture has only Ecaz components. Supply its one real printed
|
||||
// Sapho face from the Richese cache, retaining a full hand by transferring an
|
||||
// existing held card to the deck. This explicitly staged acquisition is not
|
||||
// evidence of mixed-expansion roster admission or a natural purchase.
|
||||
const sapho = richeseCards().find(candidate => candidate.id === card);
|
||||
assert.ok(sapho);
|
||||
assert.ok(![...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)].some(candidate => candidate.id === card));
|
||||
if (target.hand.length >= 4) game.deck.push(target.hand.pop()!);
|
||||
target.hand.push(sapho);
|
||||
return {game, holder, original};
|
||||
}
|
||||
function saphoOption(game: Game, holder: string, mode: 'first' | 'last') {
|
||||
const option = viewGame(game, holder).saphoOptions.find(choice => String(choice.scope) === 'testingStation' && choice.mode === mode);
|
||||
assert.ok(option, `${holder} has a real ${mode} station opportunity`);
|
||||
return option;
|
||||
}
|
||||
function saphoAction(game: Game, holder: string, mode: 'first' | 'last'): Action {
|
||||
const option = saphoOption(game, holder, mode);
|
||||
return {type:'card', card, scope:option.scope, event:option.event, mode:option.mode};
|
||||
}
|
||||
function stationAction(game: Game, actor: string, delta: number) {
|
||||
const action = discoveryStormActions(viewGame(game, actor)).find(choice => choice.delta === delta);
|
||||
assert.ok(action, `${actor} can make the ${delta} station adjustment`);
|
||||
return action;
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const mode of ['first', 'last'] as const) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} Sapho changes the contested station order ${mode} exactly once`, () => {
|
||||
const f = fixture(advanced, mode === 'first' ? 'second' : 'first');
|
||||
const initial = f.game, action = saphoAction(initial, f.holder, mode);
|
||||
const physicalOrder = [...initial.order], beforeOrder = [...initial.ecologicalStorm!.queue!.order];
|
||||
const beforeEvent = initial.ecologicalStorm!.event, sourceDistance = initial.stormPending!;
|
||||
const other = beforeOrder.find(id => id !== f.holder)!;
|
||||
reject(initial, other, action);
|
||||
reject(initial, f.holder, {...action, event:'stale-station-event'});
|
||||
let game = applyAction(reload(initial), f.holder, action);
|
||||
assert.deepEqual(game.order, physicalOrder);
|
||||
assert.deepEqual(game.ecologicalStorm?.queue?.order, [...beforeOrder].reverse());
|
||||
assert.notEqual(game.ecologicalStorm?.event, beforeEvent);
|
||||
assert.equal(game.ecologicalStorm?.owner, game.ecologicalStorm?.queue?.order[0]);
|
||||
assert.equal(game.decision?.player, game.ecologicalStorm?.owner);
|
||||
assert.equal(game.discard.filter(candidate => candidate.id === card).length, 1);
|
||||
assert.ok(!game.players.find(player => player.id === f.holder)!.hand.some(candidate => candidate.id === card));
|
||||
reject(game, f.holder, action);
|
||||
const first = game.ecologicalStorm!.owner;
|
||||
const firstDelta = sourceDistance < 40 ? 1 : -1;
|
||||
game = applyAction(reload(game), first, stationAction(game, first, firstDelta));
|
||||
assert.equal(discoveryStormDistance(game.ecologicalStorm!), sourceDistance + firstDelta);
|
||||
assert.equal(game.stormPending, sourceDistance + firstDelta);
|
||||
const second = game.ecologicalStorm!.owner;
|
||||
assert.notEqual(first, second);
|
||||
game = applyAction(reload(game), second, stationAction(game, second, -firstDelta));
|
||||
assert.equal(game.ecologicalStorm?.stage, 'complete');
|
||||
assert.equal(discoveryStormDistance(game.ecologicalStorm!), sourceDistance);
|
||||
assert.equal(game.storm, stormSectorAfter(initial.storm, sourceDistance));
|
||||
assert.equal(game.phase, 1);
|
||||
assert.equal(game.discard.filter(candidate => candidate.id === card).length, 1);
|
||||
},
|
||||
);
|
||||
|
||||
void test('station Sapho controls render and submit the exact supported action, with busy protection', () => {
|
||||
for (const mode of ['first', 'last'] as const) {
|
||||
const f = fixture(false, mode === 'first' ? 'second' : 'first');
|
||||
const view = viewGame(f.game, f.holder), option = saphoOption(f.game, f.holder, mode);
|
||||
const html = renderToStaticMarkup(createElement(JuiceOfSapho, {game:view, busy:false, act() {}}));
|
||||
assert.match(html, /Testing Station/i);
|
||||
const sent: Action[] = [];
|
||||
SaphoOrderButton({card, option, busy:false, act(action) {sent.push(action);}}).props.onClick();
|
||||
assert.deepEqual(sent, [saphoAction(f.game, f.holder, mode)]);
|
||||
SaphoOrderButton({card, option, busy:true, act(action) {sent.push(action);}}).props.onClick();
|
||||
assert.equal(sent.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all minimal bot profiles have a legal current station choice after Sapho', () => {
|
||||
const f = fixture(false, 'second');
|
||||
const game = applyAction(f.game, f.holder, saphoAction(f.game, f.holder, 'first'));
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, f.holder);
|
||||
view.players.find(player => player.id === f.holder)!.bot = difficulty;
|
||||
const choice = botActions(view).find(action => action.type === 'decision');
|
||||
assert.ok(choice, `${difficulty} can choose the original station adjustment`);
|
||||
const next = applyAction(game, f.holder, choice);
|
||||
assert.equal(next.ecologicalStorm?.queue?.choices.length, 1);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user