518 lines
20 KiB
TypeScript
518 lines
20 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
RuleError,
|
|
type Game,
|
|
type Action,
|
|
} from '../game/engine';
|
|
import { baseDeck } from '../game/cards';
|
|
import { TERRITORIES, gameDistance, splitLocation } from '../game/board';
|
|
import { forceRevivalQuote, newRevivalRules } from '../game/revival';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
function fixture(advanced = false) {
|
|
const g = createGame('CWORTH22', newPlayer('c', 'CHOAM', 'choam'), advanced, [
|
|
'choam',
|
|
]);
|
|
g.players.push(
|
|
newPlayer('e', 'Emperor', 'emperor'),
|
|
newPlayer('b', 'Bene Gesserit', 'beneGesserit'),
|
|
);
|
|
g.status = 'playing';
|
|
g.phase = 4;
|
|
g.active = 'c';
|
|
g.order = ['c', 'e', 'b'];
|
|
g.storm = 18;
|
|
g.deck = baseDeck();
|
|
g.revivalRules = newRevivalRules();
|
|
for (const p of g.players) {
|
|
p.spice = 10;
|
|
p.tanks = 6;
|
|
p.reserves = 14;
|
|
p.traitorChoices = [];
|
|
}
|
|
hold(g, 'c', 'Kulon');
|
|
hold(g, 'c', 'La La La');
|
|
return g;
|
|
}
|
|
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
|
|
const send = (g: Game, id: string, a: Action) => applyAction(g, id, a);
|
|
function hold(g: Game, id: string, name: string) {
|
|
const i = g.deck.findIndex((c) => c.name === name);
|
|
assert.ok(i >= 0);
|
|
const c = g.deck.splice(i, 1)[0];
|
|
player(g, id).hand.push(c);
|
|
return c.id;
|
|
}
|
|
const named = (g: Game, name: string) =>
|
|
player(g, 'c').hand.find((c) => c.name === name)!.id;
|
|
const play = (g: Game, name: string, target?: string) =>
|
|
send(g, 'c', { type: 'card', mode: 'choam', card: named(g, name), target });
|
|
function contest(g: Game, id = 'e') {
|
|
hold(g, id, 'Karama');
|
|
return g;
|
|
}
|
|
function allow(state: Game) {
|
|
let g = state;
|
|
for (let n = 0; g.response && n < 30; n++)
|
|
g = send(
|
|
g,
|
|
g.players.find(
|
|
(p) =>
|
|
!viewGame(g, p.id).responseControls?.hasPassed &&
|
|
!!viewGame(g, p.id).responseControls?.cancelCards.length,
|
|
)!.id,
|
|
{
|
|
type: 'passResponse',
|
|
},
|
|
);
|
|
assert.equal(g.response, null);
|
|
return g;
|
|
}
|
|
function movement(ornithopters = false) {
|
|
const g = fixture();
|
|
g.phase = 5;
|
|
player(g, 'c').forces = { 'red_chasm:7': 3 };
|
|
player(g, 'c').reserves = 11;
|
|
if (ornithopters) {
|
|
player(g, 'c').forces['arrakeen:10'] = 1;
|
|
player(g, 'c').reserves--;
|
|
}
|
|
return g;
|
|
}
|
|
function destination(g: Game, range: number) {
|
|
const target = TERRITORIES.flatMap((t) =>
|
|
t.sectors.map((s) => `${t.id}:${s}`),
|
|
).find(
|
|
(key) =>
|
|
splitLocation(key).sector !== g.storm &&
|
|
gameDistance(
|
|
g,
|
|
'red_chasm:7',
|
|
key,
|
|
(k) => splitLocation(k).sector === g.storm,
|
|
) === range,
|
|
);
|
|
assert.ok(target, `range ${range}`);
|
|
return splitLocation(target);
|
|
}
|
|
const move = (g: Game, range: number) => {
|
|
const to = destination(g, range);
|
|
return send(g, 'c', {
|
|
type: 'move',
|
|
forces: { 'red_chasm:7': 2 },
|
|
territory: to.territory,
|
|
sector: to.sector,
|
|
});
|
|
};
|
|
void test('Kulon adds one range in basic and advanced play only after its Karama response', () => {
|
|
for (const advanced of [false, true]) {
|
|
let g = contest(movement());
|
|
g.advanced = advanced;
|
|
assert.throws(() => move(g, 2), /more than 1/);
|
|
const card = named(g, 'Kulon');
|
|
g = play(g, 'Kulon');
|
|
assert.equal(g.response?.kind, 'choamWorthless');
|
|
assert.ok(player(g, 'c').hand.some((c) => c.id === card));
|
|
g = allow(g);
|
|
assert.equal(g.choamMovement?.bonus, 1);
|
|
assert.ok(g.discard.some((c) => c.id === card));
|
|
assert.throws(() => move(g, 3), /more than 2/);
|
|
g = move(g, 2);
|
|
assert.equal(player(g, 'c').moved, 1);
|
|
assert.throws(() => move(g, 1), /movement is not available/);
|
|
}
|
|
});
|
|
void test('clean Kulon discard offers Semuta before the one earned movement bonus', () => {
|
|
const initial = movement();
|
|
initial.players[1] = newPlayer('e', 'Richese', 'richese');
|
|
initial.richeseCache = richeseCards();
|
|
initial.semutaPreview = true;
|
|
const index = initial.richeseCache.findIndex(card => card.id === SEMUTA_DRUG_ID);
|
|
assert.ok(index >= 0);
|
|
const semuta = initial.richeseCache.splice(index, 1)[0];
|
|
player(initial, 'e').hand.push(semuta);
|
|
contest(initial, 'b');
|
|
const card = named(initial, 'Kulon');
|
|
const physical = (state: Game) => [
|
|
...state.deck, ...state.discard, ...state.richeseCache!,
|
|
...state.players.flatMap(p => p.hand),
|
|
].map(c => c.id).sort();
|
|
const original = physical(initial);
|
|
const absent = structuredClone(initial);
|
|
player(absent, 'e').hand = [absent.richeseCache!.splice(0, 1)[0]];
|
|
absent.richeseCache!.push(semuta);
|
|
const offered = (state: Game) => allow(play(state, 'Kulon'));
|
|
const pending = offered(initial), neutral = offered(absent);
|
|
const event = pending.pendingTreacheryDiscard!.batch.event;
|
|
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'choamKulonDiscard');
|
|
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
|
assert.equal(neutral.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
|
assert.equal(pending.choamMovement, undefined);
|
|
assert.equal(pending.discard.filter(c => c.id === card).length, 1);
|
|
assert.deepEqual(viewGame(pending, 'c'), viewGame(neutral, 'c'));
|
|
assert.deepEqual(viewGame(pending, 'b'), viewGame(neutral, 'b'));
|
|
assert.equal(viewGame(pending, 'e').semutaReaction?.canCommit, true);
|
|
assert.equal(botActions(viewGame(pending, 'e'))[0]?.type, 'semutaCommit');
|
|
assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(pending))), pending);
|
|
assert.throws(() => move(pending, 2));
|
|
for (const change of [
|
|
(state: Game) => {
|
|
const c = state.pendingTreacheryDiscard!.continuation;
|
|
if (c.kind === 'choamKulonDiscard') c.before = 3;
|
|
},
|
|
(state: Game) => { state.choamMovement = { turn: state.turn, bonus: 1 }; },
|
|
(state: Game) => { state.pendingTreacheryDiscard!.batch.entries[0].card.name = 'Forgery'; },
|
|
]) {
|
|
const corrupt: Game = JSON.parse(JSON.stringify(pending));
|
|
change(corrupt);
|
|
const before = JSON.stringify(corrupt);
|
|
assert.throws(() => applyAction(corrupt, 'e', { type: 'semutaCommit', event }));
|
|
assert.equal(JSON.stringify(corrupt), before);
|
|
}
|
|
let declined: Game = JSON.parse(JSON.stringify(pending));
|
|
for (const id of ['c', 'b', 'e'])
|
|
declined = applyAction(declined, id, { type: 'semutaPass', event });
|
|
const claimed = applyAction(JSON.parse(JSON.stringify(pending)), 'e',
|
|
{ type: 'semutaCommit', event });
|
|
for (const state of [declined, claimed]) {
|
|
assert.equal(state.pendingTreacheryDiscard, null);
|
|
assert.deepEqual(state.choamMovement, { turn: state.turn, bonus: 1 });
|
|
assert.deepEqual(physical(state), original);
|
|
assert.equal(player(move(state, 2), 'c').moved, 1);
|
|
assert.throws(() => applyAction(state, 'e', { type: 'semutaCommit', event }));
|
|
}
|
|
assert.equal(declined.discard.filter(c => c.id === card).length, 1);
|
|
assert.equal(claimed.players[1].hand.filter(c => c.id === card).length, 1);
|
|
assert.equal(claimed.discard.filter(c => c.id === semuta.id).length, 1);
|
|
});
|
|
|
|
void test('Kulon adds to ornithopters and keeps the usual storm and alliance entry restrictions', () => {
|
|
let g = movement(true);
|
|
assert.throws(() => move(g, 4), /more than 3/);
|
|
g = allow(play(g, 'Kulon'));
|
|
assert.equal(player(move(g, 4), 'c').moved, 1);
|
|
const storm = TERRITORIES.find(
|
|
(t) => t.type === 'sand' && t.sectors.includes(18),
|
|
)!;
|
|
assert.throws(
|
|
() =>
|
|
send(g, 'c', {
|
|
type: 'move',
|
|
forces: { 'red_chasm:7': 1 },
|
|
territory: storm.id,
|
|
sector: 18,
|
|
}),
|
|
RuleError,
|
|
);
|
|
const to = destination(g, 2);
|
|
player(g, 'c').ally = 'e';
|
|
player(g, 'e').ally = 'c';
|
|
player(g, 'e').forces[`${to.territory}:${to.sector}`] = 1;
|
|
assert.throws(() => move(g, 2), RuleError);
|
|
});
|
|
void test('Kulon applies to available Hajr movements without granting a third movement and expires next turn', () => {
|
|
let g = movement();
|
|
const hajr = hold(g, 'c', 'Hajr');
|
|
g = send(g, 'c', { type: 'card', card: hajr });
|
|
g = allow(play(g, 'Kulon'));
|
|
g = move(g, 2);
|
|
g = send(g, 'c', {
|
|
type: 'move',
|
|
forces: { 'red_chasm:7': 1 },
|
|
...(() => {
|
|
const to = destination(g, 2);
|
|
return { territory: to.territory, sector: to.sector };
|
|
})(),
|
|
});
|
|
assert.equal(player(g, 'c').moved, 2);
|
|
assert.equal(viewGame(g, 'c').players[0].movesAllowed, 2);
|
|
g.turn++;
|
|
player(g, 'c').moved = 0;
|
|
player(g, 'c').forces = { 'red_chasm:7': 3 };
|
|
assert.equal(viewGame(g, 'c').choamMovementBonus, 0);
|
|
assert.throws(() => move(g, 2), /more than 1/);
|
|
});
|
|
void test('Kulon rejects wrong owner, phase, active seat, spent movement and blocked windows atomically', () => {
|
|
const initial = movement();
|
|
const snapshot = structuredClone(initial);
|
|
assert.throws(
|
|
() =>
|
|
send(initial, 'e', {
|
|
type: 'card',
|
|
mode: 'choam',
|
|
card: named(initial, 'Kulon'),
|
|
}),
|
|
RuleError,
|
|
);
|
|
assert.deepEqual(initial, snapshot);
|
|
for (const change of [
|
|
(g: Game) => (g.phase = 4),
|
|
(g: Game) => (g.active = 'e'),
|
|
(g: Game) => (player(g, 'c').moved = 1),
|
|
(g: Game) =>
|
|
(g.response = {
|
|
kind: 'emperorIncome',
|
|
owner: 'e',
|
|
amount: 1,
|
|
passed: [],
|
|
}),
|
|
]) {
|
|
const g = structuredClone(initial);
|
|
change(g);
|
|
assert.throws(() => play(g, 'Kulon'), RuleError);
|
|
}
|
|
});
|
|
void test('Karama preserves a Worthless card but prevents retrying its effect during that phase', () => {
|
|
const initial = movement();
|
|
const cancel = hold(initial, 'e', 'Karama');
|
|
const card = named(initial, 'Kulon');
|
|
let g = play(initial, 'Kulon');
|
|
g = send(g, 'e', { type: 'card', card: cancel, mode: 'cancel' });
|
|
assert.equal(g.choamMovement, undefined);
|
|
assert.ok(player(g, 'c').hand.some((c) => c.id === card));
|
|
assert.throws(() => play(g, 'Kulon'), RuleError);
|
|
assert.ok(!viewGame(g, 'c').choamWorthless!.cards.some((c) => c.id === card));
|
|
g.turn++;
|
|
g = allow(play(g, 'Kulon'));
|
|
assert.equal(g.choamMovement?.bonus, 1);
|
|
});
|
|
void test('La La La proactively removes only remaining ordinary free revivals; paid revival remains legal', () => {
|
|
let g = fixture();
|
|
g = send(g, 'e', { type: 'revive', amount: 1 });
|
|
g = send(g, 'c', { type: 'decision', decline: true });
|
|
assert.equal(player(g, 'e').revived, 1);
|
|
g = allow(play(g, 'La La La', 'e'));
|
|
assert.equal(viewGame(g, 'e').revival.freeRemaining, 0);
|
|
assert.equal(forceRevivalQuote(g, player(g, 'e'), 2).cost, 4);
|
|
g = send(g, 'e', { type: 'revive', amount: 2 });
|
|
assert.equal(player(g, 'e').spice, 6);
|
|
assert.equal(player(g, 'e').revived, 3);
|
|
});
|
|
void test('proactive La La La discard offers Semuta before one free-revival block', () => {
|
|
const initial = fixture();
|
|
initial.players[1] = newPlayer('e', 'Richese', 'richese');
|
|
player(initial, 'e').spice = 10;
|
|
player(initial, 'e').tanks = 6;
|
|
player(initial, 'e').reserves = 14;
|
|
initial.richeseCache = richeseCards();
|
|
initial.semutaPreview = true;
|
|
const index = initial.richeseCache.findIndex(card => card.id === SEMUTA_DRUG_ID);
|
|
assert.ok(index >= 0);
|
|
const semuta = initial.richeseCache.splice(index, 1)[0];
|
|
player(initial, 'e').hand.push(semuta);
|
|
contest(initial, 'b');
|
|
const card = named(initial, 'La La La');
|
|
const physical = (state: Game) => [
|
|
...state.deck, ...state.discard, ...state.richeseCache!,
|
|
...state.players.flatMap(p => p.hand),
|
|
].map(c => c.id).sort();
|
|
const original = physical(initial);
|
|
const absent = structuredClone(initial);
|
|
player(absent, 'e').hand = [absent.richeseCache!.splice(0, 1)[0]];
|
|
absent.richeseCache!.push(semuta);
|
|
const offered = (state: Game) => allow(play(state, 'La La La', 'e'));
|
|
const pending = offered(initial), neutral = offered(absent);
|
|
const event = pending.pendingTreacheryDiscard!.batch.event;
|
|
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'choamLaLaLaDiscard');
|
|
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
|
assert.equal(neutral.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
|
assert.ok(!pending.revivalRules?.freeBlocked?.includes('e'));
|
|
assert.equal(pending.discard.filter(c => c.id === card).length, 1);
|
|
assert.deepEqual(viewGame(pending, 'c'), viewGame(neutral, 'c'));
|
|
assert.deepEqual(viewGame(pending, 'b'), viewGame(neutral, 'b'));
|
|
assert.equal(viewGame(pending, 'e').semutaReaction?.canCommit, true);
|
|
assert.equal(botActions(viewGame(pending, 'e'))[0]?.type, 'semutaCommit');
|
|
assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(pending))), pending);
|
|
for (const change of [
|
|
(state: Game) => {
|
|
const c = state.pendingTreacheryDiscard!.continuation;
|
|
if (c.kind === 'choamLaLaLaDiscard') c.target = 'b';
|
|
},
|
|
(state: Game) => { state.revivalRules!.freeBlocked = ['e']; },
|
|
(state: Game) => { state.pendingTreacheryDiscard!.batch.entries[0].card.name = 'Forgery'; },
|
|
]) {
|
|
const corrupt: Game = JSON.parse(JSON.stringify(pending));
|
|
change(corrupt);
|
|
const before = JSON.stringify(corrupt);
|
|
assert.throws(() => applyAction(corrupt, 'e', { type: 'semutaCommit', event }));
|
|
assert.equal(JSON.stringify(corrupt), before);
|
|
}
|
|
let declined: Game = JSON.parse(JSON.stringify(pending));
|
|
for (const id of ['c', 'b', 'e'])
|
|
declined = applyAction(declined, id, { type: 'semutaPass', event });
|
|
const claimed = applyAction(JSON.parse(JSON.stringify(pending)), 'e',
|
|
{ type: 'semutaCommit', event });
|
|
for (const state of [declined, claimed]) {
|
|
assert.equal(state.pendingTreacheryDiscard, null);
|
|
assert.deepEqual(state.revivalRules?.freeBlocked, ['e']);
|
|
assert.equal(viewGame(state, 'e').revival.freeRemaining, 0);
|
|
assert.equal(forceRevivalQuote(state, player(state, 'e'), 1).cost, 2);
|
|
assert.deepEqual(physical(state), original);
|
|
assert.throws(() => applyAction(state, 'e', { type: 'semutaCommit', event }));
|
|
}
|
|
assert.equal(declined.discard.filter(c => c.id === card).length, 1);
|
|
assert.equal(claimed.players[1].hand.filter(c => c.id === card).length, 1);
|
|
assert.equal(claimed.discard.filter(c => c.id === semuta.id).length, 1);
|
|
});
|
|
|
|
void test('every CHOAM table offers the same public free-revival response regardless of its private hand', () => {
|
|
const a = fixture(),
|
|
b = structuredClone(a);
|
|
player(b, 'c').hand = [];
|
|
const ga = send(a, 'e', { type: 'revive', amount: 2 }),
|
|
gb = send(b, 'e', { type: 'revive', amount: 2 });
|
|
assert.equal(ga.decision?.kind, 'choamFreeRevival');
|
|
assert.deepEqual(viewGame(ga, 'e').decision, viewGame(gb, 'e').decision);
|
|
assert.equal(player(ga, 'e').tanks, 6);
|
|
assert.equal(player(ga, 'e').spice, 10);
|
|
assert.throws(
|
|
() => send(ga, 'e', { type: 'decision', decline: true }),
|
|
RuleError,
|
|
);
|
|
});
|
|
void test('reactive La La La stops an entire mixed free/paid request without charging or consuming quota', () => {
|
|
let g = send(fixture(), 'e', { type: 'revive', amount: 3 });
|
|
g = play(g, 'La La La');
|
|
assert.equal(g.response, null); // No eligible cancel card: the denial has settled.
|
|
g = allow(g);
|
|
assert.equal(g.pendingRevival, null);
|
|
assert.equal(player(g, 'e').spice, 10);
|
|
assert.equal(player(g, 'e').tanks, 6);
|
|
assert.equal(player(g, 'e').revived, 0);
|
|
g = send(g, 'e', { type: 'revive', amount: 3 });
|
|
assert.equal(player(g, 'e').spice, 4);
|
|
assert.equal(player(g, 'e').revived, 3);
|
|
});
|
|
void test('canceling reactive La La La resumes the original quote exactly once and keeps the card', () => {
|
|
const initial = fixture();
|
|
const card = hold(initial, 'e', 'Karama');
|
|
let g = send(initial, 'e', { type: 'revive', amount: 3 });
|
|
g = play(g, 'La La La');
|
|
g = send(g, 'e', { type: 'card', card, mode: 'cancel' });
|
|
assert.equal(player(g, 'e').spice, 6);
|
|
assert.equal(player(g, 'e').revived, 3);
|
|
assert.equal(g.pendingRevival, null);
|
|
assert.equal(viewGame(g, 'e').revival.freeBlocked, false);
|
|
assert.ok(player(g, 'c').hand.some((c) => c.name === 'La La La'));
|
|
});
|
|
void test('a cash-in consuming the pending Worthless card aborts its effect and resumes the original revival', () => {
|
|
const initial = fixture(true);
|
|
contest(initial);
|
|
const karama = hold(initial, 'c', 'Karama');
|
|
let g = send(initial, 'e', { type: 'revive', amount: 2 });
|
|
const card = named(g, 'La La La');
|
|
g = play(g, 'La La La');
|
|
g = send(g, 'c', {
|
|
type: 'card',
|
|
mode: 'special',
|
|
card: karama,
|
|
cards: [card],
|
|
});
|
|
g = allow(g);
|
|
assert.equal(player(g, 'e').revived, 2);
|
|
assert.equal(player(g, 'e').spice, 8);
|
|
assert.equal(viewGame(g, 'e').revival.freeBlocked, false);
|
|
assert.equal(player(g, 'c').spice, 13);
|
|
});
|
|
void test('La La La denial overrides a later Fremen free-revival grant but does not block Ghola', () => {
|
|
const initial = fixture();
|
|
initial.players.push(newPlayer('f', 'Fremen', 'fremen'));
|
|
player(initial, 'f').ally = 'e';
|
|
player(initial, 'e').ally = 'f';
|
|
const ghola = hold(initial, 'e', 'Tleilaxu Ghola');
|
|
let g = allow(play(initial, 'La La La', 'e'));
|
|
g = send(g, 'f', { type: 'grantRevival' });
|
|
assert.equal(forceRevivalQuote(g, player(g, 'e'), 3).cost, 6);
|
|
g = send(g, 'e', { type: 'card', card: ghola, amount: 3 });
|
|
assert.equal(player(g, 'e').spice, 10);
|
|
assert.equal(player(g, 'e').tanks, 3);
|
|
assert.equal(player(g, 'e').revived, 0);
|
|
});
|
|
void test('the free-revival reaction precedes Tleilaxu special prevention and cancellation restores that continuation', () => {
|
|
const initial = fixture(true);
|
|
initial.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
|
|
const karama = hold(initial, 'e', 'Karama');
|
|
let g = send(initial, 'e', { type: 'revive', amount: 2 });
|
|
assert.equal(g.decision?.kind, 'choamFreeRevival');
|
|
g = play(g, 'La La La');
|
|
g = send(g, 'e', { type: 'card', card: karama, mode: 'cancel' });
|
|
assert.equal(g.decision?.kind, 'revivalStop');
|
|
assert.equal(player(g, 'e').revived, 0);
|
|
g = send(g, 't', { type: 'decision', decline: true });
|
|
g = allow(g);
|
|
assert.equal(player(g, 'e').revived, 2);
|
|
assert.equal(player(g, 'e').spice, 8);
|
|
});
|
|
void test('free denial resets on entering a later Revival phase', () => {
|
|
let g = allow(play(fixture(), 'La La La', 'e'));
|
|
g.turn++;
|
|
g.phase = 2;
|
|
g.expansions = [];
|
|
g.deck = [];
|
|
g.discard = [];
|
|
for (const p of g.players) {
|
|
p.hand = [];
|
|
p.spice = 0;
|
|
}
|
|
for (const p of g.players) g = send(g, p.id, { type: 'ready' });
|
|
while (g.decision?.kind === 'choamMarket')
|
|
g = send(g, 'c', { type: 'decision', done: true });
|
|
assert.equal(g.phase, 4);
|
|
assert.equal(viewGame(g, 'e').revival.freeBlocked, false);
|
|
assert.equal(forceRevivalQuote(g, player(g, 'e'), 1).cost, 0);
|
|
});
|
|
void test('pending Worthless effects and hidden card choices survive JSON reconnects', () => {
|
|
for (const g of [
|
|
play(
|
|
send(contest(fixture()), 'e', { type: 'revive', amount: 2 }),
|
|
'La La La',
|
|
),
|
|
play(contest(movement()), 'Kulon'),
|
|
]) {
|
|
assert.equal(g.response?.kind, 'choamWorthless');
|
|
const copy = JSON.parse(JSON.stringify(g));
|
|
assert.deepEqual(viewGame(copy, 'c'), viewGame(g, 'c'));
|
|
assert.equal(viewGame(g, 'e').choamWorthless, null);
|
|
assert.deepEqual(allow(copy), allow(g));
|
|
}
|
|
});
|
|
void test('AI policies use Kulon, choose whether to deny free revival and budget correctly after denial', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
let g = movement();
|
|
player(g, 'c').bot = difficulty;
|
|
assert.equal(botActions(viewGame(g, 'c'))[0].mode, 'choam');
|
|
g = fixture();
|
|
player(g, 'c').bot = difficulty;
|
|
g = send(g, 'e', { type: 'revive', amount: 2 });
|
|
const action = botActions(viewGame(g, 'c'))[0];
|
|
assert.equal(action.type, difficulty === 'Easy' ? 'decision' : 'card');
|
|
g = send(g, 'c', action);
|
|
g = allow(g);
|
|
if (difficulty !== 'Easy') {
|
|
player(g, 'e').bot = difficulty;
|
|
player(g, 'e').spice = 0;
|
|
assert.ok(!botActions(viewGame(g, 'e')).some((a) => a.type === 'revive'));
|
|
}
|
|
}
|
|
});
|
|
void test('unsupported Worthless powers and forged targets do not consume cards or change state', () => {
|
|
const g = fixture();
|
|
const baliset = hold(g, 'c', 'Baliset');
|
|
const before = structuredClone(g);
|
|
assert.throws(
|
|
() =>
|
|
send(g, 'c', { type: 'card', mode: 'choam', card: baliset, target: 'e' }),
|
|
RuleError,
|
|
);
|
|
assert.throws(() => play(g, 'La La La', 'missing'), RuleError);
|
|
assert.deepEqual(g, before);
|
|
});
|