Support live attribute restoration mechanics
This commit is contained in:
@@ -179,6 +179,90 @@ describe('conditional mechanic authoring payload', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts catalog Attribute references and rejects unknown IDs recursively', () => {
|
||||
const referenced = draft();
|
||||
referenced.clauses[0].outcomes[0] = {
|
||||
key: 'health-restoration',
|
||||
label: 'Health Restoration',
|
||||
phase: 'after_event',
|
||||
kind: 'restoration',
|
||||
resource: 'health',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
delivery: 'continuous',
|
||||
durationSeconds: 3,
|
||||
variants: {
|
||||
I: {
|
||||
value: 240,
|
||||
expression: {
|
||||
op: 'multiply',
|
||||
left: 240,
|
||||
right: {
|
||||
op: 'divide',
|
||||
left: {
|
||||
ref: {
|
||||
source: 'attribute',
|
||||
attributeId: 'firepower-multiplier'
|
||||
}
|
||||
},
|
||||
right: 100
|
||||
}
|
||||
}
|
||||
},
|
||||
II: {
|
||||
value: 336,
|
||||
expression: {
|
||||
op: 'multiply',
|
||||
left: 336,
|
||||
right: {
|
||||
op: 'divide',
|
||||
left: {
|
||||
ref: {
|
||||
source: 'attribute',
|
||||
attributeId: 'firepower-multiplier'
|
||||
}
|
||||
},
|
||||
right: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const accepted = parseConditionalMechanicDraft(referenced, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['firepower-multiplier'])
|
||||
});
|
||||
expect(accepted.ok).toBe(true);
|
||||
if (accepted.ok) {
|
||||
expect(accepted.value.programs.I.clauses[0].outcomes?.[0].value).toMatchObject({
|
||||
right: {
|
||||
left: {
|
||||
ref: {
|
||||
source: 'attribute',
|
||||
attributeId: 'firepower-multiplier'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(accepted.value.programs.II.clauses[0].outcomes?.[0]).toMatchObject({
|
||||
delivery: 'continuous',
|
||||
durationSeconds: 3,
|
||||
value: { left: 336 }
|
||||
});
|
||||
}
|
||||
|
||||
const rejected = parseConditionalMechanicDraft(referenced, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage'])
|
||||
});
|
||||
expect(rejected.ok).toBe(false);
|
||||
if (!rejected.ok) {
|
||||
expect(rejected.errors).toContain(
|
||||
'Conditional mechanic clauses item 1 outcomes item 1 variants I expression right left ref references unknown Attribute "firepower-multiplier".'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires the exact configured variants and returns validation errors without throwing', () => {
|
||||
const invalid = draft();
|
||||
invalid.clauses[0].outcomes[0].variants = {
|
||||
|
||||
@@ -156,6 +156,8 @@ function isReference(value: unknown): value is ValueReference {
|
||||
&& (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current');
|
||||
case 'equipment':
|
||||
return typeof value.path === 'string' && Boolean(value.path.trim());
|
||||
case 'attribute':
|
||||
return typeof value.attributeId === 'string' && Boolean(value.attributeId.trim());
|
||||
case 'reducer':
|
||||
return true;
|
||||
case 'pool':
|
||||
@@ -167,6 +169,33 @@ function isReference(value: unknown): value is ValueReference {
|
||||
}
|
||||
}
|
||||
|
||||
function validateAttributeReferences(
|
||||
value: unknown,
|
||||
allowedAttributeIds: ReadonlySet<string> | undefined,
|
||||
errors: string[],
|
||||
path = 'Conditional mechanic'
|
||||
) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) =>
|
||||
validateAttributeReferences(item, allowedAttributeIds, errors, `${path} item ${index + 1}`));
|
||||
return;
|
||||
}
|
||||
if (!object(value)) return;
|
||||
if (
|
||||
value.source === 'attribute'
|
||||
&& typeof value.attributeId === 'string'
|
||||
&& value.attributeId.trim()
|
||||
&& allowedAttributeIds
|
||||
&& !allowedAttributeIds.has(value.attributeId)
|
||||
) {
|
||||
errors.push(`${path} references unknown Attribute "${value.attributeId}".`);
|
||||
return;
|
||||
}
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
validateAttributeReferences(nested, allowedAttributeIds, errors, `${path} ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isExpression(value: unknown): value is NumericExpression {
|
||||
if (finite(value)) return true;
|
||||
if (!object(value)) return false;
|
||||
@@ -357,6 +386,19 @@ function parseOutcome(
|
||||
} else if (value.resource !== undefined) {
|
||||
errors.push(`${path} may only use a resource for restoration or resource cost.`);
|
||||
}
|
||||
const delivery = value.delivery ?? 'instant';
|
||||
if (delivery !== 'instant' && delivery !== 'continuous') {
|
||||
errors.push(`${path} has an invalid delivery mode.`);
|
||||
} else if (delivery === 'continuous') {
|
||||
if (kind !== 'restoration') {
|
||||
errors.push(`${path} may only use continuous delivery for restoration.`);
|
||||
}
|
||||
if (!finite(value.durationSeconds) || value.durationSeconds <= 0) {
|
||||
errors.push(`${path} continuous delivery requires a positive duration.`);
|
||||
}
|
||||
} else if (value.durationSeconds !== undefined) {
|
||||
errors.push(`${path} may only set a duration for continuous delivery.`);
|
||||
}
|
||||
if (
|
||||
value.combatTiming !== undefined
|
||||
&& value.combatTiming !== 'pre_event'
|
||||
@@ -547,6 +589,7 @@ export function parseConditionalMechanicDraft(
|
||||
pools,
|
||||
clauses
|
||||
};
|
||||
validateAttributeReferences(draft, options.allowedAttributeIds, errors);
|
||||
const inputRequiresAssumption = scenarioInputs.some(
|
||||
(input) => input.readiness === 'requires_assumption'
|
||||
);
|
||||
|
||||
@@ -148,6 +148,71 @@ describe('conditional-effect persistence adapter', () => {
|
||||
}]
|
||||
}]
|
||||
});
|
||||
expect(result.program?.clauses[0].outcomes?.[0]).not.toHaveProperty('delivery');
|
||||
expect(result.program?.clauses[0].outcomes?.[0]).not.toHaveProperty('durationSeconds');
|
||||
});
|
||||
|
||||
it('loads continuous restoration delivery metadata', () => {
|
||||
const result = adaptConditionalEffectProgram({
|
||||
rule: rule(),
|
||||
statePools: [],
|
||||
clauses: [clause({
|
||||
trigger_id: null,
|
||||
event_kind: 'enemy_killed',
|
||||
parameters_json: '{}',
|
||||
state_pool_id: null,
|
||||
mutations_json: '[]'
|
||||
})],
|
||||
outcomes: [outcome({
|
||||
state_pool_id: null,
|
||||
effect_key: 'predatory-restoration',
|
||||
application_kind: 'restore_health',
|
||||
effect_scope: 'self',
|
||||
stat_attribute_id: null,
|
||||
value: 240,
|
||||
value_scaling: 'flat',
|
||||
application_phase: 'after_event',
|
||||
resource_kind: 'health',
|
||||
delivery_mode: 'continuous',
|
||||
delivery_duration_seconds: 3,
|
||||
min_state_value: null
|
||||
})],
|
||||
triggers: []
|
||||
}, { name: 'Predatory Aura', variantKey: 'I' });
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({
|
||||
kind: 'restoration',
|
||||
resource: 'health',
|
||||
delivery: 'continuous',
|
||||
durationSeconds: 3
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects incomplete continuous restoration metadata', () => {
|
||||
const result = adaptConditionalEffectProgram({
|
||||
rule: rule(),
|
||||
statePools: [],
|
||||
clauses: [clause({
|
||||
trigger_id: null,
|
||||
event_kind: 'enemy_killed',
|
||||
parameters_json: '{}',
|
||||
state_pool_id: null,
|
||||
mutations_json: '[]'
|
||||
})],
|
||||
outcomes: [outcome({
|
||||
state_pool_id: null,
|
||||
application_kind: 'restore_health',
|
||||
stat_attribute_id: null,
|
||||
resource_kind: 'health',
|
||||
delivery_mode: 'continuous',
|
||||
delivery_duration_seconds: null
|
||||
})],
|
||||
triggers: []
|
||||
}, { variantKey: 'I' });
|
||||
|
||||
expect(result.status).toBe('INVALID');
|
||||
expect(result.errors).toContain('Outcome 1 continuous delivery requires a positive duration.');
|
||||
});
|
||||
|
||||
it('adapts the Happy Survival branches, explicit cadence, health timing, and upgrade references', () => {
|
||||
|
||||
@@ -82,6 +82,8 @@ export type ConditionalEffectOutcomeRow = {
|
||||
value_basis: DatabaseValue;
|
||||
resource_kind?: DatabaseValue;
|
||||
combat_timing?: DatabaseValue;
|
||||
delivery_mode?: DatabaseValue;
|
||||
delivery_duration_seconds?: DatabaseValue;
|
||||
value_scaling: DatabaseValue;
|
||||
unit: DatabaseValue;
|
||||
damage_basis: DatabaseValue;
|
||||
@@ -530,6 +532,17 @@ function parseOutcome(
|
||||
if (combatTiming && combatTiming !== 'pre_event' && combatTiming !== 'current') {
|
||||
errors.push(`${path} has invalid combat timing.`);
|
||||
}
|
||||
const delivery = nullableText(row.delivery_mode) ?? 'instant';
|
||||
if (delivery !== 'instant' && delivery !== 'continuous') {
|
||||
errors.push(`${path} has an unsupported delivery mode.`);
|
||||
}
|
||||
const durationSeconds = number(row.delivery_duration_seconds);
|
||||
if (delivery === 'continuous' && (durationSeconds === null || durationSeconds <= 0)) {
|
||||
errors.push(`${path} continuous delivery requires a positive duration.`);
|
||||
}
|
||||
if (delivery === 'instant' && durationSeconds !== null) {
|
||||
errors.push(`${path} instant delivery cannot set a duration.`);
|
||||
}
|
||||
return {
|
||||
clauseId: nullableText(row.clause_id),
|
||||
outcome: {
|
||||
@@ -546,6 +559,9 @@ function parseOutcome(
|
||||
...(combatTiming === 'pre_event' || combatTiming === 'current'
|
||||
? { combatTiming }
|
||||
: {}),
|
||||
...(delivery === 'continuous' && durationSeconds !== null
|
||||
? { delivery, durationSeconds }
|
||||
: {}),
|
||||
...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id)
|
||||
? { attributeId: text(row.stat_attribute_id) }
|
||||
: {}),
|
||||
|
||||
@@ -784,6 +784,140 @@ describe('structured mechanic runtime', () => {
|
||||
expect(result.script).toContain('COUNT 3 CONSECUTIVE MATCHES PER EVENT.targetId');
|
||||
expect(result.script).toContain('[RECIPE distinct-enemies-within-window]');
|
||||
});
|
||||
it('resolves live Attribute percentages and delivers a restoration total continuously', () => {
|
||||
const firepowerMultiplierId = '82a95611-283b-4fd8-a6ed-3567a316f36e';
|
||||
const program: MechanicProgram = {
|
||||
id: 'predatory-aura',
|
||||
name: 'Predatory Aura',
|
||||
clauses: [{
|
||||
id: 'restore-on-kill',
|
||||
when: 'enemy_killed',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [{
|
||||
id: 'health-restoration',
|
||||
label: 'Health restoration',
|
||||
phase: 'after_event',
|
||||
kind: 'restoration',
|
||||
scope: 'self',
|
||||
resource: 'health',
|
||||
basis: 'flat',
|
||||
delivery: 'continuous',
|
||||
durationSeconds: 3,
|
||||
value: {
|
||||
op: 'multiply',
|
||||
left: 240,
|
||||
right: {
|
||||
op: 'divide',
|
||||
left: { ref: { source: 'attribute', attributeId: firepowerMultiplierId } },
|
||||
right: 100
|
||||
}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
};
|
||||
const result = evaluateMechanicProgram(
|
||||
program,
|
||||
[{ kind: 'enemy_killed', at: 0 }],
|
||||
custom(3),
|
||||
{
|
||||
maxHealth: 1_000,
|
||||
currentHealth: 500,
|
||||
attributes: { [firepowerMultiplierId]: 100 }
|
||||
}
|
||||
);
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.outcomes).toHaveLength(1);
|
||||
expect(result.outcomes[0]).toMatchObject({
|
||||
at: 3,
|
||||
delivery: 'continuous',
|
||||
deliveryStart: 0,
|
||||
deliveryEnd: 3,
|
||||
totalResolvedValue: 240,
|
||||
resolvedValue: 240,
|
||||
appliedValue: 240
|
||||
});
|
||||
expect(result.finalCombat?.currentHealth).toBe(740);
|
||||
expect(result.script).toContain(
|
||||
'ATTRIBUTE[82a95611-283b-4fd8-a6ed-3567a316f36e] / 100'
|
||||
);
|
||||
expect(result.script).toContain('DELIVER TOTAL CONTINUOUSLY OVER 3s');
|
||||
|
||||
const missingAttribute = evaluateMechanicProgram(
|
||||
program,
|
||||
[{ kind: 'enemy_killed', at: 0 }],
|
||||
custom(3),
|
||||
{ maxHealth: 1_000, currentHealth: 500 }
|
||||
);
|
||||
expect(missingAttribute.status).toBe('INVALID');
|
||||
expect(missingAttribute.errors).toContain(
|
||||
`This mechanic requires authoritative Attribute ${firepowerMultiplierId}.`
|
||||
);
|
||||
});
|
||||
|
||||
it('integrates overlapping restoration streams at event boundaries without banking overheal', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'continuous-restoration',
|
||||
name: 'Continuous Restoration',
|
||||
clauses: [{
|
||||
id: 'restore',
|
||||
when: 'enemy_killed',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [{
|
||||
id: 'restore-health',
|
||||
label: 'Restore Health',
|
||||
phase: 'after_event',
|
||||
kind: 'restoration',
|
||||
scope: 'self',
|
||||
resource: 'health',
|
||||
basis: 'flat',
|
||||
value: 240,
|
||||
delivery: 'continuous',
|
||||
durationSeconds: 3
|
||||
}]
|
||||
}, {
|
||||
id: 'damage-between-streams',
|
||||
when: 'agent_hit',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [{
|
||||
id: 'health-cost',
|
||||
label: 'Incoming damage proxy',
|
||||
phase: 'on_event',
|
||||
kind: 'resource_cost',
|
||||
scope: 'self',
|
||||
resource: 'health',
|
||||
basis: 'flat',
|
||||
value: 100
|
||||
}]
|
||||
}]
|
||||
};
|
||||
const result = evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{ kind: 'enemy_killed', at: 0 },
|
||||
{ kind: 'agent_hit', at: 1 },
|
||||
{ kind: 'enemy_killed', at: 1 }
|
||||
],
|
||||
custom(4),
|
||||
{ maxHealth: 1_000, currentHealth: 500 }
|
||||
);
|
||||
const continuous = result.outcomes.filter((outcome) => outcome.delivery === 'continuous');
|
||||
expect(continuous[0]).toMatchObject({ at: 1, resolvedValue: 80, appliedValue: 80 });
|
||||
expect(continuous.reduce((total, outcome) => total + outcome.resolvedValue, 0)).toBeCloseTo(480);
|
||||
expect(continuous.reduce((total, outcome) => total + outcome.appliedValue, 0)).toBeCloseTo(480);
|
||||
expect(result.finalCombat?.currentHealth).toBe(880);
|
||||
|
||||
const capped = evaluateMechanicProgram(
|
||||
program,
|
||||
[{ kind: 'enemy_killed', at: 0 }],
|
||||
custom(3),
|
||||
{ maxHealth: 1_000, currentHealth: 950 }
|
||||
);
|
||||
expect(capped.outcomes.reduce((total, outcome) => total + outcome.appliedValue, 0)).toBe(50);
|
||||
expect(capped.finalCombat?.currentHealth).toBe(1_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mechanic validation and pseudo-script', () => {
|
||||
@@ -866,6 +1000,37 @@ describe('mechanic validation and pseudo-script', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid continuous-delivery combinations', () => {
|
||||
const base = {
|
||||
id: 'delivery-validation',
|
||||
name: 'Delivery Validation',
|
||||
clauses: [{
|
||||
id: 'invalid-delivery',
|
||||
when: 'enemy_killed' as const,
|
||||
reducer: { kind: 'none' as const },
|
||||
mutations: [],
|
||||
outcomes: [{
|
||||
id: 'damage-over-time',
|
||||
label: 'Damage',
|
||||
phase: 'after_event' as const,
|
||||
kind: 'direct_damage' as const,
|
||||
scope: 'target' as const,
|
||||
basis: 'flat' as const,
|
||||
value: 10,
|
||||
delivery: 'continuous' as const,
|
||||
durationSeconds: 3
|
||||
}]
|
||||
}]
|
||||
};
|
||||
const result = validateMechanicProgram(base);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors).toContain(
|
||||
'Clause 1 outcome 1 can only use continuous delivery for restoration.'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('formats nested conditions and state operations deterministically', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'format',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user