Files
Dune/tests/richese-reveal-response-runtime.test.ts
T
Quintine 82d857b667
Verify and publish checkpoint container / container (push) Canceled after 0s
Allow Richese reveal during selected responses
2026-10-29 07:58:48 +11:00

181 lines
11 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';
import { truthMovementInventory as inventory } from './fixture-truth-movement';
const owner = 'richese', choam = 'choam', observer = 'emperor';
const player = (game: Game, id: string) => {
const found = game.players.find(row => row.id === id); assert.ok(found); return found;
};
const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening &&
!game.truthtrance && !game.pendingTreacheryDiscard;
function next(game: Game) {
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): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The native game ended before its response.');
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('The native response was not reached.');
}
/** Genuine Basic setup with CHOAM and Ix decks, a paid first-turn No-Field
* shipment and the original next-turn Charity response. Only unopened starting
* deal and Spice card positions are selected; the physical Karama is dealt to
* Emperor and held through the intervening native turns. */
function nativeCharitySource(withAtreides = false) {
let game = createGame('RICHESECHARITY', newPlayer(owner, 'Richese', 'richese'), false, ['choam','ix']);
joinGame(game, newPlayer(choam, 'CHOAM', 'choam'));
if (withAtreides) joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
joinGame(game, newPlayer(observer, 'Emperor', 'emperor'));
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
game = act(game, game.host, {type:'start'});
assert.equal(game.status, 'setup');
const ordinaryCount = withAtreides ? 3 : 2;
const ordinary = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0, ordinaryCount);
const karama = game.deck.find(card => card.effect === 'karama');
assert.equal(ordinary.length, ordinaryCount); assert.ok(karama);
for (const [position, card] of [...ordinary, karama].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');
assert.ok(player(game, observer).hand.some(card => card.id === karama.id));
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 token = player(game, owner).noField!.tokens.find(item => item.value === 3); assert.ok(token);
const shipmentEvent = player(game, owner).noFieldEvent; assert.ok(shipmentEvent);
game = act(game, owner, {type:'ship', noField:token.id, event:shipmentEvent,
territory:'polar_sink', sector:0, allyPayment:0});
game = reach(game, clean);
assert.equal(player(game, owner).noField?.deployed?.tokenId, token.id);
game = act(game, owner, {type:'endMovement'});
game = reach(game, state => state.turn === 2 && state.phase === 2 && state.response?.kind === 'choamCharity');
assert.equal(game.response?.owner, choam);
assert.ok(player(game, observer).hand.some(card => card.id === karama.id));
const event = player(game, owner).noFieldEvent; assert.ok(event);
return {game, token:token.id, event, karama:karama.id, cards:inventory(game)};
}
let saved: ReturnType<typeof nativeCharitySource> | undefined;
const source = () => saved ??= nativeCharitySource();
let savedAtreides: ReturnType<typeof nativeCharitySource> | undefined;
const atreidesSource = () => savedAtreides ??= nativeCharitySource(true);
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
void test('Richese reveals during the real CHOAM Charity response while its original cancel opportunity and continuation remain', () => {
const {token,event,karama,cards} = source();
let before = structuredClone(source().game);
before = act(before, owner, {type:'passResponse'});
assert.equal(before.response?.kind, 'choamCharity');
assert.ok(before.response.passed.includes(owner));
const response = structuredClone(before.response), cash = player(before, choam).spice,
reserves = player(before, owner).reserves, field = player(before, owner).forces['polar_sink:0'] ?? 0;
assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true);
reject(before, observer, {type:'revealNoField', token, event});
reject(before, owner, {type:'revealNoField', token, event:'stale'});
reject(before, owner, {type:'revealNoField', token:'stale', event});
let after = act(before, owner, {type:'revealNoField', token, event});
assert.deepEqual(after.response, response);
assert.equal(after.turn, before.turn); assert.equal(after.phase, before.phase);
assert.equal(player(after, owner).noField?.deployed, null);
assert.equal(player(after, owner).noField?.lastShipped, token);
assert.notEqual(player(after, owner).noFieldEvent, event);
assert.equal(player(after, owner).reserves, reserves - 3);
assert.equal(player(after, owner).forces['polar_sink:0'], field + 3);
assert.equal(player(after, choam).spice, cash);
assert.ok(player(after, observer).hand.some(card => card.id === karama));
assert.deepEqual(inventory(after), cards);
reject(after, owner, {type:'revealNoField', token, event});
after = act(after, observer, {type:'passResponse'});
assert.notEqual(after.response?.kind, 'choamCharity');
assert.equal(player(after, choam).spice, cash + 2 * before.players.length);
assert.equal(player(after, owner).forces['polar_sink:0'], field + 3);
assert.deepEqual(inventory(after), cards);
});
void test('all minimal bot profiles can continue the preserved CHOAM Charity response', () => {
const {game, token, event} = source();
const revealed = act(structuredClone(game), owner, {type:'revealNoField', token, event});
for (const difficulty of DIFFICULTIES) {
const automated = structuredClone(revealed);
player(automated, observer).bot = difficulty;
const choice = botActions(viewGame(automated, observer)).find(action =>
action.type === 'passResponse' || action.type === 'card' && action.mode === 'cancel');
assert.ok(choice, `${difficulty} needs a legal response`);
const continued = applyAction(automated, observer, choice);
assert.ok(continued.response?.kind !== 'choamCharity' ||
continued.response.passed.includes(observer) || continued.pendingKarama || continued.pendingKull ||
continued.pendingTreacheryDiscard,
`${difficulty} must advance the original response: ${JSON.stringify({choice, response:continued.response,
decision:continued.decision, discard:!!continued.pendingTreacheryDiscard})}`);
assert.equal(player(continued, owner).forces['polar_sink:0'], 3);
}
});
void test('Richese reveals during the real Atreides normal-auction inspection response and preserves its lot', () => {
const {token,event,karama,cards} = atreidesSource();
const before = reach(structuredClone(atreidesSource().game), state => state.turn === 2 && state.phase === 3 &&
state.response?.kind === 'atreidesAuction' && !!state.auction && !state.richeseAuction);
assert.equal(before.response?.owner, 'atreides');
assert.ok(player(before, observer).hand.some(card => card.id === karama));
const response = structuredClone(before.response), lot = structuredClone(before.auction),
reserves = player(before, owner).reserves, field = player(before, owner).forces['polar_sink:0'] ?? 0;
assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true);
const after = act(before, owner, {type:'revealNoField', token, event});
assert.deepEqual(after.response, response); assert.deepEqual(after.auction, lot);
assert.equal(player(after, owner).noField?.deployed, null);
assert.equal(player(after, owner).reserves, reserves - 3);
assert.equal(player(after, owner).forces['polar_sink:0'], field + 3);
assert.deepEqual(inventory(after), cards);
const control = act(before, observer, {type:'passResponse'});
const continued = act(after, observer, {type:'passResponse'});
assert.notEqual(continued.response?.kind, 'atreidesAuction');
assert.deepEqual(continued.auction, control.auction);
assert.equal(player(continued, owner).forces['polar_sink:0'], field + 3);
assert.deepEqual(inventory(continued), cards);
});
void test('Richese reveals during the real cache-auction prevention response without replaying its declaration', () => {
const {token,event,karama,cards} = source();
const before = reach(structuredClone(source().game), state => state.turn === 2 && state.phase === 3 &&
state.response?.kind === 'richeseAuction');
assert.equal(before.response?.owner, owner);
assert.ok(player(before, observer).hand.some(card => card.id === karama));
const response = structuredClone(before.response), round = structuredClone(before.richeseBidding),
reserves = player(before, owner).reserves, field = player(before, owner).forces['polar_sink:0'] ?? 0;
assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true);
const after = act(before, owner, {type:'revealNoField', token, event});
assert.deepEqual(after.response, response); assert.deepEqual(after.richeseBidding, round);
assert.equal(player(after, owner).noField?.deployed, null);
assert.equal(player(after, owner).reserves, reserves - 3);
assert.equal(player(after, owner).forces['polar_sink:0'], field + 3);
assert.deepEqual(inventory(after), cards);
const control = act(before, observer, {type:'passResponse'});
const continued = act(after, observer, {type:'passResponse'});
assert.notEqual(continued.response?.kind, 'richeseAuction');
assert.ok(continued.richeseBidding?.event);
assert.deepEqual({...continued.richeseBidding, event:undefined}, {...control.richeseBidding, event:undefined});
assert.deepEqual(continued.richeseAuction, control.richeseAuction);
assert.deepEqual({...continued.decision, event:undefined}, {...control.decision, event:undefined});
assert.equal(player(continued, owner).forces['polar_sink:0'], field + 3);
assert.deepEqual(inventory(continued), cards);
});