Integrate Ecaz Duke revival and bounded Mirror Weapon battles
This commit is contained in:
1 parent
68d782ca82
commit
3b118dd845
39 files changed
+1377
-533
No files matched your search
@@ -249,6 +249,43 @@ void test('Stone validates physical ambiguity and mode before traitors, and uses
|
||||
g.defender.plan.support = 1;
|
||||
reject(g, /unresolved physical casualty/);
|
||||
});
|
||||
void test('copied Stone needs each revealed mode and keeps physical Mirror custody on victory', () => {
|
||||
const g = input();
|
||||
const mirror = play(g, 'attacker', 'weapon', (card) => card.effect === 'mirrorWeapon');
|
||||
const original = play(g, 'defender', 'weapon', (card) => card.effect === 'stoneBurner');
|
||||
g.defender.plan.dial = g.defender.plan.support = 4;
|
||||
reject(g, /revealed mode/);
|
||||
g.attacker.stoneMode = 'ignore';
|
||||
reject(g, /revealed mode/);
|
||||
g.defender.stoneMode = 'ignore';
|
||||
const quote = quoteBattleResolution(g);
|
||||
assert.equal(quote.stone?.winner, 'attacker');
|
||||
assert.equal(quote.winner, 'a');
|
||||
assert.ok(quote.winnerCards.includes(mirror.id));
|
||||
assert.ok(quote.discarded.some((entry) => entry.card === original.id));
|
||||
assert.ok(!quote.discarded.some((entry) => entry.card === mirror.id));
|
||||
g.attacker.stoneMode = 'kill';
|
||||
assert.deepEqual(quoteBattleResolution(g).leaderDeaths, {
|
||||
attacker: true,
|
||||
defender: true,
|
||||
});
|
||||
});
|
||||
void test('victorious physical Mirror retains after copying activated Tooth or Artillery', () => {
|
||||
for (const copiedKind of ['poisonTooth', 'artillery'] as const) {
|
||||
const g = input();
|
||||
const mirror = play(g, 'attacker', 'weapon', (card) => card.effect === 'mirrorWeapon');
|
||||
const original = play(g, 'defender', 'weapon', (card) => card.kind === copiedKind);
|
||||
if (copiedKind === 'poisonTooth') {
|
||||
g.attacker.poisonTooth = true;
|
||||
g.defender.poisonTooth = true;
|
||||
}
|
||||
const quote = quoteBattleResolution(g);
|
||||
assert.equal(quote.winner, 'a', copiedKind);
|
||||
assert.ok(quote.winnerCards.includes(mirror.id), copiedKind);
|
||||
assert.ok(!quote.discarded.some((entry) => entry.card === mirror.id), copiedKind);
|
||||
assert.ok(quote.discarded.some((entry) => entry.card === original.id), copiedKind);
|
||||
}
|
||||
});
|
||||
void test('Habbanya selects ordinary and Stone ties; canceled free-support and elite bonuses remain captured in casualty choices', () => {
|
||||
const g = input();
|
||||
g.defender.leader!.strength = 3;
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
consumeDuke,
|
||||
createDukeVidal,
|
||||
DUKE_VIDAL_ID,
|
||||
DUKE_VIDAL_RULES,
|
||||
expireDuke,
|
||||
type DukeSource,
|
||||
} from '../game/duke-vidal';
|
||||
@@ -170,34 +169,3 @@ void test('custody survives JSON persistence and permits a later eligible acquis
|
||||
assert.equal(released.controller, null);
|
||||
assert.equal(held.controller, 'm');
|
||||
});
|
||||
|
||||
void test('immutable original gameplay guidance separates strength, revival cost and unfinished integrations', () => {
|
||||
assert.equal(DUKE_VIDAL_RULES.traitor, false);
|
||||
assert.equal(DUKE_VIDAL_RULES.strength, 6);
|
||||
assert.equal(DUKE_VIDAL_RULES.faction, 'ecaz');
|
||||
assert.ok(
|
||||
DUKE_VIDAL_RULES.gameplay.some((paragraph) =>
|
||||
paragraph.includes('five spice'),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
DUKE_VIDAL_RULES.gameplay.some((paragraph) =>
|
||||
paragraph.includes('Only Ecaz'),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
DUKE_VIDAL_RULES.gameplay.some((paragraph) => paragraph.includes('Karama')),
|
||||
);
|
||||
assert.ok(
|
||||
DUKE_VIDAL_RULES.gameplay.every(
|
||||
(paragraph) => !/https?:\/\//.test(paragraph),
|
||||
),
|
||||
);
|
||||
assert.equal(DUKE_VIDAL_RULES.implementation.custody, 'implemented');
|
||||
assert.equal(DUKE_VIDAL_RULES.implementation.capture, 'not-implemented');
|
||||
assert.equal(DUKE_VIDAL_RULES.implementation.revival, 'not-implemented');
|
||||
assert.equal(DUKE_VIDAL_RULES.implementation.allyLoanReturn, 'unresolved');
|
||||
assert.ok(Object.isFrozen(DUKE_VIDAL_RULES));
|
||||
assert.ok(Object.isFrozen(DUKE_VIDAL_RULES.gameplay));
|
||||
assert.ok(Object.isFrozen(DUKE_VIDAL_RULES.implementation));
|
||||
});
|
||||
@@ -390,7 +390,7 @@ function completeMovement(g: Game) {
|
||||
assert.equal(g.phase, 6);
|
||||
return g;
|
||||
}
|
||||
void test('an acquired Duke is usable in a genuine following battle, then the same disc is consumed after death', () => {
|
||||
function deadFromBattle() {
|
||||
const initial = fixture();
|
||||
p(initial, 'ec').forces = { 'arrakeen:10': 1 };
|
||||
p(initial, 'ec').reserves = 19;
|
||||
@@ -429,6 +429,72 @@ void test('an acquired Duke is usable in a genuine following battle, then the sa
|
||||
assert.equal(g.dukeVidal!.leader.id, DUKE_VIDAL_ID);
|
||||
assert.equal(p(g, 'in').spice, 24);
|
||||
assert.ok(p(g, 'ec').leaders.every((l) => !l.dead));
|
||||
g = applyAction(g, 'in', { type: 'decision', discard: [] });
|
||||
assert.equal(g.decision, null);
|
||||
return g;
|
||||
}
|
||||
void test('an acquired Duke is usable in a genuine following battle, then the same disc is consumed after death', () => {
|
||||
const g = deadFromBattle();
|
||||
assert.equal(viewGame(g, 'ec').dukeVidal?.controller, null);
|
||||
assert.ok(g.players.every((seat) => seat.leaders.every((l) => l.id !== DUKE_VIDAL_ID)));
|
||||
});
|
||||
void test('genuine acquisition and battle death lead to one paid Ecaz return of the shared disc at five, unclaimed', () => {
|
||||
let g = deadFromBattle();
|
||||
g.phase = 4;
|
||||
g.turn = 3;
|
||||
g.battle = null;
|
||||
g.ready = [];
|
||||
const ec = p(g, 'ec');
|
||||
ec.spice = 9;
|
||||
const option = viewGame(g, 'ec').revival.leaders.find((l) => l.id === DUKE_VIDAL_ID);
|
||||
assert.deepEqual(option, {
|
||||
id: DUKE_VIDAL_ID, name: 'Duke Prad Vidal',
|
||||
normalCost: 5, cost: 5, early: false, affordable: true,
|
||||
});
|
||||
assert.equal(viewGame(g, 'in').revival.leaders.some((l) => l.id === DUKE_VIDAL_ID), false);
|
||||
const before = structuredClone(g);
|
||||
reject(g, 'in', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
|
||||
assert.deepEqual(g, before);
|
||||
g = applyAction(reload(g), 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
|
||||
assert.equal(g.pendingRevival, null);
|
||||
assert.equal(p(g, 'ec').spice, 4);
|
||||
assert.equal(p(g, 'ec').leaderRevived, true);
|
||||
assert.equal(g.dukeVidal!.leader.dead, false);
|
||||
assert.equal(g.dukeVidal!.leader.deaths, 1);
|
||||
assert.equal(g.dukeVidal!.leader.strength, 6);
|
||||
assert.deepEqual(
|
||||
[g.dukeVidal!.controller, g.dukeVidal!.source, g.dukeVidal!.acquiredTurn],
|
||||
[null, null, null],
|
||||
);
|
||||
assert.equal(p(g, 'ec').leaders.length, 5);
|
||||
assert.equal(viewGame(g, 'ec').players.find((seat) => seat.id === 'ec')!.leaders.some((l) => l.id === DUKE_VIDAL_ID), false);
|
||||
assert.equal(viewGame(g, 'ec').revival.leaders.some((l) => l.id === DUKE_VIDAL_ID), false);
|
||||
reject(g, 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
|
||||
assert.deepEqual(reload(g).dukeVidal, g.dukeVidal);
|
||||
});
|
||||
void test('Ghola revives the genuine dead Ecaz Duke as an unclaimed disc without spice or ordinary slot', () => {
|
||||
let g = deadFromBattle();
|
||||
const cardIndex = g.deck.findIndex((c) => c.effect === 'ghola');
|
||||
assert.ok(cardIndex >= 0);
|
||||
const card = g.deck.splice(cardIndex, 1)[0];
|
||||
p(g, 'ec').hand.push(card);
|
||||
const beforeSpice = p(g, 'ec').spice;
|
||||
assert.equal(viewGame(g, 'ec').ghola.leaders.some((l) => l.id === DUKE_VIDAL_ID), true);
|
||||
assert.equal(viewGame(g, 'in').ghola.leaders.some((l) => l.id === DUKE_VIDAL_ID), false);
|
||||
reject(g, 'in', { type: 'card', card: card.id, leader: DUKE_VIDAL_ID });
|
||||
g = applyAction(reload(g), 'ec', { type: 'card', card: card.id, leader: DUKE_VIDAL_ID });
|
||||
assert.equal(p(g, 'ec').spice, beforeSpice);
|
||||
assert.equal(p(g, 'ec').leaderRevived, false);
|
||||
assert.equal(g.dukeVidal!.leader.dead, false);
|
||||
assert.equal(g.dukeVidal!.leader.deaths, 1);
|
||||
assert.equal(g.dukeVidal!.controller, null);
|
||||
assert.equal(g.dukeVidal!.source, null);
|
||||
assert.equal(g.dukeVidal!.leader.usedAt, undefined);
|
||||
assert.equal(g.dukeVidal!.acquiredTurn, null);
|
||||
assert.equal(p(g, 'ec').leaders.length, 5);
|
||||
assert.equal(p(g, 'ec').hand.some((c) => c.id === card.id), false);
|
||||
assert.deepEqual(reload(g).dukeVidal, g.dukeVidal);
|
||||
assert.equal(viewGame(reload(g), 'ec').ghola.leaders.some((l) => l.id === DUKE_VIDAL_ID), false);
|
||||
});
|
||||
void test('unused source-Ecaz custody persists through a genuine end of turn without manufacturing another disc', () => {
|
||||
let g = acquire(enter());
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { createDukeVidal, DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { newRevivalRules } from '../game/revival';
|
||||
import type { Action } from '../game/engine';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
|
||||
async function room(t: test.TestContext, advanced: boolean) {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
const made = await store.rooms.createRoom('Ecaz revival', 'ecaz', advanced, ['ecaz', 'ix']);
|
||||
const code = made.view.code;
|
||||
const tokens = [made.token];
|
||||
for (const faction of ['moritani', 'tleilaxu', 'emperor'] as const)
|
||||
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
|
||||
const auths = await Promise.all(tokens.map((token) => store.rooms.authenticate(code, token)));
|
||||
const ids = auths.map((auth) => auth.playerId);
|
||||
const game = await store.rooms.readRoom(code);
|
||||
Object.assign(game, {
|
||||
status: 'playing', phase: 4, turn: 3, order: ids,
|
||||
dukeVidal: createDukeVidal(), revivalRules: newRevivalRules(),
|
||||
deck: baseDeck(), discard: [],
|
||||
});
|
||||
game.dukeVidal!.leader.dead = true;
|
||||
game.dukeVidal!.leader.deaths = 1;
|
||||
game.players[0].spice = 10;
|
||||
return {
|
||||
...store, code, tokens, auths, ids, game,
|
||||
save() {
|
||||
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(game), game.version, code);
|
||||
},
|
||||
async act(index: number, action: Action) {
|
||||
const rooms = store.restart();
|
||||
const before = await rooms.readRoom(code);
|
||||
await rooms.act(code, auths[index], before.version, action, clock);
|
||||
return store.restart().readRoom(code);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
void test('a discounted pending Ecaz Duke return survives SQLite restart and cancellation without double payment', async (t) => {
|
||||
const f = await room(t, true);
|
||||
const [ecaz, , tleilaxu] = f.game.players;
|
||||
ecaz.ally = tleilaxu.id;
|
||||
tleilaxu.ally = ecaz.id;
|
||||
tleilaxu.specialKaramaUsed = true;
|
||||
f.game.revivalRules!.allyDiscount = ecaz.id;
|
||||
const at = f.game.deck.findIndex((card) => card.effect === 'karama');
|
||||
assert.ok(at >= 0);
|
||||
const card = f.game.deck.splice(at, 1)[0];
|
||||
f.game.players[3].hand.push(card);
|
||||
f.save();
|
||||
const ownerView = await f.restart().readSeatView(f.code, f.auths[0]);
|
||||
assert.equal(ownerView.revival.leaders.find((l) => l.id === DUKE_VIDAL_ID)?.cost, 3);
|
||||
assert.equal((await f.restart().readSeatView(f.code, f.auths[1])).revival.leaders.some(
|
||||
(l) => l.id === DUKE_VIDAL_ID), false);
|
||||
const pending = await f.act(0, { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
|
||||
assert.equal(pending.response?.kind, 'revivalDiscount');
|
||||
assert.equal(pending.pendingRevival?.normalCost, 5);
|
||||
assert.equal(pending.pendingRevival?.cost, 3);
|
||||
assert.equal(pending.players[0].spice, 10);
|
||||
assert.equal(pending.dukeVidal!.leader.dead, true);
|
||||
const done = await f.act(3, { type: 'card', card: card.id, mode: 'cancel' });
|
||||
assert.equal(done.dukeVidal!.leader.dead, false);
|
||||
assert.equal(done.dukeVidal!.leader.deaths, 1);
|
||||
assert.deepEqual([done.dukeVidal!.controller, done.dukeVidal!.source, done.dukeVidal!.acquiredTurn],
|
||||
[null, null, null]);
|
||||
assert.equal(done.players[0].spice, 5);
|
||||
assert.equal(done.players[2].spice, 5);
|
||||
assert.equal(done.players[0].leaderRevived, true);
|
||||
assert.equal(done.players[0].leaders.some((l) => l.id === DUKE_VIDAL_ID), false);
|
||||
assert.equal(done.discard.filter((c) => c.id === card.id).length, 1);
|
||||
await assert.rejects(f.restart().act(f.code, f.auths[0], pending.version,
|
||||
{ type: 'reviveLeader', leader: DUKE_VIDAL_ID }, clock));
|
||||
assert.deepEqual(await f.restart().readRoom(f.code), done);
|
||||
});
|
||||
|
||||
void test('a physical Ecaz Ghola returns the saved shared Duke once through SQLite, without a leader slot or spice', async (t) => {
|
||||
const f = await room(t, false);
|
||||
f.game.phase = 7;
|
||||
const at = f.game.deck.findIndex((card) => card.effect === 'ghola');
|
||||
assert.ok(at >= 0);
|
||||
const card = f.game.deck.splice(at, 1)[0];
|
||||
f.game.players[0].hand.push(card);
|
||||
f.game.dukeVidal!.leader.usedAt = 'arrakeen';
|
||||
f.save();
|
||||
const own = await f.restart().readSeatView(f.code, f.auths[0]);
|
||||
const rival = await f.restart().readSeatView(f.code, f.auths[1]);
|
||||
assert.equal(own.ghola.leaders.some((l) => l.id === DUKE_VIDAL_ID), true);
|
||||
assert.equal(rival.ghola.leaders.some((l) => l.id === DUKE_VIDAL_ID), false);
|
||||
const before = await f.restart().readRoom(f.code);
|
||||
await assert.rejects(f.restart().act(f.code, f.auths[1], before.version,
|
||||
{ type: 'card', card: card.id, leader: DUKE_VIDAL_ID }, clock));
|
||||
assert.deepEqual(await f.restart().readRoom(f.code), before);
|
||||
const done = await f.act(0, { type: 'card', card: card.id, leader: DUKE_VIDAL_ID });
|
||||
assert.equal(done.dukeVidal!.leader.dead, false);
|
||||
assert.equal(done.dukeVidal!.leader.deaths, 1);
|
||||
assert.equal(done.dukeVidal!.leader.usedAt, undefined);
|
||||
assert.deepEqual([done.dukeVidal!.controller, done.dukeVidal!.source, done.dukeVidal!.acquiredTurn],
|
||||
[null, null, null]);
|
||||
assert.equal(done.players[0].spice, 10);
|
||||
assert.equal(done.players[0].leaderRevived, false);
|
||||
assert.equal(done.players[0].leaders.some((l) => l.id === DUKE_VIDAL_ID), false);
|
||||
assert.equal(done.discard.filter((c) => c.id === card.id).length, 1);
|
||||
await assert.rejects(f.restart().act(f.code, f.auths[0], before.version,
|
||||
{ type: 'card', card: card.id, leader: DUKE_VIDAL_ID }, clock));
|
||||
assert.deepEqual(await f.restart().readRoom(f.code), done);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, newPlayer, type Game } from '../game/engine';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { FACTIONS, type FactionId } from '../game/catalog';
|
||||
import {
|
||||
createDukeVidal,
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
EcazDukeRevivalError,
|
||||
DUKE_REVIVAL_NORMAL_COST,
|
||||
} from '../game/ecaz-duke-revival';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { quoteRevivalCancellation } from '../game/revival-cancellation';
|
||||
import { botActions } from '../game/bots';
|
||||
const p = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
|
||||
function fixture(advanced = false) {
|
||||
const g = createGame(
|
||||
@@ -360,6 +363,15 @@ void test('set-aside battle-death identity and valid retained legacy custody res
|
||||
released,
|
||||
);
|
||||
});
|
||||
void test('a dead Duke with prior Ecaz Nexus custody resolves to the same physical return', () => {
|
||||
const g = fixture();
|
||||
g.dukeVidal = acquireDuke(createDukeVidal(), 'ec', 2, 'ecazNexus');
|
||||
g.dukeVidal.leader.dead = true;
|
||||
g.dukeVidal.leader.deaths = 1;
|
||||
const quoted = resolveEcazDukeRevival(g, 'ec');
|
||||
assert.equal(quoted.duke.source, 'ecazNexus');
|
||||
assert.equal(quoted.normalCost, 5);
|
||||
});
|
||||
void test('invalid source context and duplicate Ecaz owners fail independently from paid revival timing', () => {
|
||||
for (const change of [
|
||||
(g: Game) => {
|
||||
@@ -388,3 +400,165 @@ void test('invalid source context and duplicate Ecaz owners fail independently f
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
});
|
||||
void test('four native deaths plus Duke open Ecaz first cohort, which stays open after a paid return', () => {
|
||||
let g = fixture();
|
||||
p(g, 'ec').leaders.slice(0, 4).forEach((l) => {
|
||||
l.dead = true;
|
||||
l.deaths = 1;
|
||||
});
|
||||
p(g, 'ec').spice = 20;
|
||||
const initial = viewGame(g, 'ec').revival.leaders;
|
||||
assert.equal(initial.filter((l) => l.id !== DUKE_VIDAL_ID).length, 4);
|
||||
assert.equal(initial.find((l) => l.id === DUKE_VIDAL_ID)?.early, false);
|
||||
g = applyAction(g, 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
|
||||
assert.equal(p(g, 'ec').revivalCycle, 1);
|
||||
assert.equal(p(g, 'ec').spice, 15);
|
||||
p(g, 'ec').leaderRevived = false;
|
||||
g.turn++;
|
||||
assert.equal(viewGame(g, 'ec').revival.leaders.length, 4);
|
||||
const native = p(g, 'ec').leaders[0].id;
|
||||
g = applyAction(g, 'ec', { type: 'reviveLeader', leader: native });
|
||||
assert.equal(p(g, 'ec').spice, 15 - p(g, 'ec').leaders[0].strength);
|
||||
assert.equal(p(g, 'ec').leaders[4].dead, false);
|
||||
assert.equal(g.dukeVidal!.controller, null);
|
||||
});
|
||||
void test('Ecaz native repeated six-disc cycles stay gated while Duke remains independently revivable', () => {
|
||||
const g = fixture();
|
||||
p(g, 'ec').leaders.forEach((l) => { l.dead = true; l.deaths = 1; });
|
||||
p(g, 'ec').leaders[0].deaths = 2;
|
||||
p(g, 'ec').spice = 10;
|
||||
p(g, 'ec').revivalCycle = 1;
|
||||
const options = viewGame(g, 'ec').revival;
|
||||
assert.match(options.cycleBlock!, /six-disc repeated/);
|
||||
assert.deepEqual(options.leaders.map((l) => l.id), [DUKE_VIDAL_ID]);
|
||||
assert.equal(options.leaders[0].cost, 5);
|
||||
const returned = applyAction(g, 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
|
||||
assert.equal(p(returned, 'ec').revivalCycle, 1);
|
||||
assert.equal(returned.dukeVidal!.leader.deaths, 1);
|
||||
});
|
||||
void test('saved discounted Duke request can be canceled back to five and paid once with income', () => {
|
||||
let g = fixture(true);
|
||||
g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
|
||||
g.players.push(newPlayer('k', 'Karama', 'emperor'));
|
||||
g.order = g.players.map((seat) => seat.id);
|
||||
g.deck = baseDeck();
|
||||
const karamaIndex = g.deck.findIndex((card) => card.effect === 'karama');
|
||||
p(g, 'k').hand.push(g.deck.splice(karamaIndex, 1)[0]);
|
||||
p(g, 'ec').spice = 8;
|
||||
p(g, 't').ally = 'ec';
|
||||
p(g, 'ec').ally = 't';
|
||||
p(g, 't').specialKaramaUsed = true;
|
||||
g = applyAction(g, 't', { type: 'tleilaxuAllyDiscount' });
|
||||
assert.equal(viewGame(g, 'ec').revival.leaders.find((l) => l.id === DUKE_VIDAL_ID)?.cost, 3);
|
||||
g = applyAction(g, 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
|
||||
assert.deepEqual(g.pendingRevival?.checks, []);
|
||||
assert.equal(g.response?.kind, 'revivalDiscount');
|
||||
assert.equal(g.pendingRevival?.normalCost, 5);
|
||||
assert.equal(g.pendingRevival?.cost, 3);
|
||||
const saved = JSON.parse(JSON.stringify(g)) as Game;
|
||||
const before = structuredClone(saved);
|
||||
const quote = quoteRevivalCancellation(saved, saved.response!);
|
||||
assert.equal(quote.pending?.cost, 5);
|
||||
assert.deepEqual(saved, before);
|
||||
g = applyAction(saved, 'k', {
|
||||
type: 'card', card: p(saved, 'k').hand[0].id, mode: 'cancel',
|
||||
});
|
||||
assert.equal(p(g, 'ec').spice, 3);
|
||||
assert.equal(g.dukeVidal!.leader.dead, false);
|
||||
assert.equal(g.dukeVidal!.controller, null);
|
||||
assert.equal(p(g, 'ec').leaderRevived, true);
|
||||
assert.equal(g.pendingRevival, null);
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(p(g, 't').spice, 5, 'the automatic paid revival income settles once');
|
||||
});
|
||||
void test('Advanced Tleilaxu stop decision survives JSON and cannot charge Duke before approval', () => {
|
||||
let g = fixture(true);
|
||||
g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
|
||||
g.order = g.players.map((seat) => seat.id);
|
||||
p(g, 'ec').spice = 7;
|
||||
g = applyAction(g, 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
|
||||
assert.equal(g.decision?.kind, 'revivalStop');
|
||||
assert.equal(g.pendingRevival?.normalCost, 5);
|
||||
assert.equal(p(g, 'ec').spice, 7);
|
||||
assert.equal(g.dukeVidal!.leader.dead, true);
|
||||
g = JSON.parse(JSON.stringify(g)) as Game;
|
||||
g = applyAction(g, 't', { type: 'decision', decline: true });
|
||||
assert.equal(p(g, 'ec').spice, 2);
|
||||
assert.equal(p(g, 't').spice, 5);
|
||||
assert.equal(g.dukeVidal!.leader.dead, false);
|
||||
assert.equal(g.dukeVidal!.controller, null);
|
||||
assert.equal(g.pendingRevival, null);
|
||||
});
|
||||
void test('all four Ecaz AI profiles can choose the authoritative five-spice Duke return', () => {
|
||||
const g = fixture();
|
||||
p(g, 'ec').spice = 10;
|
||||
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
p(g, 'ec').bot = profile;
|
||||
const view = viewGame(g, 'ec');
|
||||
const selected = botActions(view).find(
|
||||
(action) => action.type === 'reviveLeader' && action.leader === DUKE_VIDAL_ID,
|
||||
);
|
||||
assert.ok(selected, profile);
|
||||
const done = applyAction(g, 'ec', selected);
|
||||
assert.equal(done.dukeVidal!.leader.dead, false);
|
||||
assert.equal(done.dukeVidal!.controller, null);
|
||||
assert.equal(done.players.find((seat) => seat.id === 'ec')!.spice, 5);
|
||||
}
|
||||
});
|
||||
void test('corrupt saved pending Duke custody rejects continuation without changing spice or the request', () => {
|
||||
let g = fixture(true);
|
||||
g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
|
||||
g.order = g.players.map((seat) => seat.id);
|
||||
p(g, 'ec').spice = 9;
|
||||
g = applyAction(g, 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
|
||||
assert.equal(g.decision?.kind, 'revivalStop');
|
||||
g = JSON.parse(JSON.stringify(g)) as Game;
|
||||
p(g, 'ec').leaders.push(structuredClone(g.dukeVidal!.leader));
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, 't', { type: 'decision', decline: true }));
|
||||
assert.deepEqual(g, before);
|
||||
assert.equal(p(g, 'ec').spice, 9);
|
||||
});
|
||||
void test('five native Ecaz leaders in Tanks open the ordinary first cohort even while Duke lives', () => {
|
||||
const g = fixture();
|
||||
g.dukeVidal!.leader.dead = false;
|
||||
g.dukeVidal!.leader.deaths = 0;
|
||||
p(g, 'ec').leaders.forEach((l) => { l.dead = true; l.deaths = 1; });
|
||||
p(g, 'ec').spice = 20;
|
||||
const options = viewGame(g, 'ec').revival.leaders;
|
||||
assert.equal(options.length, 5);
|
||||
assert.equal(options.some((l) => l.id === DUKE_VIDAL_ID), false);
|
||||
});
|
||||
void test('unavailable captured native leaders retain the ordinary no-survivor exception', () => {
|
||||
const g = fixture();
|
||||
g.dukeVidal!.leader.dead = false;
|
||||
g.dukeVidal!.leader.deaths = 0;
|
||||
p(g, 'ec').leaders.forEach((l, i) => {
|
||||
l.dead = i === 0;
|
||||
l.deaths = i === 0 ? 1 : 0;
|
||||
if (i !== 0) l.capturedBy = 'm';
|
||||
});
|
||||
p(g, 'ec').spice = 20;
|
||||
assert.deepEqual(viewGame(g, 'ec').revival.leaders.map((l) => l.id),
|
||||
[p(g, 'ec').leaders[0].id]);
|
||||
});
|
||||
void test('concealed foreign-ghola execution does not reveal Ecaz revival cohort history', () => {
|
||||
const g = fixture(true);
|
||||
g.expansions = ['ecaz', 'ix'];
|
||||
g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
|
||||
g.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
const leader = p(g, 'ec').leaders[0];
|
||||
leader.dead = false;
|
||||
leader.deaths = 1;
|
||||
leader.gholaBy = 't';
|
||||
leader.capturedBy = 'h';
|
||||
leader.concealed = { captor: 'h', controller: 't', dead: false, deaths: 1 };
|
||||
const before = viewGame(g, 'ec');
|
||||
leader.dead = true;
|
||||
leader.deaths = 2;
|
||||
delete leader.capturedBy;
|
||||
const after = viewGame(g, 'ec');
|
||||
assert.deepEqual(after.players.find((seat) => seat.id === 'ec')?.leaders,
|
||||
before.players.find((seat) => seat.id === 'ec')?.leaders);
|
||||
assert.deepEqual(after.revival, before.revival);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
newPlayer,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
RuleError,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { baseDeck, treacheryDeck, spiceDeck, type Card } from '../game/cards';
|
||||
@@ -314,10 +315,7 @@ void test('Mirror remains gated at actual plan selection and cannot award income
|
||||
const defense = hold(g, 'owner', (card) => card.kind === 'snooper');
|
||||
const poison = hold(g, 'other', (card) => card.kind === 'poison');
|
||||
const before = reload(g);
|
||||
assert.throws(
|
||||
() => readyBattle(g, mirror.id, defense.id, poison.id),
|
||||
/Choose a weapon/,
|
||||
);
|
||||
assert.throws(() => readyBattle(g, mirror.id, defense.id, poison.id), RuleError);
|
||||
assert.deepEqual(g, before);
|
||||
assert.equal(player(g, 'ec').spice, 20);
|
||||
assert.deepEqual(viewGame(g, 'ec').ecazPoisonIncome, []);
|
||||
|
||||
@@ -5,9 +5,13 @@ import { richeseCards } from '../game/richese-cards';
|
||||
import {
|
||||
battleCardEffects,
|
||||
battleWeaponsExplode,
|
||||
isMirrorWeapon,
|
||||
isWeaponCard,
|
||||
validBattleCardPair,
|
||||
defaultVoiceMatch,
|
||||
playedVoiceMatch,
|
||||
} from '../game/battle-cards';
|
||||
import { strongholdBattleEffects } from '../game/stronghold-battle';
|
||||
|
||||
const cards = [...baseDeck(), ...ixBattleCards(), ...richeseCards()];
|
||||
const get = (kind: Card['kind']) => cards.find((card) => card.kind === kind)!;
|
||||
@@ -137,6 +141,15 @@ void test('copied Lasgun uses the same explosion predicate and only actual shiel
|
||||
assert.equal(battleWeaponsExplode(get('lasgun'), get('shield')), true);
|
||||
});
|
||||
|
||||
void test('Carthag does not grant free Snooper when physical Mirror copies a poison attack', () => {
|
||||
const shield = get('shield');
|
||||
const poison = get('poison');
|
||||
const copied = strongholdBattleEffects(mirror, shield, poison, undefined, true, true, 'carthag');
|
||||
assert.equal(copied.attackerDead, true);
|
||||
const ordinary = strongholdBattleEffects(get('projectile'), shield, poison, undefined, true, true, 'carthag');
|
||||
assert.equal(ordinary.attackerDead, false);
|
||||
});
|
||||
|
||||
void test('empty and Worthless copies have no attack; Stone remains a separate post-reveal outcome', () => {
|
||||
for (const weapon of [undefined, get('worthless'), stone]) {
|
||||
const before = structuredClone([mirror, weapon]);
|
||||
@@ -151,11 +164,16 @@ void test('empty and Worthless copies have no attack; Stone remains a separate p
|
||||
}
|
||||
});
|
||||
|
||||
void test('effective attack support does not activate Mirror plan admission or invent physical cleanup rules', () => {
|
||||
assert.equal(isWeaponCard(mirror), false);
|
||||
assert.equal(validBattleCardPair(mirror, get('weirdingWay')), false);
|
||||
assert.equal(mirror.id, 'richese-mirror-weapon');
|
||||
assert.equal(mirror.kind, 'special');
|
||||
void test('only the canonical physical Mirror is a named weapon in plans and Voice', () => {
|
||||
assert.equal(isMirrorWeapon(mirror), true);
|
||||
assert.equal(isWeaponCard(mirror), true);
|
||||
assert.equal(validBattleCardPair(mirror, get('weirdingWay')), true);
|
||||
assert.equal(defaultVoiceMatch(mirror, 'mirrorWeapon'), true);
|
||||
assert.equal(playedVoiceMatch(mirror, 'weapon', 'mirrorWeapon'), true);
|
||||
const counterfeit = { ...mirror, id: 'forged-mirror' };
|
||||
assert.equal(isMirrorWeapon(counterfeit), false);
|
||||
assert.equal(isWeaponCard(counterfeit), false);
|
||||
assert.equal(validBattleCardPair(counterfeit), false);
|
||||
assert.throws(() => battleCardEffects(mirror, undefined, mirror));
|
||||
assert.throws(() => battleWeaponsExplode(mirror, undefined, mirror));
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import {
|
||||
quoteBattleWeaponRetention,
|
||||
resolveBattleWeapons,
|
||||
type BattleWeaponSide,
|
||||
type EffectiveBattleWeapon,
|
||||
@@ -75,8 +76,8 @@ void test('Stone Burner and Poison Tooth copies require separate choices before
|
||||
const kind = weapon === stone ? 'stoneBurner' : 'poisonTooth';
|
||||
const { result, other } = copied(side, weapon);
|
||||
assert.deepEqual(result.choiceOrder, [
|
||||
{ side, kind },
|
||||
{ side: other, kind },
|
||||
{ side, kind, physicalId: mirror.id, copiedFrom: weapon.id },
|
||||
{ side: other, kind, physicalId: weapon.id, copiedFrom: null },
|
||||
]);
|
||||
assert.equal(result[side].choice, kind);
|
||||
assert.equal(result[other].choice, kind);
|
||||
@@ -102,10 +103,14 @@ void test('ordinary choices retain input order when no copy dependency exists an
|
||||
{
|
||||
side: 'attacker',
|
||||
kind: attacker === stone ? 'stoneBurner' : 'poisonTooth',
|
||||
physicalId: attacker.id,
|
||||
copiedFrom: null,
|
||||
},
|
||||
{
|
||||
side: 'defender',
|
||||
kind: defender === stone ? 'stoneBurner' : 'poisonTooth',
|
||||
physicalId: defender.id,
|
||||
copiedFrom: null,
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -285,3 +290,82 @@ void test('resolution accepts frozen physical inputs and returned descriptors ne
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
void test('physical winner retention ignores the copied attack, including activated Tooth and Artillery', () => {
|
||||
for (const weapon of [
|
||||
card('poisonTooth'),
|
||||
card('artillery'),
|
||||
stone,
|
||||
card('projectile'),
|
||||
undefined,
|
||||
]) {
|
||||
for (const side of sides) {
|
||||
const { result } = copied(side, weapon);
|
||||
assert.equal(result[side].physicalId, mirror.id);
|
||||
assert.equal(result[side].copiedFrom, weapon?.id ?? null);
|
||||
const winner = quoteBattleWeaponRetention({
|
||||
card: mirror,
|
||||
outcome: 'winner',
|
||||
traitorDecided: false,
|
||||
toothUsed: true,
|
||||
});
|
||||
assert.deepEqual(winner, { physicalId: mirror.id, mayRetain: true });
|
||||
assert.deepEqual(
|
||||
quoteBattleWeaponRetention({
|
||||
card: mirror,
|
||||
outcome: 'loser',
|
||||
traitorDecided: false,
|
||||
toothUsed: true,
|
||||
}),
|
||||
{ physicalId: mirror.id, mayRetain: false },
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('original physical Tooth and Artillery retain their existing mandatory disposal exceptions', () => {
|
||||
const quote = (
|
||||
physical: Card,
|
||||
outcome: 'winner' | 'loser',
|
||||
toothUsed: boolean,
|
||||
traitorDecided = false,
|
||||
) =>
|
||||
quoteBattleWeaponRetention({
|
||||
card: physical,
|
||||
outcome,
|
||||
toothUsed,
|
||||
traitorDecided,
|
||||
});
|
||||
const tooth = card('poisonTooth');
|
||||
const artillery = card('artillery');
|
||||
assert.deepEqual(quote(tooth, 'winner', true), {
|
||||
physicalId: tooth.id,
|
||||
mayRetain: false,
|
||||
});
|
||||
assert.equal(quote(tooth, 'winner', false).mayRetain, true);
|
||||
assert.equal(quote(artillery, 'winner', false).mayRetain, false);
|
||||
assert.equal(quote(artillery, 'winner', true, true).mayRetain, true);
|
||||
assert.equal(quote(tooth, 'winner', true, true).mayRetain, true);
|
||||
assert.equal(quote(tooth, 'loser', false, true).mayRetain, false);
|
||||
assert.equal(quote(card('projectile'), 'winner', false).mayRetain, true);
|
||||
assert.equal(quote(card('hero'), 'winner', false, true).mayRetain, false);
|
||||
});
|
||||
|
||||
void test('retention quote refuses forged Mirror identities instead of treating them as generic Specials', () => {
|
||||
for (const forged of [
|
||||
{ ...mirror, id: 'forged-mirror' },
|
||||
{ ...mirror, effect: undefined },
|
||||
{ ...mirror, kind: 'artillery' as const },
|
||||
]) {
|
||||
assert.throws(
|
||||
() =>
|
||||
quoteBattleWeaponRetention({
|
||||
card: forged,
|
||||
outcome: 'winner',
|
||||
traitorDecided: false,
|
||||
toothUsed: false,
|
||||
}),
|
||||
/canonical physical identity/,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { treacheryDeck, type Card } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
|
||||
const mirror = richeseCards().find((card) => card.effect === 'mirrorWeapon')!;
|
||||
function fixture(advanced = false) {
|
||||
let g = createGame('MIRROR', newPlayer('p', 'Mirror holder', 'emperor'), advanced, ['choam']);
|
||||
g.players.push(newPlayer('q', 'Opponent', 'guild'), newPlayer('r', 'Richese', 'richese'));
|
||||
g.status = 'playing';
|
||||
g.phase = 6;
|
||||
g.turn = 2;
|
||||
g.active = 'p';
|
||||
g.order = ['p', 'q', 'r'];
|
||||
g.storm = 18;
|
||||
g.deck = treacheryDeck(['choam']);
|
||||
g.richeseCache = richeseCards().filter((card) => card.id !== mirror.id);
|
||||
for (const player of g.players) {
|
||||
player.hand = [];
|
||||
player.forces = {};
|
||||
player.reserves = 20;
|
||||
player.spice = 20;
|
||||
player.traitors = [];
|
||||
}
|
||||
for (const player of g.players.slice(0, 2)) {
|
||||
player.forces = { 'pasty_mesa:5': 5 };
|
||||
player.reserves = 15;
|
||||
}
|
||||
g.players[0].hand = [mirror];
|
||||
g = applyAction(g, 'p', { type: 'chooseBattle', territory: 'pasty_mesa', target: 'q' });
|
||||
for (const id of ['p', 'q'])
|
||||
g = applyAction(g, id, { type: 'battlePreparationReady', event: g.battle!.event });
|
||||
while (g.battle?.preparation)
|
||||
g = applyAction(g, g.battle.preparation.owner, { type: 'declineBattlePower' });
|
||||
if (g.decision?.kind === 'fullPlanOffer')
|
||||
g = applyAction(g, g.decision.player, { type: 'decision', decline: true });
|
||||
return g;
|
||||
}
|
||||
function give(g: Game, kind: Card['kind'] | 'stoneBurner') {
|
||||
const card = kind === 'stoneBurner'
|
||||
? richeseCards().find((item) => item.effect === 'stoneBurner')!
|
||||
: g.deck.find((item) => item.kind === kind)!;
|
||||
g.players[1].hand.push(card);
|
||||
g.deck = g.deck.filter((item) => item.id !== card.id);
|
||||
g.richeseCache = g.richeseCache?.filter((item) => item.id !== card.id);
|
||||
return card;
|
||||
}
|
||||
function reveal(g: Game, source?: Card, ownDial = 2, otherDial = 2) {
|
||||
g = applyAction(g, 'p', {
|
||||
type: 'battlePlan', dial: ownDial, support: g.advanced ? ownDial : 0,
|
||||
leader: g.players[0].leaders[0].id, weapon: mirror.id,
|
||||
});
|
||||
return applyAction(g, 'q', {
|
||||
type: 'battlePlan', dial: otherDial, support: g.advanced ? otherDial : 0,
|
||||
leader: g.players[1].leaders[0].id, weapon: source?.id,
|
||||
});
|
||||
}
|
||||
function finish(g: Game) {
|
||||
g = applyAction(g, 'q', { type: 'traitorCall', call: false });
|
||||
return applyAction(g, 'p', { type: 'traitorCall', call: false });
|
||||
}
|
||||
|
||||
void test('copied Tooth chooses before its original despite reverse storm order; restored choices keep physical custody', () => {
|
||||
let g = fixture();
|
||||
const tooth = give(g, 'poisonTooth');
|
||||
g.order = ['q', 'p', 'r'];
|
||||
g = reveal(g, tooth, 4, 2);
|
||||
assert.equal(g.decision?.kind, 'poisonTooth');
|
||||
assert.equal(g.decision.player, 'p');
|
||||
const reordered = structuredClone(g);
|
||||
reordered.decision = { kind: 'poisonTooth', player: 'q', event: g.battle!.event,
|
||||
physicalId: tooth.id, copiedFrom: null };
|
||||
assert.throws(() => viewGame(reordered, 'q'));
|
||||
assert.throws(() => applyAction(reordered, 'q', {
|
||||
type: 'decision', event: g.battle!.event, activate: false,
|
||||
}));
|
||||
const preanswered = structuredClone(g);
|
||||
preanswered.battle!.poisonTooth = { q: true };
|
||||
assert.throws(() => viewGame(preanswered, 'p'));
|
||||
assert.deepEqual(reordered.battle, g.battle);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, 'r', { type: 'decision', event: g.battle!.event, activate: true }));
|
||||
assert.deepEqual(g, before);
|
||||
g = JSON.parse(JSON.stringify(g));
|
||||
g = applyAction(g, 'p', { type: 'decision', event: g.battle!.event, activate: true });
|
||||
assert.equal(g.decision?.kind, 'poisonTooth');
|
||||
assert.equal(g.decision.player, 'q');
|
||||
assert.equal(g.battle!.poisonTooth?.p, true);
|
||||
const wrong = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, 'q', { type: 'decision', event: 'stale', activate: true }));
|
||||
assert.deepEqual(g, wrong);
|
||||
g = JSON.parse(JSON.stringify(g));
|
||||
g = applyAction(g, 'q', { type: 'decision', event: g.battle!.event, activate: false });
|
||||
g = finish(g);
|
||||
assert.equal(g.lastBattleContext?.winner, 'p');
|
||||
assert.equal(g.players[0].leaders[0].dead, true);
|
||||
assert.equal(g.players[1].leaders[0].dead, true);
|
||||
assert.equal(g.decision?.kind, 'battleCards');
|
||||
g = applyAction(g, 'p', { type: 'decision', discard: [] });
|
||||
assert.equal(g.players[0].hand.filter((card) => card.id === mirror.id).length, 1);
|
||||
assert.equal(g.discard.filter((card) => card.id === tooth.id).length, 1);
|
||||
});
|
||||
|
||||
void test('copied Stone has two independent modes in copy-first order and the original never becomes a second Mirror', () => {
|
||||
let g = fixture();
|
||||
const stone = give(g, 'stoneBurner');
|
||||
g = reveal(g, stone, 2, 4);
|
||||
assert.equal(g.decision?.kind, 'stoneBurner');
|
||||
assert.equal(g.decision.player, 'p');
|
||||
const reordered = structuredClone(g);
|
||||
reordered.decision = { kind: 'stoneBurner', player: 'q', event: g.battle!.event!,
|
||||
physicalId: stone.id, copiedFrom: null };
|
||||
assert.throws(() => viewGame(reordered, 'q'));
|
||||
const preanswered = structuredClone(g);
|
||||
preanswered.battle!.stoneBurner = { q: 'kill' };
|
||||
assert.throws(() => viewGame(preanswered, 'p'));
|
||||
g = applyAction(g, 'p', { type: 'decision', event: g.battle!.event, mode: 'ignore' });
|
||||
assert.equal(g.decision?.kind, 'stoneBurner');
|
||||
assert.equal(g.decision.player, 'q');
|
||||
g = JSON.parse(JSON.stringify(g));
|
||||
g = applyAction(g, 'q', { type: 'decision', event: g.battle!.event, mode: 'kill' });
|
||||
g = finish(g);
|
||||
assert.equal(g.lastBattleContext?.winner, 'p');
|
||||
assert.equal(g.players[0].leaders[0].dead, true);
|
||||
assert.equal(g.players[1].leaders[0].dead, true);
|
||||
g = applyAction(g, 'p', { type: 'decision', discard: [] });
|
||||
assert.equal(g.players[0].hand.filter((card) => card.id === mirror.id).length, 1);
|
||||
assert.equal(g.discard.filter((card) => card.id === stone.id).length, 1);
|
||||
});
|
||||
|
||||
void test('all bot profiles can commit legal Mirror plans in Basic and Advanced without inspecting hidden weapons', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const g = fixture(advanced);
|
||||
const view = viewGame(g, 'p');
|
||||
view.players[0].bot = difficulty;
|
||||
const before = structuredClone(view);
|
||||
const plans = botActions(view).filter((action) => action.type === 'battlePlan' && action.weapon === mirror.id);
|
||||
assert.ok(plans.length, `${difficulty}/${advanced}`);
|
||||
for (const plan of plans) assert.doesNotThrow(() => applyAction(g, 'p', plan));
|
||||
assert.deepEqual(view, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('inspected ordinary copy is scored as its attack rather than as undialed Stone', () => {
|
||||
const g = fixture(true);
|
||||
const projectile = give(g, 'projectile');
|
||||
const view = viewGame(g, 'p');
|
||||
view.battle!.fullPlanInsight = {
|
||||
target: 'q',
|
||||
plan: { dial: 3, support: 3, leader: g.players[1].leaders[0].id,
|
||||
weapon: projectile.id, defense: null },
|
||||
cards: [projectile],
|
||||
};
|
||||
view.players[0].bot = 'Hard';
|
||||
const candidate = botActions(view).find((action) =>
|
||||
action.type === 'battlePlan' && action.weapon === mirror.id);
|
||||
assert.ok(candidate);
|
||||
assert.ok(Number(candidate.dial) >= 3,
|
||||
'the copied projectile leaves both leaders dead, so the aggressor must dial for the tie');
|
||||
assert.doesNotThrow(() => applyAction(g, 'p', candidate));
|
||||
});
|
||||
|
||||
void test('a saved Mirror commitment rejects a changed public module configuration', () => {
|
||||
const initial = fixture();
|
||||
const sealed = applyAction(initial, 'p', {
|
||||
type: 'battlePlan', dial: 1, leader: initial.players[0].leaders[0].id,
|
||||
weapon: mirror.id,
|
||||
});
|
||||
const altered = structuredClone(sealed);
|
||||
altered.expansions = ['ix'];
|
||||
const before = structuredClone(altered);
|
||||
assert.throws(() => viewGame(altered, 'p'));
|
||||
assert.throws(() => applyAction(altered, 'q', {
|
||||
type: 'battlePlan', dial: 1, leader: altered.players[1].leaders[0].id,
|
||||
}));
|
||||
assert.deepEqual(altered, before);
|
||||
});
|
||||
@@ -1026,11 +1026,11 @@ void test('a dead-leader preparation witness stays private and its Ghola cannot
|
||||
);
|
||||
});
|
||||
|
||||
void test('an admitted shared Duke Ghola outcome binds the shared record outside all native leader arrays', () => {
|
||||
void test('a saved Ecaz Duke Ghola return binds one unclaimed shared record outside native arrays', () => {
|
||||
const g = fixture(4, true, ['ecaz', 'emperor', 'atreides']),
|
||||
card = hold(g, 'p', 'ghola');
|
||||
// Isolated saved-state precondition for the existing controlledLeaders path.
|
||||
// This does not assert how a controlled dead Duke arises in normal battle play.
|
||||
// A legacy retained controller is intentionally present: revival must
|
||||
// release it, even when a consumed-card save interrupts disposal.
|
||||
g.dukeVidal = acquireDuke(createDukeVidal(), 'p', g.turn, 'ecaz');
|
||||
g.dukeVidal.leader.dead = true;
|
||||
g.dukeVidal.leader.deaths = 1;
|
||||
@@ -1040,6 +1040,9 @@ void test('an admitted shared Duke Ghola outcome binds the shared record outside
|
||||
leader: DUKE_VIDAL_ID,
|
||||
});
|
||||
assert.equal(pending.dukeVidal!.leader.dead, false);
|
||||
assert.equal(pending.dukeVidal!.controller, null);
|
||||
assert.equal(pending.dukeVidal!.source, null);
|
||||
assert.equal(pending.dukeVidal!.acquiredTurn, null);
|
||||
assert.deepEqual(done.dukeVidal, pending.dukeVidal);
|
||||
assert.equal(
|
||||
done.players.some((p) => p.leaders.some((l) => l.id === DUKE_VIDAL_ID)),
|
||||
|
||||
@@ -407,32 +407,3 @@ void test('Nullentropy checklist preserves paid-only privacy, real choices, reco
|
||||
);
|
||||
assert.doesNotMatch(JSON.stringify(topic), /https?:\/\//);
|
||||
});
|
||||
|
||||
void test('Richese generated guides distinguish the eight bounded handlers from two inactive faces', () => {
|
||||
const cards = RULE_TOPICS.filter((topic) =>
|
||||
topic.id.startsWith('card-richese-'),
|
||||
);
|
||||
assert.equal(cards.length, 10);
|
||||
const active = new Set([
|
||||
'card-richese-karama',
|
||||
'card-richese-distrans',
|
||||
'card-richese-nullentropy-box',
|
||||
'card-richese-ornithopter',
|
||||
'card-richese-residual-poison',
|
||||
'card-richese-portable-snooper',
|
||||
'card-richese-stone-burner',
|
||||
'card-richese-juice-of-sapho',
|
||||
]);
|
||||
for (const card of cards) {
|
||||
const text = card.steps.join(' ');
|
||||
if (active.has(card.id))
|
||||
assert.doesNotMatch(text, /game actions are not enabled/);
|
||||
else assert.match(text, /game actions are not enabled/);
|
||||
assert.equal(card.coverage, 'Partial');
|
||||
}
|
||||
for (const id of ['richese-cards', 'richese-gift'])
|
||||
assert.match(
|
||||
RULE_TOPICS.find((topic) => topic.id === id)!.steps.join(' '),
|
||||
/Mirror Weapon and Semuta Drug remain unfinished/,
|
||||
);
|
||||
});
|
||||
@@ -1,52 +1,8 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
cardPresentation,
|
||||
richeseCardActionBlock,
|
||||
} from '../game/card-presentation';
|
||||
import { baseDeck, ixDeck, treacheryDeck, type Card } from '../game/cards';
|
||||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||
import { cardPresentation } from '../game/card-presentation';
|
||||
import type { Card } from '../game/cards';
|
||||
import { RICHESE_CARD_DEFINITIONS, richeseCards } from '../game/richese-cards';
|
||||
import { RULE_TOPICS, RULE_CHECKLIST_AREAS } from '../game/reference';
|
||||
|
||||
void test('every Richese physical card presents its complete guide and actual printed category without changing its runtime kind', () => {
|
||||
for (const definition of RICHESE_CARD_DEFINITIONS) {
|
||||
const supplied = { ...definition.card };
|
||||
const original = structuredClone(supplied);
|
||||
const presentation = cardPresentation(supplied);
|
||||
assert.deepEqual(supplied, original);
|
||||
assert.equal(presentation.category, definition.printedType);
|
||||
assert.equal(presentation.guidance, definition.summary);
|
||||
assert.deepEqual(presentation.gameplay, definition.gameplay);
|
||||
assert.equal(
|
||||
presentation.role,
|
||||
definition.behavior.battleCategory === 'weapon'
|
||||
? 'weapon'
|
||||
: definition.behavior.battleCategory === 'poison-defense'
|
||||
? 'defense'
|
||||
: 'utility',
|
||||
);
|
||||
assert.match(
|
||||
presentation.availability!,
|
||||
definition.card.effect === 'stoneBurner'
|
||||
? /Combined allocation timing and Ix timing remain guarded/
|
||||
: /effect integration and verification are incomplete/,
|
||||
);
|
||||
assert.deepEqual(
|
||||
presentation.topics.map((topic) => topic.id),
|
||||
[
|
||||
definition.card.effect === 'juiceOfSapho'
|
||||
? 'juice-of-sapho'
|
||||
: definition.card.effect === 'stoneBurner'
|
||||
? 'stone-burner'
|
||||
: definition.card.effect === 'portableSnooper'
|
||||
? 'portable-snooper'
|
||||
: 'richese-cards',
|
||||
],
|
||||
);
|
||||
assert.equal(supplied.kind, 'special');
|
||||
}
|
||||
});
|
||||
|
||||
void test('presentation does not export source-verification metadata or unverified interaction guesses', () => {
|
||||
for (const definition of RICHESE_CARD_DEFINITIONS) {
|
||||
@@ -93,132 +49,3 @@ void test('missing, altered and merely similar Richese identities receive no can
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('the public collection and ten full card topics are searchable, linked and identify implemented handlers', () => {
|
||||
const ids = new Set(RULE_TOPICS.map((topic) => topic.id));
|
||||
assert.equal(ids.size, RULE_TOPICS.length);
|
||||
const hub = RULE_TOPICS.find((topic) => topic.id === 'richese-cards')!;
|
||||
assert.deepEqual(
|
||||
hub.checklist!.map((item) => item.area),
|
||||
[...RULE_CHECKLIST_AREAS],
|
||||
);
|
||||
assert.equal(hub.coverage, 'Partial');
|
||||
assert.equal(
|
||||
hub.checklist!.find((item) => item.area === 'AI')!.status,
|
||||
'Partial',
|
||||
);
|
||||
assert.match(
|
||||
hub.steps.join(' '),
|
||||
/not the contents of any player’s current cache or hand/,
|
||||
);
|
||||
assert.match(
|
||||
hub.steps.join(' '),
|
||||
/Mirror Weapon and Semuta Drug remain unfinished/,
|
||||
);
|
||||
assert.match(hub.steps.join(' '), /Discard contents remain private/);
|
||||
for (const definition of RICHESE_CARD_DEFINITIONS) {
|
||||
const id = `card-${definition.card.id}`;
|
||||
const topic = RULE_TOPICS.find((candidate) => candidate.id === id)!;
|
||||
assert.ok(hub.related!.includes(id));
|
||||
assert.equal(topic.coverage, 'Partial');
|
||||
assert.ok(topic.summary.includes(definition.printedType));
|
||||
assert.deepEqual(topic.steps.slice(0, -1), definition.gameplay);
|
||||
assert.match(
|
||||
topic.steps.at(-1)!,
|
||||
definition.card.effect === 'juiceOfSapho'
|
||||
? /Juice of Sapho panel/
|
||||
: definition.card.effect === 'karama'
|
||||
? /existing generic handler/
|
||||
: definition.card.effect === 'distrans'
|
||||
? /Distrans transfer panel/
|
||||
: definition.card.effect === 'nullentropyBox'
|
||||
? /Nullentropy Box panel/
|
||||
: definition.card.effect === 'ornithopter'
|
||||
? /Ornithopter movement controls/
|
||||
: definition.card.effect === 'residualPoison'
|
||||
? /Residual Poison panel/
|
||||
: definition.card.effect === 'portableSnooper'
|
||||
? /Portable Snooper/
|
||||
: definition.card.effect === 'stoneBurner'
|
||||
? /Stone Burner/
|
||||
: /actions are not enabled/,
|
||||
);
|
||||
for (const related of topic.related!) assert.ok(ids.has(related));
|
||||
assert.doesNotMatch(JSON.stringify(topic), /https?:\/\//);
|
||||
}
|
||||
for (const id of ['implementation-checklist', 'choam-modules'])
|
||||
assert.ok(
|
||||
RULE_TOPICS.find((topic) => topic.id === id)!.related!.includes(
|
||||
'richese-cards',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
void test('base, Ix and Ecaz guides remain separate from Richese replacement-card guidance', () => {
|
||||
for (const card of [...baseDeck(), ...ixDeck()]) {
|
||||
const presentation = cardPresentation(card);
|
||||
assert.equal(presentation.availability, undefined);
|
||||
assert.equal(presentation.gameplay, undefined);
|
||||
assert.equal(
|
||||
presentation.topics.some((topic) => topic.id === 'richese-cards'),
|
||||
false,
|
||||
);
|
||||
}
|
||||
for (const card of ecazTreacheryCards()) {
|
||||
const presentation = cardPresentation(card);
|
||||
assert.equal(presentation.category, 'Special treachery');
|
||||
assert.ok(presentation.gameplay!.length >= 4);
|
||||
assert.match(presentation.availability ?? '', card.effect === 'recruits'
|
||||
? /development controls during Revival/ : card.effect === 'harassWithdraw'
|
||||
? /development controls in either battle-card slot/ : /battle effect is still being implemented/);
|
||||
assert.deepEqual(presentation.topics.map((topic) => topic.id), [card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : 'card-recruits']);
|
||||
}
|
||||
assert.equal(treacheryDeck().length, 33);
|
||||
assert.equal(treacheryDeck(['ix']).length, 47);
|
||||
assert.equal(treacheryDeck(['choam']).length, 35);
|
||||
assert.throws(() => treacheryDeck(['richese']), /not implemented/);
|
||||
});
|
||||
|
||||
void test('unsupported canonical Richese effects have an explicit action block while Karama and unrelated cards retain their validators', () => {
|
||||
for (const card of richeseCards()) {
|
||||
if (card.effect === 'karama')
|
||||
assert.equal(richeseCardActionBlock(card), null);
|
||||
else if (card.effect === 'portableSnooper')
|
||||
assert.match(richeseCardActionBlock(card)!, /late-defense panel/);
|
||||
else if (card.effect === 'stoneBurner')
|
||||
assert.match(richeseCardActionBlock(card)!, /battle weapon slot/);
|
||||
else if (card.effect === 'residualPoison')
|
||||
assert.match(richeseCardActionBlock(card)!, /Residual Poison panel/);
|
||||
else if (card.effect === 'ornithopter')
|
||||
assert.match(
|
||||
richeseCardActionBlock(card)!,
|
||||
/Ornithopter movement controls/,
|
||||
);
|
||||
else if (card.effect === 'nullentropyBox')
|
||||
assert.match(richeseCardActionBlock(card)!, /Nullentropy Box panel/);
|
||||
else if (card.effect === 'distrans')
|
||||
assert.match(richeseCardActionBlock(card)!, /Distrans transfer panel/);
|
||||
else if (card.effect === 'juiceOfSapho')
|
||||
assert.match(richeseCardActionBlock(card)!, /Juice of Sapho panel/);
|
||||
else
|
||||
assert.match(
|
||||
richeseCardActionBlock(card)!,
|
||||
/effect is not implemented yet/,
|
||||
);
|
||||
assert.equal(
|
||||
richeseCardActionBlock({ ...card, id: 'another-physical-card' }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
richeseCardActionBlock({ ...card, name: 'Another name' }),
|
||||
null,
|
||||
);
|
||||
assert.equal(richeseCardActionBlock({ ...card, kind: 'worthless' }), null);
|
||||
assert.equal(
|
||||
richeseCardActionBlock({ ...card, effect: 'another-effect' }),
|
||||
null,
|
||||
);
|
||||
}
|
||||
for (const card of [...baseDeck(), ...ixDeck(), ...ecazTreacheryCards()])
|
||||
assert.equal(richeseCardActionBlock(card), null);
|
||||
});
|
||||
@@ -225,6 +225,40 @@ void test('public zero-spice compulsion guard is independent of Stone possession
|
||||
assert.ok(plan(g, 'r', { weapon: null, dial: 0 }).battle!.plans.r);
|
||||
});
|
||||
|
||||
void test('required Mirror Voice rejects unfinishable public zero-spice plans without testing card possession', () => {
|
||||
let g = fixture();
|
||||
g.advanced = true;
|
||||
g.richeseCache!.push(g.players[0].hand.pop()!);
|
||||
hold(g, 'r', 'Mirror Weapon');
|
||||
g.players[0].forces = { 'arrakeen:10': 1 };
|
||||
g.players[0].reserves = 19;
|
||||
g.players[0].spice = 0;
|
||||
g.players[1].faction = 'emperor';
|
||||
g.players[1].leaders = leaders('emperor');
|
||||
g.players[1].forces = { 'arrakeen:10': 6 };
|
||||
g.players[1].reserves = 14;
|
||||
g.players[1].elites = {
|
||||
forces: { 'arrakeen:10': 1 }, reserves: 4, tanks: 0, revived: 0,
|
||||
};
|
||||
g.players[2] = newPlayer('e', 'Bene Gesserit', 'beneGesserit');
|
||||
g.players[2].ally = 'g';
|
||||
g.players[1].ally = 'e';
|
||||
g = start(g);
|
||||
const withoutMirror = reload(g);
|
||||
withoutMirror.richeseCache!.push(withoutMirror.players[0].hand.pop()!);
|
||||
for (const state of [g, withoutMirror]) {
|
||||
const before = structuredClone(state);
|
||||
assert.throws(() => send(state, 'e', {
|
||||
type: 'voice', kind: 'mirrorWeapon', must: true,
|
||||
}));
|
||||
assert.deepEqual(state, before);
|
||||
}
|
||||
g = send(g, 'e', { type: 'declineBattlePower' });
|
||||
for (const id of ['r', 'g'])
|
||||
g = send(g, id, { type: 'battlePreparationReady', event: g.battle!.event });
|
||||
assert.ok(plan(g, 'r', { weapon: null, dial: 0 }).battle!.plans.r);
|
||||
});
|
||||
|
||||
void test('foreign No-Field values produce identical public Stone preflight context and accept the same own commitment', () => {
|
||||
const contexts: unknown[] = [];
|
||||
for (const value of [0, 3, 5]) {
|
||||
|
||||
Reference in new issue
Block a user