Permit native Advanced quiet any-player declarations alongside the original target-bound pending request. Preserve completed returns and charges. Adopt explicit provisional Ghola inclusion during the remaining Revival and phase expiry; keep printed Emperor special immunity and the existing low-Homeworld guard. Connect persistent controls and minimal legal bots through the shared quote. Validation: 14 related files148/148, types/lint/build, six actual native control/engine/GameTable SSR journeys and four existing-profile continuations. Private-driver-only feedback reuses passed source-identical checks. Native111/113-action setup, paid shipment and battle sources earn originals. No saved-room/D1/restart/deployment operation; full modes and browser/deployed acceptance remain open.
337 lines
11 KiB
TypeScript
337 lines
11 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
joinGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { baseDeck, leaders } from '../game/cards';
|
|
import { revivalPrevented } from '../game/revival';
|
|
import { botActions, runBots } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { placeFixtureHand } from './fixture-hand';
|
|
function fixture() {
|
|
let g = createGame('TREVIVAL2', newPlayer('a', 'Atreides', 'atreides'));
|
|
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
|
|
joinGame(g, newPlayer('t', 'Tleilaxu', 'guild'));
|
|
joinGame(g, newPlayer('h', 'Observer', '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.players[2].faction = 'tleilaxu';
|
|
g.players[2].leaders = leaders('tleilaxu');
|
|
g.players[2].traitors = [];
|
|
g.phase = 4;
|
|
g.advanced = true;
|
|
g.storm = 18;
|
|
g.order = ['a', 'e', 't', 'h'];
|
|
g.active = null;
|
|
for (const p of g.players) {
|
|
p.hand = [];
|
|
p.forces = {};
|
|
p.reserves = 20;
|
|
p.tanks = 0;
|
|
p.spice = 20;
|
|
p.revived = 0;
|
|
}
|
|
return g;
|
|
}
|
|
|
|
const karama = baseDeck().find((c) => c.effect === 'karama')!;
|
|
const ghola = baseDeck().find((c) => c.effect === 'ghola')!;
|
|
function beforeRevival() {
|
|
const g = fixture();
|
|
g.players[0].tanks = 8;
|
|
g.players[0].reserves = 12;
|
|
g.players[2].hand = [karama];
|
|
return g;
|
|
}
|
|
function declare(g: Game, player = 'a', amount = 3) {
|
|
return applyAction(g, player, { type: 'revive', amount });
|
|
}
|
|
function prevent(g: Game) {
|
|
return applyAction(g, 't', {
|
|
type: 'card',
|
|
card: karama.id,
|
|
mode: 'special',
|
|
});
|
|
}
|
|
function allow(state: Game) {
|
|
let g = state;
|
|
while (g.response || g.decision?.kind === 'revivalStop')
|
|
if (g.decision?.kind === 'revivalStop')
|
|
g = applyAction(g, 't', { type: 'decision', decline: true });
|
|
else
|
|
g = applyAction(
|
|
g,
|
|
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
|
|
{ type: 'passResponse' },
|
|
);
|
|
return g;
|
|
}
|
|
void test('every advanced foreign normal revival gets the same Tleilaxu window independently of card holdings', () => {
|
|
const before = beforeRevival();
|
|
const withCard = declare(before);
|
|
before.players[2].hand = [];
|
|
const withoutCard = declare(before);
|
|
assert.deepEqual(
|
|
viewGame(withCard, 'a').decision,
|
|
viewGame(withoutCard, 'a').decision,
|
|
);
|
|
assert.deepEqual(withCard.decision, {
|
|
kind: 'revivalStop',
|
|
player: 't',
|
|
recipient: 'a',
|
|
revival: 'forces',
|
|
});
|
|
assert.equal(withCard.players[0].spice, 20);
|
|
assert.equal(withCard.players[0].tanks, 8);
|
|
assert.equal(withCard.players[0].revived, 0);
|
|
const g = allow(withoutCard);
|
|
assert.equal(g.players[0].tanks, 5);
|
|
assert.equal(g.players[0].spice, 18);
|
|
assert.equal(g.players[2].spice, 23);
|
|
assert.equal(g.players[2].specialKaramaUsed, undefined);
|
|
});
|
|
void test('special Karama consumes one card once, aborts payment and prevents repeat force or leader revival for the turn', () => {
|
|
let g = prevent(declare(beforeRevival()));
|
|
assert.equal(g.players[2].specialKaramaUsed, true);
|
|
assert.equal(g.discard.filter((c) => c.id === karama.id).length, 1);
|
|
assert.equal(g.players[0].spice, 20);
|
|
assert.equal(g.players[0].tanks, 8);
|
|
assert.equal(g.players[0].revived, 0);
|
|
assert.equal(g.players[2].spice, 20);
|
|
assert.equal(g.pendingRevival, null);
|
|
assert.equal(g.decision, null);
|
|
assert.equal(g.response, null);
|
|
assert.ok(revivalPrevented(g, 'a'));
|
|
assert.ok(viewGame(g, 'a').revival.prevented);
|
|
assert.throws(() => declare(g, 'a', 1), /prevented/);
|
|
g.players[0].leaders.forEach((l) => {
|
|
l.dead = true;
|
|
l.deaths = 1;
|
|
});
|
|
assert.throws(
|
|
() => applyAction(g, 'a', { type: 'reviveLeader', leader: 'atreides-0' }),
|
|
/prevented/,
|
|
);
|
|
g.players[1].tanks = 2;
|
|
g.players[1].reserves = 18;
|
|
g = allow(declare(g, 'e', 2));
|
|
assert.equal(g.players[1].tanks, 0);
|
|
g.turn++;
|
|
assert.ok(!revivalPrevented(g, 'a'));
|
|
g = allow(declare(g, 'a', 1));
|
|
assert.equal(g.players[0].tanks, 7);
|
|
g.players[2].hand = [karama];
|
|
assert.throws(() => prevent(g), /already been used/);
|
|
});
|
|
void test('ownership, actual Karama, window, target and decline validation reject immutably', () => {
|
|
const idle = beforeRevival();
|
|
assert.throws(() => prevent(idle), /Expected a string/);
|
|
const g = declare(idle);
|
|
const snapshot = structuredClone(g);
|
|
assert.throws(
|
|
() => applyAction(g, 'a', { type: 'decision', decline: true }),
|
|
/pending decision/,
|
|
);
|
|
assert.throws(
|
|
() => applyAction(g, 't', { type: 'decision', decline: false }),
|
|
/Allow the revival/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
applyAction(g, 't', {
|
|
type: 'card',
|
|
card: karama.id,
|
|
mode: 'special',
|
|
target: 'e',
|
|
}),
|
|
/currently reviving/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
applyAction(g, 't', { type: 'card', card: '__proto__', mode: 'special' }),
|
|
/Karama card/,
|
|
);
|
|
assert.deepEqual(g, snapshot);
|
|
const noCard = structuredClone(g);
|
|
noCard.players[2].hand = [baseDeck().find((c) => c.kind === 'worthless')!];
|
|
assert.throws(() => prevent(noCard), /Karama card/);
|
|
const basic = beforeRevival();
|
|
basic.advanced = false;
|
|
assert.notEqual(declare(basic).decision?.kind, 'revivalStop');
|
|
});
|
|
void test('private leader quotes stay private through the stop decision and prevention removes the abandoned request', () => {
|
|
let g = beforeRevival();
|
|
const l = g.players[0].leaders[0];
|
|
l.dead = true;
|
|
l.deaths = 2;
|
|
l.concealed = { captor: 'h', dead: false, deaths: 0 };
|
|
g = applyAction(g, 'a', { type: 'requestLeaderRevival', leader: l.id });
|
|
g = applyAction(g, 't', {
|
|
type: 'quoteLeaderRevival',
|
|
target: 'a',
|
|
amount: 9,
|
|
});
|
|
g = applyAction(g, 'a', { type: 'acceptLeaderRevival' });
|
|
assert.equal(g.decision?.kind, 'revivalStop');
|
|
const other = viewGame(g, 'e');
|
|
assert.ok(!JSON.stringify(other.decision).includes(l.id));
|
|
assert.ok(!JSON.stringify(other.decision).includes('9'));
|
|
assert.deepEqual(other.revivalRequests, {});
|
|
g = prevent(JSON.parse(JSON.stringify(g)));
|
|
assert.equal(g.players[0].leaders[0].dead, true);
|
|
assert.equal(g.players[0].spice, 20);
|
|
assert.deepEqual(g.revivalRequests, {});
|
|
assert.equal(viewGame(g, 'e').players[0].leaders[0].dead, false);
|
|
});
|
|
void test('allowing a revival resumes its discount/early-response chain exactly once', () => {
|
|
let g = beforeRevival();
|
|
g.players[0].ally = 't';
|
|
g.players[2].ally = 'a';
|
|
g = applyAction(g, 't', { type: 'tleilaxuAllyDiscount' });
|
|
g = declare(g);
|
|
g = applyAction(g, 't', { type: 'decision', decline: true });
|
|
assert.equal(g.response, null);
|
|
g = allow(g);
|
|
assert.equal(g.players[0].spice, 19);
|
|
assert.equal(g.players[0].tanks, 5);
|
|
assert.equal(g.players[2].spice, 22);
|
|
assert.throws(
|
|
() => applyAction(g, 't', { type: 'decision', decline: true }),
|
|
/Unknown|Unsupported|not available/i,
|
|
);
|
|
});
|
|
void test('Emperor-funded extra revival waits for Tleilaxu and a stopped attempt consumes no payment or extra quota', () => {
|
|
let g = beforeRevival();
|
|
g.players[0].ally = 'e';
|
|
g.players[1].ally = 'a';
|
|
g = applyAction(g, 'e', { type: 'emperorRevival', amount: 3 });
|
|
while (g.response)
|
|
g = applyAction(
|
|
g,
|
|
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
|
|
{ type: 'passResponse' },
|
|
);
|
|
assert.equal(g.decision?.kind, 'revivalStop');
|
|
assert.equal(g.players[1].spice, 20);
|
|
g = prevent(g);
|
|
assert.equal(g.players[1].spice, 20);
|
|
assert.equal(g.players[0].tanks, 8);
|
|
assert.equal(g.emperorExtra?.a, undefined);
|
|
assert.throws(
|
|
() => applyAction(g, 'e', { type: 'emperorRevival', amount: 1 }),
|
|
/prevented/,
|
|
);
|
|
});
|
|
void test('allowing Emperor-funded revival preserves separate quota, correct payer and Tleilaxu income', () => {
|
|
let g = beforeRevival();
|
|
g.players[0].ally = 'e';
|
|
g.players[1].ally = 'a';
|
|
g = allow(applyAction(g, 'e', { type: 'emperorRevival', amount: 3 }));
|
|
assert.equal(g.players[1].spice, 14);
|
|
assert.equal(g.players[0].spice, 20);
|
|
assert.equal(g.players[2].spice, 26);
|
|
assert.equal(g.emperorExtra?.a, 3);
|
|
assert.equal(g.players[0].revived, 0);
|
|
assert.equal(g.players[0].tanks, 5);
|
|
});
|
|
void test('Ghola is prevented during Revival while Emperor special Karama retains printed immunity', () => {
|
|
const g = prevent(declare(beforeRevival()));
|
|
placeFixtureHand(g, 0, [ghola]);
|
|
const before = structuredClone(g);
|
|
assert.throws(() => applyAction(g, 'a', { type: 'card', card: ghola.id, amount: 5 }), /prevented.*Ghola/);
|
|
assert.deepEqual(g, before);
|
|
assert.equal(g.players[0].tanks, 8);
|
|
assert.equal(g.players[0].revived, 0);
|
|
let emperor = beforeRevival();
|
|
emperor.players[1].tanks = 5;
|
|
emperor.players[1].reserves = 15;
|
|
emperor = prevent(declare(emperor, 'e', 2));
|
|
emperor.players[1].hand = [karama];
|
|
emperor = applyAction(emperor, 'e', {
|
|
type: 'card',
|
|
card: karama.id,
|
|
mode: 'special',
|
|
amount: 3,
|
|
});
|
|
assert.equal(emperor.players[1].tanks, 2);
|
|
assert.equal(emperor.players[1].specialKaramaUsed, true);
|
|
});
|
|
void test('KH revival is prevented without changing its state, while a canceled attempt does not consume elite quota', () => {
|
|
let g = beforeRevival();
|
|
g.players[0].kwisatz = { dead: true, revivalCycle: 1 };
|
|
g.players[0].revivalCycle = 1;
|
|
g = applyAction(g, 'a', { type: 'reviveKwisatz' });
|
|
g = prevent(g);
|
|
assert.equal(g.players[0].kwisatz?.dead, true);
|
|
assert.equal(g.players[0].leaderRevived, false);
|
|
let elite = beforeRevival();
|
|
elite.players[1].tanks = 5;
|
|
elite.players[1].reserves = 15;
|
|
elite.players[1].elites = { tanks: 1, reserves: 4, forces: {}, revived: 0 };
|
|
elite = applyAction(elite, 'e', { type: 'revive', amount: 2, elite: 1 });
|
|
elite = prevent(elite);
|
|
assert.equal(elite.players[1].elites?.revived, 0);
|
|
assert.equal(elite.players[1].elites?.tanks, 1);
|
|
});
|
|
void test('AI decides from public threats, preserves allies and progresses without a Karama', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const before = beforeRevival();
|
|
before.players[0].tanks = 15;
|
|
before.players[0].reserves = 5;
|
|
before.players[2].bot = difficulty;
|
|
const g = declare(before);
|
|
const action = botActions(viewGame(g, 't'))[0];
|
|
assert.equal(action.mode, 'special');
|
|
assert.doesNotThrow(() => applyAction(g, 't', action));
|
|
const changed = structuredClone(g);
|
|
changed.players[0].spice = 999;
|
|
assert.deepEqual(
|
|
botActions(viewGame(changed, 't')),
|
|
botActions(viewGame(g, 't')),
|
|
);
|
|
g.players[2].ally = 'a';
|
|
g.players[0].ally = 't';
|
|
assert.deepEqual(botActions(viewGame(g, 't'))[0], {
|
|
type: 'decision',
|
|
decline: true,
|
|
});
|
|
g.players[2].ally = null;
|
|
g.players[0].ally = null;
|
|
g.players[2].hand = [];
|
|
assert.deepEqual(botActions(viewGame(g, 't'))[0], {
|
|
type: 'decision',
|
|
decline: true,
|
|
});
|
|
}
|
|
});
|
|
void test('AI progresses a blocked Revival phase and retains force and spice invariants', () => {
|
|
const before = beforeRevival();
|
|
before.players[0].tanks = 15;
|
|
before.players[0].reserves = 5;
|
|
before.players.forEach((p) => {
|
|
p.bot = 'Brutal';
|
|
});
|
|
let g = runBots(before);
|
|
for (let i = 0; i < 8 && g.phase === 4; i++) g = runBots(g);
|
|
assert.notEqual(g.phase, 4);
|
|
assert.equal(g.players[2].specialKaramaUsed, true);
|
|
for (const p of g.players) {
|
|
assert.ok(p.spice >= 0);
|
|
assert.equal(
|
|
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
|
|
20,
|
|
);
|
|
}
|
|
});
|