Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
@@ -0,0 +1,295 @@
|
||||
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 { TERRITORIES } from '../game/board';
|
||||
import { quoteIxSubstitutionCancellation } from '../game/ix-substitution-cancellation';
|
||||
|
||||
function passAll(state: Game) {
|
||||
let g = state;
|
||||
for (let n = 0; g.response && n < 20; n++) {
|
||||
const p = g.players.find((p) => !g.response!.passed.includes(p.id));
|
||||
assert.ok(p);
|
||||
g = applyAction(g, p.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(g.response, null);
|
||||
return g;
|
||||
}
|
||||
function actual(cards = true, advanced = true) {
|
||||
let g = createGame('IXCANCELQA', newPlayer('i', 'Ix', 'ixians'), advanced);
|
||||
g.players.push(
|
||||
newPlayer('e', 'Emperor', 'emperor'),
|
||||
newPlayer('b', 'BG', 'beneGesserit'),
|
||||
);
|
||||
Object.assign(g, {
|
||||
status: 'playing',
|
||||
phase: 6,
|
||||
turn: 2,
|
||||
active: 'i',
|
||||
order: ['i', 'e', 'b'],
|
||||
storm: 18,
|
||||
phaseOpening: null,
|
||||
deck: baseDeck(),
|
||||
discard: [],
|
||||
});
|
||||
for (const p of g.players) {
|
||||
p.hand = [];
|
||||
p.traitors = [];
|
||||
p.forces = p.id === 'b' ? {} : { 'arrakeen:10': 8 };
|
||||
p.reserves = p.id === 'b' ? 20 : 12;
|
||||
p.spice = 20;
|
||||
}
|
||||
g.players[0].elites = {
|
||||
reserves: 5,
|
||||
tanks: 0,
|
||||
forces: { 'arrakeen:10': 2 },
|
||||
revived: 0,
|
||||
};
|
||||
g.players[1].elites = undefined;
|
||||
const take = (player: number, pred: (c: Game['deck'][number]) => boolean) => {
|
||||
const index = g.deck.findIndex(pred);
|
||||
assert.ok(index >= 0);
|
||||
const card = g.deck.splice(index, 1)[0];
|
||||
g.players[player].hand.push(card);
|
||||
return card;
|
||||
};
|
||||
const printed = take(1, (c) => c.effect === 'karama');
|
||||
const bg = take(2, (c) => c.kind === 'worthless');
|
||||
const shield = cards ? take(0, (c) => c.kind === 'shield') : undefined;
|
||||
g = passAll(
|
||||
applyAction(g, 'i', {
|
||||
type: 'chooseBattle',
|
||||
territory: 'arrakeen',
|
||||
target: 'e',
|
||||
}),
|
||||
);
|
||||
g = applyAction(g, 'i', {
|
||||
type: 'battlePlan',
|
||||
leader: 'ixians-1',
|
||||
dial: 6,
|
||||
support: advanced ? 2 : 0,
|
||||
defense: shield?.id,
|
||||
});
|
||||
g = applyAction(g, 'e', { type: 'battlePlan', leader: 'emperor-4', dial: 0 });
|
||||
g = applyAction(g, 'i', { type: 'traitorCall', call: false });
|
||||
g = applyAction(g, 'e', { type: 'traitorCall', call: false });
|
||||
if (g.decision?.kind === 'battleLosses') {
|
||||
const choice = g.decision.options.findIndex(
|
||||
(c) => c.normal === 4 && c.elite === 2,
|
||||
);
|
||||
assert.ok(choice >= 0);
|
||||
g = applyAction(g, 'i', { type: 'decision', choice });
|
||||
}
|
||||
assert.equal(g.decision?.kind, 'ixSubstitution');
|
||||
g = applyAction(g, 'i', {
|
||||
type: 'decision',
|
||||
sources: { 'arrakeen:10': 1 },
|
||||
recover: { 'arrakeen:10': 1 },
|
||||
});
|
||||
assert.equal(g.response?.kind, 'ixSubstitution');
|
||||
return { g, printed, bg, shield };
|
||||
}
|
||||
function quote(g: Game) {
|
||||
return quoteIxSubstitutionCancellation(
|
||||
{
|
||||
...g,
|
||||
territories: TERRITORIES,
|
||||
physicalCards: [
|
||||
...g.players.flatMap((p) => p.hand),
|
||||
...g.deck,
|
||||
...g.discard,
|
||||
],
|
||||
},
|
||||
g.response!,
|
||||
);
|
||||
}
|
||||
for (const form of ['printed', 'bg'] as const) {
|
||||
void test(`actual ${form} denied substitution preserves casualties and opens the winner's remaining card choice`, () => {
|
||||
const f = actual();
|
||||
let g = f.g;
|
||||
const material = g.players.map((p) => [
|
||||
p.forces,
|
||||
p.tanks,
|
||||
p.elites,
|
||||
p.spice,
|
||||
p.battleLosses,
|
||||
]);
|
||||
const actor = form === 'printed' ? 'e' : 'b';
|
||||
g = applyAction(g, actor, {
|
||||
type: 'card',
|
||||
card: f[form].id,
|
||||
mode: 'cancel',
|
||||
});
|
||||
g = passAll(JSON.parse(JSON.stringify(g)) as Game);
|
||||
assert.equal(g.pendingIxSubstitution, null);
|
||||
assert.deepEqual(
|
||||
g.players.map((p) => [
|
||||
p.forces,
|
||||
p.tanks,
|
||||
p.elites,
|
||||
p.spice,
|
||||
p.battleLosses,
|
||||
]),
|
||||
material,
|
||||
);
|
||||
assert.deepEqual(g.decision, {
|
||||
kind: 'battleCards',
|
||||
player: 'i',
|
||||
territory: 'arrakeen',
|
||||
cards: [f.shield!.id],
|
||||
});
|
||||
assert.equal(g.discard.filter((c) => c.id === f[form].id).length, 1);
|
||||
assert.equal(viewGame(g, 'b').players[0].hand, undefined);
|
||||
g = applyAction(g, 'i', { type: 'decision', discard: [] });
|
||||
assert.equal(g.phase, 7);
|
||||
});
|
||||
void test(`malformed ${form} substitution sources and winner cleanup reject before paying Karama`, (t) => {
|
||||
const f = actual();
|
||||
const rng = t.mock.method(Math, 'random', () => {
|
||||
throw Error('No random');
|
||||
});
|
||||
const uuid = t.mock.method(crypto, 'randomUUID', () => {
|
||||
throw Error('No events');
|
||||
});
|
||||
const mutations: ((g: Game) => void)[] = [
|
||||
(g) => {
|
||||
g.pendingIxSubstitution!.player = 'e';
|
||||
},
|
||||
(g) => {
|
||||
g.pendingIxSubstitution!.territory = 'carthag';
|
||||
},
|
||||
(g) => {
|
||||
g.pendingIxSubstitution!.sources = { 'arrakeen:11': 1 };
|
||||
},
|
||||
(g) => {
|
||||
g.pendingIxSubstitution!.recover = { 'arrakeen:10': 3 };
|
||||
},
|
||||
(g) => {
|
||||
g.pendingIxSubstitution!.sources = { 'arrakeen:10': 0 };
|
||||
},
|
||||
(g) => {
|
||||
g.pendingIxSubstitution!.cards = ['absent'];
|
||||
},
|
||||
(g) => {
|
||||
g.pendingIxSubstitution!.cards.push(f.shield!.id);
|
||||
},
|
||||
(g) => {
|
||||
g.lastBattleContext!.winner = 'e';
|
||||
},
|
||||
(g) => {
|
||||
g.lastBattle = ['i', 'missing'];
|
||||
},
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const g = structuredClone(f.g);
|
||||
mutate(g);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() =>
|
||||
applyAction(g, form === 'printed' ? 'e' : 'b', {
|
||||
type: 'card',
|
||||
card: f[form].id,
|
||||
mode: 'cancel',
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
assert.equal(rng.mock.callCount(), 0);
|
||||
assert.equal(uuid.mock.callCount(), 0);
|
||||
});
|
||||
}
|
||||
void test('a canceled exchange does not require suboids, cyborg tanks, or spice that the denied exchange would consume', () => {
|
||||
const f = actual();
|
||||
const g = f.g;
|
||||
g.players[0].forces = {};
|
||||
g.players[0].elites!.tanks = 0;
|
||||
g.players[0].tanks = 0;
|
||||
g.players[0].spice = 0;
|
||||
const before = structuredClone(g);
|
||||
const q = quote(g);
|
||||
assert.equal(q?.decision?.kind, 'battleCards');
|
||||
assert.deepEqual(g, before);
|
||||
const done = applyAction(g, 'e', {
|
||||
type: 'card',
|
||||
card: f.printed.id,
|
||||
mode: 'cancel',
|
||||
});
|
||||
assert.deepEqual(done.players[0].forces, {});
|
||||
assert.equal(done.players[0].tanks, 0);
|
||||
});
|
||||
void test('empty winner-card cleanup validates its immediate aftermath before a new BG cost', () => {
|
||||
const f = actual(false);
|
||||
const bad = structuredClone(f.g);
|
||||
bad.pendingCapture = { player: 'i', loser: 'absent', territory: 'arrakeen' };
|
||||
const before = structuredClone(bad);
|
||||
assert.throws(() =>
|
||||
applyAction(bad, 'b', { type: 'card', card: f.bg.id, mode: 'cancel' }),
|
||||
);
|
||||
assert.deepEqual(bad, before);
|
||||
const good = applyAction(f.g, 'e', {
|
||||
type: 'card',
|
||||
card: f.printed.id,
|
||||
mode: 'cancel',
|
||||
});
|
||||
assert.equal(good.pendingIxSubstitution, null);
|
||||
assert.equal(good.phase, 7);
|
||||
});
|
||||
void test('legacy Basic substitution receipts retain declared battle identities without allocating a replacement event', (t) => {
|
||||
const f = actual(true, false);
|
||||
delete f.g.lastBattleContext;
|
||||
const before = structuredClone(f.g);
|
||||
const uuid = t.mock.method(crypto, 'randomUUID', () => {
|
||||
throw Error('No events');
|
||||
});
|
||||
assert.equal(quote(f.g)?.context.kind, 'legacy');
|
||||
assert.deepEqual(f.g, before);
|
||||
const done = applyAction(f.g, 'e', {
|
||||
type: 'card',
|
||||
card: f.printed.id,
|
||||
mode: 'cancel',
|
||||
});
|
||||
assert.equal(done.decision?.kind, 'battleCards');
|
||||
assert.equal(uuid.mock.callCount(), 0);
|
||||
});
|
||||
void test('paid BG substitution cancellation rejects a damaged cleanup on recovery without spending a second card', () => {
|
||||
const f = actual();
|
||||
const paid = applyAction(f.g, 'b', {
|
||||
type: 'card',
|
||||
card: f.bg.id,
|
||||
mode: 'cancel',
|
||||
});
|
||||
assert.equal(paid.response?.kind, 'worthlessKarama');
|
||||
const bad = JSON.parse(JSON.stringify(paid)) as Game;
|
||||
bad.lastBattleContext!.winner = 'e';
|
||||
const before = structuredClone(bad);
|
||||
assert.throws(() => applyAction(bad, 'e', { type: 'passResponse' }));
|
||||
assert.deepEqual(bad, before);
|
||||
assert.equal(bad.discard.filter((c) => c.id === f.bg.id).length, 1);
|
||||
const done = passAll(JSON.parse(JSON.stringify(paid)) as Game);
|
||||
assert.equal(done.decision?.kind, 'battleCards');
|
||||
assert.equal(done.discard.filter((c) => c.id === f.bg.id).length, 1);
|
||||
});
|
||||
void test('paid BG cancellation with no retained winner cards continues through cleanup exactly once after reload', () => {
|
||||
const f = actual(false);
|
||||
const paid = applyAction(f.g, 'b', {
|
||||
type: 'card',
|
||||
card: f.bg.id,
|
||||
mode: 'cancel',
|
||||
});
|
||||
assert.equal(paid.response?.kind, 'worthlessKarama');
|
||||
const done = passAll(JSON.parse(JSON.stringify(paid)) as Game);
|
||||
assert.equal(done.phase, 7);
|
||||
assert.equal(done.pendingIxSubstitution, null);
|
||||
assert.equal(done.players[0].elites!.tanks, 2);
|
||||
assert.equal(done.players[0].tanks, 6);
|
||||
assert.equal(done.players[0].forces['arrakeen:10'], 2);
|
||||
assert.equal(done.discard.filter((c) => c.id === f.bg.id).length, 1);
|
||||
const before = structuredClone(done);
|
||||
assert.throws(() => applyAction(done, 'e', { type: 'passResponse' }));
|
||||
assert.deepEqual(done, before);
|
||||
});
|
||||
Reference in new issue
Block a user