178 lines
10 KiB
TypeScript
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));
|
|
}
|
|
}
|
|
});
|