Support live attribute restoration mechanics

This commit is contained in:
JB
2026-07-27 21:32:04 -04:00
parent 36dd018a4a
commit 568e46cd05
19 changed files with 979 additions and 31 deletions
+188 -11
View File
@@ -164,6 +164,7 @@ 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: 'reducer' }
| { source: 'pool'; pool: string; entity?: EntitySelector };
@@ -263,6 +264,8 @@ export type MechanicOutcome = {
value: NumericExpression;
basis: OutcomeBasis;
resource?: MechanicResource;
delivery?: 'instant' | 'continuous';
durationSeconds?: number;
combatTiming?: 'pre_event' | 'current';
attributeId?: string;
statePool?: string;
@@ -337,6 +340,10 @@ export type MechanicOutcomeEmission = {
stateValue: number;
combatBefore: MechanicCombatContext | null;
combatAfter: MechanicCombatContext | null;
delivery: 'instant' | 'continuous';
deliveryStart: number | null;
deliveryEnd: number | null;
totalResolvedValue: number;
};
export type MechanicEvaluation = {
@@ -366,6 +373,7 @@ export type MechanicCombatContext = {
maxAmmo?: number;
currentAmmo?: number;
weaponBaseDamage?: number;
attributes?: Record<string, number>;
values?: Record<string, Scalar>;
};
@@ -389,6 +397,24 @@ type EvaluationContext = {
preEventCombat: MechanicCombatContext | null;
equipment: MechanicEquipmentContext | null;
};
type ContinuousDelivery = {
clauseId: string;
outcomeId: string;
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;
};
type ReducerState = {
count: number;
timestamps: number[];
@@ -459,6 +485,8 @@ function resolveReference(reference: ValueReference, context: EvaluationContext)
const combat = reference.timing === 'pre_event' ? context.preEventCombat : context.combat;
return getPath(combat, reference.path);
}
case 'attribute':
return context.combat?.attributes?.[reference.attributeId];
case 'equipment':
return getPath(context.equipment, reference.path);
case 'reducer':
@@ -805,6 +833,7 @@ function phaseMatches(
function copyCombat(combat: MechanicCombatContext | null): MechanicCombatContext | null {
return combat ? {
...combat,
attributes: combat.attributes ? { ...combat.attributes } : undefined,
values: combat.values ? { ...combat.values } : undefined
} : null;
}
@@ -862,7 +891,8 @@ function emitOutcomes(
at: number,
weight: number,
before: Map<string, number>,
context: EvaluationContext
context: EvaluationContext,
continuousDeliveries?: ContinuousDelivery[]
): MechanicOutcomeEmission[] {
const emissions: MechanicOutcomeEmission[] = [];
const eligible = (clause.outcomes ?? []).filter((outcome) => {
@@ -890,6 +920,27 @@ function emitOutcomes(
? 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,
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
});
continue;
}
if (outcome.kind === 'resource_cost' && targetResource) {
appliedValue = Math.min(targetResource.current, Math.max(0, appliedValue));
targetResource.setCurrent(targetResource.current - appliedValue);
@@ -919,12 +970,71 @@ function emitOutcomes(
weight,
stateValue: current,
combatBefore,
combatAfter: copyCombat(context.combat)
combatAfter: copyCombat(context.combat),
delivery: 'instant',
deliveryStart: null,
deliveryEnd: null,
totalResolvedValue: resolvedValue * weight
});
}
return emissions;
}
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,
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
});
}
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[],
@@ -932,7 +1042,8 @@ function expirePools(
pools: Map<string, RuntimePool>,
definitions: Map<string, PoolDefinition>,
combat: MechanicCombatContext | null,
equipment: MechanicEquipmentContext | null
equipment: MechanicEquipmentContext | null,
continuousDeliveries: ContinuousDelivery[]
): MechanicOutcomeEmission[] {
const expired = [...pools.entries()]
.filter(([, state]) => state.expiresAt !== null && state.expiresAt <= at + EPSILON)
@@ -943,6 +1054,7 @@ function expirePools(
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
@@ -987,7 +1099,8 @@ function expirePools(
expiryAt,
1,
before,
context
context,
continuousDeliveries
));
}
}
@@ -1048,6 +1161,7 @@ function validateReference(reference: ValueReference, pools: Set<string>, inputs
}
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 === '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.`);
}
@@ -1158,6 +1272,20 @@ 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;
@@ -1177,7 +1305,8 @@ function programCombatRequirements(program: MechanicProgram) {
if (outcome.basis === 'weapon_base_percent') weaponBaseDamage = true;
}
}
return { required, weaponBaseDamage, equipment };
if (attributeIds.size) required = true;
return { required, weaponBaseDamage, equipment, attributeIds };
}
export function validateMechanicProgram(program: MechanicProgram): ValidationResult<MechanicProgram> {
@@ -1353,6 +1482,19 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
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'],
@@ -1439,6 +1581,11 @@ export function evaluateMechanicProgram(
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.');
@@ -1472,6 +1619,7 @@ export function evaluateMechanicProgram(
const cooldowns = new Map<string, number>();
const emissions: MechanicEmission[] = [];
const outcomes: MechanicOutcomeEmission[] = [];
const continuousDeliveries: ContinuousDelivery[] = [];
if (scenario.mode === 'maximum') {
for (const clause of program.clauses) {
const event: MechanicEvent = {
@@ -1497,7 +1645,7 @@ export function evaluateMechanicProgram(
outcomes.push(...emitOutcomes({
...clause,
outcomes: (clause.outcomes ?? []).filter((outcome) => outcome.phase === 'while_active')
}, 'after', 0, 1, before, context));
}, 'after', 0, 1, before, context, continuousDeliveries));
}
}
const ordered = events
@@ -1514,8 +1662,10 @@ export function evaluateMechanicProgram(
pools,
poolDefinitions,
combat,
equipment
equipment,
continuousDeliveries
));
outcomes.push(...advanceContinuousDeliveries(event.at, continuousDeliveries, combat));
const preEventCombat = copyCombat(combat);
for (const clause of program.clauses) {
if (clause.when !== event.kind) continue;
@@ -1544,7 +1694,15 @@ 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(clause, 'before', reduction.at, weight, before, context));
outcomes.push(...emitOutcomes(
clause,
'before',
reduction.at,
weight,
before,
context,
continuousDeliveries
));
const mutationRecords = clause.mutations.map((mutation) =>
applyMutation(mutation, reduction.at, context, weight));
emissions.push({
@@ -1557,7 +1715,15 @@ export function evaluateMechanicProgram(
if (clause.cooldownSeconds) {
cooldowns.set(stateKey, reduction.at + clause.cooldownSeconds);
}
outcomes.push(...emitOutcomes(clause, 'after', reduction.at, weight, before, context));
outcomes.push(...emitOutcomes(
clause,
'after',
reduction.at,
weight,
before,
context,
continuousDeliveries
));
}
}
}
@@ -1568,7 +1734,13 @@ export function evaluateMechanicProgram(
pools,
poolDefinitions,
combat,
equipment
equipment,
continuousDeliveries
));
outcomes.push(...advanceContinuousDeliveries(
scenario.encounterSeconds,
continuousDeliveries,
combat
));
return {
status: 'OK',
@@ -1613,6 +1785,8 @@ function formatReference(reference: ValueReference): string {
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 'reducer':
@@ -1717,7 +1891,10 @@ function formatOutcome(outcome: MechanicOutcome): string {
const timing = outcome.basis === 'current_health_percent'
? ` USING ${(outcome.combatTiming ?? 'current') === 'pre_event' ? 'PRE-EVENT' : 'CURRENT'} HEALTH`
: '';
return `${outcome.phase.toUpperCase()}: APPLY ${formatExpression(outcome.value)} ${outcome.basis.toUpperCase()}${resource}${attribute}${perStack}${stackBand}${timing} // ${outcome.label}`;
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}`;
}
export function formatMechanicProgram(program: MechanicProgram): string {