Protect Sapho priority at the Testing Station
Verify and publish checkpoint container / container (push) Successful in 11m16s
Verify and publish checkpoint container / container (push) Successful in 11m16s
This commit is contained in:
1 parent
f73a6e0a9c
commit
a6be17fc5d
12 files changed
+391
-9
No files matched your search
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Children, createElement, isValidElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import type { Action } from '../game/engine';
|
||||
import { SaphoStationPriority, type SaphoStationPriorityProps } from '../components/sapho-station-priority';
|
||||
|
||||
function props(): SaphoStationPriorityProps & { sent: Action[] } {
|
||||
const sent: Action[] = [];
|
||||
return {game: {saphoStationPriority: {event:'station-event', canPass:true, waiting:false}},
|
||||
busy:false, sent, act(action) { sent.push(action); }};
|
||||
}
|
||||
const render = (input: SaphoStationPriorityProps) =>
|
||||
renderToStaticMarkup(createElement(SaphoStationPriority, input));
|
||||
|
||||
/** Invoke the actual rendered Button callback, without mounting a browser. */
|
||||
function pass(input: SaphoStationPriorityProps) {
|
||||
const section = SaphoStationPriority(input); assert.ok(section);
|
||||
const button = Children.toArray(section.props.children).find(child =>
|
||||
isValidElement(child) && (child.props as {'data-testid'?: string})['data-testid'] === 'sapho-station-priority-pass');
|
||||
assert.ok(isValidElement<{onClick: () => void; disabled: boolean}>(button));
|
||||
return button;
|
||||
}
|
||||
|
||||
void test('a neutral Testing Station opportunity stays visible and passes its exact event', () => {
|
||||
const input = props(), html = render(input);
|
||||
assert.match(html, /Testing Station priority/);
|
||||
assert.match(html, /Continue without changing the order/);
|
||||
assert.match(html, /Already completed choices stay finished/);
|
||||
assert.equal(pass(input).props.disabled, false);
|
||||
pass(input).props.onClick();
|
||||
assert.deepEqual(input.sent, [{type:'saphoStationPass', event:'station-event'}]);
|
||||
});
|
||||
|
||||
void test('busy and invalid events disable Continue without sending an action', () => {
|
||||
for (const change of ['busy', 'empty-event'] as const) {
|
||||
const input = props();
|
||||
if (change === 'busy') input.busy = true;
|
||||
else input.game.saphoStationPriority!.event = '';
|
||||
assert.match(render(input), /disabled=""/);
|
||||
const button = pass(input); assert.equal(button.props.disabled, true);
|
||||
button.props.onClick();
|
||||
assert.deepEqual(input.sent, []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('waiting and completed viewers see no Continue button or hidden card data', () => {
|
||||
for (const waiting of [true, false]) {
|
||||
const input = props();
|
||||
input.game.saphoStationPriority = {event:'station-event', canPass:false, waiting};
|
||||
// Only the server-supplied public opportunity is rendered, even when a
|
||||
// surrounding object contains unrelated private fields.
|
||||
Object.assign(input.game, {hiddenCard:'private-sapho-card-id'});
|
||||
const html = render(input);
|
||||
assert.doesNotMatch(html, /sapho-station-priority-pass|private-sapho-card-id/);
|
||||
assert.match(html, waiting ? /Waiting for the other players/ : /Your response has been recorded/);
|
||||
assert.deepEqual(input.sent, []);
|
||||
}
|
||||
const input = props(); input.game.saphoStationPriority = null;
|
||||
assert.equal(render(input), '');
|
||||
});
|
||||
|
||||
void test('stale event, consumed response and replaced priority callbacks do not send', () => {
|
||||
for (const change of ['event', 'pass', 'replace', 'close'] as const) {
|
||||
const input = props(), button = pass(input), view = input.game.saphoStationPriority!;
|
||||
if (change === 'event') view.event = 'new-event';
|
||||
else if (change === 'pass') view.canPass = false;
|
||||
else if (change === 'replace') input.game.saphoStationPriority = {...view};
|
||||
else input.game.saphoStationPriority = null;
|
||||
button.props.onClick();
|
||||
assert.deepEqual(input.sent, []);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, type Game } from '../game/engine';
|
||||
import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries';
|
||||
import { advanceTestingStationContest } from './fixture-testing-station-contest';
|
||||
|
||||
export const STATION = 'ecological-testing-station';
|
||||
export const SAPHO = 'richese-juice-of-sapho';
|
||||
|
||||
export function stationSeat(game: Game, id: string) {
|
||||
const player = game.players.find(seat => seat.id === id);
|
||||
assert.ok(player);
|
||||
return player;
|
||||
}
|
||||
|
||||
export function stationCards(game: Game) {
|
||||
return [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
|
||||
...(game.richeseRemoved ?? []), ...game.players.flatMap(player => player.hand)];
|
||||
}
|
||||
|
||||
/** Public Richese/Discovery lobby and genuine first Storm progression. The
|
||||
* rare revealed Station, its two physical occupants, and Sapho's custody are
|
||||
* staged after real setup; no priority frame or station choice is fabricated. */
|
||||
export function createSaphoStationPriorityFixture(advanced: boolean, holderHasSapho = true) {
|
||||
let game = createGame('SAPHOSTATIONPRIORITY', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']);
|
||||
for (const id of ['atreides', 'emperor'] as const) joinGame(game, newPlayer(id, id, id));
|
||||
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} : {})});
|
||||
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 placement = Object.values(DISCOVERY_CARD_PLACEMENTS).find(candidate => candidate.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 ['atreides', 'emperor']) {
|
||||
const player = stationSeat(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 === 'atreides' || id === 'emperor');
|
||||
assert.equal(order.length, 2);
|
||||
const [first, holder] = order;
|
||||
stationSeat(game, first).bot = 'Easy';
|
||||
const beforeCards = stationCards(game).map(card => card.id).sort();
|
||||
if (holderHasSapho) {
|
||||
const index = game.richeseCache!.findIndex(card => card.id === SAPHO);
|
||||
assert.ok(index >= 0);
|
||||
const target = stationSeat(game, holder);
|
||||
if (target.hand.length >= 4) game.deck.push(target.hand.pop()!);
|
||||
target.hand.push(...game.richeseCache!.splice(index, 1));
|
||||
}
|
||||
assert.deepEqual(stationCards(game).map(card => card.id).sort(), beforeCards);
|
||||
game = advanceTestingStationContest(game, state => state.decision?.kind === 'ecologicalStorm');
|
||||
assert.equal(game.ecologicalStorm?.owner, first);
|
||||
assert.deepEqual(game.ecologicalStorm?.queue?.order, order);
|
||||
return {game, first, holder, order, beforeCards};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { botActions, runBots } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { discoveryStormActions } from '../game/discovery-storm-options';
|
||||
import { tableActionOwner } from '../game/table-turn';
|
||||
import { createTestingStationContestFixture } from './fixture-testing-station-contest';
|
||||
import { createSaphoStationPriorityFixture, SAPHO, stationCards, stationSeat } from './sapho-station-priority-fixture';
|
||||
|
||||
function finishDiscard(game: Game): Game {
|
||||
if (!game.pendingTreacheryDiscard?.reaction) return game;
|
||||
const event = game.pendingTreacheryDiscard.batch.event;
|
||||
for (const player of game.players) game = applyAction(game, player.id, {type:'semutaPass', event});
|
||||
return game;
|
||||
}
|
||||
|
||||
function rejectedWithoutMutation(game: Game, player: string, action: {type:string; [key:string]:unknown}) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, player, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} Station waits for a human Sapho holder after the earlier AI passes`, () => {
|
||||
const fixture = createSaphoStationPriorityFixture(advanced);
|
||||
let game = fixture.game;
|
||||
const priority = game.saphoStationPriority;
|
||||
assert.ok(priority, 'A real Station opening creates the neutral priority window');
|
||||
assert.equal(priority.event, game.ecologicalStorm!.event);
|
||||
assert.deepEqual(priority.passed, []);
|
||||
assert.equal(viewGame(game, fixture.first).saphoStationPriority?.canPass, true);
|
||||
assert.equal(viewGame(game, fixture.holder).saphoStationPriority?.canPass, true);
|
||||
for (const player of game.players) assert.equal(tableActionOwner(viewGame(game, player.id)), null);
|
||||
|
||||
const normalChoice = {type:'decision', event:game.ecologicalStorm!.event, delta:0};
|
||||
assert.throws(() => applyAction(game, fixture.first, normalChoice), /Sapho|priority/i);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, fixture.first);
|
||||
view.players.find(player => player.id === fixture.first)!.bot = difficulty;
|
||||
const actions = botActions(view);
|
||||
assert.ok(actions.some(action => action.type === 'saphoStationPass' && action.event === priority.event), difficulty);
|
||||
assert.ok(!actions.some(action => action.type === 'decision' && action.event === game.ecologicalStorm!.event), difficulty);
|
||||
}
|
||||
game = applyAction(game, fixture.first, {type:'saphoStationPass', event:priority.event});
|
||||
assert.deepEqual(game.saphoStationPriority?.passed, [fixture.first]);
|
||||
const afterBotView = viewGame(game, fixture.first);
|
||||
assert.equal(afterBotView.saphoStationPriority?.canPass, false);
|
||||
assert.equal(afterBotView.saphoStationPriority?.waiting, true);
|
||||
assert.ok(!botActions(afterBotView).some(action => action.type === 'decision' && action.event === game.ecologicalStorm!.event));
|
||||
assert.throws(() => applyAction(game, fixture.first, normalChoice), /Sapho|priority/i);
|
||||
rejectedWithoutMutation(game, fixture.first, {type:'saphoStationPass', event:priority.event});
|
||||
rejectedWithoutMutation(game, fixture.holder, {type:'saphoStationPass', event:'stale-station-event'});
|
||||
rejectedWithoutMutation(game, 'richese', {type:'saphoStationPass', event:priority.event});
|
||||
for (const player of game.players) assert.equal(tableActionOwner(viewGame(game, player.id)), null);
|
||||
|
||||
const option = viewGame(game, fixture.holder).saphoOptions.find(row => row.scope === 'testingStation' && row.mode === 'first');
|
||||
assert.ok(option, 'The human retains their real Sapho option');
|
||||
game = applyAction(game, fixture.holder, {type:'card', card:SAPHO,
|
||||
scope:option.scope, event:option.event, mode:option.mode});
|
||||
assert.ok(!game.saphoStationPriority);
|
||||
assert.deepEqual(game.ecologicalStorm?.queue?.order, [...fixture.order].reverse());
|
||||
assert.equal(game.ecologicalStorm?.owner, fixture.holder);
|
||||
game = finishDiscard(game);
|
||||
assert.equal(game.discard.filter(card => card.id === SAPHO).length, 1);
|
||||
const choice = discoveryStormActions(viewGame(game, fixture.holder)).find(action => action.delta === 0);
|
||||
assert.ok(choice);
|
||||
game = applyAction(game, fixture.holder, choice);
|
||||
assert.equal(game.ecologicalStorm?.queue?.choices.length, 1);
|
||||
assert.deepEqual(stationCards(game).map(card => card.id).sort(), fixture.beforeCards);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} bot scheduler passes once and waits for the human Station holder`, () => {
|
||||
const fixture = createSaphoStationPriorityFixture(advanced);
|
||||
const initial = fixture.game;
|
||||
assert.ok(stationSeat(initial, fixture.first).bot);
|
||||
assert.ok(!stationSeat(initial, fixture.holder).bot);
|
||||
const event = initial.saphoStationPriority!.event;
|
||||
const game = runBots(initial, 12);
|
||||
assert.deepEqual(game.saphoStationPriority, {event, passed:[fixture.first]});
|
||||
assert.equal(game.botsPending, false);
|
||||
assert.deepEqual(game.ecologicalStorm?.queue?.choices, []);
|
||||
assert.deepEqual(game.ecologicalStorm?.queue?.order, fixture.order);
|
||||
assert.equal(game.decision?.kind, 'ecologicalStorm');
|
||||
assert.equal(game.decision?.player, fixture.first);
|
||||
assert.equal(viewGame(game, fixture.holder).saphoStationPriority?.canPass, true);
|
||||
for (const player of game.players) assert.equal(tableActionOwner(viewGame(game, player.id)), null);
|
||||
const repeated = runBots(game, 12);
|
||||
assert.deepEqual(repeated, game, 'The bot neither passes twice nor takes the protected choice');
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} Station opens the same neutral pass window when no occupant holds Sapho`, () => {
|
||||
const fixture = createSaphoStationPriorityFixture(advanced, false);
|
||||
let game = fixture.game;
|
||||
assert.equal(stationSeat(game, fixture.first).hand.some(card => card.id === SAPHO), false);
|
||||
assert.equal(stationSeat(game, fixture.holder).hand.some(card => card.id === SAPHO), false);
|
||||
const event = game.saphoStationPriority?.event; assert.ok(event);
|
||||
for (const id of fixture.order) {
|
||||
const view = viewGame(game, id);
|
||||
assert.deepEqual(view.saphoStationPriority, {event, canPass:true, waiting:false});
|
||||
game = applyAction(game, id, {type:'saphoStationPass', event});
|
||||
}
|
||||
assert.ok(!game.saphoStationPriority);
|
||||
assert.deepEqual(game.ecologicalStorm?.queue?.order, fixture.order);
|
||||
const choice = discoveryStormActions(viewGame(game, fixture.first)).find(action => action.delta === 0);
|
||||
assert.ok(choice);
|
||||
game = applyAction(game, fixture.first, choice);
|
||||
assert.equal(game.ecologicalStorm?.queue?.choices.length, 1);
|
||||
assert.deepEqual(stationCards(game).map(card => card.id).sort(), fixture.beforeCards);
|
||||
});
|
||||
|
||||
void test('A previously opened unstamped Station continues without a new priority window', () => {
|
||||
const fixture = createSaphoStationPriorityFixture(false, false);
|
||||
const old = structuredClone(fixture.game);
|
||||
delete old.saphoStationPriority;
|
||||
const choice = discoveryStormActions(viewGame(old, fixture.first)).find(action => action.delta === 0);
|
||||
assert.ok(choice);
|
||||
const game = applyAction(old, fixture.first, choice);
|
||||
assert.ok(!game.saphoStationPriority);
|
||||
assert.equal(game.ecologicalStorm?.queue?.choices.length, 1);
|
||||
});
|
||||
|
||||
void test('A Station table without Richese keeps its original unblocked choice', () => {
|
||||
const game = createTestingStationContestFixture().game;
|
||||
assert.ok(!game.players.some(player => player.faction === 'richese'));
|
||||
assert.ok(!game.saphoStationPriority);
|
||||
const owner = game.ecologicalStorm!.owner;
|
||||
assert.equal(viewGame(game, owner).saphoStationPriority, null);
|
||||
const choice = discoveryStormActions(viewGame(game, owner)).find(action => action.delta === 0);
|
||||
assert.ok(choice);
|
||||
const next = applyAction(game, owner, choice);
|
||||
assert.equal(next.ecologicalStorm?.queue?.choices.length, 1);
|
||||
});
|
||||
Reference in new issue
Block a user