Adding skills & isac talent updates
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
currentHealthPercentBandPredicate,
|
||||
eventFieldComparisonPredicate,
|
||||
parseConditionalMechanicDraft,
|
||||
readCurrentHealthPercentBand,
|
||||
readEventFieldComparison,
|
||||
readStatePoolThreshold,
|
||||
statePoolThresholdPredicate,
|
||||
type ConditionalMechanicDraftV1
|
||||
} from './mechanic-editor.js';
|
||||
|
||||
@@ -54,6 +60,116 @@ const draft = (): ConditionalMechanicDraftV1 => ({
|
||||
});
|
||||
|
||||
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('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,
|
||||
@@ -427,4 +543,51 @@ describe('conditional mechanic authoring payload', () => {
|
||||
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.'
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type PoolDefinition,
|
||||
type Predicate,
|
||||
type Reducer,
|
||||
type Scalar,
|
||||
type ScenarioInputDefinition,
|
||||
type StateScope,
|
||||
type ValueReference
|
||||
@@ -32,6 +33,7 @@ export type MechanicStateKind =
|
||||
|
||||
export type ConditionalMechanicPoolDraft = PoolDefinition & {
|
||||
stateKind: MechanicStateKind;
|
||||
expiresAfterSecondsVariants?: Record<string, number>;
|
||||
};
|
||||
|
||||
export type ConditionalMechanicVariantValue = {
|
||||
@@ -77,6 +79,138 @@ export type ConditionalMechanicDraftResult =
|
||||
| { ok: true; value: ParsedConditionalMechanicDraft }
|
||||
| { ok: false; errors: string[] };
|
||||
|
||||
export type CurrentHealthPercentBand = {
|
||||
minimum?: number;
|
||||
maximum: number;
|
||||
};
|
||||
|
||||
const currentHealthPercentReference = {
|
||||
source: 'combat',
|
||||
path: 'currentHealthPercent',
|
||||
timing: 'current'
|
||||
} as const;
|
||||
|
||||
export function currentHealthPercentBandPredicate(
|
||||
band: CurrentHealthPercentBand
|
||||
): Predicate {
|
||||
const maximum: Predicate = {
|
||||
kind: 'compare',
|
||||
left: currentHealthPercentReference,
|
||||
operator: 'lt',
|
||||
right: band.maximum
|
||||
};
|
||||
if (band.minimum === undefined) return maximum;
|
||||
return {
|
||||
kind: 'all',
|
||||
predicates: [{
|
||||
kind: 'compare',
|
||||
left: currentHealthPercentReference,
|
||||
operator: 'gte',
|
||||
right: band.minimum
|
||||
}, maximum]
|
||||
};
|
||||
}
|
||||
|
||||
export function readCurrentHealthPercentBand(
|
||||
predicate: Predicate | undefined
|
||||
): CurrentHealthPercentBand | null {
|
||||
const predicates = predicate?.kind === 'all' ? predicate.predicates : predicate ? [predicate] : [];
|
||||
let minimum: number | undefined;
|
||||
let maximum: number | undefined;
|
||||
for (const item of predicates) {
|
||||
if (
|
||||
item.kind !== 'compare'
|
||||
|| typeof item.left !== 'object'
|
||||
|| item.left === null
|
||||
|| item.left.source !== 'combat'
|
||||
|| item.left.path !== 'currentHealthPercent'
|
||||
|| item.left.timing !== 'current'
|
||||
|| typeof item.right !== 'number'
|
||||
) return null;
|
||||
if (item.operator === 'gte') minimum = item.right;
|
||||
else if (item.operator === 'lt') maximum = item.right;
|
||||
else return null;
|
||||
}
|
||||
return maximum === undefined ? null : { ...(minimum === undefined ? {} : { minimum }), maximum };
|
||||
}
|
||||
|
||||
export type PredicateComparisonOperator = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte';
|
||||
export type EditablePredicateScalar = Exclude<Scalar, null>;
|
||||
|
||||
export type EventFieldComparison = {
|
||||
path: string;
|
||||
operator: PredicateComparisonOperator;
|
||||
value: EditablePredicateScalar;
|
||||
};
|
||||
|
||||
export function eventFieldComparisonPredicate(
|
||||
comparison: EventFieldComparison
|
||||
): Predicate {
|
||||
return {
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: comparison.path },
|
||||
operator: comparison.operator,
|
||||
right: comparison.value
|
||||
};
|
||||
}
|
||||
|
||||
export function readEventFieldComparison(
|
||||
predicate: Predicate | undefined
|
||||
): EventFieldComparison | null {
|
||||
if (
|
||||
predicate?.kind !== 'compare'
|
||||
|| typeof predicate.left !== 'object'
|
||||
|| predicate.left === null
|
||||
|| predicate.left.source !== 'event'
|
||||
|| !predicate.left.path.trim()
|
||||
|| (typeof predicate.right !== 'string'
|
||||
&& typeof predicate.right !== 'number'
|
||||
&& typeof predicate.right !== 'boolean')
|
||||
) return null;
|
||||
return {
|
||||
path: predicate.left.path,
|
||||
operator: predicate.operator,
|
||||
value: predicate.right
|
||||
};
|
||||
}
|
||||
|
||||
export type StatePoolThresholdComparison = {
|
||||
pool: string;
|
||||
operator: PredicateComparisonOperator;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export function statePoolThresholdPredicate(
|
||||
comparison: StatePoolThresholdComparison
|
||||
): Predicate {
|
||||
return {
|
||||
kind: 'compare',
|
||||
left: { source: 'pool', pool: comparison.pool },
|
||||
operator: comparison.operator,
|
||||
right: comparison.value
|
||||
};
|
||||
}
|
||||
|
||||
export function readStatePoolThreshold(
|
||||
predicate: Predicate | undefined
|
||||
): StatePoolThresholdComparison | null {
|
||||
if (
|
||||
predicate?.kind !== 'compare'
|
||||
|| typeof predicate.left !== 'object'
|
||||
|| predicate.left === null
|
||||
|| predicate.left.source !== 'pool'
|
||||
|| !predicate.left.pool.trim()
|
||||
|| predicate.left.entity !== undefined
|
||||
|| typeof predicate.right !== 'number'
|
||||
|| !Number.isFinite(predicate.right)
|
||||
) return null;
|
||||
return {
|
||||
pool: predicate.left.pool,
|
||||
operator: predicate.operator,
|
||||
value: predicate.right
|
||||
};
|
||||
}
|
||||
|
||||
const object = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
const finite = (value: unknown): value is number =>
|
||||
@@ -97,13 +231,15 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
|
||||
'perStack',
|
||||
'minStacks',
|
||||
'maxStacks',
|
||||
'targeting',
|
||||
'descriptionSlot'
|
||||
],
|
||||
phase: ['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active'],
|
||||
kind: ['attribute_modifier', 'direct_damage', 'restoration', 'resource_cost', 'marker'],
|
||||
kind: ['attribute_modifier', 'direct_damage', 'restoration', 'resource_cost', 'weapon_reload', 'marker'],
|
||||
scope: [
|
||||
'self',
|
||||
'equipped_weapon',
|
||||
'inactive_weapon',
|
||||
'target',
|
||||
'allies',
|
||||
'self_and_allies',
|
||||
@@ -111,7 +247,7 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
|
||||
'all_skills'
|
||||
],
|
||||
basis: ['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'],
|
||||
resource: ['health', 'armor', 'ammo'],
|
||||
resource: ['health', 'armor', 'ammo', 'signature_ability_charge'],
|
||||
delivery: ['instant', 'continuous'],
|
||||
deliverySemantics: {
|
||||
instant: 'The full resolved value applies at activation.',
|
||||
@@ -175,7 +311,8 @@ function isReference(value: unknown): value is ValueReference {
|
||||
case 'combat':
|
||||
return typeof value.path === 'string'
|
||||
&& Boolean(value.path.trim())
|
||||
&& (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current');
|
||||
&& (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current')
|
||||
&& !(value.path === 'currentHealthPercent' && value.timing === 'pre_event');
|
||||
case 'equipment':
|
||||
return typeof value.path === 'string' && Boolean(value.path.trim());
|
||||
case 'attribute':
|
||||
@@ -222,6 +359,7 @@ function isExpression(value: unknown): value is NumericExpression {
|
||||
if (finite(value)) return true;
|
||||
if (!object(value)) return false;
|
||||
if ('ref' in value) return isReference(value.ref);
|
||||
if (value.op === 'floor') return isExpression(value.value);
|
||||
if (value.op === 'clamp') {
|
||||
return isExpression(value.value) && isExpression(value.min) && isExpression(value.max);
|
||||
}
|
||||
@@ -333,7 +471,7 @@ function parseScenarioInputs(value: unknown, errors: string[]) {
|
||||
return inputs;
|
||||
}
|
||||
|
||||
function parsePools(value: unknown, errors: string[]) {
|
||||
function parsePools(value: unknown, variantKeys: readonly string[], errors: string[]) {
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push('State pools must be an array.');
|
||||
return [] as ConditionalMechanicPoolDraft[];
|
||||
@@ -362,6 +500,26 @@ function parsePools(value: unknown, errors: string[]) {
|
||||
errors.push(`${path} ${property} must be a finite number.`);
|
||||
}
|
||||
}
|
||||
if (candidate.expiresAfterSeconds !== undefined && candidate.expiresAfterSecondsVariants !== undefined) {
|
||||
errors.push(`${path} cannot define both scalar and variant expiration durations.`);
|
||||
}
|
||||
if (candidate.expiresAfterSecondsVariants !== undefined) {
|
||||
if (!object(candidate.expiresAfterSecondsVariants)) {
|
||||
errors.push(`${path} variant expiration durations must be an object.`);
|
||||
} else {
|
||||
const variantDurations = candidate.expiresAfterSecondsVariants;
|
||||
const keys = Object.keys(variantDurations);
|
||||
if (
|
||||
keys.length !== variantKeys.length
|
||||
|| keys.some((variant) => !variantKeys.includes(variant))
|
||||
|| variantKeys.some((variant) =>
|
||||
!finite(variantDurations[variant])
|
||||
|| Number(variantDurations[variant]) <= 0)
|
||||
) {
|
||||
errors.push(`${path} variant expiration durations must define one positive number for each variant.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
pools.push(candidate as ConditionalMechanicPoolDraft);
|
||||
}
|
||||
return pools;
|
||||
@@ -439,6 +597,25 @@ function parseOutcome(
|
||||
) {
|
||||
errors.push(`${path} description slot must be a positive integer.`);
|
||||
}
|
||||
if (value.targeting !== undefined) {
|
||||
if (
|
||||
!object(value.targeting)
|
||||
|| value.targeting.kind !== 'within_radius'
|
||||
|| !['allies', 'enemies'].includes(String(value.targeting.population))
|
||||
|| !finite(value.targeting.radiusMeters)
|
||||
|| value.targeting.radiusMeters <= 0
|
||||
) {
|
||||
errors.push(`${path} has invalid radius targeting.`);
|
||||
} else if (
|
||||
value.targeting.population === 'allies'
|
||||
&& value.scope !== 'allies'
|
||||
&& value.scope !== 'self_and_allies'
|
||||
) {
|
||||
errors.push(`${path} ally radius targeting requires allies or self-and-allies scope.`);
|
||||
} else if (value.targeting.population === 'enemies' && value.scope !== 'target') {
|
||||
errors.push(`${path} enemy radius targeting requires target scope.`);
|
||||
}
|
||||
}
|
||||
if (!object(value.variants)) {
|
||||
errors.push(`${path} variants must be an object.`);
|
||||
return null;
|
||||
@@ -555,7 +732,16 @@ function programsForDraft(
|
||||
id: `${options.programId ?? 'mechanic'}:${variant}`,
|
||||
name: options.programName?.trim() || 'Conditional Mechanic',
|
||||
scenarioInputs: draft.scenarioInputs,
|
||||
pools: draft.pools.map(({ stateKind: _stateKind, ...pool }) => pool),
|
||||
pools: draft.pools.map(({
|
||||
stateKind: _stateKind,
|
||||
expiresAfterSecondsVariants,
|
||||
...pool
|
||||
}) => ({
|
||||
...pool,
|
||||
...(expiresAfterSecondsVariants
|
||||
? { expiresAfterSeconds: expiresAfterSecondsVariants[variant] }
|
||||
: {})
|
||||
})),
|
||||
clauses: draft.clauses.map((clause): MechanicClause => ({
|
||||
id: clause.key,
|
||||
...(clause.label ? { label: clause.label } : {}),
|
||||
@@ -607,7 +793,7 @@ export function parseConditionalMechanicDraft(
|
||||
errors.push('The conditional mechanic has invalid simulation readiness.');
|
||||
}
|
||||
const scenarioInputs = parseScenarioInputs(parsed.scenarioInputs, errors);
|
||||
const pools = parsePools(parsed.pools, errors);
|
||||
const pools = parsePools(parsed.pools, options.variantKeys, errors);
|
||||
const clauses = parseClauses(parsed.clauses, options.variantKeys, options, errors);
|
||||
const draft: ConditionalMechanicDraftV1 = {
|
||||
version: CONDITIONAL_MECHANIC_DRAFT_VERSION,
|
||||
|
||||
@@ -78,6 +78,7 @@ const outcome = (
|
||||
value: 3,
|
||||
value_expression_json: null,
|
||||
value_basis: 'flat',
|
||||
targeting_json: null,
|
||||
value_scaling: 'per_state_unit',
|
||||
unit: '%',
|
||||
damage_basis: null,
|
||||
@@ -152,6 +153,33 @@ describe('conditional-effect persistence adapter', () => {
|
||||
expect(result.program?.clauses[0].outcomes?.[0]).not.toHaveProperty('durationSeconds');
|
||||
});
|
||||
|
||||
it('preserves radius targeting metadata on an outcome', () => {
|
||||
const result = adaptConditionalEffectProgram({
|
||||
rule: rule(),
|
||||
statePools: [pool()],
|
||||
clauses: [clause({ trigger_id: null, event_kind: 'enemy_killed' })],
|
||||
outcomes: [outcome({
|
||||
effect_scope: 'allies',
|
||||
targeting_json: JSON.stringify({
|
||||
kind: 'within_radius',
|
||||
population: 'allies',
|
||||
radiusMeters: 40
|
||||
})
|
||||
})],
|
||||
triggers: []
|
||||
}, { name: 'Slaughter Healing', variantKey: 'I' });
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({
|
||||
scope: 'allies',
|
||||
targeting: {
|
||||
kind: 'within_radius',
|
||||
population: 'allies',
|
||||
radiusMeters: 40
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('loads continuous restoration delivery metadata', () => {
|
||||
const result = adaptConditionalEffectProgram({
|
||||
rule: rule(),
|
||||
@@ -589,4 +617,30 @@ describe('conditional-effect persistence adapter', () => {
|
||||
'Clause 1 mutations contains malformed JSON.'
|
||||
]));
|
||||
});
|
||||
|
||||
it('preserves a persisted current Health percentage predicate', () => {
|
||||
const condition = {
|
||||
kind: 'compare',
|
||||
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
|
||||
operator: 'lt',
|
||||
right: 25
|
||||
};
|
||||
const result = adaptConditionalEffectProgram({
|
||||
rule: rule(),
|
||||
statePools: [],
|
||||
clauses: [clause({
|
||||
trigger_id: null,
|
||||
event_kind: 'tick',
|
||||
parameters_json: '{}',
|
||||
conditions_json: JSON.stringify(condition),
|
||||
state_pool_id: null,
|
||||
mutations_json: '[]'
|
||||
})],
|
||||
outcomes: [],
|
||||
triggers: []
|
||||
}, { name: 'Spirited', variantKey: 'I' });
|
||||
|
||||
expect(result.status).toBe('READY');
|
||||
expect(result.program?.clauses[0].if).toEqual(condition);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,6 +84,7 @@ export type ConditionalEffectOutcomeRow = {
|
||||
combat_timing?: DatabaseValue;
|
||||
delivery_mode?: DatabaseValue;
|
||||
delivery_duration_seconds?: DatabaseValue;
|
||||
targeting_json?: DatabaseValue;
|
||||
value_scaling: DatabaseValue;
|
||||
unit: DatabaseValue;
|
||||
damage_basis: DatabaseValue;
|
||||
@@ -192,7 +193,8 @@ function isValueReference(value: unknown) {
|
||||
if (value.source === 'combat') {
|
||||
return typeof value.path === 'string'
|
||||
&& Boolean(value.path.trim())
|
||||
&& (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current');
|
||||
&& (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current')
|
||||
&& !(value.path === 'currentHealthPercent' && value.timing === 'pre_event');
|
||||
}
|
||||
if (value.source === 'attribute') {
|
||||
return typeof value.attributeId === 'string' && Boolean(value.attributeId.trim());
|
||||
@@ -211,6 +213,7 @@ function isNumericExpression(value: unknown): value is NumericExpression {
|
||||
if (finite(value)) return true;
|
||||
if (!object(value)) return false;
|
||||
if ('ref' in value) return isValueReference(value.ref);
|
||||
if (value.op === 'floor') return isNumericExpression(value.value);
|
||||
if (value.op === 'clamp') {
|
||||
return isNumericExpression(value.value)
|
||||
&& isNumericExpression(value.min)
|
||||
@@ -441,6 +444,7 @@ function parsePool(
|
||||
row: ConditionalEffectStatePoolRow,
|
||||
ruleId: string,
|
||||
index: number,
|
||||
selectedVariant: string,
|
||||
errors: string[]
|
||||
): { id: string; definition: PoolDefinition } | null {
|
||||
const path = `State pool ${index + 1}`;
|
||||
@@ -460,6 +464,14 @@ function parsePool(
|
||||
const duration = number(row.duration_seconds);
|
||||
const parameters = parseJson(row.parameters_json, `${path} parameters`, errors);
|
||||
if (parameters.ok && !object(parameters.value)) errors.push(`${path} parameters must be a JSON object.`);
|
||||
const durationVariants = parameters.ok && object(parameters.value)
|
||||
&& object(parameters.value.expiresAfterSecondsVariants)
|
||||
? parameters.value.expiresAfterSecondsVariants
|
||||
: null;
|
||||
const variantDuration = durationVariants ? number(durationVariants[selectedVariant] as DatabaseValue) : null;
|
||||
if (durationVariants && variantDuration === null) {
|
||||
errors.push(`${path} has no valid expiration duration for variant "${selectedVariant}".`);
|
||||
}
|
||||
return {
|
||||
id,
|
||||
definition: {
|
||||
@@ -470,7 +482,9 @@ function parsePool(
|
||||
...(minimum === null ? {} : { min: minimum }),
|
||||
max: maximum,
|
||||
...(bestCase === null ? {} : { bestCaseValue: bestCase }),
|
||||
...(duration === null ? {} : { expiresAfterSeconds: duration })
|
||||
...(variantDuration !== null
|
||||
? { expiresAfterSeconds: variantDuration }
|
||||
: duration === null ? {} : { expiresAfterSeconds: duration })
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -478,10 +492,14 @@ function parsePool(
|
||||
function outcomeKind(value: string): MechanicOutcome['kind'] | null {
|
||||
if (value === 'active_modifier') return 'attribute_modifier';
|
||||
if (value === 'direct_damage') return 'direct_damage';
|
||||
if (value === 'restoration' || ['restore_health', 'restore_armor', 'restore_ammo'].includes(value)) {
|
||||
if (
|
||||
value === 'restoration'
|
||||
|| ['restore_health', 'restore_armor', 'restore_ammo', 'restore_signature_ability_charge'].includes(value)
|
||||
) {
|
||||
return 'restoration';
|
||||
}
|
||||
if (value === 'resource_cost') return 'resource_cost';
|
||||
if (value === 'weapon_reload') return 'weapon_reload';
|
||||
if (value === 'marker') return 'marker';
|
||||
return null;
|
||||
}
|
||||
@@ -489,6 +507,7 @@ function outcomeKind(value: string): MechanicOutcome['kind'] | null {
|
||||
function outcomeScope(value: string): MechanicOutcome['scope'] | null {
|
||||
if (value === 'self') return 'self';
|
||||
if (value === 'equipped_weapon') return 'equipped_weapon';
|
||||
if (value === 'inactive_weapon') return 'inactive_weapon';
|
||||
if (value === 'target') return 'target';
|
||||
if (value === 'allies') return 'allies';
|
||||
if (value === 'self_and_allies') return 'self_and_allies';
|
||||
@@ -547,15 +566,17 @@ function parseOutcome(
|
||||
? 'armor'
|
||||
: text(row.application_kind) === 'restore_ammo'
|
||||
? 'ammo'
|
||||
: text(row.application_kind) === 'restore_signature_ability_charge'
|
||||
? 'signature_ability_charge'
|
||||
: text(row.application_kind) === 'resource_cost'
|
||||
? 'health'
|
||||
: null;
|
||||
const resource = nullableText(row.resource_kind) ?? legacyResource;
|
||||
if (
|
||||
(kind === 'restoration' || kind === 'resource_cost')
|
||||
&& !['health', 'armor', 'ammo'].includes(String(resource))
|
||||
&& !['health', 'armor', 'ammo', 'signature_ability_charge'].includes(String(resource))
|
||||
) {
|
||||
errors.push(`${path} requires an explicit health, armor, or ammo resource.`);
|
||||
errors.push(`${path} requires an explicit supported resource.`);
|
||||
}
|
||||
const combatTiming = nullableText(row.combat_timing);
|
||||
if (combatTiming && combatTiming !== 'pre_event' && combatTiming !== 'current') {
|
||||
@@ -572,6 +593,13 @@ function parseOutcome(
|
||||
if (delivery === 'instant' && durationSeconds !== null) {
|
||||
errors.push(`${path} instant delivery cannot set a duration.`);
|
||||
}
|
||||
let targeting: MechanicOutcome['targeting'] | undefined;
|
||||
if (row.targeting_json !== null && row.targeting_json !== undefined && row.targeting_json !== '') {
|
||||
const parsed = parseJson(row.targeting_json, `${path} targeting`, errors);
|
||||
if (parsed.ok && typeof parsed.value === 'object' && parsed.value !== null) {
|
||||
targeting = parsed.value as MechanicOutcome['targeting'];
|
||||
}
|
||||
}
|
||||
return {
|
||||
clauseId: nullableText(row.clause_id),
|
||||
outcome: {
|
||||
@@ -591,6 +619,7 @@ function parseOutcome(
|
||||
...(delivery === 'continuous' && durationSeconds !== null
|
||||
? { delivery, durationSeconds }
|
||||
: {}),
|
||||
...(targeting ? { targeting } : {}),
|
||||
...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id)
|
||||
? { attributeId: text(row.stat_attribute_id) }
|
||||
: {}),
|
||||
@@ -627,16 +656,6 @@ export function adaptConditionalEffectProgram(
|
||||
}
|
||||
const scenarioInputs = parseScenarioInputs(input.rule.scenario_inputs_json, errors);
|
||||
|
||||
const parsedPools = input.statePools
|
||||
.slice()
|
||||
.sort((left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0))
|
||||
.map((row, index) => parsePool(row, ruleId, index, errors))
|
||||
.filter((value): value is NonNullable<typeof value> => value !== null);
|
||||
const poolKeys = new Map(parsedPools.map((pool) => [pool.id, pool.definition.key]));
|
||||
const triggerMap = new Map(
|
||||
input.triggers.map((trigger) => [text(trigger.id), trigger])
|
||||
);
|
||||
|
||||
const variantKeys = [...new Set(input.outcomes.map((row) => text(row.variant_key)).filter(Boolean))];
|
||||
let selectedVariant = options.variantKey?.trim() || '';
|
||||
if (!selectedVariant) {
|
||||
@@ -648,6 +667,15 @@ export function adaptConditionalEffectProgram(
|
||||
} else if (variantKeys.length && !variantKeys.includes(selectedVariant)) {
|
||||
errors.push(`Variant "${selectedVariant}" does not exist for this rule.`);
|
||||
}
|
||||
const parsedPools = input.statePools
|
||||
.slice()
|
||||
.sort((left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0))
|
||||
.map((row, index) => parsePool(row, ruleId, index, selectedVariant, errors))
|
||||
.filter((value): value is NonNullable<typeof value> => value !== null);
|
||||
const poolKeys = new Map(parsedPools.map((pool) => [pool.id, pool.definition.key]));
|
||||
const triggerMap = new Map(
|
||||
input.triggers.map((trigger) => [text(trigger.id), trigger])
|
||||
);
|
||||
|
||||
const selectedOutcomes = input.outcomes
|
||||
.filter((row) => text(row.variant_key) === selectedVariant)
|
||||
|
||||
@@ -142,10 +142,17 @@ describe('mechanic recipe registry', () => {
|
||||
expect(health.clauses[1].if).toMatchObject({
|
||||
kind: 'all',
|
||||
predicates: expect.arrayContaining([
|
||||
{ kind: 'compare', left: { source: 'event', path: 'values.healthPercent' }, operator: 'lte', right: 50 },
|
||||
{ kind: 'compare', left: { source: 'event', path: 'values.healthPercent' }, operator: 'gt', right: 25 }
|
||||
{ kind: 'compare', left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, operator: 'lt', right: 50 },
|
||||
{ kind: 'compare', left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, operator: 'gte', right: 25 }
|
||||
])
|
||||
});
|
||||
expect(health.clauses.every((clause) => clause.when === 'tick')).toBe(true);
|
||||
expect(health.clauses[2].if).toEqual({
|
||||
kind: 'compare',
|
||||
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
|
||||
operator: 'gte',
|
||||
right: 50
|
||||
});
|
||||
|
||||
expect(seedFor('target_hit_within_distance').clauses[0]).toMatchObject({
|
||||
when: 'weapon_hit',
|
||||
|
||||
@@ -110,6 +110,11 @@ function firingLifecycleSeed(activePool: string, includeDuration?: number): Mech
|
||||
|
||||
function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed {
|
||||
const sortedBands = [...params.bands].sort((left, right) => left.thresholdPercent - right.thresholdPercent);
|
||||
const currentHealthPercent: ValueReference = {
|
||||
source: 'combat',
|
||||
path: 'currentHealthPercent',
|
||||
timing: 'current'
|
||||
};
|
||||
const pools: PoolDefinition[] = sortedBands.map((band, index) => ({
|
||||
key: `health-band-${index + 1}`,
|
||||
label: `Health band: ${band.outcomeKey}`,
|
||||
@@ -122,22 +127,22 @@ function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed {
|
||||
const lowerBound = sortedBands[index - 1]?.thresholdPercent;
|
||||
const predicates: Predicate[] = [{
|
||||
kind: 'compare',
|
||||
left: eventValue('healthPercent'),
|
||||
operator: 'lte',
|
||||
left: currentHealthPercent,
|
||||
operator: 'lt',
|
||||
right: band.thresholdPercent
|
||||
}];
|
||||
if (lowerBound !== undefined) {
|
||||
predicates.push({
|
||||
kind: 'compare',
|
||||
left: eventValue('healthPercent'),
|
||||
operator: 'gt',
|
||||
left: currentHealthPercent,
|
||||
operator: 'gte',
|
||||
right: lowerBound
|
||||
});
|
||||
}
|
||||
return {
|
||||
id: `select-health-band-${index + 1}`,
|
||||
label: `Select ${band.outcomeKey} at ${band.thresholdPercent}% Health`,
|
||||
when: 'health_damage_taken',
|
||||
label: `Select ${band.outcomeKey} below ${band.thresholdPercent}% Health`,
|
||||
when: 'tick',
|
||||
if: { kind: 'all', predicates },
|
||||
reducer: { kind: 'none' },
|
||||
mutations: pools.map((pool, poolIndex) =>
|
||||
@@ -150,11 +155,11 @@ function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed {
|
||||
clauses.push({
|
||||
id: 'clear-health-bands',
|
||||
label: 'Clear health bands above the highest threshold',
|
||||
when: 'health_damage_taken',
|
||||
when: 'tick',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: eventValue('healthPercent'),
|
||||
operator: 'gt',
|
||||
left: currentHealthPercent,
|
||||
operator: 'gte',
|
||||
right: sortedBands.at(-1)?.thresholdPercent ?? 0
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
formatMechanicProgram,
|
||||
validateMechanicProgram,
|
||||
type MechanicEvent,
|
||||
type MechanicProgram
|
||||
type MechanicEventKind,
|
||||
type MechanicProgram,
|
||||
type NumericExpression
|
||||
} from './mechanic.js';
|
||||
|
||||
const custom = (encounterSeconds = 30, inputs: Record<string, number | boolean> = {}) => ({
|
||||
@@ -17,14 +19,153 @@ const custom = (encounterSeconds = 30, inputs: Record<string, number | boolean>
|
||||
describe('structured mechanic runtime', () => {
|
||||
it('advertises the authoritative weapon-hit target and headshot fields', () => {
|
||||
expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({
|
||||
description: expect.stringContaining('headshot boolean'),
|
||||
description: expect.stringContaining('previous target'),
|
||||
fields: [
|
||||
{ path: 'targetId', label: 'Target ID', type: 'string' },
|
||||
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' }
|
||||
{ path: 'values.previousTargetId', label: 'Previous target ID', type: 'string' },
|
||||
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' },
|
||||
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
|
||||
{ path: 'values.behindCover', label: 'Agent behind cover', type: 'boolean' },
|
||||
{ path: 'values.distanceMeters', label: 'Target distance', type: 'number' }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('advertises imported support and equipment-use trigger events', () => {
|
||||
expect(MECHANIC_EVENT_REGISTRY.ally_downed).toMatchObject({
|
||||
key: 'ally_downed',
|
||||
label: 'Ally downed'
|
||||
});
|
||||
expect(MECHANIC_EVENT_REGISTRY.agent_downed).toMatchObject({
|
||||
key: 'agent_downed',
|
||||
label: 'Agent downed'
|
||||
});
|
||||
expect(MECHANIC_EVENT_REGISTRY.ally_rescued).toMatchObject({
|
||||
key: 'ally_rescued',
|
||||
label: 'Ally rescued'
|
||||
});
|
||||
expect(MECHANIC_EVENT_REGISTRY.grenade_thrown).toMatchObject({
|
||||
key: 'grenade_thrown',
|
||||
label: 'Grenade thrown'
|
||||
});
|
||||
expect(MECHANIC_EVENT_REGISTRY.medical_kit_used).toMatchObject({
|
||||
key: 'medical_kit_used',
|
||||
label: 'Medical Kit used'
|
||||
});
|
||||
expect(MECHANIC_EVENT_REGISTRY.signature_ability_deployed).toMatchObject({
|
||||
key: 'signature_ability_deployed',
|
||||
label: 'Signature Ability deployed'
|
||||
});
|
||||
expect(MECHANIC_EVENT_REGISTRY.skill_multishot_triggered).toMatchObject({
|
||||
key: 'skill_multishot_triggered',
|
||||
label: 'Skill Multi-Shot triggered'
|
||||
});
|
||||
});
|
||||
|
||||
it('executes imported talent trigger families as first-class events', () => {
|
||||
const eventKinds: MechanicEventKind[] = [
|
||||
'ally_downed',
|
||||
'agent_downed',
|
||||
'ally_rescued',
|
||||
'grenade_thrown',
|
||||
'medical_kit_used',
|
||||
'signature_ability_deployed',
|
||||
'skill_multishot_triggered'
|
||||
];
|
||||
const program: MechanicProgram = {
|
||||
id: 'imported-talent-events',
|
||||
name: 'Imported talent events',
|
||||
clauses: eventKinds.map((when) => ({
|
||||
id: when,
|
||||
when,
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'none' }],
|
||||
outcomes: [{
|
||||
id: `${when}-marker`,
|
||||
label: `${when} marker`,
|
||||
phase: 'after_event',
|
||||
kind: 'marker',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
value: 1
|
||||
}]
|
||||
}))
|
||||
};
|
||||
|
||||
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
|
||||
const events: MechanicEvent[] = eventKinds.map((kind, index) => ({ kind, at: index + 1 }));
|
||||
expect(evaluateMechanicProgram(program, events, custom()).outcomes.map(
|
||||
(outcome) => outcome.outcomeId
|
||||
)).toEqual(eventKinds.map((kind) => `${kind}-marker`));
|
||||
});
|
||||
|
||||
it('derives the previous weapon-hit target for target-switch mechanics', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'target-switch-stacks',
|
||||
name: 'Target Switch Stacks',
|
||||
pools: [{
|
||||
key: 'stacks',
|
||||
label: 'Stacks',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 100,
|
||||
expiresAfterSeconds: 5
|
||||
}],
|
||||
clauses: [
|
||||
{
|
||||
id: 'halve-on-switch',
|
||||
when: 'weapon_hit',
|
||||
if: {
|
||||
kind: 'all',
|
||||
predicates: [
|
||||
{ kind: 'exists', value: { source: 'event', path: 'values.previousTargetId' } },
|
||||
{
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.previousTargetId' },
|
||||
operator: 'ne',
|
||||
right: { source: 'event', path: 'targetId' }
|
||||
}
|
||||
]
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{
|
||||
kind: 'set',
|
||||
pool: 'stacks',
|
||||
value: {
|
||||
op: 'multiply',
|
||||
left: { ref: { source: 'pool', pool: 'stacks' } },
|
||||
right: 0.5
|
||||
}
|
||||
}]
|
||||
},
|
||||
{
|
||||
id: 'build-stacks',
|
||||
when: 'weapon_hit',
|
||||
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'add', pool: 'stacks', value: 1 }]
|
||||
}
|
||||
]
|
||||
};
|
||||
const result = evaluateMechanicProgram(program, [
|
||||
{ kind: 'weapon_hit', at: 0, targetId: 'alpha' },
|
||||
{ kind: 'weapon_hit', at: 1, targetId: 'alpha' },
|
||||
{ kind: 'weapon_hit', at: 2, targetId: 'bravo' }
|
||||
], custom(3));
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.finalState).toContainEqual(expect.objectContaining({
|
||||
pool: 'stacks',
|
||||
value: 2
|
||||
}));
|
||||
expect(result.emissions.map((emission) => emission.clauseId)).toEqual([
|
||||
'build-stacks',
|
||||
'build-stacks',
|
||||
'halve-on-switch',
|
||||
'build-stacks'
|
||||
]);
|
||||
});
|
||||
|
||||
it('models Superior Firepower as same-target sliding hits and one shared expiring stack pool', () => {
|
||||
const superiorFirepower: MechanicProgram = {
|
||||
id: 'superior-firepower',
|
||||
@@ -1077,6 +1218,242 @@ describe('structured mechanic runtime', () => {
|
||||
});
|
||||
|
||||
describe('mechanic validation and pseudo-script', () => {
|
||||
it('shares an identical timed modifier outcome across kill and assist refresh clauses', () => {
|
||||
const weaponDamage = {
|
||||
id: 'pummel-weapon-damage',
|
||||
label: 'Weapon Damage',
|
||||
phase: 'while_active' as const,
|
||||
kind: 'attribute_modifier' as const,
|
||||
scope: 'equipped_weapon' as const,
|
||||
basis: 'flat' as const,
|
||||
value: 10,
|
||||
attributeId: 'weapon-damage',
|
||||
statePool: 'pummel-active'
|
||||
};
|
||||
const program: MechanicProgram = {
|
||||
id: 'pummel-refresh',
|
||||
name: 'Pummel Refresh',
|
||||
pools: [{
|
||||
key: 'pummel-active', label: 'Pummel active', scope: 'agent', initial: 0,
|
||||
min: 0, max: 1, expiresAfterSeconds: 12
|
||||
}],
|
||||
clauses: [
|
||||
{
|
||||
id: 'activate-on-kill', when: 'enemy_killed', reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'set', pool: 'pummel-active', value: 1 }],
|
||||
outcomes: [weaponDamage]
|
||||
},
|
||||
{
|
||||
id: 'refresh-on-assist', when: 'assist', reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'set', pool: 'pummel-active', value: 1 }],
|
||||
outcomes: [{ ...weaponDamage }]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
|
||||
const result = evaluateMechanicProgram(program, [
|
||||
{ kind: 'enemy_killed', at: 1 },
|
||||
{ kind: 'assist', at: 10 }
|
||||
], custom(20));
|
||||
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.outcomes.map(({ outcomeId, at, appliedValue }) => ({ outcomeId, at, appliedValue })))
|
||||
.toEqual([
|
||||
{ outcomeId: 'pummel-weapon-damage', at: 1, appliedValue: 10 },
|
||||
{ outcomeId: 'pummel-weapon-damage', at: 10, appliedValue: 10 }
|
||||
]);
|
||||
expect(result.finalState).toContainEqual(expect.objectContaining({
|
||||
pool: 'pummel-active', value: 1, expiresAt: 22
|
||||
}));
|
||||
});
|
||||
|
||||
it('rejects a shared outcome ID when its mechanical definitions conflict', () => {
|
||||
const outcome = {
|
||||
id: 'shared-damage',
|
||||
label: 'Weapon Damage',
|
||||
phase: 'on_event' as const,
|
||||
kind: 'attribute_modifier' as const,
|
||||
scope: 'equipped_weapon' as const,
|
||||
basis: 'flat' as const,
|
||||
value: 10,
|
||||
attributeId: 'weapon-damage'
|
||||
};
|
||||
const program: MechanicProgram = {
|
||||
id: 'conflicting-shared-outcome',
|
||||
name: 'Conflicting Shared Outcome',
|
||||
clauses: [
|
||||
{
|
||||
id: 'kill', when: 'enemy_killed', reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'none' }], outcomes: [outcome]
|
||||
},
|
||||
{
|
||||
id: 'assist', when: 'assist', reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'none' }], outcomes: [{ ...outcome, value: 14 }]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
expect(validateMechanicProgram(program)).toEqual({
|
||||
ok: false,
|
||||
errors: ['Outcome "shared-damage" has conflicting definitions.']
|
||||
});
|
||||
});
|
||||
|
||||
it('applies distance bonuses in full five-meter steps and caps the result', () => {
|
||||
const distanceBonus = (
|
||||
base: number,
|
||||
increment: number,
|
||||
maximum: number
|
||||
): NumericExpression => ({
|
||||
op: 'min',
|
||||
left: maximum,
|
||||
right: {
|
||||
op: 'add',
|
||||
left: base,
|
||||
right: {
|
||||
op: 'multiply',
|
||||
left: increment,
|
||||
right: {
|
||||
op: 'floor',
|
||||
value: {
|
||||
op: 'divide',
|
||||
left: {
|
||||
op: 'max',
|
||||
left: { ref: { source: 'event', path: 'values.distanceMeters' } },
|
||||
right: 0
|
||||
},
|
||||
right: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
const program: MechanicProgram = {
|
||||
id: 'long-distance-marking',
|
||||
name: 'Long-Distance Marking',
|
||||
clauses: [{
|
||||
id: 'headshot',
|
||||
when: 'weapon_hit',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [{
|
||||
id: 'skill-damage',
|
||||
label: 'Skill Damage',
|
||||
phase: 'after_event',
|
||||
kind: 'attribute_modifier',
|
||||
scope: 'self',
|
||||
attributeId: 'skill-damage',
|
||||
basis: 'flat',
|
||||
value: distanceBonus(7.5, 1.5, 18)
|
||||
}]
|
||||
}]
|
||||
};
|
||||
|
||||
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
|
||||
const result = evaluateMechanicProgram(
|
||||
program,
|
||||
[-1, 0, 4.99, 5, 9.99, 10, 34.99, 35, 100].map((distanceMeters, at) => ({
|
||||
kind: 'weapon_hit' as const,
|
||||
at,
|
||||
values: { distanceMeters }
|
||||
})),
|
||||
custom(10)
|
||||
);
|
||||
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.outcomes.map((outcome) => outcome.resolvedValue)).toEqual([
|
||||
7.5, 7.5, 7.5, 9, 9, 10.5, 16.5, 18, 18
|
||||
]);
|
||||
expect(result.script).toContain(
|
||||
'MIN(18, (7.5 + (1.5 * FLOOR((MAX(EVENT.values.distanceMeters, 0) / 5)))))'
|
||||
);
|
||||
});
|
||||
|
||||
it('supports an immediate equipped-weapon magazine refill', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'equipped-magazine',
|
||||
name: 'Equipped Magazine',
|
||||
clauses: [{
|
||||
id: 'refill-equipped-weapon',
|
||||
when: 'enemy_killed',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'none' }],
|
||||
outcomes: [{
|
||||
id: 'equipped-weapon-reload',
|
||||
label: 'Refill equipped weapon',
|
||||
phase: 'after_event',
|
||||
kind: 'weapon_reload',
|
||||
scope: 'equipped_weapon',
|
||||
basis: 'flat',
|
||||
value: 0
|
||||
}]
|
||||
}]
|
||||
};
|
||||
|
||||
const result = evaluateMechanicProgram(
|
||||
program,
|
||||
[{ kind: 'enemy_killed', at: 1 }],
|
||||
custom(10)
|
||||
);
|
||||
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.outcomes).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'weapon_reload',
|
||||
scope: 'equipped_weapon',
|
||||
at: 1,
|
||||
value: 0
|
||||
})
|
||||
]);
|
||||
expect(result.script).toContain('RELOAD EQUIPPED WEAPON AFTER 0s');
|
||||
});
|
||||
|
||||
it('schedules an inactive-weapon reload and refreshes a pending delay', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'backup-magazine',
|
||||
name: 'Backup Magazine',
|
||||
clauses: [{
|
||||
id: 'reload-inactive-weapon',
|
||||
when: 'weapon_swapped',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'none' }],
|
||||
outcomes: [{
|
||||
id: 'inactive-weapon-reload',
|
||||
label: 'Reload inactive weapon',
|
||||
phase: 'after_event',
|
||||
kind: 'weapon_reload',
|
||||
scope: 'inactive_weapon',
|
||||
basis: 'flat',
|
||||
value: 4
|
||||
}]
|
||||
}]
|
||||
};
|
||||
|
||||
const refreshed = evaluateMechanicProgram(program, [
|
||||
{ kind: 'weapon_swapped', at: 1 },
|
||||
{ kind: 'weapon_swapped', at: 3 }
|
||||
], custom(10));
|
||||
expect(refreshed.status).toBe('OK');
|
||||
expect(refreshed.emissions.map(({ at }) => at)).toEqual([1, 3]);
|
||||
expect(refreshed.outcomes).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'weapon_reload',
|
||||
scope: 'inactive_weapon',
|
||||
at: 7,
|
||||
value: 4
|
||||
})
|
||||
]);
|
||||
expect(refreshed.script).toContain('RELOAD INACTIVE WEAPON AFTER 4s');
|
||||
|
||||
const beyondEncounter = evaluateMechanicProgram(
|
||||
program,
|
||||
[{ kind: 'weapon_swapped', at: 8 }],
|
||||
custom(10)
|
||||
);
|
||||
expect(beyondEncounter.outcomes).toEqual([]);
|
||||
});
|
||||
|
||||
it('enforces clause cooldowns without counting events received during the cooldown', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'cooldown',
|
||||
@@ -1187,6 +1564,213 @@ describe('mechanic validation and pseudo-script', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('caps Signature Ability Charge at 1000 and scales grants by charge efficiency', () => {
|
||||
const efficiencyAttributeId = 'signature-charge-efficiency';
|
||||
const program: MechanicProgram = {
|
||||
id: 'signature-charge',
|
||||
name: 'Signature Charge',
|
||||
clauses: [{
|
||||
id: 'grant-charge',
|
||||
when: 'weapon_hit',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [{
|
||||
id: 'charge',
|
||||
label: 'Signature Ability Charge',
|
||||
phase: 'after_event',
|
||||
kind: 'restoration',
|
||||
scope: 'self',
|
||||
resource: 'signature_ability_charge',
|
||||
basis: 'flat',
|
||||
value: {
|
||||
op: 'multiply',
|
||||
left: 70,
|
||||
right: {
|
||||
op: 'add',
|
||||
left: 1,
|
||||
right: {
|
||||
op: 'divide',
|
||||
left: { ref: { source: 'attribute', attributeId: efficiencyAttributeId } },
|
||||
right: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
};
|
||||
const result = evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{ kind: 'weapon_hit', at: 1 },
|
||||
{ kind: 'weapon_hit', at: 2 }
|
||||
],
|
||||
custom(),
|
||||
{
|
||||
maxHealth: 100,
|
||||
currentHealth: 100,
|
||||
currentSignatureAbilityCharge: 900,
|
||||
attributes: { [efficiencyAttributeId]: 50 }
|
||||
}
|
||||
);
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.outcomes.map((outcome) => outcome.appliedValue)).toEqual([100, 0]);
|
||||
expect(result.finalCombat?.currentSignatureAbilityCharge).toBe(1000);
|
||||
expect(result.finalCombat?.maxSignatureAbilityCharge).toBeUndefined();
|
||||
});
|
||||
|
||||
it('evaluates mutually exclusive current Health percentage bands at strict boundaries', () => {
|
||||
const band = (
|
||||
id: string,
|
||||
minimum: number | undefined,
|
||||
maximum: number,
|
||||
value: number
|
||||
): MechanicProgram['clauses'][number] => ({
|
||||
id,
|
||||
when: 'tick',
|
||||
if: minimum === undefined
|
||||
? {
|
||||
kind: 'compare',
|
||||
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
|
||||
operator: 'lt',
|
||||
right: maximum
|
||||
}
|
||||
: {
|
||||
kind: 'all',
|
||||
predicates: [{
|
||||
kind: 'compare',
|
||||
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
|
||||
operator: 'gte',
|
||||
right: minimum
|
||||
}, {
|
||||
kind: 'compare',
|
||||
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
|
||||
operator: 'lt',
|
||||
right: maximum
|
||||
}]
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [{
|
||||
id: `${id}-damage-reduction`,
|
||||
label: 'Damage Reduction',
|
||||
phase: 'while_active',
|
||||
kind: 'attribute_modifier',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
attributeId: 'damage-reduction',
|
||||
value
|
||||
}]
|
||||
});
|
||||
const spirited: MechanicProgram = {
|
||||
id: 'spirited',
|
||||
name: 'Spirited',
|
||||
clauses: [
|
||||
band('critical', undefined, 25, 15),
|
||||
band('damaged', 25, 50, 12),
|
||||
band('wounded', 50, 75, 9)
|
||||
]
|
||||
};
|
||||
const evaluateAt = (healthPercent: number, mode: 'custom' | 'maximum' = 'custom') =>
|
||||
evaluateMechanicProgram(
|
||||
spirited,
|
||||
[{ kind: 'tick', at: 0 }],
|
||||
mode === 'maximum'
|
||||
? { mode: 'maximum', encounterSeconds: 1 }
|
||||
: custom(1),
|
||||
{ maxHealth: 1_000, currentHealth: healthPercent * 10 }
|
||||
);
|
||||
const valuesAt = (healthPercent: number, mode: 'custom' | 'maximum' = 'custom') =>
|
||||
evaluateAt(healthPercent, mode).outcomes.map((outcome) => outcome.appliedValue);
|
||||
|
||||
expect(valuesAt(0)).toEqual([15]);
|
||||
expect(valuesAt(24.99)).toEqual([15]);
|
||||
expect(valuesAt(25)).toEqual([12]);
|
||||
expect(valuesAt(49.99)).toEqual([12]);
|
||||
expect(valuesAt(50)).toEqual([9]);
|
||||
expect(valuesAt(74.99)).toEqual([9]);
|
||||
expect(valuesAt(75)).toEqual([]);
|
||||
expect(valuesAt(100)).toEqual([]);
|
||||
expect(valuesAt(24, 'maximum')).toEqual([15]);
|
||||
});
|
||||
|
||||
it('re-evaluates current Health percentage after deterministic combat-state updates', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'live-health',
|
||||
name: 'Live Health',
|
||||
clauses: [{
|
||||
id: 'health-cost',
|
||||
when: 'tick',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [{
|
||||
id: 'cost',
|
||||
label: 'Health cost',
|
||||
phase: 'on_event',
|
||||
kind: 'resource_cost',
|
||||
scope: 'self',
|
||||
resource: 'health',
|
||||
basis: 'flat',
|
||||
value: 30
|
||||
}]
|
||||
}, {
|
||||
id: 'below-seventy-five',
|
||||
when: 'tick',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
|
||||
operator: 'lt',
|
||||
right: 75
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [{
|
||||
id: 'reduction',
|
||||
label: 'Damage Reduction',
|
||||
phase: 'while_active',
|
||||
kind: 'attribute_modifier',
|
||||
scope: 'self',
|
||||
attributeId: 'damage-reduction',
|
||||
basis: 'flat',
|
||||
value: 9
|
||||
}]
|
||||
}]
|
||||
};
|
||||
const result = evaluateMechanicProgram(
|
||||
program,
|
||||
[{ kind: 'tick', at: 0 }],
|
||||
custom(1),
|
||||
{ maxHealth: 100, currentHealth: 100 }
|
||||
);
|
||||
expect(result.finalCombat?.currentHealth).toBe(70);
|
||||
expect(result.outcomes.map((outcome) => outcome.outcomeId)).toEqual(['cost', 'reduction']);
|
||||
});
|
||||
|
||||
it('rejects pre-event timing for the derived current Health percentage', () => {
|
||||
const invalid: MechanicProgram = {
|
||||
id: 'invalid-health-percent',
|
||||
name: 'Invalid Health Percent',
|
||||
clauses: [{
|
||||
id: 'invalid',
|
||||
when: 'tick',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'combat', path: 'currentHealthPercent', timing: 'pre_event' },
|
||||
operator: 'lt',
|
||||
right: 50
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: []
|
||||
}]
|
||||
};
|
||||
const result = validateMechanicProgram(invalid);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('current Health percentage must use current timing')
|
||||
]));
|
||||
}
|
||||
});
|
||||
|
||||
it('formats nested conditions and state operations deterministically', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'format',
|
||||
@@ -1221,4 +1805,49 @@ describe('mechanic validation and pseudo-script', () => {
|
||||
'ADD 1 TO prepared'
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
it('preserves radius targeting on ally modifiers and enemy markers', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'radius-targeting',
|
||||
name: 'Radius Targeting',
|
||||
clauses: [{
|
||||
id: 'kill',
|
||||
when: 'enemy_killed',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'none' }],
|
||||
outcomes: [{
|
||||
id: 'healing',
|
||||
label: 'Incoming Healing',
|
||||
phase: 'on_event',
|
||||
kind: 'attribute_modifier',
|
||||
scope: 'allies',
|
||||
attributeId: 'incoming-healing',
|
||||
basis: 'flat',
|
||||
value: 7,
|
||||
targeting: { kind: 'within_radius', population: 'allies', radiusMeters: 40 }
|
||||
}, {
|
||||
id: 'taunt',
|
||||
label: 'Taunt',
|
||||
phase: 'on_event',
|
||||
kind: 'marker',
|
||||
scope: 'target',
|
||||
basis: 'flat',
|
||||
value: 1,
|
||||
targeting: { kind: 'within_radius', population: 'enemies', radiusMeters: 20 }
|
||||
}]
|
||||
}]
|
||||
};
|
||||
const result = evaluateMechanicProgram(
|
||||
program,
|
||||
[{ kind: 'enemy_killed', at: 1 }],
|
||||
custom(5)
|
||||
);
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.outcomes.map((outcome) => outcome.targeting)).toEqual([
|
||||
{ kind: 'within_radius', population: 'allies', radiusMeters: 40 },
|
||||
{ kind: 'within_radius', population: 'enemies', radiusMeters: 20 }
|
||||
]);
|
||||
expect(result.script).toContain('TO ALLIES WITHIN 40m');
|
||||
expect(result.script).toContain('TO ENEMIES WITHIN 20m');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,19 @@ export const MECHANIC_EVENT_KINDS = [
|
||||
'shot_fired',
|
||||
'weapon_hit',
|
||||
'skill_deployed',
|
||||
'signature_ability_deployed',
|
||||
'skill_hit',
|
||||
'skill_multishot_triggered',
|
||||
'agent_hit',
|
||||
'health_damage_taken',
|
||||
'damage_dealt',
|
||||
'enemy_killed',
|
||||
'assist',
|
||||
'ally_downed',
|
||||
'agent_downed',
|
||||
'ally_rescued',
|
||||
'grenade_thrown',
|
||||
'medical_kit_used',
|
||||
'healing_applied',
|
||||
'extra_health_granted',
|
||||
'movement_snapshot',
|
||||
@@ -36,15 +43,31 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
|
||||
shot_fired: {
|
||||
key: 'shot_fired',
|
||||
label: 'Shot fired',
|
||||
description: 'A round leaves the equipped weapon; hit confirmation is not required.'
|
||||
description: 'A round leaves the equipped weapon; hit confirmation is not required.',
|
||||
fields: [
|
||||
{
|
||||
path: 'values.hit',
|
||||
label: 'Shot hit',
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
path: 'values.magazineTwentyPercentBandsCrossed',
|
||||
label: '20% magazine bands crossed',
|
||||
type: 'number'
|
||||
}
|
||||
]
|
||||
},
|
||||
weapon_hit: {
|
||||
key: 'weapon_hit',
|
||||
label: 'Weapon hit',
|
||||
description: 'A weapon attack connects with a target. The headshot boolean distinguishes headshots from body shots.',
|
||||
description: 'A weapon attack connects with a target. The previous target and event values distinguish target switches, headshots, cover state, and distance.',
|
||||
fields: [
|
||||
{ path: 'targetId', label: 'Target ID', type: 'string' },
|
||||
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' }
|
||||
{ path: 'values.previousTargetId', label: 'Previous target ID', type: 'string' },
|
||||
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' },
|
||||
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
|
||||
{ path: 'values.behindCover', label: 'Agent behind cover', type: 'boolean' },
|
||||
{ path: 'values.distanceMeters', label: 'Target distance', type: 'number' }
|
||||
]
|
||||
},
|
||||
skill_deployed: {
|
||||
@@ -52,10 +75,24 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
|
||||
label: 'Skill deployed',
|
||||
description: 'The Agent deploys or activates a specialization ability.'
|
||||
},
|
||||
signature_ability_deployed: {
|
||||
key: 'signature_ability_deployed',
|
||||
label: 'Signature Ability deployed',
|
||||
description: 'The Agent deploys or activates their Signature Ability.'
|
||||
},
|
||||
skill_hit: {
|
||||
key: 'skill_hit',
|
||||
label: 'Skill hit',
|
||||
description: 'A deployed skill connects with a target. Critical and distance details belong in event values.'
|
||||
description: 'A deployed skill connects with a target. Critical and distance details belong in event values.',
|
||||
fields: [
|
||||
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
|
||||
{ path: 'values.distanceMeters', label: 'Target distance', type: 'number' }
|
||||
]
|
||||
},
|
||||
skill_multishot_triggered: {
|
||||
key: 'skill_multishot_triggered',
|
||||
label: 'Skill Multi-Shot triggered',
|
||||
description: 'A deployed skill triggers an additional Multi-Shot.'
|
||||
},
|
||||
agent_hit: {
|
||||
key: 'agent_hit',
|
||||
@@ -65,7 +102,11 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
|
||||
health_damage_taken: {
|
||||
key: 'health_damage_taken',
|
||||
label: 'Health damage taken',
|
||||
description: 'The Agent loses Health. Damage absorbed entirely by Extra Health does not emit this event.'
|
||||
description: 'The Agent loses Health. Damage absorbed entirely by Extra Health does not emit this event.',
|
||||
fields: [
|
||||
{ path: 'values.distanceMeters', label: 'Attacker distance', type: 'number' },
|
||||
{ path: 'values.recentSkillDeploymentSeconds', label: 'Seconds since relevant Skill deployment', type: 'number' }
|
||||
]
|
||||
},
|
||||
damage_dealt: {
|
||||
key: 'damage_dealt',
|
||||
@@ -75,12 +116,50 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
|
||||
enemy_killed: {
|
||||
key: 'enemy_killed',
|
||||
label: 'Enemy killed',
|
||||
description: 'An enemy dies. Weapon or skill source may be supplied when a mechanic requires the distinction.'
|
||||
description: 'An enemy dies. Weapon or skill source may be supplied when a mechanic requires the distinction.',
|
||||
fields: [
|
||||
{ path: 'values.headshot', label: 'Headshot killing shot', type: 'boolean' },
|
||||
{ path: 'values.critical', label: 'Critical killing shot', type: 'boolean' },
|
||||
{ path: 'values.source', label: 'Killing source', type: 'string' },
|
||||
{ path: 'values.behindCover', label: 'Agent behind cover', type: 'boolean' },
|
||||
{ path: 'values.distanceMeters', label: 'Enemy distance', type: 'number' }
|
||||
]
|
||||
},
|
||||
assist: {
|
||||
key: 'assist',
|
||||
label: 'Assist',
|
||||
description: 'The Agent receives credit for assisting with an enemy defeat.'
|
||||
description: 'The Agent receives credit for assisting with an enemy defeat.',
|
||||
fields: [
|
||||
{ path: 'values.behindCover', label: 'Agent behind cover', type: 'boolean' }
|
||||
]
|
||||
},
|
||||
ally_downed: {
|
||||
key: 'ally_downed',
|
||||
label: 'Ally downed',
|
||||
description: 'An allied Agent enters the downed state.',
|
||||
fields: [
|
||||
{ path: 'values.distanceMeters', label: 'Ally distance', type: 'number' }
|
||||
]
|
||||
},
|
||||
agent_downed: {
|
||||
key: 'agent_downed',
|
||||
label: 'Agent downed',
|
||||
description: 'The Agent enters the downed state.'
|
||||
},
|
||||
ally_rescued: {
|
||||
key: 'ally_rescued',
|
||||
label: 'Ally rescued',
|
||||
description: 'The Agent completes a rescue of a downed ally.'
|
||||
},
|
||||
grenade_thrown: {
|
||||
key: 'grenade_thrown',
|
||||
label: 'Grenade thrown',
|
||||
description: 'The Agent throws a grenade and consumes it from inventory.'
|
||||
},
|
||||
medical_kit_used: {
|
||||
key: 'medical_kit_used',
|
||||
label: 'Medical Kit used',
|
||||
description: 'The Agent consumes a Medical Kit.'
|
||||
},
|
||||
healing_applied: {
|
||||
key: 'healing_applied',
|
||||
@@ -95,7 +174,14 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
|
||||
movement_snapshot: {
|
||||
key: 'movement_snapshot',
|
||||
label: 'Movement snapshot',
|
||||
description: 'A sampled Agent movement state such as immobile, moving, or sprinting.'
|
||||
description: 'A one-second sample of the Agent movement state.',
|
||||
fields: [
|
||||
{
|
||||
path: 'values.state',
|
||||
label: 'Movement state (immobile, moving, or sprinting)',
|
||||
type: 'string'
|
||||
}
|
||||
]
|
||||
},
|
||||
proximity_snapshot: {
|
||||
key: 'proximity_snapshot',
|
||||
@@ -115,7 +201,10 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
|
||||
reload_started: {
|
||||
key: 'reload_started',
|
||||
label: 'Reload started',
|
||||
description: 'The equipped weapon begins reloading.'
|
||||
description: 'The equipped weapon begins reloading.',
|
||||
fields: [
|
||||
{ path: 'values.emptyMagazine', label: 'Empty magazine', type: 'boolean' }
|
||||
]
|
||||
},
|
||||
reload_completed: {
|
||||
key: 'reload_completed',
|
||||
@@ -178,9 +267,21 @@ export type ValueReference =
|
||||
| { source: 'reducer' }
|
||||
| { source: 'pool'; pool: string; entity?: EntitySelector };
|
||||
|
||||
export const MECHANIC_COMBAT_REFERENCE_REGISTRY = {
|
||||
currentHealth: {
|
||||
label: 'Current Health',
|
||||
description: 'The Agent’s resolved current Health.'
|
||||
},
|
||||
currentHealthPercent: {
|
||||
label: 'Current Health percentage',
|
||||
description: 'The Agent’s resolved current Health divided by resolved maximum Health, from 0 through 100.'
|
||||
}
|
||||
} as const;
|
||||
|
||||
export type NumericExpression =
|
||||
| number
|
||||
| { ref: ValueReference }
|
||||
| { op: 'floor'; value: NumericExpression }
|
||||
| {
|
||||
op: 'add' | 'subtract' | 'multiply' | 'divide' | 'min' | 'max';
|
||||
left: NumericExpression;
|
||||
@@ -256,19 +357,26 @@ export type OutcomeBasis =
|
||||
export type MechanicOutcomeScope =
|
||||
| 'self'
|
||||
| 'equipped_weapon'
|
||||
| 'inactive_weapon'
|
||||
| 'target'
|
||||
| 'allies'
|
||||
| 'self_and_allies'
|
||||
| 'assigned_ability'
|
||||
| 'all_skills';
|
||||
|
||||
export type MechanicResource = 'health' | 'armor' | 'ammo';
|
||||
export type MechanicResource = 'health' | 'armor' | 'ammo' | 'signature_ability_charge';
|
||||
|
||||
export type MechanicOutcomeTargeting = {
|
||||
kind: 'within_radius';
|
||||
population: 'allies' | 'enemies';
|
||||
radiusMeters: number;
|
||||
};
|
||||
|
||||
export type MechanicOutcome = {
|
||||
id: string;
|
||||
label: string;
|
||||
phase: OutcomePhase;
|
||||
kind: 'attribute_modifier' | 'direct_damage' | 'restoration' | 'resource_cost' | 'marker';
|
||||
kind: 'attribute_modifier' | 'direct_damage' | 'restoration' | 'resource_cost' | 'weapon_reload' | 'marker';
|
||||
scope: MechanicOutcomeScope;
|
||||
value: NumericExpression;
|
||||
basis: OutcomeBasis;
|
||||
@@ -281,6 +389,7 @@ export type MechanicOutcome = {
|
||||
perStack?: boolean;
|
||||
minStacks?: number;
|
||||
maxStacks?: number;
|
||||
targeting?: MechanicOutcomeTargeting;
|
||||
};
|
||||
|
||||
export type MechanicClause = {
|
||||
@@ -304,6 +413,23 @@ export type MechanicProgram = {
|
||||
clauses: MechanicClause[];
|
||||
};
|
||||
|
||||
function mechanicDefinitionsEqual(left: unknown, right: unknown): boolean {
|
||||
if (Object.is(left, right)) return true;
|
||||
if (typeof left !== typeof right || left === null || right === null) return false;
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
|
||||
return left.every((value, index) => mechanicDefinitionsEqual(value, right[index]));
|
||||
}
|
||||
if (typeof left !== 'object') return false;
|
||||
const leftRecord = left as Record<string, unknown>;
|
||||
const rightRecord = right as Record<string, unknown>;
|
||||
const leftKeys = Object.keys(leftRecord).sort();
|
||||
const rightKeys = Object.keys(rightRecord).sort();
|
||||
return leftKeys.length === rightKeys.length
|
||||
&& leftKeys.every((key, index) =>
|
||||
key === rightKeys[index] && mechanicDefinitionsEqual(leftRecord[key], rightRecord[key]));
|
||||
}
|
||||
|
||||
export type PoolSnapshot = {
|
||||
pool: string;
|
||||
scope: StateScope;
|
||||
@@ -354,6 +480,7 @@ export type MechanicOutcomeEmission = {
|
||||
deliveryStart: number | null;
|
||||
deliveryEnd: number | null;
|
||||
totalResolvedValue: number;
|
||||
targeting: MechanicOutcomeTargeting | null;
|
||||
};
|
||||
|
||||
export type MechanicEvaluation = {
|
||||
@@ -382,6 +509,8 @@ export type MechanicCombatContext = {
|
||||
currentArmor?: number;
|
||||
maxAmmo?: number;
|
||||
currentAmmo?: number;
|
||||
maxSignatureAbilityCharge?: number;
|
||||
currentSignatureAbilityCharge?: number;
|
||||
weaponBaseDamage?: number;
|
||||
attributes?: Record<string, number>;
|
||||
values?: Record<string, Scalar>;
|
||||
@@ -425,6 +554,7 @@ type ContinuousDelivery = {
|
||||
start: number;
|
||||
end: number;
|
||||
appliedThrough: number;
|
||||
targeting: MechanicOutcomeTargeting | null;
|
||||
};
|
||||
type ReducerState = {
|
||||
count: number;
|
||||
@@ -494,6 +624,15 @@ function resolveReference(reference: ValueReference, context: EvaluationContext)
|
||||
return context.scenario[reference.key];
|
||||
case 'combat': {
|
||||
const combat = reference.timing === 'pre_event' ? context.preEventCombat : context.combat;
|
||||
if (reference.path === 'currentHealthPercent') {
|
||||
if (
|
||||
!combat
|
||||
|| !finiteNumber(combat.maxHealth)
|
||||
|| combat.maxHealth <= 0
|
||||
|| !finiteNumber(combat.currentHealth)
|
||||
) return undefined;
|
||||
return clamp(combat.currentHealth / combat.maxHealth * 100, 0, 100);
|
||||
}
|
||||
return getPath(combat, reference.path);
|
||||
}
|
||||
case 'attribute':
|
||||
@@ -513,6 +652,7 @@ function numericValue(expression: NumericExpression, context: EvaluationContext)
|
||||
const value = resolveReference(expression.ref, context);
|
||||
return finiteNumber(value) ? value : typeof value === 'boolean' ? Number(value) : 0;
|
||||
}
|
||||
if (expression.op === 'floor') return Math.floor(numericValue(expression.value, context));
|
||||
if (expression.op === 'clamp') {
|
||||
return clamp(
|
||||
numericValue(expression.value, context),
|
||||
@@ -893,6 +1033,19 @@ function combatResource(
|
||||
setCurrent: (value) => { combat.currentAmmo = value; }
|
||||
};
|
||||
}
|
||||
if (resource === 'signature_ability_charge') {
|
||||
const maximum = finiteNumber(combat.maxSignatureAbilityCharge)
|
||||
? combat.maxSignatureAbilityCharge
|
||||
: 1000;
|
||||
const current = finiteNumber(combat.currentSignatureAbilityCharge)
|
||||
? combat.currentSignatureAbilityCharge
|
||||
: 0;
|
||||
return {
|
||||
current,
|
||||
maximum,
|
||||
setCurrent: (value) => { combat.currentSignatureAbilityCharge = value; }
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -957,7 +1110,8 @@ function emitOutcomes(
|
||||
stateValue: current,
|
||||
start: at,
|
||||
end: at + durationSeconds,
|
||||
appliedThrough: at
|
||||
appliedThrough: at,
|
||||
targeting: outcome.targeting ?? null
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -973,10 +1127,13 @@ function emitOutcomes(
|
||||
} else if ((outcome.kind === 'resource_cost' || outcome.kind === 'restoration') && !targetResource) {
|
||||
appliedValue = 0;
|
||||
}
|
||||
const emittedAt = outcome.kind === 'weapon_reload'
|
||||
? at + Math.max(0, appliedValue)
|
||||
: at;
|
||||
emissions.push({
|
||||
clauseId: clause.id,
|
||||
outcomeId: outcome.id,
|
||||
at,
|
||||
at: emittedAt,
|
||||
targetId: context.event.targetId ?? null,
|
||||
phase: outcome.phase,
|
||||
kind: outcome.kind,
|
||||
@@ -995,12 +1152,40 @@ function emitOutcomes(
|
||||
delivery: 'instant',
|
||||
deliveryStart: null,
|
||||
deliveryEnd: null,
|
||||
totalResolvedValue: resolvedValue * weight
|
||||
totalResolvedValue: resolvedValue * weight,
|
||||
targeting: outcome.targeting ?? null
|
||||
});
|
||||
}
|
||||
return emissions;
|
||||
}
|
||||
|
||||
function appendScheduledOutcomes(
|
||||
target: MechanicOutcomeEmission[],
|
||||
candidates: MechanicOutcomeEmission[],
|
||||
triggeredAt: number,
|
||||
encounterSeconds: number
|
||||
) {
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.kind !== 'weapon_reload') {
|
||||
target.push(candidate);
|
||||
continue;
|
||||
}
|
||||
for (let index = target.length - 1; index >= 0; index -= 1) {
|
||||
const pending = target[index];
|
||||
if (
|
||||
pending.kind === 'weapon_reload'
|
||||
&& pending.clauseId === candidate.clauseId
|
||||
&& pending.outcomeId === candidate.outcomeId
|
||||
&& pending.scope === candidate.scope
|
||||
&& pending.at > triggeredAt + EPSILON
|
||||
) {
|
||||
target.splice(index, 1);
|
||||
}
|
||||
}
|
||||
if (candidate.at <= encounterSeconds + EPSILON) target.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
function advanceContinuousDeliveries(
|
||||
at: number,
|
||||
deliveries: ContinuousDelivery[],
|
||||
@@ -1046,7 +1231,8 @@ function advanceContinuousDeliveries(
|
||||
delivery: 'continuous',
|
||||
deliveryStart: delivery.start,
|
||||
deliveryEnd: delivery.end,
|
||||
totalResolvedValue: delivery.totalResolvedValue
|
||||
totalResolvedValue: delivery.totalResolvedValue,
|
||||
targeting: delivery.targeting
|
||||
});
|
||||
}
|
||||
for (let index = deliveries.length - 1; index >= 0; index -= 1) {
|
||||
@@ -1182,7 +1368,12 @@ function validateReference(reference: ValueReference, pools: Set<string>, inputs
|
||||
errors.push(`${path} references unknown scenario input "${reference.key}".`);
|
||||
}
|
||||
if (reference.source === 'event' && !reference.path.trim()) errors.push(`${path} requires an event path.`);
|
||||
if (reference.source === 'combat' && !reference.path.trim()) errors.push(`${path} requires a combat path.`);
|
||||
if (reference.source === 'combat') {
|
||||
if (!reference.path.trim()) errors.push(`${path} requires a combat path.`);
|
||||
if (reference.path === 'currentHealthPercent' && reference.timing === 'pre_event') {
|
||||
errors.push(`${path} current Health percentage must use current timing.`);
|
||||
}
|
||||
}
|
||||
if (reference.source === 'attribute' && !reference.attributeId.trim()) errors.push(`${path} requires an Attribute ID.`);
|
||||
if (reference.source === 'equipment' && !reference.path.trim()) errors.push(`${path} requires an equipment path.`);
|
||||
}
|
||||
@@ -1202,6 +1393,10 @@ function visitExpression(
|
||||
validateReference(expression.ref, pools, inputs, errors, path);
|
||||
return;
|
||||
}
|
||||
if (expression.op === 'floor') {
|
||||
visitExpression(expression.value, pools, inputs, errors, `${path} value`);
|
||||
return;
|
||||
}
|
||||
if (expression.op === 'clamp') {
|
||||
visitExpression(expression.value, pools, inputs, errors, `${path} value`);
|
||||
visitExpression(expression.min, pools, inputs, errors, `${path} minimum`);
|
||||
@@ -1245,6 +1440,7 @@ function visitPredicate(
|
||||
function expressionUsesCombat(expression: NumericExpression): boolean {
|
||||
if (typeof expression === 'number') return false;
|
||||
if ('ref' in expression) return expression.ref.source === 'combat';
|
||||
if (expression.op === 'floor') return expressionUsesCombat(expression.value);
|
||||
if (expression.op === 'clamp') {
|
||||
return expressionUsesCombat(expression.value)
|
||||
|| expressionUsesCombat(expression.min)
|
||||
@@ -1256,6 +1452,7 @@ function expressionUsesCombat(expression: NumericExpression): boolean {
|
||||
function expressionUsesEquipment(expression: NumericExpression): boolean {
|
||||
if (typeof expression === 'number') return false;
|
||||
if ('ref' in expression) return expression.ref.source === 'equipment';
|
||||
if (expression.op === 'floor') return expressionUsesEquipment(expression.value);
|
||||
if (expression.op === 'clamp') {
|
||||
return expressionUsesEquipment(expression.value)
|
||||
|| expressionUsesEquipment(expression.min)
|
||||
@@ -1390,7 +1587,7 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
|
||||
}
|
||||
}
|
||||
const clauseIds = new Set<string>();
|
||||
const outcomeIds = new Set<string>();
|
||||
const outcomeDefinitions = new Map<string, MechanicOutcome>();
|
||||
for (const [index, clause] of program.clauses.entries()) {
|
||||
const path = `Clause ${index + 1}`;
|
||||
if (!clause.id.trim()) errors.push(`${path} requires an ID.`);
|
||||
@@ -1478,14 +1675,19 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
|
||||
for (const [outcomeIndex, outcome] of (clause.outcomes ?? []).entries()) {
|
||||
const outcomePath = `${path} outcome ${outcomeIndex + 1}`;
|
||||
if (!outcome.id.trim()) errors.push(`${outcomePath} requires an ID.`);
|
||||
if (outcomeIds.has(outcome.id)) errors.push(`Outcome "${outcome.id}" is duplicated.`);
|
||||
outcomeIds.add(outcome.id);
|
||||
const existingOutcome = outcomeDefinitions.get(outcome.id);
|
||||
if (existingOutcome && !mechanicDefinitionsEqual(existingOutcome, outcome)) {
|
||||
errors.push(`Outcome "${outcome.id}" has conflicting definitions.`);
|
||||
} else if (!existingOutcome) {
|
||||
outcomeDefinitions.set(outcome.id, outcome);
|
||||
}
|
||||
if (outcome.kind === 'attribute_modifier' && !outcome.attributeId?.trim()) {
|
||||
errors.push(`${outcomePath} requires an Attribute ID.`);
|
||||
}
|
||||
if (![
|
||||
'self',
|
||||
'equipped_weapon',
|
||||
'inactive_weapon',
|
||||
'target',
|
||||
'allies',
|
||||
'self_and_allies',
|
||||
@@ -1494,11 +1696,45 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
|
||||
].includes(outcome.scope)) {
|
||||
errors.push(`${outcomePath} has an unsupported scope.`);
|
||||
}
|
||||
if (
|
||||
outcome.kind === 'weapon_reload'
|
||||
&& outcome.scope !== 'equipped_weapon'
|
||||
&& outcome.scope !== 'inactive_weapon'
|
||||
) {
|
||||
errors.push(`${outcomePath} weapon reload must target the equipped or inactive weapon.`);
|
||||
}
|
||||
if (outcome.scope === 'inactive_weapon' && outcome.kind !== 'weapon_reload') {
|
||||
errors.push(`${outcomePath} can only target the inactive weapon with a weapon reload.`);
|
||||
}
|
||||
if (outcome.targeting !== undefined) {
|
||||
if (
|
||||
outcome.targeting.kind !== 'within_radius'
|
||||
|| !['allies', 'enemies'].includes(outcome.targeting.population)
|
||||
|| !finiteNumber(outcome.targeting.radiusMeters)
|
||||
|| outcome.targeting.radiusMeters <= 0
|
||||
) {
|
||||
errors.push(`${outcomePath} has invalid radius targeting.`);
|
||||
} else if (
|
||||
outcome.targeting.population === 'allies'
|
||||
&& outcome.scope !== 'allies'
|
||||
&& outcome.scope !== 'self_and_allies'
|
||||
) {
|
||||
errors.push(`${outcomePath} ally radius targeting requires allies or self-and-allies scope.`);
|
||||
} else if (
|
||||
outcome.targeting.population === 'enemies'
|
||||
&& outcome.scope !== 'target'
|
||||
) {
|
||||
errors.push(`${outcomePath} enemy radius targeting requires target scope.`);
|
||||
}
|
||||
}
|
||||
if (outcome.kind === 'weapon_reload' && finiteNumber(outcome.value) && outcome.value < 0) {
|
||||
errors.push(`${outcomePath} weapon reload delay cannot be negative.`);
|
||||
}
|
||||
if ((outcome.kind === 'restoration' || outcome.kind === 'resource_cost') && outcome.resource === undefined) {
|
||||
errors.push(`${outcomePath} requires a resource.`);
|
||||
}
|
||||
if ((outcome.kind === 'restoration' || outcome.kind === 'resource_cost') && outcome.resource !== undefined
|
||||
&& !['health', 'armor', 'ammo'].includes(outcome.resource)) {
|
||||
&& !['health', 'armor', 'ammo', 'signature_ability_charge'].includes(outcome.resource)) {
|
||||
errors.push(`${outcomePath} has an unsupported resource.`);
|
||||
}
|
||||
if (outcome.resource !== undefined && outcome.kind !== 'restoration' && outcome.kind !== 'resource_cost') {
|
||||
@@ -1522,6 +1758,7 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
|
||||
direct_damage: ['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'],
|
||||
restoration: ['flat', 'max_health_percent', 'current_health_percent'],
|
||||
resource_cost: ['flat', 'max_health_percent', 'current_health_percent'],
|
||||
weapon_reload: ['flat'],
|
||||
marker: ['flat']
|
||||
};
|
||||
if (!allowedBasis[outcome.kind].includes(outcome.basis)) {
|
||||
@@ -1644,6 +1881,10 @@ export function evaluateMechanicProgram(
|
||||
const continuousDeliveries: ContinuousDelivery[] = [];
|
||||
if (scenario.mode === 'maximum') {
|
||||
for (const clause of program.clauses) {
|
||||
if (
|
||||
clause.when === 'tick'
|
||||
&& events.some((event) => event.kind === 'tick' && Math.abs(event.at) <= EPSILON)
|
||||
) continue;
|
||||
const event: MechanicEvent = {
|
||||
kind: clause.when,
|
||||
at: 0,
|
||||
@@ -1660,14 +1901,15 @@ export function evaluateMechanicProgram(
|
||||
preEventCombat: copyCombat(combat),
|
||||
equipment
|
||||
};
|
||||
if (!predicateMatches(clause.if, context)) continue;
|
||||
const before = new Map((clause.outcomes ?? [])
|
||||
.map((outcome) => outcome.statePool)
|
||||
.filter((pool): pool is string => Boolean(pool))
|
||||
.map((pool) => [pool, stateValue(pool, undefined, context)]));
|
||||
outcomes.push(...emitOutcomes({
|
||||
appendScheduledOutcomes(outcomes, emitOutcomes({
|
||||
...clause,
|
||||
outcomes: (clause.outcomes ?? []).filter((outcome) => outcome.phase === 'while_active')
|
||||
}, 'after', 0, 1, before, context, continuousDeliveries));
|
||||
}, 'after', 0, 1, before, context, continuousDeliveries), 0, scenario.encounterSeconds);
|
||||
}
|
||||
}
|
||||
const ordered = events
|
||||
@@ -1676,7 +1918,19 @@ export function evaluateMechanicProgram(
|
||||
.sort((left, right) => left.event.at - right.event.at || left.index - right.index)
|
||||
.map(({ event }) => event);
|
||||
|
||||
for (const event of ordered) {
|
||||
let previousWeaponHitTargetId: string | null = null;
|
||||
for (const rawEvent of ordered) {
|
||||
const event: MechanicEvent = rawEvent.kind === 'weapon_hit' && rawEvent.targetId
|
||||
? {
|
||||
...rawEvent,
|
||||
values: {
|
||||
...(rawEvent.values ?? {}),
|
||||
...(previousWeaponHitTargetId === null
|
||||
? {}
|
||||
: { previousTargetId: previousWeaponHitTargetId })
|
||||
}
|
||||
}
|
||||
: rawEvent;
|
||||
outcomes.push(...expirePools(
|
||||
event.at,
|
||||
program.clauses,
|
||||
@@ -1716,7 +1970,7 @@ export function evaluateMechanicProgram(
|
||||
.map((outcome) => outcome.statePool)
|
||||
.filter((pool): pool is string => Boolean(pool)));
|
||||
const before = new Map([...relevantPools].map((pool) => [pool, stateValue(pool, undefined, context)]));
|
||||
outcomes.push(...emitOutcomes(
|
||||
appendScheduledOutcomes(outcomes, emitOutcomes(
|
||||
clause,
|
||||
'before',
|
||||
reduction.at,
|
||||
@@ -1724,7 +1978,7 @@ export function evaluateMechanicProgram(
|
||||
before,
|
||||
context,
|
||||
continuousDeliveries
|
||||
));
|
||||
), reduction.at, scenario.encounterSeconds);
|
||||
const mutationRecords = clause.mutations.map((mutation) =>
|
||||
applyMutation(mutation, reduction.at, context, weight));
|
||||
emissions.push({
|
||||
@@ -1737,7 +1991,7 @@ export function evaluateMechanicProgram(
|
||||
if (clause.cooldownSeconds) {
|
||||
cooldowns.set(stateKey, reduction.at + clause.cooldownSeconds);
|
||||
}
|
||||
outcomes.push(...emitOutcomes(
|
||||
appendScheduledOutcomes(outcomes, emitOutcomes(
|
||||
clause,
|
||||
'after',
|
||||
reduction.at,
|
||||
@@ -1745,9 +1999,12 @@ export function evaluateMechanicProgram(
|
||||
before,
|
||||
context,
|
||||
continuousDeliveries
|
||||
));
|
||||
), reduction.at, scenario.encounterSeconds);
|
||||
}
|
||||
}
|
||||
if (event.kind === 'weapon_hit' && event.targetId) {
|
||||
previousWeaponHitTargetId = event.targetId;
|
||||
}
|
||||
}
|
||||
outcomes.push(...expirePools(
|
||||
scenario.encounterSeconds,
|
||||
@@ -1821,6 +2078,7 @@ function formatReference(reference: ValueReference): string {
|
||||
function formatExpression(expression: NumericExpression): string {
|
||||
if (typeof expression === 'number') return formatNumber(expression);
|
||||
if ('ref' in expression) return formatReference(expression.ref);
|
||||
if (expression.op === 'floor') return `FLOOR(${formatExpression(expression.value)})`;
|
||||
if (expression.op === 'clamp') {
|
||||
return `CLAMP(${formatExpression(expression.value)}, ${formatExpression(expression.min)}, ${formatExpression(expression.max)})`;
|
||||
}
|
||||
@@ -1916,7 +2174,14 @@ function formatOutcome(outcome: MechanicOutcome): string {
|
||||
const delivery = outcome.delivery === 'continuous'
|
||||
? `; DELIVER TOTAL CONTINUOUSLY OVER ${formatNumber(outcome.durationSeconds ?? 0)}s`
|
||||
: '';
|
||||
return `${outcome.phase.toUpperCase()}: APPLY ${formatExpression(outcome.value)} ${outcome.basis.toUpperCase()}${resource}${attribute}${perStack}${stackBand}${timing}${delivery} // ${outcome.label}`;
|
||||
const targeting = outcome.targeting
|
||||
? ` TO ${outcome.targeting.population.toUpperCase()} WITHIN ${formatNumber(outcome.targeting.radiusMeters)}m`
|
||||
: '';
|
||||
if (outcome.kind === 'weapon_reload') {
|
||||
const target = outcome.scope === 'equipped_weapon' ? 'EQUIPPED' : 'INACTIVE';
|
||||
return `${outcome.phase.toUpperCase()}: RELOAD ${target} WEAPON AFTER ${formatExpression(outcome.value)}s // ${outcome.label}`;
|
||||
}
|
||||
return `${outcome.phase.toUpperCase()}: APPLY ${formatExpression(outcome.value)} ${outcome.basis.toUpperCase()}${resource}${attribute}${perStack}${stackBand}${timing}${targeting}${delivery} // ${outcome.label}`;
|
||||
}
|
||||
|
||||
export function formatMechanicProgram(program: MechanicProgram): string {
|
||||
|
||||
@@ -248,7 +248,7 @@ function stageFactorAt(
|
||||
return Math.max(EPSILON, (1 + values.additive / 100) * values.multiplicative);
|
||||
}
|
||||
|
||||
function activationTimes(
|
||||
export function skillActivationTimes(
|
||||
ability: SkillAbilityInput,
|
||||
encounterSeconds: number,
|
||||
input: SkillCalculationInput,
|
||||
@@ -483,7 +483,7 @@ export function calculateSkillDps(input: SkillCalculationInput): SkillCalculatio
|
||||
};
|
||||
}
|
||||
|
||||
const activations = activationTimes(ability, encounterSeconds, input, rules);
|
||||
const activations = skillActivationTimes(ability, encounterSeconds, input, rules);
|
||||
const events = damageEvents(ability, activations, encounterSeconds, input, rules);
|
||||
const damageByEvent = events.map((event) =>
|
||||
expectedDamage(
|
||||
|
||||
@@ -42,6 +42,46 @@ describe('talent description outcomes', () => {
|
||||
expect(missingTalentDescriptionSlots('Restore {1} over time.', outcomes)).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts a numbered marker outcome for a structured timing value', () => {
|
||||
const outcomes = numberedTalentOutcomes(draft([{
|
||||
key: 'delayed-reload',
|
||||
label: 'Delayed reload',
|
||||
phase: 'after_event',
|
||||
kind: 'marker',
|
||||
scope: 'equipped_weapon',
|
||||
basis: 'flat',
|
||||
descriptionSlot: 1,
|
||||
variants: {
|
||||
I: { value: 4 },
|
||||
II: { value: 3 }
|
||||
}
|
||||
}]));
|
||||
|
||||
expect(outcomes).toHaveLength(1);
|
||||
expect(outcomes[0]).toMatchObject({ kind: 'marker', descriptionSlot: 1 });
|
||||
expect(missingTalentDescriptionSlots('Reload after {1} seconds.', outcomes)).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts a numbered weapon-reload delay', () => {
|
||||
const outcomes = numberedTalentOutcomes(draft([{
|
||||
key: 'inactive-reload',
|
||||
label: 'Inactive weapon reload',
|
||||
phase: 'after_event',
|
||||
kind: 'weapon_reload',
|
||||
scope: 'inactive_weapon',
|
||||
basis: 'flat',
|
||||
descriptionSlot: 1,
|
||||
variants: {
|
||||
I: { value: 4 },
|
||||
II: { value: 3 }
|
||||
}
|
||||
}]));
|
||||
|
||||
expect(outcomes).toHaveLength(1);
|
||||
expect(outcomes[0]).toMatchObject({ kind: 'weapon_reload', descriptionSlot: 1 });
|
||||
expect(missingTalentDescriptionSlots('Reload after {1} seconds.', outcomes)).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports exact missing slots instead of relying on outcome count', () => {
|
||||
const outcomes = numberedTalentOutcomes(draft([{
|
||||
key: 'damage',
|
||||
|
||||
@@ -7,7 +7,9 @@ const VALUE_BEARING_OUTCOME_KINDS = new Set([
|
||||
'attribute_modifier',
|
||||
'direct_damage',
|
||||
'restoration',
|
||||
'resource_cost'
|
||||
'resource_cost',
|
||||
'weapon_reload',
|
||||
'marker'
|
||||
]);
|
||||
|
||||
export const numberedTalentOutcomes = (
|
||||
|
||||
Reference in New Issue
Block a user