Files
shd.build/src/lib/calculations/mechanic.ts
T
jb 57068b61cd
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 28s
Validate and deploy staging / deploy (push) Successful in 1m3s
feat: expand ISAC content modeling
2026-08-03 01:23:20 -04:00

3579 lines
109 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
'extra_health_changed',
'movement_snapshot',
'proximity_snapshot',
'firing_started',
'firing_stopped',
'reload_started',
'reload_completed',
'weapon_swapped',
'tick',
] as const;
export type MechanicEventKind = (typeof MECHANIC_EVENT_KINDS)[number];
export type MechanicEventDefinition = {
key: MechanicEventKind;
label: string;
description: string;
fields?: readonly {
path: string;
label: string;
type: 'boolean' | 'number' | 'string';
required?: boolean;
minimum?: number;
}[];
strictFields?: boolean;
};
export const MECHANIC_EVENT_REGISTRY: Readonly<
Record<MechanicEventKind, MechanicEventDefinition>
> = {
shot_fired: {
key: 'shot_fired',
label: 'Shot fired',
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 previous target and event values distinguish target switches, headshots, critical hits, aiming state, cover state, and distance. An optional area-target sample lists valid entities by distance from the hit target.',
fields: [
{ path: 'targetId', label: 'Target ID', type: 'string' },
{
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.aiming', label: 'Aiming down sights', type: 'boolean' },
{
path: 'values.behindCover',
label: 'Agent behind cover',
type: 'boolean',
},
{
path: 'values.distanceMeters',
label: 'Target distance',
type: 'number',
},
],
},
skill_deployed: {
key: 'skill_deployed',
label: 'Skill deployed',
description:
'The Agent deploys or activates a specialization ability. An assigned-ability deployment may supply its active duration.',
fields: [
{
path: 'values.assignedAbilityDurationSeconds',
label: 'Assigned ability duration',
type: 'number',
minimum: 0,
},
],
},
signature_ability_deployed: {
key: 'signature_ability_deployed',
label: 'Signature Ability deployed',
description:
'The Agent deploys or activates their Signature Ability. An assigned-ability deployment may supply its active duration.',
fields: [
{
path: 'values.assignedAbilityDurationSeconds',
label: 'Assigned ability duration',
type: 'number',
minimum: 0,
},
],
},
skill_hit: {
key: 'skill_hit',
label: 'Skill hit',
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.assignedAbilityActive',
label: 'Assigned ability active',
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',
label: 'Agent hit',
description:
'An incoming attack connects, including hits absorbed entirely by Extra Health.',
},
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.',
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',
label: 'Damage dealt',
description:
'The Agent deals damage; source, target, distance, and critical details belong in event values.',
},
enemy_killed: {
key: 'enemy_killed',
label: 'Enemy killed',
description:
"An enemy dies. Weapon or skill source and whether the source item's assigned ability is active 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.assignedAbilityActive',
label: 'Assigned ability active',
type: 'boolean',
},
{
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. The event may identify whether the source item's assigned ability is active.",
fields: [
{
path: 'values.assignedAbilityActive',
label: 'Assigned ability active',
type: 'boolean',
},
{
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',
label: 'Healing applied',
description:
'Health is restored; source and recipient details identify giving versus receiving healing.',
},
extra_health_granted: {
key: 'extra_health_granted',
label: 'Extra Health granted',
description: 'The Agent grants Extra Health to a recipient.',
},
extra_health_changed: {
key: 'extra_health_changed',
label: 'Extra Health changed',
description:
'The Agent’s remaining Extra Health changes, including grants, reductions, and the transition to zero.',
strictFields: true,
fields: [
{
path: 'values.hasExtraHealth',
label: 'Has Extra Health',
type: 'boolean',
required: true,
},
{
path: 'values.currentExtraHealth',
label: 'Current Extra Health',
type: 'number',
minimum: 0,
},
],
},
movement_snapshot: {
key: 'movement_snapshot',
label: 'Movement snapshot',
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',
label: 'Proximity snapshot',
description:
'A sampled count or state for nearby allies, enemies, and Agent-owned buffs.',
},
firing_started: {
key: 'firing_started',
label: 'Firing started',
description: 'The equipped weapon begins an uninterrupted firing segment.',
},
firing_stopped: {
key: 'firing_stopped',
label: 'Firing stopped',
description: 'The equipped weapon ends an uninterrupted firing segment.',
},
reload_started: {
key: 'reload_started',
label: 'Reload started',
description: 'The equipped weapon begins reloading.',
fields: [
{
path: 'values.emptyMagazine',
label: 'Empty magazine',
type: 'boolean',
},
],
},
reload_completed: {
key: 'reload_completed',
label: 'Reload completed',
description: 'The equipped weapon finishes reloading.',
},
weapon_swapped: {
key: 'weapon_swapped',
label: 'Weapon swapped',
description: 'The Agent changes the equipped active weapon.',
},
tick: {
key: 'tick',
label: 'Simulation tick',
description:
'A deterministic timeline sample used by duration and periodic mechanics.',
},
};
export type Scalar = string | number | boolean | null;
export type StateScope = 'agent' | 'target';
export type CalculationMode = 'maximum' | 'custom';
export type OutcomePhase =
'on_event' | 'after_event' | 'on_enter' | 'on_exit' | 'while_active';
export type MechanicAreaTarget = {
entityId: string;
population: 'ally' | 'enemy';
distanceMeters: number;
};
export type MechanicAreaTargetSample = {
center: 'event_target';
validTargets: MechanicAreaTarget[];
};
export const MAX_MECHANIC_AREA_TARGETS = 256;
export type MechanicEvent = {
kind: MechanicEventKind;
at: number;
actorId?: string | null;
targetId?: string | null;
values?: Record<string, Scalar>;
areaTargetSample?: MechanicAreaTargetSample;
};
export type ScenarioInputDefinition = {
key: string;
label: string;
kind: 'toggle' | 'slider' | 'number';
readiness?: 'ready' | 'requires_assumption';
description?: string;
defaultValue?: number | boolean;
bestCaseValue?: number | boolean;
min?: number;
max?: number;
};
export type MechanicScenario =
| { mode: 'maximum'; encounterSeconds: number; randomSeed?: number }
| {
mode: 'custom';
encounterSeconds: number;
inputs?: Record<string, number | boolean>;
randomSeed?: number;
};
export type EntitySelector =
| { kind: 'agent' }
| { kind: 'event_actor' }
| { kind: 'event_target' }
| { kind: 'event_field'; path: string };
export type ValueReference =
| { source: 'event'; path: string }
| { source: 'scenario'; key: string }
| { source: 'combat'; path: string; timing?: 'pre_event' | 'current' }
| { source: 'attribute'; attributeId: string }
| { source: 'equipment'; path: string }
| {
source: 'assigned_ability';
path: 'active' | 'remainingDurationSeconds';
}
| { 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;
right: NumericExpression;
}
| {
op: 'clamp';
value: NumericExpression;
min: NumericExpression;
max: NumericExpression;
};
export type Predicate =
| { kind: 'true' }
| { kind: 'exists'; value: ValueReference }
| {
kind: 'compare';
left: ValueReference | Scalar;
operator: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte';
right: ValueReference | Scalar;
}
| { kind: 'all'; predicates: Predicate[] }
| { kind: 'any'; predicates: Predicate[] }
| { kind: 'not'; predicate: Predicate };
export type Reducer =
| { kind: 'none' }
| {
kind: 'count';
threshold: number;
reset?: 'zero' | 'subtract' | 'never';
groupBy?: ValueReference;
}
| { kind: 'modulo'; every: number; groupBy?: ValueReference }
| {
kind: 'distinct_count';
threshold: number;
windowSeconds: number;
distinctBy: ValueReference;
}
| { kind: 'consecutive_count'; threshold: number; groupBy?: ValueReference }
| {
kind: 'sliding_count';
threshold: number;
windowSeconds: number;
reset?: 'zero' | 'subtract' | 'never';
groupBy?: ValueReference;
}
| {
kind: 'duration';
seconds: number;
repeat?: boolean;
groupBy?: ValueReference;
}
| {
kind: 'periodic';
intervalSeconds: NumericExpression;
immediate?: boolean;
groupBy?: ValueReference;
}
| { kind: 'derived_scalar'; value: NumericExpression };
export type PoolDefinition = {
key: string;
label: string;
scope: StateScope;
initial: number;
min?: number;
max?: number;
bestCaseValue?: number;
expiresAfterSeconds?: number;
};
type PoolMutationBase = {
pool: string;
entity?: EntitySelector;
};
export type ClauseMutation =
| { kind: 'none' }
| (PoolMutationBase & {
kind: 'add' | 'set' | 'consume' | 'derive';
value: NumericExpression;
})
| (PoolMutationBase & { kind: 'clear' })
| {
kind: 'transfer';
pool: string;
from: EntitySelector;
to: EntitySelector;
value?: NumericExpression | 'all';
clearSource?: boolean;
}
| {
kind: 'extend_duration';
target: 'assigned_ability';
seconds: NumericExpression;
}
| {
kind: 'start_cooldown_on_max';
stackPool: string;
cooldownPool: string;
};
export type OutcomeBasis =
| 'flat'
| 'max_health_percent'
| 'current_health_percent'
| 'weapon_base_percent';
export type MechanicOutcomeScope =
| 'self'
| 'equipped_weapon'
| 'inactive_weapon'
| 'target'
| 'allies'
| 'self_and_allies'
| 'assigned_ability'
| 'all_skills';
export type MechanicResource =
'health' | 'armor' | 'ammo' | 'signature_ability_charge';
export type MechanicOutcomeTargeting =
| {
kind: 'within_radius';
population: 'allies' | 'enemies';
radiusMeters: number;
}
| {
kind: 'event_target_area';
center: 'event_target';
validTargets: 'enemies';
radiusMeters: number;
};
export type MechanicOutcome = {
id: string;
label: string;
phase: OutcomePhase;
kind:
| 'attribute_modifier'
| 'direct_damage'
| 'restoration'
| 'resource_cost'
| 'weapon_reload'
| 'marker';
scope: MechanicOutcomeScope;
value: NumericExpression;
basis: OutcomeBasis;
resource?: MechanicResource;
delivery?: 'instant' | 'continuous';
durationSeconds?: number;
combatTiming?: 'pre_event' | 'current';
attributeId?: string;
statePool?: string;
perStack?: boolean;
minStacks?: number;
maxStacks?: number;
targeting?: MechanicOutcomeTargeting;
probabilityPercent?: number;
};
export type MechanicClause = {
id: string;
label?: string;
recipeKey?: string;
when: MechanicEventKind;
if?: Predicate;
reducer: Reducer;
cooldownSeconds?: number;
weight?: NumericExpression;
procChancePercent?: number;
requiresAssignedAbilityActive?: boolean;
mutations: ClauseMutation[];
outcomes?: MechanicOutcome[];
};
export type MechanicProgram = {
id: string;
name: string;
scenarioInputs?: ScenarioInputDefinition[];
pools?: PoolDefinition[];
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;
entityId: string;
value: number;
expiresAt: number | null;
};
export type MutationRecord = {
kind: ClauseMutation['kind'];
pool?: string;
entityId?: string;
before?: number;
after?: number;
targetEntityId?: string;
targetBefore?: number;
targetAfter?: number;
};
export type MechanicEmission = {
clauseId: string;
at: number;
weight: number;
reducerValue: number;
mutations: MutationRecord[];
};
export type MechanicOutcomeEmission = {
clauseId: string;
outcomeId: string;
at: number;
targetId: string | null;
phase: OutcomePhase;
kind: MechanicOutcome['kind'];
scope: MechanicOutcome['scope'];
resource: MechanicResource | null;
attributeId: string | null;
basis: OutcomeBasis;
combatTiming: 'pre_event' | 'current';
value: number;
resolvedValue: number;
appliedValue: number;
weight: number;
stateValue: number;
combatBefore: MechanicCombatContext | null;
combatAfter: MechanicCombatContext | null;
delivery: 'instant' | 'continuous';
deliveryStart: number | null;
deliveryEnd: number | null;
totalResolvedValue: number;
targeting: MechanicOutcomeTargeting | null;
probabilityPercent: number | null;
probabilityRoll: number | null;
};
export type MechanicEvaluation = {
status: 'OK' | 'INVALID';
errors: string[];
programId: string;
mode: CalculationMode;
scenarioInputs: Record<string, number | boolean>;
readiness: {
status: 'READY' | 'REQUIRES_ASSUMPTIONS';
missingInputs: string[];
};
emissions: MechanicEmission[];
outcomes: MechanicOutcomeEmission[];
finalState: PoolSnapshot[];
finalCombat: MechanicCombatContext | null;
equipment: MechanicEquipmentContext | null;
estimatedActivationCount: number;
randomSeed: number | null;
script: string;
};
export type MechanicCombatContext = {
maxHealth: number;
currentHealth: number;
maxArmor?: number;
currentArmor?: number;
maxAmmo?: number;
currentAmmo?: number;
maxSignatureAbilityCharge?: number;
currentSignatureAbilityCharge?: number;
weaponBaseDamage?: number;
attributes?: Record<string, number>;
values?: Record<string, Scalar>;
};
export type MechanicEquipmentContext = {
protocolTalentUpgrades?: number;
assignedAbility?: {
active: boolean;
remainingDurationSeconds: number;
};
values?: Record<string, Scalar>;
};
export type ValidationResult<T> =
{ ok: true; value: T } | { ok: false; errors: string[] };
type RuntimePool = PoolSnapshot;
type RuntimeAssignedAbility = {
active: boolean;
expiresAt: number | null;
};
type EvaluationContext = {
event: MechanicEvent;
scenario: Record<string, number | boolean>;
reducerValue: number;
pools: Map<string, RuntimePool>;
poolDefinitions: Map<string, PoolDefinition>;
combat: MechanicCombatContext | null;
preEventCombat: MechanicCombatContext | null;
equipment: MechanicEquipmentContext | null;
assignedAbility: RuntimeAssignedAbility;
mode: CalculationMode;
random: DeterministicRandom;
runtimeErrors: string[];
};
type ContinuousDelivery = {
clauseId: string;
outcomeId: string;
targetId: string | null;
phase: OutcomePhase;
scope: MechanicOutcomeScope;
resource: MechanicResource;
attributeId: string | null;
basis: OutcomeBasis;
combatTiming: 'pre_event' | 'current';
value: number;
totalResolvedValue: number;
weight: number;
stateValue: number;
start: number;
end: number;
appliedThrough: number;
targeting: MechanicOutcomeTargeting | null;
probabilityPercent: number | null;
probabilityRoll: number | null;
};
type DeterministicRandom = {
next: () => number;
};
type ReducerState = {
count: number;
timestamps: number[];
distinctTimestamps: Map<string, number>;
previousGroup: string | null;
startedAt: number | null;
lastPeriodicIndex: number;
durationEmitted: boolean;
};
type ReducerEmission = { at: number; value: number };
const AGENT_ENTITY = 'agent';
const DEFAULT_TARGET_ENTITY = 'target';
const EPSILON = 0.000_001;
const finiteNumber = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);
const objectValue = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const formatNumber = (value: number) =>
Number.isInteger(value) ? String(value) : String(Number(value.toFixed(3)));
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
function hashSeed(value: string) {
let hash = 2_166_136_261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16_777_619);
}
return hash >>> 0;
}
function deterministicRandom(seed: number): DeterministicRandom {
let state = seed >>> 0;
return {
next: () => {
state = (state + 0x6d2b79f5) >>> 0;
let value = state;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
},
};
}
function getPath(source: unknown, path: string): unknown {
let current = source;
for (const segment of path.split('.').filter(Boolean)) {
if (typeof current !== 'object' || current === null) return undefined;
current = (current as Record<string, unknown>)[segment];
}
return current;
}
function entityId(
selector: EntitySelector | undefined,
event: MechanicEvent,
scope: StateScope,
): string | null {
if (!selector)
return scope === 'agent' ? AGENT_ENTITY : (event.targetId ?? null);
switch (selector.kind) {
case 'agent':
return AGENT_ENTITY;
case 'event_actor':
return event.actorId ?? AGENT_ENTITY;
case 'event_target':
return event.targetId ?? null;
case 'event_field': {
const value = getPath(event, selector.path);
return typeof value === 'string' && value.trim() ? value : null;
}
}
}
function poolStorageKey(pool: string, entity: string) {
return `${pool}\u0000${entity}`;
}
function stateValue(
pool: string,
selector: EntitySelector | undefined,
context: EvaluationContext,
): number {
const definition = context.poolDefinitions.get(pool);
if (!definition) return 0;
const entity = entityId(selector, context.event, definition.scope);
if (!entity) return definition.initial;
return (
context.pools.get(poolStorageKey(pool, entity))?.value ?? definition.initial
);
}
function resolveReference(
reference: ValueReference,
context: EvaluationContext,
): unknown {
switch (reference.source) {
case 'event':
return getPath(context.event, reference.path);
case 'scenario':
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':
return context.combat?.attributes?.[reference.attributeId];
case 'equipment':
return getPath(context.equipment, reference.path);
case 'assigned_ability': {
const remaining = Math.max(
0,
(context.assignedAbility.expiresAt ?? context.event.at) -
context.event.at,
);
if (reference.path === 'active')
return context.assignedAbility.active && remaining > EPSILON;
return remaining;
}
case 'reducer':
return context.reducerValue;
case 'pool':
return stateValue(reference.pool, reference.entity, context);
}
}
function numericValue(
expression: NumericExpression,
context: EvaluationContext,
): number {
if (typeof expression === 'number') return expression;
if ('ref' in expression) {
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),
numericValue(expression.min, context),
numericValue(expression.max, context),
);
}
const left = numericValue(expression.left, context);
const right = numericValue(expression.right, context);
switch (expression.op) {
case 'add':
return left + right;
case 'subtract':
return left - right;
case 'multiply':
return left * right;
case 'divide':
return Math.abs(right) <= EPSILON ? 0 : left / right;
case 'min':
return Math.min(left, right);
case 'max':
return Math.max(left, right);
}
}
function operandValue(
operand: ValueReference | Scalar,
context: EvaluationContext,
): unknown {
if (typeof operand === 'object' && operand !== null && 'source' in operand) {
return resolveReference(operand, context);
}
return operand;
}
function predicateMatches(
predicate: Predicate | undefined,
context: EvaluationContext,
): boolean {
if (!predicate || predicate.kind === 'true') return true;
if (predicate.kind === 'exists') {
const value = resolveReference(predicate.value, context);
return value !== undefined && value !== null;
}
if (predicate.kind === 'all')
return predicate.predicates.every((item) =>
predicateMatches(item, context),
);
if (predicate.kind === 'any')
return predicate.predicates.some((item) => predicateMatches(item, context));
if (predicate.kind === 'not')
return !predicateMatches(predicate.predicate, context);
const left = operandValue(predicate.left, context);
const right = operandValue(predicate.right, context);
switch (predicate.operator) {
case 'eq':
return left === right;
case 'ne':
return left !== right;
case 'gt':
return (
typeof left === 'number' && typeof right === 'number' && left > right
);
case 'gte':
return (
typeof left === 'number' && typeof right === 'number' && left >= right
);
case 'lt':
return (
typeof left === 'number' && typeof right === 'number' && left < right
);
case 'lte':
return (
typeof left === 'number' && typeof right === 'number' && left <= right
);
}
}
function scenarioValues(
program: MechanicProgram,
scenario: MechanicScenario,
): { values: Record<string, number | boolean>; missing: string[] } {
const supplied = scenario.mode === 'custom' ? (scenario.inputs ?? {}) : {};
const values: Record<string, number | boolean> = {};
const missing: string[] = [];
for (const input of program.scenarioInputs ?? []) {
const value =
scenario.mode === 'maximum'
? input.bestCaseValue
: (supplied[input.key] ?? input.defaultValue);
if (value === undefined) {
missing.push(input.key);
continue;
}
values[input.key] = value;
}
return { values, missing };
}
function freshReducerState(): ReducerState {
return {
count: 0,
timestamps: [],
distinctTimestamps: new Map(),
previousGroup: null,
startedAt: null,
lastPeriodicIndex: -1,
durationEmitted: false,
};
}
function reducerGroup(reducer: Reducer, context: EvaluationContext): string {
if (!('groupBy' in reducer) || !reducer.groupBy) return 'global';
const value = resolveReference(reducer.groupBy, context);
return value === undefined || value === null ? 'missing' : String(value);
}
function resetCount(
state: ReducerState,
threshold: number,
reset: 'zero' | 'subtract' | 'never',
) {
if (reset === 'zero') state.count = 0;
else if (reset === 'subtract')
state.count = Math.max(0, state.count - threshold);
}
function reduceEvent(
reducer: Reducer,
matched: boolean,
event: MechanicEvent,
context: EvaluationContext,
state: ReducerState,
): ReducerEmission[] {
if (reducer.kind === 'none')
return matched ? [{ at: event.at, value: 1 }] : [];
if (reducer.kind === 'derived_scalar') {
return matched
? [{ at: event.at, value: numericValue(reducer.value, context) }]
: [];
}
if (reducer.kind === 'count') {
if (!matched) return [];
state.count += 1;
if ((reducer.reset ?? 'zero') === 'never' && state.durationEmitted)
return [];
if (state.count < reducer.threshold) return [];
const value = state.count;
if ((reducer.reset ?? 'zero') === 'never') state.durationEmitted = true;
resetCount(state, reducer.threshold, reducer.reset ?? 'zero');
return [{ at: event.at, value }];
}
if (reducer.kind === 'modulo') {
if (!matched) return [];
state.count += 1;
return state.count % reducer.every === 0
? [{ at: event.at, value: state.count }]
: [];
}
if (reducer.kind === 'distinct_count') {
if (!matched) return [];
const value = resolveReference(reducer.distinctBy, context);
if (value === undefined || value === null) return [];
for (const [key, at] of state.distinctTimestamps) {
if (event.at - at > reducer.windowSeconds + EPSILON)
state.distinctTimestamps.delete(key);
}
state.distinctTimestamps.set(String(value), event.at);
if (state.distinctTimestamps.size < reducer.threshold) return [];
const count = state.distinctTimestamps.size;
state.distinctTimestamps.clear();
return [{ at: event.at, value: count }];
}
if (reducer.kind === 'consecutive_count') {
if (!matched) {
state.count = 0;
state.previousGroup = null;
return [];
}
const value = reducer.groupBy
? resolveReference(reducer.groupBy, context)
: 'global';
if (value === undefined || value === null) {
state.count = 0;
state.previousGroup = null;
return [];
}
const group = String(value);
state.count = state.previousGroup === group ? state.count + 1 : 1;
state.previousGroup = group;
if (state.count < reducer.threshold) return [];
const count = state.count;
state.count = 0;
return [{ at: event.at, value: count }];
}
if (reducer.kind === 'sliding_count') {
if (!matched) return [];
state.timestamps = state.timestamps.filter(
(at) => event.at - at <= reducer.windowSeconds + EPSILON,
);
state.timestamps.push(event.at);
state.count = state.timestamps.length;
if ((reducer.reset ?? 'zero') === 'never' && state.durationEmitted)
return [];
if (state.count < reducer.threshold) return [];
const value = state.count;
const reset = reducer.reset ?? 'zero';
if (reset === 'never') state.durationEmitted = true;
if (reset === 'zero') state.timestamps = [];
else if (reset === 'subtract')
state.timestamps.splice(0, reducer.threshold);
state.count = state.timestamps.length;
return [{ at: event.at, value }];
}
if (reducer.kind === 'duration') {
if (!matched) {
state.startedAt = null;
state.durationEmitted = false;
return [];
}
if (state.startedAt === null) state.startedAt = event.at;
const elapsed = event.at - state.startedAt;
if (elapsed + EPSILON < reducer.seconds) return [];
if (!reducer.repeat && state.durationEmitted) return [];
const index = Math.floor((elapsed + EPSILON) / reducer.seconds);
if (reducer.repeat && index <= state.lastPeriodicIndex) return [];
state.durationEmitted = true;
state.lastPeriodicIndex = index;
return [{ at: event.at, value: elapsed }];
}
if (!matched) {
state.startedAt = null;
state.lastPeriodicIndex = -1;
return [];
}
if (state.startedAt === null) {
state.startedAt = event.at;
state.lastPeriodicIndex = reducer.immediate ? 0 : -1;
return reducer.immediate ? [{ at: event.at, value: 1 }] : [];
}
const intervalSeconds = numericValue(reducer.intervalSeconds, context);
if (!finiteNumber(intervalSeconds) || intervalSeconds <= 0) return [];
const elapsed = event.at - state.startedAt;
const index = Math.floor((elapsed + EPSILON) / intervalSeconds);
const firstIndex = Math.max(state.lastPeriodicIndex + 1, 1);
const emissions: ReducerEmission[] = [];
for (let current = firstIndex; current <= index; current += 1) {
emissions.push({
at: state.startedAt + current * intervalSeconds,
value: current,
});
}
state.lastPeriodicIndex = Math.max(state.lastPeriodicIndex, index);
return emissions;
}
function setPoolValue(
definition: PoolDefinition,
entity: string,
value: number,
at: number,
pools: Map<string, RuntimePool>,
): { before: number; after: number } {
const key = poolStorageKey(definition.key, entity);
const current = pools.get(key);
const before = current?.value ?? definition.initial;
const after = clamp(
Number.isFinite(value) ? value : before,
definition.min ?? Number.NEGATIVE_INFINITY,
definition.max ?? Number.POSITIVE_INFINITY,
);
pools.set(key, {
pool: definition.key,
scope: definition.scope,
entityId: entity,
value: after,
expiresAt:
definition.expiresAfterSeconds === undefined
? null
: at + definition.expiresAfterSeconds,
});
return { before, after };
}
function applyMutation(
mutation: ClauseMutation,
at: number,
context: EvaluationContext,
weight = 1,
): MutationRecord {
if (mutation.kind === 'none') return { kind: 'none' };
if (mutation.kind === 'extend_duration') {
const before = Math.max(0, (context.assignedAbility.expiresAt ?? at) - at);
if (!context.assignedAbility.active || before <= EPSILON) {
context.assignedAbility.active = false;
context.assignedAbility.expiresAt = null;
return { kind: mutation.kind };
}
const seconds = numericValue(mutation.seconds, context);
if (seconds <= 0) return { kind: mutation.kind, before, after: before };
context.assignedAbility.expiresAt =
(context.assignedAbility.expiresAt as number) +
seconds * clamp(weight, 0, 1);
return {
kind: mutation.kind,
before,
after: Math.max(0, context.assignedAbility.expiresAt - at),
};
}
if (mutation.kind === 'start_cooldown_on_max') return { kind: mutation.kind };
const definition = context.poolDefinitions.get(mutation.pool);
if (!definition) return { kind: mutation.kind, pool: mutation.pool };
const mutationWeight = clamp(weight, 0, 1);
if (mutation.kind === 'transfer') {
const from = entityId(mutation.from, context.event, definition.scope);
const to = entityId(mutation.to, context.event, definition.scope);
if (!from || !to) return { kind: 'transfer', pool: mutation.pool };
const sourceBefore = stateValue(mutation.pool, mutation.from, context);
const targetBefore = stateValue(mutation.pool, mutation.to, context);
const requested =
mutation.value === undefined || mutation.value === 'all'
? sourceBefore
: numericValue(mutation.value, context);
const amount = clamp(requested, 0, sourceBefore) * mutationWeight;
const sourceAfter =
mutation.clearSource === false ? sourceBefore : sourceBefore - amount;
const targetAfter = targetBefore + amount;
if (mutationWeight > 0) {
setPoolValue(definition, from, sourceAfter, at, context.pools);
setPoolValue(definition, to, targetAfter, at, context.pools);
}
return {
kind: 'transfer',
pool: mutation.pool,
entityId: from,
before: sourceBefore,
after:
mutationWeight > 0
? stateValue(mutation.pool, mutation.from, context)
: sourceBefore,
targetEntityId: to,
targetBefore,
targetAfter:
mutationWeight > 0
? stateValue(mutation.pool, mutation.to, context)
: targetBefore,
};
}
const entity = entityId(mutation.entity, context.event, definition.scope);
if (!entity) return { kind: mutation.kind, pool: mutation.pool };
const before = stateValue(mutation.pool, mutation.entity, context);
let requested = before;
if (mutation.kind === 'clear') requested = definition.initial;
else {
const value = numericValue(mutation.value, context);
if (mutation.kind === 'add') requested = before + value;
else if (mutation.kind === 'consume') requested = before - value;
else requested = value;
}
const weightedRequest = before + (requested - before) * mutationWeight;
const changed =
mutationWeight > 0
? setPoolValue(definition, entity, weightedRequest, at, context.pools)
: { before, after: before };
return {
kind: mutation.kind,
pool: mutation.pool,
entityId: entity,
before: changed.before,
after: changed.after,
};
}
function startCooldownOnMax(
mutation: Extract<ClauseMutation, { kind: 'start_cooldown_on_max' }>,
context: EvaluationContext,
priorMutations: MutationRecord[],
at: number,
): MutationRecord {
const stackDefinition = context.poolDefinitions.get(mutation.stackPool);
const cooldownDefinition = context.poolDefinitions.get(mutation.cooldownPool);
const reachedMaximum = priorMutations.some(
(record) =>
record.pool === mutation.stackPool &&
record.before !== undefined &&
record.after !== undefined &&
record.before < (stackDefinition?.max ?? Number.POSITIVE_INFINITY) &&
record.after >= (stackDefinition?.max ?? Number.POSITIVE_INFINITY),
);
if (!reachedMaximum || !cooldownDefinition)
return { kind: mutation.kind, pool: mutation.cooldownPool };
const entity =
cooldownDefinition.scope === 'agent'
? AGENT_ENTITY
: context.event.targetId;
if (!entity) return { kind: mutation.kind, pool: mutation.cooldownPool };
const changed = setPoolValue(
cooldownDefinition,
entity,
1,
at,
context.pools,
);
return {
kind: mutation.kind,
pool: mutation.cooldownPool,
entityId: entity,
before: changed.before,
after: changed.after,
};
}
function outcomeState(
outcome: MechanicOutcome,
context: EvaluationContext,
): number {
return outcome.statePool
? stateValue(outcome.statePool, undefined, context)
: 1;
}
function phaseMatches(
phase: OutcomePhase,
stateBefore: number,
stateAfter: number,
) {
if (phase === 'on_event' || phase === 'after_event') return true;
if (phase === 'on_enter') return stateBefore <= 0 && stateAfter > 0;
if (phase === 'on_exit') return stateBefore > 0 && stateAfter <= 0;
return stateAfter > 0;
}
function copyCombat(
combat: MechanicCombatContext | null,
): MechanicCombatContext | null {
return combat
? {
...combat,
attributes: combat.attributes ? { ...combat.attributes } : undefined,
values: combat.values ? { ...combat.values } : undefined,
}
: null;
}
function resolveOutcomeValue(
outcome: MechanicOutcome,
value: number,
context: EvaluationContext,
): number {
if (outcome.basis === 'flat') return value;
const combat =
outcome.combatTiming === 'pre_event'
? context.preEventCombat
: context.combat;
if (!combat) return 0;
if (outcome.basis === 'max_health_percent')
return (combat.maxHealth * value) / 100;
if (outcome.basis === 'current_health_percent')
return (combat.currentHealth * value) / 100;
return ((combat.weaponBaseDamage ?? 0) * value) / 100;
}
function outcomeTargetIds(
clause: MechanicClause,
outcome: MechanicOutcome,
context: EvaluationContext,
): Array<string | null> | null {
if (outcome.targeting?.kind !== 'event_target_area') {
return [context.event.targetId ?? null];
}
const sample = context.event.areaTargetSample;
if (!context.event.targetId || !sample) {
const error = `Clause "${clause.id}" outcome "${outcome.id}" requires an authoritative event-target area sample.`;
if (!context.runtimeErrors.includes(error))
context.runtimeErrors.push(error);
return null;
}
return sample.validTargets
.filter(
(target) =>
target.population === 'enemy' &&
target.distanceMeters <= outcome.targeting!.radiusMeters,
)
.map((target) => target.entityId)
.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
}
function outcomeResource(outcome: MechanicOutcome): MechanicResource {
return outcome.resource ?? 'health';
}
function combatResource(
combat: MechanicCombatContext,
resource: MechanicResource,
): {
current: number;
maximum: number;
setCurrent: (value: number) => void;
} | null {
if (resource === 'health') {
return {
current: combat.currentHealth,
maximum: combat.maxHealth,
setCurrent: (value) => {
combat.currentHealth = value;
},
};
}
if (
resource === 'armor' &&
finiteNumber(combat.currentArmor) &&
finiteNumber(combat.maxArmor)
) {
return {
current: combat.currentArmor,
maximum: combat.maxArmor,
setCurrent: (value) => {
combat.currentArmor = value;
},
};
}
if (
resource === 'ammo' &&
finiteNumber(combat.currentAmmo) &&
finiteNumber(combat.maxAmmo)
) {
return {
current: combat.currentAmmo,
maximum: combat.maxAmmo,
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;
}
function emitOutcomes(
clause: MechanicClause,
phase: 'before' | 'after',
at: number,
weight: number,
before: Map<string, number>,
context: EvaluationContext,
continuousDeliveries?: ContinuousDelivery[],
): MechanicOutcomeEmission[] {
const emissions: MechanicOutcomeEmission[] = [];
const stateValues = (outcome: MechanicOutcome) => {
const current = outcomeState(outcome, context);
const previous = outcome.statePool
? (before.get(outcome.statePool) ?? current)
: current;
return {
current,
previous,
effective: outcome.phase === 'on_exit' ? previous : current,
};
};
const eligible = (clause.outcomes ?? [])
.filter((outcome) => {
if (phase === 'before' && outcome.phase !== 'on_event') return false;
if (phase === 'after' && outcome.phase === 'on_event') return false;
const { current, previous, effective } = stateValues(outcome);
if (!phaseMatches(outcome.phase, previous, current)) return false;
if (outcome.minStacks !== undefined && effective < outcome.minStacks)
return false;
if (outcome.maxStacks !== undefined && effective > outcome.maxStacks)
return false;
return true;
})
.sort((left, right) => {
const priority = (outcome: MechanicOutcome) =>
outcome.kind === 'resource_cost'
? 0
: outcome.kind === 'restoration'
? 2
: 1;
return priority(left) - priority(right);
});
for (const outcome of eligible) {
const { effective: current } = stateValues(outcome);
const probabilityPercent = outcome.probabilityPercent ?? null;
const probabilityRoll =
probabilityPercent === null
? null
: context.mode === 'maximum'
? 0
: context.random.next() * 100;
if (
probabilityPercent !== null &&
(probabilityPercent <= 0 ||
(context.mode !== 'maximum' &&
(probabilityRoll as number) >= probabilityPercent))
) {
continue;
}
const baseValue = numericValue(outcome.value, context);
const value = outcome.perStack ? baseValue * current : baseValue;
const resolvedValue = resolveOutcomeValue(outcome, value, context);
const combatBefore = copyCombat(context.combat);
let appliedValue = resolvedValue * weight;
const resource =
outcome.kind === 'restoration' || outcome.kind === 'resource_cost'
? outcomeResource(outcome)
: null;
const targetResource =
context.combat && resource
? combatResource(context.combat, resource)
: null;
if (outcome.kind === 'restoration' && outcome.delivery === 'continuous') {
const durationSeconds = outcome.durationSeconds as number;
continuousDeliveries?.push({
clauseId: clause.id,
outcomeId: outcome.id,
targetId: context.event.targetId ?? null,
phase: outcome.phase,
scope: outcome.scope,
resource: outcomeResource(outcome),
attributeId: outcome.attributeId ?? null,
basis: outcome.basis,
combatTiming: outcome.combatTiming ?? 'current',
value,
totalResolvedValue: Math.max(0, resolvedValue * weight),
weight,
stateValue: current,
start: at,
end: at + durationSeconds,
appliedThrough: at,
targeting: outcome.targeting ?? null,
probabilityPercent,
probabilityRoll,
});
continue;
}
if (outcome.kind === 'resource_cost' && targetResource) {
appliedValue = Math.min(
targetResource.current,
Math.max(0, appliedValue),
);
targetResource.setCurrent(targetResource.current - appliedValue);
} else if (outcome.kind === 'restoration' && targetResource) {
appliedValue = Math.min(
targetResource.maximum - targetResource.current,
Math.max(0, appliedValue),
);
targetResource.setCurrent(targetResource.current + appliedValue);
} else if (
(outcome.kind === 'resource_cost' || outcome.kind === 'restoration') &&
!targetResource
) {
appliedValue = 0;
}
const targetIds = outcomeTargetIds(clause, outcome, context);
if (!targetIds) continue;
const emittedAt =
outcome.kind === 'weapon_reload' ? at + Math.max(0, appliedValue) : at;
for (const targetId of targetIds) {
emissions.push({
clauseId: clause.id,
outcomeId: outcome.id,
at: emittedAt,
targetId,
phase: outcome.phase,
kind: outcome.kind,
scope: outcome.scope,
resource,
attributeId: outcome.attributeId ?? null,
basis: outcome.basis,
combatTiming: outcome.combatTiming ?? 'current',
value,
resolvedValue,
appliedValue,
weight,
stateValue: current,
combatBefore,
combatAfter: copyCombat(context.combat),
delivery: 'instant',
deliveryStart: null,
deliveryEnd: null,
totalResolvedValue: resolvedValue * weight,
targeting: outcome.targeting ?? null,
probabilityPercent,
probabilityRoll,
});
}
}
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[],
combat: MechanicCombatContext | null,
): MechanicOutcomeEmission[] {
const emissions: MechanicOutcomeEmission[] = [];
for (const delivery of deliveries) {
const through = Math.min(at, delivery.end);
const elapsed = through - delivery.appliedThrough;
if (elapsed <= EPSILON) continue;
const duration = delivery.end - delivery.start;
const resolvedValue = (delivery.totalResolvedValue * elapsed) / duration;
const combatBefore = copyCombat(combat);
const targetResource = combat
? combatResource(combat, delivery.resource)
: null;
let appliedValue = 0;
if (targetResource) {
appliedValue = Math.min(
targetResource.maximum - targetResource.current,
Math.max(0, resolvedValue),
);
targetResource.setCurrent(targetResource.current + appliedValue);
}
delivery.appliedThrough = through;
emissions.push({
clauseId: delivery.clauseId,
outcomeId: delivery.outcomeId,
at: through,
targetId: delivery.targetId,
phase: delivery.phase,
kind: 'restoration',
scope: delivery.scope,
resource: delivery.resource,
attributeId: delivery.attributeId,
basis: delivery.basis,
combatTiming: delivery.combatTiming,
value: delivery.value,
resolvedValue,
appliedValue,
weight: delivery.weight,
stateValue: delivery.stateValue,
combatBefore,
combatAfter: copyCombat(combat),
delivery: 'continuous',
deliveryStart: delivery.start,
deliveryEnd: delivery.end,
totalResolvedValue: delivery.totalResolvedValue,
targeting: delivery.targeting,
probabilityPercent: delivery.probabilityPercent,
probabilityRoll: delivery.probabilityRoll,
});
}
for (let index = deliveries.length - 1; index >= 0; index -= 1) {
if (deliveries[index].appliedThrough >= deliveries[index].end - EPSILON) {
deliveries.splice(index, 1);
}
}
return emissions;
}
function expirePools(
at: number,
clauses: MechanicClause[],
scenario: Record<string, number | boolean>,
pools: Map<string, RuntimePool>,
definitions: Map<string, PoolDefinition>,
combat: MechanicCombatContext | null,
equipment: MechanicEquipmentContext | null,
assignedAbility: RuntimeAssignedAbility,
mode: CalculationMode,
random: DeterministicRandom,
continuousDeliveries: ContinuousDelivery[],
runtimeErrors: string[],
): MechanicOutcomeEmission[] {
const expired = [...pools.entries()]
.filter(
([, state]) =>
state.expiresAt !== null && state.expiresAt <= at + EPSILON,
)
.sort(
(left, right) =>
(left[1].expiresAt ?? 0) - (right[1].expiresAt ?? 0) ||
left[0].localeCompare(right[0]),
);
const emissions: MechanicOutcomeEmission[] = [];
let index = 0;
while (index < expired.length) {
const expiryAt = expired[index][1].expiresAt as number;
emissions.push(
...advanceContinuousDeliveries(expiryAt, continuousDeliveries, combat),
);
const group: typeof expired = [];
while (
index < expired.length &&
Math.abs((expired[index][1].expiresAt as number) - expiryAt) <= EPSILON
) {
group.push(expired[index]);
index += 1;
}
const preEventCombat = copyCombat(combat);
for (const [key, state] of group) {
const definition = definitions.get(state.pool);
if (!definition) continue;
pools.set(key, { ...state, value: definition.initial, expiresAt: null });
}
for (const [, state] of group) {
const definition = definitions.get(state.pool);
if (!definition) continue;
const event: MechanicEvent = {
kind: 'tick',
at: expiryAt,
actorId: AGENT_ENTITY,
targetId:
definition.scope === 'target'
? state.entityId
: DEFAULT_TARGET_ENTITY,
};
const context: EvaluationContext = {
event,
scenario,
reducerValue: 0,
pools,
poolDefinitions: definitions,
combat,
preEventCombat,
equipment,
assignedAbility,
mode,
random,
runtimeErrors,
};
const before = new Map([[state.pool, state.value]]);
for (const clause of clauses) {
const exitOutcomes = (clause.outcomes ?? []).filter(
(outcome) =>
outcome.phase === 'on_exit' && outcome.statePool === state.pool,
);
if (!exitOutcomes.length) continue;
emissions.push(
...emitOutcomes(
{ ...clause, outcomes: exitOutcomes },
'after',
expiryAt,
1,
before,
context,
continuousDeliveries,
),
);
}
}
}
return emissions;
}
function initialPools(
definitions: Map<string, PoolDefinition>,
mode: CalculationMode,
): Map<string, RuntimePool> {
const pools = new Map<string, RuntimePool>();
for (const definition of definitions.values()) {
if (mode === 'custom' && definition.scope === 'target') continue;
const entity =
definition.scope === 'agent' ? AGENT_ENTITY : DEFAULT_TARGET_ENTITY;
const value =
mode === 'maximum'
? (definition.bestCaseValue ?? definition.max ?? definition.initial)
: definition.initial;
pools.set(poolStorageKey(definition.key, entity), {
pool: definition.key,
scope: definition.scope,
entityId: entity,
value,
expiresAt: null,
});
}
return pools;
}
function invalidEvaluation(
program: MechanicProgram,
scenario: MechanicScenario,
errors: string[],
): MechanicEvaluation {
return {
status: 'INVALID',
errors,
programId: program.id,
mode: scenario.mode,
scenarioInputs: {},
readiness: { status: 'REQUIRES_ASSUMPTIONS', missingInputs: [] },
emissions: [],
outcomes: [],
finalState: [],
finalCombat: null,
equipment: null,
estimatedActivationCount: 0,
randomSeed: null,
script: formatMechanicProgram(program),
};
}
function validateReference(
reference: ValueReference,
pools: Set<string>,
inputs: Set<string>,
errors: string[],
path: string,
) {
if (reference.source === 'pool' && !pools.has(reference.pool)) {
errors.push(`${path} references unknown pool "${reference.pool}".`);
}
if (reference.source === 'scenario' && !inputs.has(reference.key)) {
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') {
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.`);
if (
reference.source === 'assigned_ability' &&
!['active', 'remainingDurationSeconds'].includes(reference.path)
) {
errors.push(`${path} references an unsupported assigned-ability field.`);
}
}
function validateStrictEventReferences(
value: unknown,
eventKind: MechanicEventKind,
errors: string[],
path: string,
) {
const definition = MECHANIC_EVENT_REGISTRY[eventKind];
if (!definition || value === null || typeof value !== 'object') return;
if (
'source' in value &&
value.source === 'event' &&
'path' in value &&
typeof value.path === 'string' &&
!definition.fields?.some((field) => field.path === value.path) &&
definition.strictFields
) {
errors.push(
`${path} references event field "${value.path}" that is not defined for ${definition.label}.`,
);
}
for (const [key, child] of Object.entries(value)) {
validateStrictEventReferences(child, eventKind, errors, `${path} ${key}`);
}
}
function containsEventReference(value: unknown, path: string): boolean {
if (!value || typeof value !== 'object') return false;
if (
'source' in value &&
value.source === 'event' &&
'path' in value &&
value.path === path
) {
return true;
}
return Object.values(value).some((child) =>
containsEventReference(child, path),
);
}
function validateStrictEventPredicate(
predicate: Predicate | undefined,
eventKind: MechanicEventKind,
errors: string[],
path: string,
) {
const definition = MECHANIC_EVENT_REGISTRY[eventKind];
if (!definition || !predicate) return;
if (predicate.kind === 'not') {
if (
eventKind === 'weapon_hit' &&
containsEventReference(predicate.predicate, 'values.aiming')
) {
errors.push(
`${path} must compare Aiming down sights explicitly instead of negating missing evidence.`,
);
}
validateStrictEventPredicate(
predicate.predicate,
eventKind,
errors,
`${path} NOT`,
);
return;
}
if (predicate.kind === 'all' || predicate.kind === 'any') {
predicate.predicates.forEach((item, index) =>
validateStrictEventPredicate(
item,
eventKind,
errors,
`${path} ${index + 1}`,
),
);
return;
}
if (predicate.kind !== 'compare') return;
const eventReference =
typeof predicate.left === 'object' && predicate.left?.source === 'event'
? predicate.left
: typeof predicate.right === 'object' &&
predicate.right?.source === 'event'
? predicate.right
: null;
const scalar =
eventReference === predicate.left ? predicate.right : predicate.left;
if (!eventReference) return;
if (
eventKind === 'weapon_hit' &&
eventReference.path === 'values.aiming' &&
(predicate.operator !== 'eq' || typeof scalar !== 'boolean')
) {
errors.push(
`${path} must compare Aiming down sights explicitly with true or false.`,
);
}
if (typeof scalar === 'object') return;
const field = definition.fields?.find(
(item) => item.path === eventReference.path,
);
if (!field || scalar === null) return;
if (typeof scalar !== field.type) {
errors.push(
`${path} must compare ${field.label} with a ${field.type} value.`,
);
}
if (field.type !== 'number' && !['eq', 'ne'].includes(predicate.operator)) {
errors.push(`${path} must use equality comparison for ${field.label}.`);
}
}
function validateMechanicEvents(events: MechanicEvent[]) {
const errors: string[] = [];
for (const [index, event] of events.entries()) {
const definition = MECHANIC_EVENT_REGISTRY[event.kind];
if (!definition) continue;
for (const field of definition.fields ?? []) {
const value = getPath(event, field.path);
const fieldPath = `Event ${index + 1} (${definition.label}) ${field.label}`;
if (value === undefined || value === null) {
if (field.required) errors.push(`${fieldPath} is required.`);
continue;
}
if (
typeof value !== field.type ||
(field.type === 'number' && !finiteNumber(value))
) {
errors.push(`${fieldPath} must be a ${field.type}.`);
continue;
}
if (
field.type === 'number' &&
field.minimum !== undefined &&
(value as number) < field.minimum
) {
errors.push(`${fieldPath} must be ${field.minimum} or greater.`);
}
}
const hasExtraHealth = getPath(event, 'values.hasExtraHealth');
const currentExtraHealth = getPath(event, 'values.currentExtraHealth');
if (
event.kind === 'extra_health_changed' &&
typeof hasExtraHealth === 'boolean' &&
finiteNumber(currentExtraHealth) &&
hasExtraHealth !== currentExtraHealth > 0
) {
errors.push(
`Event ${index + 1} (Extra Health changed) status must match its current Extra Health.`,
);
}
const sample = event.areaTargetSample as unknown;
if (sample === undefined) continue;
const samplePath = `Event ${index + 1} (${definition.label}) area-target sample`;
if (
event.kind !== 'weapon_hit' ||
!objectValue(sample) ||
sample.center !== 'event_target' ||
!Array.isArray(sample.validTargets) ||
typeof event.targetId !== 'string' ||
!event.targetId.trim()
) {
errors.push(
`${samplePath} requires a weapon-hit event target and event-target-centered valid targets.`,
);
continue;
}
if (sample.validTargets.length > MAX_MECHANIC_AREA_TARGETS) {
errors.push(
`${samplePath} cannot contain more than ${MAX_MECHANIC_AREA_TARGETS} valid targets.`,
);
continue;
}
const seen = new Set<string>();
let hasCenterTarget = false;
for (const [targetIndex, rawTarget] of sample.validTargets.entries()) {
const targetPath = `${samplePath} target ${targetIndex + 1}`;
if (
!objectValue(rawTarget) ||
typeof rawTarget.entityId !== 'string' ||
!rawTarget.entityId.trim() ||
!['ally', 'enemy'].includes(String(rawTarget.population)) ||
!finiteNumber(rawTarget.distanceMeters) ||
rawTarget.distanceMeters < 0
) {
errors.push(
`${targetPath} requires an entity ID, ally or enemy population, and a non-negative distance.`,
);
continue;
}
if (seen.has(rawTarget.entityId)) {
errors.push(`${targetPath} duplicates entity "${rawTarget.entityId}".`);
}
seen.add(rawTarget.entityId);
if (
rawTarget.entityId === event.targetId &&
rawTarget.population === 'enemy' &&
rawTarget.distanceMeters === 0
) {
hasCenterTarget = true;
}
}
if (!hasCenterTarget) {
errors.push(
`${samplePath} must include the event target as an enemy at 0 meters.`,
);
}
}
return errors;
}
export function mechanicOutcomeTargetingError(outcome: {
kind: MechanicOutcome['kind'];
scope: MechanicOutcomeScope;
phase: OutcomePhase;
targeting?: unknown;
}): string | null {
const targeting = outcome.targeting;
if (targeting === undefined) return null;
if (!objectValue(targeting)) return 'has invalid radius targeting.';
if (targeting.kind === 'within_radius') {
if (
!['allies', 'enemies'].includes(String(targeting.population)) ||
!finiteNumber(targeting.radiusMeters) ||
targeting.radiusMeters <= 0
) {
return 'has invalid radius targeting.';
}
if (
targeting.population === 'allies' &&
outcome.scope !== 'allies' &&
outcome.scope !== 'self_and_allies'
) {
return 'ally radius targeting requires allies or self-and-allies scope.';
}
if (targeting.population === 'enemies' && outcome.scope !== 'target') {
return 'enemy radius targeting requires target scope.';
}
return null;
}
if (targeting.kind !== 'event_target_area') {
return 'has invalid radius targeting.';
}
if (
Object.keys(targeting).some(
(key) =>
!['kind', 'center', 'validTargets', 'radiusMeters'].includes(key),
) ||
targeting.center !== 'event_target' ||
targeting.validTargets !== 'enemies' ||
!finiteNumber(targeting.radiusMeters) ||
targeting.radiusMeters <= 0
) {
return 'has invalid event-target area damage.';
}
if (outcome.kind !== 'direct_damage' || outcome.scope !== 'target') {
return 'event-target area damage requires target-scoped direct damage.';
}
if (outcome.phase !== 'on_event' && outcome.phase !== 'after_event') {
return 'event-target area damage requires an on-event or after-event phase.';
}
return null;
}
function visitExpression(
expression: NumericExpression,
pools: Set<string>,
inputs: Set<string>,
errors: string[],
path: string,
) {
if (typeof expression === 'number') {
if (!finiteNumber(expression)) errors.push(`${path} must be finite.`);
return;
}
if ('ref' in expression) {
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`);
visitExpression(expression.max, pools, inputs, errors, `${path} maximum`);
return;
}
visitExpression(
expression.left,
pools,
inputs,
errors,
`${path} left operand`,
);
visitExpression(
expression.right,
pools,
inputs,
errors,
`${path} right operand`,
);
}
function visitPredicate(
predicate: Predicate,
pools: Set<string>,
inputs: Set<string>,
errors: string[],
path: string,
) {
if (predicate.kind === 'true') return;
if (predicate.kind === 'exists') {
validateReference(predicate.value, pools, inputs, errors, path);
return;
}
if (predicate.kind === 'not') {
visitPredicate(predicate.predicate, pools, inputs, errors, `${path} NOT`);
return;
}
if (predicate.kind === 'all' || predicate.kind === 'any') {
if (!predicate.predicates.length)
errors.push(
`${path} ${predicate.kind.toUpperCase()} requires a predicate.`,
);
predicate.predicates.forEach((item, index) =>
visitPredicate(item, pools, inputs, errors, `${path} ${index + 1}`),
);
return;
}
if (typeof predicate.left === 'object' && predicate.left !== null) {
validateReference(
predicate.left,
pools,
inputs,
errors,
`${path} left operand`,
);
}
if (typeof predicate.right === 'object' && predicate.right !== null) {
validateReference(
predicate.right,
pools,
inputs,
errors,
`${path} right operand`,
);
}
}
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) ||
expressionUsesCombat(expression.max)
);
}
return (
expressionUsesCombat(expression.left) ||
expressionUsesCombat(expression.right)
);
}
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) ||
expressionUsesEquipment(expression.max)
);
}
return (
expressionUsesEquipment(expression.left) ||
expressionUsesEquipment(expression.right)
);
}
function predicateUsesCombat(predicate: Predicate): boolean {
if (predicate.kind === 'true') return false;
if (predicate.kind === 'exists') return predicate.value.source === 'combat';
if (predicate.kind === 'not') return predicateUsesCombat(predicate.predicate);
if (predicate.kind === 'all' || predicate.kind === 'any') {
return predicate.predicates.some(predicateUsesCombat);
}
return (
(typeof predicate.left === 'object' &&
predicate.left?.source === 'combat') ||
(typeof predicate.right === 'object' &&
predicate.right?.source === 'combat')
);
}
function predicateUsesEquipment(predicate: Predicate): boolean {
if (predicate.kind === 'true') return false;
if (predicate.kind === 'exists')
return predicate.value.source === 'equipment';
if (predicate.kind === 'not')
return predicateUsesEquipment(predicate.predicate);
if (predicate.kind === 'all' || predicate.kind === 'any') {
return predicate.predicates.some(predicateUsesEquipment);
}
return (
(typeof predicate.left === 'object' &&
predicate.left?.source === 'equipment') ||
(typeof predicate.right === 'object' &&
predicate.right?.source === 'equipment')
);
}
function programCombatRequirements(program: MechanicProgram) {
let required = false;
let weaponBaseDamage = false;
let equipment = false;
const attributeIds = new Set<string>();
const visit = (value: unknown) => {
if (!value || typeof value !== 'object') return;
if (
'source' in value &&
value.source === 'attribute' &&
'attributeId' in value &&
typeof value.attributeId === 'string'
) {
attributeIds.add(value.attributeId);
}
for (const child of Object.values(value)) visit(child);
};
visit(program);
for (const clause of program.clauses) {
if (clause.if && predicateUsesCombat(clause.if)) required = true;
if (clause.if && predicateUsesEquipment(clause.if)) equipment = true;
if (clause.weight && expressionUsesCombat(clause.weight)) required = true;
if (clause.weight && expressionUsesEquipment(clause.weight))
equipment = true;
if (
clause.reducer.kind === 'derived_scalar' &&
expressionUsesCombat(clause.reducer.value)
)
required = true;
if (
clause.reducer.kind === 'derived_scalar' &&
expressionUsesEquipment(clause.reducer.value)
)
equipment = true;
for (const mutation of clause.mutations) {
if (mutation.kind === 'extend_duration') {
if (expressionUsesCombat(mutation.seconds)) required = true;
if (expressionUsesEquipment(mutation.seconds)) equipment = true;
continue;
}
if (
'value' in mutation &&
mutation.value !== undefined &&
mutation.value !== 'all'
) {
if (expressionUsesCombat(mutation.value)) required = true;
if (expressionUsesEquipment(mutation.value)) equipment = true;
}
}
for (const outcome of clause.outcomes ?? []) {
if (outcome.basis !== 'flat' || expressionUsesCombat(outcome.value))
required = true;
if (expressionUsesEquipment(outcome.value)) equipment = true;
if (outcome.basis === 'weapon_base_percent') weaponBaseDamage = true;
}
}
if (attributeIds.size) required = true;
return { required, weaponBaseDamage, equipment, attributeIds };
}
export function validateMechanicProgram(
program: MechanicProgram,
): ValidationResult<MechanicProgram> {
const errors: string[] = [];
if (!program.id.trim()) errors.push('Program ID is required.');
if (!program.name.trim()) errors.push('Program name is required.');
const inputKeys = new Set<string>();
for (const [index, input] of (program.scenarioInputs ?? []).entries()) {
const path = `Scenario input ${index + 1}`;
if (!input.key.trim()) errors.push(`${path} requires a key.`);
if (inputKeys.has(input.key))
errors.push(`Scenario input "${input.key}" is duplicated.`);
inputKeys.add(input.key);
const required = (input.readiness ?? 'ready') === 'ready';
if (input.kind === 'toggle') {
if (
(required &&
(typeof input.defaultValue !== 'boolean' ||
typeof input.bestCaseValue !== 'boolean')) ||
(input.defaultValue !== undefined &&
typeof input.defaultValue !== 'boolean') ||
(input.bestCaseValue !== undefined &&
typeof input.bestCaseValue !== 'boolean')
) {
errors.push(`${path} toggle values must be booleans.`);
}
} else {
if (
(required &&
(!finiteNumber(input.defaultValue) ||
!finiteNumber(input.bestCaseValue))) ||
(input.defaultValue !== undefined &&
!finiteNumber(input.defaultValue)) ||
(input.bestCaseValue !== undefined &&
!finiteNumber(input.bestCaseValue))
) {
errors.push(`${path} values must be finite numbers.`);
}
if (
input.min !== undefined &&
input.max !== undefined &&
input.min > input.max
) {
errors.push(`${path} minimum cannot exceed its maximum.`);
}
for (const value of [input.defaultValue, input.bestCaseValue]) {
if (typeof value !== 'number') continue;
if (input.min !== undefined && value < input.min)
errors.push(`${path} value is below its minimum.`);
if (input.max !== undefined && value > input.max)
errors.push(`${path} value exceeds its maximum.`);
}
}
}
const poolKeys = new Set<string>();
for (const [index, pool] of (program.pools ?? []).entries()) {
const path = `Pool ${index + 1}`;
if (!pool.key.trim()) errors.push(`${path} requires a key.`);
if (poolKeys.has(pool.key))
errors.push(`Pool "${pool.key}" is duplicated.`);
poolKeys.add(pool.key);
if (!finiteNumber(pool.initial))
errors.push(`${path} initial value must be finite.`);
if (
pool.min !== undefined &&
pool.max !== undefined &&
pool.min > pool.max
) {
errors.push(`${path} minimum cannot exceed its maximum.`);
}
if (pool.min !== undefined && pool.initial < pool.min)
errors.push(`${path} initial value is below its minimum.`);
if (pool.max !== undefined && pool.initial > pool.max)
errors.push(`${path} initial value exceeds its maximum.`);
if (pool.bestCaseValue !== undefined) {
if (!finiteNumber(pool.bestCaseValue))
errors.push(`${path} best-case value must be finite.`);
if (pool.min !== undefined && pool.bestCaseValue < pool.min)
errors.push(`${path} best-case value is below its minimum.`);
if (pool.max !== undefined && pool.bestCaseValue > pool.max)
errors.push(`${path} best-case value exceeds its maximum.`);
}
if (
pool.expiresAfterSeconds !== undefined &&
(!finiteNumber(pool.expiresAfterSeconds) || pool.expiresAfterSeconds <= 0)
) {
errors.push(`${path} expiry must be greater than 0 seconds.`);
}
}
const clauseIds = 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.`);
if (clause.recipeKey !== undefined && !clause.recipeKey.trim()) {
errors.push(`${path} recipe key cannot be empty.`);
}
if (clauseIds.has(clause.id))
errors.push(`Clause "${clause.id}" is duplicated.`);
clauseIds.add(clause.id);
if (!(MECHANIC_EVENT_KINDS as readonly string[]).includes(clause.when)) {
errors.push(`${path} has an unknown event kind.`);
}
if (clause.if)
visitPredicate(
clause.if,
poolKeys,
inputKeys,
errors,
`${path} predicate`,
);
validateStrictEventReferences(
clause.if,
clause.when,
errors,
`${path} predicate`,
);
validateStrictEventPredicate(
clause.if,
clause.when,
errors,
`${path} predicate`,
);
if (clause.weight)
visitExpression(
clause.weight,
poolKeys,
inputKeys,
errors,
`${path} weight`,
);
validateStrictEventReferences(
clause.weight,
clause.when,
errors,
`${path} weight`,
);
if (
clause.cooldownSeconds !== undefined &&
(!finiteNumber(clause.cooldownSeconds) || clause.cooldownSeconds <= 0)
) {
errors.push(`${path} cooldown must be greater than 0 seconds.`);
}
if (
clause.procChancePercent !== undefined &&
(!finiteNumber(clause.procChancePercent) ||
clause.procChancePercent < 0 ||
clause.procChancePercent > 100)
) {
errors.push(`${path} proc chance must be from 0 through 100 percent.`);
}
if (
clause.requiresAssignedAbilityActive !== undefined &&
typeof clause.requiresAssignedAbilityActive !== 'boolean'
) {
errors.push(
`${path} assigned-ability activity requirement must be a boolean.`,
);
}
const reducer = clause.reducer;
if (
reducer.kind === 'count' &&
(!Number.isInteger(reducer.threshold) || reducer.threshold <= 0)
) {
errors.push(`${path} count threshold must be a positive integer.`);
}
if (
reducer.kind === 'modulo' &&
(!Number.isInteger(reducer.every) || reducer.every <= 0)
) {
errors.push(`${path} modulo interval must be a positive integer.`);
}
if (reducer.kind === 'distinct_count') {
if (!Number.isInteger(reducer.threshold) || reducer.threshold <= 0) {
errors.push(
`${path} distinct-count threshold must be a positive integer.`,
);
}
if (!finiteNumber(reducer.windowSeconds) || reducer.windowSeconds <= 0) {
errors.push(
`${path} distinct-count window must be greater than 0 seconds.`,
);
}
validateReference(
reducer.distinctBy,
poolKeys,
inputKeys,
errors,
`${path} distinct-count value`,
);
}
if (
reducer.kind === 'consecutive_count' &&
(!Number.isInteger(reducer.threshold) || reducer.threshold <= 0)
) {
errors.push(
`${path} consecutive-count threshold must be a positive integer.`,
);
}
if (reducer.kind === 'sliding_count') {
if (!Number.isInteger(reducer.threshold) || reducer.threshold <= 0) {
errors.push(
`${path} sliding-count threshold must be a positive integer.`,
);
}
if (!finiteNumber(reducer.windowSeconds) || reducer.windowSeconds <= 0) {
errors.push(`${path} sliding window must be greater than 0 seconds.`);
}
}
if (
reducer.kind === 'duration' &&
(!finiteNumber(reducer.seconds) || reducer.seconds <= 0)
) {
errors.push(`${path} duration must be greater than 0 seconds.`);
}
if (reducer.kind === 'periodic') {
visitExpression(
reducer.intervalSeconds,
poolKeys,
inputKeys,
errors,
`${path} periodic interval`,
);
if (
finiteNumber(reducer.intervalSeconds) &&
reducer.intervalSeconds <= 0
) {
errors.push(
`${path} periodic interval must be greater than 0 seconds.`,
);
}
}
if ('groupBy' in reducer && reducer.groupBy) {
validateReference(
reducer.groupBy,
poolKeys,
inputKeys,
errors,
`${path} reducer group`,
);
}
if (reducer.kind === 'derived_scalar') {
visitExpression(
reducer.value,
poolKeys,
inputKeys,
errors,
`${path} reducer value`,
);
}
validateStrictEventReferences(
reducer,
clause.when,
errors,
`${path} reducer`,
);
for (const [mutationIndex, mutation] of clause.mutations.entries()) {
if (mutation.kind === 'none') continue;
const mutationPath = `${path} mutation ${mutationIndex + 1}`;
if (mutation.kind === 'extend_duration') {
if (mutation.target !== 'assigned_ability')
errors.push(`${mutationPath} can only extend the assigned ability.`);
visitExpression(
mutation.seconds,
poolKeys,
inputKeys,
errors,
`${mutationPath} duration`,
);
continue;
}
if (mutation.kind === 'start_cooldown_on_max') {
const stack = (program.pools ?? []).find(
(pool) => pool.key === mutation.stackPool,
);
const cooldown = (program.pools ?? []).find(
(pool) => pool.key === mutation.cooldownPool,
);
if (!stack?.max || stack.max <= 0)
errors.push(`${mutationPath} requires a capped stack pool.`);
if (
!cooldown ||
cooldown.scope !== 'agent' ||
!cooldown.expiresAfterSeconds
)
errors.push(
`${mutationPath} requires an expiring Agent cooldown pool.`,
);
if (mutation.stackPool === mutation.cooldownPool)
errors.push(`${mutationPath} stack and cooldown pools must differ.`);
continue;
}
if (!poolKeys.has(mutation.pool))
errors.push(
`${mutationPath} references unknown pool "${mutation.pool}".`,
);
if (
'value' in mutation &&
mutation.value !== undefined &&
mutation.value !== 'all'
) {
visitExpression(
mutation.value,
poolKeys,
inputKeys,
errors,
`${mutationPath} value`,
);
}
if (mutation.kind === 'transfer') {
const definition = (program.pools ?? []).find(
(pool) => pool.key === mutation.pool,
);
if (definition && definition.scope !== 'target') {
errors.push(
`${mutationPath} can only transfer a target-scoped pool.`,
);
}
}
}
validateStrictEventReferences(
clause.mutations,
clause.when,
errors,
`${path} mutations`,
);
for (const [outcomeIndex, outcome] of (clause.outcomes ?? []).entries()) {
const outcomePath = `${path} outcome ${outcomeIndex + 1}`;
if (!outcome.id.trim()) errors.push(`${outcomePath} requires an 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',
'assigned_ability',
'all_skills',
].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.`,
);
}
const targetingError = mechanicOutcomeTargetingError(outcome);
if (targetingError) errors.push(`${outcomePath} ${targetingError}`);
if (
outcome.targeting?.kind === 'event_target_area' &&
clause.when !== 'weapon_hit'
) {
errors.push(
`${outcomePath} event-target area damage requires a weapon-hit clause.`,
);
}
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', 'signature_ability_charge'].includes(
outcome.resource,
)
) {
errors.push(`${outcomePath} has an unsupported resource.`);
}
if (
outcome.resource !== undefined &&
outcome.kind !== 'restoration' &&
outcome.kind !== 'resource_cost'
) {
errors.push(
`${outcomePath} can only set a resource for restoration or resource-cost outcomes.`,
);
}
const delivery = outcome.delivery ?? 'instant';
if (!['instant', 'continuous'].includes(delivery)) {
errors.push(`${outcomePath} has an unsupported delivery mode.`);
} else if (delivery === 'continuous') {
if (outcome.kind !== 'restoration') {
errors.push(
`${outcomePath} can only use continuous delivery for restoration.`,
);
}
if (
!finiteNumber(outcome.durationSeconds) ||
(outcome.durationSeconds ?? 0) <= 0
) {
errors.push(
`${outcomePath} continuous delivery duration must be greater than 0 seconds.`,
);
}
} else if (outcome.durationSeconds !== undefined) {
errors.push(
`${outcomePath} can only set a duration for continuous delivery.`,
);
}
const allowedBasis: Record<
MechanicOutcome['kind'],
readonly OutcomeBasis[]
> = {
attribute_modifier: ['flat'],
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)) {
errors.push(`${outcomePath} cannot use basis "${outcome.basis}".`);
}
if (outcome.statePool && !poolKeys.has(outcome.statePool)) {
errors.push(
`${outcomePath} references unknown pool "${outcome.statePool}".`,
);
}
if (
outcome.minStacks !== undefined &&
outcome.maxStacks !== undefined &&
outcome.minStacks > outcome.maxStacks
) {
errors.push(
`${outcomePath} minimum stacks cannot exceed maximum stacks.`,
);
}
visitExpression(
outcome.value,
poolKeys,
inputKeys,
errors,
`${outcomePath} value`,
);
if (
outcome.probabilityPercent !== undefined &&
(!finiteNumber(outcome.probabilityPercent) ||
outcome.probabilityPercent < 0 ||
outcome.probabilityPercent > 100)
) {
errors.push(
`${outcomePath} probability must be from 0 through 100 percent.`,
);
}
}
validateStrictEventReferences(
clause.outcomes,
clause.when,
errors,
`${path} outcomes`,
);
}
return errors.length ? { ok: false, errors } : { ok: true, value: program };
}
export function evaluateMechanicProgram(
program: MechanicProgram,
events: MechanicEvent[],
scenario: MechanicScenario,
combatContext?: MechanicCombatContext,
equipmentContext?: MechanicEquipmentContext,
): MechanicEvaluation {
const validation = validateMechanicProgram(program);
const scenarioErrors: string[] = [];
scenarioErrors.push(...validateMechanicEvents(events));
if (
!finiteNumber(scenario.encounterSeconds) ||
scenario.encounterSeconds <= 0
) {
scenarioErrors.push('Encounter seconds must be greater than 0.');
}
if (
scenario.randomSeed !== undefined &&
(!Number.isInteger(scenario.randomSeed) ||
scenario.randomSeed < 0 ||
scenario.randomSeed > 0xffff_ffff)
) {
scenarioErrors.push('Random seed must be an unsigned 32-bit integer.');
}
if (scenario.mode === 'custom') {
const definitions = new Map(
(program.scenarioInputs ?? []).map((input) => [input.key, input]),
);
for (const [key, value] of Object.entries(scenario.inputs ?? {})) {
const definition = definitions.get(key);
if (!definition) {
scenarioErrors.push(`Custom scenario contains unknown input "${key}".`);
continue;
}
if (definition.kind === 'toggle') {
if (typeof value !== 'boolean')
scenarioErrors.push(`Scenario input "${key}" must be a boolean.`);
continue;
}
if (!finiteNumber(value)) {
scenarioErrors.push(`Scenario input "${key}" must be a finite number.`);
continue;
}
if (definition.min !== undefined && value < definition.min) {
scenarioErrors.push(`Scenario input "${key}" is below its minimum.`);
}
if (definition.max !== undefined && value > definition.max) {
scenarioErrors.push(`Scenario input "${key}" exceeds its maximum.`);
}
}
}
const combatRequirements = programCombatRequirements(program);
if (combatRequirements.required && !combatContext) {
scenarioErrors.push('This mechanic requires authoritative combat context.');
}
if (combatContext) {
if (
!finiteNumber(combatContext.maxHealth) ||
combatContext.maxHealth <= 0
) {
scenarioErrors.push('Combat maximum Health must be greater than 0.');
}
if (
!finiteNumber(combatContext.currentHealth) ||
combatContext.currentHealth < 0 ||
combatContext.currentHealth > combatContext.maxHealth
) {
scenarioErrors.push(
'Combat current Health must be between 0 and maximum Health.',
);
}
if (
combatContext.weaponBaseDamage !== undefined &&
(!finiteNumber(combatContext.weaponBaseDamage) ||
combatContext.weaponBaseDamage < 0)
) {
scenarioErrors.push('Combat weapon base damage must be 0 or greater.');
}
if (
combatRequirements.weaponBaseDamage &&
combatContext.weaponBaseDamage === undefined
) {
scenarioErrors.push(
'This mechanic requires authoritative weapon base damage.',
);
}
for (const attributeId of combatRequirements.attributeIds) {
if (!finiteNumber(combatContext.attributes?.[attributeId])) {
scenarioErrors.push(
`This mechanic requires authoritative Attribute ${attributeId}.`,
);
}
}
}
if (combatRequirements.equipment && !equipmentContext) {
scenarioErrors.push(
'This mechanic requires authoritative equipment context.',
);
}
if (
equipmentContext?.protocolTalentUpgrades !== undefined &&
(!Number.isInteger(equipmentContext.protocolTalentUpgrades) ||
equipmentContext.protocolTalentUpgrades < 0 ||
equipmentContext.protocolTalentUpgrades > 5)
) {
scenarioErrors.push(
'Protocol talent upgrades must be an integer from 0 to 5.',
);
}
if (equipmentContext?.assignedAbility) {
const ability = equipmentContext.assignedAbility;
if (
typeof ability.active !== 'boolean' ||
!finiteNumber(ability.remainingDurationSeconds) ||
ability.remainingDurationSeconds < 0
) {
scenarioErrors.push(
'Assigned ability state requires an active flag and a non-negative remaining duration.',
);
}
}
if (!validation.ok || scenarioErrors.length) {
return invalidEvaluation(program, scenario, [
...(validation.ok ? [] : validation.errors),
...scenarioErrors,
]);
}
const resolvedScenario = scenarioValues(program, scenario);
const inputs = resolvedScenario.values;
const combat = copyCombat(combatContext ?? null);
const equipment = equipmentContext
? {
...equipmentContext,
values: equipmentContext.values
? { ...equipmentContext.values }
: undefined,
}
: null;
const poolDefinitions = new Map(
(program.pools ?? []).map((pool) => [pool.key, pool]),
);
const pools = initialPools(poolDefinitions, scenario.mode);
const randomSeed = scenario.randomSeed ?? hashSeed(program.id);
const random = deterministicRandom(randomSeed);
const assignedAbility: RuntimeAssignedAbility = {
active: Boolean(equipment?.assignedAbility?.active),
expiresAt:
equipment?.assignedAbility?.active &&
finiteNumber(equipment.assignedAbility.remainingDurationSeconds) &&
equipment.assignedAbility.remainingDurationSeconds > 0
? equipment.assignedAbility.remainingDurationSeconds
: null,
};
const reducerStates = new Map<string, ReducerState>();
const cooldowns = new Map<string, number>();
const emissions: MechanicEmission[] = [];
const outcomes: MechanicOutcomeEmission[] = [];
const continuousDeliveries: ContinuousDelivery[] = [];
const runtimeErrors: string[] = [];
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,
actorId: AGENT_ENTITY,
targetId: DEFAULT_TARGET_ENTITY,
};
const context: EvaluationContext = {
event,
scenario: inputs,
reducerValue: 1,
pools,
poolDefinitions,
combat,
preEventCombat: copyCombat(combat),
equipment,
assignedAbility,
mode: scenario.mode,
random,
runtimeErrors,
};
if (
!predicateMatches(clause.if, context) ||
(clause.requiresAssignedAbilityActive &&
resolveReference(
{ source: 'assigned_ability', path: 'active' },
context,
) !== true)
)
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)]),
);
appendScheduledOutcomes(
outcomes,
emitOutcomes(
{
...clause,
outcomes: (clause.outcomes ?? []).filter(
(outcome) => outcome.phase === 'while_active',
),
},
'after',
0,
1,
before,
context,
continuousDeliveries,
),
0,
scenario.encounterSeconds,
);
}
}
const ordered = events
.map((event, index) => ({ event, index }))
.filter(
({ event }) =>
finiteNumber(event.at) &&
event.at >= 0 &&
event.at <= scenario.encounterSeconds,
)
.sort(
(left, right) =>
left.event.at - right.event.at || left.index - right.index,
)
.map(({ event }) => event);
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;
if (
assignedAbility.expiresAt !== null &&
assignedAbility.expiresAt <= event.at + EPSILON
) {
assignedAbility.active = false;
assignedAbility.expiresAt = null;
}
const activatedDuration =
event.kind === 'skill_deployed' ||
event.kind === 'signature_ability_deployed'
? getPath(event, 'values.assignedAbilityDurationSeconds')
: undefined;
if (finiteNumber(activatedDuration) && activatedDuration > 0) {
assignedAbility.active = true;
assignedAbility.expiresAt = event.at + activatedDuration;
}
outcomes.push(
...expirePools(
event.at,
program.clauses,
inputs,
pools,
poolDefinitions,
combat,
equipment,
assignedAbility,
scenario.mode,
random,
continuousDeliveries,
runtimeErrors,
),
);
outcomes.push(
...advanceContinuousDeliveries(event.at, continuousDeliveries, combat),
);
const preEventCombat = copyCombat(combat);
for (const clause of program.clauses) {
if (clause.when !== event.kind) continue;
const baseContext: EvaluationContext = {
event,
scenario: inputs,
reducerValue: 0,
pools,
poolDefinitions,
combat,
preEventCombat,
equipment,
assignedAbility,
mode: scenario.mode,
random,
runtimeErrors,
};
const predicateMatched =
predicateMatches(clause.if, baseContext) &&
(!clause.requiresAssignedAbilityActive ||
resolveReference(
{ source: 'assigned_ability', path: 'active' },
baseContext,
) === true);
const group = reducerGroup(clause.reducer, baseContext);
const stateKey = `${clause.id}\u0000${group}`;
if (event.at + EPSILON < (cooldowns.get(stateKey) ?? 0)) continue;
let matched = predicateMatched;
if (
matched &&
clause.procChancePercent !== undefined &&
(clause.procChancePercent <= 0 ||
(scenario.mode !== 'maximum' &&
random.next() * 100 >= clause.procChancePercent))
) {
matched = false;
}
const reducerState = reducerStates.get(stateKey) ?? freshReducerState();
reducerStates.set(stateKey, reducerState);
const reduced = reduceEvent(
clause.reducer,
matched,
event,
baseContext,
reducerState,
);
for (const reduction of reduced) {
if (reduction.at + EPSILON < (cooldowns.get(stateKey) ?? 0)) continue;
const context = { ...baseContext, reducerValue: reduction.value };
const weight = clause.weight
? clamp(numericValue(clause.weight, context), 0, 1)
: 1;
const relevantPools = new Set(
(clause.outcomes ?? [])
.map((outcome) => outcome.statePool)
.filter((pool): pool is string => Boolean(pool)),
);
const before = new Map(
[...relevantPools].map((pool) => [
pool,
stateValue(pool, undefined, context),
]),
);
appendScheduledOutcomes(
outcomes,
emitOutcomes(
clause,
'before',
reduction.at,
weight,
before,
context,
continuousDeliveries,
),
reduction.at,
scenario.encounterSeconds,
);
const mutationRecords: MutationRecord[] = [];
for (const mutation of clause.mutations) {
mutationRecords.push(
mutation.kind === 'start_cooldown_on_max'
? startCooldownOnMax(
mutation,
context,
mutationRecords,
reduction.at,
)
: applyMutation(mutation, reduction.at, context, weight),
);
}
emissions.push({
clauseId: clause.id,
at: reduction.at,
weight,
reducerValue: reduction.value,
mutations: mutationRecords,
});
if (clause.cooldownSeconds) {
cooldowns.set(stateKey, reduction.at + clause.cooldownSeconds);
}
appendScheduledOutcomes(
outcomes,
emitOutcomes(
clause,
'after',
reduction.at,
weight,
before,
context,
continuousDeliveries,
),
reduction.at,
scenario.encounterSeconds,
);
}
}
if (event.kind === 'weapon_hit' && event.targetId) {
previousWeaponHitTargetId = event.targetId;
}
}
outcomes.push(
...expirePools(
scenario.encounterSeconds,
program.clauses,
inputs,
pools,
poolDefinitions,
combat,
equipment,
assignedAbility,
scenario.mode,
random,
continuousDeliveries,
runtimeErrors,
),
);
outcomes.push(
...advanceContinuousDeliveries(
scenario.encounterSeconds,
continuousDeliveries,
combat,
),
);
if (runtimeErrors.length) {
return invalidEvaluation(program, scenario, runtimeErrors);
}
return {
status: 'OK',
errors: [],
programId: program.id,
mode: scenario.mode,
scenarioInputs: inputs,
readiness: {
status: resolvedScenario.missing.length
? 'REQUIRES_ASSUMPTIONS'
: 'READY',
missingInputs: resolvedScenario.missing,
},
emissions,
outcomes,
finalState: [...pools.values()].sort(
(left, right) =>
left.pool.localeCompare(right.pool) ||
left.entityId.localeCompare(right.entityId),
),
finalCombat: copyCombat(combat),
equipment,
estimatedActivationCount: emissions.reduce(
(total, emission) => total + emission.weight,
0,
),
randomSeed,
script: formatMechanicProgram(program),
};
}
function formatEntity(selector: EntitySelector | undefined) {
if (!selector) return '';
switch (selector.kind) {
case 'agent':
return ' FOR AGENT';
case 'event_actor':
return ' FOR EVENT ACTOR';
case 'event_target':
return ' FOR EVENT TARGET';
case 'event_field':
return ` FOR EVENT.${selector.path}`;
}
}
function formatReference(reference: ValueReference): string {
switch (reference.source) {
case 'event':
return `EVENT.${reference.path}`;
case 'scenario':
return `SCENARIO.${reference.key}`;
case 'combat':
return `${reference.timing === 'pre_event' ? 'PRE-EVENT ' : ''}COMBAT.${reference.path}`;
case 'attribute':
return `ATTRIBUTE[${reference.attributeId}]`;
case 'equipment':
return `EQUIPMENT.${reference.path}`;
case 'assigned_ability':
return `ASSIGNED ABILITY.${reference.path}`;
case 'reducer':
return 'REDUCER VALUE';
case 'pool':
return `${reference.pool}${formatEntity(reference.entity)}`;
}
}
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)})`;
}
const operators = {
add: '+',
subtract: '-',
multiply: '*',
divide: '/',
min: 'MIN',
max: 'MAX',
} as const;
if (expression.op === 'min' || expression.op === 'max') {
return `${operators[expression.op]}(${formatExpression(expression.left)}, ${formatExpression(expression.right)})`;
}
return `(${formatExpression(expression.left)} ${operators[expression.op]} ${formatExpression(expression.right)})`;
}
function formatOperand(operand: ValueReference | Scalar): string {
if (typeof operand === 'object' && operand !== null)
return formatReference(operand);
if (typeof operand === 'string') return JSON.stringify(operand);
return String(operand);
}
function formatPredicate(predicate: Predicate): string {
if (predicate.kind === 'true') return 'TRUE';
if (predicate.kind === 'exists')
return `EXISTS ${formatReference(predicate.value)}`;
if (predicate.kind === 'not')
return `NOT (${formatPredicate(predicate.predicate)})`;
if (predicate.kind === 'all' || predicate.kind === 'any') {
return predicate.predicates
.map((item) => `(${formatPredicate(item)})`)
.join(predicate.kind === 'all' ? ' AND ' : ' OR ');
}
const operators = {
eq: '==',
ne: '!=',
gt: '>',
gte: '>=',
lt: '<',
lte: '<=',
} as const;
return `${formatOperand(predicate.left)} ${operators[predicate.operator]} ${formatOperand(predicate.right)}`;
}
function formatReducer(reducer: Reducer): string[] {
const group =
'groupBy' in reducer && reducer.groupBy
? ` PER ${formatReference(reducer.groupBy)}`
: '';
switch (reducer.kind) {
case 'none':
return [];
case 'count':
return [`COUNT ${formatNumber(reducer.threshold)} MATCHES${group}`];
case 'modulo':
return [`EVERY ${formatNumber(reducer.every)} MATCHES${group}`];
case 'distinct_count':
return [
`COUNT ${formatNumber(reducer.threshold)} DISTINCT ${formatReference(reducer.distinctBy)} WITHIN ${formatNumber(reducer.windowSeconds)}s`,
];
case 'consecutive_count':
return [
`COUNT ${formatNumber(reducer.threshold)} CONSECUTIVE MATCHES${group}`,
];
case 'sliding_count':
return [
`COUNT ${formatNumber(reducer.threshold)} MATCHES WITHIN ${formatNumber(reducer.windowSeconds)}s${group}`,
];
case 'duration':
return [
`AFTER CONDITION HOLDS FOR ${formatNumber(reducer.seconds)}s${group}`,
];
case 'periodic':
return [
`EVERY ${formatExpression(reducer.intervalSeconds)}s WHILE TRUE${group}`,
];
case 'derived_scalar':
return [`DERIVE ${formatExpression(reducer.value)}`];
}
}
function formatMutation(mutation: ClauseMutation): string {
if (mutation.kind === 'none') return 'NO STATE CHANGE';
if (mutation.kind === 'extend_duration')
return `EXTEND ASSIGNED ABILITY DURATION BY ${formatExpression(mutation.seconds)}s`;
if (mutation.kind === 'start_cooldown_on_max')
return `START ${mutation.cooldownPool} WHEN ${mutation.stackPool} REACHES MAX`;
if (mutation.kind === 'transfer') {
const value =
mutation.value === undefined || mutation.value === 'all'
? 'ALL'
: formatExpression(mutation.value);
return `TRANSFER ${value} ${mutation.pool}${formatEntity(mutation.from)} TO${formatEntity(mutation.to).replace(' FOR', '')}`;
}
if (mutation.kind === 'clear')
return `CLEAR ${mutation.pool}${formatEntity(mutation.entity)}`;
const verbs = {
add: 'ADD',
set: 'SET',
consume: 'CONSUME',
derive: 'DERIVE',
} as const;
const preposition =
mutation.kind === 'consume'
? 'FROM'
: mutation.kind === 'set' || mutation.kind === 'derive'
? 'AS'
: 'TO';
return `${verbs[mutation.kind]} ${formatExpression(mutation.value)} ${preposition} ${mutation.pool}${formatEntity(mutation.entity)}`;
}
function formatOutcome(outcome: MechanicOutcome): string {
const stackBand =
outcome.minStacks !== undefined || outcome.maxStacks !== undefined
? ` AT STACKS ${outcome.minStacks ?? 0}..${outcome.maxStacks ?? 'MAX'}`
: '';
const perStack = outcome.perStack ? ' PER STACK' : '';
const attribute = outcome.attributeId ? ` ${outcome.attributeId}` : '';
const resource =
outcome.kind === 'restoration' || outcome.kind === 'resource_cost'
? ` ${outcomeResource(outcome).toUpperCase()}`
: '';
const timing =
outcome.basis === 'current_health_percent'
? ` USING ${(outcome.combatTiming ?? 'current') === 'pre_event' ? 'PRE-EVENT' : 'CURRENT'} HEALTH`
: '';
const delivery =
outcome.delivery === 'continuous'
? `; DELIVER TOTAL CONTINUOUSLY OVER ${formatNumber(outcome.durationSeconds ?? 0)}s`
: '';
const targeting =
outcome.targeting?.kind === 'event_target_area'
? ` AS EXPLOSION CENTERED ON EVENT TARGET; APPLY TO VALID ENEMIES WITHIN ${formatNumber(outcome.targeting.radiusMeters)}m`
: outcome.targeting
? ` TO ${outcome.targeting.population.toUpperCase()} WITHIN ${formatNumber(outcome.targeting.radiusMeters)}m`
: '';
const probability =
outcome.probabilityPercent === undefined
? ''
: `; ${formatNumber(outcome.probabilityPercent)}% PROC`;
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}${probability} // ${outcome.label}`;
}
export function formatMechanicProgram(program: MechanicProgram): string {
const lines = [`MECHANIC ${program.name.toUpperCase()}`];
for (const pool of program.pools ?? []) {
const bounds =
pool.min !== undefined || pool.max !== undefined
? ` ${formatNumber(pool.min ?? 0)}..${pool.max === undefined ? 'UNBOUNDED' : formatNumber(pool.max)}`
: '';
const expiry = pool.expiresAfterSeconds
? `; EXPIRES ${formatNumber(pool.expiresAfterSeconds)}s AFTER WRITE`
: '';
lines.push(
`STATE ${pool.key} [${pool.scope.toUpperCase()}]${bounds}${expiry}`,
);
}
for (const clause of program.clauses) {
const recipe = clause.recipeKey ? ` [RECIPE ${clause.recipeKey}]` : '';
lines.push(
'',
`WHEN ${MECHANIC_EVENT_REGISTRY[clause.when].label.toUpperCase()} // ${clause.label ?? clause.id}${recipe}`,
);
if (clause.if) lines.push(`IF ${formatPredicate(clause.if)}`);
lines.push(...formatReducer(clause.reducer));
if (clause.cooldownSeconds)
lines.push(`COOLDOWN ${formatNumber(clause.cooldownSeconds)}s`);
if (clause.weight) lines.push(`WEIGHT ${formatExpression(clause.weight)}`);
if (clause.procChancePercent !== undefined)
lines.push(`PROC CHANCE ${formatNumber(clause.procChancePercent)}%`);
if (clause.requiresAssignedAbilityActive)
lines.push('REQUIRES ASSIGNED ABILITY ACTIVE');
lines.push(...clause.mutations.map(formatMutation));
lines.push(...(clause.outcomes ?? []).map(formatOutcome));
}
return lines.join('\n');
}