311 lines
13 KiB
TypeScript
311 lines
13 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
applyAction,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import {
|
|
emperorNexusPools,
|
|
emperorNexusRevivalCount,
|
|
emperorNexusSignature,
|
|
} from '../game/nexus-emperor-secret-ally';
|
|
import { forceRevivalRemaining, freeRevivalRemaining } from '../game/revival';
|
|
import {
|
|
nexusEmperorSecretAllyFixture,
|
|
emperorNexusPhysical,
|
|
} from './nexus-emperor-secret-ally-fixture';
|
|
import { nexusReject, nexusReload, nexusReady } from './fixture-nexus-cards';
|
|
|
|
function ownRevival(game: Game, owner: string): Game {
|
|
for (let n = 0; game.revivalOrder && game.revivalOrder.remaining[0] !== owner && n < game.players.length; n++) {
|
|
assert.equal(game.phase,4);
|
|
assert.ok(game.revivalOrder.remaining.includes(owner),'Finish only native turns preceding the still-unfinished owner.');
|
|
game = normalizeAutomaticGame(applyAction(game,game.revivalOrder.remaining[0],{type:'ready'}));
|
|
}
|
|
assert.ok(!game.revivalOrder || game.revivalOrder.remaining[0] === owner);
|
|
return game;
|
|
}
|
|
|
|
void test('a later real Ghola return and phase advance preserve historical Nexus proof without replaying its physical grant', () => {
|
|
const f = nexusEmperorSecretAllyFixture();
|
|
const card = f.g.deck.find((c) => c.effect === 'ghola')!;
|
|
f.g.deck = f.g.deck.filter((c) => c.id !== card.id);
|
|
f.g.players.find((p) => p.id === f.owner)!.hand.push(card);
|
|
f.g.players.find((p) => p.id === f.owner)!.spice = 0;
|
|
const physical = emperorNexusPhysical(f.g);
|
|
let g = applyAction(f.g, f.owner, f.action);
|
|
const receipt = nexusReload(g).nexusEmperorSecretHistory;
|
|
g = applyAction(nexusReload(g), f.owner, {
|
|
type: 'card',
|
|
card: card.id,
|
|
amount: 3,
|
|
elite: 0,
|
|
});
|
|
assert.equal(g.players.find((p) => p.id === f.owner)!.tanks, 0);
|
|
assert.equal(g.players.find((p) => p.id === f.owner)!.revived, 0);
|
|
assert.equal(g.players.find((p) => p.id === f.owner)!.spice, 0);
|
|
assert.deepEqual(g.nexusEmperorSecretHistory, receipt);
|
|
assert.deepEqual(emperorNexusPhysical(g), physical);
|
|
g = nexusReload(nexusReady(g));
|
|
assert.equal(g.phase, 5);
|
|
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
|
|
nexusReject(g, f.owner, f.action);
|
|
});
|
|
|
|
for (const advanced of [false, true])
|
|
void test(`${advanced ? 'Advanced' : 'Basic'} Emperor Secret Ally revives exactly three physical forces without ordinary payment or quota usage`, () => {
|
|
const f = nexusEmperorSecretAllyFixture({ advanced }),
|
|
before = nexusReload(f.g),
|
|
p = before.players.find((p) => p.id === f.owner)!;
|
|
const physical = emperorNexusPhysical(before),
|
|
quota = forceRevivalRemaining(before, p),
|
|
free = freeRevivalRemaining(before, p);
|
|
const g = applyAction(f.g, f.owner, f.action),
|
|
after = g.players.find((p) => p.id === f.owner)!;
|
|
assert.deepEqual(f.g, before);
|
|
assert.deepEqual(emperorNexusPools(after), {
|
|
...emperorNexusPools(p),
|
|
reserves: p.reserves + 3,
|
|
tanks: p.tanks - 3,
|
|
});
|
|
assert.equal(forceRevivalRemaining(g, after), quota);
|
|
assert.equal(freeRevivalRemaining(g, after), free);
|
|
assert.deepEqual(
|
|
g.players.filter((p) => p.id !== f.owner),
|
|
before.players.filter((p) => p.id !== f.owner),
|
|
);
|
|
assert.deepEqual(after.hand, p.hand);
|
|
assert.deepEqual(after.leaders, p.leaders);
|
|
assert.deepEqual(emperorNexusPhysical(g), physical);
|
|
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
|
|
assert.equal(
|
|
g.nexusCards!.cards!.discard.filter((c) => c === 'emperor').length,
|
|
1,
|
|
);
|
|
assert.equal(g.nexusEmperorSecretHistory!.length, 1);
|
|
assert.equal(g.phase, 4);
|
|
assert.equal(g.response, null);
|
|
assert.equal(g.decision, null);
|
|
const ordinary = applyAction(ownRevival(g,f.owner), f.owner, {
|
|
type: 'revive',
|
|
amount: 3,
|
|
elite: 0,
|
|
});
|
|
const revived = ordinary.players.find((p) => p.id === f.owner)!;
|
|
assert.equal(revived.tanks, 0);
|
|
assert.equal(revived.revived, 3);
|
|
assert.equal(revived.spice, p.spice - 2);
|
|
assert.deepEqual(emperorNexusPhysical(ordinary), physical);
|
|
nexusReject(ordinary, f.owner, f.action);
|
|
assert.deepEqual(normalizeAutomaticGame(nexusReload(ordinary)), ordinary);
|
|
});
|
|
|
|
void test('Emperor additional return still works after the ordinary three-force and free allowances are exhausted', () => {
|
|
const f = nexusEmperorSecretAllyFixture();
|
|
const paid = applyAction(ownRevival(f.g,f.owner), f.owner, {
|
|
type: 'revive',
|
|
amount: 3,
|
|
elite: 0,
|
|
});
|
|
const before = emperorNexusPools(paid.players.find((p) => p.id === f.owner)!);
|
|
const done = applyAction(paid, f.owner, f.action);
|
|
assert.deepEqual(
|
|
emperorNexusPools(done.players.find((p) => p.id === f.owner)!),
|
|
{ ...before, reserves: before.reserves + 3, tanks: before.tanks - 3 },
|
|
);
|
|
});
|
|
|
|
void test('Advanced Fedaykin use typed Tanks and share the actual one-per-turn cap with ordinary revival', () => {
|
|
for (const nexusFirst of [false, true]) {
|
|
const f = nexusEmperorSecretAllyFixture({
|
|
advanced: true,
|
|
owner: 'p',
|
|
tanks: 7,
|
|
eliteTanks: 2,
|
|
});
|
|
const before = emperorNexusPhysical(f.g);
|
|
assert.deepEqual(
|
|
viewGame(f.g, f.owner).nexusEmperorSecretAlly!.revival.eliteOptions,
|
|
[0, 1],
|
|
);
|
|
nexusReject(f.g, f.owner, { ...f.action, elite: 2 });
|
|
let g: Game;
|
|
if (nexusFirst) {
|
|
g = applyAction(f.g, f.owner, f.action);
|
|
g = ownRevival(g,f.owner);
|
|
nexusReject(
|
|
g,
|
|
f.owner,
|
|
{ type: 'revive', amount: 1, elite: 1 },
|
|
/one elite/,
|
|
);
|
|
g = applyAction(g, f.owner, { type: 'revive', amount: 3, elite: 0 });
|
|
} else {
|
|
g = applyAction(ownRevival(f.g,f.owner), f.owner, { type: 'revive', amount: 3, elite: 1 });
|
|
assert.deepEqual(
|
|
viewGame(g, f.owner).nexusEmperorSecretAlly!.revival.eliteOptions,
|
|
[0],
|
|
);
|
|
nexusReject(g, f.owner, f.action);
|
|
g = applyAction(g, f.owner, { ...f.action, elite: 0 });
|
|
}
|
|
assert.equal(g.players[0].elites!.revived, 1);
|
|
assert.equal(g.players[0].elites!.tanks, 1);
|
|
assert.deepEqual(emperorNexusPhysical(g), before);
|
|
}
|
|
});
|
|
|
|
void test('short eligible totals return every available counter while zero and exhausted typed caps remain blocked', () => {
|
|
for (const entry of [
|
|
{options:{tanks:1},amount:1,elite:0,choices:[0]},
|
|
{options:{tanks:2},amount:2,elite:0,choices:[0]},
|
|
{options:{advanced:true,owner:'p' as const,tanks:3,eliteTanks:2},amount:2,elite:1,choices:[1]},
|
|
]) {
|
|
const f = nexusEmperorSecretAllyFixture(entry.options), before = emperorNexusPools(f.g.players.find(p => p.id === f.owner)!);
|
|
const physical = emperorNexusPhysical(f.g), quote = viewGame(f.g,f.owner).nexusEmperorSecretAlly!.revival;
|
|
assert.equal(quote.amount,entry.amount);
|
|
assert.equal(quote.blocked,null);
|
|
assert.deepEqual(quote.eliteOptions,entry.choices);
|
|
const done = applyAction(f.g,f.owner,{...f.action,elite:entry.elite});
|
|
assert.deepEqual(emperorNexusPools(done.players.find(p => p.id === f.owner)!),{
|
|
...before,reserves:before.reserves+entry.amount,tanks:before.tanks-entry.amount,
|
|
eliteReserves:before.eliteReserves+entry.elite,eliteTanks:before.eliteTanks-entry.elite,
|
|
eliteRevived:before.eliteRevived+entry.elite,
|
|
});
|
|
assert.equal(emperorNexusRevivalCount(done.nexusEmperorSecretHistory!.at(-1)!),entry.amount);
|
|
assert.equal(done.nexusCards!.cards!.hands[f.owner],null);
|
|
assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'emperor').length,1);
|
|
assert.deepEqual(emperorNexusPhysical(done),physical);
|
|
}
|
|
const empty = nexusEmperorSecretAllyFixture({tanks:0});
|
|
assert.equal(viewGame(empty.g,empty.owner).nexusEmperorSecretAlly!.revival.amount,0);
|
|
assert.deepEqual(viewGame(empty.g,empty.owner).nexusEmperorSecretAlly!.revival.eliteOptions,[]);
|
|
nexusReject(empty.g,empty.owner,empty.action,/eligible/i);
|
|
assert.equal(empty.g.nexusCards!.cards!.hands[empty.owner],'emperor');
|
|
|
|
const typed = nexusEmperorSecretAllyFixture({advanced:true,owner:'p',tanks:2,eliteTanks:2});
|
|
const used = applyAction(ownRevival(typed.g,typed.owner),typed.owner,{type:'revive',amount:1,elite:1});
|
|
const remaining = used.players.find(p => p.id === typed.owner)!;
|
|
assert.equal(remaining.elites!.tanks,1);
|
|
assert.equal(remaining.elites!.revived,1);
|
|
const exhausted = viewGame(used,typed.owner).nexusEmperorSecretAlly!.revival;
|
|
assert.equal(exhausted.amount,0);
|
|
assert.deepEqual(exhausted.eliteOptions,[]);
|
|
nexusReject(used,typed.owner,typed.action,/eligible/i);
|
|
assert.equal(used.nexusCards!.cards!.hands[typed.owner],'emperor');
|
|
});
|
|
|
|
void test('only the private absent-Emperor holder gets revival choices; nonholders retain ordinary phase progress', () => {
|
|
const f = nexusEmperorSecretAllyFixture();
|
|
assert.equal(viewGame(f.g, f.owner).nexusEmperorSecretAlly!.purchase, null);
|
|
for (const id of [f.target, f.observer]) {
|
|
const v = viewGame(f.g, id);
|
|
assert.equal(v.nexusEmperorSecretAlly, null);
|
|
assert.equal(v.nexusCards!.card, null);
|
|
assert.equal(v.decision, null);
|
|
assert.equal(Object.hasOwn(v, 'nexusEmperorSecretHistory'), false);
|
|
}
|
|
const next = nexusReady(f.g);
|
|
assert.equal(next.phase, 5);
|
|
assert.equal(next.nexusCards!.cards!.hands[f.owner], 'emperor');
|
|
nexusReject(next, f.owner, f.action, /Revival/);
|
|
const ready = applyAction(f.g, f.owner, { type: 'ready' });
|
|
// As with ordinary revival, a Ready vote does not end the printed phase
|
|
// while other players are still acting; the existing timing remains valid.
|
|
assert.equal(
|
|
viewGame(ready, f.owner).nexusEmperorSecretAlly!.revival.blocked,
|
|
null,
|
|
);
|
|
assert.equal(
|
|
applyAction(ready, f.owner, f.action).nexusEmperorSecretHistory!.length,
|
|
1,
|
|
);
|
|
});
|
|
|
|
void test('stale, wrong-owner, malformed and leader-revival requests reject before any resource or random commitment', () => {
|
|
const f = nexusEmperorSecretAllyFixture(),
|
|
before = nexusReload(f.g);
|
|
for (const action of [
|
|
{ ...f.action, event: 'stale' },
|
|
{ ...f.action, elite: -1 },
|
|
{ ...f.action, elite: 0.5 },
|
|
{ ...f.action, amount: 2 },
|
|
{ ...f.action, leader: f.g.players[0].leaders[0].id },
|
|
{ type: f.action.type, event: f.action.event },
|
|
])
|
|
nexusReject(f.g, f.owner, action);
|
|
nexusReject(f.g, f.target, f.action);
|
|
assert.deepEqual(f.g, before);
|
|
const card = f.g.deck.find((c) => c.effect === 'truthtrance')!;
|
|
f.g.deck = f.g.deck.filter((c) => c.id !== card.id);
|
|
f.g.players.find((p) => p.id === f.target)!.hand.push(card);
|
|
const pending = applyAction(f.g, f.target, { type: 'card', card: card.id });
|
|
assert.ok(pending.truthtrance);
|
|
nexusReject(pending, f.owner, f.action, /current interaction/);
|
|
});
|
|
|
|
void test('ordinary Fremen aid to the other allied seat does not suppress this unallied owner', () => {
|
|
const f = nexusEmperorSecretAllyFixture();
|
|
const granted = applyAction(f.g, 'p', { type: 'grantRevival' });
|
|
assert.ok(granted.freeRevival.includes('r'));
|
|
assert.equal(
|
|
viewGame(granted, f.owner).nexusEmperorSecretAlly!.revival.blocked,
|
|
null,
|
|
);
|
|
const done = applyAction(granted, f.owner, f.action);
|
|
assert.deepEqual(done.freeRevival, granted.freeRevival);
|
|
});
|
|
|
|
|
|
void test('JSON restoration keeps signed source history private and rejects missing markers, duplicate uses and changed physical quotes', () => {
|
|
const f = nexusEmperorSecretAllyFixture(),
|
|
done = applyAction(f.g, f.owner, f.action);
|
|
for (const p of done.players)
|
|
assert.deepEqual(viewGame(nexusReload(done), p.id), viewGame(done, p.id));
|
|
assert.deepEqual(normalizeAutomaticGame(nexusReload(done)), done);
|
|
nexusReject(done, f.owner, f.action);
|
|
for (const damage of [
|
|
(g: Game) => {
|
|
delete g.nexusEmperorSecretHistory;
|
|
},
|
|
(g: Game) => {
|
|
delete g.nexusEmperorSecretEvents;
|
|
},
|
|
(g: Game) => {
|
|
g.nexusEmperorSecretEvents![0] = 'forged';
|
|
},
|
|
(g: Game) => {
|
|
g.nexusEmperorSecretHistory!.push(g.nexusEmperorSecretHistory![0]);
|
|
g.nexusEmperorSecretEvents!.push(g.nexusEmperorSecretEvents![0]);
|
|
},
|
|
(g: Game) => {
|
|
const after = g.nexusEmperorSecretHistory![0].after;
|
|
assert.ok(after);
|
|
after.reserves++;
|
|
},
|
|
(g: Game) => {
|
|
const r = g.nexusEmperorSecretHistory![0];
|
|
r.elite = 1;
|
|
r.signature = emperorNexusSignature(r);
|
|
},
|
|
(g: Game) => {
|
|
const cards = g.nexusCards!.cards!;
|
|
cards.discard = cards.discard.filter((c) => c !== 'emperor');
|
|
cards.hands[f.owner] = 'emperor';
|
|
},
|
|
]) {
|
|
const g = nexusReload(done);
|
|
damage(g);
|
|
const before = nexusReload(g);
|
|
assert.throws(() => viewGame(g, f.owner), /Emperor Nexus/);
|
|
assert.throws(() => normalizeAutomaticGame(g), /Emperor Nexus/);
|
|
assert.throws(
|
|
() => applyAction(g, f.owner, { type: 'ready' }),
|
|
/Emperor Nexus/,
|
|
);
|
|
assert.deepEqual(g, before);
|
|
}
|
|
});
|