Connect printed Diplomat defenses and Ecaz Tanked Duke Cunning

This commit is contained in:
Quintine committed 2026-10-08 19:05:08 +11:00
1 parent 039fe4e87f
commit be8703dc51
17 files changed
+811 -182

No files matched your search

+100 -63
View File
@@ -1,28 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { DiplomatDefense } from '../components/diplomat-defense';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { applyAction, viewGame, type Game } from '../game/engine';
import { applyAction, viewGame, type Game, type GameView } from '../game/engine';
import { baseDeck, ixBattleCards, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import type { DiplomatDefenseQuote } from '../game/diplomat-defense';
import {
diplomatDefenseGame,
revealDiplomatPlans,
takeBattleCard,
} from './diplomat-defense-fixture';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(
specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier,
context,
);
},
});
const { RevealedBattle } = await import('../components/revealed-battle');
aliases.deregister();
function markup(game: Game, viewer = 'a', busy = false) {
return renderToStaticMarkup(
@@ -34,6 +26,37 @@ function markup(game: Game, viewer = 'a', busy = false) {
);
}
/** Project the public revealed receipt with a chosen copied role. Native
* integration is proven separately once the server passes the widened kind. */
function markupCopy(source: Card, kind: DiplomatDefenseQuote['kind']): string {
const base = viewGame(revealDiplomatPlans(diplomatDefenseGame()), 'a');
const battle = base.battle!;
const view: GameView = {
...base,
battle: {
...battle,
cards: [source],
diplomatDefense: { ...battle.diplomatDefense!, source: source.id, kind },
},
decision: {
kind: 'diplomatDefense',
player: 'a',
event: 'projected',
cards: ['worthless-projected'],
source: source.id,
},
};
return renderToStaticMarkup(
createElement(DiplomatDefense, { game: view, act() {}, busy: false }),
);
}
function describedProtection(html: string) {
const match = /provides <strong>(.*?)<\/strong> protection/.exec(html);
assert.ok(match, html);
return match[1];
}
function revealAgainstProjectile(game: Game) {
const own = game.players.find((player) => player.id === 'a')!;
const other = game.players.find((player) => player.id === 'd')!;
@@ -54,26 +77,16 @@ function revealAgainstProjectile(game: Game) {
return next;
}
void test('the deciding owner sees the copied protection, named Worthless choice, mandatory discard and decline', () => {
void test('only the deciding owner can access eligible committed cards, with busy controls disabled', () => {
const game = diplomatDefenseGame();
const uncommitted = takeBattleCard(game, 'd', 'worthless');
const revealed = revealDiplomatPlans(game);
const html = markup(revealed);
assert.match(html, /aria-label="Diplomat defense choice"/);
assert.match(html, /opposing committed defense, <strong>Snooper/);
assert.match(html, /<strong>Poison defense<\/strong> protection/);
assert.match(html, /Copy Snooper with Baliset · mandatory discard/);
assert.match(html, /must be discarded after this battle/);
assert.match(html, /Decline Diplomat defense/);
assert.equal(html.includes(uncommitted.name), false);
assert.equal(markup(revealed, 'd'), '');
assert.ok(
(markup(revealed, 'a', true).match(/disabled=""/g) ?? []).length >= 2,
);
assert.match(
markup(revealDiplomatPlans(diplomatDefenseGame(false, true))),
/Diplomat defense choice/,
);
});
void test('all profiles copy useful revealed protection and decline a mismatched defense', () => {
@@ -113,45 +126,6 @@ void test('all profiles copy useful revealed protection and decline a mismatched
}
});
void test('the revealed plan preserves the public copied or declined Diplomat status across views', () => {
const unsealed = diplomatDefenseGame();
assert.equal(
renderToStaticMarkup(
createElement(RevealedBattle, { game: viewGame(unsealed, 'a') }),
),
'',
);
let copied = revealDiplomatPlans(unsealed);
assert.equal(copied.decision?.kind, 'diplomatDefense');
copied = applyAction(copied, 'a', {
type: 'decision',
event: copied.decision.event,
card: copied.decision.cards[0],
});
for (const viewer of ['a', 'd']) {
const html = renderToStaticMarkup(
createElement(RevealedBattle, { game: viewGame(copied, viewer) }),
);
assert.match(
html,
/Diplomat: Baliset copies Snooper · discard after battle/,
);
}
let declined = revealDiplomatPlans(diplomatDefenseGame());
assert.equal(declined.decision?.kind, 'diplomatDefense');
declined = applyAction(declined, 'a', {
type: 'decision',
event: declined.decision.event,
card: null,
});
const declinedHtml = renderToStaticMarkup(
createElement(RevealedBattle, { game: viewGame(declined, 'd') }),
);
assert.match(declinedHtml, /Diplomat: defense copy declined/);
assert.doesNotMatch(declinedHtml, /discard after battle/);
});
void test('all profiles retain legal Worthless plans for the native trainer and the selected hidden trained disc', () => {
for (const difficulty of DIFFICULTIES) {
@@ -177,7 +151,70 @@ void test('all profiles retain legal Worthless plans for the native trainer and
candidates.length,
`${difficulty} omitted ${hidden ? 'trained-disc' : 'native'} Diplomat planning`,
);
assert.doesNotThrow(() => applyAction(game, 'a', candidates[0]));
let sealed = applyAction(game, 'a', candidates[0]);
const opponent = sealed.players.find(player => player.id === 'd')!;
sealed = applyAction(sealed, 'd', {
type: 'battlePlan', dial: 0, leader: 'guild-1',
weapon: opponent.hand.find(card => card.kind === 'poison')!.id,
defense: opponent.hand.find(card => card.kind === 'snooper')!.id,
});
assert.ok(sealed.decision?.kind === 'diplomatDefense');
const selected = sealed.decision!;
sealed = applyAction(sealed, 'a', { type: 'decision', event: selected.event, card: selected.cards[0] });
sealed = applyAction(sealed, 'a', { type: 'traitorCall', call: false });
sealed = applyAction(sealed, 'd', { type: 'traitorCall', call: false });
assert.equal(sealed.players[0].leaders.find(disc => disc.id === leader)!.dead, false);
assert.equal(sealed.discard.filter(card => card.id === selected.cards[0]).length, 1);
}
}
});
void test('the choice describes the actual copied role instead of a binary Shield or Snooper', () => {
const ix = ixBattleCards(),
deck = baseDeck();
const snooper = deck.find((c) => c.kind === 'snooper')!;
const cases = [
{ source: snooper, kind: 'snooper' as const },
{ source: deck.find((c) => c.kind === 'shield')!, kind: 'shield' as const },
{
source: ix.find((c) => c.kind === 'shieldSnooper')!,
kind: 'shieldSnooper' as const,
},
{
source: ix.find((c) => c.kind === 'weirdingWay')!,
kind: 'weirdingWay' as const,
},
{
source: ix.find((c) => c.kind === 'chemistry')!,
kind: 'chemistry' as const,
},
{
source: richeseCards().find((c) => c.effect === 'portableSnooper')!,
kind: 'snooper' as const,
},
];
const described = cases.map(({ source, kind }) => {
const html = markupCopy(source, kind);
assert.ok(html.includes(source.name), source.name);
assert.ok(
html.includes(`Copy ${source.name} with worthless-projected`),
source.name,
);
return describedProtection(html);
});
assert.equal(new Set(described.slice(0, 2)).size, 2);
assert.equal(described[0], described[5]);
for (const index of [2, 3, 4])
assert.ok(
!described.slice(0, 2).includes(described[index]),
cases[index].source.name,
);
for (const [index, { source, kind }] of cases.entries()) {
const html = markupCopy(source, kind);
assert.equal(
html.includes('data-copied-role-interpretation="true"'),
index >= 2,
source.name,
);
}
});
+255 -1
View File
@@ -1,7 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game, type Action } from '../game/engine';
import { baseDeck } from '../game/cards';
import { baseDeck, ixBattleCards, ixStandardCards, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import {
battleWeaponsExplode,
defenseTypes,
isShield,
weaponKills,
} from '../game/battle-cards';
import {
quoteDiplomatDefense,
copiedDiplomatDefense,
@@ -12,6 +19,11 @@ import {
revealDiplomatPlans,
takeBattleCard,
} from './diplomat-defense-fixture';
import {
completedAdvancedNativeSkillsGame,
stageAdvancedNativeSkillBattle,
openAdvancedNativeSkillBattle,
} from './fixture-advanced-native-skills';
function choose(game: Game, card: string | null): Game {
assert.equal(game.decision?.kind, 'diplomatDefense');
@@ -306,3 +318,245 @@ void test('old unmarked revealed battles retain their original continuation with
true,
);
});
void test('a copied printed role keeps Shield Snooper hybrid, defensive Weirding Way and Chemistry distinct', () => {
const worthless = baseDeck().filter((c) => c.kind === 'worthless');
const ix = ixBattleCards();
const hybrid = ix.find((c) => c.kind === 'shieldSnooper')!,
weirding = ix.find((c) => c.kind === 'weirdingWay')!,
chemistry = ix.find((c) => c.kind === 'chemistry')!,
poisonTooth = ix.find((c) => c.kind === 'poisonTooth')!;
const deck = baseDeck();
const projectile = deck.find((c) => c.kind === 'projectile')!,
lasgun = deck.find((c) => c.kind === 'lasgun')!,
poison = deck.find((c) => c.kind === 'poison')!,
snooper = deck.find((c) => c.kind === 'snooper')!;
const assignments = [
{
skill: 'diplomat' as const,
leader: 'trainer',
faceUp: true,
captured: false,
},
];
const copy = (opposingDefense: Card, opposingWeapon?: Card) => {
const quote = quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
opposingDefense,
opposingWeapon,
});
assert.ok(quote, `${opposingDefense.name} should supply a copied role`);
const copied = copiedDiplomatDefense(quote, worthless[0].id);
assert.equal(copied.id, worthless[0].id);
return copied;
};
const hybridCopy = copy(hybrid, lasgun);
assert.equal(hybridCopy.kind, 'shieldSnooper');
assert.deepEqual(defenseTypes(hybridCopy), ['shield', 'snooper']);
assert.ok(isShield(hybridCopy));
assert.ok(battleWeaponsExplode(lasgun, undefined, undefined, hybridCopy));
// Defensive Weirding Way keeps its original paired-weapon prerequisite.
assert.equal(
quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
opposingDefense: weirding,
}),
null,
);
const weirdingCopy = copy(weirding, projectile);
assert.equal(weirdingCopy.kind, 'weirdingWay');
assert.equal(isShield(weirdingCopy), false);
assert.equal(weaponKills(projectile, weirdingCopy), false);
assert.equal(battleWeaponsExplode(lasgun, undefined, undefined, weirdingCopy), false);
// Copying the used role does not copy its own-slot prerequisite onto this plan.
assert.ok(
quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
opposingDefense: weirding,
opposingWeapon: worthless[1],
}),
);
const chemistryCopy = copy(chemistry, poisonTooth);
assert.equal(chemistryCopy.kind, 'chemistry');
assert.equal(weaponKills(poisonTooth, chemistryCopy), false);
assert.equal(weaponKills(poisonTooth, hybridCopy), true);
assert.equal(weaponKills(poison, chemistryCopy), false);
assert.equal(weaponKills(projectile, chemistryCopy), true);
const snooperCopy = copy(snooper, poison);
assert.equal(snooperCopy.kind, 'snooper');
assert.equal(weaponKills(poison, snooperCopy), false);
assert.equal(weaponKills(poisonTooth, snooperCopy), true);
});
void test('a sealed Portable Snooper supplies only its original poison-defense role', () => {
const portable = richeseCards().find((c) => c.effect === 'portableSnooper')!;
const worthless = baseDeck().filter((c) => c.kind === 'worthless');
const deck = baseDeck();
const poison = deck.find((c) => c.kind === 'poison')!,
projectile = deck.find((c) => c.kind === 'projectile')!,
shield = deck.find((c) => c.kind === 'shield')!;
const assignments = [
{
skill: 'diplomat' as const,
leader: 'trainer',
faceUp: true,
captured: false,
},
];
const quote = quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
defense: worthless[1],
opposingDefense: portable,
})!;
assert.equal(quote.kind, 'snooper');
const copied = copiedDiplomatDefense(quote, worthless[1].id);
assert.equal(copied.kind, 'snooper');
assert.equal(copied.id, worthless[1].id);
assert.equal(isShield(copied), false);
assert.equal(weaponKills(poison, copied), false);
assert.equal(weaponKills(projectile, copied), true);
for (const forged of [
{ ...portable, name: 'Snooper' },
{ ...portable, kind: 'snooper' as const },
{ ...portable, id: 'richese-portable-snooper-copy' },
{ ...portable, effect: undefined },
])
assert.equal(
quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
opposingDefense: forged,
}),
null,
forged.id,
);
const ix = ixBattleCards();
for (const unsupported of [
poison,
projectile,
worthless[0],
ix.find((c) => c.kind === 'poisonTooth')!,
ix.find((c) => c.kind === 'artillery')!,
ix.find((c) => c.kind === 'poisonBlade')!,
])
assert.equal(
quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
opposingDefense: unsupported,
}),
null,
unsupported.id,
);
// A forged face cannot inherit the role by claiming a printed name alone.
for (const forged of [
{ ...ix.find((c) => c.kind === 'weirdingWay')!, kind: 'shield' as const },
{ ...ix.find((c) => c.kind === 'chemistry')!, kind: 'snooper' as const },
{ ...shield, kind: 'shieldSnooper' as const },
{ ...shield, id: 'ix-shield', kind: 'shieldSnooper' as const },
])
assert.equal(
quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
opposingDefense: forged,
opposingWeapon: projectile,
}),
null,
forged.id,
);
});
void test('an older battle admits only its original base Shield and Snooper sources', () => {
const worthless = baseDeck().filter((c) => c.kind === 'worthless');
const deck = baseDeck();
const shield = deck.find((c) => c.kind === 'shield')!,
snooper = deck.find((c) => c.kind === 'snooper')!,
projectile = deck.find((c) => c.kind === 'projectile')!;
const ix = ixBattleCards();
const ixStandard = ixStandardCards();
const portable = richeseCards().find((c) => c.effect === 'portableSnooper')!;
const assignments = [
{
skill: 'diplomat' as const,
leader: 'trainer',
faceUp: true,
captured: false,
},
];
const legacy = (opposingDefense: Card, opposingWeapon?: Card) =>
quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
opposingDefense,
opposingWeapon,
expandedDefenses: false,
});
assert.equal(legacy(shield)?.kind, 'shield');
assert.equal(legacy(snooper, projectile)?.kind, 'snooper');
for (const unavailable of [
ix.find((c) => c.kind === 'shieldSnooper')!,
ix.find((c) => c.kind === 'weirdingWay')!,
ix.find((c) => c.kind === 'chemistry')!,
ixStandard.find((c) => c.id === 'ix-shield')!,
ixStandard.find((c) => c.id === 'ix-snooper')!,
portable,
])
assert.equal(
legacy(unavailable, unavailable.kind === 'weirdingWay' ? projectile : undefined),
null,
unavailable.id,
);
for (const unavailable of [
ix.find((c) => c.kind === 'shieldSnooper')!,
ix.find((c) => c.kind === 'weirdingWay')!,
ix.find((c) => c.kind === 'chemistry')!,
portable,
])
assert.ok(
quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
opposingDefense: unavailable,
opposingWeapon:
unavailable.kind === 'weirdingWay' ? projectile : undefined,
}),
unavailable.id,
);
});
void test('an already-open base-only battle finishes without a retroactive hybrid copy or missing-receipt deadlock', () => {
const initialized = completedAdvancedNativeSkillsGame({
family: 'ixians', requestedSkill: 'diplomat', skillOwner: 'emperor', opponents: ['guild'],
});
let game = stageAdvancedNativeSkillBattle(initialized, 'emperor', 'guild');
const worthless = takeBattleCard(game, 'emperor', 'worthless');
const weapon = takeBattleCard(game, 'guild', 'poisonBlade');
const defense = takeBattleCard(game, 'guild', 'shieldSnooper');
game = openAdvancedNativeSkillBattle(game, 'emperor', 'guild', true);
// An in-flight battle created before printed-defense capability was enabled.
game.battle!.diplomatDefenseVersion = 1;
game = applyAction(game, 'emperor', { type: 'battlePlan', dial: 0, leader: 'emperor-0', defense: worthless.id });
game = applyAction(game, 'guild', { type: 'battlePlan', dial: 0, leader: 'guild-1', weapon: weapon.id, defense: defense.id });
assert.equal(game.decision, null);
assert.equal(game.battle!.diplomatDefense, undefined);
game = applyAction(game, 'emperor', { type: 'traitorCall', call: false });
game = applyAction(game, 'guild', { type: 'traitorCall', call: false });
assert.equal(game.players.find(player => player.id === 'emperor')!.leaders.find(leader => leader.id === 'emperor-0')!.dead, true);
});
+28 -24
View File
@@ -2,10 +2,11 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { GameView } from '../game/engine';
import { botActions } from '../game/bots';
import { viewGame, type GameView } from '../game/engine';
import { NexusEcazDuke } from '../components/nexus-ecaz-duke';
import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
import { consumeDuke } from '../game/duke-vidal';
import { nexusEcazDukePosition } from './fixture-nexus-ecaz-duke';
const event = '["nexusEcazDuke",2,6,"ecaz","moritani",0]';
@@ -30,17 +31,6 @@ function markup(view: GameView, busy = false) {
return renderToStaticMarkup(createElement(NexusEcazDuke, { game: view, busy, act() {} }));
}
void test('Ecaz chooses the private, event-bound custody action before the ordinary battle decision in Basic and Advanced play', () => {
for (const advanced of [false, true]) {
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const game = projected({ advanced });
game.players[0].bot = profile;
const action = { type: 'nexusEcazDuke', event };
assert.deepEqual(nexusEcazDukeAction(game), action);
assert.deepEqual(botActions(game)[0], action);
}
}
});
void test('private projection and physical card limit the offer without probing a rival hand', () => {
const game = projected();
@@ -48,15 +38,9 @@ void test('private projection and physical card limit the offer without probing
get() { throw new Error('Rival private cards must remain unread'); },
});
const html = markup(game);
assert.match(html, /Current public controller: House Moritani/);
assert.match(html, /living, uncaptured, non-Ghola Duke/);
assert.match(html, /physical Ecaz Nexus card/);
assert.match(html, /temporary control expires at turn end/);
assert.match(html, /using him in battle consumes the shared Duke/);
assert.match(html, /printed Tanks revival, capture, and Ghola effects are not supported/);
assert.match(html, />Discard Ecaz Nexus · take Duke this turn/);
assert.match(html, /<button\b/);
assert.doesNotMatch(html, /disabled=""/);
assert.match(markup(game, true), /disabled=""[^>]*>Discard Ecaz Nexus/);
assert.match(markup(game, true), /<button\b[^>]*disabled=""/);
const rival = projected({ me: 'moritani', nexusCards: { card: null }, nexusEcazDuke: null });
assert.equal(markup(rival), '');
@@ -65,13 +49,33 @@ void test('private projection and physical card limit the offer without probing
assert.equal(markup(projected({ nexusEcazDuke: null })), '');
});
void test('actual Tanked Duke gives the native owner an enabled event-bound control, never a rival offer', () => {
for (const advanced of [false, true]) {
const { game } = nexusEcazDukePosition(advanced);
// Explicit conserved Tanks position, not fabricated Nexus custody or a public offer.
game.dukeVidal = consumeDuke(game.dukeVidal!);
game.dukeVidal.leader.dead = true;
game.dukeVidal.leader.deaths = 2;
game.dukeVidal.leader.usedAt = 'carthag';
const owner = viewGame(game, 'p');
assert.match(markup(owner), /<button\b/);
assert.doesNotMatch(markup(owner), /disabled=""/);
assert.match(markup(owner, true), /<button\b[^>]*disabled=""/);
for (const seat of ['q', 'r']) {
const rival = viewGame(game, seat);
assert.equal(rival.nexusEcazDuke, null);
assert.equal(nexusEcazDukeAction(rival), null);
assert.equal(markup(rival), '');
}
}
});
void test('blocked offer remains explanatory but no interrupt, alliance, or started battle can spend it', () => {
const blocked = projected({ nexusEcazDuke: {
event, blocked: 'Duke Vidal is captured and cannot be taken.', dukeController: 'moritani',
} });
assert.equal(nexusEcazDukeAction(blocked), null);
assert.match(markup(blocked), /Duke Vidal is captured and cannot be taken/);
assert.match(markup(blocked), /disabled=""[^>]*>Discard Ecaz Nexus/);
assert.match(markup(blocked), /<button\b[^>]*disabled=""/);
for (const changes of [
{ status: 'finished' }, { phase: 5 }, { phaseOpening: { passed: [] } },
@@ -88,6 +92,6 @@ void test('blocked offer remains explanatory but no interrupt, alliance, or star
]) {
const view = projected(changes);
assert.equal(nexusEcazDukeAction(view), null);
assert.match(markup(view), /disabled=""[^>]*>Discard Ecaz Nexus|^$/);
assert.match(markup(view), /<button\b[^>]*disabled=""|^$/);
}
});
+1 -1
View File
@@ -47,7 +47,7 @@ void test('Ecaz Cunning physically overrides Moritani Duke custody and expires w
}
});
void test('a stale offer, busy table, dead, captured or Ghola Duke cannot spend Ecaz Cunning', () => {
void test('a stale offer, busy table, invalid retained Tanks custody, captured or Ghola Duke cannot spend Ecaz Cunning', () => {
const { game, action } = position();
const changed = reload(game);
changed.dukeVidal = acquireDuke(changed.dukeVidal!, 'r', changed.turn, 'ecaz');
+158 -10
View File
@@ -1,7 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Game } from '../game/engine';
import { acquireDuke, createDukeVidal, expireDuke } from '../game/duke-vidal';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { acquireDuke, consumeDuke, createDukeVidal, expireDuke } from '../game/duke-vidal';
import { createNexusCards, discardNexusCard } from '../game/nexus-cards';
import {
createNexusEcazDukeReceipt,
@@ -11,6 +12,8 @@ import {
validateNexusEcazDuke,
type NexusEcazDukeReceipt,
} from '../game/nexus-ecaz-duke';
import { nexusEcazDukePosition } from './fixture-nexus-ecaz-duke';
import { nexusTraitorInventory } from './fixture-nexus-traitors';
function game(advanced = false): Game {
const players = [
@@ -48,14 +51,12 @@ void test('native physical Ecaz Cunning overrides Moritani at a quiet Battle bou
assert.equal(g.nexusCards!.cards!.hands['ecaz-seat'], null);
assert.deepEqual(JSON.parse(JSON.stringify(record)), record);
assert.equal(nexusEcazDukeSignature(JSON.parse(JSON.stringify(record))), record.signature);
assert.doesNotThrow(() => validateNexusEcazDuke(g, JSON.parse(JSON.stringify(record)), 0));
// Completed history does not need the original custody or the spent card to remain current.
g.dukeVidal = expireDuke(g.dukeVidal!, 3);
assert.equal(g.dukeVidal.controller, null);
assert.equal(g.dukeVidal.source, null);
g.turn = 4;
g.phase = 1;
assert.doesNotThrow(() => validateNexusEcazDuke(g, record, 0));
});
void test('unclaimed Duke creates a null-safe public event and historical receipt', () => {
@@ -70,10 +71,158 @@ void test('unclaimed Duke creates a null-safe public event and historical receip
controller: null, acquiredTurn: null, source: null,
leader: { id: 'duke-vidal', dead: false, capturedBy: null, gholaBy: null },
});
assert.doesNotThrow(() => validateNexusEcazDuke(g, receipt, 0));
assert.equal(receipt.signature, nexusEcazDukeSignature(JSON.parse(JSON.stringify(receipt))));
});
void test('Ecaz Nexus alone revives the set-aside shared Duke and clears only his battle-use stamp', () => {
for (const advanced of [false, true]) {
const g = game(advanced);
const tanked = g.dukeVidal!;
tanked.leader.dead = true;
tanked.leader.deaths = 3;
tanked.leader.usedAt = 'carthag';
const before = structuredClone(tanked);
assert.deepEqual(quoteNexusEcazDuke(g, 'ecaz-seat'), {
event: nexusEcazDukeEvent(3, 6, 'ecaz-seat', 0, null, null),
blocked: null, dukeController: null,
});
for (const source of ['moritani', 'ecaz', 'ally'] as const)
assert.throws(() => acquireDuke(tanked, 'ecaz-seat', 3, source), /Tanks/);
const receipt = createNexusEcazDukeReceipt(g, 'ecaz-seat');
assert.deepEqual(receipt.before, {
controller: null, acquiredTurn: null, source: null,
leader: { id: 'duke-vidal', dead: true, capturedBy: null, gholaBy: null },
});
assert.deepEqual(receipt.after, {
controller: 'ecaz-seat', acquiredTurn: 3, source: 'ecazNexus',
});
const acquired = acquireDuke(tanked, 'ecaz-seat', 3, 'ecazNexus');
const expectedLeader = { ...before.leader, dead: false };
delete expectedLeader.usedAt;
assert.deepEqual(acquired.leader, expectedLeader);
assert.deepEqual(tanked, before);
assert.notEqual(acquired.leader, tanked.leader);
assert.deepEqual([acquired.controller, acquired.acquiredTurn, acquired.source],
['ecaz-seat', 3, 'ecazNexus']);
g.dukeVidal = expireDuke(acquired, 3);
g.turn = 4;
g.phase = 1;
validateNexusEcazDuke(g, JSON.parse(JSON.stringify(receipt)), 0);
assert.equal(g.dukeVidal.controller, null);
assert.equal(g.dukeVidal.leader.dead, false);
assert.equal(g.dukeVidal.leader.deaths, 3);
assert.equal(receipt.signature, nexusEcazDukeSignature(receipt));
}
});
void test('native Basic and Advanced bots can spend actual Ecaz Nexus on a Tanked Duke', () => {
for (const advanced of [false, true]) {
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const { game: g } = nexusEcazDukePosition(advanced);
// Explicit conserved Tanks position after genuine setup and physical Nexus draw.
g.dukeVidal = consumeDuke(g.dukeVidal!);
g.dukeVidal.leader.dead = true;
g.dukeVidal.leader.deaths = 2;
g.dukeVidal.leader.usedAt = 'carthag';
g.players[0].bot = profile;
const before = structuredClone(g);
const view = viewGame(g, 'p');
assert.equal(view.nexusEcazDuke?.blocked, null);
assert.equal(viewGame(g, 'q').nexusEcazDuke, null);
const action = botActions(view)[0];
assert.deepEqual(action, { type: 'nexusEcazDuke', event: view.nexusEcazDuke!.event });
const result = applyAction(g, 'p', action);
assert.deepEqual(g, before);
assert.equal(result.nexusCards!.cards!.hands.p, null);
assert.equal(result.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
assert.deepEqual([result.dukeVidal!.controller, result.dukeVidal!.source,
result.dukeVidal!.acquiredTurn], ['p', 'ecazNexus', result.turn]);
assert.equal(result.dukeVidal!.leader.id, before.dukeVidal!.leader.id);
assert.equal(result.dukeVidal!.leader.dead, false);
assert.equal(result.dukeVidal!.leader.deaths, 2);
assert.equal(result.dukeVidal!.leader.usedAt, undefined);
assert.equal(result.nexusEcazDukeHistory![0].before.leader.dead, true);
assert.deepEqual(result.nexusEcazDukeEvents, [action.event]);
assert.equal(result.players.flatMap(player => player.leaders)
.some(leader => leader.id === 'duke-vidal'), false);
assert.equal(viewGame(result, 'p').players.find(player => player.id === 'p')!.leaders
.some(leader => leader.id === 'duke-vidal' && !leader.dead && !leader.usedAt), true);
assert.deepEqual(result.players.map(player => [player.spice, player.reserves, player.leaders]),
before.players.map(player => [player.spice, player.reserves, player.leaders]));
nexusTraitorInventory(result);
assert.throws(() => applyAction(result, 'p', action));
}
}
});
void test('living already-used Duke cannot gain a new battle use from Ecaz Cunning', () => {
const g = game();
g.dukeVidal!.leader.usedAt = 'carthag';
g.dukeVidal!.leader.deaths = 2;
const before = structuredClone(g.dukeVidal);
assert.match(quoteNexusEcazDuke(g, 'ecaz-seat')!.blocked!, /already been used/);
assert.throws(() => createNexusEcazDukeReceipt(g, 'ecaz-seat'), /already been used/);
assert.throws(() => acquireDuke(g.dukeVidal!, 'ecaz-seat', 3, 'ecazNexus'), /already been used/);
assert.deepEqual(g.dukeVidal, before);
const ordinary = acquireDuke(g.dukeVidal!, 'moritani-seat', 3, 'moritani');
assert.deepEqual(ordinary.leader, before!.leader);
});
void test('Tanked acquisition requires set-aside custody and never bypasses capture or Ghola', () => {
const g = game();
g.dukeVidal!.leader.dead = true;
g.dukeVidal!.leader.deaths = 1;
for (const change of [
{ controller: 'moritani-seat' },
{ acquiredTurn: 2 },
{ source: 'moritani' as const },
]) {
const duke = { ...g.dukeVidal!, ...change };
const before = structuredClone(duke);
assert.match(quoteNexusEcazDuke({ ...g, dukeVidal: duke }, 'ecaz-seat')!.blocked!, /set aside/);
assert.throws(() => acquireDuke(duke, 'ecaz-seat', 3, 'ecazNexus'), /set aside/);
assert.deepEqual(duke, before);
}
for (const dead of [false, true]) {
for (const custody of ['capturedBy', 'gholaBy'] as const) {
const duke = structuredClone(g.dukeVidal!);
duke.leader.dead = dead;
duke.leader[custody] = 'moritani-seat';
const before = structuredClone(duke);
assert.match(quoteNexusEcazDuke({ ...g, dukeVidal: duke }, 'ecaz-seat')!.blocked!,
/uncaptured, non-Ghola/);
for (const source of ['moritani', 'ecaz', 'ecazNexus', 'ally'] as const)
assert.throws(() => acquireDuke(duke, 'ecaz-seat', 3, source));
assert.deepEqual(duke, before);
}
}
});
void test('dead receipts retain the old signature shape and reject non-set-aside Tanks history', () => {
const g = game();
g.dukeVidal!.leader.dead = true;
g.dukeVidal!.leader.deaths = 2;
const original = createNexusEcazDukeReceipt(g, 'ecaz-seat');
validateNexusEcazDuke(g, original, 0);
assert.equal(original.signature, nexusEcazDukeSignature(JSON.parse(JSON.stringify(original))));
for (const change of [
{ controller: 'moritani-seat', acquiredTurn: 3, source: 'moritani' as const },
{ acquiredTurn: 2 },
{ source: 'ecaz' as const },
]) {
const damaged = structuredClone(original);
Object.assign(damaged.before, change);
damaged.event = nexusEcazDukeEvent(damaged.turn, damaged.phase, damaged.owner,
damaged.sequence, damaged.before.controller, damaged.before.acquiredTurn);
damaged.signature = nexusEcazDukeSignature(damaged);
assert.throws(() => validateNexusEcazDuke(g, damaged, 0));
}
const damaged = structuredClone(original);
damaged.before.leader.dead = 'true' as never;
damaged.signature = nexusEcazDukeSignature(damaged);
assert.throws(() => validateNexusEcazDuke(g, damaged, 0));
});
void test('ordinary Ecaz Ambassador custody survives end turn; Nexus custody does not', () => {
const g = game();
const ordinary = acquireDuke(g.dukeVidal!, 'ecaz-seat', 3, 'ecaz');
@@ -85,13 +234,12 @@ void test('ordinary Ecaz Ambassador custody survives end turn; Nexus custody doe
const nexus = acquireDuke(moritani, 'ecaz-seat', 3, 'ecazNexus');
assert.equal(expireDuke(nexus, 2).source, 'ecazNexus');
assert.equal(expireDuke(nexus, 3).controller, null);
for (const property of ['dead', 'capturedBy', 'gholaBy'] as const) {
for (const property of ['capturedBy', 'gholaBy'] as const) {
const unavailable = structuredClone(nexus);
if (property === 'dead') unavailable.leader.dead = true;
else unavailable.leader[property] = 'moritani-seat';
unavailable.leader[property] = 'moritani-seat';
assert.throws(() => acquireDuke(unavailable, 'ecaz-seat', 3, 'ecazNexus'));
assert.match(quoteNexusEcazDuke({ ...g, dukeVidal: unavailable }, 'ecaz-seat')!.blocked!,
/living, uncaptured, non-Ghola/);
/uncaptured, non-Ghola/);
}
});
@@ -127,7 +275,7 @@ void test('damaged and re-signed impossible history cannot forge the original Du
record.signature = nexusEcazDukeSignature(record);
assert.throws(() => validateNexusEcazDuke(g, record, 0));
};
invalid(record => { record.before.leader.dead = true as never; });
invalid(record => { record.before.leader.dead = true; });
invalid(record => { record.before.leader.capturedBy = 'moritani-seat' as never; });
invalid(record => { record.before.leader.gholaBy = 'ecaz-seat' as never; });
invalid(record => { record.before.controller = 'outsider'; });