739 lines
21 KiB
TypeScript
739 lines
21 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
currentHealthPercentBandPredicate,
|
|
eventFieldComparisonPredicate,
|
|
parseConditionalMechanicDraft,
|
|
readCurrentHealthPercentBand,
|
|
readEventFieldComparison,
|
|
registeredEventFields,
|
|
readStatePoolThreshold,
|
|
statePoolThresholdPredicate,
|
|
type ConditionalMechanicDraftV1,
|
|
} from './mechanic-editor.js';
|
|
|
|
const draft = (): ConditionalMechanicDraftV1 => ({
|
|
version: 1,
|
|
simulationReadiness: 'ready',
|
|
scenarioInputs: [],
|
|
pools: [
|
|
{
|
|
key: 'effect',
|
|
label: 'Effect',
|
|
stateKind: 'stack',
|
|
scope: 'agent',
|
|
initial: 0,
|
|
min: 0,
|
|
max: 10,
|
|
bestCaseValue: 10,
|
|
expiresAfterSeconds: 10,
|
|
},
|
|
],
|
|
clauses: [
|
|
{
|
|
key: 'gain-effect',
|
|
label: 'Gain Effect',
|
|
event: 'weapon_hit',
|
|
recipeKey: 'same_target_hits_within_window',
|
|
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
|
reducer: {
|
|
kind: 'sliding_count',
|
|
threshold: 9,
|
|
windowSeconds: 3,
|
|
groupBy: { source: 'event', path: 'targetId' },
|
|
},
|
|
cooldownSeconds: 2,
|
|
weight: 1,
|
|
mutations: [{ kind: 'add', pool: 'effect', value: 1 }],
|
|
outcomes: [
|
|
{
|
|
key: 'weapon-damage',
|
|
label: 'Weapon Damage',
|
|
phase: 'while_active',
|
|
kind: 'attribute_modifier',
|
|
scope: 'equipped_weapon',
|
|
basis: 'flat',
|
|
attributeId: 'weapon-damage',
|
|
statePool: 'effect',
|
|
perStack: true,
|
|
descriptionSlot: 1,
|
|
variants: {
|
|
I: { value: 3 },
|
|
II: { value: 4.2 },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
describe('conditional mechanic authoring payload', () => {
|
|
it('resolves and validates variant-specific pool expiration durations', () => {
|
|
const value = draft();
|
|
value.pools[0] = {
|
|
...value.pools[0],
|
|
expiresAfterSeconds: undefined,
|
|
expiresAfterSecondsVariants: { I: 12, II: 18 },
|
|
};
|
|
const result = parseConditionalMechanicDraft(value, {
|
|
variantKeys: ['I', 'II'],
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.value.programs.I.pools?.[0].expiresAfterSeconds).toBe(12);
|
|
expect(result.value.programs.II.pools?.[0].expiresAfterSeconds).toBe(18);
|
|
|
|
value.pools[0].expiresAfterSecondsVariants = { I: 12 };
|
|
const invalid = parseConditionalMechanicDraft(value, {
|
|
variantKeys: ['I', 'II'],
|
|
});
|
|
expect(invalid.ok).toBe(false);
|
|
if (!invalid.ok) {
|
|
expect(invalid.errors).toContain(
|
|
'State pool 1 variant expiration durations must define one positive number for each variant.',
|
|
);
|
|
}
|
|
});
|
|
|
|
it('round-trips strict manual current Health percentage band editor state', () => {
|
|
const predicate = currentHealthPercentBandPredicate({
|
|
minimum: 25,
|
|
maximum: 50,
|
|
});
|
|
expect(readCurrentHealthPercentBand(predicate)).toEqual({
|
|
minimum: 25,
|
|
maximum: 50,
|
|
});
|
|
expect(predicate).toEqual({
|
|
kind: 'all',
|
|
predicates: [
|
|
{
|
|
kind: 'compare',
|
|
left: {
|
|
source: 'combat',
|
|
path: 'currentHealthPercent',
|
|
timing: 'current',
|
|
},
|
|
operator: 'gte',
|
|
right: 25,
|
|
},
|
|
{
|
|
kind: 'compare',
|
|
left: {
|
|
source: 'combat',
|
|
path: 'currentHealthPercent',
|
|
timing: 'current',
|
|
},
|
|
operator: 'lt',
|
|
right: 50,
|
|
},
|
|
],
|
|
});
|
|
expect(
|
|
readCurrentHealthPercentBand({
|
|
kind: 'compare',
|
|
left: { source: 'event', path: 'values.healthPercent' },
|
|
operator: 'lt',
|
|
right: 50,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('round-trips editable event scalar comparisons without claiming advanced predicates', () => {
|
|
for (const value of ['moving', 4, true]) {
|
|
const comparison = {
|
|
path: 'values.state',
|
|
operator: 'eq' as const,
|
|
value,
|
|
};
|
|
const predicate = eventFieldComparisonPredicate(comparison);
|
|
expect(readEventFieldComparison(predicate)).toEqual(comparison);
|
|
}
|
|
expect(
|
|
readEventFieldComparison({
|
|
kind: 'compare',
|
|
left: { source: 'event', path: 'values.state' },
|
|
operator: 'eq',
|
|
right: { source: 'event', path: 'values.previousState' },
|
|
}),
|
|
).toBeNull();
|
|
expect(
|
|
readEventFieldComparison({
|
|
kind: 'all',
|
|
predicates: [],
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('exposes typed Extra Health fields and parses their manual editor state', () => {
|
|
expect(registeredEventFields('extra_health_changed')).toEqual([
|
|
{
|
|
path: 'values.hasExtraHealth',
|
|
label: 'Has Extra Health',
|
|
type: 'boolean',
|
|
required: true,
|
|
},
|
|
{
|
|
path: 'values.currentExtraHealth',
|
|
label: 'Current Extra Health',
|
|
type: 'number',
|
|
minimum: 0,
|
|
},
|
|
]);
|
|
const value = draft();
|
|
value.clauses[0].event = 'extra_health_changed';
|
|
value.clauses[0].recipeKey = undefined;
|
|
value.clauses[0].reducer = { kind: 'none' };
|
|
value.clauses[0].if = eventFieldComparisonPredicate({
|
|
path: 'values.hasExtraHealth',
|
|
operator: 'eq',
|
|
value: true,
|
|
});
|
|
const result = parseConditionalMechanicDraft(value, {
|
|
variantKeys: ['I', 'II'],
|
|
allowedAttributeIds: new Set(['weapon-damage']),
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.value.programs.I.clauses[0]).toMatchObject({
|
|
when: 'extra_health_changed',
|
|
if: value.clauses[0].if,
|
|
});
|
|
});
|
|
|
|
it('rejects an unregistered Extra Health event field deterministically', () => {
|
|
const value = draft();
|
|
value.clauses[0].event = 'extra_health_changed';
|
|
value.clauses[0].recipeKey = undefined;
|
|
value.clauses[0].reducer = { kind: 'none' };
|
|
value.clauses[0].if = eventFieldComparisonPredicate({
|
|
path: 'values.extraHealthActive',
|
|
operator: 'eq',
|
|
value: true,
|
|
});
|
|
const result = parseConditionalMechanicDraft(value, {
|
|
variantKeys: ['I', 'II'],
|
|
allowedAttributeIds: new Set(['weapon-damage']),
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.errors).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining('is not defined for Extra Health changed'),
|
|
]),
|
|
);
|
|
}
|
|
});
|
|
|
|
it('round-trips state-pool thresholds and preserves unsupported targeted pools as advanced', () => {
|
|
const comparison = { pool: 'stacks', operator: 'gte' as const, value: 10 };
|
|
const predicate = statePoolThresholdPredicate(comparison);
|
|
expect(readStatePoolThreshold(predicate)).toEqual(comparison);
|
|
expect(
|
|
readStatePoolThreshold({
|
|
kind: 'compare',
|
|
left: {
|
|
source: 'pool',
|
|
pool: 'stacks',
|
|
entity: { kind: 'event_target' },
|
|
},
|
|
operator: 'gt',
|
|
right: 0,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('parses movement-state and stack-threshold authoring predicates unchanged', () => {
|
|
const value = draft();
|
|
value.clauses[0].event = 'movement_snapshot';
|
|
value.clauses[0].if = eventFieldComparisonPredicate({
|
|
path: 'values.state',
|
|
operator: 'eq',
|
|
value: 'sprinting',
|
|
});
|
|
value.clauses.push({
|
|
...structuredClone(value.clauses[0]),
|
|
key: 'consume-stacks',
|
|
event: 'shot_fired',
|
|
if: statePoolThresholdPredicate({
|
|
pool: 'effect',
|
|
operator: 'gt',
|
|
value: 0,
|
|
}),
|
|
});
|
|
const result = parseConditionalMechanicDraft(value, {
|
|
variantKeys: ['I', 'II'],
|
|
allowedAttributeIds: new Set(['weapon-damage']),
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.value.draft.clauses.map((clause) => clause.if)).toEqual(
|
|
value.clauses.map((clause) => clause.if),
|
|
);
|
|
expect(result.value.programs.I.clauses.map((clause) => clause.if)).toEqual(
|
|
value.clauses.map((clause) => clause.if),
|
|
);
|
|
});
|
|
|
|
it('accepts an empty captured-only program for deferred modeling', () => {
|
|
const partial: ConditionalMechanicDraftV1 = {
|
|
version: 1,
|
|
simulationReadiness: 'captured_only',
|
|
scenarioInputs: [],
|
|
pools: [],
|
|
clauses: [],
|
|
};
|
|
const result = parseConditionalMechanicDraft(partial, {
|
|
variantKeys: ['I', 'II'],
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.value.draft).toEqual(partial);
|
|
expect(result.value.programs.I.clauses).toEqual([]);
|
|
expect(result.value.programs.II.clauses).toEqual([]);
|
|
});
|
|
|
|
it('requires a branch after a partial program is marked calculation-ready', () => {
|
|
const partial: ConditionalMechanicDraftV1 = {
|
|
version: 1,
|
|
simulationReadiness: 'ready',
|
|
scenarioInputs: [],
|
|
pools: [],
|
|
clauses: [],
|
|
};
|
|
const result = parseConditionalMechanicDraft(partial, {
|
|
variantKeys: ['I', 'II'],
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.errors).toContain(
|
|
'A simulation-ready mechanic requires at least one clause.',
|
|
);
|
|
}
|
|
});
|
|
|
|
it('builds one validated runtime program per exact source variant', () => {
|
|
const result = parseConditionalMechanicDraft(draft(), {
|
|
variantKeys: ['I', 'II'],
|
|
programId: 'superior-firepower',
|
|
programName: 'Superior Firepower',
|
|
allowedAttributeIds: new Set(['weapon-damage']),
|
|
allowedRecipeKeys: new Set(['same_target_hits_within_window']),
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(Object.keys(result.value.programs)).toEqual(['I', 'II']);
|
|
expect(result.value.programs.I.clauses[0]).toMatchObject({
|
|
id: 'gain-effect',
|
|
when: 'weapon_hit',
|
|
recipeKey: 'same_target_hits_within_window',
|
|
cooldownSeconds: 2,
|
|
weight: 1,
|
|
outcomes: [
|
|
{
|
|
id: 'weapon-damage',
|
|
scope: 'equipped_weapon',
|
|
value: 3,
|
|
},
|
|
],
|
|
});
|
|
expect(result.value.programs.II.clauses[0].outcomes?.[0].value).toBe(4.2);
|
|
});
|
|
|
|
it('preserves Happy Survival expressions, explicit cadence readiness, resources, and combat timing', () => {
|
|
const happy: ConditionalMechanicDraftV1 = {
|
|
version: 1,
|
|
simulationReadiness: 'requires_assumptions',
|
|
scenarioInputs: [
|
|
{
|
|
key: 'restoration-events-per-minute',
|
|
label: 'Restoration events per minute',
|
|
kind: 'number',
|
|
readiness: 'requires_assumption',
|
|
min: 0,
|
|
},
|
|
],
|
|
pools: [
|
|
{
|
|
key: 'nearby-enemies',
|
|
label: 'Nearby Enemies',
|
|
stateKind: 'count',
|
|
scope: 'agent',
|
|
initial: 0,
|
|
min: 0,
|
|
max: 20,
|
|
bestCaseValue: 5,
|
|
},
|
|
],
|
|
clauses: [
|
|
{
|
|
key: 'shot-health-exchange',
|
|
event: 'shot_fired',
|
|
reducer: { kind: 'none' },
|
|
mutations: [{ kind: 'none' }],
|
|
outcomes: [
|
|
{
|
|
key: 'health-cost',
|
|
label: 'Health Cost',
|
|
phase: 'on_event',
|
|
kind: 'resource_cost',
|
|
resource: 'health',
|
|
scope: 'self',
|
|
basis: 'max_health_percent',
|
|
variants: { base: { value: 2 } },
|
|
},
|
|
{
|
|
key: 'bonus-damage',
|
|
label: 'Current Health Bonus Damage',
|
|
phase: 'on_event',
|
|
kind: 'direct_damage',
|
|
scope: 'target',
|
|
basis: 'current_health_percent',
|
|
combatTiming: 'current',
|
|
variants: {
|
|
base: {
|
|
value: 0.6,
|
|
expression: {
|
|
op: 'multiply',
|
|
left: 0.6,
|
|
right: {
|
|
op: 'add',
|
|
left: 1,
|
|
right: {
|
|
op: 'divide',
|
|
left: {
|
|
ref: {
|
|
source: 'equipment',
|
|
path: 'protocolTalentUpgrades',
|
|
},
|
|
},
|
|
right: 5,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const result = parseConditionalMechanicDraft(happy, {
|
|
variantKeys: ['base'],
|
|
programName: 'Happy Survival',
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.value.programs.base.clauses[0].outcomes).toEqual([
|
|
expect.objectContaining({
|
|
kind: 'resource_cost',
|
|
resource: 'health',
|
|
value: 2,
|
|
}),
|
|
expect.objectContaining({
|
|
kind: 'direct_damage',
|
|
combatTiming: 'current',
|
|
value: expect.objectContaining({
|
|
right: expect.objectContaining({
|
|
right: expect.objectContaining({
|
|
left: {
|
|
ref: {
|
|
source: 'equipment',
|
|
path: 'protocolTalentUpgrades',
|
|
},
|
|
},
|
|
}),
|
|
}),
|
|
}),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('accepts catalog Attribute references and rejects unknown IDs recursively', () => {
|
|
const referenced = draft();
|
|
referenced.clauses[0].outcomes[0] = {
|
|
key: 'health-restoration',
|
|
label: 'Health Restoration',
|
|
phase: 'after_event',
|
|
kind: 'restoration',
|
|
resource: 'health',
|
|
scope: 'self',
|
|
basis: 'flat',
|
|
delivery: 'continuous',
|
|
durationSeconds: 3,
|
|
variants: {
|
|
I: {
|
|
value: 240,
|
|
expression: {
|
|
op: 'multiply',
|
|
left: 240,
|
|
right: {
|
|
op: 'divide',
|
|
left: {
|
|
ref: {
|
|
source: 'attribute',
|
|
attributeId: 'firepower-multiplier',
|
|
},
|
|
},
|
|
right: 100,
|
|
},
|
|
},
|
|
},
|
|
II: {
|
|
value: 336,
|
|
expression: {
|
|
op: 'multiply',
|
|
left: 336,
|
|
right: {
|
|
op: 'divide',
|
|
left: {
|
|
ref: {
|
|
source: 'attribute',
|
|
attributeId: 'firepower-multiplier',
|
|
},
|
|
},
|
|
right: 100,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const accepted = parseConditionalMechanicDraft(referenced, {
|
|
variantKeys: ['I', 'II'],
|
|
allowedAttributeIds: new Set(['firepower-multiplier']),
|
|
});
|
|
expect(accepted.ok).toBe(true);
|
|
if (accepted.ok) {
|
|
expect(
|
|
accepted.value.programs.I.clauses[0].outcomes?.[0].value,
|
|
).toMatchObject({
|
|
right: {
|
|
left: {
|
|
ref: {
|
|
source: 'attribute',
|
|
attributeId: 'firepower-multiplier',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(accepted.value.programs.II.clauses[0].outcomes?.[0]).toMatchObject(
|
|
{
|
|
delivery: 'continuous',
|
|
durationSeconds: 3,
|
|
value: { left: 336 },
|
|
},
|
|
);
|
|
}
|
|
|
|
const rejected = parseConditionalMechanicDraft(referenced, {
|
|
variantKeys: ['I', 'II'],
|
|
allowedAttributeIds: new Set(['weapon-damage']),
|
|
});
|
|
expect(rejected.ok).toBe(false);
|
|
if (!rejected.ok) {
|
|
expect(rejected.errors).toContain(
|
|
'Conditional mechanic clauses item 1 outcomes item 1 variants I expression right left ref references unknown Attribute "firepower-multiplier".',
|
|
);
|
|
}
|
|
});
|
|
|
|
it('preserves target entity mutations and state-scaled exit outcomes', () => {
|
|
const targeted: ConditionalMechanicDraftV1 = {
|
|
version: 1,
|
|
simulationReadiness: 'ready',
|
|
scenarioInputs: [],
|
|
pools: [
|
|
{
|
|
key: 'marked-target',
|
|
label: 'Marked Target',
|
|
stateKind: 'target_mark',
|
|
scope: 'target',
|
|
initial: 0,
|
|
min: 0,
|
|
max: 1,
|
|
},
|
|
],
|
|
clauses: [
|
|
{
|
|
key: 'detonate-mark',
|
|
event: 'enemy_killed',
|
|
reducer: { kind: 'none' },
|
|
mutations: [
|
|
{
|
|
kind: 'set',
|
|
pool: 'marked-target',
|
|
entity: { kind: 'event_target' },
|
|
value: 1,
|
|
},
|
|
],
|
|
outcomes: [
|
|
{
|
|
key: 'target-damage',
|
|
label: 'Target Damage',
|
|
phase: 'on_exit',
|
|
kind: 'direct_damage',
|
|
scope: 'target',
|
|
basis: 'flat',
|
|
statePool: 'marked-target',
|
|
perStack: true,
|
|
variants: {
|
|
base: {
|
|
value: 240,
|
|
expression: {
|
|
op: 'multiply',
|
|
left: 240,
|
|
right: {
|
|
op: 'divide',
|
|
left: {
|
|
ref: {
|
|
source: 'attribute',
|
|
attributeId: 'firepower-multiplier',
|
|
},
|
|
},
|
|
right: 100,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const result = parseConditionalMechanicDraft(targeted, {
|
|
variantKeys: ['base'],
|
|
allowedAttributeIds: new Set(['firepower-multiplier']),
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.value.draft).toMatchObject(targeted);
|
|
expect(result.value.programs.base).toMatchObject({
|
|
pools: [{ key: 'marked-target', scope: 'target' }],
|
|
clauses: [
|
|
{
|
|
mutations: [
|
|
{
|
|
kind: 'set',
|
|
pool: 'marked-target',
|
|
entity: { kind: 'event_target' },
|
|
value: 1,
|
|
},
|
|
],
|
|
outcomes: [
|
|
{
|
|
phase: 'on_exit',
|
|
kind: 'direct_damage',
|
|
statePool: 'marked-target',
|
|
perStack: true,
|
|
value: {
|
|
right: {
|
|
left: {
|
|
ref: {
|
|
source: 'attribute',
|
|
attributeId: 'firepower-multiplier',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('rejects outcome fields that are absent from the canonical capability manifest', () => {
|
|
const unsupported = draft();
|
|
(
|
|
unsupported.clauses[0].outcomes[0] as unknown as Record<string, unknown>
|
|
).cadence = 'continuous';
|
|
const result = parseConditionalMechanicDraft(unsupported, {
|
|
variantKeys: ['I', 'II'],
|
|
allowedAttributeIds: new Set(['weapon-damage']),
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.errors).toContain(
|
|
'Clause 1 outcome 1 contains unsupported field "cadence".',
|
|
);
|
|
}
|
|
});
|
|
|
|
it('requires the exact configured variants and returns validation errors without throwing', () => {
|
|
const invalid = draft();
|
|
invalid.clauses[0].outcomes[0].variants = {
|
|
I: { value: 3 },
|
|
legacy: { value: 99 },
|
|
};
|
|
const result = parseConditionalMechanicDraft(invalid, {
|
|
variantKeys: ['I', 'II'],
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.errors).toContain(
|
|
'Clause 1 outcome 1 must define exactly these variants: I, II.',
|
|
);
|
|
}
|
|
expect(() =>
|
|
parseConditionalMechanicDraft('{not-json', { variantKeys: ['base'] }),
|
|
).not.toThrow();
|
|
expect(
|
|
parseConditionalMechanicDraft('{not-json', { variantKeys: ['base'] }),
|
|
).toEqual({
|
|
ok: false,
|
|
errors: ['The conditional mechanic contains malformed JSON.'],
|
|
});
|
|
});
|
|
|
|
it('rejects invalid pre-event timing for current Health percentage references', () => {
|
|
const invalid = draft();
|
|
invalid.clauses[0].if = {
|
|
kind: 'compare',
|
|
left: {
|
|
source: 'combat',
|
|
path: 'currentHealthPercent',
|
|
timing: 'pre_event',
|
|
},
|
|
operator: 'lt',
|
|
right: 50,
|
|
};
|
|
const result = parseConditionalMechanicDraft(invalid, {
|
|
variantKeys: ['I', 'II'],
|
|
allowedAttributeIds: new Set(['weapon-damage']),
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it('accepts compatible radius targeting and rejects incompatible scopes', () => {
|
|
const valid = draft();
|
|
valid.clauses[0].outcomes[0].scope = 'allies';
|
|
valid.clauses[0].outcomes[0].targeting = {
|
|
kind: 'within_radius',
|
|
population: 'allies',
|
|
radiusMeters: 40,
|
|
};
|
|
const parsed = parseConditionalMechanicDraft(valid, {
|
|
variantKeys: ['I', 'II'],
|
|
allowedAttributeIds: new Set(['weapon-damage']),
|
|
});
|
|
expect(parsed.ok).toBe(true);
|
|
if (parsed.ok) {
|
|
expect(parsed.value.draft.clauses[0].outcomes[0].targeting).toEqual(
|
|
valid.clauses[0].outcomes[0].targeting,
|
|
);
|
|
}
|
|
|
|
valid.clauses[0].outcomes[0].scope = 'self';
|
|
const incompatible = parseConditionalMechanicDraft(valid, {
|
|
variantKeys: ['I', 'II'],
|
|
allowedAttributeIds: new Set(['weapon-damage']),
|
|
});
|
|
expect(incompatible.ok).toBe(false);
|
|
if (!incompatible.ok) {
|
|
expect(incompatible.errors).toContain(
|
|
'Clause 1 outcome 1 ally radius targeting requires allies or self-and-allies scope.',
|
|
);
|
|
}
|
|
});
|
|
});
|