Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
@@ -0,0 +1,346 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
const karmas = baseDeck().filter((c) => c.effect === 'karama');
|
||||
function fixture() {
|
||||
let g = createGame('GUILDST2', newPlayer('e', 'Emperor', 'emperor'));
|
||||
joinGame(g, newPlayer('g', 'Guild', 'guild'));
|
||||
joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
|
||||
g.players.forEach((p) => (p.ready = true));
|
||||
g = applyAction(g, 'e', { type: 'start' });
|
||||
g = applyAction(g, 'b', { type: 'predict', faction: 'emperor', turn: 3 });
|
||||
for (const p of g.players)
|
||||
if (p.traitorChoices.length)
|
||||
g = applyAction(g, p.id, {
|
||||
type: 'traitor',
|
||||
leader: p.traitorChoices[0],
|
||||
});
|
||||
g.advanced = true;
|
||||
g.phase = 5;
|
||||
g.active = 'e';
|
||||
g.order = ['e', 'g', 'b'];
|
||||
g.movementRemaining = [...g.order];
|
||||
g.storm = 18;
|
||||
for (const p of g.players) {
|
||||
p.forces = {};
|
||||
p.reserves = 20;
|
||||
p.spice = 20;
|
||||
p.hand = [];
|
||||
}
|
||||
g.players[0].elites = { reserves: 5, tanks: 0, forces: {}, revived: 0 };
|
||||
g.players[1].hand = [karmas[0]];
|
||||
g.discard = [];
|
||||
return g;
|
||||
}
|
||||
const declaration = {
|
||||
type: 'ship',
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
amount: 4,
|
||||
elite: 2,
|
||||
};
|
||||
const stop = (g: Game) =>
|
||||
applyAction(g, 'g', { type: 'card', mode: 'special', card: karmas[0].id });
|
||||
const permit = (g: Game) =>
|
||||
applyAction(g, 'g', { type: 'decision', allow: true });
|
||||
function responses(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;
|
||||
}
|
||||
void test('Guild decision precedes all payment, elite placement, advisor and intrusion effects', () => {
|
||||
const before = fixture();
|
||||
before.players[2].forces = { 'arrakeen:10': 1 };
|
||||
before.players[2].reserves = 19;
|
||||
const g = applyAction(before, 'e', declaration);
|
||||
assert.deepEqual(g.players, before.players);
|
||||
assert.equal(g.response, null);
|
||||
assert.deepEqual(g.decision, {
|
||||
kind: 'guildShipment',
|
||||
player: 'g',
|
||||
shipper: 'e',
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
amount: 4,
|
||||
});
|
||||
assert.equal(g.active, 'e');
|
||||
assert.equal(g.pendingShipment?.elite, 2);
|
||||
assert.equal(before.pendingShipment, undefined);
|
||||
});
|
||||
void test('stopping retains forces, elite tokens, spice and allied credit without Guild income or advisor followup', () => {
|
||||
const before = fixture();
|
||||
before.players[0].ally = 'b';
|
||||
before.players[2].ally = 'e';
|
||||
before.aid.b = { recipient: 'e', amount: 3 };
|
||||
const pending = applyAction(before, 'e', { ...declaration, allyPayment: 3 });
|
||||
const g = stop(pending);
|
||||
assert.equal(g.players[0].spice, 20);
|
||||
assert.deepEqual(g.players[0].forces, {});
|
||||
assert.deepEqual(g.players[0].elites, before.players[0].elites);
|
||||
assert.equal(g.players[0].reserves, 20);
|
||||
assert.equal(g.players[0].shipped, true);
|
||||
assert.equal(g.players[1].specialKaramaUsed, true);
|
||||
assert.equal(g.players[1].spice, 20);
|
||||
assert.equal(g.players[2].reserves, 20);
|
||||
assert.deepEqual(g.aid, before.aid);
|
||||
assert.equal(g.pendingShipment, null);
|
||||
assert.equal(g.decision, null);
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(g.active, 'e');
|
||||
assert.equal(g.discard.filter((c) => c.id === karmas[0].id).length, 1);
|
||||
assert.throws(() => applyAction(g, 'e', declaration), /once/);
|
||||
});
|
||||
void test('a stopped shipment leaves ordinary movement available and cannot be countered by normal Karama', () => {
|
||||
const before = fixture();
|
||||
before.players[0].forces = { 'arrakeen:10': 3 };
|
||||
before.players[0].reserves = 17;
|
||||
let g = stop(
|
||||
applyAction(before, 'e', {
|
||||
...declaration,
|
||||
territory: 'carthag',
|
||||
sector: 11,
|
||||
}),
|
||||
);
|
||||
g.players[0].hand = [karmas[1]];
|
||||
assert.throws(() =>
|
||||
applyAction(g, 'e', { type: 'card', mode: 'cancel', card: karmas[1].id }),
|
||||
);
|
||||
g = applyAction(g, 'e', {
|
||||
type: 'move',
|
||||
from: 'arrakeen:10',
|
||||
territory: 'polar_sink',
|
||||
sector: 0,
|
||||
amount: 1,
|
||||
});
|
||||
assert.equal(g.players[0].forces['polar_sink:0'], 1);
|
||||
});
|
||||
void test('allowing commits once and resumes Guild income before Bene Gesserit accompaniment', () => {
|
||||
const initial = fixture();
|
||||
initial.players[0].hand = [karmas[1]];
|
||||
let g = permit(applyAction(initial, 'e', declaration));
|
||||
assert.equal(g.pendingShipment, null);
|
||||
assert.equal(g.players[0].spice, 16);
|
||||
assert.equal(g.players[0].reserves, 16);
|
||||
assert.equal(g.players[0].forces['arrakeen:10'], 4);
|
||||
assert.equal(g.players[0].elites?.forces['arrakeen:10'], 2);
|
||||
assert.equal(g.players[0].elites?.reserves, 3);
|
||||
assert.equal(g.response?.kind, 'guildIncome');
|
||||
assert.equal(g.players[1].specialKaramaUsed, undefined);
|
||||
assert.equal(g.decision?.kind, 'advisor');
|
||||
assert.throws(() => permit(g));
|
||||
g = responses(g);
|
||||
assert.equal(g.players[1].spice, 24);
|
||||
g = applyAction(g, 'b', { type: 'decision', accept: false });
|
||||
assert.equal(g.decision, null);
|
||||
});
|
||||
void test('allowing Guild-funded allied shipment pays only the shipper’s share to Guild', () => {
|
||||
const before = fixture();
|
||||
before.players[0].ally = 'g';
|
||||
before.players[1].ally = 'e';
|
||||
before.aid.g = { recipient: 'e', amount: 2 };
|
||||
let g = permit(applyAction(before, 'e', { ...declaration, allyPayment: 1 }));
|
||||
assert.equal(g.players[0].spice, 19);
|
||||
assert.equal(g.aid.g.amount, 1);
|
||||
assert.equal(g.response, null);
|
||||
g = responses(g);
|
||||
assert.equal(g.players[1].spice, 21);
|
||||
});
|
||||
void test('shipment rate card returns to its original owner on stop, including another player’s gift', () => {
|
||||
const before = fixture();
|
||||
before.players[2].hand = [karmas[1]];
|
||||
const benefit = applyAction(before, 'b', {
|
||||
type: 'card',
|
||||
mode: 'shipment',
|
||||
target: 'e',
|
||||
card: karmas[1].id,
|
||||
});
|
||||
const pending = applyAction(benefit, 'e', declaration);
|
||||
assert.equal(pending.pendingShipment?.cost, 2);
|
||||
const g = stop(pending);
|
||||
assert.deepEqual(g.players[2].hand, [karmas[1]]);
|
||||
assert.equal(
|
||||
g.discard.some((c) => c.id === karmas[1].id),
|
||||
false,
|
||||
);
|
||||
assert.equal(g.karamaShipping, null);
|
||||
const allowed = permit(pending);
|
||||
assert.equal(allowed.players[0].spice, 18);
|
||||
assert.equal(allowed.response, null);
|
||||
assert.equal(allowed.players[2].hand.length, 0);
|
||||
assert.equal(allowed.discard.filter((c) => c.id === karmas[1].id).length, 1);
|
||||
});
|
||||
void test('hidden Guild cards cannot change the offered decision or expose pending funding details', () => {
|
||||
const before = fixture();
|
||||
before.players[0].ally = 'b';
|
||||
before.players[2].ally = 'e';
|
||||
before.aid.b = { recipient: 'e', amount: 3 };
|
||||
const pending = applyAction(before, 'e', { ...declaration, allyPayment: 3 });
|
||||
before.players[1].hand = [];
|
||||
const empty = applyAction(before, 'e', { ...declaration, allyPayment: 3 });
|
||||
assert.deepEqual(empty.decision, pending.decision);
|
||||
const view = viewGame(JSON.parse(JSON.stringify(pending)), 'g');
|
||||
assert.equal('pendingShipment' in view, false);
|
||||
assert.equal('allyPayment' in view.decision!, false);
|
||||
assert.equal(view.players[0].hand, undefined);
|
||||
assert.throws(() => stop(empty), /Karama card/);
|
||||
assert.ok(permit(empty).players[0].shipped);
|
||||
});
|
||||
void test('invalid shipments and forged bypasses cannot spend the Guild power or alter state', () => {
|
||||
const before = fixture();
|
||||
const copy = structuredClone(before);
|
||||
for (const extra of [
|
||||
{ amount: 21 },
|
||||
{ elite: 5 },
|
||||
{ sector: 18 },
|
||||
{ allyPayment: 1 },
|
||||
])
|
||||
assert.throws(() => applyAction(before, 'e', { ...declaration, ...extra }));
|
||||
assert.deepEqual(before, copy);
|
||||
assert.throws(() => stop(before), /declaration/);
|
||||
const pending = applyAction(before, 'e', {
|
||||
...declaration,
|
||||
allow: true,
|
||||
skipGuild: true,
|
||||
});
|
||||
assert.equal(pending.decision?.kind, 'guildShipment');
|
||||
for (const [id, action] of [
|
||||
['e', { type: 'decision', allow: true }],
|
||||
['g', { type: 'decision', allow: false }],
|
||||
['e', { type: 'endMovement' }],
|
||||
['g', { type: 'card', mode: 'cancel', card: karmas[0].id }],
|
||||
] as const)
|
||||
assert.throws(() => applyAction(pending, id, action));
|
||||
assert.equal(pending.players[0].shipped, false);
|
||||
});
|
||||
void test('the special power remains used on later turns, while ordinary Karama remains available', () => {
|
||||
let g = stop(applyAction(fixture(), 'e', declaration));
|
||||
g.turn++;
|
||||
g.players[0].shipped = false;
|
||||
g.players[1].hand = [karmas[1]];
|
||||
g = applyAction(g, 'g', {
|
||||
type: 'card',
|
||||
mode: 'shipment',
|
||||
card: karmas[1].id,
|
||||
target: 'e',
|
||||
});
|
||||
g = applyAction(g, 'e', declaration);
|
||||
assert.equal(g.decision?.kind, 'advisor');
|
||||
assert.equal(g.players[0].forces['arrakeen:10'], 4);
|
||||
assert.equal(g.players[1].specialKaramaUsed, true);
|
||||
assert.throws(() => stop(g), /already been used/);
|
||||
});
|
||||
void test('basic play and Fremen southern reinforcements do not open Guild special decisions', () => {
|
||||
let g = fixture();
|
||||
g.advanced = false;
|
||||
const shipped = applyAction(g, 'e', declaration);
|
||||
assert.equal(shipped.pendingShipment, undefined);
|
||||
assert.equal(shipped.response, null);
|
||||
assert.equal(shipped.players[1].spice, 24);
|
||||
g = fixture();
|
||||
g.players[0].faction = 'fremen';
|
||||
g.players[0].elites = undefined;
|
||||
g = applyAction(g, 'e', {
|
||||
type: 'ship',
|
||||
territory: 'sietch_tabr',
|
||||
sector: 14,
|
||||
amount: 3,
|
||||
});
|
||||
assert.equal(g.decision, null);
|
||||
assert.equal(g.pendingShipment, undefined);
|
||||
assert.equal(g.players[0].spice, 20);
|
||||
assert.equal(g.players[0].forces['sietch_tabr:14'], 3);
|
||||
});
|
||||
void test('cross-planet shipments bypass the off-planet stop decision', () => {
|
||||
const before = fixture();
|
||||
before.players[0].ally = 'g';
|
||||
before.players[1].ally = 'e';
|
||||
before.players[0].forces = { 'arrakeen:10': 3 };
|
||||
before.players[0].reserves = 17;
|
||||
const g = applyAction(before, 'e', {
|
||||
type: 'guildShip',
|
||||
from: 'arrakeen:10',
|
||||
territory: 'polar_sink',
|
||||
sector: 0,
|
||||
amount: 2,
|
||||
});
|
||||
assert.notEqual(g.decision?.kind, 'guildShipment');
|
||||
assert.equal(g.players[0].forces['polar_sink:0'], 2);
|
||||
assert.equal(g.players[1].specialKaramaUsed, undefined);
|
||||
});
|
||||
void test('AI resolves the shipment window from its own hand at every difficulty, protecting allies', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const before = fixture();
|
||||
before.players[1].bot = difficulty;
|
||||
const pending = applyAction(before, 'e', declaration);
|
||||
const actions = botActions(viewGame(pending, 'g'));
|
||||
assert.ok(actions.length > 0, difficulty);
|
||||
const resolved = applyAction(pending, 'g', actions[0]);
|
||||
assert.equal(resolved.pendingShipment, null);
|
||||
const allied = structuredClone(pending);
|
||||
allied.players[1].ally = 'e';
|
||||
assert.deepEqual(botActions(viewGame(allied, 'g'))[0], {
|
||||
type: 'decision',
|
||||
allow: true,
|
||||
});
|
||||
const empty = structuredClone(pending);
|
||||
empty.players[1].hand = [];
|
||||
assert.deepEqual(botActions(viewGame(empty, 'g'))[0], {
|
||||
type: 'decision',
|
||||
allow: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
void test('a persisted allowed shipment resumes intrusion before accompanying advisor choice', () => {
|
||||
const before = fixture();
|
||||
before.players[0].hand = [karmas[1]];
|
||||
before.players[2].forces = { 'arrakeen:10': 1 };
|
||||
before.players[2].reserves = 19;
|
||||
const pending = applyAction(before, 'e', declaration);
|
||||
let g = permit(JSON.parse(JSON.stringify(pending)));
|
||||
assert.equal(g.response?.kind, 'guildIncome');
|
||||
assert.equal(g.decision?.kind, 'intrusion');
|
||||
g = responses(g);
|
||||
g = applyAction(g, 'b', { type: 'decision', accept: true });
|
||||
assert.equal(g.response?.kind, 'advisorFlip');
|
||||
g = responses(g);
|
||||
assert.equal(g.decision?.kind, 'advisor');
|
||||
assert.ok(g.players[2].advisors?.arrakeen);
|
||||
assert.equal(g.players[0].forces['arrakeen:10'], 4);
|
||||
assert.equal(g.players[1].spice, 24);
|
||||
});
|
||||
void test('a converted Worthless shipment-rate card is restored only after its conversion was allowed', () => {
|
||||
const before = fixture();
|
||||
const worthless = baseDeck().find((c) => c.kind === 'worthless')!;
|
||||
before.players[2].hand = [worthless];
|
||||
let g = applyAction(before, 'b', {
|
||||
type: 'card',
|
||||
mode: 'shipment',
|
||||
card: worthless.id,
|
||||
target: 'e',
|
||||
});
|
||||
assert.equal(g.response?.kind, 'worthlessKarama');
|
||||
assert.throws(() => applyAction(g, 'e', declaration), /response window/);
|
||||
g = responses(g);
|
||||
g = stop(applyAction(g, 'e', declaration));
|
||||
assert.deepEqual(g.players[2].hand, [worthless]);
|
||||
assert.equal(g.pendingKarama, null);
|
||||
assert.equal(
|
||||
g.discard.some((c) => c.id === worthless.id),
|
||||
false,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user