Files
Dune/tests/richese-reveal-guild-rate-runtime.test.ts
T
Quintine 3dbd326891
Verify and publish checkpoint container / container (push) Successful in 11m10s
Expand Basic No-Field reveal response timing
2026-10-29 09:47:39 +11:00

174 lines
9.7 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { richeseRevealInterruptSupported } from '../game/richese-reveal-interruption';
import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
import { truthMovementInventory } from './fixture-truth-movement';
const richese = 'richese', guild = 'guild', atreides = 'atreides';
const player = (game: Game, id: string) => {
const result = game.players.find(row => row.id === id); assert.ok(result); return result;
};
const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening &&
!game.truthtrance && !game.pendingTreacheryDiscard;
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
/** Real public Basic setup, paid No-Field shipment and Guild-rate declaration.
* Only unopened starting and Spice card positions and scalar lotteries are
* selected; no hands, balance, forces, alliances or pending events are staged. */
function nativeRateSource() {
let game = createGame('RICHESEGUILDRATE', newPlayer(richese, 'Richese', 'richese'), false, ['choam']);
joinGame(game, newPlayer(guild, 'Guild', 'guild'));
joinGame(game, newPlayer(atreides, 'Atreides', 'atreides'));
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 ordinary = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0, 2);
const karama = game.deck.find(card => card.effect === 'karama');
assert.equal(ordinary.length, 2); 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]];
}
const reach = (until: (state: Game) => boolean) => {
for (let count = 0; count < 2200; count++) {
if (until(game)) return;
assert.notEqual(game.status, 'finished');
if (game.phase === 5 && game.active === richese && clean(game) &&
!player(game, richese).noField?.deployed && !player(game, richese).shipped) {
const owner = player(game, richese), marker = owner.noField!.tokens.find(token => token.value === 3);
assert.ok(marker); assert.ok(owner.noFieldEvent);
game = act(game, richese, {type:'ship', noField:marker.id, event:owner.noFieldEvent,
territory:'polar_sink', sector:0, allyPayment:0});
continue;
}
const step = nextTruthMovementNativeEarlyStep(game, {
position:'first', method:'onceAround', direction:'clockwise', keepUnbid:false,
});
assert.ok(step, `Missing native action at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = act(game, step.actor, step.action);
}
throw Error('The genuine Guild-rate shipment was not reached');
};
reach(state => state.status === 'playing');
assert.ok(player(game, atreides).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]];
}
reach(state => state.phase === 5 && state.active === guild && clean(state) &&
!!player(state, richese).noField?.deployed && !player(state, guild).shipped);
const marker = player(game, richese).noField!.deployed!;
const event = player(game, richese).noFieldEvent; assert.ok(event);
const initialGuild = structuredClone(player(game, guild));
game = act(game, guild, {type:'ship', territory:'arrakeen', sector:10, amount:3, allyPayment:0});
assert.equal(game.response?.kind, 'guildRate');
assert.equal(game.pendingShipment?.player, guild);
assert.equal(game.pendingShipment?.territory, 'arrakeen');
assert.equal(game.pendingShipment?.cost, 2);
assert.equal(player(game, guild).reserves, initialGuild.reserves);
assert.equal(player(game, guild).spice, initialGuild.spice);
assert.equal(player(game, guild).shipped, false);
assert.ok(viewGame(game, atreides).responseControls?.cancelCards.includes(karama.id));
return {game, marker:marker.tokenId, event, karama:karama.id, initialGuild};
}
let saved: ReturnType<typeof nativeRateSource> | undefined;
const source = () => saved ??= nativeRateSource();
function reveal() {
const {marker,event} = source(), before = structuredClone(source().game);
const response = structuredClone(before.response), shipment = structuredClone(before.pendingShipment);
const oldRichese = player(before, richese), cards = truthMovementInventory(before);
assert.equal(viewGame(before, richese).richeseNoField?.canReveal, true);
const action = {type:'revealNoField', token:marker, event} satisfies Action;
const wrong = structuredClone(before);
assert.throws(() => act(wrong, atreides, action));
assert.deepEqual(wrong, before);
const after = act(before, richese, action);
assert.deepEqual(after.response, response);
assert.deepEqual(after.pendingShipment, shipment);
assert.equal(player(after, richese).noField?.deployed, null);
assert.equal(player(after, richese).noField?.lastShipped, marker);
assert.notEqual(player(after, richese).noFieldEvent, event);
assert.equal(player(after, richese).reserves, oldRichese.reserves - 3);
assert.equal(player(after, richese).forces['polar_sink:0'], (oldRichese.forces['polar_sink:0'] ?? 0) + 3);
assert.equal(player(after, richese).spice, oldRichese.spice);
assert.deepEqual(truthMovementInventory(after), cards);
assert.throws(() => act(after, richese, action));
return after;
}
for (const cancel of [false, true]) void test(
`Basic No-Field reveal preserves Guild-rate ${cancel ? 'cancellation' : 'allowance'} and the original shipment`, () => {
const {karama,initialGuild} = source();
const before = structuredClone(source().game), after = reveal();
const action: Action = cancel ? {type:'card', mode:'cancel', card:karama} : {type:'passResponse'};
let original = act(reload(before), atreides, action);
let continued = act(reload(after), atreides, action);
if (cancel) {
for (const state of [original, continued]) {
assert.equal(state.pendingTreacheryDiscard?.batch.cause, 'karama:guildRate');
assert.equal(player(state, guild).reserves, initialGuild.reserves);
assert.equal(player(state, guild).spice, initialGuild.spice);
assert.equal(player(state, guild).shipped, false);
assert.ok(state.pendingShipment);
}
const finishDiscard = (state: Game) => {
for (let count = 0; state.pendingTreacheryDiscard && count < state.players.length + 1; count++) {
const step = nextTruthMovementNativeEarlyStep(state); assert.ok(step);
assert.equal(step.action.type, 'semutaPass');
state = act(state, step.actor, step.action);
}
assert.equal(state.pendingTreacheryDiscard, null);
return state;
};
original = finishDiscard(original);
continued = finishDiscard(continued);
}
assert.equal(continued.response?.kind === 'guildRate', false);
assert.equal(continued.pendingShipment, null);
assert.deepEqual(player(continued, guild), player(original, guild));
assert.equal(player(continued, guild).reserves, initialGuild.reserves - 3);
assert.equal(player(continued, guild).forces['arrakeen:10'], (initialGuild.forces['arrakeen:10'] ?? 0) + 3);
assert.equal(player(continued, guild).spice, initialGuild.spice - (cancel ? 3 : 2));
assert.equal(player(continued, guild).shipped, true);
assert.equal(player(continued, richese).forces['polar_sink:0'], 3);
assert.equal(continued.discard.filter(card => card.id === karama).length, cancel ? 1 : 0);
assert.throws(() => act(continued, atreides, action));
},
);
void test('all four bot profiles can answer the preserved Guild-rate response', () => {
const after = reveal();
for (const difficulty of DIFFICULTIES) {
const view = viewGame(after, atreides);
view.players.find(seat => seat.id === atreides)!.bot = difficulty;
const choice = botActions(view)[0]; assert.ok(choice, difficulty);
const next = act(reload(after), atreides, choice);
assert.equal(next.response?.kind === 'guildRate', false);
assert.equal(player(next, richese).forces['polar_sink:0'], 3);
assert.equal(player(next, guild).forces['arrakeen:10'], 3);
}
});
void test('Guild-rate reveal quote rejects a changed source, conflicting destination, stale event and other live work', () => {
const original = structuredClone(source().game);
assert.equal(richeseRevealInterruptSupported(original), true);
const variations: [string, (game: Game) => void][] = [
['destination at marker', game => { game.pendingShipment!.territory = 'polar_sink'; }],
['changed shipper', game => { game.pendingShipment!.player = richese; }],
['stale event', game => { game.response!.guildRateEvent = 'stale'; }],
['other pending child', game => { Object.assign(game, {pendingIxMove: {event:'other-child'}}); }],
['current Truthtrance history', game => { Object.assign(game, {truthHistory: [{turn:game.turn}]}); }],
];
for (const [name, mutate] of variations) {
const copy = structuredClone(original); mutate(copy);
const before = structuredClone(copy);
assert.equal(richeseRevealInterruptSupported(copy), false, name);
assert.deepEqual(copy, before, `${name}: a quote must not change game state`);
}
});