Files
Dune/tests/richese-reveal-interruption-runtime.test.ts
T
Quintine 0d55333d5d
Verify and publish checkpoint container / container (push) Canceled after 0s
Allow Richese No-Field reveal during Basic openings and markets
2026-10-29 07:27:08 +11:00

207 lines
12 KiB
TypeScript

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);
}
});