Connect printed Diplomat defenses and Ecaz Tanked Duke Cunning
This commit is contained in:
1 parent
039fe4e87f
commit
be8703dc51
17 files changed
+811
-182
No files matched your search
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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=""|^$/);
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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'; });
|
||||
|
||||
Reference in new issue
Block a user