Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
@@ -0,0 +1,439 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
newPlayer,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { baseDeck, type Card } from '../game/cards';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
|
||||
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
||||
const cards = baseDeck();
|
||||
const karamas = cards.filter((c) => c.effect === 'karama');
|
||||
const worthless = cards.filter((c) => c.kind === 'worthless');
|
||||
const shield = cards.find((c) => c.kind === 'shield')!;
|
||||
function fixture(
|
||||
factions: FactionId[] = ['guild', 'emperor', 'beneGesserit'],
|
||||
advanced = true,
|
||||
) {
|
||||
const g = createGame(
|
||||
'AUTORESP',
|
||||
newPlayer('a', 'First', factions[0]),
|
||||
advanced,
|
||||
);
|
||||
factions
|
||||
.slice(1)
|
||||
.forEach((f, i) =>
|
||||
g.players.push(newPlayer(String.fromCharCode(98 + i), f, f)),
|
||||
);
|
||||
g.status = 'playing';
|
||||
g.phase = 5;
|
||||
g.turn = 2;
|
||||
g.order = g.players.map((p) => p.id);
|
||||
g.active = 'a';
|
||||
for (const p of g.players) {
|
||||
p.hand = [];
|
||||
p.spice = 10;
|
||||
}
|
||||
g.response = { kind: 'guildIncome', owner: 'a', amount: 3, passed: [] };
|
||||
return g;
|
||||
}
|
||||
function auction() {
|
||||
const g = fixture(['harkonnen', 'emperor', 'guild']);
|
||||
g.phase = 3;
|
||||
g.auction = {
|
||||
cards: [shield],
|
||||
index: 0,
|
||||
bid: 4,
|
||||
bidder: 'a',
|
||||
active: 'a',
|
||||
passed: ['b', 'c'],
|
||||
opener: 0,
|
||||
peekKnown: false,
|
||||
};
|
||||
g.players[0].hand = [shield];
|
||||
g.deck = [worthless[0], worthless[1]];
|
||||
g.response = { kind: 'emperorIncome', owner: 'b', passed: [] };
|
||||
return g;
|
||||
}
|
||||
function battle(g: Game) {
|
||||
g.phase = 6;
|
||||
g.players[1].ally = 'a';
|
||||
g.players[0].ally = 'b';
|
||||
if (g.players[1].faction === 'emperor')
|
||||
g.response = {
|
||||
kind: 'emperorGift',
|
||||
owner: 'b',
|
||||
recipient: 'a',
|
||||
amount: 3,
|
||||
passed: [],
|
||||
};
|
||||
g.battle = {
|
||||
territory: 'arrakeen',
|
||||
attacker: 'a',
|
||||
defender: 'c',
|
||||
prepared: true,
|
||||
plans: {},
|
||||
revealed: false,
|
||||
traitorCalls: {},
|
||||
};
|
||||
for (const p of [g.players[0], g.players[2]]) {
|
||||
p.forces = { 'arrakeen:10': 6 };
|
||||
p.reserves = 14;
|
||||
}
|
||||
}
|
||||
function ids(g: Game, who: string) {
|
||||
return viewGame(g, who).responseControls!.cancelCards;
|
||||
}
|
||||
function cancel(g: Game, who: string, card: Card) {
|
||||
return applyAction(g, who, { type: 'card', mode: 'cancel', card: card.id });
|
||||
}
|
||||
|
||||
void test('uncancelable income and Harkonnen bonus drain once; JSON normalization and projection never replay draw/events', () => {
|
||||
const initial = auction();
|
||||
const snapshot = JSON.stringify(initial);
|
||||
const g = normalizeAutomaticGame(reload(initial));
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(g.players[1].spice, 14);
|
||||
assert.deepEqual(
|
||||
g.players[0].hand.map((c) => c.id),
|
||||
[shield.id, worthless[0].id],
|
||||
);
|
||||
assert.equal(g.deck.length, 1);
|
||||
assert.deepEqual(
|
||||
g.log.filter((e) => e.automatic).map((e) => e.automatic!.name),
|
||||
['Auction income', 'Bonus treachery card'],
|
||||
);
|
||||
assert.equal(JSON.stringify(initial), snapshot);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
|
||||
for (const p of g.players) viewGame(g, p.id);
|
||||
assert.deepEqual(normalizeAutomaticGame(g), g);
|
||||
});
|
||||
|
||||
void test('chain stops at a real later choice, including an eligible allied opponent', () => {
|
||||
const g = auction();
|
||||
g.players[1].hand = [karamas[0]];
|
||||
g.players[1].ally = 'a';
|
||||
g.players[0].ally = 'b';
|
||||
const waiting = normalizeAutomaticGame(g);
|
||||
assert.equal(waiting.players[1].spice, 14);
|
||||
assert.equal(waiting.response?.kind, 'harkonnenBonus');
|
||||
assert.deepEqual(ids(waiting, 'b'), [karamas[0].id]);
|
||||
assert.equal(waiting.players[0].hand.length, 1);
|
||||
const done = applyAction(waiting, 'b', { type: 'passResponse' });
|
||||
assert.equal(done.response, null);
|
||||
assert.equal(done.players[0].hand.length, 2);
|
||||
});
|
||||
|
||||
void test('viewer controls and passed IDs disclose only that viewer; prior explicit passer retains late cancellation', () => {
|
||||
let g = fixture();
|
||||
g.players[1].hand = [karamas[0]];
|
||||
g.players[2].hand = [karamas[1]];
|
||||
g = applyAction(g, 'b', { type: 'passResponse' });
|
||||
assert.deepEqual(g.response!.passed, ['b']);
|
||||
for (const p of g.players) {
|
||||
const view = viewGame(g, p.id);
|
||||
assert.deepEqual(view.response!.passed, p.id === 'b' ? ['b'] : []);
|
||||
assert.equal(view.responseControls!.hasPassed, p.id === 'b');
|
||||
assert.deepEqual(
|
||||
view.responseControls!.cancelCards,
|
||||
p.hand.map((c) => c.id),
|
||||
);
|
||||
for (const other of view.players.filter((other) => other.id !== p.id))
|
||||
assert.equal('hand' in other, false);
|
||||
}
|
||||
assert.deepEqual(ids(g, 'b'), [karamas[0].id]);
|
||||
const canceled = cancel(g, 'b', karamas[0]);
|
||||
assert.equal(canceled.response, null);
|
||||
assert.equal(canceled.players[0].spice, 10);
|
||||
assert.equal(
|
||||
canceled.discard.filter((c) => c.id === karamas[0].id).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
void test('Basic BG Worthless auto-allows; Advanced BG retains the actual conversion choice', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const g = fixture(undefined, advanced);
|
||||
g.players[2].hand = [worthless[0]];
|
||||
const next = normalizeAutomaticGame(g);
|
||||
assert.equal(!!next.response, advanced);
|
||||
assert.equal(next.players[0].spice, advanced ? 10 : 13);
|
||||
if (advanced) assert.deepEqual(ids(next, 'c'), [worthless[0].id]);
|
||||
}
|
||||
});
|
||||
|
||||
void test('retention, sealed plans and prescience reserve exact cards while a later physical card stays selectable', () => {
|
||||
for (const kind of ['retention', 'sealed', 'prescience'] as const) {
|
||||
const g = fixture();
|
||||
battle(g);
|
||||
g.players[2].hand = [worthless[0], worthless[1]];
|
||||
if (kind === 'retention')
|
||||
g.moritaniRetention = {
|
||||
owner: 'b',
|
||||
player: 'c',
|
||||
territory: 'arrakeen',
|
||||
turn: 2,
|
||||
played: [worthless[0].id],
|
||||
eligible: [worthless[0].id],
|
||||
stage: 'choose',
|
||||
};
|
||||
if (kind === 'sealed')
|
||||
g.battle!.plans.c = {
|
||||
dial: 0,
|
||||
support: 0,
|
||||
leader: null,
|
||||
weapon: worthless[0].id,
|
||||
defense: null,
|
||||
};
|
||||
if (kind === 'prescience')
|
||||
g.battle!.prescience = {
|
||||
player: 'a',
|
||||
field: 'weapon',
|
||||
value: worthless[0].id,
|
||||
};
|
||||
assert.deepEqual(ids(g, 'c'), [worthless[1].id]);
|
||||
const before = JSON.stringify(g);
|
||||
assert.throws(
|
||||
() => cancel(g, 'c', worthless[0]),
|
||||
/cleanup|sealed|prescience/,
|
||||
);
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
const next = applyAction(g, 'c', { type: 'card', mode: 'cancel' });
|
||||
assert.equal(next.response, null);
|
||||
assert.deepEqual(
|
||||
next.players[2].hand.map((c) => c.id),
|
||||
[worthless[0].id],
|
||||
);
|
||||
assert.equal(next.players[0].spice, 10);
|
||||
}
|
||||
});
|
||||
|
||||
void test('canceled BG conversion restores passes, recomputes depleted custody and completes outer power once', () => {
|
||||
let g = fixture();
|
||||
g.players[2].hand = [worthless[0]];
|
||||
g.players[1].hand = [karamas[0]];
|
||||
g = applyAction(g, 'b', { type: 'passResponse' });
|
||||
g = cancel(g, 'c', worthless[0]);
|
||||
assert.equal(g.response?.kind, 'worthlessKarama');
|
||||
assert.deepEqual(
|
||||
g.pendingKarama?.use.kind === 'cancel'
|
||||
? g.pendingKarama.use.response.passed
|
||||
: [],
|
||||
['b'],
|
||||
);
|
||||
g = cancel(reload(g), 'b', karamas[0]);
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(g.pendingKarama, null);
|
||||
assert.equal(g.players[0].spice, 13);
|
||||
assert.equal(
|
||||
g.discard.filter((c) => [worthless[0].id, karamas[0].id].includes(c.id))
|
||||
.length,
|
||||
2,
|
||||
);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
|
||||
});
|
||||
|
||||
void test('pending conversion never offers a nested Worthless conversion; real Karama still blocks it', () => {
|
||||
const g = fixture();
|
||||
g.players[2].hand = [worthless[0], karamas[0]];
|
||||
g.response = { kind: 'worthlessKarama', owner: 'b', passed: [] };
|
||||
g.pendingKarama = {
|
||||
owner: 'b',
|
||||
use: {
|
||||
kind: 'cancel',
|
||||
response: { kind: 'guildIncome', owner: 'a', amount: 3, passed: [] },
|
||||
},
|
||||
};
|
||||
assert.deepEqual(ids(g, 'c'), [karamas[0].id]);
|
||||
const before = JSON.stringify(g);
|
||||
assert.throws(
|
||||
() => cancel(g, 'c', worthless[0]),
|
||||
/conversion is already pending/,
|
||||
);
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
const next = cancel(g, 'c', karamas[0]);
|
||||
assert.equal(next.response?.kind, 'guildIncome');
|
||||
assert.deepEqual(ids(next, 'c'), [worthless[0].id]);
|
||||
});
|
||||
|
||||
void test('Truthtrance and phase-opening overlays pause normalization without payment or response alteration', () => {
|
||||
for (const overlay of ['truth', 'phase'] as const) {
|
||||
const g = fixture();
|
||||
if (overlay === 'truth')
|
||||
g.truthtrance = {
|
||||
stage: 'ask',
|
||||
queue: [{ player: 'b', card: 'spent-truth' }],
|
||||
passed: [],
|
||||
question: null,
|
||||
};
|
||||
else g.phaseOpening = { initialize: false, passed: [] };
|
||||
const next = normalizeAutomaticGame(g);
|
||||
assert.deepEqual(next.response, g.response);
|
||||
assert.equal(next.players[0].spice, 10);
|
||||
assert.deepEqual(next.log, g.log);
|
||||
if (overlay === 'phase') {
|
||||
let done = next;
|
||||
for (const p of done.players)
|
||||
done = applyAction(done, p.id, { type: 'ready' });
|
||||
assert.equal(done.response, null);
|
||||
assert.equal(done.players[0].spice, 13);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('promise-bound Worthless candidate is conservatively retained but an illegal voluntary spend rolls back', () => {
|
||||
const g = fixture();
|
||||
battle(g);
|
||||
g.players[2].hand = [worthless[0]];
|
||||
g.battle!.truthPromises = [
|
||||
{
|
||||
player: 'c',
|
||||
asker: 'a',
|
||||
claim: { kind: 'weapon', name: worthless[0].name },
|
||||
answer: true,
|
||||
},
|
||||
];
|
||||
const next = normalizeAutomaticGame(g);
|
||||
assert.deepEqual(ids(next, 'c'), [worthless[0].id]);
|
||||
assert.ok(next.response);
|
||||
const before = JSON.stringify(next);
|
||||
assert.throws(() => cancel(next, 'c', worthless[0]), /voluntarily/);
|
||||
assert.equal(JSON.stringify(next), before);
|
||||
});
|
||||
|
||||
void test('hand-exchange suspension waits; restored response uses newly returned card and original passes', () => {
|
||||
let g = fixture(['harkonnen', 'emperor', 'guild']);
|
||||
g.pendingExchange = {
|
||||
response: { kind: 'guildIncome', owner: 'c', amount: 3, passed: ['a'] },
|
||||
decision: null,
|
||||
};
|
||||
g.response = null;
|
||||
g.decision = { kind: 'handExchange', player: 'a', target: 'b', count: 1 };
|
||||
g.players[0].hand = [karamas[0]];
|
||||
g = normalizeAutomaticGame(g);
|
||||
assert.equal(g.players[2].spice, 10);
|
||||
assert.equal(g.decision?.kind, 'handExchange');
|
||||
g = applyAction(g, 'a', { type: 'decision', returnCards: [karamas[0].id] });
|
||||
assert.deepEqual(g.response?.passed, ['a']);
|
||||
assert.deepEqual(ids(g, 'b'), [karamas[0].id]);
|
||||
g = applyAction(g, 'b', { type: 'passResponse' });
|
||||
assert.equal(g.players[2].spice, 13);
|
||||
assert.equal(g.response, null);
|
||||
});
|
||||
|
||||
void test('setAutopilot preserves uncancelable response and exact gameplay state until authoritative normalization', () => {
|
||||
const initial = auction();
|
||||
const next = applyAction(initial, 'c', {
|
||||
type: 'setAutopilot',
|
||||
difficulty: 'Hard',
|
||||
});
|
||||
const comparison = structuredClone(next);
|
||||
delete comparison.players[2].autopilot;
|
||||
comparison.log = initial.log;
|
||||
assert.deepEqual(comparison, initial);
|
||||
const done = normalizeAutomaticGame(next);
|
||||
assert.equal(done.response, null);
|
||||
assert.equal(done.players[0].hand.length, 2);
|
||||
});
|
||||
|
||||
void test('setup completion waits for a real response then resumes automatically after its last choice', () => {
|
||||
const g = fixture(['guild', 'emperor'], false);
|
||||
g.status = 'setup';
|
||||
g.phase = 0;
|
||||
g.turn = 1;
|
||||
g.players.forEach((p) => {
|
||||
p.traitors = [p.leaders[0].id];
|
||||
});
|
||||
g.players[1].hand = [karamas[0]];
|
||||
const waiting = normalizeAutomaticGame(g);
|
||||
assert.equal(waiting.status, 'setup');
|
||||
assert.equal(waiting.players[0].spice, 10);
|
||||
const done = applyAction(waiting, 'b', { type: 'passResponse' });
|
||||
assert.equal(done.status, 'playing');
|
||||
assert.equal(done.response, null);
|
||||
assert.equal(done.players[0].spice, 13);
|
||||
assert.equal(done.log.filter((e) => /Setup complete/.test(e.text)).length, 1);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
|
||||
});
|
||||
|
||||
void test('summoned worm resolves its own window before restoring and automatically completing the saved response', () => {
|
||||
let g = fixture(['fremen', 'emperor', 'guild']);
|
||||
g.phase = 1;
|
||||
g.storm = 18;
|
||||
g.players[0].hand = [karamas[0]];
|
||||
g.players[2].hand = [karamas[1]];
|
||||
g.players[0].forces = { 'the_great_flat:15': 4 };
|
||||
g.players[0].reserves = 16;
|
||||
g.players[1].forces = { 'the_great_flat:15': 3 };
|
||||
g.players[1].reserves = 17;
|
||||
g.response = { kind: 'guildIncome', owner: 'b', amount: 3, passed: ['c'] };
|
||||
g = applyAction(g, 'a', {
|
||||
type: 'card',
|
||||
mode: 'special',
|
||||
card: karamas[0].id,
|
||||
territory: 'the_great_flat',
|
||||
});
|
||||
assert.equal(g.response?.kind, 'wormSurvival');
|
||||
assert.equal(g.summonedWorm?.resume.response?.kind, 'guildIncome');
|
||||
assert.equal(g.players[1].spice, 10);
|
||||
assert.equal(g.players[1].tanks, 0);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
|
||||
g = applyAction(g, 'c', { type: 'passResponse' });
|
||||
assert.equal(g.summonedWorm, null);
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(g.players[1].spice, 13);
|
||||
assert.equal(g.players[1].tanks, 3);
|
||||
assert.equal(g.players[0].forces['the_great_flat:15'], 4);
|
||||
assert.equal(g.discard.filter((c) => c.id === karamas[0].id).length, 1);
|
||||
});
|
||||
|
||||
void test('Voice cancellation can preserve a promise; automatic allowance releases an incompatible promise', () => {
|
||||
const g = fixture(['atreides', 'beneGesserit', 'emperor']);
|
||||
battle(g);
|
||||
g.players[2].hand = [shield];
|
||||
g.players[0].hand = [karamas[0]];
|
||||
g.battle!.voice = { target: 'c', kind: 'shield', must: false };
|
||||
g.battle!.truthPromises = [
|
||||
{
|
||||
player: 'c',
|
||||
asker: 'a',
|
||||
claim: { kind: 'defense', name: shield.name },
|
||||
answer: true,
|
||||
},
|
||||
];
|
||||
g.response = { kind: 'voice', owner: 'b', passed: [] };
|
||||
const waiting = normalizeAutomaticGame(g);
|
||||
assert.equal(waiting.battle!.truthPromises![0].released, undefined);
|
||||
const canceled = cancel(waiting, 'a', karamas[0]);
|
||||
assert.equal(canceled.battle!.voice, undefined);
|
||||
assert.equal(canceled.battle!.truthPromises![0].released, undefined);
|
||||
const allowed = applyAction(waiting, 'a', { type: 'passResponse' });
|
||||
assert.equal(allowed.response, null);
|
||||
assert.equal(allowed.battle!.truthPromises![0].released, true);
|
||||
});
|
||||
|
||||
void test('answering an active Truthtrance restores then drains the original response without another confirmation', () => {
|
||||
const g = fixture();
|
||||
const truth = cards.find((c) => c.effect === 'truthtrance')!;
|
||||
g.players[1].hand = [truth];
|
||||
g.truthtrance = {
|
||||
stage: 'answer',
|
||||
queue: [{ player: 'b', card: truth.id }],
|
||||
passed: ['a', 'b', 'c'],
|
||||
question: {
|
||||
kind: 'fact',
|
||||
target: 'a',
|
||||
fact: { kind: 'hand', name: 'Shield' },
|
||||
},
|
||||
};
|
||||
const done = applyAction(g, 'a', { type: 'truthAnswer', answer: 'no' });
|
||||
assert.equal(done.truthtrance, null);
|
||||
assert.equal(done.response, null);
|
||||
assert.equal(done.players[0].spice, 13);
|
||||
assert.equal(done.truthHistory?.length, 1);
|
||||
});
|
||||
Reference in new issue
Block a user