Adding skills & isac talent updates

This commit is contained in:
jb
2026-07-29 20:56:22 -04:00
parent f1b473ffa8
commit 65a4987cad
68 changed files with 5486 additions and 569 deletions
@@ -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.'
);
}
});
});
+192 -6
View File
@@ -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);
});
});
+43 -15
View File
@@ -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',
+14 -9
View File
@@ -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' },
+632 -3
View File
@@ -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');
});
});
+293 -28
View File
@@ -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 {
+2 -2
View File
@@ -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',
+3 -1
View File
@@ -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 = (