Adding skills & isac talent updates

This commit is contained in:
jb
2026-07-29 20:56:22 -04:00
parent f1b473ffa8
commit 65a4987cad
68 changed files with 5486 additions and 569 deletions
+293 -28
View File
@@ -2,12 +2,19 @@ export const MECHANIC_EVENT_KINDS = [
'shot_fired',
'weapon_hit',
'skill_deployed',
'signature_ability_deployed',
'skill_hit',
'skill_multishot_triggered',
'agent_hit',
'health_damage_taken',
'damage_dealt',
'enemy_killed',
'assist',
'ally_downed',
'agent_downed',
'ally_rescued',
'grenade_thrown',
'medical_kit_used',
'healing_applied',
'extra_health_granted',
'movement_snapshot',
@@ -36,15 +43,31 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
shot_fired: {
key: 'shot_fired',
label: 'Shot fired',
description: 'A round leaves the equipped weapon; hit confirmation is not required.'
description: 'A round leaves the equipped weapon; hit confirmation is not required.',
fields: [
{
path: 'values.hit',
label: 'Shot hit',
type: 'boolean'
},
{
path: 'values.magazineTwentyPercentBandsCrossed',
label: '20% magazine bands crossed',
type: 'number'
}
]
},
weapon_hit: {
key: 'weapon_hit',
label: 'Weapon hit',
description: 'A weapon attack connects with a target. The headshot boolean distinguishes headshots from body shots.',
description: 'A weapon attack connects with a target. The previous target and event values distinguish target switches, headshots, cover state, and distance.',
fields: [
{ path: 'targetId', label: 'Target ID', type: 'string' },
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' }
{ path: 'values.previousTargetId', label: 'Previous target ID', type: 'string' },
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' },
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
{ path: 'values.behindCover', label: 'Agent behind cover', type: 'boolean' },
{ path: 'values.distanceMeters', label: 'Target distance', type: 'number' }
]
},
skill_deployed: {
@@ -52,10 +75,24 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
label: 'Skill deployed',
description: 'The Agent deploys or activates a specialization ability.'
},
signature_ability_deployed: {
key: 'signature_ability_deployed',
label: 'Signature Ability deployed',
description: 'The Agent deploys or activates their Signature Ability.'
},
skill_hit: {
key: 'skill_hit',
label: 'Skill hit',
description: 'A deployed skill connects with a target. Critical and distance details belong in event values.'
description: 'A deployed skill connects with a target. Critical and distance details belong in event values.',
fields: [
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
{ path: 'values.distanceMeters', label: 'Target distance', type: 'number' }
]
},
skill_multishot_triggered: {
key: 'skill_multishot_triggered',
label: 'Skill Multi-Shot triggered',
description: 'A deployed skill triggers an additional Multi-Shot.'
},
agent_hit: {
key: 'agent_hit',
@@ -65,7 +102,11 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
health_damage_taken: {
key: 'health_damage_taken',
label: 'Health damage taken',
description: 'The Agent loses Health. Damage absorbed entirely by Extra Health does not emit this event.'
description: 'The Agent loses Health. Damage absorbed entirely by Extra Health does not emit this event.',
fields: [
{ path: 'values.distanceMeters', label: 'Attacker distance', type: 'number' },
{ path: 'values.recentSkillDeploymentSeconds', label: 'Seconds since relevant Skill deployment', type: 'number' }
]
},
damage_dealt: {
key: 'damage_dealt',
@@ -75,12 +116,50 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
enemy_killed: {
key: 'enemy_killed',
label: 'Enemy killed',
description: 'An enemy dies. Weapon or skill source may be supplied when a mechanic requires the distinction.'
description: 'An enemy dies. Weapon or skill source may be supplied when a mechanic requires the distinction.',
fields: [
{ path: 'values.headshot', label: 'Headshot killing shot', type: 'boolean' },
{ path: 'values.critical', label: 'Critical killing shot', type: 'boolean' },
{ path: 'values.source', label: 'Killing source', type: 'string' },
{ path: 'values.behindCover', label: 'Agent behind cover', type: 'boolean' },
{ path: 'values.distanceMeters', label: 'Enemy distance', type: 'number' }
]
},
assist: {
key: 'assist',
label: 'Assist',
description: 'The Agent receives credit for assisting with an enemy defeat.'
description: 'The Agent receives credit for assisting with an enemy defeat.',
fields: [
{ path: 'values.behindCover', label: 'Agent behind cover', type: 'boolean' }
]
},
ally_downed: {
key: 'ally_downed',
label: 'Ally downed',
description: 'An allied Agent enters the downed state.',
fields: [
{ path: 'values.distanceMeters', label: 'Ally distance', type: 'number' }
]
},
agent_downed: {
key: 'agent_downed',
label: 'Agent downed',
description: 'The Agent enters the downed state.'
},
ally_rescued: {
key: 'ally_rescued',
label: 'Ally rescued',
description: 'The Agent completes a rescue of a downed ally.'
},
grenade_thrown: {
key: 'grenade_thrown',
label: 'Grenade thrown',
description: 'The Agent throws a grenade and consumes it from inventory.'
},
medical_kit_used: {
key: 'medical_kit_used',
label: 'Medical Kit used',
description: 'The Agent consumes a Medical Kit.'
},
healing_applied: {
key: 'healing_applied',
@@ -95,7 +174,14 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
movement_snapshot: {
key: 'movement_snapshot',
label: 'Movement snapshot',
description: 'A sampled Agent movement state such as immobile, moving, or sprinting.'
description: 'A one-second sample of the Agent movement state.',
fields: [
{
path: 'values.state',
label: 'Movement state (immobile, moving, or sprinting)',
type: 'string'
}
]
},
proximity_snapshot: {
key: 'proximity_snapshot',
@@ -115,7 +201,10 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
reload_started: {
key: 'reload_started',
label: 'Reload started',
description: 'The equipped weapon begins reloading.'
description: 'The equipped weapon begins reloading.',
fields: [
{ path: 'values.emptyMagazine', label: 'Empty magazine', type: 'boolean' }
]
},
reload_completed: {
key: 'reload_completed',
@@ -178,9 +267,21 @@ export type ValueReference =
| { source: 'reducer' }
| { source: 'pool'; pool: string; entity?: EntitySelector };
export const MECHANIC_COMBAT_REFERENCE_REGISTRY = {
currentHealth: {
label: 'Current Health',
description: 'The Agent’s resolved current Health.'
},
currentHealthPercent: {
label: 'Current Health percentage',
description: 'The Agent’s resolved current Health divided by resolved maximum Health, from 0 through 100.'
}
} as const;
export type NumericExpression =
| number
| { ref: ValueReference }
| { op: 'floor'; value: NumericExpression }
| {
op: 'add' | 'subtract' | 'multiply' | 'divide' | 'min' | 'max';
left: NumericExpression;
@@ -256,19 +357,26 @@ export type OutcomeBasis =
export type MechanicOutcomeScope =
| 'self'
| 'equipped_weapon'
| 'inactive_weapon'
| 'target'
| 'allies'
| 'self_and_allies'
| 'assigned_ability'
| 'all_skills';
export type MechanicResource = 'health' | 'armor' | 'ammo';
export type MechanicResource = 'health' | 'armor' | 'ammo' | 'signature_ability_charge';
export type MechanicOutcomeTargeting = {
kind: 'within_radius';
population: 'allies' | 'enemies';
radiusMeters: number;
};
export type MechanicOutcome = {
id: string;
label: string;
phase: OutcomePhase;
kind: 'attribute_modifier' | 'direct_damage' | 'restoration' | 'resource_cost' | 'marker';
kind: 'attribute_modifier' | 'direct_damage' | 'restoration' | 'resource_cost' | 'weapon_reload' | 'marker';
scope: MechanicOutcomeScope;
value: NumericExpression;
basis: OutcomeBasis;
@@ -281,6 +389,7 @@ export type MechanicOutcome = {
perStack?: boolean;
minStacks?: number;
maxStacks?: number;
targeting?: MechanicOutcomeTargeting;
};
export type MechanicClause = {
@@ -304,6 +413,23 @@ export type MechanicProgram = {
clauses: MechanicClause[];
};
function mechanicDefinitionsEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (typeof left !== typeof right || left === null || right === null) return false;
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
return left.every((value, index) => mechanicDefinitionsEqual(value, right[index]));
}
if (typeof left !== 'object') return false;
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const leftKeys = Object.keys(leftRecord).sort();
const rightKeys = Object.keys(rightRecord).sort();
return leftKeys.length === rightKeys.length
&& leftKeys.every((key, index) =>
key === rightKeys[index] && mechanicDefinitionsEqual(leftRecord[key], rightRecord[key]));
}
export type PoolSnapshot = {
pool: string;
scope: StateScope;
@@ -354,6 +480,7 @@ export type MechanicOutcomeEmission = {
deliveryStart: number | null;
deliveryEnd: number | null;
totalResolvedValue: number;
targeting: MechanicOutcomeTargeting | null;
};
export type MechanicEvaluation = {
@@ -382,6 +509,8 @@ export type MechanicCombatContext = {
currentArmor?: number;
maxAmmo?: number;
currentAmmo?: number;
maxSignatureAbilityCharge?: number;
currentSignatureAbilityCharge?: number;
weaponBaseDamage?: number;
attributes?: Record<string, number>;
values?: Record<string, Scalar>;
@@ -425,6 +554,7 @@ type ContinuousDelivery = {
start: number;
end: number;
appliedThrough: number;
targeting: MechanicOutcomeTargeting | null;
};
type ReducerState = {
count: number;
@@ -494,6 +624,15 @@ function resolveReference(reference: ValueReference, context: EvaluationContext)
return context.scenario[reference.key];
case 'combat': {
const combat = reference.timing === 'pre_event' ? context.preEventCombat : context.combat;
if (reference.path === 'currentHealthPercent') {
if (
!combat
|| !finiteNumber(combat.maxHealth)
|| combat.maxHealth <= 0
|| !finiteNumber(combat.currentHealth)
) return undefined;
return clamp(combat.currentHealth / combat.maxHealth * 100, 0, 100);
}
return getPath(combat, reference.path);
}
case 'attribute':
@@ -513,6 +652,7 @@ function numericValue(expression: NumericExpression, context: EvaluationContext)
const value = resolveReference(expression.ref, context);
return finiteNumber(value) ? value : typeof value === 'boolean' ? Number(value) : 0;
}
if (expression.op === 'floor') return Math.floor(numericValue(expression.value, context));
if (expression.op === 'clamp') {
return clamp(
numericValue(expression.value, context),
@@ -893,6 +1033,19 @@ function combatResource(
setCurrent: (value) => { combat.currentAmmo = value; }
};
}
if (resource === 'signature_ability_charge') {
const maximum = finiteNumber(combat.maxSignatureAbilityCharge)
? combat.maxSignatureAbilityCharge
: 1000;
const current = finiteNumber(combat.currentSignatureAbilityCharge)
? combat.currentSignatureAbilityCharge
: 0;
return {
current,
maximum,
setCurrent: (value) => { combat.currentSignatureAbilityCharge = value; }
};
}
return null;
}
@@ -957,7 +1110,8 @@ function emitOutcomes(
stateValue: current,
start: at,
end: at + durationSeconds,
appliedThrough: at
appliedThrough: at,
targeting: outcome.targeting ?? null
});
continue;
}
@@ -973,10 +1127,13 @@ function emitOutcomes(
} else if ((outcome.kind === 'resource_cost' || outcome.kind === 'restoration') && !targetResource) {
appliedValue = 0;
}
const emittedAt = outcome.kind === 'weapon_reload'
? at + Math.max(0, appliedValue)
: at;
emissions.push({
clauseId: clause.id,
outcomeId: outcome.id,
at,
at: emittedAt,
targetId: context.event.targetId ?? null,
phase: outcome.phase,
kind: outcome.kind,
@@ -995,12 +1152,40 @@ function emitOutcomes(
delivery: 'instant',
deliveryStart: null,
deliveryEnd: null,
totalResolvedValue: resolvedValue * weight
totalResolvedValue: resolvedValue * weight,
targeting: outcome.targeting ?? null
});
}
return emissions;
}
function appendScheduledOutcomes(
target: MechanicOutcomeEmission[],
candidates: MechanicOutcomeEmission[],
triggeredAt: number,
encounterSeconds: number
) {
for (const candidate of candidates) {
if (candidate.kind !== 'weapon_reload') {
target.push(candidate);
continue;
}
for (let index = target.length - 1; index >= 0; index -= 1) {
const pending = target[index];
if (
pending.kind === 'weapon_reload'
&& pending.clauseId === candidate.clauseId
&& pending.outcomeId === candidate.outcomeId
&& pending.scope === candidate.scope
&& pending.at > triggeredAt + EPSILON
) {
target.splice(index, 1);
}
}
if (candidate.at <= encounterSeconds + EPSILON) target.push(candidate);
}
}
function advanceContinuousDeliveries(
at: number,
deliveries: ContinuousDelivery[],
@@ -1046,7 +1231,8 @@ function advanceContinuousDeliveries(
delivery: 'continuous',
deliveryStart: delivery.start,
deliveryEnd: delivery.end,
totalResolvedValue: delivery.totalResolvedValue
totalResolvedValue: delivery.totalResolvedValue,
targeting: delivery.targeting
});
}
for (let index = deliveries.length - 1; index >= 0; index -= 1) {
@@ -1182,7 +1368,12 @@ function validateReference(reference: ValueReference, pools: Set<string>, inputs
errors.push(`${path} references unknown scenario input "${reference.key}".`);
}
if (reference.source === 'event' && !reference.path.trim()) errors.push(`${path} requires an event path.`);
if (reference.source === 'combat' && !reference.path.trim()) errors.push(`${path} requires a combat path.`);
if (reference.source === 'combat') {
if (!reference.path.trim()) errors.push(`${path} requires a combat path.`);
if (reference.path === 'currentHealthPercent' && reference.timing === 'pre_event') {
errors.push(`${path} current Health percentage must use current timing.`);
}
}
if (reference.source === 'attribute' && !reference.attributeId.trim()) errors.push(`${path} requires an Attribute ID.`);
if (reference.source === 'equipment' && !reference.path.trim()) errors.push(`${path} requires an equipment path.`);
}
@@ -1202,6 +1393,10 @@ function visitExpression(
validateReference(expression.ref, pools, inputs, errors, path);
return;
}
if (expression.op === 'floor') {
visitExpression(expression.value, pools, inputs, errors, `${path} value`);
return;
}
if (expression.op === 'clamp') {
visitExpression(expression.value, pools, inputs, errors, `${path} value`);
visitExpression(expression.min, pools, inputs, errors, `${path} minimum`);
@@ -1245,6 +1440,7 @@ function visitPredicate(
function expressionUsesCombat(expression: NumericExpression): boolean {
if (typeof expression === 'number') return false;
if ('ref' in expression) return expression.ref.source === 'combat';
if (expression.op === 'floor') return expressionUsesCombat(expression.value);
if (expression.op === 'clamp') {
return expressionUsesCombat(expression.value)
|| expressionUsesCombat(expression.min)
@@ -1256,6 +1452,7 @@ function expressionUsesCombat(expression: NumericExpression): boolean {
function expressionUsesEquipment(expression: NumericExpression): boolean {
if (typeof expression === 'number') return false;
if ('ref' in expression) return expression.ref.source === 'equipment';
if (expression.op === 'floor') return expressionUsesEquipment(expression.value);
if (expression.op === 'clamp') {
return expressionUsesEquipment(expression.value)
|| expressionUsesEquipment(expression.min)
@@ -1390,7 +1587,7 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
}
}
const clauseIds = new Set<string>();
const outcomeIds = new Set<string>();
const outcomeDefinitions = new Map<string, MechanicOutcome>();
for (const [index, clause] of program.clauses.entries()) {
const path = `Clause ${index + 1}`;
if (!clause.id.trim()) errors.push(`${path} requires an ID.`);
@@ -1478,14 +1675,19 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
for (const [outcomeIndex, outcome] of (clause.outcomes ?? []).entries()) {
const outcomePath = `${path} outcome ${outcomeIndex + 1}`;
if (!outcome.id.trim()) errors.push(`${outcomePath} requires an ID.`);
if (outcomeIds.has(outcome.id)) errors.push(`Outcome "${outcome.id}" is duplicated.`);
outcomeIds.add(outcome.id);
const existingOutcome = outcomeDefinitions.get(outcome.id);
if (existingOutcome && !mechanicDefinitionsEqual(existingOutcome, outcome)) {
errors.push(`Outcome "${outcome.id}" has conflicting definitions.`);
} else if (!existingOutcome) {
outcomeDefinitions.set(outcome.id, outcome);
}
if (outcome.kind === 'attribute_modifier' && !outcome.attributeId?.trim()) {
errors.push(`${outcomePath} requires an Attribute ID.`);
}
if (![
'self',
'equipped_weapon',
'inactive_weapon',
'target',
'allies',
'self_and_allies',
@@ -1494,11 +1696,45 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
].includes(outcome.scope)) {
errors.push(`${outcomePath} has an unsupported scope.`);
}
if (
outcome.kind === 'weapon_reload'
&& outcome.scope !== 'equipped_weapon'
&& outcome.scope !== 'inactive_weapon'
) {
errors.push(`${outcomePath} weapon reload must target the equipped or inactive weapon.`);
}
if (outcome.scope === 'inactive_weapon' && outcome.kind !== 'weapon_reload') {
errors.push(`${outcomePath} can only target the inactive weapon with a weapon reload.`);
}
if (outcome.targeting !== undefined) {
if (
outcome.targeting.kind !== 'within_radius'
|| !['allies', 'enemies'].includes(outcome.targeting.population)
|| !finiteNumber(outcome.targeting.radiusMeters)
|| outcome.targeting.radiusMeters <= 0
) {
errors.push(`${outcomePath} has invalid radius targeting.`);
} else if (
outcome.targeting.population === 'allies'
&& outcome.scope !== 'allies'
&& outcome.scope !== 'self_and_allies'
) {
errors.push(`${outcomePath} ally radius targeting requires allies or self-and-allies scope.`);
} else if (
outcome.targeting.population === 'enemies'
&& outcome.scope !== 'target'
) {
errors.push(`${outcomePath} enemy radius targeting requires target scope.`);
}
}
if (outcome.kind === 'weapon_reload' && finiteNumber(outcome.value) && outcome.value < 0) {
errors.push(`${outcomePath} weapon reload delay cannot be negative.`);
}
if ((outcome.kind === 'restoration' || outcome.kind === 'resource_cost') && outcome.resource === undefined) {
errors.push(`${outcomePath} requires a resource.`);
}
if ((outcome.kind === 'restoration' || outcome.kind === 'resource_cost') && outcome.resource !== undefined
&& !['health', 'armor', 'ammo'].includes(outcome.resource)) {
&& !['health', 'armor', 'ammo', 'signature_ability_charge'].includes(outcome.resource)) {
errors.push(`${outcomePath} has an unsupported resource.`);
}
if (outcome.resource !== undefined && outcome.kind !== 'restoration' && outcome.kind !== 'resource_cost') {
@@ -1522,6 +1758,7 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
direct_damage: ['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'],
restoration: ['flat', 'max_health_percent', 'current_health_percent'],
resource_cost: ['flat', 'max_health_percent', 'current_health_percent'],
weapon_reload: ['flat'],
marker: ['flat']
};
if (!allowedBasis[outcome.kind].includes(outcome.basis)) {
@@ -1644,6 +1881,10 @@ export function evaluateMechanicProgram(
const continuousDeliveries: ContinuousDelivery[] = [];
if (scenario.mode === 'maximum') {
for (const clause of program.clauses) {
if (
clause.when === 'tick'
&& events.some((event) => event.kind === 'tick' && Math.abs(event.at) <= EPSILON)
) continue;
const event: MechanicEvent = {
kind: clause.when,
at: 0,
@@ -1660,14 +1901,15 @@ export function evaluateMechanicProgram(
preEventCombat: copyCombat(combat),
equipment
};
if (!predicateMatches(clause.if, context)) continue;
const before = new Map((clause.outcomes ?? [])
.map((outcome) => outcome.statePool)
.filter((pool): pool is string => Boolean(pool))
.map((pool) => [pool, stateValue(pool, undefined, context)]));
outcomes.push(...emitOutcomes({
appendScheduledOutcomes(outcomes, emitOutcomes({
...clause,
outcomes: (clause.outcomes ?? []).filter((outcome) => outcome.phase === 'while_active')
}, 'after', 0, 1, before, context, continuousDeliveries));
}, 'after', 0, 1, before, context, continuousDeliveries), 0, scenario.encounterSeconds);
}
}
const ordered = events
@@ -1676,7 +1918,19 @@ export function evaluateMechanicProgram(
.sort((left, right) => left.event.at - right.event.at || left.index - right.index)
.map(({ event }) => event);
for (const event of ordered) {
let previousWeaponHitTargetId: string | null = null;
for (const rawEvent of ordered) {
const event: MechanicEvent = rawEvent.kind === 'weapon_hit' && rawEvent.targetId
? {
...rawEvent,
values: {
...(rawEvent.values ?? {}),
...(previousWeaponHitTargetId === null
? {}
: { previousTargetId: previousWeaponHitTargetId })
}
}
: rawEvent;
outcomes.push(...expirePools(
event.at,
program.clauses,
@@ -1716,7 +1970,7 @@ export function evaluateMechanicProgram(
.map((outcome) => outcome.statePool)
.filter((pool): pool is string => Boolean(pool)));
const before = new Map([...relevantPools].map((pool) => [pool, stateValue(pool, undefined, context)]));
outcomes.push(...emitOutcomes(
appendScheduledOutcomes(outcomes, emitOutcomes(
clause,
'before',
reduction.at,
@@ -1724,7 +1978,7 @@ export function evaluateMechanicProgram(
before,
context,
continuousDeliveries
));
), reduction.at, scenario.encounterSeconds);
const mutationRecords = clause.mutations.map((mutation) =>
applyMutation(mutation, reduction.at, context, weight));
emissions.push({
@@ -1737,7 +1991,7 @@ export function evaluateMechanicProgram(
if (clause.cooldownSeconds) {
cooldowns.set(stateKey, reduction.at + clause.cooldownSeconds);
}
outcomes.push(...emitOutcomes(
appendScheduledOutcomes(outcomes, emitOutcomes(
clause,
'after',
reduction.at,
@@ -1745,9 +1999,12 @@ export function evaluateMechanicProgram(
before,
context,
continuousDeliveries
));
), reduction.at, scenario.encounterSeconds);
}
}
if (event.kind === 'weapon_hit' && event.targetId) {
previousWeaponHitTargetId = event.targetId;
}
}
outcomes.push(...expirePools(
scenario.encounterSeconds,
@@ -1821,6 +2078,7 @@ function formatReference(reference: ValueReference): string {
function formatExpression(expression: NumericExpression): string {
if (typeof expression === 'number') return formatNumber(expression);
if ('ref' in expression) return formatReference(expression.ref);
if (expression.op === 'floor') return `FLOOR(${formatExpression(expression.value)})`;
if (expression.op === 'clamp') {
return `CLAMP(${formatExpression(expression.value)}, ${formatExpression(expression.min)}, ${formatExpression(expression.max)})`;
}
@@ -1916,7 +2174,14 @@ function formatOutcome(outcome: MechanicOutcome): string {
const delivery = outcome.delivery === 'continuous'
? `; DELIVER TOTAL CONTINUOUSLY OVER ${formatNumber(outcome.durationSeconds ?? 0)}s`
: '';
return `${outcome.phase.toUpperCase()}: APPLY ${formatExpression(outcome.value)} ${outcome.basis.toUpperCase()}${resource}${attribute}${perStack}${stackBand}${timing}${delivery} // ${outcome.label}`;
const targeting = outcome.targeting
? ` TO ${outcome.targeting.population.toUpperCase()} WITHIN ${formatNumber(outcome.targeting.radiusMeters)}m`
: '';
if (outcome.kind === 'weapon_reload') {
const target = outcome.scope === 'equipped_weapon' ? 'EQUIPPED' : 'INACTIVE';
return `${outcome.phase.toUpperCase()}: RELOAD ${target} WEAPON AFTER ${formatExpression(outcome.value)}s // ${outcome.label}`;
}
return `${outcome.phase.toUpperCase()}: APPLY ${formatExpression(outcome.value)} ${outcome.basis.toUpperCase()}${resource}${attribute}${perStack}${stackBand}${timing}${targeting}${delivery} // ${outcome.label}`;
}
export function formatMechanicProgram(program: MechanicProgram): string {