473 lines
16 KiB
TypeScript
473 lines
16 KiB
TypeScript
import { placeFixtureHand } from './fixture-hand';
|
||
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import {
|
||
applyAction,
|
||
createGame,
|
||
joinGame,
|
||
newPlayer,
|
||
viewGame,
|
||
type Game,
|
||
type Player,
|
||
} from '../game/engine';
|
||
import { baseDeck, leaders } from '../game/cards';
|
||
import { forceRevivalLimit, forceRevivalQuote } from '../game/revival';
|
||
import { botActions, runBots } from '../game/bots';
|
||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||
import { createTechTokens } from '../game/tech-tokens';
|
||
function fixture() {
|
||
let g = createGame('TREVIVAL2', newPlayer('a', 'Atreides', 'atreides'));
|
||
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
|
||
joinGame(g, newPlayer('t', 'Tleilaxu', 'guild'));
|
||
joinGame(g, newPlayer('h', 'Observer', 'harkonnen'));
|
||
g.players.forEach((p) => (p.ready = true));
|
||
g = applyAction(g, 'a', { type: 'start' });
|
||
for (const p of g.players)
|
||
if (p.traitorChoices.length)
|
||
g = applyAction(g, p.id, {
|
||
type: 'traitor',
|
||
leader: p.traitorChoices[0],
|
||
});
|
||
g.players[2].faction = 'tleilaxu';
|
||
g.players[2].leaders = leaders('tleilaxu');
|
||
g.players[2].traitors = [];
|
||
g.phase = 4;
|
||
g.order = ['a', 'e', 't', 'h'];
|
||
g.active = null;
|
||
for (const p of g.players) {
|
||
p.hand = [];
|
||
p.forces = {};
|
||
p.reserves = 20;
|
||
p.tanks = 0;
|
||
p.spice = 20;
|
||
p.revived = 0;
|
||
}
|
||
return g;
|
||
}
|
||
function tanks(p: Player, n: number) {
|
||
p.tanks = n;
|
||
p.reserves = 20 - n;
|
||
}
|
||
function dead(p: Player, indices = [0]) {
|
||
for (const i of indices) {
|
||
p.leaders[i].dead = true;
|
||
p.leaders[i].deaths = 1;
|
||
}
|
||
}
|
||
function allow(state: Game) {
|
||
let g = state;
|
||
while (g.response || g.decision?.kind === 'revivalStop')
|
||
g =
|
||
g.decision?.kind === 'revivalStop'
|
||
? applyAction(g, g.decision.player, { type: 'decision', decline: true })
|
||
: applyAction(
|
||
g,
|
||
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
|
||
{ type: 'passResponse' },
|
||
);
|
||
return g;
|
||
}
|
||
function passWindow(state: Game) {
|
||
let g = state;
|
||
const kind = g.response!.kind;
|
||
while (g.response?.kind === kind)
|
||
g = applyAction(
|
||
g,
|
||
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
|
||
{ type: 'passResponse' },
|
||
);
|
||
return g;
|
||
}
|
||
const revive = (g: Game, id: string, amount: number, elite = 0) =>
|
||
applyAction(g, id, { type: 'revive', amount, elite });
|
||
const karama = baseDeck().find((c) => c.effect === 'karama')!;
|
||
function cancel(g: Game, id = 'h') {
|
||
return applyAction(g, id, { type: 'card', card: karama.id, mode: 'cancel' });
|
||
}
|
||
function request(g: Game, id = 'a', leader = 'atreides-0') {
|
||
return applyAction(g, id, { type: 'requestLeaderRevival', leader });
|
||
}
|
||
function quote(g: Game, amount: number, id = 'a') {
|
||
return applyAction(g, 't', {
|
||
type: 'quoteLeaderRevival',
|
||
target: id,
|
||
amount,
|
||
});
|
||
}
|
||
function conserved(g: Game) {
|
||
for (const p of g.players) {
|
||
assert.equal(
|
||
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
|
||
20,
|
||
);
|
||
assert.ok(p.spice >= 0);
|
||
}
|
||
}
|
||
void test('paid and free force revival income goes to Tleilaxu after a separate response', () => {
|
||
const before = fixture();
|
||
before.players[3].hand = [karama];
|
||
tanks(before.players[0], 3);
|
||
let g = revive(before, 'a', 3);
|
||
assert.equal(g.players[0].spice, 18);
|
||
assert.equal(g.players[0].reserves, 20);
|
||
assert.equal(g.players[2].spice, 20);
|
||
assert.equal(g.response?.kind, 'revivalIncome');
|
||
g = allow(JSON.parse(JSON.stringify(g)));
|
||
assert.equal(g.players[2].spice, 23);
|
||
conserved(g);
|
||
});
|
||
void test('splitting free revival into individual forces never duplicates its income', () => {
|
||
const before = fixture();
|
||
tanks(before.players[0], 3);
|
||
let g = allow(revive(before, 'a', 1));
|
||
assert.equal(g.players[2].spice, 21);
|
||
g = allow(revive(g, 'a', 1));
|
||
assert.equal(g.players[2].spice, 21);
|
||
g = allow(revive(g, 'a', 1));
|
||
assert.equal(g.players[2].spice, 23);
|
||
});
|
||
void test('Karama redirects one revival payment without undoing force return or granting a second free-income claim', () => {
|
||
const before = fixture();
|
||
tanks(before.players[0], 3);
|
||
before.players[3].hand = [karama];
|
||
let g = cancel(revive(before, 'a', 1));
|
||
assert.equal(g.players[2].spice, 20);
|
||
assert.equal(g.players[0].tanks, 2);
|
||
g = allow(revive(g, 'a', 1));
|
||
assert.equal(g.players[2].spice, 20);
|
||
g = allow(revive(g, 'a', 1));
|
||
assert.equal(g.players[2].spice, 22);
|
||
});
|
||
void test('Tleilaxu can revive all twenty forces at half price and gain their own free-revival income', () => {
|
||
const before = fixture();
|
||
before.players[3].hand = [karama];
|
||
tanks(before.players[2], 20);
|
||
let g = revive(before, 't', 20);
|
||
assert.equal(g.response?.kind, 'revivalLimit');
|
||
assert.equal(g.players[2].tanks, 20);
|
||
assert.equal(g.players[2].spice, 20);
|
||
g = passWindow(g);
|
||
assert.equal(g.response?.kind, 'revivalDiscount');
|
||
g = allow(g);
|
||
assert.equal(g.players[2].tanks, 0);
|
||
assert.equal(g.players[2].revived, 20);
|
||
assert.equal(g.players[2].spice, 3);
|
||
conserved(g);
|
||
});
|
||
void test('canceling the expanded limit aborts the pending revival and restores three-force limits for everyone', () => {
|
||
const before = fixture();
|
||
tanks(before.players[0], 5);
|
||
tanks(before.players[2], 6);
|
||
before.players[3].hand = [karama];
|
||
let g = applyAction(before, 't', {
|
||
type: 'tleilaxuRevivalLimit',
|
||
target: 'a',
|
||
});
|
||
g = cancel(revive(g, 't', 6));
|
||
assert.equal(g.pendingRevival, null);
|
||
assert.equal(g.players[2].revived, 0);
|
||
assert.equal(g.players[2].spice, 20);
|
||
assert.equal(forceRevivalLimit(g, g.players[0]), 3);
|
||
assert.throws(() => revive(g, 'a', 5), /integer/);
|
||
assert.throws(
|
||
() => applyAction(g, 't', { type: 'tleilaxuRevivalLimit', target: 'a' }),
|
||
/unavailable/,
|
||
);
|
||
g = allow(revive(g, 't', 3));
|
||
assert.equal(g.players[2].revived, 3);
|
||
conserved(g);
|
||
});
|
||
void test('a canceled discount recomputes the price before any payment or force mutation', () => {
|
||
const before = fixture();
|
||
tanks(before.players[2], 3);
|
||
before.players[3].hand = [karama];
|
||
let g = revive(before, 't', 3);
|
||
assert.equal(g.response?.kind, 'revivalDiscount');
|
||
g = allow(cancel(g));
|
||
assert.equal(g.players[2].spice, 19);
|
||
assert.equal(g.revivalRules?.fullPrice, true);
|
||
// Two spice paid at full rate and one free-revival income returned.
|
||
const poor = fixture();
|
||
tanks(poor.players[2], 3);
|
||
poor.players[2].spice = 1;
|
||
poor.players[3].hand = [karama];
|
||
const stopped = cancel(revive(poor, 't', 3));
|
||
assert.equal(stopped.pendingRevival, null);
|
||
assert.equal(stopped.players[2].tanks, 3);
|
||
assert.equal(stopped.players[2].spice, 1);
|
||
});
|
||
void test('Tleilaxu grant five force revivals and optional half price to an ally who pays the cost', () => {
|
||
const before = fixture();
|
||
before.players[0].ally = 't';
|
||
before.players[2].ally = 'a';
|
||
tanks(before.players[0], 5);
|
||
let g = applyAction(before, 't', {
|
||
type: 'tleilaxuRevivalLimit',
|
||
target: 'a',
|
||
});
|
||
g = applyAction(g, 't', { type: 'tleilaxuAllyDiscount' });
|
||
assert.deepEqual(forceRevivalQuote(g, g.players[0], 5), {
|
||
free: 2,
|
||
normalCost: 6,
|
||
cost: 3,
|
||
});
|
||
g = allow(revive(g, 'a', 5));
|
||
assert.equal(g.players[0].spice, 17);
|
||
assert.equal(g.players[2].spice, 24);
|
||
conserved(g);
|
||
});
|
||
void test('an ally discount can be canceled without changing another faction’s standard free allowance', () => {
|
||
const before = fixture();
|
||
before.players[0].ally = 't';
|
||
before.players[2].ally = 'a';
|
||
tanks(before.players[0], 3);
|
||
before.players[3].hand = [karama];
|
||
let g = applyAction(before, 't', { type: 'tleilaxuAllyDiscount' });
|
||
g = allow(cancel(revive(g, 'a', 3)));
|
||
assert.equal(g.players[0].spice, 18);
|
||
assert.equal(g.players[2].spice, 23);
|
||
assert.equal(g.revivalRules?.discountBlocked, true);
|
||
});
|
||
void test('ordinary leader revivals pay Tleilaxu and preserve native revival-cycle eligibility', () => {
|
||
const before = fixture();
|
||
dead(before.players[0], [0, 1, 2, 3, 4]);
|
||
let g = applyAction(before, 'a', {
|
||
type: 'reviveLeader',
|
||
leader: 'atreides-0',
|
||
});
|
||
assert.equal(g.response, null);
|
||
g = allow(g);
|
||
assert.equal(g.players[0].spice, 15);
|
||
assert.equal(g.players[2].spice, 25);
|
||
assert.equal(g.players[0].revivalCycle, 1);
|
||
assert.throws(
|
||
() => applyAction(g, 'a', { type: 'reviveLeader', leader: 'atreides-1' }),
|
||
/one leader/,
|
||
);
|
||
});
|
||
void test('Tleilaxu revive multiple own leaders early at rounded half price including Zoal', () => {
|
||
const before = fixture();
|
||
dead(before.players[2], [0, 1, 2]);
|
||
let g = applyAction(before, 't', {
|
||
type: 'reviveLeader',
|
||
leader: 'tleilaxu-0',
|
||
});
|
||
assert.equal(g.response, null);
|
||
assert.equal(g.players[2].leaders[0].dead, false);
|
||
g = allow(g);
|
||
assert.equal(g.players[2].spice, 18);
|
||
g = allow(
|
||
applyAction(g, 't', { type: 'reviveLeader', leader: 'tleilaxu-1' }),
|
||
);
|
||
g = allow(
|
||
applyAction(g, 't', { type: 'reviveLeader', leader: 'tleilaxu-2' }),
|
||
);
|
||
assert.equal(g.players[2].spice, 14);
|
||
assert.ok(g.players[2].leaders.every((l) => !l.dead));
|
||
});
|
||
void test('negotiated early revival is requested, privately quoted, accepted and settled once', () => {
|
||
const before = fixture();
|
||
before.players[3].hand = [karama];
|
||
dead(before.players[0]);
|
||
let g = request(before);
|
||
assert.deepEqual(viewGame(g, 'h').revivalRequests, {});
|
||
assert.equal(viewGame(g, 't').revivalRequests.a.leader, 'atreides-0');
|
||
g = quote(g, 7);
|
||
assert.equal(g.players[0].spice, 20);
|
||
assert.equal(g.players[0].leaders[0].dead, true);
|
||
g = applyAction(g, 'a', { type: 'acceptLeaderRevival' });
|
||
assert.equal(g.response?.kind, 'earlyRevival');
|
||
assert.equal('pendingRevival' in viewGame(g, 'h'), false);
|
||
g = passWindow(JSON.parse(JSON.stringify(g)));
|
||
assert.equal(g.response?.kind, 'revivalIncome');
|
||
assert.equal(viewGame(g, 'h').response?.amount, undefined);
|
||
g = allow(g);
|
||
assert.equal(g.players[0].spice, 13);
|
||
assert.equal(g.players[2].spice, 27);
|
||
assert.equal(g.players[0].leaders[0].dead, false);
|
||
assert.deepEqual(g.revivalRequests, {});
|
||
assert.throws(() => applyAction(g, 'a', { type: 'acceptLeaderRevival' }));
|
||
});
|
||
void test('a negotiated zero-spice return is allowed but all-unavailable leaders use ordinary paid revival', () => {
|
||
const before = fixture();
|
||
dead(before.players[0]);
|
||
const g = allow(
|
||
applyAction(quote(request(before), 0), 'a', {
|
||
type: 'acceptLeaderRevival',
|
||
}),
|
||
);
|
||
assert.equal(g.players[0].spice, 20);
|
||
assert.equal(g.players[2].spice, 20);
|
||
const all = fixture();
|
||
dead(all.players[0], [0, 1, 2, 3, 4]);
|
||
assert.throws(() => request(all), /ordinary revival/);
|
||
all.players[0].leaders[4].dead = false;
|
||
all.players[0].leaders[4].capturedBy = 'h';
|
||
assert.throws(() => request(all), /ordinary revival/);
|
||
});
|
||
void test('an early-return cancellation preserves a concealed leader and keeps its identity private', () => {
|
||
const before = fixture();
|
||
dead(before.players[0]);
|
||
before.players[0].leaders[0].deaths = 2;
|
||
before.players[0].leaders[0].concealed = {
|
||
captor: 'h',
|
||
dead: false,
|
||
deaths: 0,
|
||
};
|
||
before.players[1].hand = [karama];
|
||
let g = applyAction(quote(request(before), 3), 'a', {
|
||
type: 'acceptLeaderRevival',
|
||
});
|
||
g = cancel(g, 'e');
|
||
assert.equal(g.players[0].leaders[0].dead, true);
|
||
assert.equal(g.players[0].spice, 20);
|
||
assert.equal(g.players[2].spice, 20);
|
||
assert.deepEqual(viewGame(g, 'e').revivalRules.earlyBlocked, []);
|
||
assert.deepEqual(viewGame(g, 'a').revivalRules.earlyBlocked, [
|
||
'a:atreides-0',
|
||
]);
|
||
assert.throws(
|
||
() => applyAction(g, 'a', { type: 'acceptLeaderRevival' }),
|
||
/canceled/,
|
||
);
|
||
});
|
||
void test('quote ownership, negative prices, missing requests, refusal and withdrawal are enforced', () => {
|
||
const before = fixture();
|
||
dead(before.players[0]);
|
||
let g = request(before);
|
||
const snapshot = structuredClone(g);
|
||
assert.throws(
|
||
() =>
|
||
applyAction(g, 'e', {
|
||
type: 'quoteLeaderRevival',
|
||
target: 'a',
|
||
amount: 0,
|
||
}),
|
||
/Only Tleilaxu/,
|
||
);
|
||
assert.throws(() => quote(g, -1), /integer/);
|
||
assert.throws(() =>
|
||
applyAction(g, 't', {
|
||
type: 'quoteLeaderRevival',
|
||
target: '__proto__',
|
||
amount: 1,
|
||
}),
|
||
);
|
||
assert.equal(
|
||
Object.prototype.hasOwnProperty.call(Object.prototype, 'price'),
|
||
false,
|
||
);
|
||
assert.deepEqual(g, snapshot);
|
||
assert.throws(
|
||
() => applyAction(g, 'a', { type: 'acceptLeaderRevival' }),
|
||
/quote/,
|
||
);
|
||
g = applyAction(g, 't', { type: 'declineLeaderRevival', target: 'a' });
|
||
assert.throws(
|
||
() => applyAction(g, 'a', { type: 'acceptLeaderRevival' }),
|
||
/declined/,
|
||
);
|
||
g = quote(g, 2);
|
||
g = applyAction(g, 'a', { type: 'cancelLeaderRequest' });
|
||
assert.deepEqual(g.revivalRequests, {});
|
||
});
|
||
void test('KH can be revived through a negotiated early return', () => {
|
||
const before = fixture();
|
||
before.advanced = true;
|
||
before.players[0].kwisatz = { dead: true };
|
||
let g = request(before, 'a', 'kwisatz');
|
||
g = allow(applyAction(quote(g, 4), 'a', { type: 'acceptLeaderRevival' }));
|
||
assert.equal(g.players[0].kwisatz?.dead, false);
|
||
assert.equal(g.players[0].kwisatz?.revivalCycle, 2);
|
||
assert.equal(g.players[2].spice, 24);
|
||
});
|
||
void test('Ghola treachery grants one income separately from normal free revival, including outside Revival', () => {
|
||
const before = fixture();
|
||
tanks(before.players[0], 6);
|
||
const ghola = baseDeck().find((c) => c.effect === 'ghola')!;
|
||
placeFixtureHand(before, 0, [ghola]);
|
||
let g = allow(revive(before, 'a', 1));
|
||
g.phase = 8;
|
||
g = applyAction(g, 'a', { type: 'card', card: ghola.id, amount: 5 });
|
||
assert.equal(g.response, null);
|
||
g = allow(g);
|
||
assert.equal(g.players[2].spice, 22);
|
||
assert.equal(g.players[0].revived, 1);
|
||
conserved(g);
|
||
});
|
||
void test('Emperor extra revival payments go to Tleilaxu after the Emperor response', () => {
|
||
const before = fixture();
|
||
before.players[3].hand = [karama];
|
||
before.players[0].ally = 'e';
|
||
before.players[1].ally = 'a';
|
||
tanks(before.players[0], 8);
|
||
let g = applyAction(before, 't', {
|
||
type: 'tleilaxuRevivalLimit',
|
||
target: 'a',
|
||
});
|
||
g = allow(revive(g, 'a', 5));
|
||
g = applyAction(g, 'e', { type: 'emperorRevival', amount: 3 });
|
||
assert.equal(g.response?.kind, 'emperorRevival');
|
||
g = passWindow(g);
|
||
assert.equal(g.response?.kind, 'revivalIncome');
|
||
g = allow(g);
|
||
assert.equal(g.players[0].tanks, 0);
|
||
assert.equal(g.players[0].revived, 5);
|
||
assert.equal(g.emperorExtra.a, 3);
|
||
assert.equal(g.players[1].spice, 14);
|
||
assert.equal(g.players[2].spice, 33);
|
||
conserved(g);
|
||
});
|
||
void test('elite revival cap is preserved under the larger limit and pending-discount serialization', () => {
|
||
const before = fixture();
|
||
before.advanced = true;
|
||
tanks(before.players[1], 5);
|
||
before.players[1].elites = { reserves: 3, tanks: 2, forces: {}, revived: 0 };
|
||
let g = applyAction(before, 't', {
|
||
type: 'tleilaxuRevivalLimit',
|
||
target: 'e',
|
||
});
|
||
assert.throws(() => revive(g, 'e', 5, 2), /one elite/);
|
||
g = allow(revive(g, 'e', 4, 1));
|
||
assert.equal(g.players[1].elites?.revived, 1);
|
||
assert.equal(g.players[1].elites?.reserves, 4);
|
||
assert.throws(() => revive(g, 'e', 1, 1), /one elite/);
|
||
conserved(g);
|
||
});
|
||
void test('tech-token income stays separate from Tleilaxu revival income', () => {
|
||
const before = fixture();
|
||
tanks(before.players[0], 2);
|
||
before.techTokens = createTechTokens();
|
||
before.techTokens.axlotl.owner = 't';
|
||
const g = allow(revive(before, 'a', 2));
|
||
assert.equal(g.players[2].spice, 21);
|
||
assert.equal(g.techTokens?.axlotl.spice, 1);
|
||
});
|
||
void test('all AI levels handle Tleilaxu permissions, prices and private revival requests', () => {
|
||
for (const difficulty of DIFFICULTIES) {
|
||
const g = fixture();
|
||
g.players[2].bot = difficulty;
|
||
tanks(g.players[0], 5);
|
||
g.revivalRequests = { a: { leader: 'atreides-0', price: null } };
|
||
const choices = botActions(viewGame(g, 't'));
|
||
assert.ok(choices.some((a) => a.type === 'quoteLeaderRevival'));
|
||
assert.ok(choices.some((a) => a.type === 'tleilaxuRevivalLimit'));
|
||
assert.doesNotThrow(() => applyAction(g, 't', choices[0]));
|
||
const changed = structuredClone(g);
|
||
changed.players[0].spice = 999;
|
||
assert.deepEqual(botActions(viewGame(changed, 't')), choices);
|
||
}
|
||
});
|
||
void test('AI-only revival phase completes without a pending transaction or negative resources', () => {
|
||
let g = fixture();
|
||
for (const p of g.players) {
|
||
p.bot = 'Hard';
|
||
tanks(p, 8);
|
||
dead(p, [0, 1, 2]);
|
||
}
|
||
for (let i = 0; i < 20 && g.phase === 4; i++) g = runBots(g, 20);
|
||
assert.notEqual(g.phase, 4);
|
||
assert.equal(g.pendingRevival ?? null, null);
|
||
conserved(g);
|
||
});
|