Prototype native Diplomat base-defense copying

Allow one committed Worthless to copy an opposing base Shield or Snooper when no actual defense is played, including protection of the selected native trained leader. Expose an optional named public choice after both plans reveal, before traitor calls, and show the activated protection in revealed plans. Discard the chosen physical Worthless once after battle, including winner and traitor-preempted outcomes. Original physical card identities and plans stay intact. Timing and explicit-discard precedence are documented implementation inferences; lower retreat, modified defenses, combined modes and public starts remain guarded.

Reuse legal Worthless planning candidates and add a public-information decision path for all four AI profiles. Bind new battle choices to revealed plans, public skill stance, canonical card custody and an independent event, validate saved continuations before reads/actions, omit internal proof fields from views, and retain unmarked legacy-battle behavior. Independent rules/privacy/persistence review found no blocker.

Validation: focused Diplomat rules/UI/bot/JSON/SQLite checks plus 80 related legacy checks passed; typecheck, lint, 4537 offline tests, production build and 43 HTTP tests passed. Concurrent copy versus decline, stale/corrupt decisions, self-protection and mandatory traitor-winner discard are covered. Browser genuine assignment and a conserved staged battle exercised human plan submission, refresh of the pending choice, actual Snooper copying with Baliset, Hasimir survival, exactly one Baliset discard, ordinary Crysknife retention and final refresh with private cards and four board forces, fifteen reserves, one Tank and eight spice. Basic/Advanced four-profile samples completed in 210/223 actions with six/seven JSON restores and no rejected candidates; neither encountered an eligible copy, so targeted checks establish the effect.

The server was healthy after the power outage. All 563 opening rooms and original seat/recovery records were preserved (597 after QA/HTTP); SQLite integrity and foreign keys passed. No restart, reset or automation was needed.

Verified source tree: 868bb7484cef244e6ab20f8d08d62e118629e257870cf91a436bf42651b968f5
This commit is contained in:
Quintine committed 2026-09-13 22:05:03 +10:00
1 parent 788453e07e
commit f067950f5e
21 files changed
+1463 -25

No files matched your search

+183
View File
@@ -0,0 +1,183 @@
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 {
diplomatDefenseGame,
revealDiplomatPlans,
takeBattleCard,
} from './diplomat-defense-fixture';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(
specifier === 'next/image' ? 'vinext/shims/image' : specifier,
context,
);
},
});
const { RevealedBattle } = await import('../components/revealed-battle');
aliases.deregister();
function markup(game: Game, viewer = 'a', busy = false) {
return renderToStaticMarkup(
createElement(DiplomatDefense, {
game: viewGame(game, viewer),
act() {},
busy,
}),
);
}
function revealAgainstProjectile(game: Game) {
const own = game.players.find((player) => player.id === 'a')!;
const other = game.players.find((player) => player.id === 'd')!;
const weapon = takeBattleCard(game, 'd', 'projectile');
let next = applyAction(game, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-1',
defense: own.hand.find((card) => card.kind === 'worthless')!.id,
});
next = applyAction(next, 'd', {
type: 'battlePlan',
dial: 0,
leader: 'guild-1',
weapon: weapon.id,
defense: other.hand.find((card) => card.kind === 'snooper')!.id,
});
return next;
}
void test('the deciding owner sees the copied protection, named Worthless choice, mandatory discard and decline', () => {
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', () => {
for (const difficulty of DIFFICULTIES) {
let useful = revealDiplomatPlans(diplomatDefenseGame());
let view = viewGame(useful, 'a');
view.players.find((player) => player.id === 'a')!.bot = difficulty;
const copy = botActions(view)[0];
assert.deepEqual(copy, {
type: 'decision',
event:
useful.decision?.kind === 'diplomatDefense'
? useful.decision.event
: '',
card:
useful.decision?.kind === 'diplomatDefense'
? useful.decision.cards[0]
: null,
});
useful = applyAction(useful, 'a', copy);
assert.equal(useful.battle!.diplomatDefense!.stage, 'copied');
let mismatch = revealAgainstProjectile(diplomatDefenseGame());
view = viewGame(mismatch, 'a');
view.players.find((player) => player.id === 'a')!.bot = difficulty;
const decline = botActions(view)[0];
assert.deepEqual(decline, {
type: 'decision',
event:
mismatch.decision?.kind === 'diplomatDefense'
? mismatch.decision.event
: '',
card: null,
});
mismatch = applyAction(mismatch, 'a', decline);
assert.equal(mismatch.battle!.diplomatDefense!.stage, 'declined');
}
});
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) {
for (const hidden of [false, true]) {
const game = diplomatDefenseGame(hidden);
const view = viewGame(game, 'a');
view.players.find((player) => player.id === 'a')!.bot = difficulty;
const worthless = new Set(
view.players
.find((player) => player.id === 'a')!
.hand?.filter((card) => card.kind === 'worthless')
.map((card) => card.id),
);
const leader = hidden ? 'emperor-0' : 'emperor-1';
const candidates = botActions(view).filter(
(action) =>
action.type === 'battlePlan' &&
action.leader === leader &&
(worthless.has(String(action.weapon)) ||
worthless.has(String(action.defense))),
);
assert.ok(
candidates.length,
`${difficulty} omitted ${hidden ? 'trained-disc' : 'native'} Diplomat planning`,
);
assert.doesNotThrow(() => applyAction(game, 'a', candidates[0]));
}
}
});
+51
View File
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import { applyAction, type Game } from '../game/engine';
import type { Card } from '../game/cards';
import { leaderSkillBattle } from './leader-skill-battle-fixture';
/** Genuine skill assignment and conserved battle staging; returns before sealing plans. */
export function diplomatDefenseGame(hide = false, advanced = false): Game {
const game = leaderSkillBattle({
skill: 'diplomat',
unsealed: true,
hide,
advanced,
});
takeBattleCard(game, 'd', 'poison');
takeBattleCard(game, 'd', 'snooper');
return game;
}
export function takeBattleCard(
game: Game,
player: string,
kind: Card['kind'],
): Card {
const index = game.deck.findIndex((card) => card.kind === kind);
assert.ok(index >= 0, `Missing fixture ${kind}`);
const [card] = game.deck.splice(index, 1);
game.players.find((p) => p.id === player)!.hand.push(card);
return card;
}
export function revealDiplomatPlans(
game: Game,
slot: 'weapon' | 'defense' = 'defense',
): Game {
const own = game.players[0],
other = game.players[1];
let next = applyAction(game, 'a', {
type: 'battlePlan',
dial: 0,
leader: game.battle!.leaderSkillHidden?.a ? 'emperor-0' : 'emperor-1',
[slot]: own.hand.find((card) => card.kind === 'worthless')!.id,
});
next = applyAction(next, 'd', {
type: 'battlePlan',
dial: 0,
leader: 'guild-1',
weapon: other.hand.find((card) => card.kind === 'poison')!.id,
defense: other.hand.find((card) => card.kind === 'snooper')!.id,
});
return next;
}
+403
View File
@@ -0,0 +1,403 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DatabaseSync } from 'node:sqlite';
import type { RoomsClock } from '../db/rooms';
import * as engine from '../game/engine';
import { unitStore } from './fixture-nexus-room-store';
import {
diplomatDefenseGame,
revealDiplomatPlans,
} from './diplomat-defense-fixture';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
async function fixture(hide = true) {
const store = unitStore();
const sqlite: DatabaseSync = store.sqlite;
const created = await store.rooms.createRoom(
'Diplomat owner',
'emperor',
false,
[],
);
const joined = await store.rooms.joinRoom(
created.view.code,
'Diplomat opponent',
'guild',
);
const code = created.view.code;
const tokens = [created.token!, joined.token!];
const original = await Promise.all(
tokens.map((token) => store.rooms.authenticate(code, token)),
);
for (const [index, seat] of original.entries())
sqlite
.prepare('UPDATE seats SET player_id=? WHERE room_code=? AND player_id=?')
.run(index ? 'd' : 'a', code, seat.playerId);
const seats = await Promise.all(
tokens.map((token) => store.restart().authenticate(code, token)),
);
const state = diplomatDefenseGame(hide);
state.code = code;
state.version = (await store.rooms.readRoom(code)).version;
const save = (game: engine.Game) =>
sqlite
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
.run(JSON.stringify(game), game.version, code);
save(state);
store.writes.length = 0;
const worthless = state.players[0].hand.find(
(card) => card.kind === 'worthless',
)!;
const poison = state.players[1].hand.find((card) => card.kind === 'poison')!;
const snooper = state.players[1].hand.find(
(card) => card.kind === 'snooper',
)!;
assert.ok(worthless && poison && snooper);
return {
...store,
sqlite,
code,
seats,
state,
save,
worthless,
poison,
snooper,
};
}
function ownerPlan(f: Awaited<ReturnType<typeof fixture>>): engine.Action {
return {
type: 'battlePlan',
dial: 0,
leader: f.state.battle!.leaderSkillHidden?.a ? 'emperor-0' : 'emperor-1',
defense: f.worthless.id,
};
}
function opponentPlan(f: Awaited<ReturnType<typeof fixture>>): engine.Action {
return {
type: 'battlePlan',
dial: 0,
leader: 'guild-1',
weapon: f.poison.id,
defense: f.snooper.id,
};
}
function barrier() {
let release!: () => void;
const ready = new Promise<void>((resolve) => {
release = resolve;
});
let arrivals = 0;
return async () => {
if (++arrivals === 2) release();
await ready;
};
}
function committedOnce(
results: PromiseSettledResult<unknown>[],
writes: { changes: number }[],
) {
assert.equal(
results.filter((result) => result.status === 'fulfilled').length,
1,
);
assert.equal(
results.filter((result) => result.status === 'rejected').length,
1,
);
assert.deepEqual(
writes.map((write) => write.changes).sort((a, b) => a - b),
[0, 1],
);
}
void test('restart conceals the sealed Worthless, publishes the revealed Diplomat offer, and concurrent copies save the selected trainer once', async () => {
const f = await fixture(true);
try {
await f.rooms.act(f.code, f.seats[0], f.state.version, ownerPlan(f), clock);
const sealed = await f.restart().readRoom(f.code);
const owner = await f.restart().readSeatView(f.code, f.seats[0]);
const observer = await f.restart().readSeatView(f.code, f.seats[1]);
assert.equal(owner.battle!.plans.a.defense, f.worthless.id);
assert.equal(observer.battle!.plans.a, undefined);
assert.equal(
JSON.stringify(observer.battle).includes(f.worthless.id),
false,
);
await f
.restart()
.act(f.code, f.seats[1], sealed.version, opponentPlan(f), clock);
const offered = await f.restart().readRoom(f.code);
assert.equal(offered.decision?.kind, 'diplomatDefense');
assert.equal(offered.battle!.diplomatDefense?.stage, 'offered');
assert.deepEqual(offered.battle!.diplomatDefense?.cards, [f.worthless.id]);
for (const seat of f.seats) {
const view = await f.restart().readSeatView(f.code, seat);
assert.deepEqual(view.decision, offered.decision);
assert.equal(view.battle!.diplomatDefense?.stage, 'offered');
const publicReceipt = JSON.stringify(view.battle!.diplomatDefense);
assert.equal(publicReceipt.includes('signature'), false);
assert.equal(publicReceipt.includes('frame'), false);
}
f.writes.length = 0;
f.hooks.beforeWrite = barrier();
const choice: engine.Action = {
type: 'decision',
event: offered.decision!.event,
card: f.worthless.id,
};
const choices = await Promise.allSettled([
f.rooms.act(f.code, f.seats[0], offered.version, choice, clock),
f.restart().act(f.code, f.seats[0], offered.version, choice, clock),
]);
delete f.hooks.beforeWrite;
committedOnce(choices, f.writes);
let selected = await f.restart().readRoom(f.code);
assert.equal(selected.battle!.diplomatDefense?.stage, 'copied');
assert.equal(selected.battle!.diplomatDefense?.card, f.worthless.id);
assert.equal(
selected.players[0].hand.filter((card) => card.id === f.worthless.id)
.length,
1,
);
assert.equal(
selected.discard.some((card) => card.id === f.worthless.id),
false,
);
await f
.restart()
.act(
f.code,
f.seats[0],
selected.version,
{ type: 'traitorCall', call: false },
clock,
);
selected = await f.restart().readRoom(f.code);
await f
.restart()
.act(
f.code,
f.seats[1],
selected.version,
{ type: 'traitorCall', call: false },
clock,
);
const resolved = await f.restart().readRoom(f.code);
assert.equal(
resolved.players[0].leaders.find((leader) => leader.id === 'emperor-0')!
.dead,
false,
);
assert.equal(
resolved.discard.filter((card) => card.id === f.worthless.id).length,
1,
);
assert.equal(
resolved.players.some((player) =>
player.hand.some((card) => card.id === f.worthless.id),
),
false,
);
} finally {
f.sqlite.close();
}
});
void test('concurrent restored copy and decline commit one public choice without moving the Worthless early', async () => {
const f = await fixture(false);
try {
const offered = revealDiplomatPlans(f.state, 'weapon');
f.save(offered);
assert.equal(offered.decision?.kind, 'diplomatDefense');
f.writes.length = 0;
f.hooks.beforeWrite = barrier();
const event = offered.decision.event;
const results = await Promise.allSettled([
f.rooms.act(
f.code,
f.seats[0],
offered.version,
{ type: 'decision', event, card: f.worthless.id },
clock,
),
f
.restart()
.act(
f.code,
f.seats[0],
offered.version,
{ type: 'decision', event, card: null },
clock,
),
]);
delete f.hooks.beforeWrite;
committedOnce(results, f.writes);
const current = await f.restart().readRoom(f.code);
const copied = results[0].status === 'fulfilled';
assert.equal(
current.battle!.diplomatDefense?.stage,
copied ? 'copied' : 'declined',
);
assert.equal(
current.battle!.diplomatDefense?.card,
copied ? f.worthless.id : null,
);
assert.equal(
current.players[0].hand.filter((card) => card.id === f.worthless.id)
.length,
1,
);
assert.equal(
current.discard.some((card) => card.id === f.worthless.id),
false,
);
} finally {
f.sqlite.close();
}
});
void test('a restored activated copy still discards its exact Worthless after a successful Traitor call', async () => {
const f = await fixture(true);
try {
const owner = f.state.players[0];
const old = owner.traitors[0];
const identity = 'guild-1';
if (old !== identity) {
const holder = f.state.players.find((player) =>
player.traitors.includes(identity),
);
if (holder) holder.traitors[holder.traitors.indexOf(identity)] = old;
else {
const reserve = f.state.traitorReserve!.indexOf(identity);
assert.ok(reserve >= 0);
f.state.traitorReserve![reserve] = old;
}
owner.traitors[0] = identity;
}
const offered = revealDiplomatPlans(f.state);
assert.equal(offered.decision?.kind, 'diplomatDefense');
const copied = engine.applyAction(offered, 'a', {
type: 'decision',
event: offered.decision.event,
card: f.worthless.id,
});
f.save(copied);
await f
.restart()
.act(
f.code,
f.seats[0],
copied.version,
{ type: 'traitorCall', call: true },
clock,
);
const called = await f.restart().readRoom(f.code);
await f
.restart()
.act(
f.code,
f.seats[1],
called.version,
{ type: 'traitorCall', call: false },
clock,
);
const resolved = await f.restart().readRoom(f.code);
assert.equal(resolved.lastBattleContext?.result, 'traitor');
assert.equal(resolved.lastBattleContext?.winner, 'a');
assert.equal(
resolved.discard.filter((card) => card.id === f.worthless.id).length,
1,
);
assert.equal(
resolved.players.some((player) =>
player.hand.some((card) => card.id === f.worthless.id),
),
false,
);
} finally {
f.sqlite.close();
}
});
void test('corrupted restored receipt, decision, or independent battle marker rejects views and actions without mutation', async () => {
const f = await fixture();
try {
const original = revealDiplomatPlans(f.state);
assert.equal(original.decision?.kind, 'diplomatDefense');
const action: engine.Action = {
type: 'decision',
event: original.decision.event,
card: f.worthless.id,
};
const corruptions: ((game: engine.Game) => void)[] = [
(game) => {
game.battle!.diplomatDefense!.frame += '-changed';
},
(game) => {
if (game.decision?.kind === 'diplomatDefense')
game.decision.source = 'wrong-defense';
},
(game) => {
delete game.battle!.diplomatDefenseVersion;
},
(game) => {
delete game.battle!.diplomatDefenseEvent;
},
];
for (const corrupt of corruptions) {
const bad = structuredClone(original);
corrupt(bad);
f.save(bad);
f.writes.length = 0;
for (const seat of f.seats)
await assert.rejects(f.restart().readSeatView(f.code, seat));
await assert.rejects(
f.restart().act(f.code, f.seats[0], bad.version, action, clock),
);
const snapshot = structuredClone(bad);
assert.throws(() => engine.normalizeAutomaticGame(bad));
assert.deepEqual(bad, snapshot);
assert.equal(f.writes.length, 0);
assert.deepEqual(await f.rooms.readRoom(f.code), snapshot);
}
} finally {
f.sqlite.close();
}
});
void test('a restored legacy revealed battle without a Diplomat marker keeps its existing traitor continuation', async () => {
const f = await fixture();
try {
const legacy = revealDiplomatPlans(f.state);
delete legacy.battle!.diplomatDefenseVersion;
delete legacy.battle!.diplomatDefenseEvent;
delete legacy.battle!.diplomatDefense;
legacy.decision = null;
f.save(legacy);
const view = await f.restart().readSeatView(f.code, f.seats[0]);
assert.equal(view.decision, null);
await f
.restart()
.act(
f.code,
f.seats[0],
legacy.version,
{ type: 'traitorCall', call: false },
clock,
);
const continued = await f.restart().readRoom(f.code);
assert.equal(continued.battle!.traitorCalls.a, false);
assert.equal(continued.battle!.diplomatDefense, undefined);
} finally {
f.sqlite.close();
}
});
+308
View File
@@ -0,0 +1,308 @@
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 {
quoteDiplomatDefense,
copiedDiplomatDefense,
} from '../game/diplomat-defense';
import { leaderSkillBattleBonus } from '../game/leader-skill-combat';
import {
diplomatDefenseGame,
revealDiplomatPlans,
takeBattleCard,
} from './diplomat-defense-fixture';
function choose(game: Game, card: string | null): Game {
assert.equal(game.decision?.kind, 'diplomatDefense');
return applyAction(JSON.parse(JSON.stringify(game)), 'a', {
type: 'decision',
event: game.decision.event,
card,
});
}
function finish(game: Game, call = false): Game {
let next = applyAction(game, 'a', { type: 'traitorCall', call });
next = applyAction(next, 'd', { type: 'traitorCall', call: false });
for (const p of next.players)
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
const ids = [
...next.deck,
...next.discard,
...next.players.flatMap((p) => p.hand),
].map((c) => c.id);
assert.equal(new Set(ids).size, 33);
assert.equal(ids.length, 33);
return next;
}
function reject(game: Game, owner: string, action: Action) {
const before = JSON.stringify(game);
assert.throws(() => applyAction(game, owner, action));
assert.equal(JSON.stringify(game), before);
}
void test('Diplomat quotes one physical Worthless from either slot and never copies absent or unsupported defenses', () => {
const cards = baseDeck(),
worthless = cards.filter((c) => c.kind === 'worthless'),
snooper = cards.find((c) => c.kind === 'snooper')!;
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: snooper,
})!;
assert.deepEqual(
quote.cards,
worthless.slice(0, 2).map((c) => c.id),
);
assert.equal(copiedDiplomatDefense(quote, worthless[0].id).kind, 'snooper');
assert.equal(worthless[0].kind, 'worthless');
assert.throws(() => copiedDiplomatDefense(quote, 'unplayed'));
for (const opposingDefense of [
undefined,
worthless[0],
{ ...snooper, id: 'unrecognized' },
{ ...snooper, kind: 'shieldSnooper' as const },
])
assert.equal(
quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
opposingDefense,
}),
null,
);
assert.equal(
quoteDiplomatDefense({
assignments,
selectedLeader: 'other',
weapon: worthless[0],
defense: snooper,
opposingDefense: snooper,
}),
null,
);
assert.equal(
quoteDiplomatDefense({
assignments: [{ ...assignments[0], faceUp: false }],
selectedLeader: 'other',
weapon: worthless[0],
opposingDefense: snooper,
}),
null,
);
assert.ok(
quoteDiplomatDefense({
assignments: [{ ...assignments[0], faceUp: false }],
selectedLeader: 'trainer',
weapon: worthless[0],
opposingDefense: snooper,
}),
);
assert.equal(
quoteDiplomatDefense({
assignments: [{ ...assignments[0], captured: true }],
selectedLeader: 'trainer',
weapon: worthless[0],
opposingDefense: snooper,
}),
null,
);
});
void test('native face-up and selected Diplomats copy a Snooper after reveal in Basic and Advanced without rewriting sealed plans', () => {
for (const advanced of [false, true])
for (const hide of [false, true])
for (const slot of ['weapon', 'defense'] as const) {
const game = diplomatDefenseGame(hide, advanced),
card = game.players[0].hand[0].id;
const pending = revealDiplomatPlans(game, slot),
plans = structuredClone(pending.battle!.plans);
assert.equal(pending.decision?.kind, 'diplomatDefense');
reject(pending, 'd', {
type: 'decision',
event: pending.decision.event,
card,
});
reject(pending, 'a', { type: 'traitorCall', call: false });
reject(pending, 'a', { type: 'decision', event: 'stale', card });
const copied = choose(pending, card);
assert.deepEqual(copied.battle!.plans, plans);
assert.equal(
copied.players[0].hand.find((c) => c.id === card)!.kind,
'worthless',
);
const done = finish(copied),
leader = done.players[0].leaders.find(
(l) => l.id === (hide ? 'emperor-0' : 'emperor-1'),
)!;
assert.equal(leader.dead, false);
assert.ok(!done.players[0].hand.some((c) => c.id === card));
assert.equal(done.discard.filter((c) => c.id === card).length, 1);
reject(done, 'a', {
type: 'decision',
event: pending.decision.event,
card,
});
}
});
void test('declining the visible copy leaves ordinary weapon deaths and card custody unchanged', () => {
const game = diplomatDefenseGame(),
card = game.players[0].hand[0].id;
const pending = revealDiplomatPlans(game);
const done = finish(choose(pending, null));
assert.equal(
done.players[0].leaders.find((l) => l.id === 'emperor-1')!.dead,
true,
);
assert.equal(done.players[0].forces['arrakeen:10'] ?? 0, 0);
assert.equal(done.discard.filter((c) => c.id === card).length, 1);
});
void test('two committed Worthless cards offer one choice and only the chosen winner card is mandatory discard', () => {
let game = diplomatDefenseGame();
const first = game.players[0].hand[0],
second = takeBattleCard(game, 'a', 'worthless');
game = applyAction(game, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-1',
weapon: first.id,
defense: second.id,
});
game = applyAction(game, 'd', {
type: 'battlePlan',
dial: 0,
leader: 'guild-1',
weapon: game.players[1].hand.find((c) => c.kind === 'poison')!.id,
defense: game.players[1].hand.find((c) => c.kind === 'snooper')!.id,
});
assert.equal(game.decision?.kind, 'diplomatDefense');
assert.deepEqual(
new Set(game.decision.cards),
new Set([first.id, second.id]),
);
const done = finish(choose(game, second.id));
assert.equal(done.discard.filter((c) => c.id === second.id).length, 1);
assert.ok(done.players[0].hand.some((c) => c.id === first.id));
assert.equal(done.decision?.kind, 'battleCards');
assert.ok(done.decision.cards.includes(first.id));
assert.ok(!done.decision.cards.includes(second.id));
});
void test('an actual defense or absent opposing defense creates no Diplomat choice', () => {
for (const ownDefense of [false, true]) {
let game = diplomatDefenseGame();
const defense = ownDefense
? takeBattleCard(game, 'a', 'snooper').id
: undefined;
game = applyAction(game, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-1',
weapon: game.players[0].hand[0].id,
defense,
});
game = applyAction(game, 'd', {
type: 'battlePlan',
dial: 0,
leader: 'guild-1',
defense: ownDefense
? game.players[1].hand.find((c) => c.kind === 'snooper')!.id
: undefined,
});
assert.notEqual(game.decision?.kind, 'diplomatDefense');
finish(game);
}
});
void test('copying a Shield preserves the existing Lasgun explosion instead of making a survival loop', () => {
let game = diplomatDefenseGame(true);
const card = game.players[0].hand[0].id,
laser = takeBattleCard(game, 'd', 'lasgun'),
shield = takeBattleCard(game, 'd', 'shield');
// The staged opponent only needs its played pair; return other physical cards to the deck.
game.deck.push(
...game.players[1].hand.filter(
(c) => ![laser.id, shield.id].includes(c.id),
),
);
game.players[1].hand = game.players[1].hand.filter((c) =>
[laser.id, shield.id].includes(c.id),
);
game = applyAction(game, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-0',
defense: card,
});
game = applyAction(game, 'd', {
type: 'battlePlan',
dial: 0,
leader: 'guild-1',
weapon: laser.id,
defense: shield.id,
});
const done = finish(choose(game, card));
for (const p of done.players) assert.equal(p.tanks, 5);
assert.equal(
done.players[0].leaders.find((l) => l.id === 'emperor-0')!.dead,
true,
);
assert.equal(
done.players[1].leaders.find((l) => l.id === 'guild-1')!.dead,
true,
);
assert.equal(done.discard.filter((c) => c.id === card).length, 1);
});
void test('copied defense role supports captured Medic or Prana while the physical Worthless still supports Warmaster', () => {
const card = baseDeck().find((c) => c.kind === 'worthless')!;
for (const [skill, kind] of [
['killer-medic', 'snooper'],
['prana-bindu-adept', 'shield'],
['warmaster', 'snooper'],
] as const) {
const bonus = leaderSkillBattleBonus({
assignments: [
{ skill, leader: 'captive', faceUp: false, captured: true },
],
selectedLeader: { id: 'captive', kind: 'disc' },
weapon: undefined,
defense: card,
effectiveDefense: { id: card.id, name: 'Copied defense', kind },
skilledLeaderSurvives: true,
});
assert.equal(bonus.bonus, 3, skill);
}
});
void test('old unmarked revealed battles retain their original continuation without inventing a copy', () => {
const unsealed = diplomatDefenseGame();
delete unsealed.battle!.diplomatDefenseVersion;
const game = revealDiplomatPlans(unsealed);
assert.notEqual(game.decision?.kind, 'diplomatDefense');
assert.equal(
viewGame(JSON.parse(JSON.stringify(game)), 'a').battle!.revealed,
true,
);
const done = finish(game);
assert.equal(
done.players[0].leaders.find((l) => l.id === 'emperor-1')!.dead,
true,
);
});
+5 -1
View File
@@ -222,7 +222,7 @@ void test('battle visibility choice names the skilled leader and disables both a
assert.equal(disabledControls(markup(skills, true)), 2);
});
void test('the panel states the exact five connected role modifiers and unfinished boundary', () => {
void test('the panel states the connected role modifiers, Diplomat copy and unfinished boundary', () => {
const html = markup(view());
for (const name of [
'Warmaster',
@@ -233,5 +233,9 @@ void test('the panel states the exact five connected role modifiers and unfinish
])
assert.match(html, new RegExp(name));
assert.match(html, /development-only module/);
assert.match(
html,
/Diplomat copying an opposing base defense with a committed Worthless card after plans are revealed/,
);
assert.match(html, /Other Leader Skill effects are unfinished/);
});