Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
@@ -0,0 +1,308 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { baseDeck, type Card } from '../game/cards';
|
||||
import {
|
||||
CHEAP_HERO_TRAITOR,
|
||||
matchingTraitor,
|
||||
traitorDeck,
|
||||
} from '../game/traitors';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
const heroes = baseDeck().filter((c) => c.kind === 'hero');
|
||||
/** Assign fixture cards by transferring their exact physical IDs from the deck. */
|
||||
function setHand(g: Game, index: number, selected: Card[]) {
|
||||
const owner = g.players[index];
|
||||
const ids = new Set(selected.map((c) => c.id));
|
||||
assert.equal(ids.size, selected.length);
|
||||
const held = new Map(owner.hand.map((c) => [c.id, c]));
|
||||
for (const previous of owner.hand)
|
||||
if (!ids.has(previous.id)) g.deck.push(previous);
|
||||
owner.hand = selected.map((wanted) => {
|
||||
if (held.has(wanted.id)) return held.get(wanted.id)!;
|
||||
assert.ok(
|
||||
!g.players.some(
|
||||
(p, i) => i !== index && p.hand.some((c) => c.id === wanted.id),
|
||||
),
|
||||
`Another player owns ${wanted.id}`,
|
||||
);
|
||||
const at = g.deck.findIndex((c) => c.id === wanted.id);
|
||||
assert.ok(at >= 0, `Fixture deck must contain ${wanted.id}`);
|
||||
return g.deck.splice(at, 1)[0];
|
||||
});
|
||||
}
|
||||
function fixture(harkonnen = false) {
|
||||
let g = createGame('HEROTRAITOR2', newPlayer('a', 'Atreides', 'atreides'));
|
||||
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
|
||||
if (harkonnen) joinGame(g, newPlayer('h', 'Harkonnen', '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.phase = 6;
|
||||
g.active = 'a';
|
||||
g.order = g.players.map((p) => p.id);
|
||||
g.storm = 18;
|
||||
// Return dealt cards before replacing setup hands for this battle fixture.
|
||||
g.deck.push(...g.players.flatMap((p) => p.hand));
|
||||
for (const p of g.players) {
|
||||
p.hand = [];
|
||||
p.traitors = [];
|
||||
p.spice = 20;
|
||||
p.forces = p.id === 'h' ? {} : { 'arrakeen:10': 5 };
|
||||
p.reserves = p.id === 'h' ? 20 : 15;
|
||||
}
|
||||
if (harkonnen) {
|
||||
g.players[0].ally = 'h';
|
||||
g.players[2].ally = 'a';
|
||||
}
|
||||
return g;
|
||||
}
|
||||
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 prepared(state: Game) {
|
||||
let g = allow(
|
||||
applyAction(state, 'a', {
|
||||
type: 'chooseBattle',
|
||||
territory: 'arrakeen',
|
||||
target: 'e',
|
||||
}),
|
||||
);
|
||||
while (g.battle?.preparation)
|
||||
g = applyAction(g, g.battle.preparation.owner, {
|
||||
type: 'declineBattlePower',
|
||||
});
|
||||
if (g.decision?.kind === 'fullPlanOffer')
|
||||
g = applyAction(g, g.decision.player, { type: 'decision', decline: true });
|
||||
return g;
|
||||
}
|
||||
function plans(state: Game, a: object = {}, e: object = {}) {
|
||||
const g = applyAction(prepared(state), 'a', {
|
||||
type: 'battlePlan',
|
||||
leader: 'atreides-0',
|
||||
dial: 0,
|
||||
...a,
|
||||
});
|
||||
return applyAction(g, 'e', {
|
||||
type: 'battlePlan',
|
||||
leader: heroes[0].id,
|
||||
dial: 5,
|
||||
...e,
|
||||
});
|
||||
}
|
||||
function call(g: Game, id: string, reveal: boolean) {
|
||||
return applyAction(g, id, { type: 'traitorCall', call: reveal });
|
||||
}
|
||||
void test('one expansion traitor identity matches either hero and adds no leader disc', () => {
|
||||
const g = fixture();
|
||||
assert.equal(traitorDeck(g.players).length, 10);
|
||||
assert.equal(traitorDeck(g.players, true).length, 11);
|
||||
assert.equal(
|
||||
traitorDeck(g.players, true).filter((id) => id === CHEAP_HERO_TRAITOR)
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
for (const c of heroes)
|
||||
assert.equal(
|
||||
matchingTraitor([CHEAP_HERO_TRAITOR], c.id, c),
|
||||
CHEAP_HERO_TRAITOR,
|
||||
);
|
||||
assert.equal(matchingTraitor([CHEAP_HERO_TRAITOR], 'emperor-0'), undefined);
|
||||
assert.equal(
|
||||
g.players
|
||||
.flatMap((p) => p.leaders)
|
||||
.some((l) => l.id === CHEAP_HERO_TRAITOR),
|
||||
false,
|
||||
);
|
||||
});
|
||||
void test('dealt Cheap Hero traitor is selectable and private during setup', () => {
|
||||
let g = fixture();
|
||||
g.status = 'setup';
|
||||
g.players[0].traitorChoices = [
|
||||
CHEAP_HERO_TRAITOR,
|
||||
'emperor-0',
|
||||
'emperor-1',
|
||||
'emperor-2',
|
||||
];
|
||||
g = applyAction(g, 'a', { type: 'traitor', leader: CHEAP_HERO_TRAITOR });
|
||||
assert.deepEqual(viewGame(g, 'a').players[0].traitors, [CHEAP_HERO_TRAITOR]);
|
||||
assert.equal(viewGame(g, 'e').players[0].traitors, undefined);
|
||||
assert.deepEqual(viewGame(g, 'e').players[0].revealedTraitors, []);
|
||||
});
|
||||
for (const hero of heroes)
|
||||
void test(`${hero.name} loses to its shared traitor with zero bounty and no dead leader disc`, () => {
|
||||
const before = fixture();
|
||||
setHand(before, 1, [hero]);
|
||||
before.players[0].traitors = [CHEAP_HERO_TRAITOR, 'emperor-1'];
|
||||
let g = plans(before, {}, { leader: hero.id });
|
||||
g = call(g, 'a', true);
|
||||
assert.deepEqual(viewGame(g, 'e').players[0].revealedTraitors, []);
|
||||
g = call(JSON.parse(JSON.stringify(g)), 'e', false);
|
||||
assert.equal(g.players[0].forces['arrakeen:10'], 5);
|
||||
assert.equal(g.players[1].tanks, 5);
|
||||
assert.equal(g.players[0].spice, 20);
|
||||
assert.ok(g.players.every((p) => p.leaders.every((l) => !l.dead)));
|
||||
assert.ok(g.discard.some((c) => c.id === hero.id));
|
||||
assert.deepEqual(g.players[0].traitors, [CHEAP_HERO_TRAITOR, 'emperor-1']);
|
||||
for (const p of g.players)
|
||||
assert.deepEqual(viewGame(g, p.id).players[0].revealedTraitors, [
|
||||
CHEAP_HERO_TRAITOR,
|
||||
]);
|
||||
assert.equal(viewGame(g, 'e').players[0].traitors, undefined);
|
||||
});
|
||||
void test('repeated hero traitor use retains one public identity', () => {
|
||||
const before = fixture();
|
||||
before.players[0].revealedTraitors = [CHEAP_HERO_TRAITOR];
|
||||
before.players[0].traitors = [CHEAP_HERO_TRAITOR];
|
||||
setHand(before, 1, [heroes[1]]);
|
||||
const g = call(
|
||||
call(plans(before, {}, { leader: heroes[1].id }), 'a', true),
|
||||
'e',
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(g.players[0].revealedTraitors, [CHEAP_HERO_TRAITOR]);
|
||||
assert.deepEqual(g.players[0].traitors, [CHEAP_HERO_TRAITOR]);
|
||||
});
|
||||
void test('wrong traitor and ordinary leader targets are rejected without mutation', () => {
|
||||
const before = fixture();
|
||||
setHand(before, 1, [heroes[0]]);
|
||||
let g = plans(before);
|
||||
const snapshot = structuredClone(g);
|
||||
assert.throws(() => call(g, 'a', true), /do not hold/);
|
||||
assert.deepEqual(g, snapshot);
|
||||
before.players[0].traitors = [CHEAP_HERO_TRAITOR];
|
||||
g = plans(before, {}, { leader: 'emperor-0' });
|
||||
assert.throws(() => call(g, 'a', true), /do not hold/);
|
||||
});
|
||||
void test('Kwisatz Haderach protects an accompanying Cheap Hero against the shared traitor', () => {
|
||||
const before = fixture();
|
||||
before.advanced = true;
|
||||
before.players[0].battleLosses = 7;
|
||||
setHand(before, 0, [heroes[0]]);
|
||||
before.players[1].traitors = [CHEAP_HERO_TRAITOR];
|
||||
const g = plans(
|
||||
before,
|
||||
{ leader: heroes[0].id, kwisatz: true },
|
||||
{ leader: 'emperor-0', dial: 0 },
|
||||
);
|
||||
assert.throws(() => call(g, 'e', true), /Kwisatz Haderach/);
|
||||
});
|
||||
void test('Harkonnen can call the hero traitor for an ally after the Karama response', () => {
|
||||
const before = fixture(true);
|
||||
setHand(before, 1, [
|
||||
heroes[0],
|
||||
baseDeck().find((c) => c.effect === 'karama')!,
|
||||
]);
|
||||
before.players[2].traitors = [CHEAP_HERO_TRAITOR];
|
||||
let g = call(call(plans(before), 'a', false), 'e', false);
|
||||
g = call(g, 'h', true);
|
||||
assert.equal(g.response?.kind, 'harkonnenTraitor');
|
||||
assert.equal(g.players[1].tanks, 0);
|
||||
g = allow(JSON.parse(JSON.stringify(g)));
|
||||
assert.equal(g.players[1].tanks, 5);
|
||||
assert.equal(g.players[0].spice, 20);
|
||||
assert.deepEqual(g.players[2].revealedTraitors, [CHEAP_HERO_TRAITOR]);
|
||||
});
|
||||
void test('Karama cancels the Harkonnen ally call before the battle resolves', () => {
|
||||
const before = fixture(true);
|
||||
const karama = baseDeck().find((c) => c.effect === 'karama')!;
|
||||
setHand(before, 1, [heroes[0], karama]);
|
||||
before.players[2].traitors = [CHEAP_HERO_TRAITOR];
|
||||
let g = call(
|
||||
call(call(plans(before, {}, { dial: 0 }), 'a', false), 'e', false),
|
||||
'h',
|
||||
true,
|
||||
);
|
||||
g = applyAction(g, 'e', { type: 'card', card: karama.id, mode: 'cancel' });
|
||||
assert.deepEqual(g.players[2].revealedTraitors ?? [], []);
|
||||
assert.deepEqual(g.players[2].traitors, [CHEAP_HERO_TRAITOR]);
|
||||
assert.equal(g.response, null);
|
||||
});
|
||||
void test('simultaneous hero and leader traitors destroy both armies', () => {
|
||||
const before = fixture();
|
||||
setHand(before, 1, [heroes[0]]);
|
||||
before.players[0].traitors = [CHEAP_HERO_TRAITOR];
|
||||
before.players[1].traitors = ['atreides-0'];
|
||||
const g = call(call(plans(before), 'a', true), 'e', true);
|
||||
assert.equal(g.players[0].tanks, 5);
|
||||
assert.equal(g.players[1].tanks, 5);
|
||||
assert.equal(g.players[0].leaders[0].dead, true);
|
||||
assert.ok(g.players[1].leaders.every((l) => !l.dead));
|
||||
assert.deepEqual(g.players[1].revealedTraitors, ['atreides-0']);
|
||||
});
|
||||
void test('all bot levels can select the new traitor and call it from their private view', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const before = fixture();
|
||||
before.players[0].bot = difficulty;
|
||||
const setup = structuredClone(before);
|
||||
setup.status = 'setup';
|
||||
setup.players[0].traitorChoices = [CHEAP_HERO_TRAITOR];
|
||||
assert.ok(
|
||||
botActions(viewGame(setup, 'a')).some(
|
||||
(a) => a.type === 'traitor' && a.leader === CHEAP_HERO_TRAITOR,
|
||||
),
|
||||
);
|
||||
before.players[0].traitors = [CHEAP_HERO_TRAITOR];
|
||||
setHand(before, 1, [heroes[0]]);
|
||||
const g = plans(before);
|
||||
assert.ok(
|
||||
botActions(viewGame(g, 'a')).some(
|
||||
(a) => a.type === 'traitorCall' && a.call === true,
|
||||
),
|
||||
difficulty,
|
||||
);
|
||||
}
|
||||
});
|
||||
void test('Hard and Brutal avoid a publicly exposed leader held by the opponent or its Harkonnen ally', () => {
|
||||
for (const difficulty of ['Hard', 'Brutal'] as const)
|
||||
for (const alliedHolder of [false, true]) {
|
||||
const before = fixture(alliedHolder);
|
||||
before.players[0].bot = difficulty;
|
||||
if (alliedHolder) {
|
||||
before.players[0].ally = null;
|
||||
before.players[1].ally = 'h';
|
||||
before.players[2].ally = 'e';
|
||||
}
|
||||
const strongest = [...before.players[0].leaders].sort(
|
||||
(a, b) => b.strength - a.strength,
|
||||
)[0];
|
||||
before.players[alliedHolder ? 2 : 1].revealedTraitors = [strongest.id];
|
||||
const g = prepared(before);
|
||||
const choices = botActions(viewGame(g, 'a')).filter(
|
||||
(a) => a.type === 'battlePlan',
|
||||
);
|
||||
assert.ok(choices.length > 0);
|
||||
assert.notEqual(
|
||||
choices[0].leader,
|
||||
strongest.id,
|
||||
`${difficulty}: ${alliedHolder}`,
|
||||
);
|
||||
assert.doesNotThrow(() => applyAction(g, 'a', choices[0]));
|
||||
// Unrevealed holdings cannot influence the personalized policy.
|
||||
const privateChanged = structuredClone(g);
|
||||
privateChanged.players[1].traitors = ['atreides-1'];
|
||||
assert.deepEqual(
|
||||
botActions(viewGame(privateChanged, 'a')),
|
||||
botActions(viewGame(g, 'a')),
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user