174 lines
9.7 KiB
TypeScript
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`);
|
|
}
|
|
});
|