460 lines
17 KiB
TypeScript
460 lines
17 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
viewGame,
|
|
RuleError,
|
|
type Game,
|
|
type Plan,
|
|
} from '../game/engine';
|
|
import { treacheryDeck, leaders } from '../game/cards';
|
|
import type { PlanClaim } from '../game/battle-promises';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
|
|
function fixture(advanced = false) {
|
|
const g = createGame(
|
|
'PREPTRUTH',
|
|
newPlayer('a', 'Atreides', 'atreides'),
|
|
advanced,
|
|
);
|
|
g.players.push(
|
|
newPlayer('e', 'Emperor', 'emperor'),
|
|
newPlayer('b', 'Sisterhood', 'beneGesserit'),
|
|
);
|
|
g.status = 'playing';
|
|
g.phase = 6;
|
|
g.turn = 2;
|
|
g.storm = 18;
|
|
g.active = 'a';
|
|
g.order = ['a', 'e', 'b'];
|
|
g.deck = treacheryDeck(['ix']);
|
|
for (const p of g.players) {
|
|
p.hand = [];
|
|
p.spice = 5;
|
|
p.traitors = [];
|
|
p.traitorChoices = [];
|
|
}
|
|
for (const p of g.players.slice(0, 2)) {
|
|
p.forces = { 'arrakeen:10': 4 };
|
|
p.reserves = 16;
|
|
}
|
|
g.battle = {
|
|
territory: 'arrakeen',
|
|
attacker: 'a',
|
|
defender: 'e',
|
|
prepared: true,
|
|
plans: {},
|
|
revealed: false,
|
|
traitorCalls: {},
|
|
};
|
|
hold(g, 'a', 'Truthtrance');
|
|
hold(g, 'a', 'Truthtrance');
|
|
hold(g, 'e', 'Tleilaxu Ghola');
|
|
g.players[1].leaders[0].dead = true;
|
|
g.players[1].leaders[0].deaths = 1;
|
|
return g;
|
|
}
|
|
function hold(g: Game, player: string, name: string) {
|
|
const index = g.deck.findIndex((c) => c.name === name);
|
|
assert.ok(index >= 0, name);
|
|
const card = g.deck.splice(index, 1)[0];
|
|
g.players.find((p) => p.id === player)!.hand.push(card);
|
|
return card.id;
|
|
}
|
|
function ask(initial: Game, claim: PlanClaim, target = 'e', asker = 'a') {
|
|
let g = applyAction(initial, asker, {
|
|
type: 'card',
|
|
card: initial.players
|
|
.find((p) => p.id === asker)!
|
|
.hand.find((c) => c.effect === 'truthtrance')!.id,
|
|
});
|
|
while (g.truthtrance!.stage === 'priority')
|
|
g = applyAction(
|
|
g,
|
|
g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id,
|
|
{ type: 'truthPass' },
|
|
);
|
|
return applyAction(g, asker, {
|
|
type: 'truthAsk',
|
|
question: { kind: 'battlePlan', target, claim },
|
|
});
|
|
}
|
|
function promise(g: Game, claim: PlanClaim, target = 'e', asker = 'a') {
|
|
g = ask(g, claim, target, asker);
|
|
return applyAction(g, target, { type: 'truthAnswer', answer: 'yes' });
|
|
}
|
|
function allow(state: Game) {
|
|
let g = state;
|
|
while (g.response)
|
|
g = applyAction(
|
|
g,
|
|
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
|
|
{ type: 'passResponse' },
|
|
);
|
|
return g;
|
|
}
|
|
function seal(g: Game, plan: Plan, id = 'e') {
|
|
return applyAction(g, id, { type: 'battlePlan', ...plan });
|
|
}
|
|
|
|
void test('Advanced leaderless announcement is public before reveal without exposing sealed choices', () => {
|
|
let game = fixture(true);
|
|
for (const leader of game.players[0].leaders) leader.usedAt = 'carthag';
|
|
game = seal(game, { dial: 1, support: 0, leader: null, weapon: null, defense: null }, 'a');
|
|
assert.equal(game.battle!.revealed, false);
|
|
for (const observer of ['e', 'b']) {
|
|
const view = viewGame(game, observer);
|
|
assert.deepEqual(view.battle!.leaderless, ['a']);
|
|
assert.deepEqual(view.battle!.plans, {});
|
|
assert.deepEqual(view.battle!.cards, []);
|
|
}
|
|
assert.equal(viewGame(game, 'a').battle!.plans.a.dial, 1);
|
|
const saved = JSON.stringify(game);
|
|
game = JSON.parse(saved);
|
|
assert.deepEqual(viewGame(game, 'b').battle!.leaderless, ['a']);
|
|
assert.equal(JSON.stringify(game), saved);
|
|
assert.throws(() => seal(game, { dial: 1, support: 0, leader: null, weapon: null, defense: null }, 'a'), RuleError);
|
|
assert.equal(JSON.stringify(game), saved);
|
|
game = seal(game, { dial: 1, support: 0, leader: game.players[1].leaders[1].id, weapon: null, defense: null });
|
|
assert.equal(game.battle!.revealed, true);
|
|
assert.deepEqual(viewGame(game, 'b').battle!.leaderless, ['a']);
|
|
assert.equal(viewGame(game, 'b').battle!.plans.a.dial, 1);
|
|
});
|
|
const leaderClaim: PlanClaim = { kind: 'leader', leader: 'emperor-0' };
|
|
const supportClaim: PlanClaim = { kind: 'support', compare: 'gte', value: 1 };
|
|
const liveCards = (g: Game) =>
|
|
[...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
|
|
.map((c) => c.id)
|
|
.sort();
|
|
function tleilaxu() {
|
|
const g = fixture(true),
|
|
p = g.players[1];
|
|
p.faction = 'tleilaxu';
|
|
p.leaders = leaders('tleilaxu');
|
|
p.spice = 0;
|
|
p.tanks = 1;
|
|
p.reserves = 15;
|
|
return g;
|
|
}
|
|
void test('Ghola makes a dead-leader promise reachable without consuming or revealing the preparation card', () => {
|
|
const initial = fixture();
|
|
const before = JSON.stringify(initial);
|
|
const pending = ask(initial, leaderClaim);
|
|
assert.deepEqual(viewGame(pending, 'e').truthBattleAnswers, ['yes', 'no']);
|
|
const g = applyAction(pending, 'e', { type: 'truthAnswer', answer: 'yes' });
|
|
assert.equal(g.battle!.truthPromises![0].released, undefined);
|
|
const own = viewGame(g, 'e').battle!;
|
|
assert.equal(own.compliantPlan, null);
|
|
assert.equal(own.compliantPreparation!.actions.length, 1);
|
|
assert.equal(own.compliantPreparation!.actions[0].leader, 'emperor-0');
|
|
for (const id of ['a', 'b']) {
|
|
assert.equal(viewGame(g, id).battle!.compliantPreparation, null);
|
|
assert.equal(viewGame(g, id).players[1].hand, undefined);
|
|
}
|
|
assert.equal(g.players[1].leaders[0].dead, true);
|
|
assert.ok(g.players[1].hand.some((c) => c.effect === 'ghola'));
|
|
assert.equal(JSON.stringify(initial), before);
|
|
assert.throws(
|
|
() => seal(g, own.compliantPreparation!.plan),
|
|
/leader is not available/,
|
|
);
|
|
const revived = applyAction(
|
|
JSON.parse(JSON.stringify(g)),
|
|
'e',
|
|
own.compliantPreparation!.actions[0],
|
|
);
|
|
assert.equal(revived.players[1].leaders[0].dead, false);
|
|
assert.equal(revived.players[1].leaderRevived, false);
|
|
assert.equal(revived.players[1].spice, 5);
|
|
assert.equal(viewGame(revived, 'e').battle!.compliantPreparation, null);
|
|
const done = seal(revived, viewGame(revived, 'e').battle!.compliantPlan!);
|
|
assert.equal(done.battle!.plans.e.leader, 'emperor-0');
|
|
assert.deepEqual(liveCards(done), liveCards(initial));
|
|
});
|
|
void test('prescience can commit a Ghola-reachable leader both with and without an earlier Truthtrance', () => {
|
|
for (const truth of [false, true]) {
|
|
let g: Game = truth ? promise(fixture(), leaderClaim) : fixture();
|
|
g.battle!.preparation = {
|
|
kind: 'prescience',
|
|
owner: 'a',
|
|
beneficiary: 'a',
|
|
};
|
|
g = allow(applyAction(g, 'a', { type: 'prescience', field: 'leader' }));
|
|
g = applyAction(g, 'e', { type: 'prescienceAnswer', value: 'emperor-0' });
|
|
assert.equal(g.battle!.prescience!.value, 'emperor-0');
|
|
const preparation = viewGame(g, 'e').battle!.compliantPreparation!;
|
|
assert.equal(preparation.actions[0].leader, 'emperor-0');
|
|
assert.equal(viewGame(g, 'a').battle!.compliantPreparation, null);
|
|
g = applyAction(g, 'e', preparation.actions[0]);
|
|
g = seal(g, viewGame(g, 'e').battle!.compliantPlan!);
|
|
assert.equal(g.battle!.plans.e.leader, 'emperor-0');
|
|
}
|
|
});
|
|
void test('one physical Ghola cannot revive both a dead Kwisatz and a needed leader', () => {
|
|
const initial = fixture(true);
|
|
const a = initial.players[0],
|
|
e = initial.players[1];
|
|
e.hand = e.hand.filter((c) => {
|
|
if (c.effect === 'ghola') {
|
|
a.hand.push(c);
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
e.hand.push(
|
|
a.hand.splice(
|
|
a.hand.findIndex((c) => c.effect === 'truthtrance'),
|
|
1,
|
|
)[0],
|
|
);
|
|
a.leaders.forEach((l) => {
|
|
l.dead = true;
|
|
l.deaths = 1;
|
|
});
|
|
a.battleLosses = 7;
|
|
a.kwisatz = { dead: true };
|
|
const both: PlanClaim = {
|
|
kind: 'and',
|
|
terms: [
|
|
{ kind: 'leader', leader: 'atreides-0' },
|
|
{ kind: 'kwisatz', use: true },
|
|
],
|
|
};
|
|
assert.deepEqual(
|
|
viewGame(ask(initial, both, 'a', 'e'), 'a').truthBattleAnswers,
|
|
['no'],
|
|
);
|
|
assert.deepEqual(
|
|
viewGame(ask(initial, { kind: 'kwisatz', use: true }, 'a', 'e'), 'a')
|
|
.truthBattleAnswers,
|
|
['no'],
|
|
);
|
|
const hero = initial.deck.find((c) => c.kind === 'hero')!;
|
|
hold(initial, 'a', hero.name);
|
|
let g = promise(initial, { kind: 'kwisatz', use: true }, 'a', 'e');
|
|
const preparation = viewGame(g, 'a').battle!.compliantPreparation!;
|
|
assert.equal(preparation.actions[0].leader, 'kwisatz');
|
|
g = applyAction(g, 'a', preparation.actions[0]);
|
|
const plan = viewGame(g, 'a').battle!.compliantPlan!;
|
|
assert.equal(plan.leader, hero.id);
|
|
assert.equal(plan.kwisatz, true);
|
|
assert.ok(seal(g, plan, 'a').battle!.plans.a.kwisatz);
|
|
});
|
|
void test('Ghola reachability respects leader control, captured leaders and actual card possession', () => {
|
|
for (const mode of ['no-card', 'captured', 'foreign'] as const) {
|
|
const g = fixture();
|
|
if (mode === 'no-card') g.discard.push(g.players[1].hand.pop()!);
|
|
if (mode === 'captured') g.players[1].leaders[0].capturedBy = 'a';
|
|
if (mode === 'foreign') g.players[1].leaders[0].gholaBy = 'a';
|
|
assert.deepEqual(
|
|
viewGame(ask(g, leaderClaim), 'e').truthBattleAnswers,
|
|
['no'],
|
|
mode,
|
|
);
|
|
}
|
|
const initial = fixture(true);
|
|
const p = initial.players[1];
|
|
p.faction = 'tleilaxu';
|
|
p.leaders = leaders('tleilaxu');
|
|
initial.players[0].leaders[0].dead = true;
|
|
initial.players[0].leaders[0].gholaBy = 'e';
|
|
const g = promise(initial, { kind: 'leader', leader: 'atreides-0' });
|
|
const action = viewGame(g, 'e').battle!.compliantPreparation!.actions[0];
|
|
assert.equal(action.leader, 'atreides-0');
|
|
const revived = allow(applyAction(g, 'e', action));
|
|
assert.equal(revived.players[0].leaders[0].dead, false);
|
|
assert.equal(revived.players[0].leaders[0].gholaBy, 'e');
|
|
});
|
|
void test('a promised Ghola cannot be diverted to a revival that makes the answer impossible', () => {
|
|
const initial = fixture();
|
|
initial.players[1].leaders[1].dead = true;
|
|
initial.players[1].tanks = 1;
|
|
initial.players[1].reserves--;
|
|
const g = promise(initial, leaderClaim);
|
|
const card = g.players[1].hand.find((c) => c.effect === 'ghola')!.id;
|
|
const before = JSON.stringify(g);
|
|
for (const extra of [{ leader: 'emperor-1' }, { amount: 1 }])
|
|
assert.throws(
|
|
() => applyAction(g, 'e', { type: 'card', card, ...extra }),
|
|
/voluntarily/,
|
|
);
|
|
assert.equal(JSON.stringify(g), before);
|
|
// An OR promise keeps its real alternatives; the engine does not force its first witness.
|
|
const alternate = promise(initial, {
|
|
kind: 'or',
|
|
terms: [leaderClaim, { kind: 'leader', leader: 'emperor-1' }],
|
|
});
|
|
const revived = applyAction(alternate, 'e', {
|
|
type: 'card',
|
|
card,
|
|
leader: 'emperor-1',
|
|
});
|
|
assert.equal(revived.players[1].leaders[1].dead, false);
|
|
assert.equal(revived.battle!.truthPromises![0].released, undefined);
|
|
});
|
|
void test('Tleilaxu Ghola income is a reachable funding path but remains pending until its response ends', () => {
|
|
const initial = tleilaxu();
|
|
hold(initial, 'a', 'Karama');
|
|
let g = promise(initial, supportClaim);
|
|
const own = viewGame(g, 'e').battle!;
|
|
assert.equal(own.compliantPlan, null);
|
|
assert.equal(own.compliantPreparation!.actions[0].amount, 1);
|
|
g = applyAction(g, 'e', own.compliantPreparation!.actions[0]);
|
|
assert.equal(g.response!.kind, 'revivalIncome');
|
|
assert.equal(g.players[1].spice, 0);
|
|
assert.equal(g.battle!.truthPromises![0].released, undefined);
|
|
const waiting = viewGame(g, 'e').battle!;
|
|
assert.equal(waiting.compliantPlan, null);
|
|
assert.equal(waiting.compliantPreparation!.waitingForIncome, true);
|
|
assert.deepEqual(waiting.compliantPreparation!.actions, []);
|
|
assert.throws(
|
|
() => seal(g, waiting.compliantPreparation!.plan),
|
|
/response window/,
|
|
);
|
|
g = allow(JSON.parse(JSON.stringify(g)));
|
|
assert.equal(g.players[1].spice, 1);
|
|
assert.equal(g.players[1].tanks, 0);
|
|
assert.equal(g.players[1].reserves, 16);
|
|
assert.equal(viewGame(g, 'e').battle!.compliantPreparation, null);
|
|
g = seal(g, viewGame(g, 'e').battle!.compliantPlan!);
|
|
assert.equal(g.battle!.plans.e.support, 1);
|
|
assert.deepEqual(liveCards(g), liveCards(initial));
|
|
});
|
|
void test('canceling conditional revival income releases the promise without undoing the actual revival', () => {
|
|
const initial = tleilaxu();
|
|
const karama = hold(initial, 'a', 'Karama');
|
|
let g = promise(initial, supportClaim);
|
|
g = applyAction(
|
|
g,
|
|
'e',
|
|
viewGame(g, 'e').battle!.compliantPreparation!.actions[0],
|
|
);
|
|
g = applyAction(g, 'a', { type: 'card', mode: 'cancel', card: karama });
|
|
assert.equal(g.players[1].spice, 0);
|
|
assert.equal(g.players[1].tanks, 0);
|
|
assert.equal(g.players[1].reserves, 16);
|
|
assert.equal(g.battle!.truthPromises![0].released, true);
|
|
assert.equal(g.response, null);
|
|
});
|
|
void test('a Worthless counter-window preserves possible income until the original cancellation settles', () => {
|
|
const initial = tleilaxu();
|
|
const karama = hold(initial, 'a', 'Karama');
|
|
const worthless = initial.deck.find((c) => c.kind === 'worthless')!;
|
|
hold(initial, 'b', worthless.name);
|
|
let g = promise(initial, supportClaim);
|
|
g = applyAction(
|
|
g,
|
|
'e',
|
|
viewGame(g, 'e').battle!.compliantPreparation!.actions[0],
|
|
);
|
|
const conversion = applyAction(g, 'b', {
|
|
type: 'card',
|
|
mode: 'cancel',
|
|
card: worthless.id,
|
|
});
|
|
assert.equal(conversion.response!.kind, 'worthlessKarama');
|
|
assert.equal(conversion.battle!.truthPromises![0].released, undefined);
|
|
assert.equal(
|
|
viewGame(conversion, 'e').battle!.compliantPreparation!.waitingForIncome,
|
|
true,
|
|
);
|
|
assert.equal(viewGame(conversion, 'a').battle!.compliantPreparation, null);
|
|
const blocked = allow(conversion);
|
|
assert.equal(blocked.battle!.truthPromises![0].released, true);
|
|
const restored = allow(
|
|
applyAction(conversion, 'a', {
|
|
type: 'card',
|
|
mode: 'cancel',
|
|
card: karama,
|
|
}),
|
|
);
|
|
assert.equal(restored.players[1].spice, 1);
|
|
assert.equal(restored.battle!.truthPromises![0].released, undefined);
|
|
assert.ok(viewGame(restored, 'e').battle!.compliantPlan);
|
|
});
|
|
void test('Ghola income cannot fund another faction or pretend to add forces directly to a battle', () => {
|
|
const initial = fixture(true);
|
|
initial.players[1].spice = 0;
|
|
initial.players[1].tanks = 1;
|
|
initial.players[1].reserves--;
|
|
initial.players[2].faction = 'tleilaxu';
|
|
initial.players[2].leaders = leaders('tleilaxu');
|
|
assert.deepEqual(
|
|
viewGame(ask(initial, supportClaim), 'e').truthBattleAnswers,
|
|
['no'],
|
|
);
|
|
const force = fixture();
|
|
force.players[1].tanks = 5;
|
|
force.players[1].reserves -= 5;
|
|
assert.deepEqual(
|
|
viewGame(ask(force, { kind: 'dial', compare: 'gte', value: 5 }), 'e')
|
|
.truthBattleAnswers,
|
|
['no'],
|
|
);
|
|
});
|
|
void test('one revival can satisfy a leader-and-support promise when its actual Tleilaxu income suffices', () => {
|
|
const initial = tleilaxu();
|
|
initial.players[1].leaders[0].dead = true;
|
|
const g = promise(initial, {
|
|
kind: 'and',
|
|
terms: [{ kind: 'leader', leader: 'tleilaxu-0' }, supportClaim],
|
|
});
|
|
const next = viewGame(g, 'e').battle!.compliantPreparation!.actions[0];
|
|
assert.equal(next.leader, 'tleilaxu-0');
|
|
const revived = allow(applyAction(g, 'e', next));
|
|
const legal = viewGame(revived, 'e').battle!.compliantPlan!;
|
|
assert.equal(legal.leader, 'tleilaxu-0');
|
|
assert.equal(legal.support, 1);
|
|
assert.ok(seal(revived, legal).battle!.plans.e);
|
|
});
|
|
void test('all AI levels execute required Ghola preparation before sealing or answering prescience', () => {
|
|
for (const level of DIFFICULTIES)
|
|
for (const prescience of [false, true]) {
|
|
const initial = fixture();
|
|
initial.players[1].bot = level;
|
|
let g: Game = promise(initial, leaderClaim);
|
|
if (prescience) {
|
|
g.battle!.prescience = { player: 'a', field: 'leader' };
|
|
g.battle!.preparation = {
|
|
kind: 'prescienceAnswer',
|
|
owner: 'e',
|
|
beneficiary: 'a',
|
|
};
|
|
}
|
|
const actions = botActions(viewGame(g, 'e'));
|
|
assert.equal(actions[0].type, 'card');
|
|
assert.equal(actions[0].leader, 'emperor-0');
|
|
g = applyAction(g, 'e', actions[0]);
|
|
if (prescience) {
|
|
const response = botActions(viewGame(g, 'e'))[0];
|
|
assert.equal(response.type, 'prescienceAnswer');
|
|
g = applyAction(g, 'e', response);
|
|
assert.equal(g.battle!.prescience!.value, 'emperor-0');
|
|
}
|
|
let done: Game | undefined;
|
|
for (const action of botActions(viewGame(g, 'e'))) {
|
|
try {
|
|
done = applyAction(g, 'e', action);
|
|
break;
|
|
} catch (error) {
|
|
if (!(error instanceof RuleError)) throw error;
|
|
}
|
|
}
|
|
assert.equal(done!.battle!.plans.e.leader, 'emperor-0', level);
|
|
}
|
|
});
|
|
void test('a sealed plan cannot be changed through a future Ghola search', () => {
|
|
let g = fixture();
|
|
g = applyAction(g, 'e', { type: 'battlePlan', dial: 0, leader: 'emperor-1' });
|
|
const pending = ask(g, leaderClaim);
|
|
assert.deepEqual(viewGame(pending, 'e').truthBattleAnswers, ['no']);
|
|
assert.equal(pending.players[1].leaders[0].dead, true);
|
|
assert.equal(
|
|
pending.players[1].hand.filter((c) => c.effect === 'ghola').length,
|
|
1,
|
|
);
|
|
});
|