Files
Dune/tests/richese-reveal-ordinary-responses-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

178 lines
10 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 seat = (g: Game, id: string) => g.players.find(player => player.id === id)!;
const quiet = (g: Game) => !g.response && !g.decision && !g.phaseOpening && !g.truthtrance && !g.pendingTreacheryDiscard;
function next(g: Game) {
if (g.phase === 5 && g.active === 'emperor' && quiet(g))
return {actor: 'emperor', action: {type:'endMovement'} satisfies Action};
return nextTruthMovementNativeEarlyStep(g, {position:'first', method:'onceAround', direction:'clockwise', keepUnbid:false});
}
function reach(g: Game, predicate: (g: Game) => boolean): Game {
for (let count = 0; count < 1800; count++) {
if (predicate(g)) return g;
assert.notEqual(g.status, 'finished');
const step = next(g);
assert.ok(step, `No native step at ${g.turn}/${g.phase}/${g.decision?.kind}`);
g = act(g, step.actor, step.action);
}
throw Error('The native game did not reach its response.');
}
/** Original Basic setup, actual paid first-turn No-Field shipment, then the
* following Movement phase. Only unopened starting-card and Spice positions
* are controlled, preserving every original physical card. */
function source() {
let g = createGame('RICHESEORDINARY', newPlayer('richese', 'Richese', 'richese'), false, ['choam','ix']);
for (const id of ['choam','atreides','emperor'] as const) joinGame(g, newPlayer(id, id, id));
for (const p of g.players) g = act(g, p.id, {type:'ready'});
g = act(g, g.host, {type:'start'});
const ordinary = g.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0, 3);
const karama = g.deck.find(card => card.effect === 'karama');
assert.equal(ordinary.length, 3); assert.ok(karama);
for (const [position, card] of [...ordinary, karama].entries()) {
const from = g.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[g.deck[position], g.deck[from]] = [g.deck[from], g.deck[position]];
}
g = reach(g, state => state.status === 'playing');
assert.ok(seat(g, 'emperor').hand.some(card => card.id === karama.id));
for (const position of [0,1]) {
const from = g.spiceDeck.findIndex((card,index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position);
[g.spiceDeck[position],g.spiceDeck[from]] = [g.spiceDeck[from],g.spiceDeck[position]];
}
g = reach(g, state => state.turn === 1 && state.phase === 5 && state.active === 'richese' && quiet(state));
const token = seat(g,'richese').noField!.tokens.find(item => item.value === 3); assert.ok(token);
const event = seat(g,'richese').noFieldEvent; assert.ok(event);
g = act(g, 'richese', {type:'ship', noField:token.id, event, territory:'polar_sink', sector:0, allyPayment:0});
g = reach(g, quiet);
g = act(g, 'richese', {type:'endMovement'});
g = reach(g, state => state.turn === 2 && state.phase === 5 && state.response?.kind === 'atreidesSpice');
assert.equal(seat(g,'richese').noField?.deployed?.tokenId, token.id);
const revealEvent = seat(g,'richese').noFieldEvent; assert.ok(revealEvent);
return {game:g, token:token.id, event:revealEvent, karama:karama.id};
}
let cached: ReturnType<typeof source> | undefined;
const fixture = () => cached ??= source();
function reveal(g: Game) {
const {token,event} = fixture();
assert.equal(viewGame(g,'richese').richeseNoField?.canReveal, true);
return act(g,'richese',{type:'revealNoField',token,event});
}
function assertMaterialized(before: Game, after: Game) {
assert.equal(seat(after,'richese').noField?.deployed, null);
assert.equal(seat(after,'richese').reserves, seat(before,'richese').reserves - 3);
assert.equal(seat(after,'richese').forces['polar_sink:0'], (seat(before,'richese').forces['polar_sink:0'] ?? 0) + 3);
assert.deepEqual(inventory(after), inventory(before));
}
function finishCancellation(g: Game, continuation: 'karamaTerminalDiscard' | 'karamaEmperorShareDiscard') {
const kull = g.pendingKull;
assert.ok(kull);
assert.equal(kull.stage, 'offer');
assert.equal(kull.card, fixture().karama);
g = act(g, kull.player, {type:'kullDecision',event:kull.event,decline:true});
const original = g.pendingTreacheryDiscard;
assert.ok(original);
assert.equal(original.continuation.kind, continuation);
const event = original.batch.event;
assert.deepEqual(original.batch.entries.map(entry => entry.card.id), [fixture().karama]);
while (g.pendingTreacheryDiscard?.batch.event === event) {
const reaction = g.pendingTreacheryDiscard.reaction;
assert.equal(reaction?.stage, 'offer');
const actor = g.players.find(player => !reaction.passed.includes(player.id));
assert.ok(actor);
g = act(g, actor.id, {type:'semutaPass',event});
}
return g;
}
void test('voluntary reveal preserves the native Atreides Spice inspection and its existing passes', () => {
let before = structuredClone(fixture().game);
before = act(before,'richese',{type:'passResponse'});
assert.equal(before.response?.kind,'atreidesSpice');
const response = structuredClone(before.response), deck = structuredClone(before.spiceDeck);
const after = reveal(before);
assert.deepEqual(after.response,response); assert.deepEqual(after.spiceDeck,deck);
assertMaterialized(before,after);
const control = act(before,'emperor',{type:'passResponse'});
const continued = act(after,'emperor',{type:'passResponse'});
assert.equal(continued.response?.kind === 'atreidesSpice',false);
assert.equal(continued.spicePeekKnown,control.spicePeekKnown);
assert.deepEqual(continued.spiceDeck,control.spiceDeck);
assert.equal(seat(continued,'richese').forces['polar_sink:0'],seat(after,'richese').forces['polar_sink:0']);
});
void test('Karama still cancels the original Spice inspection after the marker reveal', () => {
const before = structuredClone(fixture().game), after = reveal(before);
const continued = finishCancellation(act(after,'emperor',{type:'card',card:fixture().karama,mode:'cancel'}),'karamaTerminalDiscard');
assert.equal(continued.response?.kind === 'atreidesSpice',false);
assert.equal(continued.spicePeekKnown,false);
assert.equal(continued.discard.filter(card => card.id === fixture().karama).length,1);
assert.deepEqual(continued.spiceDeck,before.spiceDeck);
assert.equal(seat(continued,'richese').forces['polar_sink:0'],seat(after,'richese').forces['polar_sink:0']);
});
function giftSource() {
let g = structuredClone(fixture().game);
g = reach(g,state => state.turn === 2 && state.phase === 5 && state.active === 'emperor' && quiet(state));
// The real gift declaration is used; only the pre-existing reciprocal
// alliance is staged so this fixture does not depend on a random Nexus.
seat(g,'emperor').ally = 'richese'; seat(g,'richese').ally = 'emperor';
// Transfer the original Karama to a separate responder with a physical
// card swap so the gift has a real counter opportunity.
const emperor = seat(g,'emperor'), atreides = seat(g,'atreides');
const index = emperor.hand.findIndex(card => card.id === fixture().karama);
assert.ok(index >= 0 && atreides.hand.length);
[emperor.hand[index],atreides.hand[0]] = [atreides.hand[0],emperor.hand[index]];
g = act(g,'emperor',{type:'emperorGift',amount:3});
assert.equal(g.response?.kind,'emperorGift');
return g;
}
void test('voluntary reveal preserves an ordinary allied Emperor gift and its once-only payment', () => {
let before = giftSource();
before = act(before,'richese',{type:'passResponse'});
const response = structuredClone(before.response), emperor = seat(before,'emperor').spice,
richese = seat(before,'richese').spice;
const after = reveal(before);
assert.deepEqual(after.response,response); assertMaterialized(before,after);
const control = act(before,'atreides',{type:'passResponse'});
const continued = act(after,'atreides',{type:'passResponse'});
assert.equal(continued.response?.kind === 'emperorGift',false);
assert.equal(seat(continued,'emperor').spice,emperor - 3);
assert.equal(seat(continued,'richese').spice,richese + 3);
assert.equal(seat(continued,'emperor').spice,seat(control,'emperor').spice);
assert.equal(seat(continued,'richese').spice,seat(control,'richese').spice);
assert.equal(seat(continued,'richese').forces['polar_sink:0'],seat(after,'richese').forces['polar_sink:0']);
});
void test('Karama still cancels the original allied gift after the marker reveal', () => {
const before = giftSource(), after = reveal(before);
const emperor = seat(before,'emperor').spice, richese = seat(before,'richese').spice;
const continued = finishCancellation(act(after,'atreides',{type:'card',card:fixture().karama,mode:'cancel'}),'karamaEmperorShareDiscard');
assert.equal(continued.response?.kind === 'emperorGift',false);
assert.equal(seat(continued,'emperor').spice,emperor);
assert.equal(seat(continued,'richese').spice,richese);
assert.equal(continued.discard.filter(card => card.id === fixture().karama).length,1);
assert.equal(seat(continued,'richese').forces['polar_sink:0'],seat(after,'richese').forces['polar_sink:0']);
});
void test('all bot profiles retain a legal response to either original parent after reveal', () => {
for (const before of [structuredClone(fixture().game),giftSource()]) {
const after = reveal(before);
for (const difficulty of DIFFICULTIES) {
const actor = before.response?.kind === 'emperorGift' ? 'atreides' : 'emperor';
const view = viewGame(after,actor);
view.players.find(p => p.id === actor)!.bot = difficulty;
const action = botActions(view).find(item => item.type === 'passResponse' || item.type === 'card' && item.mode === 'cancel');
assert.ok(action, `${difficulty} needs an original response action`);
assert.doesNotThrow(() => applyAction(after,actor,action));
}
}
});