Allow Richese No-Field reveal during Basic openings and markets
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 07:27:08 +11:00
1 parent ac7db3cb35
commit 0d55333d5d
9 files changed
+617 -3

No files matched your search

@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import * as jsxRuntime from 'react/jsx-runtime';
import ts from 'typescript';
import { RicheseNoFieldControls } from '../components/richese-no-field';
import { applyAction, viewGame, type Action, type GameView } from '../game/engine';
import * as catalog from '../game/catalog';
import * as board from '../game/board';
import * as advisors from '../game/advisors';
import * as shipmentPrice from '../game/shipment-price';
import * as noField from '../game/richese-no-field';
import * as homeworldCards from '../game/homeworld-cards';
import * as smuggler from '../game/smuggler-no-field';
import * as homeworldShipment from '../game/richese-homeworld-shipment';
import { smugglerNoFieldAction, smugglerNoFieldGame } from './smuggler-no-field-fixture';
// Stage the admitted quote in a personalized view; engine admission is tested
// independently. The marker itself comes from a real Basic shipment.
function projection(viewer: 'r' | 'h'): GameView {
let game = smugglerNoFieldGame();
game = applyAction(game, 'r', smugglerNoFieldAction(game, 3, false));
const view = viewGame(game, viewer);
view.phase = 3;
view.decision = { kind: 'auctionPayment', player: 'h' };
if (viewer === 'r') {
view.richeseNoField!.canReveal = true;
view.richeseNoField!.revealBlock = null;
}
return view;
}
function props(game: GameView, busy: boolean, act: (action: Action) => void) {
return { game, busy, act, destination: 'arrakeen', sector: 10, allyPayment: '' };
}
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
return [node, ...elements(node.props.children)];
}
// Invoke the actual component with deterministic hooks to exercise its callback.
function control(game: GameView, busy: boolean, sent: Action[]) {
const exports: { RicheseNoFieldControls?: typeof RicheseNoFieldControls } = {};
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/richese-no-field.tsx', import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
}).outputText, { exports, require(name: string) {
const modules: Record<string, unknown> = {
'react/jsx-runtime': jsxRuntime,
react: { useId: () => 'fixture', useState: (initial: unknown) => [initial, () => {}] },
'@/game/catalog': catalog, '@/game/board': board, '@/game/advisors': advisors,
'@/game/shipment-price': shipmentPrice, '@/game/richese-no-field': noField,
'@/game/homeworld-cards': homeworldCards, '@/game/smuggler-no-field': smuggler,
'@/game/richese-homeworld-shipment': homeworldShipment,
'./richese-homeworld-forces': {}, './shipment-quote': {}, './ui/button': { Button: 'button' },
};
if (!(name in modules)) throw Error(`Unexpected import ${name}`);
return modules[name];
} });
assert.ok(exports.RicheseNoFieldControls);
return elements(exports.RicheseNoFieldControls(props(game, busy, action => sent.push(action))));
}
void test('the owner can reveal the current marker during an admitted Basic auction payment', () => {
const game = projection('r'), sent: Action[] = [];
const html = renderToStaticMarkup(createElement(RicheseNoFieldControls, props(game, false, () => {})));
assert.match(html, /Reveal your No-Field/);
const button = control(game, false, sent).find(element => element.type === 'button' && element.props.children === 'Reveal your No-Field');
assert.ok(button);
assert.equal(button.props.disabled, false);
(button.props.onClick as () => void)();
assert.deepEqual(JSON.parse(JSON.stringify(sent)), [{ type: 'revealNoField',
event: game.richeseNoField!.event, token: game.richeseNoField!.private!.deployed!.tokenId }]);
});
void test('the observer has no private inventory or reveal action; busy blocks the owner callback', () => {
const observer = projection('h'), sent: Action[] = [];
assert.equal(observer.richeseNoField!.private, null);
assert.equal(renderToStaticMarkup(createElement(RicheseNoFieldControls, props(observer, false, () => {}))), '');
assert.deepEqual(control(observer, false, sent), []);
const button = control(projection('r'), true, sent).find(element => element.type === 'button' && element.props.children === 'Reveal your No-Field');
assert.ok(button);
assert.equal(button.props.disabled, true);
(button.props.onClick as () => void)();
assert.deepEqual(sent, []);
});
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, newPlayer, viewGame, type Game } from '../game/engine';
import { richeseRevealInterruptSupported } from '../game/richese-reveal-interruption';
import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field';
import {
bindTruthMixedBribe,
createTruthMixedBribeFixture,
truthMixedBribeFact,
truthMixedBribeGroup,
truthMixedBribePayment,
} from './fixture-truth-mixed-bribe';
// The public question and residual are produced by the native mixed-question
// fixture. The pending auction parent, transfer of its records to a Richese
// table, marker, and alternate no-residual ledger are explicitly staged for
// this availability comparison; they are not a replay of the original game.
function stagedPair() {
const fixture = createTruthMixedBribeFixture();
const mixed = truthMixedBribeGroup('or', truthMixedBribeFact(fixture.hajrFact),
truthMixedBribePayment(fixture.asker));
const answered = bindTruthMixedBribe(fixture, 'yes', mixed).game;
const record = answered.truthHistory?.at(-1);
const residuals = answered.bribeExpressionPromises;
assert.ok(record && residuals?.length);
assert.equal(record.answer, 'yes');
assert.ok(residuals.some(row => !row.fulfilled && !row.released));
const base = createGame('REVEALPRIVACY', newPlayer('r', 'Richese', 'richese'), false, ['choam']);
base.players.push(...answered.players.map(player => newPlayer(player.id, player.name, player.faction)));
const richese = base.players[0];
richese.noField = deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']), {
tokenId: 'three', controller: 'r', location: { territory: 'polar_sink', sector: 0 },
});
richese.noFieldEvent = 'marker-event';
base.status = 'playing';
base.phase = 3;
base.turn = record.turn;
base.response = null;
base.phaseOpening = null;
base.decision = { kind: 'auctionPayment', player: fixture.target };
base.auction = { cards: [], index: 0, bid: 3, bidder: fixture.target, active: fixture.target, passed: [], opener: 0 };
base.truthHistory = [structuredClone(record)];
const withResidual = structuredClone(base);
withResidual.bribeExpressionPromises = structuredClone(residuals);
const withoutResidual = structuredClone(base);
withoutResidual.bribeExpressionPromises = [];
return { withResidual, withoutResidual, record };
}
void test('a public current-turn mixed answer uniformly hides private residual-dependent interruption availability', () => {
const { withResidual, withoutResidual, record } = stagedPair();
const baseline = structuredClone(withoutResidual);
baseline.truthHistory = [];
assert.equal(richeseRevealInterruptSupported(baseline), true, 'the staged economic parent is otherwise admitted');
for (const game of [withResidual, withoutResidual]) {
const before = structuredClone(game);
assert.deepEqual(viewGame(game, 'r').truthHistory, [record]);
assert.equal(richeseRevealInterruptSupported(game), false);
const info = viewGame(game, 'r').richeseNoField;
assert.ok(info?.private?.deployed);
assert.equal(info.canReveal, false);
assert.throws(() => applyAction(game, 'r', { type: 'revealNoField',
event: info.event, token: info.private!.deployed!.tokenId }));
assert.deepEqual(game, before);
}
assert.equal(viewGame(withResidual, 'r').richeseNoField!.revealBlock,
viewGame(withoutResidual, 'r').richeseNoField!.revealBlock);
});
void test('the public history fence applies only to the current turn', () => {
const { withoutResidual } = stagedPair();
const game: Game = structuredClone(withoutResidual);
game.turn++;
assert.equal(richeseRevealInterruptSupported(game), true);
assert.equal(viewGame(game, 'r').richeseNoField?.canReveal, true);
});
@@ -0,0 +1,105 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, newPlayer, type Decision, type Game } from '../game/engine';
import { richeseRevealInterruptSupported as supported } from '../game/richese-reveal-interruption';
/** Staged parent records test only the admission quote; native parent and
* physical token transitions are exercised by the separate runtime suite. */
function source(): Game {
const g = createGame('REVEALQUOTE', newPlayer('r', 'Richese', 'richese'), false, ['choam', 'ix']);
g.players.push(newPlayer('c', 'CHOAM', 'choam'), newPlayer('e', 'Emperor', 'emperor'));
g.status = 'playing'; g.phase = 3; g.turn = 2;
return g;
}
function parent(kind: Decision['kind']): Game {
const g = source();
if (kind === 'auctionPayment') {
g.decision = {kind, player:'e'};
g.auction = {cards:[], index:0, bid:3, bidder:'e', active:'e', passed:[], opener:0};
} else if (kind === 'richeseDeclaration' || kind === 'richeseCacheTerms' || kind === 'richeseCache' || kind === 'richeseUnbid') {
g.decision = {kind, player:'r'};
g.richeseBidding = {owner:'r', event:'round', turn:g.turn,
stage:kind === 'richeseDeclaration' ? 'declaration' : kind === 'richeseUnbid' ? 'lot' : 'cacheOffer',
position:'first', normalCount:1, blackMarketSold:false, cacheCanceled:false};
if (kind === 'richeseUnbid') g.richeseAuction = {
event:'lot', cardId:'card', source:'cache', owner:'r', method:'onceAround', eligible:['r','e'],
order:['e','r'], tieOrder:['r','e'], active:null, bid:0, bidder:null, passed:['r','e'], acted:['r','e'], sealed:{}, outcome:{kind:'unbid'},
};
} else if (kind === 'choamMarket' || kind === 'choamTradeReply' || kind === 'choamTradeConfirm') {
g.decision = {kind, player:kind === 'choamTradeReply' ? 'r' : 'c'};
g.choamMarket = {owner:'c', resume:'phase', blocked:[],
...(kind === 'choamMarket' ? {} : {trade:{ally:'r', offered:'offered',
...(kind === 'choamTradeConfirm' ? {returned:'returned'} : {})}})};
} else if (kind === 'richeseAllyOpportunity') {
g.phase = 5;
g.decision = {kind, player:'r', recipient:'e'};
g.richeseAllyOpportunity = {turn:g.turn, recipient:'e'};
} else throw Error(`Unrecognized staged parent ${kind}`);
return g;
}
void test('each supported economic parent is admitted without modifying its frame or inspecting marker custody', () => {
for (const kind of ['auctionPayment','richeseDeclaration','richeseCacheTerms','richeseCache','richeseUnbid',
'choamMarket','choamTradeReply','choamTradeConfirm','richeseAllyOpportunity'] as const) {
const game = parent(kind), before = structuredClone(game);
assert.equal(supported(game), true, kind);
assert.deepEqual(game, before);
assert.equal(game.players[0].noField?.deployed ?? null, null, 'Seat/marker authorization belongs to the caller');
}
});
void test('a clean Ix phase opening remains admitted before or after its owner passes', () => {
const game = source(); game.phase = 0; game.phaseOpening = {passed:[], initialize:true}; game.amalWindowOpen = true;
assert.equal(supported(game), true);
game.phaseOpening.passed.push('r');
assert.equal(supported(game), true);
const before = structuredClone(game); assert.equal(supported(game), true); assert.deepEqual(game, before);
game.decision = {kind:'auctionPayment', player:'e'}; assert.equal(supported(game), false);
game.decision = null; game.amalWindowOpen = false; assert.equal(supported(game), false);
});
void test('a legacy table without public Truthtrance history keeps its earlier interruption behavior', () => {
const game = parent('auctionPayment');
delete game.truthHistory;
assert.equal(supported(game), false);
});
void test('profile and nested-interaction boundaries do not admit another parent or module', () => {
const patches: Array<Partial<Game>> = [
{advanced:true}, {status:'setup'}, {phase:6}, {phase:-1}, {turn:0},
{expansions:['choam','ecaz']}, {expansions:['ix']},
{response:{kind:'richeseAuction', owner:'r', passed:[]}},
{pendingNullentropy:{} as NonNullable<Game['pendingNullentropy']>},
{pendingTreacheryDiscard:{} as NonNullable<Game['pendingTreacheryDiscard']>},
{pendingShipment:{} as NonNullable<Game['pendingShipment']>},
{pendingKull:{} as NonNullable<Game['pendingKull']>},
{biddingEnd:{} as NonNullable<Game['biddingEnd']>},
{stormResolution:{} as NonNullable<Game['stormResolution']>},
{techTokens:{} as NonNullable<Game['techTokens']>},
{leaderSkills:{} as NonNullable<Game['leaderSkills']>},
{discoveryEnabled:true}, {ecazTreachery:true},
];
for (const patch of patches) {
const game = Object.assign(parent('auctionPayment'), patch), before = structuredClone(game);
assert.equal(supported(game), false, JSON.stringify(patch)); assert.deepEqual(game, before);
}
const wrong = parent('auctionPayment'); wrong.decision = {kind:'revivalStop', player:'e', recipient:'r', revival:'forces'};
assert.equal(supported(wrong), false);
const missing = parent('auctionPayment'); missing.auction = null; assert.equal(supported(missing), false);
const wrongSeat = parent('auctionPayment'); wrongSeat.auction!.bidder = 'r'; assert.equal(supported(wrongSeat), false);
const native = parent('auctionPayment'); native.players.push(newPlayer('i','Ixians','ixians'));
native.expansions = ['choam']; assert.equal(supported(native), false);
});
void test('live current-turn commitments block the interruption; settled and previous-turn records do not', () => {
const game = parent('auctionPayment');
game.shipmentPromises = [{turn:game.turn, player:'e', asker:'r', answer:true, territory:'carthag', minimum:3}];
assert.equal(supported(game), false);
game.shipmentPromises[0].fulfilled = true; assert.equal(supported(game), true);
game.shipmentPromises[0].fulfilled = false; game.shipmentPromises[0].turn--; assert.equal(supported(game), true);
game.shipmentPromises = [];
game.futureBattleLeaderPromises = [{turn:game.turn, asker:'r', player:'e', leader:'leader', answer:true, status:'waiting'}];
assert.equal(supported(game), false);
game.futureBattleLeaderPromises[0].status = 'settled'; assert.equal(supported(game), true);
game.deals = [{status:'active'}] as unknown as Game['deals']; assert.equal(supported(game), false);
});
@@ -0,0 +1,206 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
const owner = 'richese', observer = 'emperor', choam = 'choam';
const seat = (game: Game, id: string) => {
const player = game.players.find(row => row.id === id); assert.ok(player); return player;
};
const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening &&
!game.truthtrance && !game.pendingTreacheryDiscard;
function next(game: Game) {
// The inherited native helper intentionally leaves Emperor's own move to
// callers. This programme completes the original turn without a shipment.
if (game.phase === 5 && game.active === observer && clean(game))
return {actor:observer, action:{type:'endMovement'} satisfies Action};
return nextTruthMovementNativeEarlyStep(game, {
position:'first', method:'onceAround', direction:'clockwise', keepUnbid:false,
});
}
function reach(game: Game, until: (state: Game) => boolean) {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'Native game ended before the requested window');
const step = next(game); assert.ok(step, `Supply native action at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = act(game, step.actor, step.action);
}
throw Error('Native No-Field programme did not reach the requested window');
}
/** Genuine public Basic Richese/CHOAM/Emperor, both selected native decks.
* Select only original unopened starting-card/Spice positions. Real setup,
* auction passes, one paid concealed shipment and native phase controls lead
* to the next turn. Polar Sink keeps the marker safe from Storm and battles. */
function nativeSource() {
let game = createGame('RICHESEINTERRUPT', newPlayer(owner, 'Richese', 'richese'), false, ['choam','ix']);
joinGame(game, newPlayer(choam, 'CHOAM', 'choam'));
joinGame(game, newPlayer(observer, 'Emperor', 'emperor'));
for (const p of game.players) game = act(game, p.id, {type:'ready'});
game = act(game, game.host, {type:'start'});
assert.equal(game.status, 'setup'); assert.deepEqual(game.expansions, ['choam','ix']);
assert.ok(game.players.every(p => p.hand.length === 0));
const ordinary = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0, 3);
assert.equal(ordinary.length, 3);
for (const [position, card] of ordinary.entries()) {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
}
game = reach(game, state => state.status === 'playing');
for (const position of [0,1]) {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position);
[game.spiceDeck[position], game.spiceDeck[from]] = [game.spiceDeck[from], game.spiceDeck[position]];
}
game = reach(game, state => state.turn === 1 && state.phase === 5 && state.active === owner && clean(state));
const richese = seat(game, owner), token = richese.noField!.tokens.find(item => item.value === 3); assert.ok(token);
const shipmentEvent = richese.noFieldEvent; assert.ok(shipmentEvent);
const beforeSpice = richese.spice;
game = act(game, owner, {type:'ship', noField:token.id, event:shipmentEvent,
territory:'polar_sink', sector:0, allyPayment:0});
game = reach(game, clean);
assert.equal(seat(game, owner).spice, beforeSpice - 2);
assert.equal(seat(game, owner).noField?.deployed?.tokenId, token.id);
assert.equal(seat(game, owner).reserves, 20);
assert.equal(seat(game, owner).forces['polar_sink:0'] ?? 0, 0);
game = act(game, owner, {type:'endMovement'});
game = reach(game, state => state.turn === 2 && state.phase === 0 && !!state.phaseOpening &&
!state.response && !state.decision && !state.truthtrance);
assert.equal(seat(game, owner).noField?.deployed?.tokenId, token.id);
const event = seat(game, owner).noFieldEvent; assert.ok(event);
return {game, token:token.id, event};
}
let saved: ReturnType<typeof nativeSource> | undefined;
const source = () => saved ??= nativeSource();
const reveal = (game: Game, token: string, event: string) =>
applyAction(game, owner, {type:'revealNoField', token, event});
function reject(game: Game, id: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, id, action));
assert.deepEqual(game, before);
}
function verifyRevealed(before: Game, after: Game, token: string, event: string) {
assert.equal(seat(after, owner).noField?.deployed, null);
assert.notEqual(seat(after, owner).noFieldEvent, event);
assert.equal(seat(after, owner).noField?.lastShipped, token);
assert.equal(seat(after, owner).reserves, seat(before, owner).reserves - 3);
assert.equal(seat(after, owner).forces['polar_sink:0'], (seat(before, owner).forces['polar_sink:0'] ?? 0) + 3);
assert.equal(seat(after, owner).tanks, seat(before, owner).tanks);
assert.equal(seat(after, owner).spice, seat(before, owner).spice);
reject(after, owner, {type:'revealNoField', token, event});
}
void test('Basic native Ix opening keeps prior passes and resumes after a voluntary marker reveal', () => {
const {token,event} = source();
let game = structuredClone(source().game);
game = act(game, owner, {type:'ready'});
assert.ok(game.phaseOpening!.passed.includes(owner));
const parent = structuredClone(game.phaseOpening);
assert.equal(viewGame(game, owner).richeseNoField?.canReveal, true);
reject(game, observer, {type:'revealNoField', token, event});
reject(game, owner, {type:'revealNoField', token, event:'stale'});
reject(game, owner, {type:'revealNoField', token:'wrong-token', event});
const before = structuredClone(game), after = reveal(game, token, event);
verifyRevealed(before, after, token, event);
assert.deepEqual(after.phaseOpening, parent);
assert.equal(after.phase, before.phase); assert.equal(after.turn, before.turn);
const continued = reach(after, state => !state.phaseOpening);
assert.equal(seat(continued, owner).forces['polar_sink:0'], 3);
});
for (const kind of ['choamMarket','richeseDeclaration','richeseCache','richeseUnbid'] as const) void test(
`Basic native ${kind} remains intact while Richese reveals, then its original decision continues`, () => {
const {token,event} = source();
const before = reach(structuredClone(source().game), state =>
state.turn === 2 && state.decision?.kind === kind && !state.response && !state.phaseOpening);
const parent = structuredClone(before.decision), market = structuredClone(before.choamMarket ?? null),
bidding = structuredClone(before.richeseBidding ?? null), auction = structuredClone(before.auction ?? null);
assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true);
const after = reveal(before, token, event);
verifyRevealed(before, after, token, event);
assert.deepEqual(after.decision, parent); assert.deepEqual(after.choamMarket ?? null, market);
assert.deepEqual(after.richeseBidding ?? null, bidding); assert.deepEqual(after.auction ?? null, auction);
const step = next(after); assert.ok(step);
assert.equal(step.actor, parent!.player);
const continued = act(after, step.actor, step.action);
assert.notDeepEqual(continued.decision, parent);
assert.equal(seat(continued, owner).forces['polar_sink:0'], 3);
},
);
/** Reorder only an unopened turn-two Spice card, then form a real CHOAM /
* Emperor alliance at its Nexus and initiate their ordinary market trade. */
function nativeTradeReply() {
let game = structuredClone(source().game);
const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
assert.ok(worm >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(worm, 1)[0]);
game = reach(game, state => state.turn === 2 && state.phase === 1 && !!state.nexus &&
!state.spiceWindow && !state.spiceResolution && clean(state));
game = act(game, choam, {type:'alliance', target:observer});
game = act(game, observer, {type:'alliance', target:choam});
assert.equal(seat(game, choam).ally, observer);
assert.equal(seat(game, observer).ally, choam);
game = reach(game, state => state.turn === 2 && state.phase === 1 &&
state.decision?.kind === 'choamMarket' && !state.response && !state.phaseOpening);
const offered = seat(game, choam).hand[0]; assert.ok(offered);
const returned = seat(game, observer).hand[0]; assert.ok(returned);
game = act(game, choam, {type:'decision', mode:'trade', card:offered.id});
assert.equal(game.decision?.kind, 'choamTradeReply');
assert.equal(game.decision.player, observer);
return {game, offered:offered.id, returned:returned.id};
}
let tradeFixture: ReturnType<typeof nativeTradeReply> | undefined;
const tradeSource = () => tradeFixture ??= nativeTradeReply();
void test('Basic CHOAM trade reply retains its genuine alliance, offered card and exact continuation', () => {
const {token,event} = source(), {offered,returned} = tradeSource();
const before = structuredClone(tradeSource().game);
const parent = structuredClone(before.decision), market = structuredClone(before.choamMarket);
assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true);
const after = reveal(before, token, event);
verifyRevealed(before, after, token, event);
assert.deepEqual(after.decision, parent); assert.deepEqual(after.choamMarket, market);
let continued = act(after, observer, {type:'decision', card:returned});
assert.equal(continued.decision?.kind, 'choamTradeConfirm');
assert.equal(continued.decision.player, choam);
continued = act(continued, choam, {type:'decision', accept:true});
assert.equal(continued.decision?.kind, 'choamMarket');
assert.ok(seat(continued, choam).hand.some(card => card.id === returned));
assert.ok(seat(continued, observer).hand.some(card => card.id === offered));
assert.equal(seat(continued, owner).forces['polar_sink:0'], 3);
});
void test('Basic CHOAM trade confirmation remains pending through reveal and exchanges the actual cards', () => {
const {token,event} = source(), f = tradeSource();
const before = act(structuredClone(f.game), observer, {type:'decision', card:f.returned});
assert.equal(before.decision?.kind, 'choamTradeConfirm');
const parent = structuredClone(before.decision), market = structuredClone(before.choamMarket);
assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true);
const after = reveal(before, token, event);
verifyRevealed(before, after, token, event);
assert.deepEqual(after.decision, parent); assert.deepEqual(after.choamMarket, market);
const continued = act(after, choam, {type:'decision', accept:true});
assert.equal(continued.decision?.kind, 'choamMarket');
assert.ok(seat(continued, choam).hand.some(card => card.id === f.returned));
assert.ok(seat(continued, observer).hand.some(card => card.id === f.offered));
assert.equal(seat(continued, owner).forces['polar_sink:0'], 3);
});
void test('all four minimal bot profiles can answer the preserved native Richese economic decision', () => {
const {token,event} = source();
const before = reach(structuredClone(source().game), state => state.turn === 2 &&
state.decision?.kind === 'richeseDeclaration' && !state.response && !state.phaseOpening);
const after = reveal(before, token, event);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(after, owner);
view.players.find(player => player.id === owner)!.bot = difficulty;
const choice = botActions(view).find(action => action.type === 'decision');
assert.ok(choice, `${difficulty} needs an original legal declaration`);
const continued = applyAction(structuredClone(after), owner, choice);
assert.notDeepEqual(continued.decision, after.decision);
assert.equal(seat(continued, owner).forces['polar_sink:0'], 3);
}
});