Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
@@ -0,0 +1,171 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
|
||||
function fixture(): Game {
|
||||
const g = createGame(
|
||||
'RETAINBOT',
|
||||
newPlayer('m', 'Moritani', 'moritani'),
|
||||
true,
|
||||
);
|
||||
g.players.push(
|
||||
newPlayer('b', 'Defeated ally', 'beneGesserit'),
|
||||
newPlayer('e', 'Enemy', 'emperor'),
|
||||
);
|
||||
g.status = 'playing';
|
||||
g.phase = 6;
|
||||
g.turn = 2;
|
||||
g.order = ['m', 'b', 'e'];
|
||||
// This fixture starts after b lost its battle against e.
|
||||
g.lastBattle = ['b', 'e'];
|
||||
g.players[0].ally = 'b';
|
||||
g.players[1].ally = 'm';
|
||||
const deck = baseDeck();
|
||||
const shield = deck.find((c) => c.kind === 'shield')!;
|
||||
const worthless = deck.find((c) => c.kind === 'worthless')!;
|
||||
const unplayed = deck.find((c) => c.effect === 'karama')!;
|
||||
g.players[1].hand = [shield, worthless, unplayed];
|
||||
g.moritaniRetention = {
|
||||
owner: 'm',
|
||||
player: 'b',
|
||||
territory: 'arrakeen',
|
||||
turn: g.turn,
|
||||
played: [shield.id, worthless.id],
|
||||
eligible: [shield.id, worthless.id],
|
||||
stage: 'choose',
|
||||
};
|
||||
g.decision = {
|
||||
kind: 'moritaniRetention',
|
||||
owner: 'm',
|
||||
player: 'b',
|
||||
territory: 'arrakeen',
|
||||
cards: [...g.moritaniRetention.eligible],
|
||||
};
|
||||
return g;
|
||||
}
|
||||
function bots(g: Game, id: string, difficulty: (typeof DIFFICULTIES)[number]) {
|
||||
const copy = structuredClone(g);
|
||||
copy.players.find((p) => p.id === id)!.bot = difficulty;
|
||||
return botActions(viewGame(copy, id));
|
||||
}
|
||||
|
||||
void test('every profile lets only the defeated ally choose an eligible card from its own hand', () => {
|
||||
const g = fixture();
|
||||
const before = structuredClone(g);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
assert.deepEqual(bots(g, 'm', difficulty), [], difficulty);
|
||||
assert.deepEqual(bots(g, 'e', difficulty), [], difficulty);
|
||||
const actions = bots(g, 'b', difficulty);
|
||||
assert.equal(actions.length, 1, difficulty);
|
||||
const chosen = actions[0];
|
||||
assert.equal(chosen.type, 'decision');
|
||||
assert.ok(g.moritaniRetention!.eligible.includes(String(chosen.keep)));
|
||||
if (difficulty !== 'Easy')
|
||||
assert.equal(chosen.keep, g.players[1].hand[0].id, difficulty);
|
||||
const next = applyAction(g, 'b', chosen);
|
||||
assert.equal(next.response?.kind, 'moritaniRetention');
|
||||
assert.equal(next.response?.owner, 'm');
|
||||
assert.equal(next.moritaniRetention?.keep, chosen.keep);
|
||||
assert.deepEqual(
|
||||
next.players[1].hand,
|
||||
g.players[1].hand,
|
||||
'selection reserves cards until response settlement',
|
||||
);
|
||||
}
|
||||
assert.deepEqual(g, before);
|
||||
});
|
||||
|
||||
void test('retention response actions remain legal for all seats and profiles after JSON reload', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let g = fixture();
|
||||
g = applyAction(g, 'b', bots(g, 'b', difficulty)[0]);
|
||||
const chosen = g.moritaniRetention!.keep!;
|
||||
for (const id of ['m', 'e', 'b']) {
|
||||
g = JSON.parse(JSON.stringify(g));
|
||||
const actions = bots(g, id, difficulty);
|
||||
assert.deepEqual(
|
||||
actions,
|
||||
id === 'b' ? [{ type: 'passResponse' }] : [],
|
||||
`${difficulty}: ${id}`,
|
||||
);
|
||||
if (actions.length) g = applyAction(g, id, actions[0]);
|
||||
}
|
||||
assert.equal(g.moritaniRetention ?? null, null);
|
||||
assert.ok(g.players[1].hand.some((c) => c.id === chosen));
|
||||
const discarded = fixture().moritaniRetention!.played.filter(
|
||||
(id) => id !== chosen,
|
||||
);
|
||||
assert.ok(discarded.every((id) => g.discard.some((c) => c.id === id)));
|
||||
}
|
||||
});
|
||||
|
||||
void test('missing entitled cards produce a null choice while the server rejects invalid custody atomically', () => {
|
||||
const g = fixture();
|
||||
g.players[1].hand = [];
|
||||
const before = structuredClone(g);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
assert.deepEqual(
|
||||
bots(g, 'b', difficulty),
|
||||
[{ type: 'decision', keep: null }],
|
||||
difficulty,
|
||||
);
|
||||
assert.throws(() => applyAction(g, 'b', { type: 'decision', keep: null }));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('an empty eligible decision falls back to decline without choosing an unplayed card', () => {
|
||||
const g = fixture();
|
||||
g.moritaniRetention!.eligible = [];
|
||||
assert.equal(g.decision?.kind, 'moritaniRetention');
|
||||
if (g.decision?.kind === 'moritaniRetention') g.decision.cards = [];
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
assert.deepEqual(
|
||||
bots(g, 'b', difficulty),
|
||||
[{ type: 'decision', keep: null }],
|
||||
difficulty,
|
||||
);
|
||||
const next = applyAction(g, 'b', { type: 'decision', keep: null });
|
||||
assert.equal(next.moritaniRetention ?? null, null);
|
||||
assert.ok(
|
||||
g.moritaniRetention!.played.every((id) =>
|
||||
next.discard.some((card) => card.id === id),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
void test('reserved played BG worthless faces cannot fund a Karama response; a separate card still can', () => {
|
||||
// Isolate selection while a retention reservation coexists with an enemy response.
|
||||
// The engine's continuation orders retention first in ordinary battle resolution.
|
||||
const g = fixture();
|
||||
const worthless = g.players[1].hand.find((c) => c.kind === 'worthless')!;
|
||||
const unplayed = g.players[1].hand.find((c) => c.effect === 'karama')!;
|
||||
g.players[1].hand = [worthless];
|
||||
g.decision = null;
|
||||
g.moritaniRetention!.stage = 'response';
|
||||
g.moritaniRetention!.keep = worthless.id;
|
||||
g.response = { kind: 'harkonnenBonus', owner: 'e', passed: [] };
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
assert.deepEqual(bots(g, 'b', difficulty), [], difficulty);
|
||||
}
|
||||
g.players[1].hand.push(unplayed);
|
||||
assert.deepEqual(bots(g, 'b', 'Brutal'), [
|
||||
{ type: 'card', card: unplayed.id, mode: 'cancel' },
|
||||
]);
|
||||
g.moritaniRetention = null;
|
||||
assert.deepEqual(
|
||||
bots(g, 'b', 'Brutal'),
|
||||
[{ type: 'card', card: worthless.id, mode: 'cancel' }],
|
||||
'reservation release restores ordinary Karama eligibility',
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user