Add opt-in CHOAM market skip for the current turn
Verify and publish checkpoint container / container (push) Successful in 10m42s

This commit is contained in:
Quintine committed 2026-10-29 07:08:53 +11:00
1 parent 685bd8bdfc
commit 825f903cce
12 files changed
+536 -2

No files matched your search

@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Children, createElement, isValidElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { ChoamMarket } from '../components/choam-market';
import { ChoamMarketAuto } from '../components/choam-market-auto';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
// Staged projections isolate the controls; the engine suite exercises actual
// market eligibility, turn expiry and the owner-only view.
function projected(id = 'c'): GameView {
const game = createGame('MARKETCONTROLS', newPlayer('c', 'CHOAM', 'choam'), false, ['choam']);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
game.status = 'playing';
game.phase = 4;
game.turn = 2;
game.choamMarket = { owner: 'c', resume: 'phase', blocked: [] };
game.decision = { kind: 'choamMarket', player: 'c' };
const view = viewGame(game, id);
view.choamMarketSkipAvailable = id === 'c';
view.choamMarketSkipActive = false;
return view;
}
type ButtonProps = { children?: ReactNode; disabled?: boolean; onClick?: () => void };
function findButton(node: ReactNode, label: string): ButtonProps | undefined {
for (const child of Children.toArray(node)) {
if (!isValidElement<ButtonProps>(child)) continue;
if (child.props.children === label && child.props.onClick) return child.props;
const nested = findButton(child.props.children, label);
if (nested) return nested;
}
return undefined;
}
void test('the market owner can finish this phase or explicitly waive later markets with informed consent', () => {
const game = projected();
const actions: Action[] = [];
const props = { game, busy: false, act(action: Action) { actions.push(action); } };
const html = renderToStaticMarkup(createElement(ChoamMarket, props));
assert.match(html, /Finish this phase/);
assert.match(html, /Finish and skip remaining markets this turn/);
assert.match(html, /other sales and trades and opportunities to receive gifts/);
assert.match(html, /enable it again only from a future market/);
const tree = ChoamMarket(props);
const finish = findButton(tree, 'Finish this phase');
const skip = findButton(tree, 'Finish and skip remaining markets this turn');
assert.ok(finish && skip);
assert.equal(skip.disabled, false);
finish.onClick!();
skip.onClick!();
assert.deepEqual(actions, [
{ type: 'decision', done: true },
{ type: 'decision', done: true, skipMarketsThisTurn: true },
]);
const busy = findButton(ChoamMarket({ ...props, busy: true }), 'Finish and skip remaining markets this turn');
assert.ok(busy?.disabled);
busy.onClick!();
assert.equal(actions.length, 2);
});
void test('the skip control is absent without the owner quote or in another market decision', () => {
for (const game of [projected('e'), { ...projected(), choamMarketSkipAvailable: false }]) {
assert.doesNotMatch(renderToStaticMarkup(createElement(ChoamMarket, { game, busy: false, act() {} })),
/Finish and skip remaining markets this turn/);
}
const reply = projected();
reply.decision = { kind: 'choamTradeReply', player: 'c' };
assert.doesNotMatch(renderToStaticMarkup(createElement(ChoamMarket, { game: reply, busy: false, act() {} })),
/Finish and skip remaining markets this turn/);
});
void test('the active preference is persistent owner-facing guidance with a busy-safe cancel action', () => {
const game = projected();
game.choamMarket = null;
game.decision = null;
game.choamMarketSkipAvailable = false;
game.choamMarketSkipActive = true;
const actions: Action[] = [];
const props = { game, busy: false, act(action: Action) { actions.push(action); } };
const html = renderToStaticMarkup(createElement(ChoamMarketAuto, props));
assert.match(html, /remaining markets this turn will be skipped/);
assert.match(html, /other sales and trades and opportunities to receive gifts/);
assert.match(html, /enable it again only from a future market/);
const cancel = findButton(ChoamMarketAuto(props), 'Cancel skipping remaining markets');
assert.ok(cancel);
assert.equal(cancel.disabled, false);
cancel.onClick!();
assert.deepEqual(actions, [{ type: 'cancelChoamMarketSkip' }]);
const busy = findButton(ChoamMarketAuto({ ...props, busy: true }), 'Cancel skipping remaining markets');
assert.ok(busy?.disabled);
busy.onClick!();
assert.equal(actions.length, 1);
game.choamMarketSkipActive = false;
assert.equal(renderToStaticMarkup(createElement(ChoamMarketAuto, props)), '');
});
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { actChoamCharityFunding, advanceChoamCharityFunding, choamCharityFundingInventory,
createChoamCharityFundingFixture, passChoamCharityFundingResponses } from './fixture-choam-charity-funding';
import { actTruthtranceChoamPromises, advanceTruthtranceChoamPromises,
allowTruthtranceChoamPromisesIncome, createTruthtranceChoamPromisesFixture,
finishTruthtranceChoamPromises, truthtranceChoamPromisesInventory } from './fixture-truthtrance-choam-promises';
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
/** Genuine public Basic setup and first Storm closing. */
function firstMarket() {
const source = createChoamCharityFundingFixture();
return advanceChoamCharityFunding(structuredClone(source.afterSetup), state =>
state.turn === 1 && state.phase === 0 && state.decision?.kind === 'choamMarket');
}
void test('a pending sale and its Karama response finish exactly once before a standing waiver can close the market', () => {
const game = firstMarket();
const owner = game.players.find(player => player.id === 'choam'); assert.ok(owner);
// Preserve all 35 physical cards while staging a saleable original card in
// place of CHOAM's original deal. Atreides already genuinely holds Karama.
const worthless = game.deck.findIndex(card => card.kind === 'worthless'); assert.ok(worthless >= 0);
[owner.hand[0], game.deck[worthless]] = [game.deck[worthless], owner.hand[0]];
const card = owner.hand[0], beforeCards = choamCharityFundingInventory(game), beforeSpice = owner.spice;
const pending = actChoamCharityFunding(game, owner.id, {type:'decision', mode:'sell', card:card.id});
assert.equal(pending.response?.kind, 'choamSale');
assert.equal(pending.choamMarket?.sale?.card, card.id);
// The sole later-state staging is a previously accepted same-turn preference.
pending.choamMarketSkip = {owner:owner.id, turn:pending.turn};
const normalized = normalizeAutomaticGame(structuredClone(pending));
assert.equal(normalized.response?.kind, 'choamSale');
assert.equal(normalized.choamMarket?.sale?.card, card.id);
assert.equal(normalized.players.find(player => player.id === owner.id)!.spice, beforeSpice);
assert.equal(viewGame(normalized, owner.id).choamMarketSkipAvailable, false);
reject(normalized, owner.id, {type:'decision', done:true, skipMarketsThisTurn:true});
reject(normalized, owner.id, {type:'cancelChoamMarketSkip'});
const settled = passChoamCharityFundingResponses(normalized);
assert.equal(settled.response, null);
assert.equal(settled.choamMarket, null);
assert.equal(settled.phase, 1);
assert.equal(settled.players.find(player => player.id === owner.id)!.spice, beforeSpice + 2);
assert.equal(settled.discard.filter(item => item.id === card.id).length, 1);
assert.deepEqual(choamCharityFundingInventory(settled), beforeCards);
});
void test('a real binding Truthtrance shipment prevents market skipping until the commitment can proceed', () => {
const f = createTruthtranceChoamPromisesFixture({advanced:false, kind:'shipment'});
let game = actTruthtranceChoamPromises(structuredClone(f.game), f.asker, f.declarationStep.action);
game = advanceTruthtranceChoamPromises(game, state => state.truthtrance?.stage === 'ask');
game = actTruthtranceChoamPromises(game, f.asker, f.askStep.action);
game = actTruthtranceChoamPromises(game, f.target, {type:'truthAnswer', answer:'yes'});
assert.equal(game.shipmentPromises?.[0]?.answer, true);
assert.equal(game.shipmentPromises?.[0]?.fulfilled, undefined);
// Only the previously accepted same-turn preference is staged; the card,
// question and binding answer all came from actual native actions above.
game.choamMarketSkip = {owner:f.target, turn:game.turn};
game = advanceTruthtranceChoamPromises(game, state => state.phase === 0 && state.decision?.kind === 'choamMarket');
const commitment = structuredClone(game.shipmentPromises);
const cards = truthtranceChoamPromisesInventory(game);
assert.equal(viewGame(game, f.target).choamMarketSkipActive, true);
assert.equal(viewGame(game, f.target).choamMarketSkipAvailable, false);
const normalized = normalizeAutomaticGame(structuredClone(game));
assert.equal(normalized.decision?.kind, 'choamMarket');
assert.deepEqual(normalized.shipmentPromises, commitment);
reject(normalized, f.target, {type:'decision', done:true, skipMarketsThisTurn:true});
game = actTruthtranceChoamPromises(normalized, f.target, {type:'decision', done:true});
assert.deepEqual(game.shipmentPromises, commitment);
game = advanceTruthtranceChoamPromises(game, state => state.phase === 2 && state.response?.kind === 'choamCharity');
game = allowTruthtranceChoamPromisesIncome(game);
const completed = finishTruthtranceChoamPromises(f, game);
assert.equal(completed.delivered.shipmentPromises?.[0]?.fulfilled, true);
assert.equal(completed.delivered.shipmentPromises?.[0]?.released, undefined);
assert.deepEqual(truthtranceChoamPromisesInventory(completed.delivered), cards);
});
void test('malformed opt-in packets cannot waive a market; switching to autopilot cancels an accepted waiver', () => {
const market = firstMarket();
for (const action of [
{type:'decision', done:true, skipMarketsThisTurn:false},
{type:'decision', done:true, skipMarketsThisTurn:'true'},
{type:'decision', done:true, skipMarketsThisTurn:true, extra:true},
]) reject(market, 'choam', action);
let game = actChoamCharityFunding(market, 'choam', {type:'decision', done:true, skipMarketsThisTurn:true});
assert.equal(viewGame(game, 'choam').choamMarketSkipActive, true);
game = actChoamCharityFunding(game, 'choam', {type:'setAutopilot', difficulty:'Easy'});
assert.equal(game.choamMarketSkip, undefined);
game = actChoamCharityFunding(game, 'choam', {type:'setAutopilot', difficulty:null});
assert.equal(viewGame(game, 'choam').choamMarketSkipActive, false);
});
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { registerHooks } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { viewGame, type Game } from '../game/engine';
import { actChoamCharityFunding, advanceChoamCharityFunding,
createChoamCharityFundingFixture } from './fixture-choam-charity-funding';
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();
const render = (game: Game, actor: string, busy = false) =>
renderToStaticMarkup(createElement(GameTable, {game:viewGame(game, actor), busy,
send:async () => { assert.fail('Rendering must not submit an action.'); }, onExit() {}}));
/** The starting deal, Storm and first market are genuine public Basic actions;
* the only chosen positions are the source fixture's unopened starting cards. */
function firstMarket() {
const source = createChoamCharityFundingFixture();
return advanceChoamCharityFunding(structuredClone(source.afterSetup), state =>
state.turn === 1 && state.phase === 0 && state.decision?.kind === 'choamMarket');
}
void test('the real CHOAM table offers opt-in, then shows a persistent owner-only cancel control until canceled', () => {
const market = firstMarket();
assert.deepEqual(market.expansions, ['choam']);
assert.equal(market.advanced, false);
assert.equal(market.decision?.player, 'choam');
const ownerHtml = render(market, 'choam');
assert.match(ownerHtml, /Finish and skip remaining markets this turn/);
assert.match(ownerHtml, /other sales and trades and opportunities to receive gifts/);
assert.doesNotMatch(render(market, 'atreides'), /Finish and skip remaining markets this turn/);
let game = actChoamCharityFunding(market, 'choam', {type:'decision', done:true, skipMarketsThisTurn:true});
assert.equal(game.phase, 1);
assert.equal(game.choamMarket, null);
assert.equal(viewGame(game, 'choam').choamMarketSkipActive, true);
assert.equal(viewGame(game, 'atreides').choamMarketSkipActive, false);
for (const state of [game, JSON.parse(JSON.stringify(game)) as Game]) {
const html = render(state, 'choam');
assert.match(html, /Your remaining markets this turn will be skipped/);
assert.match(html, /Cancel skipping remaining markets/);
assert.match(html, /enable it again only from a future market/);
assert.doesNotMatch(render(state, 'atreides'), /Cancel skipping remaining markets/);
assert.doesNotMatch(render(state, 'guild'), /Cancel skipping remaining markets/);
}
game = actChoamCharityFunding(game, 'choam', {type:'cancelChoamMarketSkip'});
assert.equal(viewGame(game, 'choam').choamMarketSkipActive, false);
assert.doesNotMatch(render(game, 'choam'), /Cancel skipping remaining markets/);
});
+113
View File
@@ -0,0 +1,113 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { actChoamCharityFunding, advanceChoamCharityFunding, createChoamCharityFundingFixture,
nextChoamCharityFundingStep } from './fixture-choam-charity-funding';
let fixture: ReturnType<typeof createChoamCharityFundingFixture> | undefined;
const source = () => fixture ??= createChoamCharityFundingFixture();
const owner = 'choam', observer = 'atreides';
const seat = (g: Game, id: string) => {
const player = g.players.find(p => p.id === id); assert.ok(player); return player;
};
const step = (g: Game) => {
const next = nextChoamCharityFundingStep(g); assert.ok(next);
return actChoamCharityFunding(g, next.actor, next.action);
};
const rejected = (g: Game, id: string, action: {type:string; [key:string]:unknown}) => {
const before = structuredClone(g);
assert.throws(() => applyAction(g, id, action));
assert.deepEqual(g, before);
};
/** Public Basic CHOAM/Atreides/Guild game, with the fixture's original deal
* and genuine setup and Storm actions; stop at the actual first closing market. */
function firstMarket() {
const g = advanceChoamCharityFunding(structuredClone(source().afterSetup), state =>
state.turn === 1 && state.phase === 0 && state.decision?.kind === 'choamMarket');
assert.deepEqual(g.expansions, ['choam']);
assert.equal(g.advanced, false);
assert.equal(g.choamMarket?.owner, owner);
assert.equal(g.choamMarket.resume, 'storm');
return g;
}
function optIn(g: Game) {
return actChoamCharityFunding(g, owner, {type:'decision', done:true, skipMarketsThisTurn:true});
}
void test('a human CHOAM can waive remaining markets this turn and the native Storm continuation proceeds', () => {
const before = firstMarket();
assert.equal(viewGame(before, owner).choamMarketSkipAvailable, true);
assert.equal(viewGame(before, owner).choamMarketSkipActive, false);
assert.equal(viewGame(before, observer).choamMarketSkipAvailable, false);
assert.equal(viewGame(before, observer).choamMarketSkipActive, false);
const game = optIn(before);
assert.equal(game.turn, 1);
assert.equal(game.phase, 1);
assert.equal(game.choamMarket, null);
assert.equal(viewGame(game, owner).choamMarketSkipActive, true);
assert.equal(viewGame(game, observer).choamMarketSkipActive, false);
let next = JSON.parse(JSON.stringify(game)) as Game;
for (let count = 0; next.turn === 1 && next.phase < 7 && count < 800; count++) {
assert.notEqual(next.decision?.kind, 'choamMarket', `Unexpected market in phase ${next.phase}`);
next = step(next);
}
assert.equal(next.turn, 1);
assert.equal(next.phase, 7);
assert.equal(viewGame(next, owner).choamMarketSkipActive, true);
assert.notEqual(next.decision?.kind, 'choamMarket');
});
void test('waiver continuation does not inspect or depend on CHOAM’s hidden card faces', () => {
const original = optIn(firstMarket());
const changed = structuredClone(original);
const choam = seat(changed, owner), guild = seat(changed, 'guild');
assert.ok(choam.hand.length && guild.hand.length);
// Swap two actual held cards, preserving all public counts and custody.
[choam.hand[0], guild.hand[0]] = [guild.hand[0], choam.hand[0]];
assert.deepEqual(viewGame(original, observer), viewGame(changed, observer));
for (let game of [original, changed]) {
for (let count = 0; game.phase === 1 && count < 100; count++) {
assert.notEqual(game.decision?.kind, 'choamMarket');
game = step(game);
}
assert.equal(game.phase, 2);
assert.notEqual(game.decision?.kind, 'choamMarket');
}
});
void test('only CHOAM can opt in or revoke, and revocation restores the next real market', () => {
const market = firstMarket();
rejected(market, observer, {type:'decision', done:true, skipMarketsThisTurn:true});
rejected(market, owner, {type:'cancelChoamMarketSkip'});
let game = optIn(market);
rejected(game, observer, {type:'cancelChoamMarketSkip'});
game = actChoamCharityFunding(game, owner, {type:'cancelChoamMarketSkip'});
assert.equal(viewGame(game, owner).choamMarketSkipActive, false);
rejected(game, owner, {type:'cancelChoamMarketSkip'});
game = advanceChoamCharityFunding(game, state => state.phase === 1 && state.decision?.kind === 'choamMarket');
assert.equal(game.choamMarket?.owner, owner);
assert.equal(viewGame(game, owner).choamMarketSkipAvailable, true);
game = actChoamCharityFunding(game, owner, {type:'decision', done:true});
assert.equal(viewGame(game, owner).choamMarketSkipActive, false);
});
for (const mode of ['bot','autopilot'] as const) void test(
`the standing waiver does not skip a market when CHOAM is under ${mode} control`, () => {
let game = optIn(firstMarket());
// A labelled staged controller handover; all game actions before it were native.
seat(game, owner)[mode] = 'Easy';
game = advanceChoamCharityFunding(game, state => state.phase === 1 && state.decision?.kind === 'choamMarket');
assert.equal(game.choamMarket?.owner, owner);
},
);
void test('the waiver expires at the next turn and preserves CHOAM’s final Mentat decision', () => {
let game = optIn(firstMarket());
game = advanceChoamCharityFunding(game, state => state.turn === 1 && state.decision?.kind === 'choamMentat');
assert.equal(game.phase, 8);
assert.equal(game.decision?.player, owner);
game = advanceChoamCharityFunding(game, state => state.turn === 2 && state.phase === 0 && state.decision?.kind === 'choamMarket');
assert.equal(viewGame(game, owner).choamMarketSkipActive, false);
assert.equal(viewGame(game, owner).choamMarketSkipAvailable, true);
});
+11 -2
View File
@@ -224,7 +224,7 @@ void test('CHOAM duplicate sale retains its witness and three spice through Semu
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
assert.equal(claimed.decision?.kind, 'choamMarket');
});
void test('canceled CHOAM sale creates no Semuta event or bank income', () => {
void test('canceled CHOAM sale offers the spent Karama but creates no sale discard or bank income', () => {
const g = fixture();
g.players[1] = newPlayer('e', 'Richese', 'richese');
g.richeseCache = richeseCards();
@@ -235,8 +235,17 @@ void test('canceled CHOAM sale creates no Semuta event or bank income', () => {
const sold = hold(g, 'c', 'Baliset');
const declaration = sell(ready(g), sold.id);
const karama = declaration.players[2].hand.find(card => card.effect === 'karama')!;
const canceled = applyAction(declaration, 'b',
let canceled = applyAction(declaration, 'b',
{ type: 'card', card: karama.id, mode: 'cancel' });
assert.equal(canceled.pendingTreacheryDiscard?.continuation.kind, 'karamaMarketDiscard');
const event = canceled.pendingTreacheryDiscard!.batch.event;
for (let i = 0; canceled.pendingTreacheryDiscard?.batch.event === event && i < canceled.players.length; i++) {
const reaction: NonNullable<Game['pendingTreacheryDiscard']>['reaction'] = canceled.pendingTreacheryDiscard!.reaction;
assert.equal(reaction?.stage, 'offer');
if (reaction?.stage !== 'offer') throw Error('The Semuta offer is missing.');
const player = canceled.players.find(seat => !reaction.passed.includes(seat.id)); assert.ok(player);
canceled = applyAction(canceled, player.id, {type:'semutaPass', event});
}
assert.ok(!canceled.pendingTreacheryDiscard);
assert.equal(canceled.players[0].spice, 20);
assert.equal(canceled.players[0].hand.filter(card => card.id === sold.id).length, 1);