Integrate Ecaz Duke revival and bounded Mirror Weapon battles

This commit is contained in:
Quintine committed 2026-09-28 18:02:04 +10:00
1 parent 68d782ca82
commit 3b118dd845
39 files changed
+1377 -533

No files matched your search

+37
View File
@@ -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;
-32
View File
@@ -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));
});
+67 -1
View File
@@ -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());
+113
View File
@@ -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);
});
+175 -1
View File
@@ -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);
});
+2 -4
View File
@@ -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, []);
+23 -5
View File
@@ -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));
});
+86 -2
View File
@@ -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/,
);
}
});
+181
View File
@@ -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)),
-29
View File
@@ -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/,
);
});
+2 -175
View File
@@ -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);
});
+34
View File
@@ -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]) {