Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
@@ -0,0 +1,266 @@
|
||||
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 { forceRevivalRemaining, newRevivalRules } from '../game/revival';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
|
||||
function fixture(tleilaxu = true) {
|
||||
const g = createGame('FREEREV', newPlayer('f', 'Fremen', 'fremen'));
|
||||
g.players.push(
|
||||
newPlayer(
|
||||
't',
|
||||
tleilaxu ? 'Tleilaxu' : 'Atreides',
|
||||
tleilaxu ? 'tleilaxu' : 'atreides',
|
||||
),
|
||||
newPlayer('e', 'Emperor', 'emperor'),
|
||||
);
|
||||
g.status = 'playing';
|
||||
g.phase = 4;
|
||||
g.order = ['f', 't', 'e'];
|
||||
g.revivalRules = newRevivalRules();
|
||||
for (const p of g.players) {
|
||||
p.reserves = 10;
|
||||
p.tanks = 10;
|
||||
p.spice = 20;
|
||||
}
|
||||
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 unchangedRejection(g: Game, amount: number) {
|
||||
const before = structuredClone(g);
|
||||
assert.throws(
|
||||
() => applyAction(g, 'f', { type: 'revive', amount }),
|
||||
/Forces/,
|
||||
);
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
|
||||
void test('official FAQ permits Fremen to purchase additional revivals under the Tleilaxu five-force allowance', () => {
|
||||
let g = applyAction(fixture(), 't', {
|
||||
type: 'tleilaxuRevivalLimit',
|
||||
target: 'f',
|
||||
});
|
||||
assert.equal(viewGame(g, 'f').revival.limit, 5);
|
||||
assert.equal(viewGame(g, 'f').revival.forcesRemaining, 5);
|
||||
g = allow(applyAction(g, 'f', { type: 'revive', amount: 2 }));
|
||||
assert.equal(forceRevivalRemaining(g, g.players[0]), 3);
|
||||
g = allow(applyAction(g, 'f', { type: 'revive', amount: 1 }));
|
||||
assert.equal(g.players[0].spice, 20);
|
||||
assert.equal(g.players[0].freeForcesRevived, 3);
|
||||
assert.equal(g.players[0].revived, 3);
|
||||
assert.equal(g.players[0].tanks, 7);
|
||||
g = allow(applyAction(g, 'f', { type: 'revive', amount: 2 }));
|
||||
assert.equal(g.players[0].spice, 16);
|
||||
assert.equal(g.players[0].freeForcesRevived, 3);
|
||||
assert.equal(g.players[0].revived, 5);
|
||||
assert.equal(g.players[0].tanks, 5);
|
||||
assert.equal(forceRevivalRemaining(g, g.players[0]), 0);
|
||||
unchangedRejection(g, 1);
|
||||
});
|
||||
|
||||
void test('without Tleilaxu, free-revival prevention still leaves Fremen no ordinary paid revival method', () => {
|
||||
const g = fixture(false);
|
||||
g.expansions = ['ix'];
|
||||
g.revivalRules!.freeBlocked = ['f'];
|
||||
assert.equal(viewGame(g, 'f').revival.forcesRemaining, 0);
|
||||
unchangedRejection(g, 1);
|
||||
assert.equal(forceRevivalRemaining(g, g.players[2]), 3);
|
||||
const paid = allow(applyAction(g, 'e', { type: 'revive', amount: 3 }));
|
||||
assert.equal(paid.players[2].revived, 3);
|
||||
});
|
||||
|
||||
void test('Emperor-paid extra returns and Ghola remain separate from Fremen normal revival', () => {
|
||||
let g = fixture();
|
||||
g.players[0].ally = 'e';
|
||||
g.players[2].ally = 'f';
|
||||
g = allow(applyAction(g, 'f', { type: 'revive', amount: 3 }));
|
||||
g = allow(applyAction(g, 'e', { type: 'emperorRevival', amount: 3 }));
|
||||
assert.equal(g.players[0].revived, 3);
|
||||
assert.equal(g.players[0].freeForcesRevived, 3);
|
||||
assert.equal(g.players[0].spice, 20);
|
||||
assert.equal(g.players[2].spice, 14);
|
||||
assert.equal(g.emperorExtra.f, 3);
|
||||
const ghola = baseDeck().find((c) => c.effect === 'ghola')!;
|
||||
g.players[0].hand = [ghola];
|
||||
g = allow(applyAction(g, 'f', { type: 'card', card: ghola.id, amount: 4 }));
|
||||
assert.equal(g.players[0].tanks, 0);
|
||||
assert.equal(g.players[0].reserves, 20);
|
||||
assert.equal(g.players[0].revived, 3);
|
||||
assert.equal(g.players[0].freeForcesRevived, 3);
|
||||
});
|
||||
|
||||
void test('a stale pending paid Fremen request without Tleilaxu is rejected before any custody changes', () => {
|
||||
let g = fixture(false);
|
||||
g.revivalRules!.expanded = ['f'];
|
||||
g.pendingRevival = {
|
||||
player: 'f',
|
||||
kind: 'forces',
|
||||
amount: 5,
|
||||
elite: 0,
|
||||
free: 3,
|
||||
cost: 4,
|
||||
normalCost: 4,
|
||||
checks: [],
|
||||
};
|
||||
g.response = { kind: 'revivalLimit', owner: 't', recipient: 'f', passed: [] };
|
||||
g = allow(JSON.parse(JSON.stringify(g)));
|
||||
assert.equal(g.pendingRevival, null);
|
||||
assert.equal(g.players[0].reserves, 10);
|
||||
assert.equal(g.players[0].tanks, 10);
|
||||
assert.equal(g.players[0].spice, 20);
|
||||
assert.equal(g.players[0].revived, 0);
|
||||
assert.equal(g.players[0].freeForcesRevived, 0);
|
||||
assert.match(
|
||||
g.log.at(-1)!.text,
|
||||
/exceeds their current force-revival allowance/,
|
||||
);
|
||||
g = allow(applyAction(g, 'f', { type: 'revive', amount: 3 }));
|
||||
assert.equal(g.players[0].revived, 3);
|
||||
});
|
||||
|
||||
void test('all four AI policies use the correct Fremen paid permission and private allowance', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const g = fixture();
|
||||
g.players[0].bot = difficulty;
|
||||
g.revivalRules!.expanded = ['f'];
|
||||
const proposals = botActions(viewGame(g, 'f')).filter(
|
||||
(a) => a.type === 'revive',
|
||||
);
|
||||
assert.ok(proposals.length > 0, difficulty);
|
||||
assert.ok(
|
||||
proposals.some((a) => Number(a.amount) === 5),
|
||||
difficulty,
|
||||
);
|
||||
for (const action of proposals)
|
||||
assert.doesNotThrow(() => applyAction(g, 'f', action));
|
||||
g.revivalRules!.freeBlocked = ['f'];
|
||||
const paid = botActions(viewGame(g, 'f')).filter(
|
||||
(a) => a.type === 'revive',
|
||||
);
|
||||
assert.ok(
|
||||
paid.some((a) => Number(a.amount) === 5),
|
||||
difficulty,
|
||||
);
|
||||
for (const action of paid)
|
||||
assert.doesNotThrow(() => applyAction(g, 'f', action));
|
||||
const baseOnly = fixture(false);
|
||||
baseOnly.players[0].bot = difficulty;
|
||||
baseOnly.expansions = ['ix'];
|
||||
baseOnly.revivalRules!.freeBlocked = ['f'];
|
||||
assert.ok(
|
||||
botActions(viewGame(baseOnly, 'f')).every((a) => a.type !== 'revive'),
|
||||
difficulty,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Tleilaxu presence does not itself raise the normal limit, while blocked free revival may be purchased', () => {
|
||||
let g = fixture();
|
||||
g.revivalRules!.freeBlocked = ['f'];
|
||||
assert.equal(viewGame(g, 'f').revival.freeRemaining, 0);
|
||||
assert.equal(viewGame(g, 'f').revival.forcesRemaining, 3);
|
||||
unchangedRejection(g, 4);
|
||||
g = allow(applyAction(g, 'f', { type: 'revive', amount: 3 }));
|
||||
assert.equal(g.players[0].spice, 14);
|
||||
assert.equal(g.players[0].freeForcesRevived, 0);
|
||||
assert.equal(g.players[0].revived, 3);
|
||||
assert.equal(g.players[1].spice, 26);
|
||||
assert.equal(g.players[0].reserves + g.players[0].tanks, 20);
|
||||
});
|
||||
|
||||
void test('canceling Tleilaxu expanded revival returns no forces and charges no revival fee', () => {
|
||||
let g = applyAction(fixture(), 't', {
|
||||
type: 'tleilaxuRevivalLimit',
|
||||
target: 'f',
|
||||
});
|
||||
const karama = baseDeck().find((c) => c.effect === 'karama')!;
|
||||
g.players[2].hand = [karama];
|
||||
g = applyAction(g, 'f', { type: 'revive', amount: 5 });
|
||||
assert.equal(g.response?.kind, 'revivalLimit');
|
||||
assert.equal(g.players[0].spice, 20);
|
||||
assert.equal(g.players[0].tanks, 10);
|
||||
g = applyAction(g, 'e', { type: 'card', mode: 'cancel', card: karama.id });
|
||||
assert.equal(g.pendingRevival, null);
|
||||
assert.equal(g.players[0].spice, 20);
|
||||
assert.equal(g.players[0].tanks, 10);
|
||||
assert.equal(g.players[0].revived, 0);
|
||||
assert.equal(viewGame(g, 'f').revival.forcesRemaining, 3);
|
||||
unchangedRejection(g, 5);
|
||||
g.revivalRules!.freeBlocked = ['f'];
|
||||
g = allow(applyAction(g, 'f', { type: 'revive', amount: 3 }));
|
||||
assert.equal(g.players[0].spice, 14);
|
||||
assert.equal(g.players[0].revived, 3);
|
||||
});
|
||||
|
||||
void test('a serialized five-force pending revival settles its free and paid portions exactly once', () => {
|
||||
let g = applyAction(fixture(), 't', {
|
||||
type: 'tleilaxuRevivalLimit',
|
||||
target: 'f',
|
||||
});
|
||||
g.players.find((p) => p.id === 'e')!.hand = [
|
||||
baseDeck().find((c) => c.effect === 'karama')!,
|
||||
];
|
||||
g = applyAction(g, 'f', { type: 'revive', amount: 5 });
|
||||
assert.equal(g.pendingRevival?.free, 3);
|
||||
assert.equal(g.pendingRevival?.cost, 4);
|
||||
assert.equal(g.players[0].spice, 20);
|
||||
g = allow(JSON.parse(JSON.stringify(g)));
|
||||
assert.equal(g.pendingRevival, null);
|
||||
assert.equal(g.players[0].spice, 16);
|
||||
assert.equal(g.players[1].spice, 25); // Four paid spice plus once-per-turn free-revival income.
|
||||
assert.equal(g.players[0].tanks, 5);
|
||||
assert.equal(g.players[0].reserves, 15);
|
||||
assert.equal(g.players[0].revived, 5);
|
||||
assert.equal(g.players[0].freeForcesRevived, 3);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, 'e', { type: 'passResponse' }));
|
||||
assert.deepEqual(g, before);
|
||||
});
|
||||
|
||||
void test('actual reactive La La La preserves the Tleilaxu paid exception but grants none without Tleilaxu', () => {
|
||||
for (const tleilaxu of [true, false]) {
|
||||
let g = fixture(tleilaxu);
|
||||
const choam = newPlayer('c', 'CHOAM', 'choam');
|
||||
const card = baseDeck().find((c) => c.name === 'La La La')!;
|
||||
choam.hand = [card];
|
||||
g.players.push(choam);
|
||||
g.order.push('c');
|
||||
if (tleilaxu)
|
||||
g = applyAction(g, 't', { type: 'tleilaxuRevivalLimit', target: 'f' });
|
||||
const amount = tleilaxu ? 5 : 3;
|
||||
g = applyAction(g, 'f', { type: 'revive', amount });
|
||||
assert.equal(g.decision?.kind, 'choamFreeRevival');
|
||||
g = applyAction(g, 'c', { type: 'card', mode: 'choam', card: card.id });
|
||||
g = allow(JSON.parse(JSON.stringify(g)));
|
||||
assert.equal(g.pendingRevival, null);
|
||||
assert.equal(g.players[0].spice, 20);
|
||||
assert.equal(g.players[0].tanks, 10);
|
||||
assert.equal(g.players[0].revived, 0);
|
||||
assert.equal(g.players[0].freeForcesRevived, 0);
|
||||
assert.equal(viewGame(g, 'f').revival.freeRemaining, 0);
|
||||
if (tleilaxu) {
|
||||
g = allow(applyAction(g, 'f', { type: 'revive', amount: 5 }));
|
||||
assert.equal(g.players[0].spice, 10);
|
||||
assert.equal(g.players[1].spice, 30);
|
||||
assert.equal(g.players[0].reserves, 15);
|
||||
assert.equal(g.players[0].freeForcesRevived, 0);
|
||||
} else unchangedRejection(g, 3);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user