347 lines
12 KiB
TypeScript
347 lines
12 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,
|
|
} from '../game/engine';
|
|
import { baseDeck, leaders } from '../game/cards';
|
|
import { TERRITORIES, distance, location, splitLocation } from '../game/board';
|
|
import { eliteRevivalRemaining, forceRevivalQuote } from '../game/revival';
|
|
import { botActions, runBots } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
function fixture(advanced = false) {
|
|
let g = createGame('IXECON22', newPlayer('e', 'Emperor', 'emperor'));
|
|
joinGame(g, newPlayer('i', 'Ixians', 'guild'));
|
|
g.players.forEach((p) => {
|
|
p.ready = true;
|
|
});
|
|
g = applyAction(g, 'e', { type: 'start' });
|
|
for (const p of g.players)
|
|
g = applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0] });
|
|
g.players[1].faction = 'ixians';
|
|
g.players[1].leaders = leaders('ixians');
|
|
g.players[1].elites = { reserves: 7, tanks: 0, forces: {}, revived: 0 };
|
|
g.players.forEach((p) => {
|
|
p.hand = [];
|
|
p.forces = {};
|
|
p.reserves = 20;
|
|
p.tanks = 0;
|
|
p.spice = 20;
|
|
});
|
|
g.phase = 5;
|
|
g.active = 'i';
|
|
g.order = ['i', 'e'];
|
|
g.movementRemaining = ['i', 'e'];
|
|
g.advanced = advanced;
|
|
g.storm = 18;
|
|
return g;
|
|
}
|
|
const sand = TERRITORIES.filter((t) => t.type === 'sand').flatMap((t) =>
|
|
t.sectors.filter((s) => s !== 18).map((s) => location(t.id, s)),
|
|
);
|
|
const blocked = (key: string) => splitLocation(key).sector === 18;
|
|
const origin = sand.find((key) =>
|
|
[1, 2, 3].every((d) => sand.some((to) => distance(key, to, blocked) === d)),
|
|
)!;
|
|
const target = (d: number) =>
|
|
sand.find((to) => distance(origin, to, blocked) === d)!;
|
|
function field(g: Game, count = 4, cyborgs = 1) {
|
|
const p = g.players[1];
|
|
p.forces = { [origin]: count };
|
|
p.reserves = 20 - count;
|
|
p.elites!.forces = { [origin]: cyborgs };
|
|
p.elites!.reserves = 7 - cyborgs;
|
|
return g;
|
|
}
|
|
function tanks(g: Game, count = 6, cyborgs = 4) {
|
|
const p = g.players[1];
|
|
p.tanks = count;
|
|
p.reserves = 20 - count;
|
|
p.elites!.tanks = cyborgs;
|
|
p.elites!.reserves = 7 - cyborgs;
|
|
g.phase = 4;
|
|
return g;
|
|
}
|
|
function allow(state: Game) {
|
|
let g = state;
|
|
while (g.response || g.decision?.kind === 'revivalStop')
|
|
if (g.decision?.kind === 'revivalStop')
|
|
g = applyAction(g, g.decision.player, {
|
|
type: 'decision',
|
|
decline: true,
|
|
});
|
|
else
|
|
g = applyAction(
|
|
g,
|
|
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
|
|
{ type: 'passResponse' },
|
|
);
|
|
return g;
|
|
}
|
|
function move(g: Game, d = 2, amount = 4, elite = 1, from = origin) {
|
|
const to = splitLocation(target(d));
|
|
return applyAction(g, 'i', {
|
|
type: 'move',
|
|
from,
|
|
amount,
|
|
elite,
|
|
territory: to.territory,
|
|
sector: to.sector,
|
|
});
|
|
}
|
|
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);
|
|
}
|
|
const e = g.players[1].elites!;
|
|
assert.equal(
|
|
e.reserves + e.tanks + Object.values(e.forces).reduce((a, b) => a + b, 0),
|
|
7,
|
|
);
|
|
}
|
|
void test('a cyborg carries accompanying suboids two territories through a serialized Karama response', () => {
|
|
const initial = field(fixture());
|
|
initial.players[0].hand = [baseDeck().find((c) => c.effect === 'karama')!];
|
|
let g = move(initial);
|
|
assert.equal(g.response?.kind, 'ixMovement');
|
|
assert.equal(g.players[1].forces[origin], 4);
|
|
assert.equal(g.players[1].moved, 0);
|
|
g = allow(JSON.parse(JSON.stringify(g)));
|
|
assert.equal(g.players[1].forces[target(2)], 4);
|
|
assert.equal(g.players[1].elites!.forces[target(2)], 1);
|
|
assert.equal(g.players[1].moved, 1);
|
|
assert.equal(g.pendingIxMove, null);
|
|
conserved(g);
|
|
});
|
|
void test('a cyborg left behind cannot give a suboid-only group two-territory movement', () => {
|
|
const g = field(fixture());
|
|
const snapshot = structuredClone(g);
|
|
assert.throws(() => move(g, 2, 3, 0), /more than 1/);
|
|
assert.deepEqual(g, snapshot);
|
|
const moved = move(g, 1, 3, 0);
|
|
assert.equal(moved.response, null);
|
|
assert.equal(moved.players[1].elites!.forces[origin], 1);
|
|
conserved(moved);
|
|
});
|
|
void test('movement cancellation leaves the army in place, prevents retrying that advantage and preserves a shorter move', () => {
|
|
const before = field(fixture());
|
|
const karama = baseDeck().find((c) => c.effect === 'karama')!;
|
|
before.players[0].hand = [karama];
|
|
let g = move(before);
|
|
g = applyAction(g, 'e', { type: 'card', card: karama.id, mode: 'cancel' });
|
|
assert.equal(g.players[1].moved, 0);
|
|
assert.equal(g.players[1].forces[origin], 4);
|
|
assert.equal(viewGame(g, 'i').players[1].ixMovementBlocked, true);
|
|
assert.throws(() => move(g), /more than 1/);
|
|
g = move(g, 1);
|
|
assert.equal(g.players[1].moved, 1);
|
|
conserved(g);
|
|
});
|
|
void test('ornithopters give suboids three territories without a cyborg-power response', () => {
|
|
const before = field(fixture());
|
|
before.players[1].forces['arrakeen:10'] = 1;
|
|
before.players[1].reserves--;
|
|
const g = move(before, 3, 3, 0);
|
|
assert.equal(g.response, null);
|
|
assert.equal(g.players[1].forces[target(3)], 3);
|
|
conserved(g);
|
|
});
|
|
void test('multisector movement needs a cyborg in the selected group and preserves the selected type counts', () => {
|
|
const before = field(fixture());
|
|
const terr = TERRITORIES.find(
|
|
(t) => t.type === 'sand' && t.sectors.filter((s) => s !== 18).length >= 2,
|
|
)!;
|
|
const keys = terr.sectors
|
|
.filter((s) => s !== 18)
|
|
.slice(0, 2)
|
|
.map((s) => location(terr.id, s));
|
|
const dest = sand.find(
|
|
(key) =>
|
|
keys.every((src) => distance(src, key, blocked) <= 2) &&
|
|
keys.some((src) => distance(src, key, blocked) === 2),
|
|
)!;
|
|
assert.ok(dest);
|
|
const to = splitLocation(dest);
|
|
before.players[1].forces = { [keys[0]]: 2, [keys[1]]: 2 };
|
|
before.players[1].elites!.forces = { [keys[1]]: 1 };
|
|
const g = allow(
|
|
applyAction(before, 'i', {
|
|
type: 'move',
|
|
forces: { [keys[0]]: 2, [keys[1]]: 2 },
|
|
eliteForces: { [keys[1]]: 1 },
|
|
territory: to.territory,
|
|
sector: to.sector,
|
|
}),
|
|
);
|
|
assert.equal(g.players[1].forces[dest], 4);
|
|
assert.equal(g.players[1].elites!.forces[dest], 1);
|
|
conserved(g);
|
|
});
|
|
void test('cyborgs collect three spice each and suboids two without ornithopters', () => {
|
|
const before = field(fixture(), 4, 2);
|
|
before.spice[origin] = 20;
|
|
before.movementRemaining = ['i'];
|
|
const g = applyAction(before, 'i', { type: 'endMovement' });
|
|
assert.equal(g.phase, 7);
|
|
assert.equal(g.players[1].spice, 30);
|
|
assert.equal(g.spice[origin], 10);
|
|
conserved(g);
|
|
});
|
|
void test('ornithopters increase suboid collection without raising cyborg capacity above three', () => {
|
|
const before = field(fixture(), 4, 2);
|
|
before.players[1].forces['arrakeen:10'] = 1;
|
|
before.players[1].reserves--;
|
|
before.spice[origin] = 20;
|
|
before.movementRemaining = ['i'];
|
|
const g = applyAction(before, 'i', { type: 'endMovement' });
|
|
assert.equal(g.players[1].spice, 32);
|
|
assert.equal(g.spice[origin], 8);
|
|
conserved(g);
|
|
});
|
|
void test('mixed revival uses the selected free type and charges three per paid cyborg and two per suboid', () => {
|
|
const before = tanks(fixture());
|
|
assert.equal(forceRevivalQuote(before, before.players[1], 3, 2).cost, 5);
|
|
const cyborgFree = allow(
|
|
applyAction(before, 'i', { type: 'revive', amount: 3, elite: 2 }),
|
|
);
|
|
assert.equal(cyborgFree.players[1].spice, 15);
|
|
assert.equal(cyborgFree.players[1].elites!.tanks, 2);
|
|
const suboidFree = allow(
|
|
applyAction(before, 'i', {
|
|
type: 'revive',
|
|
amount: 3,
|
|
elite: 2,
|
|
freeElite: 0,
|
|
}),
|
|
);
|
|
assert.equal(suboidFree.players[1].spice, 14);
|
|
conserved(cyborgFree);
|
|
conserved(suboidFree);
|
|
assert.throws(
|
|
() =>
|
|
applyAction(before, 'i', {
|
|
type: 'revive',
|
|
amount: 2,
|
|
elite: 2,
|
|
freeElite: 0,
|
|
}),
|
|
/integer/,
|
|
);
|
|
});
|
|
void test('cyborg revival is not capped at one and Ghola may return several while preserving the normal allowance', () => {
|
|
const before = tanks(fixture(true), 7, 7);
|
|
const ghola = baseDeck().find((c) => c.effect === 'ghola')!;
|
|
let g = allow(
|
|
applyAction(before, 'i', { type: 'revive', amount: 3, elite: 3 }),
|
|
);
|
|
assert.equal(g.players[1].spice, 14);
|
|
assert.equal(g.players[1].revived, 3);
|
|
placeFixtureHand(g, 1, [ghola]);
|
|
g = allow(
|
|
applyAction(g, 'i', { type: 'card', card: ghola.id, amount: 4, elite: 4 }),
|
|
);
|
|
assert.equal(g.players[1].elites!.reserves, 7);
|
|
assert.equal(g.players[1].revived, 3);
|
|
conserved(g);
|
|
const emperor = newPlayer('e', 'Emperor', 'emperor');
|
|
emperor.elites = { tanks: 3, reserves: 2, forces: {}, revived: 1 };
|
|
assert.equal(eliteRevivalRemaining(emperor), 0);
|
|
});
|
|
void test('Tleilaxu ally discount applies to the mixed paid total and cancellation restores the full type-based cost', () => {
|
|
const before = tanks(fixture());
|
|
const t = newPlayer('t', 'Tleilaxu', 'tleilaxu');
|
|
t.spice = 5;
|
|
before.players.push(t);
|
|
before.order.push('t');
|
|
before.players[1].ally = 't';
|
|
t.ally = 'i';
|
|
const g = applyAction(before, 't', { type: 'tleilaxuAllyDiscount' });
|
|
const karama = baseDeck().find((c) => c.effect === 'karama')!;
|
|
g.players[0].hand = [karama];
|
|
const declared = applyAction(g, 'i', { type: 'revive', amount: 3, elite: 2 });
|
|
assert.equal(declared.response?.kind, 'revivalDiscount');
|
|
const discounted = allow(declared);
|
|
assert.equal(discounted.players[1].spice, 17);
|
|
assert.equal(discounted.players[2].spice, 9);
|
|
const canceled = allow(
|
|
applyAction(declared, 'e', {
|
|
type: 'card',
|
|
card: karama.id,
|
|
mode: 'cancel',
|
|
}),
|
|
);
|
|
assert.equal(canceled.players[1].spice, 15);
|
|
assert.equal(canceled.players[2].spice, 11);
|
|
conserved(canceled);
|
|
});
|
|
void test('Emperor-funded extra cyborgs use their actual paid cost with no free-revival discount', () => {
|
|
const before = tanks(fixture(), 6, 4);
|
|
before.players[0].ally = 'i';
|
|
before.players[1].ally = 'e';
|
|
const g = allow(
|
|
applyAction(before, 'e', { type: 'emperorRevival', amount: 3, elite: 3 }),
|
|
);
|
|
assert.equal(g.players[0].spice, 11);
|
|
assert.equal(g.players[1].spice, 20);
|
|
assert.equal(g.players[1].revived, 0);
|
|
assert.equal(g.emperorExtra.i, 3);
|
|
conserved(g);
|
|
before.players[0].spice = 8;
|
|
const snapshot = structuredClone(before);
|
|
assert.throws(
|
|
() =>
|
|
applyAction(before, 'e', { type: 'emperorRevival', amount: 3, elite: 3 }),
|
|
/Not enough spice/,
|
|
);
|
|
assert.deepEqual(before, snapshot);
|
|
});
|
|
void test('ordinary shipment uses physical counts and keeps cyborg inventory intact', () => {
|
|
const before = fixture();
|
|
const to = splitLocation(origin);
|
|
const g = allow(
|
|
applyAction(before, 'i', {
|
|
type: 'ship',
|
|
amount: 3,
|
|
elite: 2,
|
|
territory: to.territory,
|
|
sector: to.sector,
|
|
}),
|
|
);
|
|
assert.equal(g.players[1].spice, 14);
|
|
assert.equal(g.players[1].forces[origin], 3);
|
|
assert.equal(g.players[1].elites!.forces[origin], 2);
|
|
conserved(g);
|
|
});
|
|
void test('all AI levels select affordable typed revivals and handle two-territory movement responses', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const before = tanks(fixture(true), 7, 7);
|
|
before.players[1].bot = difficulty;
|
|
before.players[1].spice = 3;
|
|
const actions = botActions(viewGame(before, 'i'));
|
|
const revival = actions.find((a) => a.type === 'revive');
|
|
assert.ok(revival);
|
|
const g = allow(applyAction(before, 'i', revival));
|
|
assert.ok(g.players[1].spice >= 0);
|
|
assert.ok(g.players[1].revived >= 1);
|
|
conserved(g);
|
|
const moving = field(fixture());
|
|
moving.players.forEach((p) => {
|
|
p.bot = difficulty;
|
|
});
|
|
const declared = move(moving);
|
|
const progressed = runBots(declared, 2);
|
|
assert.equal(progressed.players[1].moved, 1);
|
|
assert.equal(progressed.pendingIxMove, null);
|
|
conserved(progressed);
|
|
}
|
|
});
|