134 lines
7.3 KiB
TypeScript
134 lines
7.3 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { viewGame, type Action, type Game } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { actFremenRevivalGrant as act, advanceFremenRevivalGrant as advance,
|
|
createFremenRevivalGrantFixture as fixture, fremenRevivalGrantInventory as inventory,
|
|
fremenRevivalGrantPlayer as player } from './fixture-fremen-revival-grant';
|
|
|
|
const pay = (target: string, amount: number): Action => ({type:'bribe', target, amount});
|
|
function reject(game: Game, actor: string, action: Action) {
|
|
const before = structuredClone(game);
|
|
assert.throws(() => act(game, actor, action));
|
|
assert.deepEqual(game, before, 'A rejected bribe leaves its pending parent unchanged.');
|
|
}
|
|
|
|
/** Real classic Basic setup, native first auction and an originally dealt
|
|
* Emperor Karama. The shared fixture selects unopened starting positions and
|
|
* uses actual setup actions; no response, wallet or auction is fabricated. */
|
|
function auctionSource() {
|
|
const f = fixture();
|
|
const game = advance(structuredClone(f.afterSetup), state => state.turn === 1 && state.phase === 3 &&
|
|
state.response?.kind === 'atreidesAuction' && !!state.auction && !state.richeseAuction);
|
|
assert.equal(game.response?.owner, f.ally);
|
|
assert.ok(player(game, f.observer).hand.some(card => card.id === f.karama));
|
|
return {f, game};
|
|
}
|
|
let auctionCache: ReturnType<typeof auctionSource> | undefined;
|
|
const auction = () => auctionCache ??= auctionSource();
|
|
|
|
/** Real original paid battle, casualties, later worm/Nexus and alliance lead
|
|
* to Fremen's actual revival grant, held open by Emperor's physical Karama. */
|
|
function grantSource(advanced = false) {
|
|
const f = fixture({advanced});
|
|
const game = act(f.game, f.fremen, f.grant);
|
|
assert.equal(game.response?.kind, 'fremenRevivalGrant');
|
|
assert.ok(player(game, f.observer).hand.some(card => card.id === f.karama));
|
|
return {f, game};
|
|
}
|
|
let grantCache: ReturnType<typeof grantSource> | undefined;
|
|
const grant = () => grantCache ??= grantSource();
|
|
|
|
void test('a Basic auction inspection response retains its original lot and passes during an out-of-turn bribe', () => {
|
|
const {f, game: source} = auction();
|
|
const before = structuredClone(source), response = structuredClone(before.response), lot = structuredClone(before.auction);
|
|
const oldPurse = player(before, f.fremen).spice, oldCash = player(before, f.observer).spice;
|
|
const oldBribes = player(before, f.observer).bribes, cards = inventory(before);
|
|
assert.ok(oldPurse >= 1);
|
|
assert.equal(viewGame(before, f.fremen).bribeOptions.blocked, null);
|
|
const paid = act(before, f.fremen, pay(f.observer, 1));
|
|
assert.deepEqual(paid.response, response); assert.deepEqual(paid.auction, lot);
|
|
assert.equal(player(paid, f.fremen).spice, oldPurse - 1);
|
|
assert.equal(player(paid, f.observer).spice, oldCash);
|
|
assert.equal(player(paid, f.observer).bribes, oldBribes + 1);
|
|
assert.deepEqual(inventory(paid), cards);
|
|
const control = act(before, f.observer, {type:'passResponse'});
|
|
const continued = act(paid, f.observer, {type:'passResponse'});
|
|
assert.notEqual(continued.response?.kind, 'atreidesAuction');
|
|
assert.deepEqual(continued.auction, control.auction);
|
|
assert.equal(player(continued, f.observer).bribes, oldBribes + 1);
|
|
assert.deepEqual(inventory(continued), cards);
|
|
});
|
|
|
|
void test('a Basic auction inspection can still be canceled by the original Karama after a bribe', () => {
|
|
const {f, game: source} = auction(), before = structuredClone(source);
|
|
const paid = act(before, f.fremen, pay(f.observer, 1));
|
|
const control = act(before, f.observer, {type:'card', card:f.karama, mode:'cancel'});
|
|
const canceled = act(paid, f.observer, {type:'card', card:f.karama, mode:'cancel'});
|
|
assert.deepEqual(canceled.auction, control.auction);
|
|
assert.equal(canceled.discard.filter(card => card.id === f.karama).length, 1);
|
|
assert.equal(player(canceled, f.observer).bribes, player(before, f.observer).bribes + 1);
|
|
});
|
|
|
|
void test('a Basic Fremen grant response keeps its exact source and grants the ally after the original pass', () => {
|
|
const {f, game: source} = grant(), before = structuredClone(source);
|
|
const response = structuredClone(before.response), oldPurse = player(before, f.observer).spice;
|
|
const oldCash = player(before, f.ally).spice, oldBribes = player(before, f.ally).bribes, cards = inventory(before);
|
|
assert.ok(oldPurse >= 1);
|
|
assert.equal(viewGame(before, f.observer).bribeOptions.blocked, null);
|
|
const paid = act(before, f.observer, pay(f.ally, 1));
|
|
assert.deepEqual(paid.response, response);
|
|
assert.equal(player(paid, f.observer).spice, oldPurse - 1);
|
|
assert.equal(player(paid, f.ally).spice, oldCash);
|
|
assert.equal(player(paid, f.ally).bribes, oldBribes + 1);
|
|
assert.ok(!paid.freeRevival.includes(f.ally));
|
|
const control = act(before, f.observer, {type:'passResponse'});
|
|
const continued = act(paid, f.observer, {type:'passResponse'});
|
|
assert.notEqual(continued.response?.kind, 'fremenRevivalGrant');
|
|
assert.deepEqual(continued.freeRevival, control.freeRevival);
|
|
assert.ok(continued.freeRevival.includes(f.ally));
|
|
assert.deepEqual(inventory(continued), cards);
|
|
});
|
|
|
|
void test('a Basic Fremen grant can still be canceled after the payment', () => {
|
|
const {f, game: source} = grant(), before = structuredClone(source);
|
|
const paid = act(before, f.observer, pay(f.ally, 1));
|
|
const control = act(before, f.observer, {type:'card', card:f.karama, mode:'cancel'});
|
|
const canceled = act(paid, f.observer, {type:'card', card:f.karama, mode:'cancel'});
|
|
assert.deepEqual(canceled.freeRevival, control.freeRevival);
|
|
assert.ok(!canceled.freeRevival.includes(f.ally));
|
|
assert.equal(canceled.discard.filter(card => card.id === f.karama).length, 1);
|
|
assert.equal(player(canceled, f.ally).bribes, player(before, f.ally).bribes + 1);
|
|
});
|
|
|
|
void test('invalid or allied bribes leave a pending Basic grant untouched', () => {
|
|
const {f, game: source} = grant(), game = structuredClone(source);
|
|
reject(game, f.fremen, pay(f.ally, 1));
|
|
reject(game, f.observer, pay(f.observer, 1));
|
|
reject(game, f.observer, pay(f.ally, 0));
|
|
reject(game, f.observer, pay(f.ally, player(game, f.observer).spice + 1));
|
|
});
|
|
|
|
void test('an Advanced grant remains outside the Basic interruption profile', () => {
|
|
const {f, game} = grantSource(true);
|
|
assert.ok(viewGame(game, f.observer).bribeOptions.blocked);
|
|
reject(game, f.observer, pay(f.ally, 1));
|
|
});
|
|
|
|
void test('all four minimal bot profiles can continue the preserved Basic grant response', () => {
|
|
const {f, game: source} = grant(), paid = act(source, f.observer, pay(f.ally, 1));
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const game = structuredClone(paid), view = viewGame(game, f.observer);
|
|
view.players.find(seat => seat.id === f.observer)!.bot = difficulty;
|
|
const action = botActions(view).find(choice => choice.type === 'passResponse' ||
|
|
choice.type === 'card' && choice.mode === 'cancel');
|
|
assert.ok(action, `${difficulty} needs an original legal response`);
|
|
const next = act(game, f.observer, action);
|
|
assert.ok(next.response?.kind !== 'fremenRevivalGrant' || next.response.passed.includes(f.observer) ||
|
|
next.pendingKull || next.pendingKarama || next.pendingTreacheryDiscard,
|
|
`${difficulty} must advance the original response`);
|
|
assert.equal(player(next, f.ally).bribes, player(source, f.ally).bribes + 1);
|
|
}
|
|
});
|