Adding skills & isac talent updates
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user