Support Truthtrance battle cancellation questions

This commit is contained in:
Quintine committed 2026-10-27 20:57:00 +11:00
1 parent 813a2bacbe
commit 56b3aeab68
15 files changed
+675 -29

No files matched your search

@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import { type Action, type Game } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TruthFact } from '../game/truthtrance';
import {
actTruthTurnPower, createTruthTurnPowerFixture, truthTurnPowerInventory,
truthTurnPowerPlayer, type TruthTurnPowerStep,
} from './fixture-truth-turn-power';
export type ResponseCancelLeaf = { kind: 'windowCardPlay'; effect: 'karamaBattleCancel'; source: string };
export type ResponseCancelExpression = MixedActionExpression<ResponseCancelLeaf>;
export const actResponseCancel = actTruthTurnPower;
export const responseCancelFact = (fact: TruthFact): ResponseCancelExpression => ({ kind: 'fact', fact });
export const responseCancelPlay = (source: string): ResponseCancelExpression => ({
kind: 'action', action: { kind: 'windowCardPlay', effect: 'karamaBattleCancel', source },
});
/** Genuine Basic setup, shipment, battle and native power declarations are
* inherited from the existing fixture. Two explicit controlled physical-card
* swaps place the purchased Karama with the respondent and the other original
* Karama with the asker. All cards remain present once; this does not claim
* either controlled swap was a native sale or legal in-game transfer. */
export function createResponseCancelFixture(power: 'voice' | 'prescience') {
const base = createTruthTurnPowerFixture({ power });
let game = structuredClone(base.game);
const owner = base.target, asker = base.asker;
const target = power === 'voice' ? base.atreides : base.bg;
assert.notEqual(owner, target); assert.notEqual(asker, target);
const holder = truthTurnPowerPlayer(game, asker), respondent = truthTurnPowerPlayer(game, target);
const bought = holder.hand.findIndex(card => card.id === base.karama);
assert.ok(bought >= 0); assert.ok(respondent.hand.length);
[holder.hand[bought], respondent.hand[0]] = [respondent.hand[0], holder.hand[bought]];
const second = game.deck.findIndex(card => card.kind === 'special' && card.effect === 'karama');
assert.ok(second >= 0, 'The second original Karama must be in the controlled unopened deck');
const secondKarama = game.deck[second].id;
[holder.hand[bought], game.deck[second]] = [game.deck[second], holder.hand[bought]];
assert.deepEqual(truthTurnPowerInventory(game), base.originalCards);
const beforeDeclaration = structuredClone(game);
game = actTruthTurnPower(game, owner, base.nativeAction);
assert.equal(game.response?.kind, power); assert.equal(game.response.owner, owner);
assert.equal(game.battle?.event, base.event); assert.equal(game.battle.territory, base.territory);
const source = JSON.stringify(['karamaBattleCancel', game.turn, base.event, base.territory, power, owner]);
const mixed = responseCancelPlay(source);
return { base, game, beforeDeclaration, asker, target, owner, power, source, mixed,
truth: base.truth, karama: base.karama, secondKarama,
cancel: { type: 'card', card: base.karama, mode: 'cancel' } satisfies Action,
foreignCancel: { type: 'card', card: secondKarama, mode: 'cancel' } satisfies Action,
sourceDisclosure: `${base.sourceDisclosure} The adapter uses controlled physical hand/deck swaps for ` +
'two Karama cards, then makes the actual native power declaration and retains its real response.',
};
}
export type ResponseCancelFixture = ReturnType<typeof createResponseCancelFixture>;
export function declareResponseCancel(f: ResponseCancelFixture, source: Game = f.game, actions: TruthTurnPowerStep[] = []) {
let game = actTruthTurnPower(source, f.asker, { type: 'card', card: f.truth }, actions);
while (game.truthtrance?.stage === 'priority') {
const player = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id));
assert.ok(player); game = actTruthTurnPower(game, player.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
return game;
}
export function askResponseCancel(game: Game, f: ResponseCancelFixture, mixed = f.mixed) {
return actTruthTurnPower(game, f.asker, { type: 'truthAsk', question: {
kind: 'turnActions', target: f.target, turn: game.turn, mixed: structuredClone(mixed),
} });
}
export function bindResponseCancel(f: ResponseCancelFixture, answer: 'yes' | 'no', mixed = f.mixed) {
const declared = declareResponseCancel(f), asked = askResponseCancel(declared, f, mixed);
const game = actTruthTurnPower(asked, f.target, { type: 'truthAnswer', answer });
return { declared, asked, game };
}
+130
View File
@@ -0,0 +1,130 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game } from '../game/engine';
import { windowCardOpportunity } from '../game/window-card-play';
import { truthTurnPowerInventory, truthTurnPowerPlayer } from './fixture-truth-turn-power';
import {
actResponseCancel as act, askResponseCancel as ask, bindResponseCancel as bind,
createResponseCancelFixture as fixture, declareResponseCancel as declare,
responseCancelFact as fact, responseCancelPlay as play, type ResponseCancelFixture,
} from './fixture-truth-turn-response-cancel';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before);
}
function receipt(game: Game, f: ResponseCancelFixture) {
const row = game.turnActionPromises?.find(promise => promise.player === f.target);
assert.ok(row); const stage = row.windowCards?.karamaBattleCancel; assert.ok(stage);
return { row, stage };
}
function physical(game: Game, f: ResponseCancelFixture) {
assert.deepEqual(truthTurnPowerInventory(game), f.base.originalCards);
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
}
void test('current native Voice and Prescience responses accept a printed cancellation Yes and record the original physical cost once', () => {
for (const power of ['voice', 'prescience'] as const) {
const f = fixture(power);
const window = windowCardOpportunity(f.game, 'karamaBattleCancel');
assert.equal(window?.source, f.source);
const declared = declare(f), asked = ask(declared, f);
assert.ok(viewGame(asked, f.target).truthTurnActionAnswers?.includes('yes'));
let game = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.equal(game.response?.kind, power);
assert.deepEqual(receipt(game, f).stage, { source: f.source, played: false, closed: false });
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.mode, 'cancel');
assert.equal(guide.action.card, f.karama);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, f.target);
view.players.find(player => player.id === f.target)!.bot = difficulty;
assert.deepEqual(botActions(view)[0], guide.action);
}
game = act(game, f.target, guide.action);
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
assert.equal(game.battle?.[power], undefined);
assert.equal(receipt(game, f).stage.played, true);
assert.equal(receipt(game, f).row.fulfilled, true);
assert.ok(truthTurnPowerPlayer(game, f.asker).hand.some(card => card.id === f.secondKarama));
physical(game, f);
}
});
void test('No blocks the exact printed cancellation while retaining the original response and other player choice', () => {
for (const power of ['voice', 'prescience'] as const) {
const f = fixture(power); let game = bind(f, 'no').game;
assert.equal(game.response?.kind, power);
const view = viewGame(game, f.target);
assert.ok(!view.responseControls?.cancelCards.includes(f.karama));
for (const difficulty of DIFFICULTIES) {
const bot = viewGame(game, f.target);
bot.players.find(player => player.id === f.target)!.bot = difficulty;
assert.ok(!botActions(bot).some(action => action.type === 'card' && action.card === f.karama && action.mode === 'cancel'));
}
rejected(game, f.target, f.cancel);
assert.ok(truthTurnPowerPlayer(game, f.target).hand.some(card => card.id === f.karama));
game = act(game, f.asker, { type: 'passResponse' });
if (game.response && !game.response.passed.includes(f.target))
game = act(game, f.target, { type: 'passResponse' });
assert.equal(game.response, null);
assert.equal(receipt(game, f).stage.played, false);
assert.equal(receipt(game, f).stage.closed, true);
assert.equal(receipt(game, f).row.fulfilled, true);
assert.ok(game.battle?.[power]);
physical(game, f);
}
});
void test('a Yes may pass while another participant remains, but cannot voluntarily close its last actual opportunity', () => {
for (const power of ['voice', 'prescience'] as const) {
const f = fixture(power), bound = bind(f, 'yes').game;
let passed = act(bound, f.target, { type: 'passResponse' });
assert.ok(passed.response?.passed.includes(f.target));
passed = act(passed, f.target, f.cancel);
assert.equal(receipt(passed, f).row.fulfilled, true);
let last = act(bound, f.asker, { type: 'passResponse' });
assert.equal(last.response?.kind, power);
rejected(last, f.target, { type: 'passResponse' });
last = act(last, f.target, f.cancel);
assert.equal(receipt(last, f).row.fulfilled, true);
physical(last, f);
}
});
void test('another player cancelling the same original power releases an unmet Yes without spending the respondent card', () => {
for (const power of ['voice', 'prescience'] as const) {
const f = fixture(power); let game = bind(f, 'yes').game;
game = act(game, f.asker, f.foreignCancel);
assert.equal(game.battle?.[power], undefined);
assert.equal(game.discard.filter(card => card.id === f.secondKarama).length, 1);
assert.ok(truthTurnPowerPlayer(game, f.target).hand.some(card => card.id === f.karama));
assert.equal(receipt(game, f).stage.played, false);
assert.equal(receipt(game, f).stage.closed, true);
assert.equal(receipt(game, f).row.released, true);
physical(game, f);
}
});
void test('current facts may combine with this exact response, while stale sources, the power owner and unrelated actions are rejected', () => {
const f = fixture('voice'), declared = declare(f);
const stale = play(JSON.stringify(['karamaBattleCancel', declared.turn, 'other-battle', f.base.territory, f.power, f.owner]));
rejected(declared, f.asker, { type: 'truthAsk', question: {
kind: 'turnActions', target: f.target, turn: declared.turn, mixed: stale,
} });
rejected(declared, f.asker, { type: 'truthAsk', question: {
kind: 'turnActions', target: f.owner, turn: declared.turn, mixed: f.mixed,
} });
rejected(declared, f.asker, { type: 'truthAsk', question: {
kind: 'turnActions', target: f.target, turn: declared.turn,
mixed: { kind: 'action', action: { kind: 'payment', recipient: f.asker, minimum: 1 } },
} });
const mixed = { kind: 'and' as const, terms: [
fact({ kind: 'spice', compare: 'gte', value: 0 }), f.mixed,
] };
const asked = ask(declared, f, mixed);
assert.ok(viewGame(asked, f.target).truthTurnActionAnswers?.includes('yes'));
});
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { TurnActionFields, turnActionInputError } from '../components/turn-action-fields';
import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TurnActionLeaf } from '../game/turn-action-promises';
type Expression = MixedActionExpression<TurnActionLeaf>;
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
const source = (kind: 'voice' | 'prescience') => JSON.stringify([
'karamaBattleCancel', 2, 'controlled-battle', 'arrakeen', kind, 'a',
]);
/** A controlled public projection tests the editor, not native battle legality. */
function projection(kind: 'voice' | 'prescience'): GameView {
const game = createGame('TURNACTIONCANCEL', newPlayer('p', 'Respondent', 'emperor'));
joinGame(game, newPlayer('a', 'Declarer', kind === 'voice' ? 'beneGesserit' : 'atreides'));
joinGame(game, newPlayer('r', 'Other', 'harkonnen'));
const view = viewGame(game, 'p');
view.status = 'playing'; view.turn = 2; view.phase = 6;
view.response = { kind, owner: 'a', passed: [] };
view.battle = { event: 'controlled-battle', territory: 'arrakeen', attacker: 'a', defender: 'p', revealed: false,
submitted: [], traitorVoters: ['p', 'a'], traitorSubmitted: [], plans: {}, truthPromises: [],
} as unknown as NonNullable<GameView['battle']>;
return view;
}
const claim = (kind: 'voice' | 'prescience'): Expression => ({ kind: 'action', action: {
kind: 'windowCardPlay', effect: 'karamaBattleCancel', source: source(kind),
} });
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
return [node, ...elements(node.props.children)];
}
for (const kind of ['voice', 'prescience'] as const) {
void test(`editor binds printed Karama to the current ${kind} response`, () => {
const game = projection(kind), value = claim(kind);
assert.equal(turnActionInputError(value, game, 'p'), null);
const html = renderToStaticMarkup(createElement(TurnActionFields, { game, value, target: 'p', disabled: false, onChange() {} }));
assert.match(html, /Spend a printed Karama to cancel this Voice or Prescience/);
assert.match(html, kind === 'voice' ? /Voice declared by Declarer/ : /Prescience declared by Declarer/);
assert.match(html, /Arrakeen/);
assert.match(html, /Only spending a physical printed Karama/);
assert.doesNotMatch(html, /Respondent.s hand|private-card/);
assert.notEqual(turnActionInputError(value, game, 'a'), null, 'the declaration owner is not a cancellation target');
game.response = null;
assert.notEqual(turnActionInputError(value, game, 'p'), null, 'closed original response is no longer selectable');
});
}
void test('editor defaults to the current battle cancellation and keeps its exact source', () => {
const game = projection('voice');
let value: Expression = { kind: 'action', action: { kind: 'ship', territory: 'carthag', minimum: 1 } };
const tree = TurnActionFields({ game, value, target: 'p', disabled: false, onChange(next) { value = next; } });
const select = elements(tree).find(element => element.props.id === 'truth-turn-action-1-action');
assert.ok(select);
(select.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value: 'windowCardPlay' } });
assert.deepEqual(value, claim('voice'));
});
void test('response questions reject unrelated future actions even within a mixed expression', () => {
const game = projection('voice');
const unrelated: Expression = { kind: 'action', action: { kind: 'ship', territory: 'carthag', minimum: 1 } };
const mixed: Expression = { kind: 'or', terms: [claim('voice'), unrelated] };
assert.match(turnActionInputError(unrelated, game, 'p') ?? '', /choose only a printed Karama cancellation/i);
assert.match(turnActionInputError(mixed, game, 'p') ?? '', /choose only a printed Karama cancellation/i);
game.response = null;
assert.equal(turnActionInputError(unrelated, game, 'p'), null, 'ordinary questions retain their existing editor rules');
});
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { TurnActionGuideButton } from '../components/turn-action-promises';
import { baseDeck } from '../game/cards';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
/** Controlled projected quote tests callback fencing, not battle legality. */
function projection(kind: 'voice' | 'prescience') {
const game = createGame('TURNCANCELGUIDE', newPlayer('p', 'Respondent', 'emperor'));
joinGame(game, newPlayer('a', 'Declarer', kind === 'voice' ? 'beneGesserit' : 'atreides'));
joinGame(game, newPlayer('r', 'Other', 'harkonnen'));
const view = viewGame(game, 'p');
view.status = 'playing'; view.turn = 2; view.phase = 6;
view.response = { kind, owner: 'a', passed: [] };
view.battle = { event: 'controlled-battle', territory: 'arrakeen', attacker: 'a', defender: 'p', revealed: false,
submitted: [], traitorVoters: ['p', 'a'], traitorSubmitted: [], plans: {}, truthPromises: [],
} as unknown as NonNullable<GameView['battle']>;
const card = baseDeck().find(item => item.kind === 'special' && item.effect === 'karama'); assert.ok(card);
const physical = { ...card, id: 'private-physical-karama' };
view.players.find(player => player.id === 'p')!.hand = [physical];
view.responseControls = { cancelCards: [physical.id], hasPassed: false };
const event = 'controlled-truth-promise';
const source = JSON.stringify(['karamaBattleCancel', 2, 'controlled-battle', 'arrakeen', kind, 'a']);
view.turnActionPromises = [{ event, player: 'p', asker: 'a', turn: 2,
mixed: { kind: 'action', action: { kind: 'windowCardPlay', effect: 'karamaBattleCancel', source } },
answer: true, fulfilled: false, released: false }];
const action: Action = { type: 'card', card: physical.id, mode: 'cancel',
turnActionPromiseEvent: event, turnActionPromisePhase: 6 };
view.turnActionGuide = { event, action };
return { view, action };
}
for (const kind of ['voice', 'prescience'] as const) {
void test(`human guide submits only the quoted printed Karama for current ${kind}`, () => {
const { view, action } = projection(kind), sent: Action[] = [];
const props = { game: view, busy: false, act: (next: Action) => sent.push(next) };
const button = TurnActionGuideButton(props); assert.ok(button); assert.equal(button.props.disabled, false);
const html = renderToStaticMarkup(createElement(TurnActionGuideButton, props));
assert.match(html, kind === 'voice' ? /cancel this Voice/ : /cancel this Prescience/);
assert.doesNotMatch(html, /private-physical-karama/);
button.props.onClick(); assert.deepEqual(sent, [action]);
});
}
void test('stale battle response or changed native cancellation quote prevents guide dispatch', () => {
for (const mutate of [
(view: GameView) => { view.response = null; },
(view: GameView) => { view.response = { kind: 'prescience', owner: 'a', passed: [] }; },
(view: GameView) => { view.responseControls!.cancelCards = []; },
(view: GameView) => { view.battle!.event = 'another-battle'; },
]) {
const { view } = projection('voice'), sent: Action[] = [];
const button = TurnActionGuideButton({ game: view, busy: false, act: action => sent.push(action) });
assert.ok(button); assert.equal(button.props.disabled, false);
mutate(view); button.props.onClick(); assert.deepEqual(sent, []);
}
});
void test('guide excludes non-printed cards and unavailable native quotes', () => {
for (const mutate of [
(view: GameView) => { view.responseControls!.cancelCards = []; },
(view: GameView) => { view.players.find(player => player.id === 'p')!.hand![0].kind = 'worthless'; },
(view: GameView) => { view.turnActionGuide!.action.mode = 'shipment'; },
]) {
const { view } = projection('voice'); mutate(view);
const button = TurnActionGuideButton({ game: view, busy: false, act() {} });
assert.ok(button); assert.equal(button.props.disabled, true);
}
});
+89
View File
@@ -0,0 +1,89 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { karamaBattleCancelOpportunity, parseKaramaBattleCancelSource,
windowCardNativeEffect, windowCardOpportunity, type WindowCardGame } from '../game/window-card-play';
const fixture = (kind: 'voice' | 'prescience' = 'voice'): WindowCardGame => ({
status: 'playing', advanced: false, phase: 6, turn: 3, shieldWallDestroyed: false,
battle: { event: 'battle:3:one', territory: 'Arrakeen', revealed: false },
response: { kind, owner: 'owner', passed: [] },
players: [{ id: 'owner' }, { id: 'target' }, { id: 'third' }],
});
void test('current native battle response has a canonical public source for either power', () => {
for (const kind of ['voice', 'prescience'] as const) {
const game = fixture(kind);
const source = JSON.stringify(['karamaBattleCancel', 3, 'battle:3:one', 'Arrakeen', kind, 'owner']);
const opportunity = { effect: 'karamaBattleCancel', source, turn: 3 };
assert.deepEqual(windowCardOpportunity(game, 'karamaBattleCancel'), opportunity);
assert.deepEqual(karamaBattleCancelOpportunity(game, 'target'), opportunity);
assert.deepEqual(parseKaramaBattleCancelSource(source), {
turn: 3, battle: 'battle:3:one', territory: 'Arrakeen', kind, owner: 'owner',
});
assert.equal(windowCardNativeEffect('karamaBattleCancel'), 'karama');
assert.equal(windowCardOpportunity(game, 'karamaBattleCancel', source)?.source, source);
assert.equal(windowCardOpportunity(game, 'karamaBattleCancel', `${source} `), null);
assert.equal(karamaBattleCancelOpportunity(game, 'owner'), null);
assert.equal(karamaBattleCancelOpportunity(game, 'not-seated'), null);
}
});
void test('a response or battle identity change never reuses the prior source', () => {
const game = fixture();
const source = windowCardOpportunity(game, 'karamaBattleCancel')!.source;
const variants: WindowCardGame[] = [
{ ...game, turn: 4 },
{ ...game, battle: { event: 'battle:3:two', territory: 'Arrakeen', revealed: false } },
{ ...game, battle: { event: 'battle:3:one', territory: 'Carthag', revealed: false } },
{ ...game, response: { kind: 'prescience', owner: 'owner', passed: [] } },
{ ...game, response: { kind: 'voice', owner: 'third', passed: [] } },
];
for (const variant of variants) {
assert.ok(windowCardOpportunity(variant, 'karamaBattleCancel'));
assert.equal(windowCardOpportunity(variant, 'karamaBattleCancel', source), null);
assert.equal(karamaBattleCancelOpportunity(variant, 'target', source), null);
}
});
void test('missing, malformed, ineligible and non-Basic response states have no opportunity', () => {
const game = fixture();
const variants: WindowCardGame[] = [
{ ...game, status: 'lobby' }, { ...game, advanced: true }, { ...game, advanced: undefined },
{ ...game, phase: 5 }, { ...game, turn: 0 }, { ...game, response: null },
{ ...game, response: { kind: 'nexusPrescience', owner: 'owner' } },
{ ...game, response: { kind: 'voice', owner: 'missing' } },
{ ...game, battle: null },
{ ...game, battle: { event: null, territory: 'Arrakeen', revealed: false } },
{ ...game, battle: { event: 'battle:3:one', territory: 'Arrakeen', revealed: true } },
{ ...game, players: [{ id: 'owner' }, { id: 'owner' }] },
{ ...game, players: undefined },
];
for (const variant of variants) assert.equal(windowCardOpportunity(variant, 'karamaBattleCancel'), null);
});
void test('source parser rejects ambiguous, noncanonical and malformed tuples', () => {
const invalid = [null, '', '{}', '[]',
'[ "karamaBattleCancel",3,"event","territory","voice","owner"]',
JSON.stringify(['karamaBattleCancel', 0, 'event', 'territory', 'voice', 'owner']),
JSON.stringify(['karamaBattleCancel', 1, 'event', 'territory', 'voice', 'owner', 'extra']),
JSON.stringify(['karamaBattleCancel', 1, 'event', 'territory', 'nexusPrescience', 'owner']),
JSON.stringify(['karamaBattleCancel', 1, 'event ', 'territory', 'voice', 'owner']),
];
for (const source of invalid) assert.equal(parseKaramaBattleCancelSource(source), null);
const source = JSON.stringify(['karamaBattleCancel', 1, 'battle:one', 'territory:two', 'voice', 'owner']);
const distinct = JSON.stringify(['karamaBattleCancel', 1, 'battle:one:territory', 'two', 'voice', 'owner']);
assert.notEqual(source, distinct);
assert.notDeepEqual(parseKaramaBattleCancelSource(source), parseKaramaBattleCancelSource(distinct));
});
void test('public identity does not depend on private hands or passed responders', () => {
const game = fixture();
const source = windowCardOpportunity(game, 'karamaBattleCancel')!.source;
const altered = {
...game,
response: { kind: 'voice', owner: 'owner', passed: ['target'] },
players: [{ id: 'owner', hand: ['secret-a'] }, { id: 'target', hand: ['secret-b'] }, { id: 'third' }],
};
assert.equal(karamaBattleCancelOpportunity(altered, 'target')?.source, source);
assert.doesNotMatch(source, /secret/);
});