Support Truthtrance battle cancellation questions
This commit is contained in:
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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in new issue
Block a user