3579 lines
109 KiB
TypeScript
3579 lines
109 KiB
TypeScript
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');
|
||
}
|