Expose full Truthtrance battle-plan and shipment editors
This commit is contained in:
1 parent
8e2f2c6a95
commit
4ee51917bc
21 files changed
+1958
-92
No files matched your search
@@ -0,0 +1,194 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { PlanClaim } from '../game/battle-promises';
|
||||
import type { TruthAnswer, TruthQuestion } from '../game/truthtrance';
|
||||
import {
|
||||
createTruthTraitorDecisionFixture,
|
||||
actTruthTraitorDecision,
|
||||
truthTraitorDecisionClean,
|
||||
truthTraitorDecisionPlayer,
|
||||
truthTraitorDecisionInventory,
|
||||
type TruthTraitorDecisionStep,
|
||||
} from './fixture-truth-traitor-decision';
|
||||
|
||||
export type TruthPlanExpressionStep = TruthTraitorDecisionStep;
|
||||
export type TruthPlanExpressionScenario = {
|
||||
id: string;
|
||||
claim: PlanClaim;
|
||||
answer: 'yes' | 'no';
|
||||
plan: Action;
|
||||
contradictingPlan: Action;
|
||||
};
|
||||
export const actTruthPlanExpression = actTruthTraitorDecision;
|
||||
export const truthPlanExpressionClean = truthTraitorDecisionClean;
|
||||
export const truthPlanExpressionPlayer = truthTraitorDecisionPlayer;
|
||||
export const truthPlanExpressionInventory = truthTraitorDecisionInventory;
|
||||
|
||||
/** Stop at the genuine source's earned beforePlans boundary. The prefix omits
|
||||
* its last two actual seal actions, rather than installing a new battle. */
|
||||
export function createTruthPlanExpressionFixture() {
|
||||
const base = createTruthTraitorDecisionFixture();
|
||||
assert.deepEqual(base.actions.slice(-base.planSteps.length), base.planSteps);
|
||||
const actions = structuredClone(base.actions.slice(0, -base.planSteps.length));
|
||||
const game = structuredClone(base.beforePlans);
|
||||
assert.equal(game.status, 'playing'); assert.equal(game.advanced, false);
|
||||
assert.equal(game.phase, 6); assert.equal(game.turn, 1); assert.deepEqual(game.expansions, []);
|
||||
assert.ok(game.battle && !game.battle.revealed && !Object.keys(game.battle.plans).length);
|
||||
assert.ok(truthPlanExpressionClean(game));
|
||||
const projectile = truthPlanExpressionPlayer(game, base.target).hand.find(card => card.id === base.ordinary);
|
||||
assert.ok(projectile && projectile.kind === 'projectile');
|
||||
assert.ok(truthPlanExpressionPlayer(game, base.asker).hand.some(card => card.id === base.card));
|
||||
assert.deepEqual(truthPlanExpressionInventory(game), base.originalCards);
|
||||
const claim: PlanClaim = { kind: 'and', terms: [
|
||||
{ kind: 'dial', compare: 'eq', value: 0 },
|
||||
{ kind: 'weapon', name: null },
|
||||
{ kind: 'defense', name: null },
|
||||
] };
|
||||
const question: Extract<TruthQuestion, { kind: 'battlePlan' }> = {
|
||||
kind: 'battlePlan', target: base.target, territory: base.territory, claim,
|
||||
};
|
||||
const declarationStep: TruthPlanExpressionStep = { actor: base.asker,
|
||||
action: { type: 'card', card: base.card } };
|
||||
const askStep: TruthPlanExpressionStep = { actor: base.asker, action: { type: 'truthAsk', question } };
|
||||
const answerStep: TruthPlanExpressionStep = { actor: base.target, action: { type: 'truthAnswer', answer: 'yes' } };
|
||||
const targetPlan = structuredClone(base.targetPlan);
|
||||
const ownPlan = structuredClone(base.ownPlan);
|
||||
const projectilePlan: Action = { ...targetPlan, weapon: projectile.id };
|
||||
return {
|
||||
game, source: structuredClone(game), beforePlans: structuredClone(game), base,
|
||||
initial: base.initial, lobby: base.lobby, setup: base.setup,
|
||||
beforeStartingDeal: base.beforeStartingDeal, afterSetup: base.afterSetup,
|
||||
beforeShipment: base.beforeShipment, shipment: base.shipment, beforeBattle: base.beforeBattle,
|
||||
beforePreparation: base.beforePreparation,
|
||||
asker: base.asker, target: base.target, card: base.card, truth: base.card,
|
||||
ordinary: base.ordinary, projectile, territory: base.territory, sector: base.sector, event: base.event,
|
||||
targetPlan, ownPlan, projectilePlan,
|
||||
targetPlanStep: { actor: base.target, action: targetPlan },
|
||||
ownPlanStep: { actor: base.asker, action: ownPlan },
|
||||
projectilePlanStep: { actor: base.target, action: projectilePlan },
|
||||
claim, question, declarationStep, askStep, answerStep,
|
||||
deal: base.deal, dealOrder: base.dealOrder, spiceOrder: base.spiceOrder,
|
||||
originalCards: base.originalCards, originalInventory: base.originalCards,
|
||||
originalTraitors: base.originalTraitors, originalSpice: base.originalSpice,
|
||||
actions, steps: actions, sourceActionCount: actions.length,
|
||||
sourceCounts: { beforePlans: actions.length, inheritedReveal: base.sourceActionCount },
|
||||
staging: [
|
||||
...base.staging.slice(0, 2),
|
||||
'Use the inherited actual Storm, Spice Blow, Charity, Bidding, Revival, paid Emperor shipment and battle preparation actions. Stop at its earned beforePlans snapshot; the exposed action prefix omits precisely its two later seal actions. Neither private plan is installed.',
|
||||
'Atreides still holds its original Truthtrance and Emperor its original projectile. Declaration, priority, whole question, definite answer, one discard and the original zero-dial unarmed or actual projectile plan remain caller-owned actions.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type TruthPlanExpressionFixture = ReturnType<typeof createTruthPlanExpressionFixture>;
|
||||
|
||||
/** Both original plans are feasible; their weapon slot changes the aggregate. */
|
||||
export function truthPlanExpressionClaims(fixture: TruthPlanExpressionFixture): {
|
||||
closedTrue: PlanClaim[]; projectileFalse: PlanClaim[];
|
||||
} {
|
||||
return {
|
||||
closedTrue: [
|
||||
{ kind: 'dial', compare: 'eq', value: 0 },
|
||||
{ kind: 'weapon', name: null },
|
||||
{ kind: 'defense', name: null },
|
||||
{ kind: 'support', compare: 'eq', value: 0 },
|
||||
{ kind: 'leader', leader: fixture.base.leaders.emperor },
|
||||
{ kind: 'kwisatz', use: false },
|
||||
{ kind: 'dial', compare: 'lte', value: 1 },
|
||||
{ kind: 'support', compare: 'lte', value: 0 },
|
||||
],
|
||||
projectileFalse: [
|
||||
{ kind: 'dial', compare: 'gte', value: 1 },
|
||||
{ kind: 'weapon', name: null },
|
||||
{ kind: 'leader', leader: null },
|
||||
{ kind: 'kwisatz', use: true },
|
||||
{ kind: 'support', compare: 'gte', value: 1 },
|
||||
{ kind: 'dial', compare: 'eq', value: 1 },
|
||||
{ kind: 'weapon', name: null },
|
||||
{ kind: 'leader', leader: null },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function truthPlanExpressionScenario(fixture: TruthPlanExpressionFixture,
|
||||
kind: 'and' | 'or' = 'and', count = 3, answer: 'yes' | 'no' = 'yes',
|
||||
): TruthPlanExpressionScenario {
|
||||
assert.ok(Number.isInteger(count) && count >= 3 && count <= 8);
|
||||
const { closedTrue, projectileFalse } = truthPlanExpressionClaims(fixture);
|
||||
return {
|
||||
id: `${count}-${kind}-${answer}`,
|
||||
claim: { kind, terms: (kind === 'and' ? closedTrue : projectileFalse).slice(0, count) }, answer,
|
||||
plan: structuredClone(answer === 'yes' ? fixture.targetPlan : fixture.projectilePlan),
|
||||
contradictingPlan: structuredClone(answer === 'yes' ? fixture.projectilePlan : fixture.targetPlan),
|
||||
};
|
||||
}
|
||||
|
||||
export function truthPlanExpressionNestedScenario(fixture: TruthPlanExpressionFixture,
|
||||
kind: 'and' | 'or' = 'and', answer: 'yes' | 'no' = 'yes',
|
||||
): TruthPlanExpressionScenario {
|
||||
const { closedTrue, projectileFalse } = truthPlanExpressionClaims(fixture);
|
||||
const claim: PlanClaim = kind === 'and'
|
||||
? { kind: 'and', terms: [closedTrue[0], { kind: 'or', terms: [
|
||||
projectileFalse[0], projectileFalse[2], closedTrue[1],
|
||||
] }, closedTrue[2]] }
|
||||
: { kind: 'or', terms: [projectileFalse[0], { kind: 'and', terms: closedTrue.slice(0, 3) }, projectileFalse[2]] };
|
||||
return { ...truthPlanExpressionScenario(fixture, kind, 3, answer), id: `nested-${kind}-${answer}`, claim };
|
||||
}
|
||||
|
||||
export function passTruthPlanExpressionPriority(game: Game, actions: TruthPlanExpressionStep[] = []): Game {
|
||||
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
|
||||
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actTruthPlanExpression(game, actor.id, { type: 'truthPass' }, actions);
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
return game;
|
||||
}
|
||||
|
||||
export function declareTruthPlanExpression(fixture: TruthPlanExpressionFixture) {
|
||||
const before = structuredClone(fixture.game), actions: TruthPlanExpressionStep[] = [];
|
||||
const declaration = structuredClone(fixture.declarationStep);
|
||||
const game = actTruthPlanExpression(before, declaration.actor, declaration.action, actions);
|
||||
assert.equal(game.truthtrance?.stage, 'priority');
|
||||
return { game, before, declaration, opened: structuredClone(game), actions };
|
||||
}
|
||||
|
||||
/** Raw native Ask source for SSR and actual edit/callback runtime proof. */
|
||||
export function createTruthPlanExpressionAskSource(fixture: TruthPlanExpressionFixture) {
|
||||
const declared = declareTruthPlanExpression(fixture);
|
||||
const game = passTruthPlanExpressionPriority(declared.game, declared.actions);
|
||||
return { ...declared, game, beforeQuestion: structuredClone(game) };
|
||||
}
|
||||
|
||||
export function askTruthPlanExpression(fixture: TruthPlanExpressionFixture, claim: PlanClaim = fixture.claim) {
|
||||
const source = createTruthPlanExpressionAskSource(fixture), actions = source.actions;
|
||||
const question: TruthPlanExpressionStep = { actor: fixture.asker, action: { type: 'truthAsk', question: {
|
||||
kind: 'battlePlan', target: fixture.target, territory: fixture.territory, claim: structuredClone(claim),
|
||||
} } };
|
||||
const game = actTruthPlanExpression(source.game, question.actor, question.action, actions);
|
||||
assert.equal(game.truthtrance?.stage, 'answer');
|
||||
return { ...source, game, question, actions };
|
||||
}
|
||||
|
||||
export function answerTruthPlanExpression(game: Game, answer: TruthAnswer,
|
||||
actions: TruthPlanExpressionStep[] = []): Game {
|
||||
const target = game.truthtrance?.question?.target; assert.ok(target);
|
||||
assert.ok(viewGame(game, target).truthBattleAnswers?.includes(answer));
|
||||
return actTruthPlanExpression(game, target, { type: 'truthAnswer', answer }, actions);
|
||||
}
|
||||
|
||||
export function sealTruthPlanExpression(game: Game, fixture: TruthPlanExpressionFixture,
|
||||
plan: Action = fixture.targetPlan, actions: TruthPlanExpressionStep[] = []) {
|
||||
game = actTruthPlanExpression(game, fixture.target, plan, actions);
|
||||
const afterTargetPlan = structuredClone(game);
|
||||
game = actTruthPlanExpression(game, fixture.asker, fixture.ownPlan, actions);
|
||||
assert.ok(game.battle?.revealed);
|
||||
return { game, afterTargetPlan, actions };
|
||||
}
|
||||
|
||||
export function finishTruthPlanExpression(fixture: TruthPlanExpressionFixture,
|
||||
scenario: TruthPlanExpressionScenario = truthPlanExpressionScenario(fixture)) {
|
||||
const asked = askTruthPlanExpression(fixture, scenario.claim), actions = asked.actions;
|
||||
const answered = answerTruthPlanExpression(asked.game, scenario.answer, actions);
|
||||
assert.ok(truthPlanExpressionClean(answered));
|
||||
const sealed = sealTruthPlanExpression(answered, fixture, scenario.plan, actions);
|
||||
return { ...asked, asked: asked.game, answered, ...sealed, scenario };
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import type { ShipmentExpression } from '../game/shipment-promises';
|
||||
import type { TruthQuestion } from '../game/truthtrance';
|
||||
import {
|
||||
createTruthTraitorDecisionFixture,
|
||||
actTruthTraitorDecision,
|
||||
advanceTruthTraitorDecision,
|
||||
truthTraitorDecisionClean,
|
||||
truthTraitorDecisionInventory,
|
||||
truthTraitorDecisionPlayer,
|
||||
type TruthTraitorDecisionStep,
|
||||
} from './fixture-truth-traitor-decision';
|
||||
|
||||
export type TruthShipmentExpressionStep = TruthTraitorDecisionStep;
|
||||
export type TruthShipmentExpressionScenario = {
|
||||
id: string;
|
||||
expression: ShipmentExpression;
|
||||
answer: 'yes' | 'no';
|
||||
};
|
||||
export const actTruthShipmentExpression = actTruthTraitorDecision;
|
||||
export const truthShipmentExpressionClean = truthTraitorDecisionClean;
|
||||
export const truthShipmentExpressionInventory = truthTraitorDecisionInventory;
|
||||
export const truthShipmentExpressionPlayer = truthTraitorDecisionPlayer;
|
||||
const leaf = (territory = 'arrakeen', minimum = 1): ShipmentExpression => ({ territory, minimum });
|
||||
|
||||
/** The original one-force Arrakeen shipment determines every selected answer.
|
||||
* AND No retains true earlier clauses; OR Yes needs the final destination. */
|
||||
export function truthShipmentExpressionScenario(
|
||||
op: 'and' | 'or' = 'and', count = 3, answer: 'yes' | 'no' = 'yes',
|
||||
): TruthShipmentExpressionScenario {
|
||||
assert.ok(Number.isInteger(count) && count >= 3 && count <= 16);
|
||||
const alternatives = TERRITORIES.filter(place => place.id !== 'arrakeen');
|
||||
const terms = op === 'and'
|
||||
? Array.from({ length: count }, (_, index) => leaf('arrakeen', answer === 'no' && index === count - 1 ? 2 : 1))
|
||||
: [...alternatives.slice(0, count - 1).map(place => leaf(place.id)), leaf('arrakeen', answer === 'yes' ? 1 : 2)];
|
||||
return { id: `${count}-${op}-${answer}`, expression: { op, terms }, answer };
|
||||
}
|
||||
|
||||
export function truthShipmentExpressionNestedScenario(
|
||||
op: 'and' | 'or' = 'and', answer: 'yes' | 'no' = 'yes',
|
||||
): TruthShipmentExpressionScenario {
|
||||
const decisive = leaf('arrakeen', answer === 'yes' ? 1 : 2);
|
||||
const expression: ShipmentExpression = op === 'and'
|
||||
? { op, terms: [leaf(), { op: 'or', terms: [leaf('carthag'), leaf('sietch_tabr'), decisive] }, leaf()] }
|
||||
: { op, terms: [leaf('carthag'), { op: 'and', terms: [leaf(), leaf(), decisive] }, leaf('sietch_tabr')] };
|
||||
return { id: `nested-${op}-${answer}`, expression, answer };
|
||||
}
|
||||
|
||||
export function truthShipmentExpressionDepthScenario(depth = 4): TruthShipmentExpressionScenario {
|
||||
let expression = leaf();
|
||||
for (let level = 0; level < depth; level++) expression = level % 2 === 0
|
||||
? { op: 'and', terms: [leaf(), expression] }
|
||||
: { op: 'or', terms: [leaf('carthag'), expression] };
|
||||
return { id: `depth-${depth}`, expression, answer: 'yes' };
|
||||
}
|
||||
|
||||
/** Reuse the genuine two-seat Basic source only through its beforeShipment
|
||||
* boundary. The prefix excludes that original shipment and every later battle
|
||||
* action. No postdeal hand, wallet, army, clock or promise is installed. */
|
||||
export function createTruthShipmentExpressionFixture() {
|
||||
const base = createTruthTraitorDecisionFixture();
|
||||
const shipmentIndex = base.actions.findIndex(step => step.actor === base.shipmentStep.actor &&
|
||||
JSON.stringify(step.action) === JSON.stringify(base.shipmentStep.action));
|
||||
assert.ok(shipmentIndex >= 0);
|
||||
const actions = structuredClone(base.actions.slice(0, shipmentIndex));
|
||||
const game = structuredClone(base.beforeShipment);
|
||||
assert.equal(game.phase, 5); assert.equal(game.active, base.target);
|
||||
assert.ok(truthShipmentExpressionClean(game)); assert.equal(game.pendingShipment ?? null, null);
|
||||
assert.equal(truthShipmentExpressionPlayer(game, base.target).shipped, false);
|
||||
assert.equal(truthShipmentExpressionPlayer(game, base.target).reserves, 20);
|
||||
assert.ok(truthShipmentExpressionPlayer(game, base.asker).hand.some(card => card.id === base.card));
|
||||
assert.deepEqual(truthShipmentExpressionInventory(game), base.originalCards);
|
||||
const scenario = truthShipmentExpressionScenario();
|
||||
const question: Extract<TruthQuestion, { kind: 'shipment' }> = {
|
||||
kind: 'shipment', target: base.target, claim: scenario.expression,
|
||||
};
|
||||
const declarationStep: TruthShipmentExpressionStep = { actor: base.asker, action: { type: 'card', card: base.card } };
|
||||
const askStep: TruthShipmentExpressionStep = { actor: base.asker, action: { type: 'truthAsk', question } };
|
||||
const answerStep: TruthShipmentExpressionStep = { actor: base.target, action: { type: 'truthAnswer', answer: scenario.answer } };
|
||||
const continuationStep: TruthShipmentExpressionStep = { actor: base.target, action: { type: 'endMovement' } };
|
||||
return {
|
||||
game, source: structuredClone(game), base, beforeShipment: structuredClone(game),
|
||||
lobby: base.lobby, setup: base.setup, beforeStartingDeal: base.beforeStartingDeal,
|
||||
afterSetup: base.afterSetup, initial: base.initial, deal: base.deal, dealOrder: base.dealOrder,
|
||||
spiceOrder: base.spiceOrder, originalSpice: base.originalSpice, originalCards: base.originalCards,
|
||||
originalInventory: base.originalCards, asker: base.asker, target: base.target,
|
||||
card: base.card, truth: base.card, ordinary: base.ordinary, location: base.location,
|
||||
scenario, expression: scenario.expression, question, declarationStep, askStep, answerStep,
|
||||
shipmentStep: structuredClone(base.shipmentStep), continuationStep,
|
||||
actions, steps: actions, sourceActionCount: actions.length,
|
||||
sourceCounts: { shipment: shipmentIndex, completeBase: base.sourceActionCount },
|
||||
staging: [...base.staging.slice(0, 2),
|
||||
'Only the original action prefix through Emperor’s unused active reserve-shipment turn is retained. Atreides has genuinely ended Movement. Truthtrance declaration, priority, the full pure shipment question, answer, original paid one-force Arrakeen delivery and Emperor endMovement remain caller actions. No later source shipment or battle action is counted as prefix evidence.'],
|
||||
};
|
||||
}
|
||||
export type TruthShipmentExpressionFixture = ReturnType<typeof createTruthShipmentExpressionFixture>;
|
||||
|
||||
export function declareTruthShipmentExpression(fixture: TruthShipmentExpressionFixture) {
|
||||
const before = structuredClone(fixture.game), actions: TruthShipmentExpressionStep[] = [];
|
||||
const declaration = structuredClone(fixture.declarationStep);
|
||||
const game = actTruthShipmentExpression(before, declaration.actor, declaration.action, actions);
|
||||
assert.equal(game.truthtrance?.stage, 'priority');
|
||||
return { game, before, declaration, opened: structuredClone(game), actions };
|
||||
}
|
||||
|
||||
export function passTruthShipmentExpressionPriority(game: Game, actions: TruthShipmentExpressionStep[] = []): Game {
|
||||
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
|
||||
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actTruthShipmentExpression(game, actor.id, { type: 'truthPass' }, actions);
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
return game;
|
||||
}
|
||||
|
||||
export function askTruthShipmentExpression(fixture: TruthShipmentExpressionFixture, expression = fixture.expression) {
|
||||
const declared = declareTruthShipmentExpression(fixture), actions = declared.actions;
|
||||
let game = passTruthShipmentExpressionPriority(declared.game, actions);
|
||||
const beforeQuestion = structuredClone(game);
|
||||
const question: TruthShipmentExpressionStep = { actor: fixture.asker, action: { type: 'truthAsk', question: {
|
||||
kind: 'shipment', target: fixture.target, claim: structuredClone(expression),
|
||||
} } };
|
||||
game = actTruthShipmentExpression(game, question.actor, question.action, actions);
|
||||
assert.equal(game.truthtrance?.stage, 'answer');
|
||||
return { ...declared, game, beforeQuestion, question, actions };
|
||||
}
|
||||
|
||||
export function answerTruthShipmentExpression(game: Game, answer: 'yes' | 'no', actions: TruthShipmentExpressionStep[] = []) {
|
||||
const target = game.truthtrance?.question?.target; assert.ok(target);
|
||||
assert.ok(viewGame(game, target).truthShipmentAnswers!.includes(answer));
|
||||
return actTruthShipmentExpression(game, target, { type: 'truthAnswer', answer }, actions);
|
||||
}
|
||||
|
||||
/** Settle original shipment responses only; actual delivery remains explicit. */
|
||||
export function settleTruthShipmentExpression(game: Game, actions: TruthShipmentExpressionStep[] = []) {
|
||||
return advanceTruthTraitorDecision(game, state => truthShipmentExpressionClean(state) && !state.pendingShipment, actions);
|
||||
}
|
||||
|
||||
export function finishTruthShipmentExpression(
|
||||
fixture: TruthShipmentExpressionFixture, scenario: TruthShipmentExpressionScenario = fixture.scenario,
|
||||
) {
|
||||
const asked = askTruthShipmentExpression(fixture, scenario.expression), actions = asked.actions;
|
||||
let game = answerTruthShipmentExpression(asked.game, scenario.answer, actions);
|
||||
const answerStep = actions.at(-1)!, bound = structuredClone(game);
|
||||
const shipmentStep = structuredClone(fixture.shipmentStep);
|
||||
game = actTruthShipmentExpression(game, shipmentStep.actor, shipmentStep.action, actions);
|
||||
const declaredShipment = structuredClone(game);
|
||||
game = settleTruthShipmentExpression(game, actions);
|
||||
return { ...asked, asked: asked.game, game, answerStep, bound, shipmentStep, declaredShipment, actions };
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
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 { BattleClaimFields } from '../components/battle-promises';
|
||||
import { BattleClaimExpressionFields } from '../components/battle-claim-expression-fields';
|
||||
import { TruthBattlePlanQuestion } from '../components/truth-battle-plan-question';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { battleClaimExpressionError, battleClaimExpressionSize } from '../game/battle-claim-expression';
|
||||
import type { PlanClaim } from '../game/battle-promises';
|
||||
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
|
||||
import type { TruthQuestion } from '../game/truthtrance';
|
||||
|
||||
// Controlled projections supplement the separate earned native battle source.
|
||||
// Hook-free components are invoked directly; React renders BattleClaimFields
|
||||
// and its hook. Leaf edits cross the actual controlled onChange boundary.
|
||||
function projected(): GameView {
|
||||
const game = createGame('TRUTHBATTLECONTROLS', newPlayer('a', 'Asker', 'atreides'), false);
|
||||
joinGame(game, newPlayer('t', 'Target', 'emperor'));
|
||||
joinGame(game, newPlayer('o', 'Observer', 'harkonnen'));
|
||||
const view = viewGame(game, 'a');
|
||||
view.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'projected-truth' }], passed: [], question: null };
|
||||
view.battle = { attacker: 'a', defender: 't', territory: 'arrakeen' } as NonNullable<GameView['battle']>;
|
||||
return view;
|
||||
}
|
||||
const dial = (value = 0): PlanClaim => ({ kind: 'dial', compare: 'eq', value });
|
||||
const weapon = (name: string | null = null): PlanClaim => ({ kind: 'weapon', name });
|
||||
type Question = Extract<TruthQuestion, { kind: 'battlePlan' }>;
|
||||
const questionFor = (claim: PlanClaim, target = 't', territory = 'arrakeen'): Question => ({ kind: 'battlePlan', target, territory, claim });
|
||||
type ElementProps = { children?: ReactNode; [key: string]: unknown };
|
||||
type Element = ReactElement<ElementProps>;
|
||||
function elements(node: ReactNode): Element[] {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!isValidElement<ElementProps>(node)) return [];
|
||||
return [node, ...elements(node.props.children)];
|
||||
}
|
||||
function control(tree: ReactNode, key: string): Element {
|
||||
const found = elements(tree).find(element => element.props['data-battle-claim-control'] === key);
|
||||
assert.ok(found, `Control ${key} is rendered`); return found;
|
||||
}
|
||||
function click(button: Element) { (button.props.onClick as () => void)(); }
|
||||
function fieldset(tree: ReactNode, path: string): Element {
|
||||
const found = elements(tree).find(element => element.props['data-battle-claim-path'] === path);
|
||||
assert.ok(found, `Condition or group ${path} is rendered`); return found;
|
||||
}
|
||||
function editLeaf(tree: ReactNode, path: string, next: PlanClaim) {
|
||||
const field = elements(fieldset(tree, path)).find(element => element.type === BattleClaimFields);
|
||||
assert.ok(field, `Plan leaf ${path} is rendered`);
|
||||
(field.props.onChange as (claim: PlanClaim) => void)(next);
|
||||
}
|
||||
function switchGroup(tree: ReactNode, path: string, kind: string) {
|
||||
const select = elements(fieldset(tree, path)).find(element => element.type === 'select');
|
||||
assert.ok(select);
|
||||
(select.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: kind } });
|
||||
}
|
||||
function editor(initial: PlanClaim, disabled = false) {
|
||||
let value = initial, changes = 0;
|
||||
const game = projected();
|
||||
return {
|
||||
get value() { return value; }, get changes() { return changes; },
|
||||
tree: () => BattleClaimExpressionFields({ value, game, disabled, onChange(next) { value = next; changes++; } }),
|
||||
html: () => renderToStaticMarkup(createElement(BattleClaimExpressionFields, { value, game, disabled, onChange() {} })),
|
||||
};
|
||||
}
|
||||
function askTree(game: GameView, question: Question, busy = false) {
|
||||
const actions: Action[] = [], changes: PlanClaim[] = [];
|
||||
const props = { game, question, busy, act(action: Action) { actions.push(action); }, onChange(next: PlanClaim) { changes.push(next); } };
|
||||
const tree = TruthBattlePlanQuestion(props);
|
||||
const button = elements(tree).find(element => element.props['data-testid'] === 'truth-battle-plan-ask');
|
||||
assert.ok(button, 'The actual Ask action is rendered');
|
||||
return { tree, button, actions, changes, html: () => renderToStaticMarkup(createElement(TruthBattlePlanQuestion, props)) };
|
||||
}
|
||||
function groupsDeep(depth: number): PlanClaim {
|
||||
let claim = weapon();
|
||||
for (let level = 1; level <= depth; level++) claim = { kind: 'and', terms: [claim, dial(level)] };
|
||||
return claim;
|
||||
}
|
||||
|
||||
void test('actual group and leaf callbacks author three through eight plan conditions without losing earlier leaves', () => {
|
||||
const e = editor(weapon('Shield'));
|
||||
click(control(e.tree(), 'and-1'));
|
||||
editLeaf(e.tree(), '1.2', { kind: 'defense', name: null });
|
||||
for (let count = 3; count <= 8; count++) {
|
||||
const before = structuredClone(e.value);
|
||||
click(control(e.tree(), 'add-1'));
|
||||
editLeaf(e.tree(), `1.${count}`, dial(count));
|
||||
assert.ok(e.value.kind === 'and'); assert.equal(e.value.terms.length, count);
|
||||
assert.deepEqual(e.value.terms.slice(0, count - 1), (before as Extract<PlanClaim, { kind: 'and' | 'or' }>).terms);
|
||||
assert.deepEqual(e.value.terms[count - 1], dial(count));
|
||||
}
|
||||
const before = structuredClone(e.value), changes = e.changes;
|
||||
assert.equal((e.html().match(/Plan element/g) ?? []).length, 8);
|
||||
assert.match(e.html(), /9 \/ 16 total clauses/);
|
||||
assert.equal(control(e.tree(), 'add-1').props.disabled, true);
|
||||
click(control(e.tree(), 'add-1'));
|
||||
assert.equal(e.changes, changes); assert.deepEqual(e.value, before);
|
||||
});
|
||||
|
||||
void test('nested AND and OR callbacks edit the whole tree, switch meaning and add groups', () => {
|
||||
const e = editor(dial(2));
|
||||
click(control(e.tree(), 'and-1'));
|
||||
editLeaf(e.tree(), '1.2', weapon());
|
||||
click(control(e.tree(), 'or-1.2'));
|
||||
editLeaf(e.tree(), '1.2.2', { kind: 'kwisatz', use: false });
|
||||
click(control(e.tree(), 'add-1.2'));
|
||||
editLeaf(e.tree(), '1.2.3', { kind: 'support', compare: 'gte', value: 3 });
|
||||
const before = structuredClone(e.value);
|
||||
switchGroup(e.tree(), '1.2', 'and');
|
||||
assert.deepEqual(e.value, { kind: 'and', terms: [dial(2), { kind: 'and', terms: [
|
||||
weapon(), { kind: 'kwisatz', use: false }, { kind: 'support', compare: 'gte', value: 3 },
|
||||
] }] });
|
||||
switchGroup(e.tree(), '1.2', 'or'); assert.deepEqual(e.value, before);
|
||||
const changes = e.changes;
|
||||
switchGroup(e.tree(), '1.2', 'single'); assert.equal(e.changes, changes);
|
||||
click(control(e.tree(), 'nested-1.2'));
|
||||
assert.ok(e.value.kind === 'and');
|
||||
const nested = e.value.terms[1]; assert.ok(nested.kind === 'or');
|
||||
assert.equal(nested.terms.length, 4);
|
||||
assert.deepEqual(nested.terms[3], { kind: 'and', terms: [dial(), dial()] });
|
||||
assert.match(e.html(), /Group 1\.2 · OR/); assert.match(e.html(), /Group 1\.2\.4 · AND/);
|
||||
});
|
||||
|
||||
void test('removal collapses singleton groups and retains every branch of a surviving group', () => {
|
||||
const retained: PlanClaim = { kind: 'leader', leader: CHEAP_HERO_TRAITOR };
|
||||
const e = editor({ kind: 'or', terms: [weapon(), { kind: 'and', terms: [dial(9), retained] }] });
|
||||
click(control(e.tree(), 'remove-1.2.1'));
|
||||
assert.deepEqual(e.value, { kind: 'or', terms: [weapon(), retained] });
|
||||
click(control(e.tree(), 'remove-1.1')); assert.deepEqual(e.value, retained);
|
||||
const survivor: PlanClaim = { kind: 'or', terms: [dial(1), dial(2), dial(3)] };
|
||||
const whole = editor({ kind: 'and', terms: [{ kind: 'and', terms: [weapon(), dial()] }, survivor] });
|
||||
click(control(whole.tree(), 'remove-1.1')); assert.deepEqual(whole.value, survivor);
|
||||
assert.equal(battleClaimExpressionError(whole.value), null);
|
||||
});
|
||||
|
||||
void test('sixteen nodes remain fully editable while add and wrap callbacks enforce the whole-tree budget', () => {
|
||||
const full: PlanClaim = { kind: 'and', terms: [
|
||||
{ kind: 'or', terms: Array.from({ length: 8 }, (_, index) => dial(index)) },
|
||||
{ kind: 'and', terms: Array.from({ length: 5 }, (_, index) => dial(index + 8)) },
|
||||
] };
|
||||
const e = editor(full);
|
||||
assert.deepEqual(battleClaimExpressionSize(e.value), { nodes: 16, depth: 2 });
|
||||
for (const key of ['add-1', 'nested-1', 'add-1.2', 'and-1.2.1', 'or-1']) {
|
||||
assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
|
||||
}
|
||||
assert.equal(e.changes, 0); assert.deepEqual(e.value, full);
|
||||
assert.equal((e.html().match(/Plan element/g) ?? []).length, 13);
|
||||
editLeaf(e.tree(), '1.2.5', weapon('Snooper'));
|
||||
assert.equal(e.changes, 1); assert.deepEqual(battleClaimExpressionSize(e.value), { nodes: 16, depth: 2 });
|
||||
});
|
||||
|
||||
void test('four grouping levels permit deepest leaf repair and shallow nesting but reject a fifth level', () => {
|
||||
const e = editor(groupsDeep(4));
|
||||
assert.deepEqual(battleClaimExpressionSize(e.value), { nodes: 9, depth: 4 });
|
||||
for (const key of ['and-1', 'or-1.1.1.1.1', 'nested-1.1.1.1']) {
|
||||
assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
|
||||
}
|
||||
assert.equal(e.changes, 0);
|
||||
editLeaf(e.tree(), '1.1.1.1.1', weapon('Snooper'));
|
||||
click(control(e.tree(), 'add-1.1.1.1'));
|
||||
click(control(e.tree(), 'or-1.2'));
|
||||
assert.deepEqual(battleClaimExpressionSize(e.value), { nodes: 12, depth: 4 });
|
||||
});
|
||||
|
||||
void test('busy guards direct add, wrap, remove, switch and leaf callbacks as well as native fieldset controls', () => {
|
||||
const initial: PlanClaim = { kind: 'and', terms: [weapon(), dial(3)] }, e = editor(initial, true);
|
||||
assert.match(e.html(), /<fieldset\b[^>]*disabled=""[^>]*data-testid="truth-battle-plan-expression"/);
|
||||
for (const key of ['add-1', 'nested-1', 'and-1.1', 'or-1.1', 'remove-1.2']) {
|
||||
assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
|
||||
}
|
||||
switchGroup(e.tree(), '1', 'or'); editLeaf(e.tree(), '1.2', dial(-1));
|
||||
assert.equal(e.changes, 0); assert.deepEqual(e.value, initial);
|
||||
});
|
||||
|
||||
void test('Ask sends exactly the complete three-term, eight-term and nested question with public leader identities', () => {
|
||||
const game = projected();
|
||||
const claims: PlanClaim[] = [
|
||||
{ kind: 'and', terms: [dial(), weapon(), { kind: 'defense', name: null }] },
|
||||
{ kind: 'or', terms: Array.from({ length: 8 }, (_, index) => dial(index)) },
|
||||
{ kind: 'and', terms: [dial(), { kind: 'or', terms: [weapon(),
|
||||
{ kind: 'leader', leader: game.allLeaders[0].id }, { kind: 'leader', leader: CHEAP_HERO_TRAITOR }] }] },
|
||||
];
|
||||
for (const claim of claims) {
|
||||
const question = questionFor(claim), before = structuredClone(question), ask = askTree(game, question);
|
||||
assert.equal(ask.button.props.disabled, false); click(ask.button);
|
||||
assert.deepEqual(ask.actions, [{ type: 'truthAsk', question }]);
|
||||
assert.deepEqual(Object.keys(ask.actions[0]).sort(), ['question', 'type']);
|
||||
assert.deepEqual(question, before); assert.match(ask.html(), /data-testid="truth-battle-plan-preview"/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('every invalid third or nested leaf blocks Ask despite valid earlier AND or OR clauses', () => {
|
||||
const invalid: PlanClaim[] = [dial(-1), dial(0.25), dial(NaN), dial(41),
|
||||
{ kind: 'support', compare: 'eq', value: 0.5 },
|
||||
{ kind: 'dial', compare: 'not-a-comparison', value: 1 } as unknown as PlanClaim,
|
||||
weapon('Not an original card'), { kind: 'leader', leader: 'not-a-public-leader' },
|
||||
{ kind: 'kwisatz', use: 'yes' } as unknown as PlanClaim,
|
||||
];
|
||||
for (const kind of ['and', 'or'] as const) for (const bad of invalid) {
|
||||
for (const leaf of [bad, { kind: 'and', terms: [dial(), bad] } as PlanClaim]) {
|
||||
const ask = askTree(projected(), questionFor({ kind, terms: [dial(), weapon(), leaf] }));
|
||||
assert.equal(ask.button.props.disabled, true, `${kind}: ${bad.kind}`); click(ask.button);
|
||||
assert.deepEqual(ask.actions, []); assert.match(ask.html(), /Resolve the battle-plan fields before asking/);
|
||||
}
|
||||
}
|
||||
const e = editor({ kind: 'and', terms: [dial(), weapon(), dial()] });
|
||||
editLeaf(e.tree(), '1.3', dial(-1));
|
||||
assert.ok(e.value.kind === 'and'); assert.deepEqual(e.value.terms[2], dial(-1));
|
||||
editLeaf(e.tree(), '1.3', dial(0.5));
|
||||
assert.deepEqual(e.value.terms[2], dial(0.5), 'Invalid typing remains repairable');
|
||||
});
|
||||
|
||||
void test('over-width, over-node, over-depth and singleton questions cannot Ask and keep all leaves visible', () => {
|
||||
const invalid: PlanClaim[] = [
|
||||
{ kind: 'and', terms: [dial()] },
|
||||
{ kind: 'or', terms: Array.from({ length: 9 }, (_, index) => dial(index)) },
|
||||
{ kind: 'and', terms: [
|
||||
{ kind: 'or', terms: Array.from({ length: 8 }, (_, index) => dial(index)) },
|
||||
{ kind: 'and', terms: Array.from({ length: 6 }, (_, index) => dial(index)) },
|
||||
] }, groupsDeep(5),
|
||||
];
|
||||
const leaves = (claim: PlanClaim): number => claim.kind === 'and' || claim.kind === 'or'
|
||||
? claim.terms.reduce((count, term) => count + leaves(term), 0) : 1;
|
||||
for (const claim of invalid) {
|
||||
const ask = askTree(projected(), questionFor(claim));
|
||||
assert.equal(ask.button.props.disabled, true); click(ask.button); assert.deepEqual(ask.actions, []);
|
||||
assert.match(ask.html(), /role="alert"/); assert.equal((ask.html().match(/Plan element/g) ?? []).length, leaves(claim));
|
||||
}
|
||||
});
|
||||
|
||||
void test('live queue ownership, target seating, current battle and territory guard Ask and editor callbacks', () => {
|
||||
const question = questionFor(dial()), cases: { game: GameView; question?: Question; busy?: boolean }[] = [{ game: projected(), busy: true }];
|
||||
for (const stage of ['priority', 'answer', 'unknown'] as const) {
|
||||
const game = projected(); game.truthtrance!.stage = stage; cases.push({ game });
|
||||
}
|
||||
for (const owner of ['t', 'not-seated']) {
|
||||
const game = projected(); game.truthtrance!.queue[0].player = owner; cases.push({ game });
|
||||
}
|
||||
const unseated = projected(); unseated.me = 'not-seated'; unseated.truthtrance!.queue[0].player = 'not-seated'; cases.push({ game: unseated });
|
||||
const empty = projected(); empty.truthtrance!.queue = []; cases.push({ game: empty });
|
||||
const absent = projected(); absent.truthtrance = null; cases.push({ game: absent });
|
||||
const noBattle = projected(); noBattle.battle = null; cases.push({ game: noBattle });
|
||||
for (const target of ['a', 'o', 'not-seated']) cases.push({ game: projected(), question: questionFor(dial(), target) });
|
||||
cases.push({ game: projected(), question: questionFor(dial(), 't', 'carthag') });
|
||||
for (const entry of cases) {
|
||||
const ask = askTree(entry.game, entry.question ?? question, entry.busy);
|
||||
assert.equal(ask.button.props.disabled, true); click(ask.button);
|
||||
const fields = elements(ask.tree).find(element => element.type === BattleClaimExpressionFields);
|
||||
if (fields) {
|
||||
assert.equal(fields.props.disabled, true); (fields.props.onChange as (claim: PlanClaim) => void)(dial(9));
|
||||
}
|
||||
assert.deepEqual(ask.actions, []); assert.deepEqual(ask.changes, []); assert.doesNotThrow(ask.html);
|
||||
}
|
||||
for (const change of [
|
||||
(game: GameView) => { game.truthtrance!.stage = 'answer'; },
|
||||
(game: GameView) => { game.truthtrance!.queue[0].player = 't'; },
|
||||
(game: GameView) => { game.battle!.territory = 'carthag'; },
|
||||
(game: GameView) => { game.players = game.players.filter(player => player.id !== question.target); },
|
||||
]) {
|
||||
const game = projected(), stale = askTree(game, question); change(game); click(stale.button);
|
||||
assert.deepEqual(stale.actions, [], 'An existing callback rechecks current public ownership and battle');
|
||||
}
|
||||
});
|
||||
|
||||
void test('authoring does not infer private cards, plans, answer choices or later reveal policy', () => {
|
||||
const game = projected();
|
||||
game.battle!.revealed = true;
|
||||
for (const player of game.players) for (const field of ['hand', 'traitors', 'leaders', 'spice', 'forces'])
|
||||
Object.defineProperty(player, field, { get() { throw Error(`Authoring read private ${field}`); } });
|
||||
for (const field of ['plans', 'myPlan', 'compliantPlan'])
|
||||
Object.defineProperty(game.battle!, field, { get() { throw Error(`Authoring read private ${field}`); } });
|
||||
for (const field of ['truthBattleAnswers', 'truthAnswer', 'truthKnowledge'])
|
||||
Object.defineProperty(game, field, { get() { throw Error(`Authoring inferred ${field}`); } });
|
||||
const claim: PlanClaim = { kind: 'and', terms: [
|
||||
{ kind: 'leader', leader: game.allLeaders[0].id }, weapon('Shield'),
|
||||
{ kind: 'or', terms: [{ kind: 'leader', leader: CHEAP_HERO_TRAITOR }, dial(0.5)] },
|
||||
] };
|
||||
const question = questionFor(claim), ask = askTree(game, question);
|
||||
assert.equal(ask.button.props.disabled, false); assert.doesNotThrow(ask.html); click(ask.button);
|
||||
assert.deepEqual(ask.actions, [{ type: 'truthAsk', question }]);
|
||||
const html = ask.html();
|
||||
assert.match(html, /AND requires all conditions; OR requires at least one/);
|
||||
assert.match(html, /No makes the whole expression false, not necessarily every condition/);
|
||||
assert.match(html, /App limits: 8 terms per group, 16 total clauses/);
|
||||
assert.match(html, /not additional printed rules/); assert.ok(html.includes(game.allLeaders[0].name));
|
||||
assert.match(html, /Cheap Hero \/ Heroine/); assert.doesNotMatch(html, /https?:\/\//);
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { matchesPlanClaim, type PlanClaim } from '../game/battle-promises';
|
||||
import { battleClaimExpressionSize } from '../game/battle-claim-expression';
|
||||
import {
|
||||
createTruthPlanExpressionFixture as fixture,
|
||||
actTruthPlanExpression as act,
|
||||
truthPlanExpressionClean as clean,
|
||||
truthPlanExpressionPlayer as player,
|
||||
truthPlanExpressionInventory as inventory,
|
||||
truthPlanExpressionClaims as claims,
|
||||
truthPlanExpressionScenario as scenario,
|
||||
truthPlanExpressionNestedScenario as nestedScenario,
|
||||
declareTruthPlanExpression as declare,
|
||||
passTruthPlanExpressionPriority as passPriority,
|
||||
createTruthPlanExpressionAskSource as askSource,
|
||||
askTruthPlanExpression as ask,
|
||||
answerTruthPlanExpression as answer,
|
||||
sealTruthPlanExpression as seal,
|
||||
finishTruthPlanExpression as finish,
|
||||
type TruthPlanExpressionFixture,
|
||||
} from './fixture-truth-plan-expression';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action, message?: RegExp) {
|
||||
const before = structuredClone(game), run = () => applyAction(game, actor, action);
|
||||
if (message) assert.throws(run, message); else assert.throws(run);
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function battleControls(game: Game) {
|
||||
const battle = game.battle; assert.ok(battle);
|
||||
return {
|
||||
status: game.status, turn: game.turn, phase: game.phase, active: game.active, ready: game.ready,
|
||||
event: battle.event, territory: battle.territory, attacker: battle.attacker, defender: battle.defender,
|
||||
revealed: battle.revealed, plans: battle.plans,
|
||||
preparation: battle.preparation ?? null, preLeader: battle.preLeader ?? null,
|
||||
traitorCalls: battle.traitorCalls,
|
||||
};
|
||||
}
|
||||
function answered(game: Game, f: TruthPlanExpressionFixture, claim: PlanClaim, expected: 'yes' | 'no') {
|
||||
assert.ok(clean(game)); assert.equal(game.truthtrance, null);
|
||||
assert.deepEqual(battleControls(game), battleControls(f.source));
|
||||
assert.deepEqual(player(game, f.target).hand, player(f.source, f.target).hand);
|
||||
assert.equal(game.discard.length, f.source.discard.length + 1);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(player(game, f.asker).hand.some(card => card.id === f.card), false);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
assert.deepEqual(game.battle!.truthPromises, [{ player: f.target, asker: f.asker, claim, answer: expected === 'yes' }]);
|
||||
assert.deepEqual(game.truthHistory!.at(-1), {
|
||||
turn: 1, phase: 6, asker: f.asker,
|
||||
question: { kind: 'battlePlan', target: f.target, territory: f.territory, claim }, answer: expected,
|
||||
});
|
||||
}
|
||||
function actualPlan(game: Game, f: TruthPlanExpressionFixture, claim: PlanClaim, expected: 'yes' | 'no') {
|
||||
assert.ok(game.battle?.revealed);
|
||||
assert.equal(game.battle.event, f.event); assert.equal(game.battle.territory, f.territory);
|
||||
const plan = game.battle.plans[f.target];
|
||||
assert.equal(matchesPlanClaim(claim, { ...plan, kwisatz: !!plan.kwisatz }, player(game, f.target).hand), expected === 'yes');
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
|
||||
void test('native battle expression source stops at its honest action prefix before both original plans', () => {
|
||||
const f = fixture();
|
||||
assert.equal(f.setup.deck.length, 33); assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(candidate => candidate.faction), ['atreides', 'emperor']);
|
||||
assert.equal(f.sourceActionCount, f.base.sourceActionCount - f.base.planSteps.length);
|
||||
assert.equal(f.actions.length, f.sourceActionCount);
|
||||
assert.deepEqual(f.actions, f.base.actions.slice(0, -2));
|
||||
assert.deepEqual(f.game, f.base.beforePlans); assert.ok(clean(f.game));
|
||||
assert.equal(f.game.phase, 6); assert.equal(f.game.turn, 1);
|
||||
assert.equal(f.game.battle!.revealed, false); assert.deepEqual(f.game.battle!.plans, {});
|
||||
assert.deepEqual(player(f.game, f.target).hand.map(card => card.id), [f.ordinary]);
|
||||
assert.equal(f.projectile.kind, 'projectile');
|
||||
assert.equal(player(f.game, f.target).forces['arrakeen:10'], 1);
|
||||
assert.equal(player(f.game, f.target).reserves, 19);
|
||||
assert.deepEqual(f.claim, { kind: 'and', terms: [
|
||||
{ kind: 'dial', compare: 'eq', value: 0 }, { kind: 'weapon', name: null }, { kind: 'defense', name: null },
|
||||
] });
|
||||
const prePromise = act(f.game, f.target, f.projectilePlan);
|
||||
assert.equal(prePromise.battle!.plans[f.target].weapon, f.ordinary, 'The original held projectile is legal before the promise');
|
||||
assert.equal(prePromise.battle!.revealed, false);
|
||||
assert.deepEqual(inventory(f.game), f.originalCards); assert.equal(f.originalCards.length, 33);
|
||||
});
|
||||
|
||||
void test('three through eight condition AND and OR answers bind actual original empty or projectile plans', () => {
|
||||
const f = fixture();
|
||||
for (const kind of ['and', 'or'] as const) for (const count of [3, 4, 5, 6, 7, 8])
|
||||
for (const expected of ['yes', 'no'] as const) {
|
||||
const selected = scenario(f, kind, count, expected), asked = ask(f, selected.claim);
|
||||
assert.equal(asked.actions.length, 3, 'Declaration, the other seat priority pass and whole question');
|
||||
assert.deepEqual(viewGame(asked.game, f.target).truthBattleAnswers, ['yes', 'no']);
|
||||
assert.deepEqual(battleControls(asked.game), battleControls(f.source));
|
||||
reject(asked.game, f.target, { type: 'truthAnswer', answer: 'unknown' }, /legal plans/);
|
||||
const done = answer(asked.game, expected, asked.actions);
|
||||
answered(done, f, selected.claim, expected);
|
||||
reject(done, f.target, selected.contradictingPlan, /honor your Truthtrance/);
|
||||
const sealed = seal(done, f, selected.plan, asked.actions);
|
||||
assert.equal(asked.actions.length, 6);
|
||||
assert.equal(sealed.afterTargetPlan.battle!.revealed, false);
|
||||
actualPlan(sealed.game, f, selected.claim, expected);
|
||||
assert.equal(sealed.game.battle!.plans[f.target].weapon, expected === 'yes' ? null : f.ordinary);
|
||||
}
|
||||
});
|
||||
|
||||
void test('mixed nested AND inside OR and OR inside AND enforce the complete aggregate through sealing', () => {
|
||||
const f = fixture();
|
||||
for (const kind of ['and', 'or'] as const) for (const expected of ['yes', 'no'] as const) {
|
||||
const selected = nestedScenario(f, kind, expected), asked = ask(f, selected.claim);
|
||||
assert.deepEqual(asked.game.truthtrance!.question, {
|
||||
kind: 'battlePlan', target: f.target, territory: f.territory, claim: selected.claim,
|
||||
});
|
||||
const done = answer(asked.game, expected);
|
||||
answered(done, f, selected.claim, expected);
|
||||
reject(done, f.target, selected.contradictingPlan, /honor your Truthtrance/);
|
||||
actualPlan(seal(done, f, selected.plan).game, f, selected.claim, expected);
|
||||
}
|
||||
});
|
||||
|
||||
void test('one definite answer discards once and the original battle completes after the same two seal controls', () => {
|
||||
const f = fixture(), declared = declare(f);
|
||||
assert.deepEqual(declared.game.truthtrance!.queue, [{ player: f.asker, card: f.card }]);
|
||||
assert.deepEqual(declared.game.truthtrance!.passed, [f.asker]);
|
||||
reject(declared.game, f.asker, f.askStep.action, /priority/);
|
||||
let game = passPriority(declared.game, declared.actions);
|
||||
reject(game, f.target, f.askStep.action, /holder/);
|
||||
game = act(game, f.asker, f.askStep.action, declared.actions);
|
||||
reject(game, f.asker, f.answerStep.action, /questioned player/);
|
||||
reject(game, f.target, f.targetPlan);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 0);
|
||||
game = answer(game, 'yes', declared.actions);
|
||||
answered(game, f, f.claim, 'yes');
|
||||
reject(game, f.target, f.projectilePlan, /honor your Truthtrance/);
|
||||
reject(game, f.target, f.answerStep.action);
|
||||
const sealed = seal(game, f, f.targetPlan, declared.actions);
|
||||
game = sealed.game; assert.equal(declared.actions.length, 6);
|
||||
actualPlan(game, f, f.claim, 'yes');
|
||||
for (const actor of [f.target, f.asker]) game = act(game, actor, { type: 'traitorCall', call: false }, declared.actions);
|
||||
assert.equal(game.battle, null); assert.equal(declared.actions.length, 8);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
assert.equal(game.truthHistory!.length, 1);
|
||||
});
|
||||
|
||||
void test('all leaves are server validated beyond the former second condition before consuming the original card', () => {
|
||||
const f = fixture(), source = askSource(f), game = source.game;
|
||||
const invalid: PlanClaim[] = [
|
||||
{ kind: 'and', terms: [f.claim, { kind: 'dial', compare: 'eq', value: -1 }] },
|
||||
{ kind: 'or', terms: [{ kind: 'dial', compare: 'eq', value: 0 }, { kind: 'weapon', name: null },
|
||||
{ kind: 'weapon', name: 'Not an original card' }] },
|
||||
{ kind: 'and', terms: [{ kind: 'dial', compare: 'eq', value: 1 }, { kind: 'weapon', name: null },
|
||||
{ kind: 'or', terms: [{ kind: 'dial', compare: 'eq', value: 0 }, { kind: 'leader', leader: 'not-a-leader' }] }] },
|
||||
];
|
||||
for (const claim of invalid) reject(game, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'battlePlan', target: f.target, territory: f.territory, claim,
|
||||
} }, /valid battle-plan condition/);
|
||||
assert.equal(game.truthtrance!.stage, 'ask'); assert.equal(game.truthtrance!.question, null);
|
||||
assert.deepEqual(battleControls(game), battleControls(f.source));
|
||||
assert.deepEqual(game.discard, f.source.discard); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('existing sixteen-node and four-level boundaries ask, answer and seal the real committed plan', () => {
|
||||
const f = fixture(), { closedTrue } = claims(f);
|
||||
const sixteen: PlanClaim = { kind: 'and', terms: [
|
||||
{ kind: 'and', terms: closedTrue }, { kind: 'and', terms: closedTrue.slice(0, 5) },
|
||||
] };
|
||||
let depthFour: PlanClaim = closedTrue[1];
|
||||
for (let depth = 0; depth < 4; depth++) depthFour = { kind: 'and', terms: [closedTrue[0], depthFour] };
|
||||
assert.deepEqual(battleClaimExpressionSize(sixteen), { nodes: 16, depth: 2 });
|
||||
assert.deepEqual(battleClaimExpressionSize(depthFour), { nodes: 9, depth: 4 });
|
||||
for (const [id, claim] of [['sixteen-nodes', sixteen], ['depth-four', depthFour]] as const) {
|
||||
const selected = { ...scenario(f), id, claim }, done = finish(f, selected);
|
||||
assert.equal(done.actions.length, 6);
|
||||
answered(done.answered, f, claim, 'yes'); actualPlan(done.game, f, claim, 'yes');
|
||||
}
|
||||
});
|
||||
|
||||
void test('a ninth child, seventeenth node, singleton or fifth grouping level rejects before the original card is consumed', () => {
|
||||
const f = fixture(), game = askSource(f).game, { closedTrue } = claims(f);
|
||||
let depthFive: PlanClaim = closedTrue[1];
|
||||
for (let depth = 0; depth < 5; depth++) depthFive = { kind: 'and', terms: [closedTrue[0], depthFive] };
|
||||
const invalid: PlanClaim[] = [
|
||||
{ kind: 'and', terms: [] }, { kind: 'or', terms: [closedTrue[0]] },
|
||||
{ kind: 'or', terms: [...closedTrue, closedTrue[0]] },
|
||||
{ kind: 'and', terms: [{ kind: 'and', terms: closedTrue }, { kind: 'and', terms: closedTrue.slice(0, 6) }] },
|
||||
depthFive,
|
||||
];
|
||||
for (const claim of invalid) reject(game, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'battlePlan', target: f.target, territory: f.territory, claim,
|
||||
} }, /valid battle-plan condition/);
|
||||
assert.equal(game.truthtrance!.stage, 'ask'); assert.equal(game.truthtrance!.question, null);
|
||||
assert.deepEqual(game.discard, f.source.discard);
|
||||
assert.ok(player(game, f.asker).hand.some(card => card.id === f.card));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('all four minimal profiles choose feasible whole answers and seal legal plans for grouped questions', () => {
|
||||
const f = fixture();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const declared = declare(f), priorityView = viewGame(declared.game, f.target);
|
||||
priorityView.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(priorityView), [{ type: 'truthPass' }]);
|
||||
const passed = act(declared.game, f.target, botActions(priorityView)[0]);
|
||||
assert.equal(passed.truthtrance?.stage, 'ask');
|
||||
for (const selected of [scenario(f, 'and', 8), scenario(f, 'or', 8), nestedScenario(f, 'and'), nestedScenario(f, 'or')]) {
|
||||
const asked = ask(f, selected.claim), answerView = viewGame(asked.game, f.target);
|
||||
answerView.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
const before = structuredClone(answerView), response = botActions(answerView)[0];
|
||||
assert.deepEqual(answerView, before); assert.equal(response?.type, 'truthAnswer');
|
||||
assert.ok(answerView.truthBattleAnswers?.includes(response.answer as 'yes' | 'no' | 'unknown'));
|
||||
assert.deepEqual(botActions(viewGame(asked.game, f.asker)), []);
|
||||
let game = act(asked.game, f.target, response);
|
||||
const planView = viewGame(game, f.target); planView.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
const plan = botActions(planView).find(action => action.type === 'battlePlan');
|
||||
assert.ok(plan, `${difficulty} supplies a legal plan after its aggregate answer`);
|
||||
game = act(game, f.target, plan);
|
||||
const sealed = game.battle!.plans[f.target];
|
||||
assert.equal(matchesPlanClaim(selected.claim, { ...sealed, kwisatz: !!sealed.kwisatz }, player(game, f.target).hand), response.answer === 'yes');
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
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 { ShipmentClauseFields } from '../components/shipment-claim-fields';
|
||||
import { ShipmentExpressionFields } from '../components/shipment-expression-fields';
|
||||
import { TruthShipmentQuestion } from '../components/truth-shipment-question';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { parseShipmentExpression, type ShipmentExpression } from '../game/shipment-promises';
|
||||
import { shipmentExpressionInputError, shipmentExpressionInputSize } from '../game/shipment-expression-input';
|
||||
import type { TruthQuestion } from '../game/truthtrance';
|
||||
|
||||
// Entitled controlled projections supplement the separate original-card source.
|
||||
// React renders descendants; direct calls reach only hook-free controlled APIs.
|
||||
function projected(): GameView {
|
||||
const source = createGame('SHIPMENTEXPRESSIONCONTROLS', newPlayer('a', 'Asker', 'atreides'), false);
|
||||
joinGame(source, newPlayer('t', 'Target', 'emperor'));
|
||||
const game = viewGame(source, 'a');
|
||||
game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'projected-truth-card' }], passed: [], question: null };
|
||||
game.truthShipmentTargets = ['t'];
|
||||
return game;
|
||||
}
|
||||
const leaf = (territory = 'arrakeen', minimum = 1): ShipmentExpression => ({ territory, minimum });
|
||||
const question = (claim: ShipmentExpression, target = 't'): Extract<TruthQuestion, { kind: 'shipment' }> => ({ kind: 'shipment', target, claim });
|
||||
type ElementProps = { children?: ReactNode; [key: string]: unknown };
|
||||
type Element = ReactElement<ElementProps>;
|
||||
function elements(node: ReactNode): Element[] {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!isValidElement<ElementProps>(node)) return [];
|
||||
return [node, ...elements(node.props.children)];
|
||||
}
|
||||
function control(tree: ReactNode, key: string): Element {
|
||||
const found = elements(tree).find(element => element.props['data-shipment-expression-control'] === key);
|
||||
assert.ok(found, `Shipment control ${key} is rendered`); return found;
|
||||
}
|
||||
const click = (button: Element) => (button.props.onClick as () => void)();
|
||||
function nodeAt(tree: ReactNode, path: string): Element {
|
||||
const found = elements(tree).find(element => element.props['data-shipment-expression-path'] === path);
|
||||
assert.ok(found, `Shipment condition or group ${path} is rendered`); return found;
|
||||
}
|
||||
function editLeaf(tree: ReactNode, path: string, next: ShipmentExpression) {
|
||||
const field = elements(nodeAt(tree, path)).find(element => element.type === ShipmentClauseFields);
|
||||
assert.ok(field); (field.props.onChange as (value: ShipmentExpression) => void)(next);
|
||||
}
|
||||
function switchGroup(tree: ReactNode, path: string, op: string) {
|
||||
const select = elements(nodeAt(tree, path)).find(element => element.type === 'select');
|
||||
assert.ok(select);
|
||||
(select.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: op } });
|
||||
}
|
||||
function editor(initial: ShipmentExpression, disabled = false, id = 'custom-shipment') {
|
||||
let value = initial, changes = 0;
|
||||
return {
|
||||
get value() { return value; }, get changes() { return changes; },
|
||||
tree: () => ShipmentExpressionFields({ value, id, disabled, onChange(next) { value = next; changes++; } }),
|
||||
html: () => renderToStaticMarkup(createElement(ShipmentExpressionFields, { value, id, disabled, onChange() {} })),
|
||||
};
|
||||
}
|
||||
function askTree(game: GameView, value: Extract<TruthQuestion, { kind: 'shipment' }>, busy = false) {
|
||||
const actions: Action[] = [], changes: ShipmentExpression[] = [];
|
||||
const props = { game, question: value, busy, act(action: Action) { actions.push(action); }, onChange(next: ShipmentExpression) { changes.push(next); } };
|
||||
const tree = TruthShipmentQuestion(props);
|
||||
const button = elements(tree).find(element => element.props['data-testid'] === 'truth-shipment-ask');
|
||||
assert.ok(button);
|
||||
return { tree, button, actions, changes, html: () => renderToStaticMarkup(createElement(TruthShipmentQuestion, props)) };
|
||||
}
|
||||
function groupsDeep(depth: number): ShipmentExpression {
|
||||
let expression = leaf();
|
||||
for (let i = 0; i < depth; i++) expression = { op: 'and', terms: [expression, leaf('carthag')] };
|
||||
return expression;
|
||||
}
|
||||
|
||||
void test('actual add and leaf callbacks author three through sixteen shipment conditions without truncation', () => {
|
||||
const initial = leaf('polar_sink', 3), e = editor(initial);
|
||||
click(control(e.tree(), 'or-1'));
|
||||
for (let count = 3; count <= 16; count++) {
|
||||
const previous = structuredClone(e.value); assert.ok('op' in previous);
|
||||
click(control(e.tree(), 'add-1'));
|
||||
editLeaf(e.tree(), `1.${count}`, leaf(TERRITORIES[count - 1].id, count));
|
||||
assert.ok('op' in e.value); assert.equal(e.value.terms.length, count);
|
||||
assert.deepEqual(e.value.terms.slice(0, count - 1), previous.terms);
|
||||
}
|
||||
assert.ok('op' in e.value); assert.deepEqual(e.value.terms[0], initial);
|
||||
assert.deepEqual(shipmentExpressionInputSize(e.value), { leaves: 16, depth: 1 });
|
||||
assert.deepEqual(parseShipmentExpression(e.value), e.value);
|
||||
const html = e.html();
|
||||
assert.equal((html.match(/This-turn shipment destination/g) ?? []).length, 16);
|
||||
assert.match(html, /16 \/ 16 shipment conditions/);
|
||||
assert.match(html, /id="custom-shipment-1\.16-minimum"/);
|
||||
assert.match(html, /for="custom-shipment-1\.16-territory"/);
|
||||
});
|
||||
|
||||
void test('nested AND and OR groups preserve every branch and allow independent group meaning changes', () => {
|
||||
const e = editor(leaf());
|
||||
click(control(e.tree(), 'and-1')); editLeaf(e.tree(), '1.2', leaf('carthag', 3));
|
||||
click(control(e.tree(), 'or-1.2')); editLeaf(e.tree(), '1.2.2', leaf('sietch_tabr', 4));
|
||||
click(control(e.tree(), 'add-1.2')); editLeaf(e.tree(), '1.2.3', leaf('polar_sink', 5));
|
||||
const original = structuredClone(e.value);
|
||||
switchGroup(e.tree(), '1.2', 'and');
|
||||
assert.deepEqual(e.value, { op: 'and', terms: [leaf(), { op: 'and', terms: [leaf('carthag', 3), leaf('sietch_tabr', 4), leaf('polar_sink', 5)] }] });
|
||||
switchGroup(e.tree(), '1.2', 'or'); assert.deepEqual(e.value, original);
|
||||
const changes = e.changes; switchGroup(e.tree(), '1.2', 'single'); assert.equal(e.changes, changes);
|
||||
click(control(e.tree(), 'nested-and-1'));
|
||||
click(control(e.tree(), 'nested-or-1'));
|
||||
assert.ok('op' in e.value);
|
||||
assert.deepEqual(e.value.terms.slice(0, 2), 'op' in original ? original.terms : []);
|
||||
assert.deepEqual(e.value.terms[2], { op: 'and', terms: [leaf('carthag'), leaf('carthag')] });
|
||||
assert.deepEqual(e.value.terms[3], { op: 'or', terms: [leaf('carthag'), leaf('carthag')] });
|
||||
assert.match(e.html(), /Shipment group 1\.2 · OR/);
|
||||
});
|
||||
|
||||
void test('remove collapses singleton groups and retains the complete remaining expression', () => {
|
||||
const retained = leaf('polar_sink', 5);
|
||||
const e = editor({ op: 'or', terms: [leaf(), { op: 'and', terms: [leaf('carthag'), retained] }] });
|
||||
click(control(e.tree(), 'remove-1.2.1'));
|
||||
assert.deepEqual(e.value, { op: 'or', terms: [leaf(), retained] });
|
||||
click(control(e.tree(), 'remove-1.1')); assert.deepEqual(e.value, retained);
|
||||
const survivor: ShipmentExpression = { op: 'or', terms: [leaf(), leaf('carthag'), leaf('sietch_tabr')] };
|
||||
const grouped = editor({ op: 'and', terms: [{ op: 'and', terms: [leaf(), leaf()] }, survivor] });
|
||||
click(control(grouped.tree(), 'remove-1.1')); assert.deepEqual(grouped.value, survivor);
|
||||
});
|
||||
|
||||
void test('sixteen leaves count independently of groups and guard all add and wrap callbacks', () => {
|
||||
const full: ShipmentExpression = { op: 'and', terms: [
|
||||
{ op: 'or', terms: Array.from({ length: 8 }, () => leaf()) },
|
||||
{ op: 'and', terms: Array.from({ length: 8 }, () => leaf('carthag')) },
|
||||
] };
|
||||
const e = editor(full), before = structuredClone(full);
|
||||
assert.deepEqual(shipmentExpressionInputSize(full), { leaves: 16, depth: 2 });
|
||||
assert.equal(shipmentExpressionInputError(full), null);
|
||||
for (const key of ['add-1', 'nested-and-1', 'nested-or-1', 'add-1.2', 'and-1.2.1', 'or-1']) {
|
||||
assert.equal(control(e.tree(), key).props.disabled, true, key); click(control(e.tree(), key));
|
||||
}
|
||||
assert.equal(e.changes, 0); assert.deepEqual(e.value, before);
|
||||
editLeaf(e.tree(), '1.2.8', leaf('polar_sink', 20)); assert.equal(e.changes, 1);
|
||||
assert.equal((e.html().match(/This-turn shipment destination/g) ?? []).length, 16);
|
||||
});
|
||||
|
||||
void test('depth-four allows deepest leaf edits and shallow nesting while directly invoked fifth-depth controls no-op', () => {
|
||||
const e = editor(groupsDeep(4));
|
||||
assert.deepEqual(shipmentExpressionInputSize(e.value), { leaves: 5, depth: 4 });
|
||||
for (const key of ['and-1', 'or-1.1.1.1.1', 'nested-and-1.1.1.1', 'nested-or-1.1.1.1']) {
|
||||
assert.equal(control(e.tree(), key).props.disabled, true, key); click(control(e.tree(), key));
|
||||
}
|
||||
assert.equal(e.changes, 0);
|
||||
editLeaf(e.tree(), '1.1.1.1.1', leaf('arrakeen', 20));
|
||||
click(control(e.tree(), 'add-1.1.1.1')); click(control(e.tree(), 'or-1.2'));
|
||||
assert.deepEqual(shipmentExpressionInputSize(e.value), { leaves: 7, depth: 4 });
|
||||
assert.equal(shipmentExpressionInputError(e.value), null);
|
||||
});
|
||||
|
||||
void test('invalid numeric leaves remain editable and structurally addable while full parser validation blocks Ask', () => {
|
||||
for (const minimum of [NaN, -1, 0, 0.5, 21, Infinity]) {
|
||||
const e = editor({ op: 'or', terms: [leaf(), leaf('carthag'), leaf('arrakeen', minimum)] });
|
||||
assert.equal(shipmentExpressionInputError(e.value), null);
|
||||
const ask = askTree(projected(), question(e.value));
|
||||
assert.equal(ask.button.props.disabled, true); click(ask.button); assert.deepEqual(ask.actions, []);
|
||||
assert.match(ask.html(), /Choose a minimum of one to twenty physical forces/);
|
||||
click(control(e.tree(), 'add-1')); assert.ok('op' in e.value); assert.equal(e.value.terms.length, 4);
|
||||
editLeaf(e.tree(), '1.3', leaf('arrakeen', 2));
|
||||
assert.deepEqual(parseShipmentExpression(e.value), e.value);
|
||||
}
|
||||
});
|
||||
|
||||
void test('busy disables the fieldset and guards actual add, wrap, remove, switch and leaf callbacks', () => {
|
||||
const initial: ShipmentExpression = { op: 'and', terms: [leaf(), leaf('carthag')] }, e = editor(initial, true);
|
||||
assert.match(e.html(), /<fieldset\b[^>]*disabled=""[^>]*data-testid="shipment-expression"/);
|
||||
for (const key of ['add-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'or-1.1', 'remove-1.2']) {
|
||||
assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
|
||||
}
|
||||
switchGroup(e.tree(), '1', 'or'); editLeaf(e.tree(), '1.2', leaf('polar_sink', 7));
|
||||
assert.equal(e.changes, 0); assert.deepEqual(e.value, initial);
|
||||
});
|
||||
|
||||
void test('Ask forwards the exact full three-, eight-, sixteen-leaf and nested question without adding fields', () => {
|
||||
const expressions: ShipmentExpression[] = [
|
||||
...[3, 8, 16].map(count => ({ op: 'or' as const, terms: TERRITORIES.slice(0, count).map(place => leaf(place.id)) })),
|
||||
{ op: 'and', terms: [leaf(), { op: 'or', terms: [leaf('carthag'), leaf('polar_sink'), leaf('sietch_tabr')] }] }, groupsDeep(4),
|
||||
];
|
||||
for (const expression of expressions) {
|
||||
const value = question(expression), before = structuredClone(value), ask = askTree(projected(), value);
|
||||
assert.equal(ask.button.props.disabled, false); click(ask.button);
|
||||
assert.deepEqual(ask.actions, [{ type: 'truthAsk', question: value }]);
|
||||
assert.equal((ask.actions[0] as { question: unknown }).question, value);
|
||||
assert.deepEqual(value, before); assert.match(ask.html(), /data-testid="truth-shipment-preview"/);
|
||||
}
|
||||
const legacy: Extract<TruthQuestion, { kind: 'shipment' }> = { kind: 'shipment', target: 't', territory: 'arrakeen', minimum: 3 };
|
||||
const ask = askTree(projected(), legacy); click(ask.button);
|
||||
assert.deepEqual(ask.actions, [{ type: 'truthAsk', question: legacy }]);
|
||||
});
|
||||
|
||||
void test('constants, malformed late leaves, excessive width or depth and singleton groups block the whole Ask', () => {
|
||||
const invalid: ShipmentExpression[] = [
|
||||
{ op: 'or', terms: [leaf(), leaf('carthag'), { constant: 'yes' }] },
|
||||
{ op: 'or', terms: [leaf(), { op: 'and', terms: [leaf(), { constant: 'no' }] }] },
|
||||
{ op: 'or', terms: [leaf(), leaf('carthag'), leaf('not-a-territory')] },
|
||||
{ op: 'or', terms: Array.from({ length: 17 }, () => leaf()) },
|
||||
{ op: 'and', terms: [leaf()] }, groupsDeep(5),
|
||||
];
|
||||
for (const expression of invalid) {
|
||||
const ask = askTree(projected(), question(expression));
|
||||
assert.equal(ask.button.props.disabled, true); click(ask.button); assert.deepEqual(ask.actions, []);
|
||||
assert.doesNotThrow(ask.html); assert.match(ask.html(), /role="alert"/);
|
||||
}
|
||||
for (const constant of ['yes', 'no', 'unknown'] as const) {
|
||||
const expression: ShipmentExpression = { op: 'or', terms: [leaf(), { constant }] };
|
||||
assert.match(shipmentExpressionInputError(expression)!, /reserved for internal/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('busy, actual queue owner, target seating and authoritative offered targets guard Ask and editor callbacks', () => {
|
||||
const cases: { game: GameView; value?: Extract<TruthQuestion, { kind: 'shipment' }>; busy?: boolean }[] = [{ game: projected(), busy: true }];
|
||||
for (const stage of ['priority', 'answer', 'unknown'] as const) {
|
||||
const game = projected(); game.truthtrance!.stage = stage; cases.push({ game });
|
||||
}
|
||||
for (const owner of ['t', 'not-seated']) {
|
||||
const game = projected(); game.truthtrance!.queue[0].player = owner; cases.push({ game });
|
||||
}
|
||||
const unseated = projected(); unseated.me = 'not-seated'; unseated.truthtrance!.queue[0].player = 'not-seated'; cases.push({ game: unseated });
|
||||
const empty = projected(); empty.truthtrance!.queue = []; cases.push({ game: empty });
|
||||
const absent = projected(); absent.truthtrance = null; cases.push({ game: absent });
|
||||
const unsupported = projected(); unsupported.truthShipmentTargets = []; cases.push({ game: unsupported });
|
||||
for (const target of ['a', 'not-seated']) cases.push({ game: projected(), value: question(leaf(), target) });
|
||||
for (const entry of cases) {
|
||||
const ask = askTree(entry.game, entry.value ?? question(leaf()), entry.busy);
|
||||
assert.equal(ask.button.props.disabled, true); click(ask.button);
|
||||
const fields = elements(ask.tree).find(element => element.type === ShipmentExpressionFields);
|
||||
if (fields) {
|
||||
assert.equal(fields.props.disabled, true);
|
||||
(fields.props.onChange as (next: ShipmentExpression) => void)(leaf('polar_sink'));
|
||||
}
|
||||
assert.deepEqual(ask.actions, []); assert.deepEqual(ask.changes, []); assert.doesNotThrow(ask.html);
|
||||
}
|
||||
const game = projected(), stale = askTree(game, question(leaf()));
|
||||
const fields = elements(stale.tree).find(element => element.type === ShipmentExpressionFields); assert.ok(fields);
|
||||
for (const mutate of [
|
||||
() => { game.truthtrance!.stage = 'answer'; },
|
||||
() => { game.truthtrance!.stage = 'ask'; game.truthtrance!.queue[0].player = 't'; },
|
||||
() => { game.truthtrance!.queue[0].player = 'a'; game.truthShipmentTargets = []; },
|
||||
() => { game.truthShipmentTargets = ['t']; game.players = game.players.filter(player => player.id !== 't'); },
|
||||
]) {
|
||||
mutate(); click(stale.button); (fields.props.onChange as (next: ShipmentExpression) => void)(leaf('polar_sink'));
|
||||
}
|
||||
assert.deepEqual(stale.actions, []); assert.deepEqual(stale.changes, []);
|
||||
});
|
||||
|
||||
void test('guidance describes the whole eventual shipment, aggregate No and skip without new consent or private feasibility reads', () => {
|
||||
const game = projected();
|
||||
for (const player of game.players) for (const field of ['hand', 'traitors', 'spice', 'reserves', 'forces', 'leaders'])
|
||||
Object.defineProperty(player, field, { get() { throw Error(`Shipment authoring read private ${field}`); } });
|
||||
for (const field of ['truthShipmentAnswers', 'shipmentCompletion', 'truthAnswer', 'truthKnowledge'])
|
||||
Object.defineProperty(game, field, { get() { throw Error(`Shipment authoring read private ${field}`); } });
|
||||
const ask = askTree(game, question({ op: 'or', terms: [leaf(), leaf('carthag'), leaf('sietch_tabr')] }));
|
||||
const html = ask.html();
|
||||
assert.match(html, /same eventual shipment from reserves this turn/);
|
||||
assert.match(html, /No requires the whole expression to be false/);
|
||||
assert.match(html, /Skipping shipment makes every condition false/);
|
||||
assert.match(html, /Groups do not count as conditions/); assert.match(html, /not additional printed rules/);
|
||||
assert.match(html, /Arrakeen/); assert.match(html, / OR /);
|
||||
assert.doesNotMatch(html, /Confirm question|Acknowledge|Current fact type/);
|
||||
click(ask.button); assert.equal(ask.actions.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { matchesShipment, type ShipmentExpression } from '../game/shipment-promises';
|
||||
import { shipmentExpressionInputSize } from '../game/shipment-expression-input';
|
||||
import {
|
||||
createTruthShipmentExpressionFixture as fixture,
|
||||
actTruthShipmentExpression as act,
|
||||
truthShipmentExpressionClean as clean,
|
||||
truthShipmentExpressionPlayer as player,
|
||||
truthShipmentExpressionInventory as inventory,
|
||||
truthShipmentExpressionScenario as scenario,
|
||||
truthShipmentExpressionNestedScenario as nestedScenario,
|
||||
truthShipmentExpressionDepthScenario as depthScenario,
|
||||
declareTruthShipmentExpression as declare,
|
||||
passTruthShipmentExpressionPriority as passPriority,
|
||||
askTruthShipmentExpression as ask,
|
||||
answerTruthShipmentExpression as answer,
|
||||
settleTruthShipmentExpression as settle,
|
||||
finishTruthShipmentExpression as finish,
|
||||
type TruthShipmentExpressionFixture,
|
||||
} from './fixture-truth-shipment-expression';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action, message?: RegExp) {
|
||||
const before = structuredClone(game);
|
||||
const run = () => applyAction(game, actor, action);
|
||||
if (message) assert.throws(run, message); else assert.throws(run);
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
/** Only the native interrupted movement controls, not a recovery campaign. */
|
||||
function movementControls(game: Game) {
|
||||
return { turn: game.turn, phase: game.phase, active: game.active, order: game.order,
|
||||
movementRemaining: game.movementRemaining, storm: game.storm,
|
||||
response: game.response ?? null, decision: game.decision ?? null,
|
||||
phaseOpening: game.phaseOpening ?? null, pendingShipment: game.pendingShipment ?? null };
|
||||
}
|
||||
function bound(game: Game, f: TruthShipmentExpressionFixture, expression: ShipmentExpression, expected: 'yes' | 'no') {
|
||||
assert.ok(clean(game)); assert.equal(game.truthtrance, null);
|
||||
assert.deepEqual(movementControls(game), movementControls(f.source));
|
||||
assert.equal(player(game, f.target).shipped, false); assert.equal(player(game, f.target).reserves, 20);
|
||||
assert.deepEqual(player(game, f.target).hand, player(f.source, f.target).hand);
|
||||
assert.equal(player(game, f.target).spice, player(f.source, f.target).spice);
|
||||
assert.deepEqual(game.shipmentPromises, [{ player: f.target, asker: f.asker,
|
||||
turn: f.source.turn, claim: expression, answer: expected === 'yes' }]);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(game.discard.length, f.source.discard.length + 1);
|
||||
assert.equal(player(game, f.asker).hand.some(card => card.id === f.card), false);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
assert.deepEqual(game.truthHistory!.at(-1), { turn: f.source.turn, phase: 5, asker: f.asker,
|
||||
question: { kind: 'shipment', target: f.target, claim: expression }, answer: expected });
|
||||
}
|
||||
function delivered(game: Game, f: TruthShipmentExpressionFixture) {
|
||||
assert.ok(clean(game)); assert.equal(game.pendingShipment ?? null, null);
|
||||
assert.equal(game.phase, 5); assert.equal(game.active, f.target);
|
||||
assert.equal(player(game, f.target).shipped, true);
|
||||
assert.equal(player(game, f.target).forces[f.location], 1);
|
||||
assert.equal(player(game, f.target).reserves, 19);
|
||||
assert.equal(player(f.source, f.target).spice - player(game, f.target).spice, 1);
|
||||
assert.equal(game.shipmentPromises!.length, 1);
|
||||
assert.equal(game.shipmentPromises![0].fulfilled, true);
|
||||
assert.equal(game.shipmentPromises![0].released, undefined);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
|
||||
void test('pure shipment source uses only the original public Basic prefix before Emperor’s unused active turn', () => {
|
||||
const f = fixture();
|
||||
assert.equal(f.game.advanced, false); assert.deepEqual(f.game.expansions, []);
|
||||
assert.equal(f.setup.deck.length, 33); assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(candidate => candidate.faction), ['atreides', 'emperor']);
|
||||
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.target);
|
||||
assert.equal(player(f.game, f.target).reserves, 20); assert.deepEqual(player(f.game, f.target).forces, {});
|
||||
assert.equal(player(f.game, f.target).shipped, false);
|
||||
assert.ok(player(f.game, f.asker).hand.some(card => card.id === f.card));
|
||||
assert.ok(player(f.game, f.target).hand.some(card => card.id === f.ordinary));
|
||||
assert.deepEqual(f.actions, f.base.actions.slice(0, f.sourceCounts.shipment));
|
||||
assert.deepEqual(f.base.actions[f.sourceActionCount], f.shipmentStep);
|
||||
assert.ok(f.sourceActionCount < f.base.sourceActionCount);
|
||||
assert.equal(f.actions.some(step => step.action.type === 'chooseBattle' || step.action.type === 'battlePlan'), false);
|
||||
assert.equal(f.game.truthHistory?.length ?? 0, 0); assert.equal(f.game.shipmentPromises?.length ?? 0, 0);
|
||||
assert.deepEqual(inventory(f.game), f.originalCards); assert.equal(f.originalCards.length, 33);
|
||||
});
|
||||
|
||||
void test('native three through eight leaf AND and OR promises retain and fulfill both whole-expression Yes and No', () => {
|
||||
const f = fixture();
|
||||
for (const op of ['and', 'or'] as const) for (const count of [3, 4, 5, 6, 7, 8])
|
||||
for (const expected of ['yes', 'no'] as const) {
|
||||
const selected = scenario(op, count, expected), done = finish(f, selected);
|
||||
assert.equal(done.question.action.type, 'truthAsk');
|
||||
assert.deepEqual(done.beforeQuestion.truthtrance!.question, null);
|
||||
assert.deepEqual(viewGame(done.asked, f.target).truthShipmentAnswers, ['yes', 'no']);
|
||||
assert.equal(viewGame(done.asked, f.asker).truthShipmentAnswers, null);
|
||||
bound(done.bound, f, selected.expression, expected); delivered(done.game, f);
|
||||
assert.equal(matchesShipment({ claim: selected.expression }, { territory: 'arrakeen', amount: 1 }), expected === 'yes');
|
||||
assert.equal(done.actions.length, 5, 'Original declaration, one priority pass, full question, answer and delivery');
|
||||
if (op === 'and' && expected === 'no') {
|
||||
assert.ok('op' in selected.expression);
|
||||
assert.equal(matchesShipment({ claim: selected.expression.terms[0] }, { territory: 'arrakeen', amount: 1 }), true,
|
||||
'No makes the whole expression false while earlier conditions may remain true');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('native nested AND within OR and OR within AND keep their complete statement through actual delivery', () => {
|
||||
const f = fixture();
|
||||
for (const op of ['and', 'or'] as const) for (const expected of ['yes', 'no'] as const) {
|
||||
const selected = nestedScenario(op, expected), done = finish(f, selected);
|
||||
assert.deepEqual(done.asked.truthtrance!.question, { kind: 'shipment', target: f.target, claim: selected.expression });
|
||||
bound(done.bound, f, selected.expression, expected); delivered(done.game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a sixteen-leaf distinct-destination OR and four group levels use the full existing pure-shipment bounds', () => {
|
||||
const f = fixture(), sixteen = scenario('or', 16, 'yes');
|
||||
assert.ok('op' in sixteen.expression);
|
||||
assert.equal(new Set(sixteen.expression.terms.map(term => 'territory' in term ? term.territory : '')).size, 16);
|
||||
assert.ok(sixteen.expression.terms.every(term => 'minimum' in term && term.minimum === 1));
|
||||
assert.deepEqual(sixteen.expression.terms[15], { territory: 'arrakeen', minimum: 1 });
|
||||
assert.deepEqual(shipmentExpressionInputSize(sixteen.expression), { leaves: 16, depth: 1 });
|
||||
for (const selected of [sixteen, depthScenario(4)]) {
|
||||
const done = finish(f, selected);
|
||||
bound(done.bound, f, selected.expression, 'yes'); delivered(done.game, f);
|
||||
const continued = act(done.game, f.continuationStep.actor, f.continuationStep.action, done.actions);
|
||||
assert.equal(done.actions.length, 6);
|
||||
assert.equal(continued.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(continued.shipmentPromises![0].fulfilled, true);
|
||||
assert.deepEqual(inventory(continued), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('original priority, target-only answer and one discard resume the same actor’s paid shipment and endMovement', () => {
|
||||
const f = fixture(), declared = declare(f);
|
||||
assert.deepEqual(declared.game.truthtrance!.queue, [{ player: f.asker, card: f.card }]);
|
||||
assert.deepEqual(declared.game.truthtrance!.passed, [f.asker]);
|
||||
reject(declared.game, f.asker, f.askStep.action, /priority/);
|
||||
let game = passPriority(declared.game, declared.actions);
|
||||
reject(game, f.target, f.askStep.action, /holder/);
|
||||
game = act(game, f.asker, f.askStep.action, declared.actions);
|
||||
reject(game, f.asker, f.answerStep.action, /questioned player/);
|
||||
reject(game, f.target, { type: 'truthAnswer', answer: 'unknown' });
|
||||
reject(game, f.target, f.shipmentStep.action);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 0);
|
||||
game = answer(game, 'yes', declared.actions);
|
||||
bound(game, f, f.expression, 'yes');
|
||||
reject(game, f.target, { type: 'endMovement' });
|
||||
reject(game, f.target, { ...f.shipmentStep.action, territory: 'carthag', sector: 11 });
|
||||
game = act(game, f.shipmentStep.actor, f.shipmentStep.action, declared.actions);
|
||||
game = settle(game, declared.actions); delivered(game, f);
|
||||
assert.equal(game.active, f.continuationStep.actor);
|
||||
reject(game, f.target, f.answerStep.action);
|
||||
const continued = act(game, f.continuationStep.actor, f.continuationStep.action, declared.actions);
|
||||
assert.equal(declared.actions.length, 6);
|
||||
assert.equal(continued.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(inventory(continued), f.originalCards);
|
||||
});
|
||||
|
||||
void test('OR No rejects its matching original delivery and can explicitly skip the shipment instead', () => {
|
||||
const f = fixture(), selected = scenario('or', 16, 'yes'), asked = ask(f, selected.expression);
|
||||
let game = answer(asked.game, 'no');
|
||||
bound(game, f, selected.expression, 'no');
|
||||
reject(game, f.target, f.shipmentStep.action);
|
||||
game = act(game, f.continuationStep.actor, f.continuationStep.action);
|
||||
assert.equal(player(game, f.target).reserves, 20); assert.deepEqual(player(game, f.target).forces, {});
|
||||
assert.equal(game.shipmentPromises![0].fulfilled, true);
|
||||
assert.equal(game.shipmentPromises![0].released, undefined);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('all leaves validate before Ask even under decisive OR; constants, seventeenth leaf and fifth depth never consume the card', () => {
|
||||
const f = fixture(), game = passPriority(declare(f).game);
|
||||
const a: ShipmentExpression = { territory: 'arrakeen', minimum: 1 };
|
||||
const invalid: ShipmentExpression[] = [
|
||||
{ op: 'or', terms: [a, a, { territory: 'arrakeen', minimum: 0 }] },
|
||||
{ op: 'or', terms: [a, a, { territory: 'not-a-territory', minimum: 1 }] },
|
||||
...(['yes', 'no', 'unknown'] as const).map(constant => ({ op: 'or' as const, terms: [a, a, { constant }] })),
|
||||
{ op: 'or', terms: [a, { op: 'and', terms: [a, { constant: 'yes' }] }] },
|
||||
{ op: 'and', terms: [a] }, { op: 'or', terms: [] },
|
||||
{ op: 'or', terms: Array.from({ length: 17 }, () => a) },
|
||||
depthScenario(5).expression,
|
||||
];
|
||||
for (const expression of invalid)
|
||||
reject(game, f.asker, { type: 'truthAsk', question: { kind: 'shipment', target: f.target, claim: expression } });
|
||||
assert.equal(game.truthtrance!.stage, 'ask'); assert.equal(game.truthtrance!.question, null);
|
||||
assert.deepEqual(game.discard, f.source.discard);
|
||||
assert.ok(player(game, f.asker).hand.some(card => card.id === f.card));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('a genuine incompatible-destination AND offers only No and honors it with a matching individual branch', () => {
|
||||
const f = fixture(), expression: ShipmentExpression = { op: 'and', terms: [
|
||||
{ territory: 'arrakeen', minimum: 1 }, { territory: 'carthag', minimum: 1 }, { territory: 'sietch_tabr', minimum: 1 },
|
||||
] };
|
||||
const asked = ask(f, expression);
|
||||
assert.deepEqual(viewGame(asked.game, f.target).truthShipmentAnswers, ['no']);
|
||||
reject(asked.game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const done = finish(f, { id: 'incompatible-and-no', expression, answer: 'no' });
|
||||
bound(done.bound, f, expression, 'no'); delivered(done.game, f);
|
||||
});
|
||||
|
||||
void test('all four minimal profiles pass native priority and legally answer and complete full pure shipment expressions', () => {
|
||||
const f = fixture();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const declared = declare(f), priority = viewGame(declared.game, f.target);
|
||||
priority.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(priority), [{ type: 'truthPass' }]);
|
||||
const passed = act(declared.game, f.target, botActions(priority)[0]);
|
||||
assert.equal(passPriority(passed).truthtrance!.stage, 'ask');
|
||||
for (const selected of [scenario('and', 8, 'yes'), scenario('or', 8, 'no'),
|
||||
nestedScenario('and', 'no'), nestedScenario('or', 'yes'), scenario('or', 16, 'yes')]) {
|
||||
const asked = ask(f, selected.expression), view = viewGame(asked.game, f.target);
|
||||
view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
const before = structuredClone(view), actions = botActions(view);
|
||||
assert.deepEqual(view, before); assert.equal(actions.length, 1);
|
||||
assert.equal(actions[0].type, 'truthAnswer');
|
||||
const selectedAnswer = actions[0].answer;
|
||||
assert.ok(selectedAnswer === 'yes' || selectedAnswer === 'no');
|
||||
assert.ok(view.truthShipmentAnswers!.includes(selectedAnswer));
|
||||
assert.deepEqual(botActions(viewGame(asked.game, f.asker)), []);
|
||||
let game = act(asked.game, f.target, actions[0]);
|
||||
bound(game, f, selected.expression, selectedAnswer);
|
||||
const completion = viewGame(game, f.target).shipmentCompletion?.actions;
|
||||
assert.ok(completion?.length, 'The authoritative existing completion supplies a legal next step');
|
||||
game = act(game, f.target, completion[0]); game = settle(game);
|
||||
assert.equal(game.shipmentPromises![0].fulfilled, true);
|
||||
assert.equal(game.shipmentPromises![0].released, undefined);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user