feat: add source-owned combat mechanic programs
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { evaluateMechanicProgram, type MechanicProgram } from '$lib/calculations/mechanic';
|
||||
import { createDraft } from './loadout.js';
|
||||
import {
|
||||
buildWeaponMechanicTimeline,
|
||||
mechanicModifiersAt,
|
||||
weaponCriticalChance
|
||||
} from './loadout-combat.js';
|
||||
|
||||
describe('loadout mechanic timeline', () => {
|
||||
it('emits deterministic critical hits from modeled critical chance', () => {
|
||||
const profile = createDraft().combatProfile;
|
||||
profile.encounterSeconds = 4;
|
||||
profile.hitRate = 100;
|
||||
profile.shotsPerBurst = 100;
|
||||
profile.burstPauseSeconds = 0;
|
||||
|
||||
const timeline = buildWeaponMechanicTimeline(
|
||||
{ rpm: 60, magazine: 100, reload_speed: 0 },
|
||||
profile,
|
||||
50
|
||||
);
|
||||
const criticalHits = timeline
|
||||
.filter((event) => event.kind === 'weapon_hit')
|
||||
.map((event) => event.values?.critical);
|
||||
|
||||
expect(criticalHits).toEqual([false, true, false, true, false]);
|
||||
});
|
||||
|
||||
it('derives critical chance through the configured binding transform', () => {
|
||||
expect(weaponCriticalChance(
|
||||
{ critical: 80 },
|
||||
[{
|
||||
attributeId: 'critical',
|
||||
stage: 'critical_chance',
|
||||
operator: 'add',
|
||||
weight: 0.5,
|
||||
hardCap: 30,
|
||||
curveType: 'none'
|
||||
}]
|
||||
)).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-shot mechanic modifiers', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'phase-order',
|
||||
name: 'Phase order',
|
||||
pools: [{
|
||||
key: 'stacks',
|
||||
label: 'Stacks',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 10,
|
||||
expiresAfterSeconds: 10
|
||||
}],
|
||||
clauses: [
|
||||
{
|
||||
id: 'stack-on-hit',
|
||||
when: 'weapon_hit',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'add', pool: 'stacks', value: 1 }],
|
||||
outcomes: [
|
||||
{
|
||||
id: 'instant-a',
|
||||
label: 'Instant A',
|
||||
phase: 'on_event',
|
||||
kind: 'attribute_modifier',
|
||||
scope: 'equipped_weapon',
|
||||
value: 2,
|
||||
basis: 'flat',
|
||||
attributeId: 'weapon-damage'
|
||||
},
|
||||
{
|
||||
id: 'after-hit',
|
||||
label: 'After hit',
|
||||
phase: 'after_event',
|
||||
kind: 'attribute_modifier',
|
||||
scope: 'equipped_weapon',
|
||||
value: 100,
|
||||
basis: 'flat',
|
||||
attributeId: 'weapon-damage'
|
||||
},
|
||||
{
|
||||
id: 'active-stacks',
|
||||
label: 'Active stacks',
|
||||
phase: 'while_active',
|
||||
kind: 'attribute_modifier',
|
||||
scope: 'equipped_weapon',
|
||||
value: 3,
|
||||
basis: 'flat',
|
||||
attributeId: 'weapon-damage',
|
||||
statePool: 'stacks',
|
||||
perStack: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'second-instant',
|
||||
when: 'weapon_hit',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [{
|
||||
id: 'instant-b',
|
||||
label: 'Instant B',
|
||||
phase: 'on_event',
|
||||
kind: 'attribute_modifier',
|
||||
scope: 'equipped_weapon',
|
||||
value: 5,
|
||||
basis: 'flat',
|
||||
attributeId: 'weapon-damage'
|
||||
}]
|
||||
}
|
||||
]
|
||||
};
|
||||
const evaluation = evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{ kind: 'weapon_hit', at: 1, targetId: 'target' },
|
||||
{ kind: 'weapon_hit', at: 2, targetId: 'target' }
|
||||
],
|
||||
{ mode: 'custom', encounterSeconds: 5 }
|
||||
);
|
||||
const source = {
|
||||
status: 'OK' as const,
|
||||
result: evaluation,
|
||||
program,
|
||||
encounterSeconds: 5
|
||||
};
|
||||
|
||||
it('combines same-attribute on-event effects and excludes after-event effects', () => {
|
||||
expect(mechanicModifiersAt(source, 1, 'weapon_hit')).toEqual([{
|
||||
attributeId: 'weapon-damage',
|
||||
scope: 'equipped_weapon',
|
||||
value: 7
|
||||
}]);
|
||||
});
|
||||
|
||||
it('uses the pre-hit active stack state when the triggering hit adds another stack', () => {
|
||||
expect(mechanicModifiersAt(source, 1.5, 'weapon_hit')[0]?.value).toBe(3);
|
||||
expect(mechanicModifiersAt(source, 2, 'weapon_hit')[0]?.value).toBe(10);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,26 @@
|
||||
import {
|
||||
calculateCombat,
|
||||
expectedWeaponShotDamage,
|
||||
resolveWeaponFiringCycle,
|
||||
type AttributeBinding,
|
||||
type CalculationStage
|
||||
} from '$lib/calculations/combat';
|
||||
import {
|
||||
evaluateMechanicProgram,
|
||||
type MechanicCombatContext,
|
||||
type MechanicEvaluation,
|
||||
type MechanicEvent,
|
||||
type MechanicOutcome,
|
||||
type MechanicProgram,
|
||||
type NumericExpression
|
||||
} from '$lib/calculations/mechanic';
|
||||
import { adaptConditionalEffectProgram } from '$lib/calculations/mechanic-persistence';
|
||||
import {
|
||||
calculateSkillDps,
|
||||
type SkillAttributeBinding,
|
||||
type SkillCalculation,
|
||||
type SkillModifierWindow
|
||||
} from '$lib/calculations/skill';
|
||||
import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout';
|
||||
import type { WeaponCalculationResult } from '$lib/components/builder/WeaponCalculationResult.svelte';
|
||||
|
||||
@@ -16,7 +34,7 @@ type Catalog = {
|
||||
chipSets: any[];
|
||||
protocols: any[];
|
||||
specializations: any[];
|
||||
calculation: { revision: any | null; stages: any[]; profiles: any[] };
|
||||
calculation: { revision: any | null; stages: any[]; profiles: any[]; skillProfiles?: any[] };
|
||||
};
|
||||
|
||||
export type LoadoutSummary = {
|
||||
@@ -47,6 +65,16 @@ function attribute(catalog: Catalog, id: string) {
|
||||
return catalog.attributes.find((item) => item.id === id);
|
||||
}
|
||||
|
||||
function applyAttributeValue(catalog: Catalog, current: number, statId: string, incoming: number) {
|
||||
const stat = attribute(catalog, statId);
|
||||
const combined = stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
|
||||
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
|
||||
: current + incoming;
|
||||
return stat?.cap_value === null || stat?.cap_value === undefined
|
||||
? combined
|
||||
: Math.min(combined, Number(stat.cap_value));
|
||||
}
|
||||
|
||||
function unitValue(catalog: Catalog, value: unknown, statId: string) {
|
||||
const numeric = Number(value);
|
||||
const rendered = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, '');
|
||||
@@ -118,6 +146,7 @@ export function summarizeLoadout(draft: LoadoutDraft, catalog: Catalog): Loadout
|
||||
equippedTalents.push({
|
||||
key: `gear:${slot}:${selection.talentId}`,
|
||||
id: selection.talentId,
|
||||
rule: catalog.talents.find((entry) => entry.id === selection.talentId)?.rule ?? null,
|
||||
tier: Number(item.tier),
|
||||
source: slot,
|
||||
description: talentDescription(catalog, selection.talentId, Number(item.tier))
|
||||
@@ -152,6 +181,7 @@ export function summarizeLoadout(draft: LoadoutDraft, catalog: Catalog): Loadout
|
||||
equippedTalents.push({
|
||||
key: `weapon:${index}:${talentId}`,
|
||||
id: talentId,
|
||||
rule: catalog.talents.find((entry) => entry.id === talentId)?.rule ?? null,
|
||||
tier: selection.tier,
|
||||
source: index ? 'Secondary weapon' : 'Primary weapon',
|
||||
description: talentDescription(catalog, talentId, selection.tier)
|
||||
@@ -188,7 +218,9 @@ export function summarizeLoadout(draft: LoadoutDraft, catalog: Catalog): Loadout
|
||||
key: `chips:${group.item.id}`,
|
||||
id: '',
|
||||
custom: group.item.talent,
|
||||
rule: group.item.talent.rule ?? null,
|
||||
tier: group.highEnd,
|
||||
abilityId: group.item.specialization_ability_id ?? null,
|
||||
source: '3 Skill Chips',
|
||||
description: group.item.talent.description_template.replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => {
|
||||
const effect = group.item.talent.effects[Number(raw) - 1];
|
||||
@@ -223,6 +255,7 @@ export function summarizeLoadout(draft: LoadoutDraft, catalog: Catalog): Loadout
|
||||
key: `protocol:${protocol.id}`,
|
||||
id: '',
|
||||
custom: protocol.talent,
|
||||
rule: protocol.talent.rule ?? null,
|
||||
tier: draft.protocol.talentUpgrades,
|
||||
source: 'OS Protocol',
|
||||
description: protocol.talent.description_template.replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => {
|
||||
@@ -239,19 +272,6 @@ export function summarizeLoadout(draft: LoadoutDraft, catalog: Catalog): Loadout
|
||||
}
|
||||
}
|
||||
|
||||
for (const equipped of equippedTalents) {
|
||||
if (!draft.activeTalents.includes(equipped.key)) continue;
|
||||
const effects = equipped.custom?.effects ?? talentTier(catalog, equipped.id, equipped.tier)?.effects ?? [];
|
||||
for (const effect of effects) {
|
||||
const value = equipped.key.startsWith('chips:')
|
||||
? scaled(effect.min_value, effect.max_value, equipped.tier, 3)
|
||||
: equipped.key.startsWith('protocol:')
|
||||
? Number(effect.base_value) * (1 + equipped.tier / 5)
|
||||
: effect.value;
|
||||
add(effect.stat_attribute_id, value, `${equipped.source} talent`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stats: [...values]
|
||||
.map(([id, value]) => ({ id, value, sourceCount: sources.get(id)?.size ?? 0 }))
|
||||
@@ -265,7 +285,18 @@ export function summarizeLoadout(draft: LoadoutDraft, catalog: Catalog): Loadout
|
||||
}
|
||||
|
||||
function calculationStage(catalog: Catalog, stageKey: string): CalculationStage | null {
|
||||
if (['flat_damage', 'weapon_damage', 'critical_chance', 'critical_damage', 'headshot_chance', 'headshot_damage', 'final_damage'].includes(stageKey)) {
|
||||
if ([
|
||||
'flat_damage',
|
||||
'weapon_damage',
|
||||
'critical_chance',
|
||||
'critical_damage',
|
||||
'headshot_chance',
|
||||
'headshot_damage',
|
||||
'rate_of_fire',
|
||||
'magazine_size',
|
||||
'reload_speed',
|
||||
'final_damage'
|
||||
].includes(stageKey)) {
|
||||
return stageKey as CalculationStage;
|
||||
}
|
||||
const stage = catalog.calculation.stages.find((item) => item.stage_key === stageKey);
|
||||
@@ -275,6 +306,9 @@ function calculationStage(catalog: Catalog, stageKey: string): CalculationStage
|
||||
if (stage.operator === 'multiplicative_percent') return 'final_damage';
|
||||
if (stage.operator === 'expected_critical') return stageKey.includes('chance') ? 'critical_chance' : 'critical_damage';
|
||||
if (stage.operator === 'expected_headshot') return stageKey.includes('chance') ? 'headshot_chance' : 'headshot_damage';
|
||||
if (stage.operator === 'rate_of_fire') return 'rate_of_fire';
|
||||
if (stage.operator === 'magazine_size') return 'magazine_size';
|
||||
if (stage.operator === 'reload_speed') return 'reload_speed';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -298,6 +332,516 @@ function calculationBindings(catalog: Catalog, weapon: any): AttributeBinding[]
|
||||
});
|
||||
}
|
||||
|
||||
function transformedBindingValue(raw: number, binding: AttributeBinding) {
|
||||
const weight = Number.isFinite(binding.weight) ? Number(binding.weight) : 1;
|
||||
const sign = raw < 0 ? -1 : 1;
|
||||
let value = Math.abs(raw * weight);
|
||||
const softCap = Number.isFinite(Number(binding.softCap)) && Number(binding.softCap) >= 0
|
||||
? Number(binding.softCap)
|
||||
: null;
|
||||
const hardCap = Number.isFinite(Number(binding.hardCap)) && Number(binding.hardCap) >= 0
|
||||
? Number(binding.hardCap)
|
||||
: null;
|
||||
if (softCap !== null && value > softCap && binding.curveType && binding.curveType !== 'none') {
|
||||
const parameter = Math.max(0.01, Number(binding.curveParameter ?? 1));
|
||||
const overflow = value - softCap;
|
||||
if (binding.curveType === 'power') {
|
||||
value = softCap + overflow * Math.pow(softCap / Math.max(softCap + overflow, 0.000_001), parameter);
|
||||
} else {
|
||||
const ceiling = hardCap ?? Math.max(softCap * 2, softCap + 1);
|
||||
const ratio = Math.min(1, Math.max(0, overflow / Math.max(ceiling - softCap, 0.000_001)));
|
||||
value = softCap + (ceiling - softCap) * (ratio * ratio * (3 - 2 * ratio));
|
||||
}
|
||||
}
|
||||
if (hardCap !== null) value = Math.min(value, hardCap);
|
||||
return value * sign;
|
||||
}
|
||||
|
||||
export function weaponCriticalChance(
|
||||
attributes: Record<string, number>,
|
||||
bindings: AttributeBinding[]
|
||||
) {
|
||||
return Math.min(100, Math.max(0, bindings
|
||||
.filter((binding) => binding.stage === 'critical_chance' && binding.operator === 'add')
|
||||
.reduce((total, binding) =>
|
||||
total + transformedBindingValue(Number(attributes[binding.attributeId] ?? 0), binding), 0)));
|
||||
}
|
||||
|
||||
function conditionalProgram(
|
||||
rule: any,
|
||||
variantKey: string,
|
||||
valueScale = 1
|
||||
): MechanicProgram | null {
|
||||
if (!rule?.rule) return null;
|
||||
const adapted = adaptConditionalEffectProgram(rule, { variantKey });
|
||||
if (!adapted.program || adapted.status === 'INVALID' || adapted.status === 'CAPTURED_ONLY') return null;
|
||||
if (valueScale === 1) return adapted.program;
|
||||
const scale = (value: NumericExpression): NumericExpression =>
|
||||
typeof value === 'number' ? value * valueScale : value;
|
||||
return {
|
||||
...adapted.program,
|
||||
clauses: adapted.program.clauses.map((clause) => ({
|
||||
...clause,
|
||||
outcomes: clause.outcomes?.map((outcome) => ({ ...outcome, value: scale(outcome.value) }))
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function interpolatedConditionalProgram(
|
||||
rule: any,
|
||||
minimumVariant: string,
|
||||
maximumVariant: string,
|
||||
step: number,
|
||||
totalSteps: number
|
||||
): MechanicProgram | null {
|
||||
const minimum = conditionalProgram(rule, minimumVariant);
|
||||
const maximum = conditionalProgram(rule, maximumVariant);
|
||||
if (!minimum || !maximum) return minimum ?? maximum;
|
||||
const ratio = Math.max(0, Math.min(1, Number(step) / Math.max(1, Number(totalSteps))));
|
||||
const maximumByKey = new Map(maximum.clauses.flatMap((clause) =>
|
||||
(clause.outcomes ?? []).map((outcome) => [`${clause.id}:${outcome.id}`, outcome] as const)
|
||||
));
|
||||
return {
|
||||
...minimum,
|
||||
clauses: minimum.clauses.map((clause) => ({
|
||||
...clause,
|
||||
outcomes: clause.outcomes?.map((outcome) => {
|
||||
const upper = maximumByKey.get(`${clause.id}:${outcome.id}`);
|
||||
return upper && typeof outcome.value === 'number' && typeof upper.value === 'number'
|
||||
? { ...outcome, value: outcome.value + (upper.value - outcome.value) * ratio }
|
||||
: outcome;
|
||||
})
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function equippedConditionalProgram(equipped: any): MechanicProgram | null {
|
||||
if (String(equipped.key).startsWith('chips:')) {
|
||||
return interpolatedConditionalProgram(equipped.rule, 'minimum', 'maximum', Number(equipped.tier), 3);
|
||||
}
|
||||
if (String(equipped.key).startsWith('protocol:')) {
|
||||
return conditionalProgram(equipped.rule, 'base');
|
||||
}
|
||||
return conditionalProgram(
|
||||
equipped.rule,
|
||||
String(equipped.key).startsWith('weapon:') || String(equipped.key).startsWith('gear:')
|
||||
? (Number(equipped.tier) >= 2 ? 'II' : 'I')
|
||||
: 'base'
|
||||
);
|
||||
}
|
||||
|
||||
type ActiveMechanicEvaluation = {
|
||||
status: 'OK';
|
||||
result: MechanicEvaluation;
|
||||
program: MechanicProgram;
|
||||
encounterSeconds: number;
|
||||
};
|
||||
|
||||
function mechanicScenarioInputs(program: MechanicProgram, configured: unknown) {
|
||||
const values = configured && typeof configured === 'object' && !Array.isArray(configured)
|
||||
? configured as Record<string, number | boolean>
|
||||
: {};
|
||||
return Object.fromEntries((program.scenarioInputs ?? []).map((input) => [
|
||||
input.key,
|
||||
values[input.key] ?? input.defaultValue ?? (input.kind === 'toggle' ? false : 0)
|
||||
]));
|
||||
}
|
||||
|
||||
function evaluateConfiguredMechanic(
|
||||
program: MechanicProgram,
|
||||
events: MechanicEvent[],
|
||||
state: any,
|
||||
profile: LoadoutDraft['combatProfile'],
|
||||
weaponBaseDamage: number,
|
||||
maxHealth: number,
|
||||
calculationMode: LoadoutDraft['calculationMode'],
|
||||
equipmentContext = { protocolTalentUpgrades: 0 }
|
||||
): ActiveMechanicEvaluation | null {
|
||||
if (calculationMode !== 'maximum' && state.mode === 'off') return null;
|
||||
const encounterSeconds = Math.max(1, Number(profile.encounterSeconds));
|
||||
const maximum = calculationMode === 'maximum' || state.mode === 'peak';
|
||||
const result = evaluateMechanicProgram(
|
||||
program,
|
||||
events,
|
||||
maximum
|
||||
? { mode: 'maximum', encounterSeconds }
|
||||
: { mode: 'custom', encounterSeconds, inputs: mechanicScenarioInputs(program, state.inputs) },
|
||||
{
|
||||
maxHealth: Math.max(1, maxHealth),
|
||||
currentHealth: Math.max(0, maxHealth * Number(profile.healthPercent) / 100),
|
||||
weaponBaseDamage,
|
||||
values: { extraHealth: profile.extraHealthActive ? 1 : 0 }
|
||||
},
|
||||
equipmentContext
|
||||
);
|
||||
return result.status === 'OK' ? { status: 'OK', result, program, encounterSeconds } : null;
|
||||
}
|
||||
|
||||
type ModifierInterval = {
|
||||
start: number;
|
||||
end: number;
|
||||
value: number;
|
||||
stateValue: number;
|
||||
attributeId: string;
|
||||
scope: string;
|
||||
outcomeId: string;
|
||||
phase: MechanicOutcome['phase'];
|
||||
activationEvent: MechanicEvent['kind'];
|
||||
endAtNextActivation: boolean;
|
||||
};
|
||||
|
||||
function modifierIntervals(source: ActiveMechanicEvaluation): ModifierInterval[] {
|
||||
const emissions = source.result.outcomes
|
||||
.filter((emission) => emission.kind === 'attribute_modifier' && emission.attributeId)
|
||||
.sort((left, right) => left.at - right.at);
|
||||
return emissions.flatMap((emission, index) => {
|
||||
const clause = source.program.clauses.find((candidate) =>
|
||||
candidate.outcomes?.some((outcome) => outcome.id === emission.outcomeId));
|
||||
const definition = clause?.outcomes?.find((candidate) => candidate.id === emission.outcomeId);
|
||||
if (!clause || !definition) return [];
|
||||
const next = emissions.slice(index + 1).find((candidate) => candidate.outcomeId === emission.outcomeId);
|
||||
const pool = definition.statePool
|
||||
? source.program.pools?.find((candidate) => candidate.key === definition.statePool)
|
||||
: null;
|
||||
const naturalEnd = definition.phase === 'while_active'
|
||||
? emission.at + Number(pool?.expiresAfterSeconds ?? source.encounterSeconds)
|
||||
: emission.at;
|
||||
const end = Math.min(source.encounterSeconds, next?.at ?? naturalEnd, naturalEnd);
|
||||
if (
|
||||
end <= emission.at
|
||||
&& definition.phase !== 'while_active'
|
||||
&& definition.phase !== 'on_event'
|
||||
) return [];
|
||||
return [{
|
||||
start: emission.at,
|
||||
end: Math.max(emission.at, end),
|
||||
value: emission.appliedValue,
|
||||
stateValue: emission.stateValue,
|
||||
attributeId: String(emission.attributeId),
|
||||
scope: emission.scope,
|
||||
outcomeId: emission.outcomeId,
|
||||
phase: definition.phase,
|
||||
activationEvent: clause.when,
|
||||
endAtNextActivation: next !== undefined && next.at <= naturalEnd
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function averageModifierValues(source: ActiveMechanicEvaluation) {
|
||||
const values = new Map<string, { value: number; scope: string }>();
|
||||
for (const interval of modifierIntervals(source)) {
|
||||
const key = `${interval.attributeId}:${interval.scope}`;
|
||||
const current = values.get(key) ?? { value: 0, scope: interval.scope };
|
||||
current.value += interval.value * (interval.end - interval.start) / source.encounterSeconds;
|
||||
values.set(key, current);
|
||||
}
|
||||
return [...values].map(([key, value]) => ({
|
||||
attributeId: key.slice(0, key.lastIndexOf(':')),
|
||||
scope: value.scope,
|
||||
value: value.value
|
||||
}));
|
||||
}
|
||||
|
||||
const TIMELINE_EVENT_ORDER: Readonly<Record<MechanicEvent['kind'], number>> = {
|
||||
firing_started: 0,
|
||||
shot_fired: 1,
|
||||
weapon_hit: 2,
|
||||
damage_dealt: 3,
|
||||
firing_stopped: 4,
|
||||
reload_started: 5,
|
||||
reload_completed: 6,
|
||||
weapon_swapped: 7,
|
||||
enemy_killed: 8,
|
||||
assist: 9,
|
||||
skill_deployed: 10,
|
||||
skill_hit: 11,
|
||||
agent_hit: 12,
|
||||
health_damage_taken: 13,
|
||||
healing_applied: 14,
|
||||
extra_health_granted: 15,
|
||||
tick: 16,
|
||||
proximity_snapshot: 17,
|
||||
movement_snapshot: 18
|
||||
};
|
||||
|
||||
function intervalAppliesAt(
|
||||
interval: ModifierInterval,
|
||||
at: number,
|
||||
eventKind: MechanicEvent['kind']
|
||||
) {
|
||||
const epsilon = 0.000_001;
|
||||
const queryOrder = TIMELINE_EVENT_ORDER[eventKind];
|
||||
const activationOrder = TIMELINE_EVENT_ORDER[interval.activationEvent];
|
||||
if (interval.phase === 'on_event') {
|
||||
return Math.abs(at - interval.start) <= epsilon && activationOrder <= queryOrder;
|
||||
}
|
||||
if (interval.phase !== 'while_active') return false;
|
||||
if (at < interval.start - epsilon || at > interval.end + epsilon) return false;
|
||||
if (Math.abs(at - interval.start) <= epsilon && activationOrder >= queryOrder) return false;
|
||||
if (
|
||||
Math.abs(at - interval.end) <= epsilon
|
||||
&& !interval.endAtNextActivation
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function mechanicModifiersAt(
|
||||
source: ActiveMechanicEvaluation,
|
||||
at: number,
|
||||
eventKind: MechanicEvent['kind'] = 'weapon_hit'
|
||||
) {
|
||||
const values = new Map<string, { value: number; scope: string }>();
|
||||
for (const interval of modifierIntervals(source)) {
|
||||
if (!intervalAppliesAt(interval, at, eventKind)) continue;
|
||||
const key = `${interval.attributeId}:${interval.scope}`;
|
||||
const current = values.get(key) ?? { value: 0, scope: interval.scope };
|
||||
current.value += interval.value;
|
||||
values.set(key, current);
|
||||
}
|
||||
return [...values].map(([key, value]) => ({
|
||||
attributeId: key.slice(0, key.lastIndexOf(':')),
|
||||
scope: value.scope,
|
||||
value: value.value
|
||||
}));
|
||||
}
|
||||
|
||||
function mechanicSummary(source: ActiveMechanicEvaluation) {
|
||||
const intervals = modifierIntervals(source);
|
||||
const maxStacks = Math.max(1, ...(source.program.pools ?? []).map((pool) => Number(pool.max ?? 1)));
|
||||
const maxStacksReached = Math.max(0, ...intervals.map((interval) => interval.stateValue));
|
||||
const covered = intervals.reduce((total, interval) => total + Math.max(0, interval.end - interval.start), 0);
|
||||
const uptimePercent = Math.min(100, covered / source.encounterSeconds * 100);
|
||||
const buildUp = source.result.outcomes.find((emission) => emission.stateValue >= maxStacks)?.at ?? null;
|
||||
return {
|
||||
averageStacks: intervals.reduce(
|
||||
(total, interval) => total + interval.stateValue * (interval.end - interval.start) / source.encounterSeconds,
|
||||
0
|
||||
),
|
||||
maxStacksReached,
|
||||
maxStacks,
|
||||
stacksAtEnd: Math.max(0, ...source.result.finalState.map((state) => state.value)),
|
||||
buildUpSeconds: buildUp,
|
||||
maintained: uptimePercent >= 99.9 && maxStacksReached >= maxStacks,
|
||||
uptimePercent,
|
||||
activationCount: source.result.estimatedActivationCount,
|
||||
instantTotal: source.result.outcomes
|
||||
.filter((outcome) => outcome.kind !== 'direct_damage' && outcome.kind !== 'attribute_modifier')
|
||||
.reduce((total, outcome) => total + outcome.appliedValue, 0)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWeaponMechanicTimeline(
|
||||
weapon: any,
|
||||
profile: LoadoutDraft['combatProfile'],
|
||||
criticalChancePercent = 0
|
||||
): MechanicEvent[] {
|
||||
const encounterSeconds = Math.max(0, Number(profile.encounterSeconds));
|
||||
const rpm = Math.max(1, Number(weapon?.rpm));
|
||||
const magazine = Math.max(1, Math.round(Number(weapon?.magazine)));
|
||||
const reloadSeconds = Math.max(0, Number(weapon?.reload_speed));
|
||||
const shotsPerBurst = Math.max(1, Math.round(Number(profile.shotsPerBurst)));
|
||||
const pauseSeconds = Math.max(0, Number(profile.burstPauseSeconds));
|
||||
const targetInterval = Number(profile.distinctTargetsPerMinute) > 0
|
||||
? Math.min(
|
||||
Math.max(0.1, Number(profile.sameTargetSeconds)),
|
||||
60 / Number(profile.distinctTargetsPerMinute)
|
||||
)
|
||||
: Math.max(0.1, Number(profile.sameTargetSeconds));
|
||||
const events: MechanicEvent[] = [{ kind: 'firing_started', at: 0 }];
|
||||
let time = 0;
|
||||
let magazineRemaining = magazine;
|
||||
let burstRemaining = shotsPerBurst;
|
||||
let hitAccumulator = 0;
|
||||
let headshotAccumulator = 0;
|
||||
let criticalAccumulator = 0;
|
||||
const criticalChance = Math.max(0, Math.min(100, Number(criticalChancePercent))) / 100;
|
||||
const shotInterval = 60 / rpm;
|
||||
while (time <= encounterSeconds && events.length < 100_000) {
|
||||
if (magazineRemaining <= 0) {
|
||||
events.push({ kind: 'reload_started', at: time });
|
||||
time += reloadSeconds;
|
||||
if (time <= encounterSeconds) {
|
||||
events.push({ kind: 'reload_completed', at: time });
|
||||
events.push({ kind: 'firing_started', at: time });
|
||||
}
|
||||
magazineRemaining = magazine;
|
||||
burstRemaining = shotsPerBurst;
|
||||
continue;
|
||||
}
|
||||
if (burstRemaining <= 0) {
|
||||
if (pauseSeconds > 0) {
|
||||
events.push({ kind: 'firing_stopped', at: time });
|
||||
time += pauseSeconds;
|
||||
if (time <= encounterSeconds) events.push({ kind: 'firing_started', at: time });
|
||||
}
|
||||
burstRemaining = shotsPerBurst;
|
||||
continue;
|
||||
}
|
||||
hitAccumulator += Math.max(0, Math.min(100, Number(profile.hitRate))) / 100;
|
||||
const hit = hitAccumulator >= 1;
|
||||
if (hit) hitAccumulator -= 1;
|
||||
if (hit) headshotAccumulator += Math.max(0, Math.min(100, Number(profile.headshotRate))) / 100;
|
||||
const headshot = hit && headshotAccumulator >= 1;
|
||||
if (headshot) headshotAccumulator -= 1;
|
||||
if (hit) criticalAccumulator += criticalChance;
|
||||
const critical = hit && criticalAccumulator >= 1;
|
||||
if (critical) criticalAccumulator -= 1;
|
||||
const targetId = `target-${Math.floor(time / targetInterval)}`;
|
||||
events.push({
|
||||
kind: 'shot_fired',
|
||||
at: time,
|
||||
targetId,
|
||||
values: {
|
||||
magazineRemaining,
|
||||
magazinePercent: magazineRemaining / magazine * 100,
|
||||
distanceMeters: Number(profile.targetDistanceMeters)
|
||||
}
|
||||
});
|
||||
if (hit) {
|
||||
const values = {
|
||||
headshot,
|
||||
critical,
|
||||
distanceMeters: Number(profile.targetDistanceMeters)
|
||||
};
|
||||
events.push({ kind: 'weapon_hit', at: time, targetId, values });
|
||||
events.push({ kind: 'damage_dealt', at: time, targetId, values: { ...values, source: 'weapon' } });
|
||||
}
|
||||
magazineRemaining -= 1;
|
||||
burstRemaining -= 1;
|
||||
time += shotInterval;
|
||||
}
|
||||
events.push({ kind: 'firing_stopped', at: encounterSeconds });
|
||||
const intervalEvents = (
|
||||
kind: 'enemy_killed' | 'health_damage_taken' | 'weapon_swapped',
|
||||
perMinute: number
|
||||
) => {
|
||||
if (!(perMinute > 0)) return;
|
||||
const interval = 60 / perMinute;
|
||||
let headshotKillAccumulator = 0;
|
||||
for (let at = interval; at <= encounterSeconds; at += interval) {
|
||||
if (kind === 'enemy_killed') {
|
||||
headshotKillAccumulator += Math.max(0, Math.min(100, Number(profile.headshotKillRate))) / 100;
|
||||
const headshot = headshotKillAccumulator >= 1;
|
||||
if (headshot) headshotKillAccumulator -= 1;
|
||||
events.push({
|
||||
kind,
|
||||
at,
|
||||
targetId: `target-${Math.floor(at / targetInterval)}`,
|
||||
values: { headshot, source: 'weapon' }
|
||||
});
|
||||
}
|
||||
else events.push({ kind, at });
|
||||
}
|
||||
};
|
||||
intervalEvents('enemy_killed', Number(profile.killsPerMinute));
|
||||
intervalEvents('health_damage_taken', Number(profile.damageTakenPerMinute));
|
||||
intervalEvents('weapon_swapped', Number(profile.swapsPerMinute));
|
||||
for (let at = 0; at <= encounterSeconds; at += 1) {
|
||||
events.push({ kind: 'tick', at });
|
||||
events.push({
|
||||
kind: 'proximity_snapshot',
|
||||
at,
|
||||
values: {
|
||||
distanceMeters: Number(profile.targetDistanceMeters),
|
||||
targetDistanceMeters: Number(profile.targetDistanceMeters),
|
||||
enemyCount: 1,
|
||||
allyCount: 0
|
||||
}
|
||||
});
|
||||
events.push({ kind: 'movement_snapshot', at, values: { moving: false, sprinting: false } });
|
||||
}
|
||||
return events.sort((left, right) => left.at - right.at);
|
||||
}
|
||||
|
||||
function effectAppliesToWeapon(scope: string, sourceKey: string, weaponIndex: number) {
|
||||
if (scope === 'allies' || scope === 'assigned_ability' || scope === 'all_skills') return false;
|
||||
if (scope !== 'equipped_weapon') return true;
|
||||
const match = sourceKey.match(/^weapon:(\d+):/);
|
||||
return !match || Number(match[1]) === weaponIndex;
|
||||
}
|
||||
|
||||
function effectAppliesToSkills(scope: string) {
|
||||
return !['allies', 'equipped_weapon'].includes(scope);
|
||||
}
|
||||
|
||||
function skillModifierWindows(
|
||||
draft: LoadoutDraft,
|
||||
catalog: Catalog,
|
||||
summary: LoadoutSummary,
|
||||
allowedAttributeIds: Set<string>
|
||||
): SkillModifierWindow[] {
|
||||
const focus = selectedFocus(draft, catalog);
|
||||
const combatEffects = selectedNodeRanks(draft, catalog).flatMap(({ node, rank }: any) =>
|
||||
(rank?.combatEffects ?? []).map((effect: any) => ({
|
||||
...effect,
|
||||
nodeName: node.name,
|
||||
abilityId: null
|
||||
}))
|
||||
);
|
||||
const sources = [
|
||||
...summary.equippedTalents.map((equipped: any) => ({
|
||||
key: String(equipped.key),
|
||||
abilityId: equipped.abilityId ? String(equipped.abilityId) : null,
|
||||
equipmentContext: {
|
||||
protocolTalentUpgrades: String(equipped.key).startsWith('protocol:')
|
||||
? Number(equipped.tier)
|
||||
: 0
|
||||
},
|
||||
program: equippedConditionalProgram(equipped)
|
||||
})),
|
||||
...combatEffects.map((effect: any) => ({
|
||||
key: String(effect.id),
|
||||
abilityId: effect.abilityId,
|
||||
equipmentContext: { protocolTalentUpgrades: 0 },
|
||||
program: conditionalProgram(effect.rule, 'base')
|
||||
}))
|
||||
].filter((source) => source.program);
|
||||
const primarySelection = draft.weapons[0];
|
||||
const primaryWeapon = catalog.weapons.find((weapon) => weapon.id === primarySelection.itemId);
|
||||
const primaryBindings = calculationBindings(catalog, primaryWeapon);
|
||||
const baseAttributes = Object.fromEntries(summary.stats.map((stat) => [stat.id, stat.value]));
|
||||
const primaryCriticalChance = weaponCriticalChance(baseAttributes, primaryBindings);
|
||||
const focusAbilityIds = new Set((focus?.abilities ?? []).map((ability: any) => String(ability.id)));
|
||||
const windows: SkillModifierWindow[] = [];
|
||||
const maxHealthId = catalog.attributes.find((item) => item.name === 'Health')?.id;
|
||||
const maxHealth = summary.stats.find((stat) => stat.id === maxHealthId)?.value ?? 1;
|
||||
const evaluatedSources = sources.flatMap((source) => {
|
||||
const state = draft.conditionalEffects[source.key] ?? { mode: 'off' };
|
||||
const profile = { ...draft.combatProfile, ...(state.overrides ?? {}) };
|
||||
const evaluation = evaluateConfiguredMechanic(
|
||||
source.program!,
|
||||
primaryWeapon ? buildWeaponMechanicTimeline(primaryWeapon, profile, primaryCriticalChance) : [],
|
||||
state,
|
||||
profile,
|
||||
Number(primarySelection.damage ?? 0),
|
||||
maxHealth,
|
||||
draft.calculationMode,
|
||||
source.equipmentContext
|
||||
);
|
||||
return evaluation ? [{ ...source, evaluation }] : [];
|
||||
});
|
||||
|
||||
for (const source of evaluatedSources) {
|
||||
for (const interval of modifierIntervals(source.evaluation)) {
|
||||
if (!effectAppliesToSkills(interval.scope) || !allowedAttributeIds.has(interval.attributeId)) continue;
|
||||
const abilityIds = interval.scope === 'assigned_ability'
|
||||
? (source.abilityId && focusAbilityIds.has(source.abilityId) ? [source.abilityId] : [])
|
||||
: undefined;
|
||||
if (interval.scope === 'assigned_ability' && !abilityIds?.length) continue;
|
||||
windows.push({
|
||||
startSeconds: interval.start,
|
||||
endSeconds: interval.end,
|
||||
attributes: { [interval.attributeId]: interval.value },
|
||||
abilityIds
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return windows;
|
||||
}
|
||||
|
||||
export function evaluateLoadoutCombat(
|
||||
draft: LoadoutDraft,
|
||||
catalog: Catalog,
|
||||
@@ -307,6 +851,24 @@ export function evaluateLoadoutCombat(
|
||||
const combatEffects = selectedNodeRanks(draft, catalog).flatMap(({ node, rank }: any) =>
|
||||
(rank?.combatEffects ?? []).map((effect: any) => ({ ...effect, nodeName: node.name }))
|
||||
);
|
||||
const conditionalSources = [
|
||||
...summary.equippedTalents.map((equipped: any) => ({
|
||||
key: equipped.key,
|
||||
name: equipped.custom?.name ?? catalog.talents.find((talent) => talent.id === equipped.id)?.name ?? 'Talent',
|
||||
equipmentContext: {
|
||||
protocolTalentUpgrades: String(equipped.key).startsWith('protocol:')
|
||||
? Number(equipped.tier)
|
||||
: 0
|
||||
},
|
||||
program: equippedConditionalProgram(equipped)
|
||||
})),
|
||||
...combatEffects.map((effect: any) => ({
|
||||
key: String(effect.id),
|
||||
name: String(effect.definition?.name ?? effect.nodeName ?? 'Specialization effect'),
|
||||
equipmentContext: { protocolTalentUpgrades: 0 },
|
||||
program: conditionalProgram(effect.rule, 'base')
|
||||
}))
|
||||
].filter((source) => source.program);
|
||||
const required = (weaponIndex: number) => {
|
||||
const missing: string[] = [];
|
||||
if (!catalog.calculation.revision) missing.push('An active ISAC calculation revision');
|
||||
@@ -337,29 +899,79 @@ export function evaluateLoadoutCombat(
|
||||
};
|
||||
}
|
||||
const bindings = calculationBindings(catalog, weapon);
|
||||
const effects = combatEffects.map((effect: any) => ({
|
||||
id: String(effect.id),
|
||||
trigger: {
|
||||
hitsRequired: Number(effect.trigger_count),
|
||||
windowSeconds: Number(effect.trigger_window_seconds)
|
||||
},
|
||||
lifecycle: {
|
||||
maxStacks: Number(effect.max_stacks),
|
||||
durationSeconds: Number(effect.duration_seconds),
|
||||
stacksPerTrigger: Number(effect.stacks_per_trigger)
|
||||
},
|
||||
attributes: Object.fromEntries(effect.modifiers.map((modifier: any) => [
|
||||
String(modifier.stat_attribute_id),
|
||||
Number(modifier.value_per_stack)
|
||||
]))
|
||||
}));
|
||||
const perEffect = Object.fromEntries(combatEffects.map((effect: any) => {
|
||||
const state = draft.specialization.effectStates[effect.id] ?? { mode: 'off' };
|
||||
return [effect.id, {
|
||||
mode: state.mode,
|
||||
...(state.overrides ?? {})
|
||||
}];
|
||||
}));
|
||||
const baseAttributes = Object.fromEntries(summary.stats.map((stat) => [stat.id, stat.value]));
|
||||
const maxHealthId = catalog.attributes.find((item) => item.name === 'Health')?.id;
|
||||
const maxHealth = Number(baseAttributes[maxHealthId] ?? 1);
|
||||
const evaluateSources = (
|
||||
timelineWeapon: any,
|
||||
criticalAttributes: Record<string, number>
|
||||
) => conditionalSources.flatMap((source) => {
|
||||
const state = draft.conditionalEffects[source.key] ?? { mode: 'off' };
|
||||
const profile = { ...draft.combatProfile, ...(state.overrides ?? {}) };
|
||||
const evaluation = evaluateConfiguredMechanic(
|
||||
source.program!,
|
||||
buildWeaponMechanicTimeline(
|
||||
timelineWeapon,
|
||||
profile,
|
||||
weaponCriticalChance(criticalAttributes, bindings)
|
||||
),
|
||||
state,
|
||||
profile,
|
||||
Number(selection.damage),
|
||||
maxHealth,
|
||||
draft.calculationMode,
|
||||
source.equipmentContext
|
||||
);
|
||||
return evaluation ? [{ ...source, state, evaluation }] : [];
|
||||
});
|
||||
const attributesFrom = (evaluations: ReturnType<typeof evaluateSources>) => {
|
||||
const values = { ...baseAttributes };
|
||||
for (const source of evaluations) {
|
||||
for (const modifier of averageModifierValues(source.evaluation)) {
|
||||
if (!effectAppliesToWeapon(modifier.scope, source.key, weaponIndex)) continue;
|
||||
values[modifier.attributeId] = applyAttributeValue(
|
||||
catalog,
|
||||
values[modifier.attributeId] ?? 0,
|
||||
modifier.attributeId,
|
||||
modifier.value
|
||||
);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
};
|
||||
let timelineWeapon = { ...weapon };
|
||||
let evaluations = evaluateSources(timelineWeapon, baseAttributes);
|
||||
for (let pass = 0; pass < 3; pass += 1) {
|
||||
const currentAttributes = attributesFrom(evaluations);
|
||||
const cycle = resolveWeaponFiringCycle(
|
||||
{
|
||||
rpm: Number(weapon?.rpm),
|
||||
magazine: Number(weapon?.magazine),
|
||||
reloadSeconds: Number(weapon?.reload_speed)
|
||||
},
|
||||
currentAttributes,
|
||||
bindings
|
||||
);
|
||||
timelineWeapon = {
|
||||
...weapon,
|
||||
rpm: cycle.rpm,
|
||||
magazine: cycle.magazine,
|
||||
reload_speed: cycle.reloadSeconds
|
||||
};
|
||||
evaluations = evaluateSources(timelineWeapon, currentAttributes);
|
||||
}
|
||||
const modeledAttributes = attributesFrom(evaluations);
|
||||
let directDamageTotal = 0;
|
||||
for (const source of evaluations) {
|
||||
for (const outcome of source.evaluation.result.outcomes) {
|
||||
if (outcome.kind !== 'direct_damage'
|
||||
|| !effectAppliesToWeapon(outcome.scope, source.key, weaponIndex)) continue;
|
||||
directDamageTotal += outcome.appliedValue;
|
||||
}
|
||||
}
|
||||
const calculationStages = catalog.calculation.stages
|
||||
.map((stage) => calculationStage(catalog, String(stage.stage_key)))
|
||||
.filter((stage): stage is CalculationStage => stage !== null);
|
||||
const result = calculateCombat({
|
||||
weapon: {
|
||||
baseDamage: selection.damage,
|
||||
@@ -368,16 +980,15 @@ export function evaluateLoadoutCombat(
|
||||
reloadSeconds: Number(weapon?.reload_speed),
|
||||
headshotMultiplier: 2
|
||||
},
|
||||
attributes: Object.fromEntries(summary.stats.map((stat) => [stat.id, stat.value])),
|
||||
attributes: modeledAttributes,
|
||||
bindings,
|
||||
stageOrder: catalog.calculation.stages
|
||||
.map((stage) => calculationStage(catalog, String(stage.stage_key)))
|
||||
.filter((stage): stage is CalculationStage => stage !== null),
|
||||
stackingEffects: effects,
|
||||
stageOrder: calculationStages,
|
||||
assumptions: {
|
||||
mode: 'off',
|
||||
...draft.specialization.combatProfile,
|
||||
perEffect
|
||||
hitRate: draft.combatProfile.hitRate,
|
||||
headshotRate: draft.combatProfile.headshotRate,
|
||||
encounterSeconds: draft.combatProfile.encounterSeconds,
|
||||
firingActivity: 100,
|
||||
}
|
||||
});
|
||||
if (result.status === 'INSUFFICIENT_DATA') {
|
||||
@@ -393,6 +1004,40 @@ export function evaluateLoadoutCombat(
|
||||
revisionLabel: `RULES R${catalog.calculation.revision.version}`
|
||||
};
|
||||
}
|
||||
const timeline = buildWeaponMechanicTimeline(
|
||||
timelineWeapon,
|
||||
draft.combatProfile,
|
||||
weaponCriticalChance(modeledAttributes, bindings)
|
||||
);
|
||||
let encounterWeaponDamage = 0;
|
||||
for (const event of timeline) {
|
||||
if (event.kind !== 'weapon_hit') continue;
|
||||
const attributesAtShot = { ...baseAttributes };
|
||||
for (const source of evaluations) {
|
||||
for (const modifier of mechanicModifiersAt(source.evaluation, event.at, event.kind)) {
|
||||
if (!effectAppliesToWeapon(modifier.scope, source.key, weaponIndex)) continue;
|
||||
attributesAtShot[modifier.attributeId] = applyAttributeValue(
|
||||
catalog,
|
||||
attributesAtShot[modifier.attributeId] ?? 0,
|
||||
modifier.attributeId,
|
||||
modifier.value
|
||||
);
|
||||
}
|
||||
}
|
||||
encounterWeaponDamage += expectedWeaponShotDamage(
|
||||
{
|
||||
baseDamage: selection.damage,
|
||||
rpm: Number(weapon?.rpm),
|
||||
magazine: Number(weapon?.magazine),
|
||||
reloadSeconds: Number(weapon?.reload_speed),
|
||||
headshotMultiplier: 2
|
||||
},
|
||||
attributesAtShot,
|
||||
bindings,
|
||||
event.values?.headshot === true,
|
||||
calculationStages
|
||||
);
|
||||
}
|
||||
return {
|
||||
weaponName: weapon.name,
|
||||
weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON',
|
||||
@@ -403,22 +1048,107 @@ export function evaluateLoadoutCombat(
|
||||
criticalHeadshot: result.hitValues?.criticalHeadshot,
|
||||
burstDps: result.burstDps ?? undefined,
|
||||
sustainedDps: result.sustainedDps ?? undefined,
|
||||
encounterDps: result.encounterDps ?? undefined,
|
||||
effectSummaries: result.stackSummaries.map((stack) => {
|
||||
const effect = combatEffects.find((item: any) => String(item.id) === stack.effectId);
|
||||
return {
|
||||
id: stack.effectId,
|
||||
name: String(effect?.definition?.name ?? effect?.nodeName ?? 'Conditional effect'),
|
||||
mode: stack.mode,
|
||||
averageStacks: stack.averageStacks,
|
||||
maxStacksReached: stack.maxStacksReached,
|
||||
maxStacks: Number(effect?.max_stacks ?? 0),
|
||||
stacksAtEnd: stack.stacksAtEnd,
|
||||
buildUpSeconds: stack.buildUpSeconds,
|
||||
maintained: stack.maintained
|
||||
};
|
||||
}),
|
||||
encounterDps: (encounterWeaponDamage + directDamageTotal)
|
||||
/ Math.max(1, draft.combatProfile.encounterSeconds),
|
||||
effectSummaries: evaluations.map((source) => ({
|
||||
id: source.key,
|
||||
name: source.name,
|
||||
mode: source.state.mode,
|
||||
...mechanicSummary(source.evaluation)
|
||||
})),
|
||||
revisionLabel: `RULES R${catalog.calculation.revision.version}`
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function evaluateLoadoutSkillCombat(
|
||||
draft: LoadoutDraft,
|
||||
catalog: Catalog,
|
||||
summary = summarizeLoadout(draft, catalog)
|
||||
): SkillCalculation {
|
||||
const focus = selectedFocus(draft, catalog);
|
||||
const revisionId = String(catalog.calculation.revision?.id ?? '');
|
||||
const supportedStages = new Set([
|
||||
'flat_damage',
|
||||
'skill_damage',
|
||||
'final_damage',
|
||||
'critical_chance',
|
||||
'critical_damage',
|
||||
'multishot_chance',
|
||||
'multishot_damage',
|
||||
'cooldown_recovery',
|
||||
'duration'
|
||||
]);
|
||||
const bindings: SkillAttributeBinding[] = (catalog.calculation.skillProfiles ?? [])
|
||||
.flatMap((profile: any) => {
|
||||
const stage = profile.stage_key === 'engineering' ? 'skill_damage' : String(profile.stage_key);
|
||||
return supportedStages.has(stage)
|
||||
? [{
|
||||
attributeId: String(profile.stat_attribute_id),
|
||||
stage: stage as SkillAttributeBinding['stage'],
|
||||
operator: profile.operator === 'multiply' ? 'multiply' as const : 'add' as const,
|
||||
weight: Number(profile.weight ?? 1)
|
||||
}]
|
||||
: [];
|
||||
});
|
||||
const boundIds = new Set(bindings.map((binding) => binding.attributeId));
|
||||
const attributes = Object.fromEntries(
|
||||
summary.stats.filter((stat) => boundIds.has(stat.id)).map((stat) => [stat.id, stat.value])
|
||||
);
|
||||
const abilities = (focus?.abilities ?? []).map((ability: any) => {
|
||||
const attack = ability.attack;
|
||||
const selected = draft.skillActivations[ability.id] ?? { mode: 'off', targets: 1 };
|
||||
const activation = selected.mode === 'custom_per_minute'
|
||||
? { mode: 'custom_per_minute' as const, usesPerMinute: Number(selected.usesPerMinute ?? 1) }
|
||||
: selected.mode === 'on_cooldown'
|
||||
? { mode: 'on_cooldown' as const }
|
||||
: { mode: 'off' as const };
|
||||
const chargePolicy = attack?.charge_policy === 'independent'
|
||||
? { type: 'independent' as const, charges: Number(attack.charge_count) }
|
||||
: attack?.charge_policy === 'full_set'
|
||||
? { type: 'full_set' as const, charges: Number(attack.charge_count) }
|
||||
: { type: 'none' as const };
|
||||
return {
|
||||
id: String(ability.id),
|
||||
baseDamage: attack?.base_damage === undefined ? undefined : Number(attack.base_damage),
|
||||
damageType: attack?.damage_type,
|
||||
delivery: attack?.delivery === 'periodic'
|
||||
? {
|
||||
type: 'periodic' as const,
|
||||
ticks: Math.max(1, Math.floor(Number(attack.damage_duration_seconds) / Number(attack.tick_interval_seconds))),
|
||||
durationSeconds: Number(attack.damage_duration_seconds),
|
||||
hitsPerTick: Number(attack.hits_per_activation ?? 1),
|
||||
intervalSeconds: Number(attack.tick_interval_seconds),
|
||||
firstTickDelaySeconds: Number(attack.tick_interval_seconds)
|
||||
}
|
||||
: { type: 'direct' as const, hits: Number(attack?.hits_per_activation ?? 1) },
|
||||
castSeconds: Number(attack?.cast_seconds ?? 0),
|
||||
cooldownSeconds: Number(ability.cooldown_seconds ?? 0),
|
||||
targetCap: Number(attack?.target_cap ?? 1),
|
||||
targetCount: Number(selected.targets ?? 1),
|
||||
chargePolicy,
|
||||
activation
|
||||
};
|
||||
});
|
||||
return calculateSkillDps({
|
||||
rules: { revisionId, bindings },
|
||||
abilities,
|
||||
attributes,
|
||||
attributeRules: Object.fromEntries([...boundIds].map((id) => {
|
||||
const stat = attribute(catalog, id);
|
||||
return [id, {
|
||||
combination: stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
|
||||
? 'multiplicative_percent' as const
|
||||
: 'additive' as const,
|
||||
cap: stat?.cap_value === null || stat?.cap_value === undefined
|
||||
? null
|
||||
: Number(stat.cap_value)
|
||||
}];
|
||||
})),
|
||||
modifierWindows: skillModifierWindows(draft, catalog, summary, boundIds),
|
||||
assumptions: {
|
||||
encounterSeconds: draft.combatProfile.encounterSeconds,
|
||||
targetCount: Math.max(1, ...Object.values(draft.skillActivations).map((selection) => Number(selection.targets ?? 1)))
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ describe('parseLoadoutDraft', () => {
|
||||
const parsed = parseLoadoutDraft(draft);
|
||||
|
||||
expect(parsed).toEqual(draft);
|
||||
expect(parsed?.calculationMode).toBe('maximum');
|
||||
expect(parsed).not.toBe(draft);
|
||||
parsed!.gear.Holster.level = 1;
|
||||
expect(draft.gear.Holster.level).toBe(50);
|
||||
@@ -22,20 +23,46 @@ describe('parseLoadoutDraft', () => {
|
||||
expect(parseLoadoutDraft(retired)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a version 2 draft that does not contain exactly nine chips', () => {
|
||||
it('rejects a version 4 draft that does not contain exactly nine chips', () => {
|
||||
const draft = copyDraft();
|
||||
draft.chips.pop();
|
||||
|
||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a specialization missing a required current field', () => {
|
||||
const draft = copyDraft() as unknown as { specialization: Record<string, unknown> };
|
||||
delete draft.specialization.combatProfile;
|
||||
it('rejects a draft missing the unified combat profile', () => {
|
||||
const draft = copyDraft() as unknown as Record<string, unknown>;
|
||||
delete draft.combatProfile;
|
||||
|
||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a custom scenario and preserves its assumptions', () => {
|
||||
const draft = copyDraft();
|
||||
draft.calculationMode = 'custom';
|
||||
draft.combatProfile.hitRate = 63;
|
||||
draft.combatProfile.targetDistanceMeters = 28;
|
||||
draft.conditionalEffects.relentless = {
|
||||
mode: 'encounter',
|
||||
inputs: {
|
||||
alliesWithinRange: 3,
|
||||
targetHasDebuff: true
|
||||
}
|
||||
};
|
||||
|
||||
expect(parseLoadoutDraft(draft)).toEqual(draft);
|
||||
});
|
||||
|
||||
it('rejects drafts without the required calculation model', () => {
|
||||
const missing = copyDraft() as unknown as Record<string, unknown>;
|
||||
delete missing.calculationMode;
|
||||
expect(parseLoadoutDraft(missing)).toBeNull();
|
||||
|
||||
const invalid = copyDraft() as unknown as Record<string, unknown>;
|
||||
invalid.calculationMode = 'legacy';
|
||||
expect(parseLoadoutDraft(invalid)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a specialization that carries retired unassigned chips', () => {
|
||||
const draft = copyDraft() as unknown as { specialization: Record<string, unknown> };
|
||||
draft.specialization.unassignedChips = [];
|
||||
|
||||
+98
-25
@@ -3,23 +3,49 @@ export type GearSelection = { itemId: string; level: number; brandId: string; ta
|
||||
export type WeaponSelection = { itemId: string; level: number; tier: number; damage: number; firepower: number; toughness: number; engineering: number; talent1Id: string; talent2Id: string; rolls: [Roll, Roll] };
|
||||
export type ChipSelection = { setId: string; quality: 'Superior' | 'High-end'; primary: Roll; secondary: [Roll, Roll] };
|
||||
export type ProtocolSelection = { itemId: string; level: number; talentUpgrades: number };
|
||||
export type CombatProfile = { encounterSeconds: number; hitRate: number; headshotRate: number; firingActivity: number };
|
||||
export type CombatProfile = {
|
||||
encounterSeconds: number;
|
||||
hitRate: number;
|
||||
headshotRate: number;
|
||||
shotsPerBurst: number;
|
||||
burstPauseSeconds: number;
|
||||
sameTargetSeconds: number;
|
||||
distinctTargetsPerMinute: number;
|
||||
killsPerMinute: number;
|
||||
headshotKillRate: number;
|
||||
damageTakenPerMinute: number;
|
||||
swapsPerMinute: number;
|
||||
targetDistanceMeters: number;
|
||||
healthPercent: number;
|
||||
extraHealthActive: boolean;
|
||||
};
|
||||
export type CalculationMode = 'maximum' | 'custom';
|
||||
export type EffectMode = 'off' | 'peak' | 'encounter';
|
||||
export type ConditionalEffectSelection = { mode: EffectMode; overrides?: Partial<CombatProfile> };
|
||||
export type ConditionalEffectSelection = {
|
||||
mode: EffectMode;
|
||||
overrides?: Partial<CombatProfile>;
|
||||
inputs?: Record<string, number | boolean>;
|
||||
};
|
||||
export type SkillActivationSelection = {
|
||||
mode: 'off' | 'on_cooldown' | 'custom_per_minute';
|
||||
usesPerMinute?: number;
|
||||
targets: number;
|
||||
};
|
||||
export type SpecializationSelection = {
|
||||
focusId: string;
|
||||
nodeRanks: Record<string, number>;
|
||||
effectStates: Record<string, ConditionalEffectSelection>;
|
||||
combatProfile: CombatProfile;
|
||||
};
|
||||
export type LoadoutDraft = {
|
||||
version: 2;
|
||||
version: 4;
|
||||
buildName: string;
|
||||
calculationMode: CalculationMode;
|
||||
gear: Record<string, GearSelection>;
|
||||
weapons: [WeaponSelection, WeaponSelection];
|
||||
chips: [ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection];
|
||||
protocol: ProtocolSelection;
|
||||
activeTalents: string[];
|
||||
conditionalEffects: Record<string, ConditionalEffectSelection>;
|
||||
combatProfile: CombatProfile;
|
||||
skillActivations: Record<string, SkillActivationSelection>;
|
||||
specialization: SpecializationSelection;
|
||||
};
|
||||
|
||||
@@ -28,20 +54,36 @@ export const blankRoll = (): Roll => ({ statId: '', value: 0 });
|
||||
export const blankGear = (): GearSelection => ({ itemId: '', level: 50, brandId: '', talentId: '', health: 0, armor: 0, firepower: 0, toughness: 0, engineering: 0, rolls: [blankRoll(), blankRoll()], augmentSlots: [] });
|
||||
export const blankWeapon = (): WeaponSelection => ({ itemId: '', level: 50, tier: 0, damage: 0, firepower: 0, toughness: 0, engineering: 0, talent1Id: '', talent2Id: '', rolls: [blankRoll(), blankRoll()] });
|
||||
export const blankChip = (): ChipSelection => ({ setId: '', quality: 'Superior', primary: blankRoll(), secondary: [blankRoll(), blankRoll()] });
|
||||
export const defaultCombatProfile = (): CombatProfile => ({ encounterSeconds: 60, hitRate: 75, headshotRate: 20, firingActivity: 80 });
|
||||
export const defaultCombatProfile = (): CombatProfile => ({
|
||||
encounterSeconds: 60,
|
||||
hitRate: 75,
|
||||
headshotRate: 20,
|
||||
shotsPerBurst: 30,
|
||||
burstPauseSeconds: 0,
|
||||
sameTargetSeconds: 5,
|
||||
distinctTargetsPerMinute: 12,
|
||||
killsPerMinute: 4,
|
||||
headshotKillRate: 20,
|
||||
damageTakenPerMinute: 6,
|
||||
swapsPerMinute: 2,
|
||||
targetDistanceMeters: 15,
|
||||
healthPercent: 100,
|
||||
extraHealthActive: false
|
||||
});
|
||||
export const createDraft = (): LoadoutDraft => ({
|
||||
version: 2,
|
||||
version: 4,
|
||||
buildName: 'Untitled loadout',
|
||||
calculationMode: 'maximum',
|
||||
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
|
||||
weapons: [blankWeapon(), blankWeapon()],
|
||||
chips: [blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip()],
|
||||
protocol: { itemId: '', level: 1, talentUpgrades: 0 },
|
||||
activeTalents: [],
|
||||
conditionalEffects: {},
|
||||
combatProfile: defaultCombatProfile(),
|
||||
skillActivations: {},
|
||||
specialization: {
|
||||
focusId: '',
|
||||
nodeRanks: {},
|
||||
effectStates: {},
|
||||
combatProfile: defaultCombatProfile()
|
||||
nodeRanks: {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -88,39 +130,70 @@ const isProtocolSelection = (value: unknown): value is ProtocolSelection =>
|
||||
&& isFiniteNumber(value.talentUpgrades);
|
||||
const isCombatProfile = (value: unknown): value is CombatProfile =>
|
||||
isRecord(value)
|
||||
&& hasExactKeys(value, ['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'])
|
||||
&& ['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'].every((key) => isFiniteNumber(value[key]));
|
||||
&& hasExactKeys(value, [
|
||||
'encounterSeconds', 'hitRate', 'headshotRate', 'shotsPerBurst', 'burstPauseSeconds',
|
||||
'sameTargetSeconds', 'distinctTargetsPerMinute', 'killsPerMinute', 'headshotKillRate', 'damageTakenPerMinute',
|
||||
'swapsPerMinute', 'targetDistanceMeters', 'healthPercent', 'extraHealthActive'
|
||||
])
|
||||
&& [
|
||||
'encounterSeconds', 'hitRate', 'headshotRate', 'shotsPerBurst', 'burstPauseSeconds',
|
||||
'sameTargetSeconds', 'distinctTargetsPerMinute', 'killsPerMinute', 'headshotKillRate', 'damageTakenPerMinute',
|
||||
'swapsPerMinute', 'targetDistanceMeters', 'healthPercent'
|
||||
].every((key) => isFiniteNumber(value[key]))
|
||||
&& typeof value.extraHealthActive === 'boolean';
|
||||
const isEffectStates = (value: unknown): value is Record<string, ConditionalEffectSelection> =>
|
||||
isRecord(value) && Object.values(value).every((entry) => {
|
||||
if (!isRecord(entry)
|
||||
|| !hasExactKeys(entry, entry.overrides === undefined ? ['mode'] : ['mode', 'overrides'])
|
||||
|| !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false;
|
||||
const allowed = new Set(['mode', 'overrides', 'inputs']);
|
||||
if (Object.keys(entry).some((key) => !allowed.has(key))
|
||||
|| !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false;
|
||||
if (entry.inputs !== undefined && (
|
||||
!isRecord(entry.inputs)
|
||||
|| Object.values(entry.inputs).some((input) =>
|
||||
typeof input !== 'boolean' && !isFiniteNumber(input)
|
||||
)
|
||||
)) return false;
|
||||
return entry.overrides === undefined || (
|
||||
isRecord(entry.overrides)
|
||||
&& Object.entries(entry.overrides).every(([key, override]) =>
|
||||
['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'].includes(key) && isFiniteNumber(override)
|
||||
key === 'extraHealthActive'
|
||||
? typeof override === 'boolean'
|
||||
: key in defaultCombatProfile() && isFiniteNumber(override)
|
||||
)
|
||||
);
|
||||
});
|
||||
const isSkillActivations = (value: unknown): value is Record<string, SkillActivationSelection> =>
|
||||
isRecord(value) && Object.values(value).every((entry) =>
|
||||
isRecord(entry)
|
||||
&& ['off', 'on_cooldown', 'custom_per_minute'].includes(String(entry.mode))
|
||||
&& isFiniteNumber(entry.targets)
|
||||
&& (entry.usesPerMinute === undefined || isFiniteNumber(entry.usesPerMinute))
|
||||
&& Object.keys(entry).every((key) => ['mode', 'usesPerMinute', 'targets'].includes(key))
|
||||
);
|
||||
|
||||
export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
|
||||
if (!isRecord(value)
|
||||
|| !hasExactKeys(value, ['version', 'buildName', 'gear', 'weapons', 'chips', 'protocol', 'activeTalents', 'specialization'])
|
||||
|| value.version !== 2
|
||||
|| typeof value.buildName !== 'string') return null;
|
||||
|| !hasExactKeys(value, [
|
||||
'version', 'buildName', 'calculationMode', 'gear', 'weapons', 'chips', 'protocol',
|
||||
'conditionalEffects', 'combatProfile', 'skillActivations', 'specialization'
|
||||
])
|
||||
|| value.version !== 4
|
||||
|| typeof value.buildName !== 'string'
|
||||
|| !['maximum', 'custom'].includes(String(value.calculationMode))) return null;
|
||||
const gear = value.gear;
|
||||
if (!isRecord(gear) || Object.keys(gear).length !== gearSlots.length
|
||||
|| !gearSlots.every((slot) => isGearSelection(gear[slot]))) return null;
|
||||
if (!Array.isArray(value.weapons) || value.weapons.length !== 2 || !value.weapons.every(isWeaponSelection)) return null;
|
||||
if (!Array.isArray(value.chips) || value.chips.length !== 9 || !value.chips.every(isChipSelection)) return null;
|
||||
if (!isProtocolSelection(value.protocol) || !Array.isArray(value.activeTalents)
|
||||
|| !value.activeTalents.every((talent) => typeof talent === 'string')) return null;
|
||||
if (!isProtocolSelection(value.protocol)
|
||||
|| !isEffectStates(value.conditionalEffects)
|
||||
|| !isCombatProfile(value.combatProfile)
|
||||
|| !isSkillActivations(value.skillActivations)) return null;
|
||||
if (!isRecord(value.specialization)
|
||||
|| !hasExactKeys(value.specialization, ['focusId', 'nodeRanks', 'effectStates', 'combatProfile'])
|
||||
|| !hasExactKeys(value.specialization, ['focusId', 'nodeRanks'])
|
||||
|| typeof value.specialization.focusId !== 'string'
|
||||
|| !isNumberRecord(value.specialization.nodeRanks)
|
||||
|| !isEffectStates(value.specialization.effectStates)
|
||||
|| !isCombatProfile(value.specialization.combatProfile)) return null;
|
||||
|| !isNumberRecord(value.specialization.nodeRanks)) return null;
|
||||
return structuredClone(value) as LoadoutDraft;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
formatItemDamageRangeScript,
|
||||
formatWeaponCalculationScript
|
||||
} from './calculation-script';
|
||||
|
||||
describe('formatWeaponCalculationScript', () => {
|
||||
it('starts from the selected equipped damage and explains ordered typed stages', () => {
|
||||
const lines = formatWeaponCalculationScript({
|
||||
revision: { version: 4, name: 'Resurgence model' },
|
||||
stages: [
|
||||
{ stage_key: 'base', label: 'Base damage', operator: 'base_damage', display_order: 1 },
|
||||
{ stage_key: 'flat', label: 'Flat damage', operator: 'flat_add', display_order: 2 },
|
||||
{ stage_key: 'cycle', label: 'Firing cycle', operator: 'firing_cycle', display_order: 3 }
|
||||
],
|
||||
profiles: [{
|
||||
stat_attribute_id: 'weapon-flat',
|
||||
stage_key: 'flat',
|
||||
weight: 1.5,
|
||||
soft_cap: null,
|
||||
hard_cap: 100,
|
||||
curve_type: 'none',
|
||||
curve_parameter: null,
|
||||
weapon_category: 'SMG'
|
||||
}],
|
||||
attributes: [{ id: 'weapon-flat', name: 'Flat Weapon Damage', unit: null }]
|
||||
});
|
||||
expect(lines).toContain('TAKE equipped_weapon.damage AS base_damage');
|
||||
expect(lines).toContain('ADD flat_weapon_damage × 1.5 HARD_CAP 100 FOR SMG TO damage');
|
||||
expect(lines.at(-1)).toBe('RETURN expected_damage_per_second');
|
||||
});
|
||||
|
||||
it('keeps level scaling in item configuration instead of applying it twice', () => {
|
||||
const lines = formatItemDamageRangeScript();
|
||||
expect(lines).toContain('SCALE configured_damage_range TO equipped_weapon.level');
|
||||
expect(lines).toContain('PASS equipped_weapon.damage TO combat_calculation');
|
||||
expect(lines).not.toContain('SCALE equipped_weapon.damage TO equipped_weapon.level');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
export type CalculationScriptStage = {
|
||||
stage_key: string;
|
||||
label: string;
|
||||
operator: string;
|
||||
display_order: number;
|
||||
};
|
||||
|
||||
export type CalculationScriptProfile = {
|
||||
stat_attribute_id: string;
|
||||
stage_key: string;
|
||||
weight?: number | null;
|
||||
soft_cap?: number | null;
|
||||
hard_cap?: number | null;
|
||||
curve_type?: string | null;
|
||||
curve_parameter?: number | null;
|
||||
weapon_category?: string | null;
|
||||
};
|
||||
|
||||
export type CalculationScriptAttribute = {
|
||||
id: string;
|
||||
name: string;
|
||||
unit?: string | null;
|
||||
};
|
||||
|
||||
export type CalculationScriptRevision = {
|
||||
version?: number | null;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
const number = (value: unknown) =>
|
||||
Number.isInteger(Number(value)) ? String(Number(value)) : String(Number(Number(value).toFixed(3)));
|
||||
|
||||
const identifier = (value: unknown) => String(value ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '') || 'unnamed';
|
||||
|
||||
function profileExpression(
|
||||
profile: CalculationScriptProfile,
|
||||
attributes: Map<string, CalculationScriptAttribute>
|
||||
) {
|
||||
const attribute = attributes.get(profile.stat_attribute_id);
|
||||
const unit = attribute?.unit ? ` ${attribute.unit}` : '';
|
||||
const weight = Number(profile.weight ?? 1);
|
||||
let expression = `${identifier(attribute?.name ?? profile.stat_attribute_id)}${unit}`;
|
||||
if (weight !== 1) expression += ` × ${number(weight)}`;
|
||||
if (profile.soft_cap !== null && profile.soft_cap !== undefined) {
|
||||
const curve = profile.curve_type && profile.curve_type !== 'none'
|
||||
? ` USING ${String(profile.curve_type).toUpperCase()}(${number(profile.curve_parameter ?? 1)})`
|
||||
: '';
|
||||
expression += ` SOFT_CAP ${number(profile.soft_cap)}${curve}`;
|
||||
}
|
||||
if (profile.hard_cap !== null && profile.hard_cap !== undefined) {
|
||||
expression += ` HARD_CAP ${number(profile.hard_cap)}`;
|
||||
}
|
||||
if (profile.weapon_category && profile.weapon_category !== '*') {
|
||||
expression += ` FOR ${String(profile.weapon_category).toUpperCase()}`;
|
||||
}
|
||||
return expression;
|
||||
}
|
||||
|
||||
const operatorStatement = (operator: string, label: string, sources: string[]) => {
|
||||
const source = sources.length ? sources.join(', ') : 'no_bound_attributes';
|
||||
switch (operator) {
|
||||
case 'base_damage':
|
||||
return ['TAKE equipped_weapon.damage AS base_damage'];
|
||||
case 'flat_add':
|
||||
return [`ADD ${source} TO damage`];
|
||||
case 'additive_percent':
|
||||
return [`APPLY SUM(${source}) AS additive_percent TO damage`];
|
||||
case 'multiplicative_percent':
|
||||
return [`APPLY EACH(${source}) AS multiplicative_percent TO damage`];
|
||||
case 'expected_critical':
|
||||
return [
|
||||
`WEIGHT critical_damage BY critical_chance`,
|
||||
...(sources.length ? [`SOURCE ${source}`] : [])
|
||||
];
|
||||
case 'expected_headshot':
|
||||
return [
|
||||
`WEIGHT headshot_damage BY headshot_chance`,
|
||||
...(sources.length ? [`SOURCE ${source}`] : [])
|
||||
];
|
||||
case 'rate_of_fire':
|
||||
return [`ADJUST equipped_weapon.rpm USING ${source}`];
|
||||
case 'magazine_size':
|
||||
return [`ADJUST equipped_weapon.magazine USING ${source}`];
|
||||
case 'reload_speed':
|
||||
return [`ADJUST equipped_weapon.reload_seconds USING ${source}`];
|
||||
case 'firing_cycle':
|
||||
return [
|
||||
'CALCULATE firing_cycle FROM rpm, magazine, reload_seconds',
|
||||
'RETURN expected_damage_per_second'
|
||||
];
|
||||
default:
|
||||
return [`APPLY ${identifier(label)} USING ${source}`];
|
||||
}
|
||||
};
|
||||
|
||||
export function formatWeaponCalculationScript(input: {
|
||||
revision?: CalculationScriptRevision | null;
|
||||
stages: CalculationScriptStage[];
|
||||
profiles: CalculationScriptProfile[];
|
||||
attributes: CalculationScriptAttribute[];
|
||||
}) {
|
||||
const attributes = new Map(input.attributes.map((attribute) => [String(attribute.id), attribute]));
|
||||
const lines = [
|
||||
`CALCULATE expected_weapon_dps${input.revision?.version ? ` WITH rules_r${input.revision.version}` : ''}`
|
||||
];
|
||||
for (const stage of [...input.stages].sort(
|
||||
(left, right) => Number(left.display_order) - Number(right.display_order)
|
||||
)) {
|
||||
const sources = input.profiles
|
||||
.filter((profile) => profile.stage_key === stage.stage_key)
|
||||
.map((profile) => profileExpression(profile, attributes));
|
||||
lines.push('', `STAGE ${String(stage.label).toUpperCase()}`);
|
||||
lines.push(...operatorStatement(String(stage.operator), String(stage.label), sources));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function formatItemDamageRangeScript() {
|
||||
return [
|
||||
'CONFIGURE equipped_weapon',
|
||||
'READ configured_damage_range AT level_1',
|
||||
'SCALE configured_damage_range TO equipped_weapon.level',
|
||||
'SELECT equipped_weapon.damage WITHIN scaled_damage_range',
|
||||
'PASS equipped_weapon.damage TO combat_calculation'
|
||||
];
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { calculateCombat, type CombatCalculationInput } from './combat.js';
|
||||
import {
|
||||
calculateCombat,
|
||||
expectedWeaponShotDamage,
|
||||
type CombatCalculationInput
|
||||
} from './combat.js';
|
||||
|
||||
const base = (overrides: Partial<CombatCalculationInput> = {}): CombatCalculationInput => ({
|
||||
weapon: { baseDamage: 100, rpm: 600, magazine: 10, reloadSeconds: 2, headshotMultiplier: 2 },
|
||||
@@ -24,6 +28,18 @@ describe('calculateCombat', () => {
|
||||
expect(result.sustainedDps).toBeCloseTo(495);
|
||||
});
|
||||
|
||||
it('calculates expected body and headshot damage for timeline events', () => {
|
||||
const input = base({
|
||||
attributes: { critChance: 50, critDamage: 100 },
|
||||
bindings: [
|
||||
{ attributeId: 'critChance', stage: 'critical_chance', operator: 'add' },
|
||||
{ attributeId: 'critDamage', stage: 'critical_damage', operator: 'add' }
|
||||
]
|
||||
});
|
||||
expect(expectedWeaponShotDamage(input.weapon, input.attributes, input.bindings)).toBe(150);
|
||||
expect(expectedWeaponShotDamage(input.weapon, input.attributes, input.bindings, true)).toBe(300);
|
||||
});
|
||||
|
||||
it('applies weights, hard caps, and a configured curve safely', () => {
|
||||
const result = calculateCombat(base({
|
||||
attributes: { weapon: 100 },
|
||||
@@ -41,6 +57,20 @@ describe('calculateCombat', () => {
|
||||
expect(result.hitValues?.normal).toBe(180);
|
||||
});
|
||||
|
||||
it('applies firing-cycle attributes to burst and sustained DPS', () => {
|
||||
const result = calculateCombat(base({
|
||||
attributes: { rate: 20, magazine: 100, reload: 100 },
|
||||
bindings: [
|
||||
{ attributeId: 'rate', stage: 'rate_of_fire', operator: 'add' },
|
||||
{ attributeId: 'magazine', stage: 'magazine_size', operator: 'add' },
|
||||
{ attributeId: 'reload', stage: 'reload_speed', operator: 'add' }
|
||||
]
|
||||
}));
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.burstDps).toBe(1200);
|
||||
expect(result.sustainedDps).toBeCloseTo(750);
|
||||
});
|
||||
|
||||
it('keeps effects off, applies peak stacks, and honors per-effect overrides', () => {
|
||||
const stackingEffects = [{
|
||||
id: 'relentless',
|
||||
|
||||
@@ -5,6 +5,9 @@ export type CalculationStage =
|
||||
| 'critical_damage'
|
||||
| 'headshot_chance'
|
||||
| 'headshot_damage'
|
||||
| 'rate_of_fire'
|
||||
| 'magazine_size'
|
||||
| 'reload_speed'
|
||||
| 'final_damage';
|
||||
export type CalculationOperator = 'add' | 'multiply';
|
||||
export type CurveType = 'none' | 'power' | 'smoothstep';
|
||||
@@ -26,6 +29,11 @@ export type WeaponCombatInput = {
|
||||
reloadSeconds?: number;
|
||||
headshotMultiplier?: number;
|
||||
};
|
||||
export type WeaponFiringCycle = {
|
||||
rpm: number;
|
||||
magazine: number;
|
||||
reloadSeconds: number;
|
||||
};
|
||||
export type StackingMode = 'off' | 'peak' | 'encounter';
|
||||
export type TriggerRule = { hitsRequired: number; windowSeconds: number };
|
||||
export type StackLifecycle = { maxStacks: number; durationSeconds: number; stacksPerTrigger?: number };
|
||||
@@ -244,11 +252,63 @@ function dpsFor(
|
||||
+ values.critical * boundCriticalChance * (1 - headshotChance)
|
||||
+ values.headshot * (1 - boundCriticalChance) * headshotChance
|
||||
+ values.criticalHeadshot * boundCriticalChance * headshotChance;
|
||||
const shotsPerSecond = weapon.rpm / 60;
|
||||
const { rpm, magazine, reloadSeconds } = resolveWeaponFiringCycle(
|
||||
weapon,
|
||||
attributes,
|
||||
bindings
|
||||
);
|
||||
const shotsPerSecond = rpm / 60;
|
||||
return {
|
||||
values,
|
||||
burst: average * shotsPerSecond,
|
||||
sustained: average * weapon.magazine / (weapon.magazine / shotsPerSecond + weapon.reloadSeconds)
|
||||
sustained: average * magazine / (magazine / shotsPerSecond + reloadSeconds)
|
||||
};
|
||||
}
|
||||
|
||||
export function expectedWeaponShotDamage(
|
||||
weapon: WeaponCombatInput,
|
||||
attributes: StatValues,
|
||||
bindings: AttributeBinding[],
|
||||
headshot = false,
|
||||
stageOrder?: CalculationStage[]
|
||||
) {
|
||||
const resolvedWeapon: Required<WeaponCombatInput> = {
|
||||
baseDamage: Number(weapon.baseDamage),
|
||||
rpm: Number(weapon.rpm),
|
||||
magazine: Number(weapon.magazine),
|
||||
reloadSeconds: Number(weapon.reloadSeconds),
|
||||
headshotMultiplier: Number(weapon.headshotMultiplier ?? 2)
|
||||
};
|
||||
const values = hitValues(resolvedWeapon, attributes, bindings, stageOrder);
|
||||
const critical = stageValues(attributes, bindings, 'critical_chance');
|
||||
const criticalChance = clamp(critical.additive / 100, 0, 1);
|
||||
return headshot
|
||||
? values.headshot * (1 - criticalChance) + values.criticalHeadshot * criticalChance
|
||||
: values.normal * (1 - criticalChance) + values.critical * criticalChance;
|
||||
}
|
||||
|
||||
export function resolveWeaponFiringCycle(
|
||||
weapon: WeaponCombatInput,
|
||||
attributes: StatValues,
|
||||
bindings: AttributeBinding[]
|
||||
): WeaponFiringCycle {
|
||||
const rateOfFire = stageValues(attributes, bindings, 'rate_of_fire');
|
||||
const magazineSize = stageValues(attributes, bindings, 'magazine_size');
|
||||
const reloadSpeed = stageValues(attributes, bindings, 'reload_speed');
|
||||
return {
|
||||
rpm: Number(weapon.rpm)
|
||||
* (1 + rateOfFire.additive / 100)
|
||||
* rateOfFire.multiplicative,
|
||||
magazine: Math.max(
|
||||
1,
|
||||
Number(weapon.magazine)
|
||||
* (1 + magazineSize.additive / 100)
|
||||
* magazineSize.multiplicative
|
||||
),
|
||||
reloadSeconds: Number(weapon.reloadSeconds) / Math.max(
|
||||
EPSILON,
|
||||
(1 + reloadSpeed.additive / 100) * reloadSpeed.multiplicative
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
TRIGGER_ACTION_REGISTRY,
|
||||
evaluateConditionalProgram,
|
||||
summarizeTrigger,
|
||||
validateConditionalProgram,
|
||||
validateTriggerConfiguration,
|
||||
type ConditionalEvent,
|
||||
type ConditionalProgram,
|
||||
type TriggerConfiguration
|
||||
} from './conditional.js';
|
||||
|
||||
const modifier = {
|
||||
id: 'weapon-damage',
|
||||
scope: 'equipped_weapon' as const,
|
||||
application: 'active_modifier' as const,
|
||||
attributeId: 'weapon-damage',
|
||||
value: 3
|
||||
};
|
||||
|
||||
const program = (
|
||||
trigger: TriggerConfiguration,
|
||||
overrides: Partial<ConditionalProgram> = {}
|
||||
): ConditionalProgram => ({
|
||||
id: 'test-effect',
|
||||
trigger,
|
||||
lifecycle: {
|
||||
cooldownSeconds: 0,
|
||||
durationSeconds: 10,
|
||||
maxStacks: 10,
|
||||
stacksPerActivation: 1
|
||||
},
|
||||
outcomes: [modifier],
|
||||
...overrides
|
||||
});
|
||||
|
||||
const shot = (
|
||||
at: number,
|
||||
targetId: string | null,
|
||||
hit = true,
|
||||
headshot = false
|
||||
): ConditionalEvent => ({ kind: 'shot', at, targetId, hit, headshot });
|
||||
|
||||
describe('trigger action registry', () => {
|
||||
it('owns every action and validates discriminated parameters', () => {
|
||||
expect(Object.keys(TRIGGER_ACTION_REGISTRY)).toHaveLength(15);
|
||||
expect(validateTriggerConfiguration('same_target_hits_within_window', {
|
||||
hitsRequired: 9,
|
||||
windowSeconds: 3
|
||||
})).toMatchObject({ ok: true });
|
||||
expect(validateTriggerConfiguration('same_target_hits_within_window', {
|
||||
hitsRequired: 0,
|
||||
windowSeconds: -1
|
||||
})).toEqual({
|
||||
ok: false,
|
||||
errors: ['Hits required must be a positive integer.', 'Window seconds must be greater than 0.']
|
||||
});
|
||||
expect(validateTriggerConfiguration('while_firing', { unexpected: true })).toMatchObject({ ok: false });
|
||||
expect(validateTriggerConfiguration('target_hit_within_distance', { meters: 0 })).toEqual({
|
||||
ok: false,
|
||||
errors: ['Distance must be greater than 0.']
|
||||
});
|
||||
});
|
||||
|
||||
it('generates user-facing summaries from typed values', () => {
|
||||
expect(summarizeTrigger({
|
||||
action: 'distinct_enemies_within_window',
|
||||
params: { enemiesRequired: 4, windowSeconds: 2.5 }
|
||||
})).toBe('Hit 4 distinct enemies within 2.5 seconds.');
|
||||
expect(summarizeTrigger({
|
||||
action: 'continuous_firing_duration',
|
||||
params: { seconds: 1 }
|
||||
})).toBe('Fire continuously for 1 second.');
|
||||
expect(summarizeTrigger({
|
||||
action: 'target_hit_within_distance',
|
||||
params: { meters: 20 }
|
||||
})).toBe('Hit a target within 20 meters.');
|
||||
});
|
||||
|
||||
it('validates unique health bands and their outcome relationships', () => {
|
||||
const result = validateConditionalProgram(program({
|
||||
action: 'health_threshold_bands',
|
||||
params: {
|
||||
bands: [
|
||||
{ thresholdPercent: 50, outcomeKey: 'low' },
|
||||
{ thresholdPercent: 20, outcomeKey: 'critical' }
|
||||
]
|
||||
}
|
||||
}, {
|
||||
outcomes: [
|
||||
{ ...modifier, id: 'low', outcomeKey: 'low' },
|
||||
{ ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 }
|
||||
]
|
||||
}));
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it('rejects negative instant outcomes but permits negative Attribute modifiers', () => {
|
||||
expect(validateConditionalProgram(program({
|
||||
action: 'headshot',
|
||||
params: {}
|
||||
}, {
|
||||
outcomes: [{
|
||||
id: 'damage',
|
||||
scope: 'target',
|
||||
application: 'direct_damage',
|
||||
basis: 'flat',
|
||||
value: -10
|
||||
}]
|
||||
}))).toEqual({
|
||||
ok: false,
|
||||
errors: ['Instant outcome "damage" cannot use a negative value.']
|
||||
});
|
||||
expect(validateConditionalProgram(program({
|
||||
action: 'headshot',
|
||||
params: {}
|
||||
}, {
|
||||
outcomes: [{ ...modifier, value: -10 }]
|
||||
}))).toMatchObject({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateConditionalProgram', () => {
|
||||
it('counts repeated hits against the same target inside the window', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
action: 'same_target_hits_within_window',
|
||||
params: { hitsRequired: 3, windowSeconds: 1 }
|
||||
}), [
|
||||
shot(0, 'alpha'),
|
||||
shot(0.2, 'bravo'),
|
||||
shot(0.4, 'alpha'),
|
||||
shot(0.8, 'alpha')
|
||||
], { mode: 'encounter', encounterSeconds: 5 });
|
||||
expect(result).toMatchObject({
|
||||
status: 'OK',
|
||||
activationCount: 1,
|
||||
maxStacksReached: 1
|
||||
});
|
||||
expect(result.activations[0]).toMatchObject({ at: 0.8, stacksBefore: 0, stacksAfter: 1 });
|
||||
});
|
||||
|
||||
it('counts each distinct enemy only once per window', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
action: 'distinct_enemies_within_window',
|
||||
params: { enemiesRequired: 3, windowSeconds: 2 }
|
||||
}), [
|
||||
shot(0, 'alpha'),
|
||||
shot(0.2, 'alpha'),
|
||||
shot(0.4, 'bravo'),
|
||||
shot(0.6, 'charlie')
|
||||
], { mode: 'encounter', encounterSeconds: 5 });
|
||||
expect(result.activationCount).toBe(1);
|
||||
expect(result.activations[0].at).toBe(0.6);
|
||||
});
|
||||
|
||||
it('resets consecutive hits after a miss or target change', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
action: 'consecutive_same_target_hits',
|
||||
params: { hitsRequired: 3 }
|
||||
}), [
|
||||
shot(0, 'alpha'),
|
||||
shot(0.1, 'alpha'),
|
||||
shot(0.2, null, false),
|
||||
shot(0.3, 'alpha'),
|
||||
shot(0.4, 'bravo'),
|
||||
shot(0.5, 'bravo'),
|
||||
shot(0.6, 'bravo')
|
||||
], { mode: 'encounter', encounterSeconds: 5 });
|
||||
expect(result.activationCount).toBe(1);
|
||||
expect(result.activations[0].at).toBe(0.6);
|
||||
});
|
||||
|
||||
it('starts cooldown on activation and applies the activation after its triggering event', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
action: 'headshot',
|
||||
params: {}
|
||||
}, {
|
||||
lifecycle: {
|
||||
cooldownSeconds: 3,
|
||||
durationSeconds: 5,
|
||||
maxStacks: 3,
|
||||
stacksPerActivation: 1
|
||||
}
|
||||
}), [
|
||||
shot(1, 'alpha', true, true),
|
||||
shot(2, 'alpha', true, true),
|
||||
shot(4, 'alpha', true, true)
|
||||
], { mode: 'encounter', encounterSeconds: 10 });
|
||||
expect(result.activations).toHaveLength(2);
|
||||
expect(result.activations.map((activation) => activation.at)).toEqual([1, 4]);
|
||||
expect(result.activations[0].effectiveAfter).toBeGreaterThan(1);
|
||||
expect(result.activations[1]).toMatchObject({
|
||||
stacksBefore: 1,
|
||||
stacksAfter: 2,
|
||||
expiresAt: 9,
|
||||
cooldownUntil: 7
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes one shared duration and clears every stack on expiry', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
action: 'damage_taken',
|
||||
params: {}
|
||||
}, {
|
||||
lifecycle: {
|
||||
cooldownSeconds: 0,
|
||||
durationSeconds: 3,
|
||||
maxStacks: 3,
|
||||
stacksPerActivation: 1
|
||||
}
|
||||
}), [
|
||||
{ kind: 'damage_taken', at: 0 },
|
||||
{ kind: 'damage_taken', at: 1 },
|
||||
{ kind: 'damage_taken', at: 5 }
|
||||
], { mode: 'encounter', encounterSeconds: 10 });
|
||||
expect(result.activations.map(({ stacksBefore, stacksAfter }) => ({ stacksBefore, stacksAfter }))).toEqual([
|
||||
{ stacksBefore: 0, stacksAfter: 1 },
|
||||
{ stacksBefore: 1, stacksAfter: 2 },
|
||||
{ stacksBefore: 0, stacksAfter: 1 }
|
||||
]);
|
||||
expect(result.intervals).toEqual([
|
||||
{ start: 0.000001, end: 1.000001, stacks: 1, outcomeKey: null },
|
||||
{ start: 1.000001, end: 4, stacks: 2, outcomeKey: null },
|
||||
{ start: 5.000001, end: 8, stacks: 1, outcomeKey: null }
|
||||
]);
|
||||
});
|
||||
|
||||
it('resets firing progress on reload without removing an active buff', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
action: 'continuous_firing_duration',
|
||||
params: { seconds: 2 }
|
||||
}, {
|
||||
lifecycle: {
|
||||
cooldownSeconds: 0,
|
||||
durationSeconds: 10,
|
||||
maxStacks: 4,
|
||||
stacksPerActivation: 1
|
||||
}
|
||||
}), [
|
||||
{ kind: 'firing_started', at: 0 },
|
||||
shot(0.5, 'alpha'),
|
||||
{ kind: 'reload_started', at: 3 },
|
||||
{ kind: 'reload_completed', at: 4 },
|
||||
{ kind: 'firing_started', at: 4 },
|
||||
{ kind: 'firing_stopped', at: 7 }
|
||||
], { mode: 'encounter', encounterSeconds: 12 });
|
||||
expect(result.activations.map((activation) => activation.at)).toEqual([2, 6]);
|
||||
expect(result.activations[1]).toMatchObject({ stacksBefore: 1, stacksAfter: 2, expiresAt: 16 });
|
||||
expect(result.uptimePercent).toBeCloseTo((10 / 12) * 100);
|
||||
});
|
||||
|
||||
it('activates continuous firing only once in each uninterrupted segment', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
action: 'continuous_firing_duration',
|
||||
params: { seconds: 2 }
|
||||
}), [
|
||||
{ kind: 'firing_started', at: 0 },
|
||||
{ kind: 'firing_stopped', at: 7 }
|
||||
], { mode: 'encounter', encounterSeconds: 10 });
|
||||
expect(result.activations.map((activation) => activation.at)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('uses only the strongest matching health band', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
action: 'health_threshold_bands',
|
||||
params: {
|
||||
bands: [
|
||||
{ thresholdPercent: 50, outcomeKey: 'low' },
|
||||
{ thresholdPercent: 20, outcomeKey: 'critical' }
|
||||
]
|
||||
}
|
||||
}, {
|
||||
lifecycle: { cooldownSeconds: 0, durationSeconds: 0, maxStacks: 1, stacksPerActivation: 1 },
|
||||
outcomes: [
|
||||
{ ...modifier, id: 'low', outcomeKey: 'low', value: 5 },
|
||||
{ ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 }
|
||||
]
|
||||
}), [], {
|
||||
mode: 'encounter',
|
||||
encounterSeconds: 20,
|
||||
healthPercent: 15
|
||||
});
|
||||
expect(result.selectedOutcomeKey).toBe('critical');
|
||||
expect(result.intervals).toEqual([{ start: 0, end: 20, stacks: 1, outcomeKey: 'critical' }]);
|
||||
});
|
||||
|
||||
it('allows a direct proc-rate override and reports per-stack instant outcomes', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
action: 'weapon_kill',
|
||||
params: {}
|
||||
}, {
|
||||
lifecycle: { cooldownSeconds: 0, durationSeconds: 20, maxStacks: 3, stacksPerActivation: 1 },
|
||||
outcomes: [{
|
||||
id: 'blast',
|
||||
scope: 'target',
|
||||
application: 'direct_damage',
|
||||
basis: 'weapon_base_percent',
|
||||
value: 25
|
||||
}]
|
||||
}), [], {
|
||||
mode: 'encounter',
|
||||
encounterSeconds: 30,
|
||||
directProcsPerMinute: 6
|
||||
});
|
||||
expect(result.activations.map((activation) => activation.at)).toEqual([10, 20, 30]);
|
||||
expect(result.outcomeEvents.map((event) => event.value)).toEqual([25, 50, 75]);
|
||||
});
|
||||
|
||||
it('uses real trigger events when the whole-program activation override is zero', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
action: 'weapon_kill',
|
||||
params: {}
|
||||
}), [
|
||||
{ kind: 'enemy_killed', at: 4 }
|
||||
], {
|
||||
mode: 'encounter',
|
||||
encounterSeconds: 30,
|
||||
directProcsPerMinute: 0
|
||||
});
|
||||
expect(result.activations.map((activation) => activation.at)).toEqual([4]);
|
||||
});
|
||||
|
||||
it('models headshot kills from enemy-killed timeline events', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
action: 'headshot_kill',
|
||||
params: {}
|
||||
}), [
|
||||
{ kind: 'enemy_killed', at: 2, headshot: false },
|
||||
{ kind: 'enemy_killed', at: 7, headshot: true }
|
||||
], { mode: 'encounter', encounterSeconds: 15 });
|
||||
expect(result.activations.map((activation) => activation.at)).toEqual([7]);
|
||||
});
|
||||
|
||||
it('activates on successful target hits while the target is within the configured distance', () => {
|
||||
const inRange = evaluateConditionalProgram(program({
|
||||
action: 'target_hit_within_distance',
|
||||
params: { meters: 20 }
|
||||
}), [
|
||||
shot(1, 'alpha', false),
|
||||
shot(2, null),
|
||||
shot(3, 'alpha'),
|
||||
shot(4, 'bravo')
|
||||
], {
|
||||
mode: 'encounter',
|
||||
encounterSeconds: 10,
|
||||
distanceMeters: 20
|
||||
});
|
||||
expect(inRange.activations.map((activation) => activation.at)).toEqual([3, 4]);
|
||||
|
||||
const outOfRange = evaluateConditionalProgram(program({
|
||||
action: 'target_hit_within_distance',
|
||||
params: { meters: 20 }
|
||||
}), [shot(3, 'alpha')], {
|
||||
mode: 'encounter',
|
||||
encounterSeconds: 10,
|
||||
distanceMeters: 21
|
||||
});
|
||||
expect(outOfRange.activationCount).toBe(0);
|
||||
});
|
||||
|
||||
it('requires a target-distance assumption for distance-limited hit events', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
action: 'target_hit_within_distance',
|
||||
params: { meters: 20 }
|
||||
}), [shot(3, 'alpha')], {
|
||||
mode: 'encounter',
|
||||
encounterSeconds: 10
|
||||
});
|
||||
expect(result.activationCount).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps target proximity as a persistent condition rather than a hit event', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
action: 'within_distance',
|
||||
params: { meters: 20 }
|
||||
}), [], {
|
||||
mode: 'encounter',
|
||||
encounterSeconds: 10,
|
||||
distanceMeters: 20
|
||||
});
|
||||
expect(result.activationCount).toBe(1);
|
||||
expect(result.intervals).toEqual([{ start: 0, end: 10, stacks: 10, outcomeKey: null }]);
|
||||
});
|
||||
|
||||
it('keeps while-firing effects active only inside firing segments', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
action: 'while_firing',
|
||||
params: {}
|
||||
}), [
|
||||
{ kind: 'firing_started', at: 1 },
|
||||
{ kind: 'firing_stopped', at: 3 },
|
||||
{ kind: 'firing_started', at: 5 },
|
||||
{ kind: 'reload_started', at: 8 }
|
||||
], { mode: 'encounter', encounterSeconds: 10 });
|
||||
expect(result.intervals).toEqual([
|
||||
{ start: 1, end: 3, stacks: 10, outcomeKey: null },
|
||||
{ start: 5, end: 8, stacks: 10, outcomeKey: null }
|
||||
]);
|
||||
expect(result.uptimePercent).toBe(50);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,964 @@
|
||||
export const TRIGGER_ACTION_KEYS = [
|
||||
'same_target_hits_within_window',
|
||||
'distinct_enemies_within_window',
|
||||
'consecutive_same_target_hits',
|
||||
'continuous_firing_duration',
|
||||
'while_firing',
|
||||
'health_threshold_bands',
|
||||
'headshot',
|
||||
'headshot_kill',
|
||||
'weapon_kill',
|
||||
'target_hit_within_distance',
|
||||
'damage_taken',
|
||||
'weapon_swap',
|
||||
'reload',
|
||||
'within_distance',
|
||||
'extra_health_active'
|
||||
] as const;
|
||||
|
||||
export type TriggerActionKey = (typeof TRIGGER_ACTION_KEYS)[number];
|
||||
|
||||
export type SameTargetHitsParams = {
|
||||
hitsRequired: number;
|
||||
windowSeconds: number;
|
||||
};
|
||||
export type DistinctEnemiesParams = {
|
||||
enemiesRequired: number;
|
||||
windowSeconds: number;
|
||||
};
|
||||
export type ConsecutiveHitsParams = {
|
||||
hitsRequired: number;
|
||||
};
|
||||
export type ContinuousFiringParams = {
|
||||
seconds: number;
|
||||
};
|
||||
export type HealthThresholdBand = {
|
||||
thresholdPercent: number;
|
||||
outcomeKey: string;
|
||||
};
|
||||
export type HealthThresholdParams = {
|
||||
bands: HealthThresholdBand[];
|
||||
};
|
||||
export type DistanceParams = {
|
||||
meters: number;
|
||||
};
|
||||
export type EmptyTriggerParams = Record<string, never>;
|
||||
|
||||
export type TriggerParamsByAction = {
|
||||
same_target_hits_within_window: SameTargetHitsParams;
|
||||
distinct_enemies_within_window: DistinctEnemiesParams;
|
||||
consecutive_same_target_hits: ConsecutiveHitsParams;
|
||||
continuous_firing_duration: ContinuousFiringParams;
|
||||
while_firing: EmptyTriggerParams;
|
||||
health_threshold_bands: HealthThresholdParams;
|
||||
headshot: EmptyTriggerParams;
|
||||
headshot_kill: EmptyTriggerParams;
|
||||
weapon_kill: EmptyTriggerParams;
|
||||
target_hit_within_distance: DistanceParams;
|
||||
damage_taken: EmptyTriggerParams;
|
||||
weapon_swap: EmptyTriggerParams;
|
||||
reload: EmptyTriggerParams;
|
||||
within_distance: DistanceParams;
|
||||
extra_health_active: EmptyTriggerParams;
|
||||
};
|
||||
|
||||
export type TriggerConfiguration = {
|
||||
[K in TriggerActionKey]: {
|
||||
action: K;
|
||||
params: TriggerParamsByAction[K];
|
||||
}
|
||||
}[TriggerActionKey];
|
||||
|
||||
export type EffectScope =
|
||||
| 'self'
|
||||
| 'equipped_weapon'
|
||||
| 'target'
|
||||
| 'allies'
|
||||
| 'self_and_allies'
|
||||
| 'assigned_ability'
|
||||
| 'all_skills';
|
||||
|
||||
export type ModifierOutcome = {
|
||||
id: string;
|
||||
outcomeKey?: string;
|
||||
scope: EffectScope;
|
||||
application: 'active_modifier';
|
||||
attributeId: string;
|
||||
value: number;
|
||||
};
|
||||
export type DirectDamageOutcome = {
|
||||
id: string;
|
||||
outcomeKey?: string;
|
||||
scope: EffectScope;
|
||||
application: 'direct_damage';
|
||||
basis: 'flat' | 'weapon_base_percent';
|
||||
value: number;
|
||||
};
|
||||
export type RestorationOutcome = {
|
||||
id: string;
|
||||
outcomeKey?: string;
|
||||
scope: EffectScope;
|
||||
application: 'restore_health' | 'restore_armor' | 'restore_ammo';
|
||||
value: number;
|
||||
};
|
||||
export type ConditionalOutcome = ModifierOutcome | DirectDamageOutcome | RestorationOutcome;
|
||||
|
||||
export type ConditionalLifecycle = {
|
||||
cooldownSeconds: number;
|
||||
durationSeconds: number;
|
||||
maxStacks: number;
|
||||
stacksPerActivation: number;
|
||||
};
|
||||
|
||||
export type ConditionalProgram = {
|
||||
id: string;
|
||||
trigger: TriggerConfiguration;
|
||||
lifecycle: ConditionalLifecycle;
|
||||
outcomes: ConditionalOutcome[];
|
||||
};
|
||||
|
||||
export type ShotEvent = {
|
||||
kind: 'shot';
|
||||
at: number;
|
||||
targetId?: string | null;
|
||||
hit: boolean;
|
||||
headshot?: boolean;
|
||||
};
|
||||
export type EnemyKilledEvent = {
|
||||
kind: 'enemy_killed';
|
||||
at: number;
|
||||
targetId?: string | null;
|
||||
headshot?: boolean;
|
||||
};
|
||||
export type ConditionalEvent =
|
||||
| ShotEvent
|
||||
| EnemyKilledEvent
|
||||
| { kind: 'firing_started'; at: number }
|
||||
| { kind: 'firing_stopped'; at: number }
|
||||
| { kind: 'reload_started'; at: number }
|
||||
| { kind: 'reload_completed'; at: number }
|
||||
| { kind: 'damage_taken'; at: number }
|
||||
| { kind: 'weapon_swapped'; at: number };
|
||||
|
||||
export type ConditionalMode = 'off' | 'peak' | 'encounter';
|
||||
|
||||
export type ConditionalAssumptions = {
|
||||
mode: ConditionalMode;
|
||||
encounterSeconds: number;
|
||||
healthPercent?: number;
|
||||
distanceMeters?: number;
|
||||
extraHealthActive?: boolean;
|
||||
directProcsPerMinute?: number;
|
||||
};
|
||||
|
||||
export type ValidationResult<T> =
|
||||
| { ok: true; value: T }
|
||||
| { ok: false; errors: string[] };
|
||||
|
||||
export type TriggerActionDefinition = {
|
||||
key: TriggerActionKey;
|
||||
label: string;
|
||||
validate: (params: unknown) => ValidationResult<TriggerParamsByAction[TriggerActionKey]>;
|
||||
summarize: (params: TriggerParamsByAction[TriggerActionKey]) => string;
|
||||
};
|
||||
|
||||
export type ConditionalActivation = {
|
||||
at: number;
|
||||
effectiveAfter: number;
|
||||
stacksBefore: number;
|
||||
stacksAfter: number;
|
||||
expiresAt: number;
|
||||
cooldownUntil: number;
|
||||
outcomeKey: string | null;
|
||||
};
|
||||
|
||||
export type ConditionalStateInterval = {
|
||||
start: number;
|
||||
end: number;
|
||||
stacks: number;
|
||||
outcomeKey: string | null;
|
||||
};
|
||||
|
||||
export type ConditionalOutcomeEvent = {
|
||||
at: number;
|
||||
effectiveAfter: number;
|
||||
outcomeId: string;
|
||||
application: Exclude<ConditionalOutcome['application'], 'active_modifier'>;
|
||||
scope: EffectScope;
|
||||
value: number;
|
||||
basis: DirectDamageOutcome['basis'] | null;
|
||||
stacks: number;
|
||||
};
|
||||
|
||||
export type ConditionalEvaluation = {
|
||||
status: 'OK' | 'INVALID';
|
||||
errors: string[];
|
||||
programId: string;
|
||||
mode: ConditionalMode;
|
||||
activations: ConditionalActivation[];
|
||||
intervals: ConditionalStateInterval[];
|
||||
outcomeEvents: ConditionalOutcomeEvent[];
|
||||
activationCount: number;
|
||||
maxStacksReached: number;
|
||||
averageStacks: number;
|
||||
uptimePercent: number;
|
||||
selectedOutcomeKey: string | null;
|
||||
};
|
||||
|
||||
const EPSILON = 0.000_001;
|
||||
const finiteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
|
||||
const plural = (value: number, singular: string, pluralForm = `${singular}s`) =>
|
||||
`${formatNumber(value)} ${value === 1 ? singular : pluralForm}`;
|
||||
const formatNumber = (value: number) => Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2)));
|
||||
|
||||
function positiveNumber(value: unknown, name: string, errors: string[]) {
|
||||
if (!finiteNumber(value) || value <= 0) errors.push(`${name} must be greater than 0.`);
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, name: string, errors: string[]) {
|
||||
if (!Number.isInteger(value) || Number(value) <= 0) errors.push(`${name} must be a positive integer.`);
|
||||
}
|
||||
|
||||
function validateEmpty(params: unknown): ValidationResult<EmptyTriggerParams> {
|
||||
if (params === undefined || (isObject(params) && Object.keys(params).length === 0)) {
|
||||
return { ok: true, value: {} };
|
||||
}
|
||||
return { ok: false, errors: ['This trigger does not accept parameters.'] };
|
||||
}
|
||||
|
||||
function validateSameTarget(params: unknown): ValidationResult<SameTargetHitsParams> {
|
||||
const errors: string[] = [];
|
||||
if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] };
|
||||
positiveInteger(params.hitsRequired, 'Hits required', errors);
|
||||
positiveNumber(params.windowSeconds, 'Window seconds', errors);
|
||||
return errors.length
|
||||
? { ok: false, errors }
|
||||
: { ok: true, value: { hitsRequired: Number(params.hitsRequired), windowSeconds: Number(params.windowSeconds) } };
|
||||
}
|
||||
|
||||
function validateDistinctEnemies(params: unknown): ValidationResult<DistinctEnemiesParams> {
|
||||
const errors: string[] = [];
|
||||
if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] };
|
||||
positiveInteger(params.enemiesRequired, 'Enemies required', errors);
|
||||
positiveNumber(params.windowSeconds, 'Window seconds', errors);
|
||||
return errors.length
|
||||
? { ok: false, errors }
|
||||
: { ok: true, value: { enemiesRequired: Number(params.enemiesRequired), windowSeconds: Number(params.windowSeconds) } };
|
||||
}
|
||||
|
||||
function validateConsecutive(params: unknown): ValidationResult<ConsecutiveHitsParams> {
|
||||
const errors: string[] = [];
|
||||
if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] };
|
||||
positiveInteger(params.hitsRequired, 'Hits required', errors);
|
||||
return errors.length
|
||||
? { ok: false, errors }
|
||||
: { ok: true, value: { hitsRequired: Number(params.hitsRequired) } };
|
||||
}
|
||||
|
||||
function validateContinuousFiring(params: unknown): ValidationResult<ContinuousFiringParams> {
|
||||
const errors: string[] = [];
|
||||
if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] };
|
||||
positiveNumber(params.seconds, 'Firing seconds', errors);
|
||||
return errors.length
|
||||
? { ok: false, errors }
|
||||
: { ok: true, value: { seconds: Number(params.seconds) } };
|
||||
}
|
||||
|
||||
function validateHealthBands(params: unknown): ValidationResult<HealthThresholdParams> {
|
||||
const errors: string[] = [];
|
||||
if (!isObject(params) || !Array.isArray(params.bands)) {
|
||||
return { ok: false, errors: ['Health bands must be an array.'] };
|
||||
}
|
||||
if (params.bands.length < 1 || params.bands.length > 5) {
|
||||
errors.push('Health threshold triggers require between 1 and 5 bands.');
|
||||
}
|
||||
const bands: HealthThresholdBand[] = [];
|
||||
const thresholds = new Set<number>();
|
||||
const outcomeKeys = new Set<string>();
|
||||
for (const [index, candidate] of params.bands.entries()) {
|
||||
if (!isObject(candidate)) {
|
||||
errors.push(`Health band ${index + 1} must be an object.`);
|
||||
continue;
|
||||
}
|
||||
const threshold = candidate.thresholdPercent;
|
||||
const outcomeKey = typeof candidate.outcomeKey === 'string' ? candidate.outcomeKey.trim() : '';
|
||||
if (!finiteNumber(threshold) || threshold < 0 || threshold > 100) {
|
||||
errors.push(`Health band ${index + 1} threshold must be between 0 and 100.`);
|
||||
}
|
||||
if (!outcomeKey) errors.push(`Health band ${index + 1} requires an outcome key.`);
|
||||
if (finiteNumber(threshold) && thresholds.has(threshold)) {
|
||||
errors.push(`Health threshold ${formatNumber(threshold)}% is duplicated.`);
|
||||
}
|
||||
if (outcomeKey && outcomeKeys.has(outcomeKey)) errors.push(`Outcome key "${outcomeKey}" is duplicated.`);
|
||||
if (finiteNumber(threshold)) thresholds.add(threshold);
|
||||
if (outcomeKey) outcomeKeys.add(outcomeKey);
|
||||
if (finiteNumber(threshold) && outcomeKey) bands.push({ thresholdPercent: threshold, outcomeKey });
|
||||
}
|
||||
return errors.length
|
||||
? { ok: false, errors }
|
||||
: { ok: true, value: { bands: bands.sort((a, b) => a.thresholdPercent - b.thresholdPercent) } };
|
||||
}
|
||||
|
||||
function validateDistance(params: unknown): ValidationResult<DistanceParams> {
|
||||
const errors: string[] = [];
|
||||
if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] };
|
||||
positiveNumber(params.meters, 'Distance', errors);
|
||||
return errors.length
|
||||
? { ok: false, errors }
|
||||
: { ok: true, value: { meters: Number(params.meters) } };
|
||||
}
|
||||
|
||||
const emptySummary = () => '';
|
||||
|
||||
export const TRIGGER_ACTION_REGISTRY: Readonly<Record<TriggerActionKey, TriggerActionDefinition>> = {
|
||||
same_target_hits_within_window: {
|
||||
key: 'same_target_hits_within_window',
|
||||
label: 'Same-target hits within a window',
|
||||
validate: validateSameTarget,
|
||||
summarize: (params) => {
|
||||
const value = params as SameTargetHitsParams;
|
||||
return `Hit the same enemy ${plural(value.hitsRequired, 'time')} within ${plural(value.windowSeconds, 'second')}.`;
|
||||
}
|
||||
},
|
||||
distinct_enemies_within_window: {
|
||||
key: 'distinct_enemies_within_window',
|
||||
label: 'Distinct enemies hit within a window',
|
||||
validate: validateDistinctEnemies,
|
||||
summarize: (params) => {
|
||||
const value = params as DistinctEnemiesParams;
|
||||
return `Hit ${plural(value.enemiesRequired, 'distinct enemy', 'distinct enemies')} within ${plural(value.windowSeconds, 'second')}.`;
|
||||
}
|
||||
},
|
||||
consecutive_same_target_hits: {
|
||||
key: 'consecutive_same_target_hits',
|
||||
label: 'Consecutive same-target hits',
|
||||
validate: validateConsecutive,
|
||||
summarize: (params) => {
|
||||
const value = params as ConsecutiveHitsParams;
|
||||
return `Hit the same enemy ${plural(value.hitsRequired, 'consecutive time')}.`;
|
||||
}
|
||||
},
|
||||
continuous_firing_duration: {
|
||||
key: 'continuous_firing_duration',
|
||||
label: 'Continuous firing duration',
|
||||
validate: validateContinuousFiring,
|
||||
summarize: (params) => {
|
||||
const value = params as ContinuousFiringParams;
|
||||
return `Fire continuously for ${plural(value.seconds, 'second')}.`;
|
||||
}
|
||||
},
|
||||
while_firing: {
|
||||
key: 'while_firing',
|
||||
label: 'While firing',
|
||||
validate: validateEmpty,
|
||||
summarize: () => 'While firing.'
|
||||
},
|
||||
health_threshold_bands: {
|
||||
key: 'health_threshold_bands',
|
||||
label: 'Health threshold bands',
|
||||
validate: validateHealthBands,
|
||||
summarize: (params) => {
|
||||
const value = params as HealthThresholdParams;
|
||||
const thresholds = [...value.bands]
|
||||
.sort((a, b) => b.thresholdPercent - a.thresholdPercent)
|
||||
.map((band) => `${formatNumber(band.thresholdPercent)}% (${band.outcomeKey})`);
|
||||
return `Use the strongest matching effect while health is at or below ${thresholds.join(', ')}.`;
|
||||
}
|
||||
},
|
||||
headshot: {
|
||||
key: 'headshot',
|
||||
label: 'Headshot',
|
||||
validate: validateEmpty,
|
||||
summarize: () => 'Land a headshot.'
|
||||
},
|
||||
headshot_kill: {
|
||||
key: 'headshot_kill',
|
||||
label: 'Headshot kill',
|
||||
validate: validateEmpty,
|
||||
summarize: () => 'Kill an enemy with a headshot.'
|
||||
},
|
||||
weapon_kill: {
|
||||
key: 'weapon_kill',
|
||||
label: 'Weapon kill',
|
||||
validate: validateEmpty,
|
||||
summarize: () => 'Kill an enemy with a weapon.'
|
||||
},
|
||||
target_hit_within_distance: {
|
||||
key: 'target_hit_within_distance',
|
||||
label: 'Target hit within distance',
|
||||
validate: validateDistance,
|
||||
summarize: (params) => {
|
||||
const value = params as DistanceParams;
|
||||
return `Hit a target within ${plural(value.meters, 'meter')}.`;
|
||||
}
|
||||
},
|
||||
damage_taken: {
|
||||
key: 'damage_taken',
|
||||
label: 'Damage taken',
|
||||
validate: validateEmpty,
|
||||
summarize: () => 'Take damage.'
|
||||
},
|
||||
weapon_swap: {
|
||||
key: 'weapon_swap',
|
||||
label: 'Weapon swap',
|
||||
validate: validateEmpty,
|
||||
summarize: () => 'Swap weapons.'
|
||||
},
|
||||
reload: {
|
||||
key: 'reload',
|
||||
label: 'Reload',
|
||||
validate: validateEmpty,
|
||||
summarize: () => 'Start reloading.'
|
||||
},
|
||||
within_distance: {
|
||||
key: 'within_distance',
|
||||
label: 'Target within distance',
|
||||
validate: validateDistance,
|
||||
summarize: (params) => {
|
||||
const value = params as DistanceParams;
|
||||
return `While the target is within ${plural(value.meters, 'meter')}.`;
|
||||
}
|
||||
},
|
||||
extra_health_active: {
|
||||
key: 'extra_health_active',
|
||||
label: 'Extra health active',
|
||||
validate: validateEmpty,
|
||||
summarize: () => 'While extra health is active.'
|
||||
}
|
||||
};
|
||||
|
||||
export function isTriggerActionKey(value: unknown): value is TriggerActionKey {
|
||||
return typeof value === 'string' && (TRIGGER_ACTION_KEYS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function validateTriggerConfiguration(
|
||||
action: TriggerActionKey,
|
||||
params: unknown
|
||||
): ValidationResult<TriggerConfiguration> {
|
||||
const result = TRIGGER_ACTION_REGISTRY[action].validate(params);
|
||||
if (!result.ok) return result;
|
||||
return { ok: true, value: { action, params: result.value } as TriggerConfiguration };
|
||||
}
|
||||
|
||||
export function summarizeTrigger(configuration: TriggerConfiguration): string {
|
||||
return TRIGGER_ACTION_REGISTRY[configuration.action].summarize(configuration.params);
|
||||
}
|
||||
|
||||
export function validateConditionalProgram(program: ConditionalProgram): ValidationResult<ConditionalProgram> {
|
||||
const errors: string[] = [];
|
||||
if (!program.id.trim()) errors.push('Program ID is required.');
|
||||
const trigger = validateTriggerConfiguration(program.trigger.action, program.trigger.params);
|
||||
if (!trigger.ok) errors.push(...trigger.errors.map((error) => `Trigger: ${error}`));
|
||||
const lifecycle = program.lifecycle;
|
||||
if (!finiteNumber(lifecycle.cooldownSeconds) || lifecycle.cooldownSeconds < 0) {
|
||||
errors.push('Cooldown seconds must be 0 or greater.');
|
||||
}
|
||||
if (!finiteNumber(lifecycle.durationSeconds) || lifecycle.durationSeconds < 0) {
|
||||
errors.push('Duration seconds must be 0 or greater.');
|
||||
}
|
||||
positiveInteger(lifecycle.maxStacks, 'Maximum stacks', errors);
|
||||
positiveInteger(lifecycle.stacksPerActivation, 'Stacks per activation', errors);
|
||||
if (
|
||||
Number.isInteger(lifecycle.maxStacks)
|
||||
&& Number.isInteger(lifecycle.stacksPerActivation)
|
||||
&& lifecycle.stacksPerActivation > lifecycle.maxStacks
|
||||
) {
|
||||
errors.push('Stacks per activation cannot exceed maximum stacks.');
|
||||
}
|
||||
const outcomeIds = new Set<string>();
|
||||
const healthOutcomeKeys = program.trigger.action === 'health_threshold_bands'
|
||||
? new Set((program.trigger.params as HealthThresholdParams).bands.map((band) => band.outcomeKey))
|
||||
: null;
|
||||
for (const [index, outcome] of program.outcomes.entries()) {
|
||||
if (!outcome.id.trim()) errors.push(`Outcome ${index + 1} requires an ID.`);
|
||||
if (outcomeIds.has(outcome.id)) errors.push(`Outcome ID "${outcome.id}" is duplicated.`);
|
||||
outcomeIds.add(outcome.id);
|
||||
if (!finiteNumber(outcome.value)) errors.push(`Outcome "${outcome.id}" requires a finite value.`);
|
||||
if (
|
||||
outcome.application !== 'active_modifier'
|
||||
&& finiteNumber(outcome.value)
|
||||
&& outcome.value < 0
|
||||
) {
|
||||
errors.push(`Instant outcome "${outcome.id}" cannot use a negative value.`);
|
||||
}
|
||||
if (outcome.application === 'active_modifier' && !outcome.attributeId.trim()) {
|
||||
errors.push(`Modifier outcome "${outcome.id}" requires an Attribute ID.`);
|
||||
}
|
||||
if (healthOutcomeKeys && (!outcome.outcomeKey || !healthOutcomeKeys.has(outcome.outcomeKey))) {
|
||||
errors.push(`Outcome "${outcome.id}" must reference a configured health-band outcome key.`);
|
||||
}
|
||||
}
|
||||
if (healthOutcomeKeys) {
|
||||
const provided = new Set(program.outcomes.map((outcome) => outcome.outcomeKey).filter(Boolean));
|
||||
for (const key of healthOutcomeKeys) {
|
||||
if (!provided.has(key)) errors.push(`Health-band outcome "${key}" has no configured effect.`);
|
||||
}
|
||||
}
|
||||
return errors.length ? { ok: false, errors } : { ok: true, value: program };
|
||||
}
|
||||
|
||||
type CandidateActivation = { at: number; outcomeKey: string | null };
|
||||
type FiringSegment = { start: number; end: number };
|
||||
|
||||
function orderedEvents(events: ConditionalEvent[], encounterSeconds: number) {
|
||||
return events
|
||||
.map((event, index) => ({ event, index }))
|
||||
.filter(({ event }) => finiteNumber(event.at) && event.at >= 0 && event.at <= encounterSeconds)
|
||||
.sort((left, right) => left.event.at - right.event.at || left.index - right.index)
|
||||
.map(({ event }) => event);
|
||||
}
|
||||
|
||||
function firingSegments(events: ConditionalEvent[], encounterSeconds: number): FiringSegment[] {
|
||||
const segments: FiringSegment[] = [];
|
||||
let startedAt: number | null = null;
|
||||
for (const event of events) {
|
||||
if (event.kind === 'firing_started') {
|
||||
if (startedAt === null) startedAt = event.at;
|
||||
continue;
|
||||
}
|
||||
if (event.kind === 'shot' && startedAt === null) {
|
||||
startedAt = event.at;
|
||||
continue;
|
||||
}
|
||||
if ((event.kind === 'firing_stopped' || event.kind === 'reload_started') && startedAt !== null) {
|
||||
segments.push({ start: startedAt, end: event.at });
|
||||
startedAt = null;
|
||||
}
|
||||
}
|
||||
if (startedAt !== null) segments.push({ start: startedAt, end: encounterSeconds });
|
||||
return segments.filter((segment) => segment.end - segment.start > EPSILON);
|
||||
}
|
||||
|
||||
function eventCandidates(
|
||||
trigger: TriggerConfiguration,
|
||||
events: ConditionalEvent[],
|
||||
assumptions: ConditionalAssumptions
|
||||
): CandidateActivation[] {
|
||||
switch (trigger.action) {
|
||||
case 'same_target_hits_within_window': {
|
||||
const params = trigger.params;
|
||||
const hits = new Map<string, number[]>();
|
||||
const candidates: CandidateActivation[] = [];
|
||||
for (const event of events) {
|
||||
if (event.kind !== 'shot' || !event.hit || !event.targetId) continue;
|
||||
const recent = (hits.get(event.targetId) ?? []).filter(
|
||||
(at) => event.at - at <= params.windowSeconds + EPSILON
|
||||
);
|
||||
recent.push(event.at);
|
||||
if (recent.length >= params.hitsRequired) {
|
||||
candidates.push({ at: event.at, outcomeKey: null });
|
||||
hits.set(event.targetId, []);
|
||||
} else {
|
||||
hits.set(event.targetId, recent);
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
case 'distinct_enemies_within_window': {
|
||||
const params = trigger.params;
|
||||
let recent = new Map<string, number>();
|
||||
const candidates: CandidateActivation[] = [];
|
||||
for (const event of events) {
|
||||
if (event.kind !== 'shot' || !event.hit || !event.targetId) continue;
|
||||
recent = new Map(
|
||||
[...recent].filter(([, at]) => event.at - at <= params.windowSeconds + EPSILON)
|
||||
);
|
||||
recent.set(event.targetId, event.at);
|
||||
if (recent.size >= params.enemiesRequired) {
|
||||
candidates.push({ at: event.at, outcomeKey: null });
|
||||
recent.clear();
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
case 'consecutive_same_target_hits': {
|
||||
const params = trigger.params;
|
||||
let targetId: string | null = null;
|
||||
let count = 0;
|
||||
const candidates: CandidateActivation[] = [];
|
||||
for (const event of events) {
|
||||
if (event.kind !== 'shot') continue;
|
||||
if (!event.hit || !event.targetId) {
|
||||
targetId = null;
|
||||
count = 0;
|
||||
continue;
|
||||
}
|
||||
if (event.targetId !== targetId) {
|
||||
targetId = event.targetId;
|
||||
count = 1;
|
||||
} else {
|
||||
count += 1;
|
||||
}
|
||||
if (count >= params.hitsRequired) {
|
||||
candidates.push({ at: event.at, outcomeKey: null });
|
||||
count = 0;
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
case 'continuous_firing_duration': {
|
||||
return firingSegments(events, assumptions.encounterSeconds)
|
||||
.filter((segment) => segment.end - segment.start + EPSILON >= trigger.params.seconds)
|
||||
.map((segment) => ({
|
||||
at: segment.start + trigger.params.seconds,
|
||||
outcomeKey: null
|
||||
}));
|
||||
}
|
||||
case 'headshot':
|
||||
return events
|
||||
.filter((event): event is ShotEvent => event.kind === 'shot' && event.hit && event.headshot === true)
|
||||
.map((event) => ({ at: event.at, outcomeKey: null }));
|
||||
case 'headshot_kill':
|
||||
return events
|
||||
.filter((event): event is EnemyKilledEvent => event.kind === 'enemy_killed' && event.headshot === true)
|
||||
.map((event) => ({ at: event.at, outcomeKey: null }));
|
||||
case 'weapon_kill':
|
||||
return events
|
||||
.filter((event) => event.kind === 'enemy_killed')
|
||||
.map((event) => ({ at: event.at, outcomeKey: null }));
|
||||
case 'target_hit_within_distance':
|
||||
if (
|
||||
!finiteNumber(assumptions.distanceMeters)
|
||||
|| assumptions.distanceMeters > trigger.params.meters + EPSILON
|
||||
) return [];
|
||||
return events
|
||||
.filter((event): event is ShotEvent =>
|
||||
event.kind === 'shot' && event.hit && Boolean(event.targetId)
|
||||
)
|
||||
.map((event) => ({ at: event.at, outcomeKey: null }));
|
||||
case 'damage_taken':
|
||||
return events
|
||||
.filter((event) => event.kind === 'damage_taken')
|
||||
.map((event) => ({ at: event.at, outcomeKey: null }));
|
||||
case 'weapon_swap':
|
||||
return events
|
||||
.filter((event) => event.kind === 'weapon_swapped')
|
||||
.map((event) => ({ at: event.at, outcomeKey: null }));
|
||||
case 'reload':
|
||||
return events
|
||||
.filter((event) => event.kind === 'reload_started')
|
||||
.map((event) => ({ at: event.at, outcomeKey: null }));
|
||||
case 'while_firing':
|
||||
case 'health_threshold_bands':
|
||||
case 'within_distance':
|
||||
case 'extra_health_active':
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function directRateCandidates(procsPerMinute: number, encounterSeconds: number): CandidateActivation[] {
|
||||
if (!finiteNumber(procsPerMinute) || procsPerMinute <= 0) return [];
|
||||
const interval = 60 / procsPerMinute;
|
||||
const candidates: CandidateActivation[] = [];
|
||||
for (let at = interval; at <= encounterSeconds + EPSILON; at += interval) {
|
||||
candidates.push({ at, outcomeKey: null });
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function strongestHealthOutcome(params: HealthThresholdParams, healthPercent: number | undefined) {
|
||||
if (!finiteNumber(healthPercent)) return null;
|
||||
const current = clamp(healthPercent, 0, 100);
|
||||
return [...params.bands]
|
||||
.sort((left, right) => left.thresholdPercent - right.thresholdPercent)
|
||||
.find((band) => current <= band.thresholdPercent + EPSILON)?.outcomeKey ?? null;
|
||||
}
|
||||
|
||||
function persistentCondition(
|
||||
trigger: TriggerConfiguration,
|
||||
assumptions: ConditionalAssumptions
|
||||
): { active: boolean; outcomeKey: string | null } {
|
||||
switch (trigger.action) {
|
||||
case 'health_threshold_bands': {
|
||||
const outcomeKey = strongestHealthOutcome(trigger.params, assumptions.healthPercent);
|
||||
return { active: outcomeKey !== null, outcomeKey };
|
||||
}
|
||||
case 'within_distance':
|
||||
return {
|
||||
active: finiteNumber(assumptions.distanceMeters)
|
||||
&& assumptions.distanceMeters <= trigger.params.meters + EPSILON,
|
||||
outcomeKey: null
|
||||
};
|
||||
case 'extra_health_active':
|
||||
return { active: assumptions.extraHealthActive === true, outcomeKey: null };
|
||||
default:
|
||||
return { active: false, outcomeKey: null };
|
||||
}
|
||||
}
|
||||
|
||||
function outcomeEventsAt(
|
||||
program: ConditionalProgram,
|
||||
at: number,
|
||||
stacks: number,
|
||||
outcomeKey: string | null
|
||||
): ConditionalOutcomeEvent[] {
|
||||
return program.outcomes
|
||||
.filter((outcome) => outcome.application !== 'active_modifier')
|
||||
.filter((outcome) => outcomeKey === null || outcome.outcomeKey === outcomeKey)
|
||||
.map((outcome) => ({
|
||||
at,
|
||||
effectiveAfter: at + EPSILON,
|
||||
outcomeId: outcome.id,
|
||||
application: outcome.application,
|
||||
scope: outcome.scope,
|
||||
value: outcome.value * stacks,
|
||||
basis: outcome.application === 'direct_damage' ? outcome.basis : null,
|
||||
stacks
|
||||
}));
|
||||
}
|
||||
|
||||
function summarizeIntervals(intervals: ConditionalStateInterval[], encounterSeconds: number) {
|
||||
if (encounterSeconds <= 0) return { averageStacks: 0, uptimePercent: 0 };
|
||||
let stackSeconds = 0;
|
||||
let activeSeconds = 0;
|
||||
for (const interval of intervals) {
|
||||
const duration = Math.max(0, interval.end - interval.start);
|
||||
stackSeconds += duration * interval.stacks;
|
||||
if (interval.stacks > 0) activeSeconds += duration;
|
||||
}
|
||||
return {
|
||||
averageStacks: stackSeconds / encounterSeconds,
|
||||
uptimePercent: clamp(activeSeconds / encounterSeconds * 100, 0, 100)
|
||||
};
|
||||
}
|
||||
|
||||
function stateIntervals(
|
||||
activations: ConditionalActivation[],
|
||||
encounterSeconds: number
|
||||
): ConditionalStateInterval[] {
|
||||
const boundaries = new Set<number>([0, encounterSeconds]);
|
||||
for (const activation of activations) {
|
||||
boundaries.add(clamp(activation.effectiveAfter, 0, encounterSeconds));
|
||||
boundaries.add(clamp(activation.expiresAt, 0, encounterSeconds));
|
||||
}
|
||||
const ordered = [...boundaries].sort((left, right) => left - right);
|
||||
const intervals: ConditionalStateInterval[] = [];
|
||||
for (let index = 0; index < ordered.length - 1; index += 1) {
|
||||
const start = ordered[index];
|
||||
const end = ordered[index + 1];
|
||||
if (end - start <= EPSILON) continue;
|
||||
const active = [...activations]
|
||||
.reverse()
|
||||
.find((activation) =>
|
||||
activation.effectiveAfter <= start + EPSILON
|
||||
&& activation.expiresAt > start + EPSILON
|
||||
);
|
||||
if (!active || active.stacksAfter <= 0) continue;
|
||||
const prior = intervals.at(-1);
|
||||
if (
|
||||
prior
|
||||
&& Math.abs(prior.end - start) <= EPSILON
|
||||
&& prior.stacks === active.stacksAfter
|
||||
&& prior.outcomeKey === active.outcomeKey
|
||||
) {
|
||||
prior.end = end;
|
||||
} else {
|
||||
intervals.push({ start, end, stacks: active.stacksAfter, outcomeKey: active.outcomeKey });
|
||||
}
|
||||
}
|
||||
return intervals;
|
||||
}
|
||||
|
||||
function evaluateCandidates(
|
||||
program: ConditionalProgram,
|
||||
candidates: CandidateActivation[],
|
||||
encounterSeconds: number
|
||||
) {
|
||||
const activations: ConditionalActivation[] = [];
|
||||
const outcomeEvents: ConditionalOutcomeEvent[] = [];
|
||||
let stacks = 0;
|
||||
let expiresAt = 0;
|
||||
let cooldownUntil = 0;
|
||||
for (const candidate of candidates.sort((left, right) => left.at - right.at)) {
|
||||
if (candidate.at < 0 || candidate.at > encounterSeconds + EPSILON) continue;
|
||||
if (candidate.at + EPSILON < cooldownUntil) continue;
|
||||
if (candidate.at + EPSILON >= expiresAt) stacks = 0;
|
||||
const stacksBefore = stacks;
|
||||
stacks = Math.min(
|
||||
program.lifecycle.maxStacks,
|
||||
stacks + program.lifecycle.stacksPerActivation
|
||||
);
|
||||
expiresAt = candidate.at + program.lifecycle.durationSeconds;
|
||||
cooldownUntil = candidate.at + program.lifecycle.cooldownSeconds;
|
||||
const activation: ConditionalActivation = {
|
||||
at: candidate.at,
|
||||
effectiveAfter: candidate.at + EPSILON,
|
||||
stacksBefore,
|
||||
stacksAfter: stacks,
|
||||
expiresAt,
|
||||
cooldownUntil,
|
||||
outcomeKey: candidate.outcomeKey
|
||||
};
|
||||
activations.push(activation);
|
||||
outcomeEvents.push(...outcomeEventsAt(program, candidate.at, stacks, candidate.outcomeKey));
|
||||
}
|
||||
return { activations, outcomeEvents };
|
||||
}
|
||||
|
||||
function evaluatePersistent(
|
||||
program: ConditionalProgram,
|
||||
assumptions: ConditionalAssumptions,
|
||||
outcomeKey: string | null
|
||||
) {
|
||||
const stacks = program.lifecycle.maxStacks;
|
||||
const activation: ConditionalActivation = {
|
||||
at: 0,
|
||||
effectiveAfter: 0,
|
||||
stacksBefore: 0,
|
||||
stacksAfter: stacks,
|
||||
expiresAt: assumptions.encounterSeconds,
|
||||
cooldownUntil: 0,
|
||||
outcomeKey
|
||||
};
|
||||
const intervals = assumptions.encounterSeconds > 0
|
||||
? [{ start: 0, end: assumptions.encounterSeconds, stacks, outcomeKey }]
|
||||
: [];
|
||||
return {
|
||||
activations: [activation],
|
||||
intervals,
|
||||
outcomeEvents: outcomeEventsAt(program, 0, stacks, outcomeKey)
|
||||
};
|
||||
}
|
||||
|
||||
function evaluateWhileFiring(
|
||||
program: ConditionalProgram,
|
||||
events: ConditionalEvent[],
|
||||
assumptions: ConditionalAssumptions
|
||||
) {
|
||||
const segments = firingSegments(events, assumptions.encounterSeconds);
|
||||
const stacks = program.lifecycle.maxStacks;
|
||||
const activations = segments.map<ConditionalActivation>((segment) => ({
|
||||
at: segment.start,
|
||||
effectiveAfter: segment.start + EPSILON,
|
||||
stacksBefore: 0,
|
||||
stacksAfter: stacks,
|
||||
expiresAt: segment.end,
|
||||
cooldownUntil: segment.start,
|
||||
outcomeKey: null
|
||||
}));
|
||||
return {
|
||||
activations,
|
||||
intervals: segments.map<ConditionalStateInterval>((segment) => ({
|
||||
start: segment.start,
|
||||
end: segment.end,
|
||||
stacks,
|
||||
outcomeKey: null
|
||||
})),
|
||||
outcomeEvents: activations.flatMap((activation) =>
|
||||
outcomeEventsAt(program, activation.at, stacks, null)
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function invalidEvaluation(programId: string, mode: ConditionalMode, errors: string[]): ConditionalEvaluation {
|
||||
return {
|
||||
status: 'INVALID',
|
||||
errors,
|
||||
programId,
|
||||
mode,
|
||||
activations: [],
|
||||
intervals: [],
|
||||
outcomeEvents: [],
|
||||
activationCount: 0,
|
||||
maxStacksReached: 0,
|
||||
averageStacks: 0,
|
||||
uptimePercent: 0,
|
||||
selectedOutcomeKey: null
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluateConditionalProgram(
|
||||
program: ConditionalProgram,
|
||||
events: ConditionalEvent[],
|
||||
assumptions: ConditionalAssumptions
|
||||
): ConditionalEvaluation {
|
||||
const validation = validateConditionalProgram(program);
|
||||
const assumptionErrors: string[] = [];
|
||||
if (!finiteNumber(assumptions.encounterSeconds) || assumptions.encounterSeconds <= 0) {
|
||||
assumptionErrors.push('Encounter seconds must be greater than 0.');
|
||||
}
|
||||
if (
|
||||
assumptions.directProcsPerMinute !== undefined
|
||||
&& (!finiteNumber(assumptions.directProcsPerMinute) || assumptions.directProcsPerMinute < 0)
|
||||
) {
|
||||
assumptionErrors.push('Direct procs per minute must be 0 or greater.');
|
||||
}
|
||||
if (!validation.ok || assumptionErrors.length) {
|
||||
return invalidEvaluation(
|
||||
program.id,
|
||||
assumptions.mode,
|
||||
[...(validation.ok ? [] : validation.errors), ...assumptionErrors]
|
||||
);
|
||||
}
|
||||
if (assumptions.mode === 'off') {
|
||||
return {
|
||||
...invalidEvaluation(program.id, assumptions.mode, []),
|
||||
status: 'OK'
|
||||
};
|
||||
}
|
||||
const ordered = orderedEvents(events, assumptions.encounterSeconds);
|
||||
let activations: ConditionalActivation[] = [];
|
||||
let intervals: ConditionalStateInterval[] = [];
|
||||
let outcomeEvents: ConditionalOutcomeEvent[] = [];
|
||||
let selectedOutcomeKey: string | null = null;
|
||||
|
||||
if (assumptions.mode === 'peak') {
|
||||
selectedOutcomeKey = program.trigger.action === 'health_threshold_bands'
|
||||
? strongestHealthOutcome(program.trigger.params, assumptions.healthPercent)
|
||||
?? [...program.trigger.params.bands]
|
||||
.sort((left, right) => left.thresholdPercent - right.thresholdPercent)[0]?.outcomeKey
|
||||
?? null
|
||||
: null;
|
||||
const persistent = evaluatePersistent(program, assumptions, selectedOutcomeKey);
|
||||
({ activations, intervals, outcomeEvents } = persistent);
|
||||
} else if (
|
||||
finiteNumber(assumptions.directProcsPerMinute)
|
||||
&& assumptions.directProcsPerMinute > 0
|
||||
) {
|
||||
const evaluated = evaluateCandidates(
|
||||
program,
|
||||
directRateCandidates(assumptions.directProcsPerMinute, assumptions.encounterSeconds),
|
||||
assumptions.encounterSeconds
|
||||
);
|
||||
activations = evaluated.activations;
|
||||
outcomeEvents = evaluated.outcomeEvents;
|
||||
intervals = stateIntervals(activations, assumptions.encounterSeconds);
|
||||
} else if (program.trigger.action === 'while_firing') {
|
||||
const evaluated = evaluateWhileFiring(program, ordered, assumptions);
|
||||
({ activations, intervals, outcomeEvents } = evaluated);
|
||||
} else {
|
||||
const condition = persistentCondition(program.trigger, assumptions);
|
||||
if (condition.active) {
|
||||
selectedOutcomeKey = condition.outcomeKey;
|
||||
const persistent = evaluatePersistent(program, assumptions, condition.outcomeKey);
|
||||
({ activations, intervals, outcomeEvents } = persistent);
|
||||
} else {
|
||||
const evaluated = evaluateCandidates(
|
||||
program,
|
||||
eventCandidates(program.trigger, ordered, assumptions),
|
||||
assumptions.encounterSeconds
|
||||
);
|
||||
activations = evaluated.activations;
|
||||
outcomeEvents = evaluated.outcomeEvents;
|
||||
intervals = stateIntervals(activations, assumptions.encounterSeconds);
|
||||
}
|
||||
}
|
||||
const summary = summarizeIntervals(intervals, assumptions.encounterSeconds);
|
||||
return {
|
||||
status: 'OK',
|
||||
errors: [],
|
||||
programId: program.id,
|
||||
mode: assumptions.mode,
|
||||
activations,
|
||||
intervals,
|
||||
outcomeEvents,
|
||||
activationCount: activations.length,
|
||||
maxStacksReached: Math.max(0, ...activations.map((activation) => activation.stacksAfter)),
|
||||
averageStacks: summary.averageStacks,
|
||||
uptimePercent: summary.uptimePercent,
|
||||
selectedOutcomeKey
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
parseConditionalMechanicDraft,
|
||||
type ConditionalMechanicDraftV1
|
||||
} from './mechanic-editor.js';
|
||||
|
||||
const draft = (): ConditionalMechanicDraftV1 => ({
|
||||
version: 1,
|
||||
simulationReadiness: 'ready',
|
||||
scenarioInputs: [],
|
||||
pools: [{
|
||||
key: 'effect',
|
||||
label: 'Effect',
|
||||
stateKind: 'stack',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 10,
|
||||
bestCaseValue: 10,
|
||||
expiresAfterSeconds: 10
|
||||
}],
|
||||
clauses: [{
|
||||
key: 'gain-effect',
|
||||
label: 'Gain Effect',
|
||||
event: 'weapon_hit',
|
||||
recipeKey: 'same_target_hits_within_window',
|
||||
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
||||
reducer: {
|
||||
kind: 'sliding_count',
|
||||
threshold: 9,
|
||||
windowSeconds: 3,
|
||||
groupBy: { source: 'event', path: 'targetId' }
|
||||
},
|
||||
cooldownSeconds: 2,
|
||||
weight: 1,
|
||||
mutations: [{ kind: 'add', pool: 'effect', value: 1 }],
|
||||
outcomes: [{
|
||||
key: 'weapon-damage',
|
||||
label: 'Weapon Damage',
|
||||
phase: 'while_active',
|
||||
kind: 'attribute_modifier',
|
||||
scope: 'equipped_weapon',
|
||||
basis: 'flat',
|
||||
attributeId: 'weapon-damage',
|
||||
statePool: 'effect',
|
||||
perStack: true,
|
||||
descriptionSlot: 1,
|
||||
variants: {
|
||||
I: { value: 3 },
|
||||
II: { value: 4.2 }
|
||||
}
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
describe('conditional mechanic authoring payload', () => {
|
||||
it('builds one validated runtime program per exact source variant', () => {
|
||||
const result = parseConditionalMechanicDraft(draft(), {
|
||||
variantKeys: ['I', 'II'],
|
||||
programId: 'superior-firepower',
|
||||
programName: 'Superior Firepower',
|
||||
allowedAttributeIds: new Set(['weapon-damage']),
|
||||
allowedRecipeKeys: new Set(['same_target_hits_within_window'])
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(Object.keys(result.value.programs)).toEqual(['I', 'II']);
|
||||
expect(result.value.programs.I.clauses[0]).toMatchObject({
|
||||
id: 'gain-effect',
|
||||
when: 'weapon_hit',
|
||||
recipeKey: 'same_target_hits_within_window',
|
||||
cooldownSeconds: 2,
|
||||
weight: 1,
|
||||
outcomes: [{
|
||||
id: 'weapon-damage',
|
||||
scope: 'equipped_weapon',
|
||||
value: 3
|
||||
}]
|
||||
});
|
||||
expect(result.value.programs.II.clauses[0].outcomes?.[0].value).toBe(4.2);
|
||||
});
|
||||
|
||||
it('preserves Happy Survival expressions, explicit cadence readiness, resources, and combat timing', () => {
|
||||
const happy: ConditionalMechanicDraftV1 = {
|
||||
version: 1,
|
||||
simulationReadiness: 'requires_assumptions',
|
||||
scenarioInputs: [{
|
||||
key: 'restoration-events-per-minute',
|
||||
label: 'Restoration events per minute',
|
||||
kind: 'number',
|
||||
readiness: 'requires_assumption',
|
||||
min: 0
|
||||
}],
|
||||
pools: [{
|
||||
key: 'nearby-enemies',
|
||||
label: 'Nearby Enemies',
|
||||
stateKind: 'count',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 20,
|
||||
bestCaseValue: 5
|
||||
}],
|
||||
clauses: [{
|
||||
key: 'shot-health-exchange',
|
||||
event: 'shot_fired',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'none' }],
|
||||
outcomes: [
|
||||
{
|
||||
key: 'health-cost',
|
||||
label: 'Health Cost',
|
||||
phase: 'on_event',
|
||||
kind: 'resource_cost',
|
||||
resource: 'health',
|
||||
scope: 'self',
|
||||
basis: 'max_health_percent',
|
||||
variants: { base: { value: 2 } }
|
||||
},
|
||||
{
|
||||
key: 'bonus-damage',
|
||||
label: 'Current Health Bonus Damage',
|
||||
phase: 'on_event',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
basis: 'current_health_percent',
|
||||
combatTiming: 'current',
|
||||
variants: {
|
||||
base: {
|
||||
value: 0.6,
|
||||
expression: {
|
||||
op: 'multiply',
|
||||
left: 0.6,
|
||||
right: {
|
||||
op: 'add',
|
||||
left: 1,
|
||||
right: {
|
||||
op: 'divide',
|
||||
left: {
|
||||
ref: {
|
||||
source: 'equipment',
|
||||
path: 'protocolTalentUpgrades'
|
||||
}
|
||||
},
|
||||
right: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
};
|
||||
const result = parseConditionalMechanicDraft(happy, {
|
||||
variantKeys: ['base'],
|
||||
programName: 'Happy Survival'
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.programs.base.clauses[0].outcomes).toEqual([
|
||||
expect.objectContaining({ kind: 'resource_cost', resource: 'health', value: 2 }),
|
||||
expect.objectContaining({
|
||||
kind: 'direct_damage',
|
||||
combatTiming: 'current',
|
||||
value: expect.objectContaining({
|
||||
right: expect.objectContaining({
|
||||
right: expect.objectContaining({
|
||||
left: {
|
||||
ref: {
|
||||
source: 'equipment',
|
||||
path: 'protocolTalentUpgrades'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires the exact configured variants and returns validation errors without throwing', () => {
|
||||
const invalid = draft();
|
||||
invalid.clauses[0].outcomes[0].variants = {
|
||||
I: { value: 3 },
|
||||
legacy: { value: 99 }
|
||||
};
|
||||
const result = parseConditionalMechanicDraft(invalid, { variantKeys: ['I', 'II'] });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors).toContain(
|
||||
'Clause 1 outcome 1 must define exactly these variants: I, II.'
|
||||
);
|
||||
}
|
||||
expect(() => parseConditionalMechanicDraft('{not-json', { variantKeys: ['base'] }))
|
||||
.not.toThrow();
|
||||
expect(parseConditionalMechanicDraft('{not-json', { variantKeys: ['base'] }))
|
||||
.toEqual({
|
||||
ok: false,
|
||||
errors: ['The conditional mechanic contains malformed JSON.']
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,605 @@
|
||||
import {
|
||||
MECHANIC_EVENT_KINDS,
|
||||
validateMechanicProgram,
|
||||
type ClauseMutation,
|
||||
type MechanicClause,
|
||||
type MechanicEventKind,
|
||||
type MechanicOutcome,
|
||||
type MechanicOutcomeScope,
|
||||
type MechanicProgram,
|
||||
type MechanicResource,
|
||||
type NumericExpression,
|
||||
type OutcomeBasis,
|
||||
type OutcomePhase,
|
||||
type PoolDefinition,
|
||||
type Predicate,
|
||||
type Reducer,
|
||||
type ScenarioInputDefinition,
|
||||
type StateScope,
|
||||
type ValueReference
|
||||
} from './mechanic.js';
|
||||
|
||||
export const CONDITIONAL_MECHANIC_DRAFT_VERSION = 1 as const;
|
||||
|
||||
export type MechanicStateKind =
|
||||
| 'flag'
|
||||
| 'count'
|
||||
| 'stack'
|
||||
| 'duration'
|
||||
| 'scalar'
|
||||
| 'target_mark'
|
||||
| 'resource';
|
||||
|
||||
export type ConditionalMechanicPoolDraft = PoolDefinition & {
|
||||
stateKind: MechanicStateKind;
|
||||
};
|
||||
|
||||
export type ConditionalMechanicVariantValue = {
|
||||
value: number;
|
||||
expression?: NumericExpression;
|
||||
};
|
||||
|
||||
export type ConditionalMechanicOutcomeDraft = Omit<MechanicOutcome, 'id' | 'value'> & {
|
||||
key: string;
|
||||
descriptionSlot?: number;
|
||||
variants: Record<string, ConditionalMechanicVariantValue>;
|
||||
};
|
||||
|
||||
export type ConditionalMechanicClauseDraft = Omit<MechanicClause, 'id' | 'when' | 'outcomes'> & {
|
||||
key: string;
|
||||
event: MechanicEventKind;
|
||||
recipeKey?: string;
|
||||
outcomes: ConditionalMechanicOutcomeDraft[];
|
||||
};
|
||||
|
||||
export type ConditionalMechanicDraftV1 = {
|
||||
version: typeof CONDITIONAL_MECHANIC_DRAFT_VERSION;
|
||||
simulationReadiness: 'ready' | 'requires_assumptions' | 'captured_only';
|
||||
scenarioInputs: ScenarioInputDefinition[];
|
||||
pools: ConditionalMechanicPoolDraft[];
|
||||
clauses: ConditionalMechanicClauseDraft[];
|
||||
};
|
||||
|
||||
export type ParseConditionalMechanicDraftOptions = {
|
||||
variantKeys: readonly string[];
|
||||
programId?: string;
|
||||
programName?: string;
|
||||
allowedAttributeIds?: ReadonlySet<string>;
|
||||
allowedRecipeKeys?: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
export type ParsedConditionalMechanicDraft = {
|
||||
draft: ConditionalMechanicDraftV1;
|
||||
programs: Record<string, MechanicProgram>;
|
||||
};
|
||||
|
||||
export type ConditionalMechanicDraftResult =
|
||||
| { ok: true; value: ParsedConditionalMechanicDraft }
|
||||
| { ok: false; errors: string[] };
|
||||
|
||||
const object = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
const finite = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value);
|
||||
const scalar = (value: unknown) =>
|
||||
value === null || ['string', 'number', 'boolean'].includes(typeof value);
|
||||
const identifier = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
|
||||
const events = new Set<string>(MECHANIC_EVENT_KINDS);
|
||||
const phases = new Set<OutcomePhase>(['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active']);
|
||||
const outcomeKinds = new Set<MechanicOutcome['kind']>([
|
||||
'attribute_modifier',
|
||||
'direct_damage',
|
||||
'restoration',
|
||||
'resource_cost',
|
||||
'marker'
|
||||
]);
|
||||
const scopes = new Set<MechanicOutcomeScope>([
|
||||
'self',
|
||||
'equipped_weapon',
|
||||
'target',
|
||||
'allies',
|
||||
'self_and_allies',
|
||||
'assigned_ability',
|
||||
'all_skills'
|
||||
]);
|
||||
const bases = new Set<OutcomeBasis>([
|
||||
'flat',
|
||||
'max_health_percent',
|
||||
'current_health_percent',
|
||||
'weapon_base_percent'
|
||||
]);
|
||||
const resources = new Set<MechanicResource>(['health', 'armor', 'ammo']);
|
||||
const stateKinds = new Set<MechanicStateKind>([
|
||||
'flag',
|
||||
'count',
|
||||
'stack',
|
||||
'duration',
|
||||
'scalar',
|
||||
'target_mark',
|
||||
'resource'
|
||||
]);
|
||||
|
||||
function parseRaw(raw: unknown, errors: string[]) {
|
||||
if (typeof raw !== 'string') return raw;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
errors.push('The conditional mechanic contains malformed JSON.');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function key(value: unknown, path: string, errors: string[]) {
|
||||
if (typeof value !== 'string' || !identifier.test(value)) {
|
||||
errors.push(`${path} must use a stable lower-case key.`);
|
||||
return '';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isEntitySelector(value: unknown) {
|
||||
if (!object(value) || typeof value.kind !== 'string') return false;
|
||||
if (['agent', 'event_actor', 'event_target'].includes(value.kind)) return true;
|
||||
return value.kind === 'event_field' && typeof value.path === 'string' && Boolean(value.path.trim());
|
||||
}
|
||||
|
||||
function isReference(value: unknown): value is ValueReference {
|
||||
if (!object(value) || typeof value.source !== 'string') return false;
|
||||
switch (value.source) {
|
||||
case 'event':
|
||||
return typeof value.path === 'string' && Boolean(value.path.trim());
|
||||
case 'scenario':
|
||||
return typeof value.key === 'string' && Boolean(value.key.trim());
|
||||
case 'combat':
|
||||
return typeof value.path === 'string'
|
||||
&& Boolean(value.path.trim())
|
||||
&& (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current');
|
||||
case 'equipment':
|
||||
return typeof value.path === 'string' && Boolean(value.path.trim());
|
||||
case 'reducer':
|
||||
return true;
|
||||
case 'pool':
|
||||
return typeof value.pool === 'string'
|
||||
&& Boolean(value.pool.trim())
|
||||
&& (value.entity === undefined || isEntitySelector(value.entity));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isExpression(value: unknown): value is NumericExpression {
|
||||
if (finite(value)) return true;
|
||||
if (!object(value)) return false;
|
||||
if ('ref' in value) return isReference(value.ref);
|
||||
if (value.op === 'clamp') {
|
||||
return isExpression(value.value) && isExpression(value.min) && isExpression(value.max);
|
||||
}
|
||||
return ['add', 'subtract', 'multiply', 'divide', 'min', 'max'].includes(String(value.op))
|
||||
&& isExpression(value.left)
|
||||
&& isExpression(value.right);
|
||||
}
|
||||
|
||||
function isOperand(value: unknown) {
|
||||
return scalar(value) || isReference(value);
|
||||
}
|
||||
|
||||
function isPredicate(value: unknown): value is Predicate {
|
||||
if (!object(value) || typeof value.kind !== 'string') return false;
|
||||
if (value.kind === 'true') return true;
|
||||
if (value.kind === 'exists') return isReference(value.value);
|
||||
if (value.kind === 'not') return isPredicate(value.predicate);
|
||||
if (value.kind === 'all' || value.kind === 'any') {
|
||||
return Array.isArray(value.predicates) && value.predicates.every(isPredicate);
|
||||
}
|
||||
return value.kind === 'compare'
|
||||
&& ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'].includes(String(value.operator))
|
||||
&& isOperand(value.left)
|
||||
&& isOperand(value.right);
|
||||
}
|
||||
|
||||
function optionalGroupBy(value: Record<string, unknown>) {
|
||||
return value.groupBy === undefined || isReference(value.groupBy);
|
||||
}
|
||||
|
||||
function isReducer(value: unknown): value is Reducer {
|
||||
if (!object(value) || typeof value.kind !== 'string') return false;
|
||||
switch (value.kind) {
|
||||
case 'none':
|
||||
return true;
|
||||
case 'count':
|
||||
return finite(value.threshold) && optionalGroupBy(value);
|
||||
case 'modulo':
|
||||
return finite(value.every) && optionalGroupBy(value);
|
||||
case 'distinct_count':
|
||||
return finite(value.threshold)
|
||||
&& finite(value.windowSeconds)
|
||||
&& isReference(value.distinctBy);
|
||||
case 'consecutive_count':
|
||||
return finite(value.threshold) && optionalGroupBy(value);
|
||||
case 'sliding_count':
|
||||
return finite(value.threshold) && finite(value.windowSeconds) && optionalGroupBy(value);
|
||||
case 'duration':
|
||||
return finite(value.seconds) && optionalGroupBy(value);
|
||||
case 'periodic':
|
||||
return isExpression(value.intervalSeconds) && optionalGroupBy(value);
|
||||
case 'derived_scalar':
|
||||
return isExpression(value.value);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isMutation(value: unknown): value is ClauseMutation {
|
||||
if (!object(value) || typeof value.kind !== 'string') return false;
|
||||
if (value.kind === 'none') return true;
|
||||
if (value.kind === 'clear') {
|
||||
return typeof value.pool === 'string'
|
||||
&& (value.entity === undefined || isEntitySelector(value.entity));
|
||||
}
|
||||
if (value.kind === 'transfer') {
|
||||
return typeof value.pool === 'string'
|
||||
&& isEntitySelector(value.from)
|
||||
&& isEntitySelector(value.to)
|
||||
&& (value.value === undefined || value.value === 'all' || isExpression(value.value));
|
||||
}
|
||||
return ['add', 'set', 'consume', 'derive'].includes(value.kind)
|
||||
&& typeof value.pool === 'string'
|
||||
&& isExpression(value.value)
|
||||
&& (value.entity === undefined || isEntitySelector(value.entity));
|
||||
}
|
||||
|
||||
function parseScenarioInputs(value: unknown, errors: string[]) {
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push('Scenario inputs must be an array.');
|
||||
return [] as ScenarioInputDefinition[];
|
||||
}
|
||||
const inputs: ScenarioInputDefinition[] = [];
|
||||
for (const [index, candidate] of value.entries()) {
|
||||
const path = `Scenario input ${index + 1}`;
|
||||
if (!object(candidate)) {
|
||||
errors.push(`${path} must be an object.`);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!key(candidate.key, `${path} key`, errors)
|
||||
|| typeof candidate.label !== 'string'
|
||||
|| !candidate.label.trim()
|
||||
|| !['toggle', 'slider', 'number'].includes(String(candidate.kind))
|
||||
) {
|
||||
errors.push(`${path} requires a label and supported input kind.`);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
candidate.readiness !== undefined
|
||||
&& candidate.readiness !== 'ready'
|
||||
&& candidate.readiness !== 'requires_assumption'
|
||||
) {
|
||||
errors.push(`${path} has an invalid readiness value.`);
|
||||
continue;
|
||||
}
|
||||
inputs.push(candidate as ScenarioInputDefinition);
|
||||
}
|
||||
return inputs;
|
||||
}
|
||||
|
||||
function parsePools(value: unknown, errors: string[]) {
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push('State pools must be an array.');
|
||||
return [] as ConditionalMechanicPoolDraft[];
|
||||
}
|
||||
const pools: ConditionalMechanicPoolDraft[] = [];
|
||||
for (const [index, candidate] of value.entries()) {
|
||||
const path = `State pool ${index + 1}`;
|
||||
if (!object(candidate)) {
|
||||
errors.push(`${path} must be an object.`);
|
||||
continue;
|
||||
}
|
||||
const poolKey = key(candidate.key, `${path} key`, errors);
|
||||
if (
|
||||
!poolKey
|
||||
|| typeof candidate.label !== 'string'
|
||||
|| !candidate.label.trim()
|
||||
|| !stateKinds.has(candidate.stateKind as MechanicStateKind)
|
||||
|| !['agent', 'target'].includes(String(candidate.scope))
|
||||
|| !finite(candidate.initial)
|
||||
) {
|
||||
errors.push(`${path} requires a label, state kind, scope, and finite initial value.`);
|
||||
continue;
|
||||
}
|
||||
for (const property of ['min', 'max', 'bestCaseValue', 'expiresAfterSeconds'] as const) {
|
||||
if (candidate[property] !== undefined && !finite(candidate[property])) {
|
||||
errors.push(`${path} ${property} must be a finite number.`);
|
||||
}
|
||||
}
|
||||
pools.push(candidate as ConditionalMechanicPoolDraft);
|
||||
}
|
||||
return pools;
|
||||
}
|
||||
|
||||
function parseOutcome(
|
||||
value: unknown,
|
||||
path: string,
|
||||
variantKeys: readonly string[],
|
||||
allowedAttributes: ReadonlySet<string> | undefined,
|
||||
errors: string[]
|
||||
): ConditionalMechanicOutcomeDraft | null {
|
||||
if (!object(value)) {
|
||||
errors.push(`${path} must be an object.`);
|
||||
return null;
|
||||
}
|
||||
const outcomeKey = key(value.key, `${path} key`, errors);
|
||||
if (
|
||||
!outcomeKey
|
||||
|| typeof value.label !== 'string'
|
||||
|| !value.label.trim()
|
||||
|| !phases.has(value.phase as OutcomePhase)
|
||||
|| !outcomeKinds.has(value.kind as MechanicOutcome['kind'])
|
||||
|| !scopes.has(value.scope as MechanicOutcomeScope)
|
||||
|| !bases.has(value.basis as OutcomeBasis)
|
||||
) {
|
||||
errors.push(`${path} requires a label, phase, kind, scope, and value basis.`);
|
||||
return null;
|
||||
}
|
||||
const kind = value.kind as MechanicOutcome['kind'];
|
||||
if (kind === 'attribute_modifier') {
|
||||
if (typeof value.attributeId !== 'string' || !value.attributeId.trim()) {
|
||||
errors.push(`${path} attribute modifiers require an Attribute ID.`);
|
||||
} else if (allowedAttributes && !allowedAttributes.has(value.attributeId)) {
|
||||
errors.push(`${path} references unknown Attribute "${value.attributeId}".`);
|
||||
}
|
||||
} else if (value.attributeId !== undefined) {
|
||||
errors.push(`${path} may only use an Attribute ID for an attribute modifier.`);
|
||||
}
|
||||
if (kind === 'restoration' || kind === 'resource_cost') {
|
||||
if (!resources.has(value.resource as MechanicResource)) {
|
||||
errors.push(`${path} ${kind.replace('_', ' ')} requires a resource.`);
|
||||
}
|
||||
} else if (value.resource !== undefined) {
|
||||
errors.push(`${path} may only use a resource for restoration or resource cost.`);
|
||||
}
|
||||
if (
|
||||
value.combatTiming !== undefined
|
||||
&& value.combatTiming !== 'pre_event'
|
||||
&& value.combatTiming !== 'current'
|
||||
) {
|
||||
errors.push(`${path} has invalid combat timing.`);
|
||||
}
|
||||
if (
|
||||
value.descriptionSlot !== undefined
|
||||
&& (!Number.isInteger(value.descriptionSlot) || Number(value.descriptionSlot) < 1)
|
||||
) {
|
||||
errors.push(`${path} description slot must be a positive integer.`);
|
||||
}
|
||||
if (!object(value.variants)) {
|
||||
errors.push(`${path} variants must be an object.`);
|
||||
return null;
|
||||
}
|
||||
const actualKeys = Object.keys(value.variants).sort();
|
||||
const expectedKeys = [...variantKeys].sort();
|
||||
if (
|
||||
actualKeys.length !== expectedKeys.length
|
||||
|| actualKeys.some((variant, index) => variant !== expectedKeys[index])
|
||||
) {
|
||||
errors.push(`${path} must define exactly these variants: ${variantKeys.join(', ')}.`);
|
||||
}
|
||||
const variants: Record<string, ConditionalMechanicVariantValue> = {};
|
||||
for (const variant of variantKeys) {
|
||||
const configured = value.variants[variant];
|
||||
if (!object(configured) || !finite(configured.value)) {
|
||||
errors.push(`${path} variant "${variant}" requires a finite display value.`);
|
||||
continue;
|
||||
}
|
||||
if (configured.expression !== undefined && !isExpression(configured.expression)) {
|
||||
errors.push(`${path} variant "${variant}" has an invalid value expression.`);
|
||||
continue;
|
||||
}
|
||||
variants[variant] = configured as ConditionalMechanicVariantValue;
|
||||
}
|
||||
return {
|
||||
...(value as unknown as ConditionalMechanicOutcomeDraft),
|
||||
key: outcomeKey,
|
||||
variants
|
||||
};
|
||||
}
|
||||
|
||||
function parseClauses(
|
||||
value: unknown,
|
||||
variantKeys: readonly string[],
|
||||
options: ParseConditionalMechanicDraftOptions,
|
||||
errors: string[]
|
||||
) {
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push('Mechanic clauses must be an array.');
|
||||
return [] as ConditionalMechanicClauseDraft[];
|
||||
}
|
||||
const clauses: ConditionalMechanicClauseDraft[] = [];
|
||||
for (const [index, candidate] of value.entries()) {
|
||||
const path = `Clause ${index + 1}`;
|
||||
if (!object(candidate)) {
|
||||
errors.push(`${path} must be an object.`);
|
||||
continue;
|
||||
}
|
||||
const clauseKey = key(candidate.key, `${path} key`, errors);
|
||||
if (!clauseKey || !events.has(String(candidate.event))) {
|
||||
errors.push(`${path} requires a supported direct event.`);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
candidate.recipeKey !== undefined
|
||||
&& (
|
||||
typeof candidate.recipeKey !== 'string'
|
||||
|| !candidate.recipeKey.trim()
|
||||
|| (options.allowedRecipeKeys && !options.allowedRecipeKeys.has(candidate.recipeKey))
|
||||
)
|
||||
) {
|
||||
errors.push(`${path} has an unknown recipe key.`);
|
||||
}
|
||||
if (!isPredicate(candidate.if ?? { kind: 'true' })) {
|
||||
errors.push(`${path} has an invalid predicate.`);
|
||||
}
|
||||
if (!isReducer(candidate.reducer)) errors.push(`${path} has an invalid reducer.`);
|
||||
if (candidate.weight !== undefined && !isExpression(candidate.weight)) {
|
||||
errors.push(`${path} has an invalid weight expression.`);
|
||||
}
|
||||
if (
|
||||
candidate.cooldownSeconds !== undefined
|
||||
&& (!finite(candidate.cooldownSeconds) || candidate.cooldownSeconds <= 0)
|
||||
) {
|
||||
errors.push(`${path} cooldown must be greater than zero.`);
|
||||
}
|
||||
if (!Array.isArray(candidate.mutations) || !candidate.mutations.every(isMutation)) {
|
||||
errors.push(`${path} mutations must be a valid array.`);
|
||||
}
|
||||
if (!Array.isArray(candidate.outcomes)) {
|
||||
errors.push(`${path} outcomes must be an array.`);
|
||||
continue;
|
||||
}
|
||||
const outcomes = candidate.outcomes
|
||||
.map((outcome, outcomeIndex) => parseOutcome(
|
||||
outcome,
|
||||
`${path} outcome ${outcomeIndex + 1}`,
|
||||
variantKeys,
|
||||
options.allowedAttributeIds,
|
||||
errors
|
||||
))
|
||||
.filter((outcome): outcome is ConditionalMechanicOutcomeDraft => outcome !== null);
|
||||
clauses.push({
|
||||
...(candidate as unknown as ConditionalMechanicClauseDraft),
|
||||
key: clauseKey,
|
||||
event: candidate.event as MechanicEventKind,
|
||||
if: (candidate.if ?? { kind: 'true' }) as Predicate,
|
||||
reducer: candidate.reducer as Reducer,
|
||||
mutations: Array.isArray(candidate.mutations) ? candidate.mutations as ClauseMutation[] : [],
|
||||
outcomes
|
||||
});
|
||||
}
|
||||
return clauses;
|
||||
}
|
||||
|
||||
function programsForDraft(
|
||||
draft: ConditionalMechanicDraftV1,
|
||||
options: ParseConditionalMechanicDraftOptions
|
||||
) {
|
||||
const programs: Record<string, MechanicProgram> = {};
|
||||
for (const variant of options.variantKeys) {
|
||||
programs[variant] = {
|
||||
id: `${options.programId ?? 'mechanic'}:${variant}`,
|
||||
name: options.programName?.trim() || 'Conditional Mechanic',
|
||||
scenarioInputs: draft.scenarioInputs,
|
||||
pools: draft.pools.map(({ stateKind: _stateKind, ...pool }) => pool),
|
||||
clauses: draft.clauses.map((clause): MechanicClause => ({
|
||||
id: clause.key,
|
||||
...(clause.label ? { label: clause.label } : {}),
|
||||
when: clause.event,
|
||||
...(clause.recipeKey ? { recipeKey: clause.recipeKey } : {}),
|
||||
...(clause.if ? { if: clause.if } : {}),
|
||||
reducer: clause.reducer,
|
||||
...(clause.cooldownSeconds === undefined
|
||||
? {}
|
||||
: { cooldownSeconds: clause.cooldownSeconds }),
|
||||
...(clause.weight === undefined ? {} : { weight: clause.weight }),
|
||||
mutations: clause.mutations,
|
||||
outcomes: clause.outcomes.map(({ key: outcomeKey, variants, descriptionSlot: _slot, ...outcome }) => ({
|
||||
...outcome,
|
||||
id: outcomeKey,
|
||||
value: variants[variant].expression ?? variants[variant].value
|
||||
}))
|
||||
}))
|
||||
};
|
||||
}
|
||||
return programs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the browser-authored mechanic payload without evaluating text as code.
|
||||
* Every branch is converted to each source variant and validated by the runtime.
|
||||
* Malformed payloads return errors and never escape into page rendering as throws.
|
||||
*/
|
||||
export function parseConditionalMechanicDraft(
|
||||
raw: unknown,
|
||||
options: ParseConditionalMechanicDraftOptions
|
||||
): ConditionalMechanicDraftResult {
|
||||
const errors: string[] = [];
|
||||
try {
|
||||
if (
|
||||
!Array.isArray(options.variantKeys)
|
||||
|| !options.variantKeys.length
|
||||
|| options.variantKeys.some((variant) => !variant.trim())
|
||||
|| new Set(options.variantKeys).size !== options.variantKeys.length
|
||||
) {
|
||||
return { ok: false, errors: ['Variant keys must be unique non-empty strings.'] };
|
||||
}
|
||||
const parsed = parseRaw(raw, errors);
|
||||
if (!object(parsed)) return { ok: false, errors: errors.length ? errors : ['The conditional mechanic must be an object.'] };
|
||||
if (parsed.version !== CONDITIONAL_MECHANIC_DRAFT_VERSION) {
|
||||
errors.push(`Conditional mechanic version ${CONDITIONAL_MECHANIC_DRAFT_VERSION} is required.`);
|
||||
}
|
||||
if (!['ready', 'requires_assumptions', 'captured_only'].includes(String(parsed.simulationReadiness))) {
|
||||
errors.push('The conditional mechanic has invalid simulation readiness.');
|
||||
}
|
||||
const scenarioInputs = parseScenarioInputs(parsed.scenarioInputs, errors);
|
||||
const pools = parsePools(parsed.pools, errors);
|
||||
const clauses = parseClauses(parsed.clauses, options.variantKeys, options, errors);
|
||||
const draft: ConditionalMechanicDraftV1 = {
|
||||
version: CONDITIONAL_MECHANIC_DRAFT_VERSION,
|
||||
simulationReadiness: parsed.simulationReadiness as ConditionalMechanicDraftV1['simulationReadiness'],
|
||||
scenarioInputs,
|
||||
pools,
|
||||
clauses
|
||||
};
|
||||
const inputRequiresAssumption = scenarioInputs.some(
|
||||
(input) => input.readiness === 'requires_assumption'
|
||||
);
|
||||
if (draft.simulationReadiness === 'ready' && inputRequiresAssumption) {
|
||||
errors.push('Simulation readiness must require assumptions while an input requires an assumption.');
|
||||
}
|
||||
if (draft.simulationReadiness !== 'captured_only' && !clauses.length) {
|
||||
errors.push('A simulation-ready mechanic requires at least one clause.');
|
||||
}
|
||||
const duplicate = (values: string[], label: string) => {
|
||||
const seen = new Set<string>();
|
||||
for (const value of values) {
|
||||
if (seen.has(value)) errors.push(`${label} "${value}" is duplicated.`);
|
||||
seen.add(value);
|
||||
}
|
||||
};
|
||||
duplicate(scenarioInputs.map((input) => input.key), 'Scenario input');
|
||||
duplicate(pools.map((pool) => pool.key), 'State pool');
|
||||
duplicate(clauses.map((clause) => clause.key), 'Clause');
|
||||
for (const clause of clauses) {
|
||||
duplicate(clause.outcomes.map((outcome) => outcome.key), `Clause "${clause.key}" outcome`);
|
||||
}
|
||||
if (errors.length) return { ok: false, errors };
|
||||
const programs = programsForDraft(draft, options);
|
||||
for (const [variant, program] of Object.entries(programs)) {
|
||||
let validation;
|
||||
try {
|
||||
validation = validateMechanicProgram(program);
|
||||
} catch {
|
||||
errors.push(`Variant "${variant}" contains a structure the mechanic runtime cannot validate.`);
|
||||
continue;
|
||||
}
|
||||
if (!validation.ok) {
|
||||
errors.push(...validation.errors.map((error) => `Variant "${variant}": ${error}`));
|
||||
}
|
||||
}
|
||||
return errors.length
|
||||
? { ok: false, errors }
|
||||
: { ok: true, value: { draft, programs } };
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
errors: ['The conditional mechanic could not be parsed safely.']
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function blankConditionalMechanicDraft(): ConditionalMechanicDraftV1 {
|
||||
return {
|
||||
version: CONDITIONAL_MECHANIC_DRAFT_VERSION,
|
||||
simulationReadiness: 'captured_only',
|
||||
scenarioInputs: [],
|
||||
pools: [],
|
||||
clauses: []
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
adaptConditionalEffectProgram,
|
||||
type ConditionalEffectClauseRow,
|
||||
type ConditionalEffectOutcomeRow,
|
||||
type ConditionalEffectRuleRow,
|
||||
type ConditionalEffectStatePoolRow
|
||||
} from './mechanic-persistence.js';
|
||||
|
||||
const rule = (
|
||||
overrides: Partial<ConditionalEffectRuleRow> = {}
|
||||
): ConditionalEffectRuleRow => ({
|
||||
id: 'rule-1',
|
||||
source_type: 'talent',
|
||||
source_id: 'talent-1',
|
||||
configured: 1,
|
||||
script_version: 1,
|
||||
simulation_readiness: 'ready',
|
||||
scenario_inputs_json: '[]',
|
||||
...overrides
|
||||
});
|
||||
|
||||
const pool = (
|
||||
overrides: Partial<ConditionalEffectStatePoolRow> = {}
|
||||
): ConditionalEffectStatePoolRow => ({
|
||||
id: 'pool-1',
|
||||
rule_id: 'rule-1',
|
||||
state_key: 'effect',
|
||||
label: 'Effect',
|
||||
state_kind: 'stack',
|
||||
state_scope: 'agent',
|
||||
initial_value: 0,
|
||||
minimum_value: 0,
|
||||
maximum_value: 10,
|
||||
best_case_value: 10,
|
||||
duration_seconds: 10,
|
||||
expiry_policy: 'clear_all',
|
||||
refresh_policy: 'refresh',
|
||||
parameters_json: '{}',
|
||||
sort_order: 1,
|
||||
...overrides
|
||||
});
|
||||
|
||||
const clause = (
|
||||
overrides: Partial<ConditionalEffectClauseRow> = {}
|
||||
): ConditionalEffectClauseRow => ({
|
||||
id: 'clause-1',
|
||||
rule_id: 'rule-1',
|
||||
clause_key: 'primary',
|
||||
label: 'Primary',
|
||||
trigger_id: 'trigger-1',
|
||||
parameters_json: JSON.stringify({ hitsRequired: 9, windowSeconds: 3 }),
|
||||
conditions_json: JSON.stringify({ kind: 'true' }),
|
||||
reducer_json: JSON.stringify({ kind: 'none' }),
|
||||
emission_kind: 'each_match',
|
||||
state_pool_id: 'pool-1',
|
||||
mutations_json: JSON.stringify([{ kind: 'add', pool: 'effect', value: 1 }]),
|
||||
cooldown_seconds: null,
|
||||
output_branch: 'active',
|
||||
sort_order: 1,
|
||||
active: 1,
|
||||
...overrides
|
||||
});
|
||||
|
||||
const outcome = (
|
||||
overrides: Partial<ConditionalEffectOutcomeRow> = {}
|
||||
): ConditionalEffectOutcomeRow => ({
|
||||
id: 'outcome-1',
|
||||
rule_id: 'rule-1',
|
||||
clause_id: 'clause-1',
|
||||
state_pool_id: 'pool-1',
|
||||
effect_key: 'weapon-damage',
|
||||
variant_key: 'I',
|
||||
outcome_key: 'active',
|
||||
application_kind: 'active_modifier',
|
||||
effect_scope: 'equipped_weapon',
|
||||
stat_attribute_id: 'weapon-damage',
|
||||
value: 3,
|
||||
value_expression_json: null,
|
||||
value_basis: 'flat',
|
||||
value_scaling: 'per_state_unit',
|
||||
unit: '%',
|
||||
damage_basis: null,
|
||||
application_phase: 'while_active',
|
||||
min_state_value: 1,
|
||||
max_state_value: null,
|
||||
description_slot: 1,
|
||||
sort_order: 1,
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('conditional-effect persistence adapter', () => {
|
||||
it('adapts a migrated simple same-target rule without losing its typed mechanics', () => {
|
||||
const result = adaptConditionalEffectProgram({
|
||||
rule: rule(),
|
||||
statePools: [pool()],
|
||||
clauses: [clause()],
|
||||
outcomes: [outcome()],
|
||||
triggers: [{
|
||||
id: 'trigger-1',
|
||||
action_key: 'same_target_hits_within_window',
|
||||
name: 'Same-target hits'
|
||||
}]
|
||||
}, { name: 'Superior Firepower', variantKey: 'I' });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'READY',
|
||||
simulationReadiness: 'ready',
|
||||
errors: []
|
||||
});
|
||||
expect(result.program).toMatchObject({
|
||||
id: 'rule-1',
|
||||
name: 'Superior Firepower',
|
||||
pools: [{
|
||||
key: 'effect',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
max: 10,
|
||||
bestCaseValue: 10,
|
||||
expiresAfterSeconds: 10
|
||||
}],
|
||||
clauses: [{
|
||||
id: 'clause-1',
|
||||
when: 'weapon_hit',
|
||||
if: {
|
||||
kind: 'exists',
|
||||
value: { source: 'event', path: 'targetId' }
|
||||
},
|
||||
reducer: {
|
||||
kind: 'sliding_count',
|
||||
threshold: 9,
|
||||
windowSeconds: 3,
|
||||
reset: 'zero',
|
||||
groupBy: { source: 'event', path: 'targetId' }
|
||||
},
|
||||
mutations: [{ kind: 'add', pool: 'effect', value: 1 }],
|
||||
outcomes: [{
|
||||
id: 'outcome-1',
|
||||
phase: 'while_active',
|
||||
kind: 'attribute_modifier',
|
||||
scope: 'equipped_weapon',
|
||||
attributeId: 'weapon-damage',
|
||||
value: 3,
|
||||
basis: 'flat',
|
||||
statePool: 'effect',
|
||||
perStack: true,
|
||||
minStacks: 1
|
||||
}]
|
||||
}]
|
||||
});
|
||||
});
|
||||
|
||||
it('adapts the Happy Survival branches, explicit cadence, health timing, and upgrade references', () => {
|
||||
const upgradeScale = {
|
||||
op: 'add',
|
||||
left: 1,
|
||||
right: {
|
||||
op: 'divide',
|
||||
left: { ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
|
||||
right: 5
|
||||
}
|
||||
};
|
||||
const result = adaptConditionalEffectProgram({
|
||||
rule: rule({
|
||||
source_type: 'os_protocol_talent',
|
||||
source_id: 'happy-survival',
|
||||
simulation_readiness: 'requires_assumptions',
|
||||
scenario_inputs_json: JSON.stringify([{
|
||||
key: 'restorationIntervalSeconds',
|
||||
label: 'Restoration interval',
|
||||
kind: 'number',
|
||||
readiness: 'requires_assumption',
|
||||
description: 'Enter the verified talent cadence.',
|
||||
min: 0.01
|
||||
}])
|
||||
}),
|
||||
statePools: [pool({
|
||||
state_key: 'nearby-enemies',
|
||||
label: 'Nearby Enemies',
|
||||
state_kind: 'count',
|
||||
maximum_value: 20,
|
||||
best_case_value: 5,
|
||||
duration_seconds: null
|
||||
})],
|
||||
clauses: [
|
||||
clause({
|
||||
id: 'sample-nearby',
|
||||
clause_key: 'sample-nearby',
|
||||
label: 'Sample nearby enemies',
|
||||
trigger_id: null,
|
||||
event_kind: 'proximity_snapshot',
|
||||
parameters_json: '{}',
|
||||
reducer_json: JSON.stringify({
|
||||
kind: 'derived_scalar',
|
||||
value: { ref: { source: 'event', path: 'values.enemiesWithinRadius' } }
|
||||
}),
|
||||
mutations_json: JSON.stringify([{
|
||||
kind: 'derive',
|
||||
pool: 'nearby-enemies',
|
||||
value: { ref: { source: 'reducer' } }
|
||||
}]),
|
||||
sort_order: 1
|
||||
}),
|
||||
clause({
|
||||
id: 'restore-nearby',
|
||||
clause_key: 'restore-nearby',
|
||||
label: 'Restore by nearby enemies',
|
||||
trigger_id: null,
|
||||
event_kind: 'tick',
|
||||
weight_expression_json: JSON.stringify({
|
||||
op: 'divide',
|
||||
left: { ref: { source: 'scenario', key: 'restorationIntervalSeconds' } },
|
||||
right: 60
|
||||
}),
|
||||
parameters_json: '{}',
|
||||
reducer_json: JSON.stringify({
|
||||
kind: 'periodic',
|
||||
intervalSeconds: {
|
||||
ref: {
|
||||
source: 'scenario',
|
||||
key: 'restorationIntervalSeconds'
|
||||
}
|
||||
}
|
||||
}),
|
||||
mutations_json: JSON.stringify([{ kind: 'none' }]),
|
||||
state_pool_id: null,
|
||||
sort_order: 2
|
||||
}),
|
||||
clause({
|
||||
id: 'shot-health-exchange',
|
||||
clause_key: 'shot-health-exchange',
|
||||
label: 'Shot health exchange',
|
||||
trigger_id: null,
|
||||
event_kind: 'shot_fired',
|
||||
recipe_key: 'happy-survival-shot',
|
||||
parameters_json: '{}',
|
||||
mutations_json: JSON.stringify([{ kind: 'none' }]),
|
||||
state_pool_id: null,
|
||||
sort_order: 3
|
||||
})
|
||||
],
|
||||
outcomes: [
|
||||
outcome({
|
||||
id: 'restore-health',
|
||||
clause_id: 'restore-nearby',
|
||||
state_pool_id: 'pool-1',
|
||||
effect_key: 'nearby-restoration',
|
||||
variant_key: 'base',
|
||||
application_kind: 'restore_health',
|
||||
effect_scope: 'self',
|
||||
stat_attribute_id: null,
|
||||
value: 0.5,
|
||||
value_expression_json: JSON.stringify({
|
||||
op: 'multiply',
|
||||
left: {
|
||||
op: 'clamp',
|
||||
value: {
|
||||
op: 'multiply',
|
||||
left: { ref: { source: 'pool', pool: 'nearby-enemies' } },
|
||||
right: 0.5
|
||||
},
|
||||
min: 0,
|
||||
max: 2.5
|
||||
},
|
||||
right: upgradeScale
|
||||
}),
|
||||
value_basis: 'max_health_percent',
|
||||
resource_kind: 'health',
|
||||
value_scaling: 'flat',
|
||||
application_phase: 'after_event',
|
||||
min_state_value: null,
|
||||
sort_order: 1
|
||||
}),
|
||||
outcome({
|
||||
id: 'health-cost',
|
||||
clause_id: 'shot-health-exchange',
|
||||
state_pool_id: null,
|
||||
effect_key: 'shot-health-cost',
|
||||
variant_key: 'base',
|
||||
application_kind: 'resource_cost',
|
||||
effect_scope: 'self',
|
||||
stat_attribute_id: null,
|
||||
value: 2,
|
||||
value_basis: 'max_health_percent',
|
||||
resource_kind: 'health',
|
||||
value_scaling: 'flat',
|
||||
application_phase: 'on_event',
|
||||
min_state_value: null,
|
||||
sort_order: 2
|
||||
}),
|
||||
outcome({
|
||||
id: 'bonus-damage',
|
||||
clause_id: 'shot-health-exchange',
|
||||
state_pool_id: null,
|
||||
effect_key: 'current-health-bonus-damage',
|
||||
variant_key: 'base',
|
||||
application_kind: 'direct_damage',
|
||||
effect_scope: 'target',
|
||||
stat_attribute_id: null,
|
||||
value: 0.6,
|
||||
value_expression_json: JSON.stringify({
|
||||
op: 'multiply',
|
||||
left: 0.6,
|
||||
right: upgradeScale
|
||||
}),
|
||||
value_basis: 'current_health_percent',
|
||||
combat_timing: 'current',
|
||||
value_scaling: 'flat',
|
||||
damage_basis: 'flat',
|
||||
application_phase: 'on_event',
|
||||
min_state_value: null,
|
||||
sort_order: 3
|
||||
})
|
||||
],
|
||||
triggers: []
|
||||
}, { name: 'Happy Survival', variantKey: 'base' });
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.status).toBe('REQUIRES_ASSUMPTIONS');
|
||||
expect(result.program?.scenarioInputs?.[0]).toMatchObject({
|
||||
key: 'restorationIntervalSeconds',
|
||||
readiness: 'requires_assumption'
|
||||
});
|
||||
expect(result.program?.clauses.map(({ when }) => when)).toEqual([
|
||||
'proximity_snapshot',
|
||||
'tick',
|
||||
'shot_fired'
|
||||
]);
|
||||
expect(result.program?.clauses[1].weight).toEqual({
|
||||
op: 'divide',
|
||||
left: { ref: { source: 'scenario', key: 'restorationIntervalSeconds' } },
|
||||
right: 60
|
||||
});
|
||||
expect(result.program?.clauses[2].recipeKey).toBe('happy-survival-shot');
|
||||
expect(result.program?.clauses[1].reducer).toEqual({
|
||||
kind: 'periodic',
|
||||
intervalSeconds: {
|
||||
ref: {
|
||||
source: 'scenario',
|
||||
key: 'restorationIntervalSeconds'
|
||||
}
|
||||
}
|
||||
});
|
||||
const restoration = result.program?.clauses[1].outcomes?.[0];
|
||||
const shotOutcomes = result.program?.clauses[2].outcomes;
|
||||
expect(restoration).toMatchObject({
|
||||
kind: 'restoration',
|
||||
phase: 'after_event',
|
||||
basis: 'max_health_percent',
|
||||
value: expect.objectContaining({
|
||||
op: 'multiply',
|
||||
right: expect.objectContaining({
|
||||
op: 'add',
|
||||
right: expect.objectContaining({
|
||||
left: {
|
||||
ref: {
|
||||
source: 'equipment',
|
||||
path: 'protocolTalentUpgrades'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
expect(shotOutcomes).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'health-cost',
|
||||
kind: 'resource_cost',
|
||||
resource: 'health',
|
||||
phase: 'on_event',
|
||||
basis: 'max_health_percent',
|
||||
value: 2
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'bonus-damage',
|
||||
kind: 'direct_damage',
|
||||
phase: 'on_event',
|
||||
basis: 'current_health_percent',
|
||||
combatTiming: 'current',
|
||||
value: expect.objectContaining({
|
||||
left: 0.6,
|
||||
right: expect.objectContaining({
|
||||
right: expect.objectContaining({
|
||||
left: {
|
||||
ref: {
|
||||
source: 'equipment',
|
||||
path: 'protocolTalentUpgrades'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns INVALID instead of throwing when persisted JSON is malformed', () => {
|
||||
expect(() => adaptConditionalEffectProgram({
|
||||
rule: rule({ scenario_inputs_json: '[not-json' }),
|
||||
statePools: [pool()],
|
||||
clauses: [clause({ mutations_json: '{broken' })],
|
||||
outcomes: [outcome()],
|
||||
triggers: [{ id: 'trigger-1', action_key: 'same_target_hits_within_window' }]
|
||||
}, { variantKey: 'I' })).not.toThrow();
|
||||
|
||||
const result = adaptConditionalEffectProgram({
|
||||
rule: rule({ scenario_inputs_json: '[not-json' }),
|
||||
statePools: [pool()],
|
||||
clauses: [clause({ mutations_json: '{broken' })],
|
||||
outcomes: [outcome()],
|
||||
triggers: [{ id: 'trigger-1', action_key: 'same_target_hits_within_window' }]
|
||||
}, { variantKey: 'I' });
|
||||
expect(result.status).toBe('INVALID');
|
||||
expect(result.program).toBeNull();
|
||||
expect(result.errors).toEqual(expect.arrayContaining([
|
||||
'Rule scenario inputs contains malformed JSON.',
|
||||
'Clause 1 mutations contains malformed JSON.'
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,757 @@
|
||||
import {
|
||||
MECHANIC_EVENT_KINDS,
|
||||
validateMechanicProgram,
|
||||
type ClauseMutation,
|
||||
type MechanicClause,
|
||||
type MechanicEventKind,
|
||||
type MechanicOutcome,
|
||||
type MechanicProgram,
|
||||
type NumericExpression,
|
||||
type OutcomeBasis,
|
||||
type OutcomePhase,
|
||||
type PoolDefinition,
|
||||
type Predicate,
|
||||
type Reducer,
|
||||
type ScenarioInputDefinition
|
||||
} from './mechanic.js';
|
||||
|
||||
type DatabaseValue = string | number | bigint | boolean | null | undefined;
|
||||
|
||||
export type ConditionalEffectRuleRow = {
|
||||
id: DatabaseValue;
|
||||
source_type: DatabaseValue;
|
||||
source_id: DatabaseValue;
|
||||
configured: DatabaseValue;
|
||||
script_version: DatabaseValue;
|
||||
simulation_readiness: DatabaseValue;
|
||||
scenario_inputs_json: DatabaseValue;
|
||||
};
|
||||
|
||||
export type ConditionalEffectStatePoolRow = {
|
||||
id: DatabaseValue;
|
||||
rule_id: DatabaseValue;
|
||||
state_key: DatabaseValue;
|
||||
label: DatabaseValue;
|
||||
state_kind: DatabaseValue;
|
||||
state_scope: DatabaseValue;
|
||||
initial_value: DatabaseValue;
|
||||
minimum_value: DatabaseValue;
|
||||
maximum_value: DatabaseValue;
|
||||
best_case_value: DatabaseValue;
|
||||
duration_seconds: DatabaseValue;
|
||||
expiry_policy: DatabaseValue;
|
||||
refresh_policy: DatabaseValue;
|
||||
parameters_json: DatabaseValue;
|
||||
sort_order: DatabaseValue;
|
||||
};
|
||||
|
||||
export type ConditionalEffectClauseRow = {
|
||||
id: DatabaseValue;
|
||||
rule_id: DatabaseValue;
|
||||
clause_key: DatabaseValue;
|
||||
label: DatabaseValue;
|
||||
trigger_id: DatabaseValue;
|
||||
event_kind?: DatabaseValue;
|
||||
recipe_key?: DatabaseValue;
|
||||
parameters_json: DatabaseValue;
|
||||
conditions_json: DatabaseValue;
|
||||
reducer_json: DatabaseValue;
|
||||
weight_expression_json?: DatabaseValue;
|
||||
emission_kind: DatabaseValue;
|
||||
state_pool_id: DatabaseValue;
|
||||
mutations_json: DatabaseValue;
|
||||
cooldown_seconds: DatabaseValue;
|
||||
output_branch: DatabaseValue;
|
||||
sort_order: DatabaseValue;
|
||||
active: DatabaseValue;
|
||||
};
|
||||
|
||||
export type ConditionalEffectOutcomeRow = {
|
||||
id: DatabaseValue;
|
||||
rule_id: DatabaseValue;
|
||||
clause_id: DatabaseValue;
|
||||
state_pool_id: DatabaseValue;
|
||||
effect_key: DatabaseValue;
|
||||
variant_key: DatabaseValue;
|
||||
outcome_key: DatabaseValue;
|
||||
application_kind: DatabaseValue;
|
||||
effect_scope: DatabaseValue;
|
||||
stat_attribute_id: DatabaseValue;
|
||||
value: DatabaseValue;
|
||||
value_expression_json: DatabaseValue;
|
||||
value_basis: DatabaseValue;
|
||||
resource_kind?: DatabaseValue;
|
||||
combat_timing?: DatabaseValue;
|
||||
value_scaling: DatabaseValue;
|
||||
unit: DatabaseValue;
|
||||
damage_basis: DatabaseValue;
|
||||
application_phase: DatabaseValue;
|
||||
min_state_value: DatabaseValue;
|
||||
max_state_value: DatabaseValue;
|
||||
description_slot: DatabaseValue;
|
||||
sort_order: DatabaseValue;
|
||||
};
|
||||
|
||||
export type TriggerActionMetadataRow = {
|
||||
id: DatabaseValue;
|
||||
action_key: DatabaseValue;
|
||||
event_kind?: DatabaseValue;
|
||||
name?: DatabaseValue;
|
||||
};
|
||||
|
||||
export type MechanicPersistenceInput = {
|
||||
rule: ConditionalEffectRuleRow;
|
||||
statePools: ConditionalEffectStatePoolRow[];
|
||||
clauses: ConditionalEffectClauseRow[];
|
||||
outcomes: ConditionalEffectOutcomeRow[];
|
||||
triggers: TriggerActionMetadataRow[];
|
||||
};
|
||||
|
||||
export type MechanicPersistenceOptions = {
|
||||
name?: string;
|
||||
variantKey?: string;
|
||||
};
|
||||
|
||||
export type MechanicPersistenceStatus =
|
||||
| 'READY'
|
||||
| 'REQUIRES_ASSUMPTIONS'
|
||||
| 'CAPTURED_ONLY'
|
||||
| 'INVALID';
|
||||
|
||||
export type MechanicPersistenceResult = {
|
||||
status: MechanicPersistenceStatus;
|
||||
simulationReadiness: 'ready' | 'requires_assumptions' | 'captured_only' | null;
|
||||
program: MechanicProgram | null;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
type ParsedJson = { ok: true; value: unknown } | { ok: false };
|
||||
|
||||
const eventKinds = new Set<string>(MECHANIC_EVENT_KINDS);
|
||||
const object = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
const finite = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value);
|
||||
const text = (value: DatabaseValue) => typeof value === 'string' ? value.trim() : '';
|
||||
const nullableText = (value: DatabaseValue) => {
|
||||
const result = text(value);
|
||||
return result || null;
|
||||
};
|
||||
const number = (value: DatabaseValue) => {
|
||||
if (typeof value === 'bigint') return Number(value);
|
||||
if (typeof value === 'boolean' || value === null || value === undefined || value === '') return null;
|
||||
const result = Number(value);
|
||||
return Number.isFinite(result) ? result : null;
|
||||
};
|
||||
const enabled = (value: DatabaseValue) =>
|
||||
value === true || value === 1 || value === 1n || value === '1';
|
||||
|
||||
function parseJson(value: DatabaseValue, path: string, errors: string[]): ParsedJson {
|
||||
if (typeof value !== 'string') {
|
||||
errors.push(`${path} must contain JSON text.`);
|
||||
return { ok: false };
|
||||
}
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(value) };
|
||||
} catch {
|
||||
errors.push(`${path} contains malformed JSON.`);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function requiredText(value: DatabaseValue, path: string, errors: string[]) {
|
||||
const result = text(value);
|
||||
if (!result) errors.push(`${path} is required.`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function requiredNumber(value: DatabaseValue, path: string, errors: string[]) {
|
||||
const result = number(value);
|
||||
if (result === null) errors.push(`${path} must be a finite number.`);
|
||||
return result ?? 0;
|
||||
}
|
||||
|
||||
function isNumericExpression(value: unknown): value is NumericExpression {
|
||||
if (finite(value)) return true;
|
||||
if (!object(value)) return false;
|
||||
if ('ref' in value) {
|
||||
if (!object(value.ref) || typeof value.ref.source !== 'string') return false;
|
||||
const source = value.ref.source;
|
||||
if (source === 'event' || source === 'combat' || source === 'equipment') {
|
||||
return typeof value.ref.path === 'string';
|
||||
}
|
||||
if (source === 'scenario') return typeof value.ref.key === 'string';
|
||||
if (source === 'reducer') return true;
|
||||
if (source === 'pool') return typeof value.ref.pool === 'string';
|
||||
return false;
|
||||
}
|
||||
if (value.op === 'clamp') {
|
||||
return isNumericExpression(value.value)
|
||||
&& isNumericExpression(value.min)
|
||||
&& isNumericExpression(value.max);
|
||||
}
|
||||
return ['add', 'subtract', 'multiply', 'divide', 'min', 'max'].includes(String(value.op))
|
||||
&& isNumericExpression(value.left)
|
||||
&& isNumericExpression(value.right);
|
||||
}
|
||||
|
||||
function isPredicate(value: unknown): value is Predicate {
|
||||
if (!object(value) || typeof value.kind !== 'string') return false;
|
||||
if (value.kind === 'true') return true;
|
||||
if (value.kind === 'exists') return object(value.value) && typeof value.value.source === 'string';
|
||||
if (value.kind === 'not') return isPredicate(value.predicate);
|
||||
if (value.kind === 'all' || value.kind === 'any') {
|
||||
return Array.isArray(value.predicates) && value.predicates.every(isPredicate);
|
||||
}
|
||||
return value.kind === 'compare'
|
||||
&& ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'].includes(String(value.operator))
|
||||
&& 'left' in value
|
||||
&& 'right' in value;
|
||||
}
|
||||
|
||||
function isReducer(value: unknown): value is Reducer {
|
||||
if (!object(value) || typeof value.kind !== 'string') return false;
|
||||
if (value.kind === 'none') return true;
|
||||
if (value.kind === 'count') return finite(value.threshold);
|
||||
if (value.kind === 'modulo') return finite(value.every);
|
||||
if (value.kind === 'distinct_count') {
|
||||
return finite(value.threshold)
|
||||
&& finite(value.windowSeconds)
|
||||
&& object(value.distinctBy)
|
||||
&& typeof value.distinctBy.source === 'string';
|
||||
}
|
||||
if (value.kind === 'consecutive_count') return finite(value.threshold);
|
||||
if (value.kind === 'sliding_count') return finite(value.threshold) && finite(value.windowSeconds);
|
||||
if (value.kind === 'duration') return finite(value.seconds);
|
||||
if (value.kind === 'periodic') return isNumericExpression(value.intervalSeconds);
|
||||
return value.kind === 'derived_scalar' && isNumericExpression(value.value);
|
||||
}
|
||||
|
||||
function isMutation(value: unknown): value is ClauseMutation {
|
||||
if (!object(value) || typeof value.kind !== 'string') return false;
|
||||
if (value.kind === 'none') return true;
|
||||
if (value.kind === 'clear') return typeof value.pool === 'string';
|
||||
if (value.kind === 'transfer') {
|
||||
return typeof value.pool === 'string' && object(value.from) && object(value.to)
|
||||
&& (value.value === undefined || value.value === 'all' || isNumericExpression(value.value));
|
||||
}
|
||||
return ['add', 'set', 'consume', 'derive'].includes(value.kind)
|
||||
&& typeof value.pool === 'string'
|
||||
&& isNumericExpression(value.value);
|
||||
}
|
||||
|
||||
function parseScenarioInputs(
|
||||
raw: DatabaseValue,
|
||||
errors: string[]
|
||||
): ScenarioInputDefinition[] {
|
||||
const parsed = parseJson(raw, 'Rule scenario inputs', errors);
|
||||
if (!parsed.ok) return [];
|
||||
if (!Array.isArray(parsed.value)) {
|
||||
errors.push('Rule scenario inputs must be a JSON array.');
|
||||
return [];
|
||||
}
|
||||
const inputs: ScenarioInputDefinition[] = [];
|
||||
for (const [index, candidate] of parsed.value.entries()) {
|
||||
const path = `Scenario input ${index + 1}`;
|
||||
if (!object(candidate)) {
|
||||
errors.push(`${path} must be an object.`);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
typeof candidate.key !== 'string'
|
||||
|| typeof candidate.label !== 'string'
|
||||
|| !['toggle', 'slider', 'number'].includes(String(candidate.kind))
|
||||
) {
|
||||
errors.push(`${path} requires a key, label, and supported input kind.`);
|
||||
continue;
|
||||
}
|
||||
inputs.push(candidate as ScenarioInputDefinition);
|
||||
}
|
||||
return inputs;
|
||||
}
|
||||
|
||||
type LegacyMapping = {
|
||||
when: MechanicEventKind;
|
||||
predicate?: Predicate;
|
||||
reducer?: Reducer;
|
||||
};
|
||||
|
||||
function legacyActionMapping(
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
path: string,
|
||||
errors: string[]
|
||||
): LegacyMapping | null {
|
||||
const eventValue = (field: string) => ({ source: 'event' as const, path: `values.${field}` });
|
||||
const targetExists: Predicate = {
|
||||
kind: 'exists',
|
||||
value: { source: 'event', path: 'targetId' }
|
||||
};
|
||||
switch (action) {
|
||||
case 'same_target_hits_within_window': {
|
||||
const hits = params.hitsRequired;
|
||||
const window = params.windowSeconds;
|
||||
if (!Number.isInteger(hits) || Number(hits) <= 0 || !finite(window) || window <= 0) {
|
||||
errors.push(`${path} has invalid same-target hit parameters.`);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
when: 'weapon_hit',
|
||||
predicate: targetExists,
|
||||
reducer: {
|
||||
kind: 'sliding_count',
|
||||
threshold: Number(hits),
|
||||
windowSeconds: window,
|
||||
reset: 'zero',
|
||||
groupBy: { source: 'event', path: 'targetId' }
|
||||
}
|
||||
};
|
||||
}
|
||||
case 'headshot':
|
||||
return {
|
||||
when: 'weapon_hit',
|
||||
predicate: {
|
||||
kind: 'compare',
|
||||
left: eventValue('headshot'),
|
||||
operator: 'eq',
|
||||
right: true
|
||||
}
|
||||
};
|
||||
case 'headshot_kill':
|
||||
return {
|
||||
when: 'enemy_killed',
|
||||
predicate: {
|
||||
kind: 'compare',
|
||||
left: eventValue('headshot'),
|
||||
operator: 'eq',
|
||||
right: true
|
||||
}
|
||||
};
|
||||
case 'weapon_kill':
|
||||
return { when: 'enemy_killed' };
|
||||
case 'target_hit_within_distance': {
|
||||
const meters = params.meters;
|
||||
if (!finite(meters) || meters <= 0) {
|
||||
errors.push(`${path} has an invalid distance parameter.`);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
when: 'weapon_hit',
|
||||
predicate: {
|
||||
kind: 'all',
|
||||
predicates: [
|
||||
targetExists,
|
||||
{
|
||||
kind: 'compare',
|
||||
left: eventValue('distanceMeters'),
|
||||
operator: 'lte',
|
||||
right: meters
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
case 'damage_taken':
|
||||
return { when: 'health_damage_taken' };
|
||||
case 'weapon_swap':
|
||||
return { when: 'weapon_swapped' };
|
||||
case 'reload':
|
||||
return { when: 'reload_started' };
|
||||
default:
|
||||
errors.push(
|
||||
`${path} uses legacy action "${action}", which cannot be represented exactly by the mechanic runtime.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function combinePredicates(left: Predicate | undefined, right: Predicate | undefined) {
|
||||
if (!left || left.kind === 'true') return right;
|
||||
if (!right || right.kind === 'true') return left;
|
||||
return { kind: 'all' as const, predicates: [left, right] };
|
||||
}
|
||||
|
||||
function resolveClauseMapping(
|
||||
trigger: TriggerActionMetadataRow | null,
|
||||
eventKind: DatabaseValue,
|
||||
parameters: Record<string, unknown>,
|
||||
explicitPredicate: Predicate,
|
||||
explicitReducer: Reducer,
|
||||
path: string,
|
||||
errors: string[]
|
||||
) {
|
||||
const directEvent = nullableText(eventKind)
|
||||
?? nullableText(trigger?.event_kind)
|
||||
?? (eventKinds.has(text(trigger?.action_key)) ? text(trigger?.action_key) : null);
|
||||
if (directEvent) {
|
||||
return {
|
||||
when: directEvent as MechanicEventKind,
|
||||
predicate: explicitPredicate,
|
||||
reducer: explicitReducer
|
||||
};
|
||||
}
|
||||
if (!trigger) {
|
||||
errors.push(`${path} requires a direct event or known Action Family recipe.`);
|
||||
return null;
|
||||
}
|
||||
const legacy = legacyActionMapping(text(trigger.action_key), parameters, path, errors);
|
||||
if (!legacy) return null;
|
||||
return {
|
||||
when: legacy.when,
|
||||
predicate: combinePredicates(legacy.predicate, explicitPredicate),
|
||||
reducer: explicitReducer.kind === 'none'
|
||||
? legacy.reducer ?? explicitReducer
|
||||
: explicitReducer
|
||||
};
|
||||
}
|
||||
|
||||
function parsePool(
|
||||
row: ConditionalEffectStatePoolRow,
|
||||
ruleId: string,
|
||||
index: number,
|
||||
errors: string[]
|
||||
): { id: string; definition: PoolDefinition } | null {
|
||||
const path = `State pool ${index + 1}`;
|
||||
if (text(row.rule_id) !== ruleId) {
|
||||
errors.push(`${path} belongs to a different rule.`);
|
||||
return null;
|
||||
}
|
||||
const id = requiredText(row.id, `${path} ID`, errors);
|
||||
const key = requiredText(row.state_key, `${path} key`, errors);
|
||||
const label = requiredText(row.label, `${path} label`, errors);
|
||||
const scope = text(row.state_scope);
|
||||
if (scope !== 'agent' && scope !== 'target') errors.push(`${path} has an invalid scope.`);
|
||||
const initial = requiredNumber(row.initial_value, `${path} initial value`, errors);
|
||||
const minimum = number(row.minimum_value);
|
||||
const maximum = requiredNumber(row.maximum_value, `${path} maximum value`, errors);
|
||||
const bestCase = number(row.best_case_value);
|
||||
const duration = number(row.duration_seconds);
|
||||
const parameters = parseJson(row.parameters_json, `${path} parameters`, errors);
|
||||
if (parameters.ok && !object(parameters.value)) errors.push(`${path} parameters must be a JSON object.`);
|
||||
return {
|
||||
id,
|
||||
definition: {
|
||||
key,
|
||||
label,
|
||||
scope: scope === 'target' ? 'target' : 'agent',
|
||||
initial,
|
||||
...(minimum === null ? {} : { min: minimum }),
|
||||
max: maximum,
|
||||
...(bestCase === null ? {} : { bestCaseValue: bestCase }),
|
||||
...(duration === null ? {} : { expiresAfterSeconds: duration })
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function outcomeKind(value: string): MechanicOutcome['kind'] | null {
|
||||
if (value === 'active_modifier') return 'attribute_modifier';
|
||||
if (value === 'direct_damage') return 'direct_damage';
|
||||
if (value === 'restoration' || ['restore_health', 'restore_armor', 'restore_ammo'].includes(value)) {
|
||||
return 'restoration';
|
||||
}
|
||||
if (value === 'resource_cost') return 'resource_cost';
|
||||
if (value === 'marker') return 'marker';
|
||||
return null;
|
||||
}
|
||||
|
||||
function outcomeScope(value: string): MechanicOutcome['scope'] | null {
|
||||
if (value === 'self') return 'self';
|
||||
if (value === 'equipped_weapon') return 'equipped_weapon';
|
||||
if (value === 'target') return 'target';
|
||||
if (value === 'allies') return 'allies';
|
||||
if (value === 'self_and_allies') return 'self_and_allies';
|
||||
if (value === 'assigned_ability') return 'assigned_ability';
|
||||
if (value === 'all_skills') return 'all_skills';
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseOutcome(
|
||||
row: ConditionalEffectOutcomeRow,
|
||||
ruleId: string,
|
||||
poolKeys: Map<string, string>,
|
||||
index: number,
|
||||
errors: string[]
|
||||
): { clauseId: string | null; outcome: MechanicOutcome } | null {
|
||||
const path = `Outcome ${index + 1}`;
|
||||
if (text(row.rule_id) !== ruleId) {
|
||||
errors.push(`${path} belongs to a different rule.`);
|
||||
return null;
|
||||
}
|
||||
const id = requiredText(row.id, `${path} ID`, errors);
|
||||
const label = requiredText(row.effect_key, `${path} effect key`, errors);
|
||||
const kind = outcomeKind(text(row.application_kind));
|
||||
if (!kind) errors.push(`${path} has an unsupported application kind.`);
|
||||
const scope = outcomeScope(text(row.effect_scope));
|
||||
if (!scope) {
|
||||
errors.push(`${path} has an effect scope that cannot be represented without losing meaning.`);
|
||||
}
|
||||
const basis = text(row.value_basis);
|
||||
if (!['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'].includes(basis)) {
|
||||
errors.push(`${path} has an unsupported value basis.`);
|
||||
}
|
||||
const phase = text(row.application_phase);
|
||||
if (!['while_active', 'on_event', 'after_event', 'on_enter', 'on_exit'].includes(phase)) {
|
||||
errors.push(`${path} has an unsupported application phase.`);
|
||||
}
|
||||
let value: NumericExpression = requiredNumber(row.value, `${path} value`, errors);
|
||||
if (row.value_expression_json !== null && row.value_expression_json !== undefined && row.value_expression_json !== '') {
|
||||
const parsed = parseJson(row.value_expression_json, `${path} value expression`, errors);
|
||||
if (parsed.ok) {
|
||||
if (isNumericExpression(parsed.value)) value = parsed.value;
|
||||
else errors.push(`${path} value expression is not a valid numeric expression.`);
|
||||
}
|
||||
}
|
||||
const statePoolId = nullableText(row.state_pool_id);
|
||||
const statePool = statePoolId ? poolKeys.get(statePoolId) : undefined;
|
||||
if (statePoolId && !statePool) errors.push(`${path} references an unknown state pool.`);
|
||||
const scaling = text(row.value_scaling);
|
||||
if (scaling !== 'flat' && scaling !== 'per_state_unit') {
|
||||
errors.push(`${path} has an unsupported value-scaling mode.`);
|
||||
}
|
||||
if (!kind || !scope) return null;
|
||||
const legacyResource = text(row.application_kind) === 'restore_health'
|
||||
? 'health'
|
||||
: text(row.application_kind) === 'restore_armor'
|
||||
? 'armor'
|
||||
: text(row.application_kind) === 'restore_ammo'
|
||||
? 'ammo'
|
||||
: text(row.application_kind) === 'resource_cost'
|
||||
? 'health'
|
||||
: null;
|
||||
const resource = nullableText(row.resource_kind) ?? legacyResource;
|
||||
if (
|
||||
(kind === 'restoration' || kind === 'resource_cost')
|
||||
&& !['health', 'armor', 'ammo'].includes(String(resource))
|
||||
) {
|
||||
errors.push(`${path} requires an explicit health, armor, or ammo resource.`);
|
||||
}
|
||||
const combatTiming = nullableText(row.combat_timing);
|
||||
if (combatTiming && combatTiming !== 'pre_event' && combatTiming !== 'current') {
|
||||
errors.push(`${path} has invalid combat timing.`);
|
||||
}
|
||||
return {
|
||||
clauseId: nullableText(row.clause_id),
|
||||
outcome: {
|
||||
id,
|
||||
label,
|
||||
phase: phase as OutcomePhase,
|
||||
kind,
|
||||
scope,
|
||||
value,
|
||||
basis: basis as OutcomeBasis,
|
||||
...(resource && ['health', 'armor', 'ammo'].includes(resource)
|
||||
? { resource: resource as 'health' | 'armor' | 'ammo' }
|
||||
: {}),
|
||||
...(combatTiming === 'pre_event' || combatTiming === 'current'
|
||||
? { combatTiming }
|
||||
: {}),
|
||||
...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id)
|
||||
? { attributeId: text(row.stat_attribute_id) }
|
||||
: {}),
|
||||
...(statePool ? { statePool } : {}),
|
||||
...(scaling === 'per_state_unit' ? { perStack: true } : {}),
|
||||
...(number(row.min_state_value) === null ? {} : { minStacks: number(row.min_state_value) as number }),
|
||||
...(number(row.max_state_value) === null ? {} : { maxStacks: number(row.max_state_value) as number })
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts normalized conditional-effect rows into the runtime's typed mechanic
|
||||
* program. Invalid persisted data is reported as `INVALID`; this function never
|
||||
* throws for row or JSON validation failures.
|
||||
*/
|
||||
export function adaptConditionalEffectProgram(
|
||||
input: MechanicPersistenceInput,
|
||||
options: MechanicPersistenceOptions = {}
|
||||
): MechanicPersistenceResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const readiness = text(input.rule.simulation_readiness);
|
||||
const simulationReadiness = ['ready', 'requires_assumptions', 'captured_only'].includes(readiness)
|
||||
? readiness as MechanicPersistenceResult['simulationReadiness']
|
||||
: null;
|
||||
if (!simulationReadiness) errors.push('Rule simulation readiness is invalid.');
|
||||
const ruleId = requiredText(input.rule.id, 'Rule ID', errors);
|
||||
const sourceType = requiredText(input.rule.source_type, 'Rule source type', errors);
|
||||
const sourceId = requiredText(input.rule.source_id, 'Rule source ID', errors);
|
||||
const scriptVersion = requiredNumber(input.rule.script_version, 'Rule script version', errors);
|
||||
if (!Number.isInteger(scriptVersion) || scriptVersion <= 0) {
|
||||
errors.push('Rule script version must be a positive integer.');
|
||||
}
|
||||
const scenarioInputs = parseScenarioInputs(input.rule.scenario_inputs_json, errors);
|
||||
|
||||
const parsedPools = input.statePools
|
||||
.slice()
|
||||
.sort((left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0))
|
||||
.map((row, index) => parsePool(row, ruleId, index, errors))
|
||||
.filter((value): value is NonNullable<typeof value> => value !== null);
|
||||
const poolKeys = new Map(parsedPools.map((pool) => [pool.id, pool.definition.key]));
|
||||
const triggerMap = new Map(
|
||||
input.triggers.map((trigger) => [text(trigger.id), trigger])
|
||||
);
|
||||
|
||||
const variantKeys = [...new Set(input.outcomes.map((row) => text(row.variant_key)).filter(Boolean))];
|
||||
let selectedVariant = options.variantKey?.trim() || '';
|
||||
if (!selectedVariant) {
|
||||
if (variantKeys.length > 1) {
|
||||
errors.push(`A variant key is required because the rule has variants: ${variantKeys.join(', ')}.`);
|
||||
} else {
|
||||
selectedVariant = variantKeys[0] ?? 'base';
|
||||
}
|
||||
} else if (variantKeys.length && !variantKeys.includes(selectedVariant)) {
|
||||
errors.push(`Variant "${selectedVariant}" does not exist for this rule.`);
|
||||
}
|
||||
|
||||
const selectedOutcomes = input.outcomes
|
||||
.filter((row) => text(row.variant_key) === selectedVariant)
|
||||
.slice()
|
||||
.sort((left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0))
|
||||
.map((row, index) => parseOutcome(row, ruleId, poolKeys, index, errors))
|
||||
.filter((value): value is NonNullable<typeof value> => value !== null);
|
||||
const outcomesByClause = new Map<string, MechanicOutcome[]>();
|
||||
const unassignedOutcomes: MechanicOutcome[] = [];
|
||||
for (const parsed of selectedOutcomes) {
|
||||
if (!parsed.clauseId) {
|
||||
unassignedOutcomes.push(parsed.outcome);
|
||||
continue;
|
||||
}
|
||||
const current = outcomesByClause.get(parsed.clauseId) ?? [];
|
||||
current.push(parsed.outcome);
|
||||
outcomesByClause.set(parsed.clauseId, current);
|
||||
}
|
||||
|
||||
const activeRows = input.clauses
|
||||
.filter((row) => enabled(row.active))
|
||||
.slice()
|
||||
.sort((left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0));
|
||||
if (unassignedOutcomes.length && activeRows.length !== 1) {
|
||||
errors.push('Outcomes without a clause can only be adapted when the rule has exactly one active clause.');
|
||||
}
|
||||
const clauses: MechanicClause[] = [];
|
||||
for (const [index, row] of activeRows.entries()) {
|
||||
const path = `Clause ${index + 1}`;
|
||||
if (text(row.rule_id) !== ruleId) {
|
||||
errors.push(`${path} belongs to a different rule.`);
|
||||
continue;
|
||||
}
|
||||
const id = requiredText(row.id, `${path} ID`, errors);
|
||||
const triggerId = nullableText(row.trigger_id);
|
||||
const directEvent = nullableText(row.event_kind);
|
||||
const trigger = triggerId ? triggerMap.get(triggerId) ?? null : null;
|
||||
if (!directEvent && !trigger) {
|
||||
errors.push(`${path} references unknown trigger "${triggerId}".`);
|
||||
continue;
|
||||
}
|
||||
const parametersJson = parseJson(row.parameters_json, `${path} parameters`, errors);
|
||||
const conditionsJson = parseJson(row.conditions_json, `${path} conditions`, errors);
|
||||
const reducerJson = parseJson(row.reducer_json, `${path} reducer`, errors);
|
||||
const mutationsJson = parseJson(row.mutations_json, `${path} mutations`, errors);
|
||||
if (!parametersJson.ok || !conditionsJson.ok || !reducerJson.ok || !mutationsJson.ok) continue;
|
||||
if (!object(parametersJson.value)) {
|
||||
errors.push(`${path} parameters must be a JSON object.`);
|
||||
continue;
|
||||
}
|
||||
if (!isPredicate(conditionsJson.value)) {
|
||||
errors.push(`${path} conditions are not a valid predicate.`);
|
||||
continue;
|
||||
}
|
||||
if (!isReducer(reducerJson.value)) {
|
||||
errors.push(`${path} reducer is invalid.`);
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(mutationsJson.value) || !mutationsJson.value.every(isMutation)) {
|
||||
errors.push(`${path} mutations must be an array of valid state mutations.`);
|
||||
continue;
|
||||
}
|
||||
const mapping = resolveClauseMapping(
|
||||
trigger,
|
||||
row.event_kind,
|
||||
parametersJson.value,
|
||||
conditionsJson.value,
|
||||
reducerJson.value,
|
||||
path,
|
||||
errors
|
||||
);
|
||||
if (!mapping) continue;
|
||||
const clauseOutcomes = outcomesByClause.get(id) ?? [];
|
||||
if (activeRows.length === 1) clauseOutcomes.push(...unassignedOutcomes);
|
||||
let weight: NumericExpression | undefined;
|
||||
if (
|
||||
row.weight_expression_json !== null
|
||||
&& row.weight_expression_json !== undefined
|
||||
&& row.weight_expression_json !== ''
|
||||
) {
|
||||
const parsedWeight = parseJson(row.weight_expression_json, `${path} weight`, errors);
|
||||
if (parsedWeight.ok) {
|
||||
if (isNumericExpression(parsedWeight.value)) weight = parsedWeight.value;
|
||||
else errors.push(`${path} weight is not a valid numeric expression.`);
|
||||
}
|
||||
}
|
||||
const recipeKey = nullableText(row.recipe_key);
|
||||
clauses.push({
|
||||
id,
|
||||
label: text(row.label) || text(row.clause_key) || undefined,
|
||||
when: mapping.when,
|
||||
...(recipeKey ? { recipeKey } : {}),
|
||||
...(mapping.predicate && mapping.predicate.kind !== 'true' ? { if: mapping.predicate } : {}),
|
||||
reducer: mapping.reducer,
|
||||
...(number(row.cooldown_seconds) === null
|
||||
? {}
|
||||
: { cooldownSeconds: number(row.cooldown_seconds) as number }),
|
||||
...(weight === undefined ? {} : { weight }),
|
||||
mutations: mutationsJson.value,
|
||||
...(clauseOutcomes.length ? { outcomes: clauseOutcomes } : {})
|
||||
});
|
||||
}
|
||||
for (const clauseId of outcomesByClause.keys()) {
|
||||
if (!activeRows.some((row) => text(row.id) === clauseId)) {
|
||||
errors.push(`Outcomes reference missing or inactive clause "${clauseId}".`);
|
||||
}
|
||||
}
|
||||
|
||||
const program: MechanicProgram = {
|
||||
id: ruleId,
|
||||
name: options.name?.trim() || `${sourceType}:${sourceId}`,
|
||||
...(scenarioInputs.length ? { scenarioInputs } : {}),
|
||||
...(parsedPools.length ? { pools: parsedPools.map((pool) => pool.definition) } : {}),
|
||||
clauses
|
||||
};
|
||||
if (errors.length) {
|
||||
return { status: 'INVALID', simulationReadiness, program: null, errors, warnings };
|
||||
}
|
||||
try {
|
||||
const validation = validateMechanicProgram(program);
|
||||
if (!validation.ok) {
|
||||
return {
|
||||
status: 'INVALID',
|
||||
simulationReadiness,
|
||||
program: null,
|
||||
errors: validation.errors,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
status: 'INVALID',
|
||||
simulationReadiness,
|
||||
program: null,
|
||||
errors: ['The persisted mechanic contains a structure that the runtime cannot validate.'],
|
||||
warnings
|
||||
};
|
||||
}
|
||||
if (!enabled(input.rule.configured) || simulationReadiness === 'captured_only') {
|
||||
warnings.push('The mechanic is retained for configuration and is not ready for simulation.');
|
||||
return { status: 'CAPTURED_ONLY', simulationReadiness, program, errors: [], warnings };
|
||||
}
|
||||
const requiresAssumptions = simulationReadiness === 'requires_assumptions'
|
||||
|| scenarioInputs.some((item) => item.readiness === 'requires_assumption');
|
||||
return {
|
||||
status: requiresAssumptions ? 'REQUIRES_ASSUMPTIONS' : 'READY',
|
||||
simulationReadiness,
|
||||
program,
|
||||
errors: [],
|
||||
warnings
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
TRIGGER_ACTION_KEYS,
|
||||
validateTriggerConfiguration,
|
||||
type TriggerActionKey,
|
||||
type TriggerConfiguration
|
||||
} from './conditional.js';
|
||||
import {
|
||||
createMechanicRecipeForTrigger,
|
||||
MECHANIC_RECIPE_REGISTRY,
|
||||
type MechanicRecipeSeed
|
||||
} from './mechanic-recipes.js';
|
||||
import { validateMechanicProgram } from './mechanic.js';
|
||||
|
||||
const paramsByAction: Record<TriggerActionKey, unknown> = {
|
||||
same_target_hits_within_window: { hitsRequired: 3, windowSeconds: 2 },
|
||||
distinct_enemies_within_window: { enemiesRequired: 3, windowSeconds: 4 },
|
||||
consecutive_same_target_hits: { hitsRequired: 4 },
|
||||
continuous_firing_duration: { seconds: 2.5 },
|
||||
while_firing: {},
|
||||
health_threshold_bands: {
|
||||
bands: [
|
||||
{ thresholdPercent: 25, outcomeKey: 'critical' },
|
||||
{ thresholdPercent: 50, outcomeKey: 'damaged' }
|
||||
]
|
||||
},
|
||||
headshot: {},
|
||||
headshot_kill: {},
|
||||
weapon_kill: {},
|
||||
target_hit_within_distance: { meters: 15 },
|
||||
damage_taken: {},
|
||||
weapon_swap: {},
|
||||
reload: {},
|
||||
within_distance: { meters: 10 },
|
||||
extra_health_active: {}
|
||||
};
|
||||
|
||||
function triggerFor(action: TriggerActionKey): TriggerConfiguration {
|
||||
const trigger = validateTriggerConfiguration(action, paramsByAction[action]);
|
||||
if (!trigger.ok) throw new Error(trigger.errors.join(' '));
|
||||
return trigger.value;
|
||||
}
|
||||
|
||||
function seedFor(action: TriggerActionKey): MechanicRecipeSeed {
|
||||
const trigger = triggerFor(action);
|
||||
const result = createMechanicRecipeForTrigger(trigger);
|
||||
if (result.status !== 'supported') throw new Error(result.reason);
|
||||
return result.seed;
|
||||
}
|
||||
|
||||
describe('mechanic recipe registry', () => {
|
||||
it('covers every trigger action with a supported seed or an explicit reason', () => {
|
||||
expect(Object.keys(MECHANIC_RECIPE_REGISTRY).sort()).toEqual([...TRIGGER_ACTION_KEYS].sort());
|
||||
for (const action of TRIGGER_ACTION_KEYS) {
|
||||
const result = createMechanicRecipeForTrigger(triggerFor(action));
|
||||
expect(['supported', 'unsupported']).toContain(result.status);
|
||||
if (result.status === 'unsupported') expect(result.reason.trim()).not.toBe('');
|
||||
else expect(result.seed.clauses.every((clause) => clause.recipeKey === action)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('produces a valid editable program for every supported recipe without hidden placeholders', () => {
|
||||
for (const action of TRIGGER_ACTION_KEYS) {
|
||||
const seed = seedFor(action);
|
||||
const validation = validateMechanicProgram({
|
||||
id: `recipe-${action}`,
|
||||
name: `Recipe ${action}`,
|
||||
...seed
|
||||
});
|
||||
expect(validation, action).toMatchObject({ ok: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('maps hit-family actions to same-target, distinct, and consecutive reducers', () => {
|
||||
expect(seedFor('same_target_hits_within_window').clauses[0]).toMatchObject({
|
||||
when: 'weapon_hit',
|
||||
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
||||
reducer: {
|
||||
kind: 'sliding_count', threshold: 3, windowSeconds: 2, reset: 'zero',
|
||||
groupBy: { source: 'event', path: 'targetId' }
|
||||
}
|
||||
});
|
||||
expect(seedFor('distinct_enemies_within_window').clauses[0]).toMatchObject({
|
||||
when: 'weapon_hit',
|
||||
reducer: {
|
||||
kind: 'distinct_count', threshold: 3, windowSeconds: 4,
|
||||
distinctBy: { source: 'event', path: 'targetId' }
|
||||
}
|
||||
});
|
||||
expect(seedFor('consecutive_same_target_hits').clauses[0]).toMatchObject({
|
||||
when: 'weapon_hit',
|
||||
reducer: {
|
||||
kind: 'consecutive_count', threshold: 4,
|
||||
groupBy: { source: 'event', path: 'targetId' }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('models firing triggers as explicit lifecycle branches', () => {
|
||||
const continuous = seedFor('continuous_firing_duration');
|
||||
expect(continuous.pools).toEqual([expect.objectContaining({ key: 'firing-active', max: 1 })]);
|
||||
expect(continuous.clauses.map((clause) => clause.when)).toEqual([
|
||||
'firing_started', 'firing_stopped', 'reload_started', 'tick'
|
||||
]);
|
||||
expect(continuous.clauses.at(-1)).toMatchObject({
|
||||
reducer: { kind: 'duration', seconds: 2.5 },
|
||||
if: { kind: 'compare', left: { source: 'pool', pool: 'firing-active' }, operator: 'gt', right: 0 }
|
||||
});
|
||||
expect(seedFor('while_firing').clauses.map((clause) => clause.when)).toEqual([
|
||||
'firing_started', 'firing_stopped', 'reload_started'
|
||||
]);
|
||||
});
|
||||
|
||||
it('models health bands and persistent distance and Extra Health state as clearable branches', () => {
|
||||
const health = seedFor('health_threshold_bands');
|
||||
expect(health.pools?.map((pool) => pool.key)).toEqual(['health-band-1', 'health-band-2']);
|
||||
expect(health.clauses.map((clause) => clause.id)).toEqual([
|
||||
'select-health-band-1', 'select-health-band-2', 'clear-health-bands'
|
||||
]);
|
||||
expect(health.clauses[1].if).toMatchObject({
|
||||
kind: 'all',
|
||||
predicates: expect.arrayContaining([
|
||||
{ kind: 'compare', left: { source: 'event', path: 'values.healthPercent' }, operator: 'lte', right: 50 },
|
||||
{ kind: 'compare', left: { source: 'event', path: 'values.healthPercent' }, operator: 'gt', right: 25 }
|
||||
])
|
||||
});
|
||||
|
||||
expect(seedFor('target_hit_within_distance').clauses[0]).toMatchObject({
|
||||
when: 'weapon_hit',
|
||||
if: {
|
||||
kind: 'all',
|
||||
predicates: expect.arrayContaining([
|
||||
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
||||
{ kind: 'compare', left: { source: 'event', path: 'values.distanceMeters' }, operator: 'lte', right: 15 }
|
||||
])
|
||||
}
|
||||
});
|
||||
|
||||
const withinDistance = seedFor('within_distance');
|
||||
expect(withinDistance.clauses.map((clause) => clause.id)).toEqual([
|
||||
'enter-target-within-distance', 'exit-target-within-distance'
|
||||
]);
|
||||
expect(withinDistance.clauses[0].if).toEqual({
|
||||
kind: 'compare', left: { source: 'event', path: 'values.distanceMeters' }, operator: 'lte', right: 10
|
||||
});
|
||||
expect(withinDistance.clauses[1].if).toMatchObject({ kind: 'not' });
|
||||
|
||||
const extraHealth = seedFor('extra_health_active');
|
||||
expect(extraHealth.clauses.map((clause) => clause.id)).toEqual([
|
||||
'enter-extra-health-active', 'exit-extra-health-active'
|
||||
]);
|
||||
expect(extraHealth.clauses[0].if).toEqual({
|
||||
kind: 'compare', left: { source: 'event', path: 'values.extraHealthActive' }, operator: 'eq', right: true
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
import type {
|
||||
ConsecutiveHitsParams,
|
||||
ContinuousFiringParams,
|
||||
DistanceParams,
|
||||
DistinctEnemiesParams,
|
||||
HealthThresholdParams,
|
||||
SameTargetHitsParams,
|
||||
TriggerActionKey,
|
||||
TriggerConfiguration,
|
||||
TriggerParamsByAction
|
||||
} from './conditional.js';
|
||||
import type {
|
||||
MechanicClause,
|
||||
MechanicProgram,
|
||||
PoolDefinition,
|
||||
Predicate,
|
||||
ValueReference
|
||||
} from './mechanic.js';
|
||||
|
||||
/** A partial program that an editor can merge into a new mechanic draft. */
|
||||
export type MechanicRecipeSeed = Pick<MechanicProgram, 'scenarioInputs' | 'pools' | 'clauses'>;
|
||||
|
||||
export type SupportedMechanicRecipe = {
|
||||
status: 'supported';
|
||||
seed: MechanicRecipeSeed;
|
||||
};
|
||||
|
||||
export type UnsupportedMechanicRecipe = {
|
||||
status: 'unsupported';
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type MechanicRecipeResult = SupportedMechanicRecipe | UnsupportedMechanicRecipe;
|
||||
|
||||
export type MechanicRecipeDefinition<K extends TriggerActionKey = TriggerActionKey> = {
|
||||
key: K;
|
||||
createSeed: (params: TriggerParamsByAction[K]) => MechanicRecipeResult;
|
||||
};
|
||||
|
||||
export type MechanicRecipeRegistry = {
|
||||
[K in TriggerActionKey]: MechanicRecipeDefinition<K>;
|
||||
};
|
||||
|
||||
const eventValue = (field: string): ValueReference => ({ source: 'event', path: `values.${field}` });
|
||||
const targetExists: Predicate = {
|
||||
kind: 'exists',
|
||||
value: { source: 'event', path: 'targetId' }
|
||||
};
|
||||
const noMutation = [{ kind: 'none' as const }];
|
||||
|
||||
const supported = (seed: MechanicRecipeSeed): SupportedMechanicRecipe => ({ status: 'supported', seed });
|
||||
|
||||
function directClause(
|
||||
id: string,
|
||||
when: MechanicClause['when'],
|
||||
ifPredicate?: Predicate
|
||||
): MechanicClause {
|
||||
return { id, when, if: ifPredicate, reducer: { kind: 'none' }, mutations: noMutation };
|
||||
}
|
||||
|
||||
function firingLifecycleSeed(activePool: string, includeDuration?: number): MechanicRecipeSeed {
|
||||
const pools: PoolDefinition[] = [{
|
||||
key: activePool,
|
||||
label: 'Firing active',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 1
|
||||
}];
|
||||
const clauses: MechanicClause[] = [
|
||||
{
|
||||
id: 'start-firing',
|
||||
label: 'Start firing lifecycle',
|
||||
when: 'firing_started',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'set', pool: activePool, value: 1 }]
|
||||
},
|
||||
{
|
||||
id: 'stop-firing',
|
||||
label: 'Stop firing lifecycle',
|
||||
when: 'firing_stopped',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'clear', pool: activePool }]
|
||||
},
|
||||
{
|
||||
id: 'reload-stops-firing',
|
||||
label: 'Reload stops firing lifecycle',
|
||||
when: 'reload_started',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'clear', pool: activePool }]
|
||||
}
|
||||
];
|
||||
if (includeDuration !== undefined) {
|
||||
clauses.push({
|
||||
id: 'continuous-firing-duration',
|
||||
label: 'Reach continuous firing duration',
|
||||
when: 'tick',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'pool', pool: activePool },
|
||||
operator: 'gt',
|
||||
right: 0
|
||||
},
|
||||
reducer: { kind: 'duration', seconds: includeDuration },
|
||||
mutations: noMutation
|
||||
});
|
||||
}
|
||||
return { pools, clauses };
|
||||
}
|
||||
|
||||
function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed {
|
||||
const sortedBands = [...params.bands].sort((left, right) => left.thresholdPercent - right.thresholdPercent);
|
||||
const pools: PoolDefinition[] = sortedBands.map((band, index) => ({
|
||||
key: `health-band-${index + 1}`,
|
||||
label: `Health band: ${band.outcomeKey}`,
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 1
|
||||
}));
|
||||
const clauses: MechanicClause[] = sortedBands.map((band, index) => {
|
||||
const lowerBound = sortedBands[index - 1]?.thresholdPercent;
|
||||
const predicates: Predicate[] = [{
|
||||
kind: 'compare',
|
||||
left: eventValue('healthPercent'),
|
||||
operator: 'lte',
|
||||
right: band.thresholdPercent
|
||||
}];
|
||||
if (lowerBound !== undefined) {
|
||||
predicates.push({
|
||||
kind: 'compare',
|
||||
left: eventValue('healthPercent'),
|
||||
operator: 'gt',
|
||||
right: lowerBound
|
||||
});
|
||||
}
|
||||
return {
|
||||
id: `select-health-band-${index + 1}`,
|
||||
label: `Select ${band.outcomeKey} at ${band.thresholdPercent}% Health`,
|
||||
when: 'health_damage_taken',
|
||||
if: { kind: 'all', predicates },
|
||||
reducer: { kind: 'none' },
|
||||
mutations: pools.map((pool, poolIndex) =>
|
||||
poolIndex === index
|
||||
? { kind: 'set' as const, pool: pool.key, value: 1 }
|
||||
: { kind: 'clear' as const, pool: pool.key }
|
||||
)
|
||||
};
|
||||
});
|
||||
clauses.push({
|
||||
id: 'clear-health-bands',
|
||||
label: 'Clear health bands above the highest threshold',
|
||||
when: 'health_damage_taken',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: eventValue('healthPercent'),
|
||||
operator: 'gt',
|
||||
right: sortedBands.at(-1)?.thresholdPercent ?? 0
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: pools.map((pool) => ({ kind: 'clear' as const, pool: pool.key }))
|
||||
});
|
||||
return { pools, clauses };
|
||||
}
|
||||
|
||||
function proximityLifecycleSeed(
|
||||
pool: PoolDefinition,
|
||||
activePredicate: Predicate,
|
||||
label: string
|
||||
): MechanicRecipeSeed {
|
||||
return {
|
||||
pools: [pool],
|
||||
clauses: [
|
||||
{
|
||||
id: `enter-${pool.key}`,
|
||||
label: `Enter ${label}`,
|
||||
when: 'proximity_snapshot',
|
||||
if: activePredicate,
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'set', pool: pool.key, value: 1 }]
|
||||
},
|
||||
{
|
||||
id: `exit-${pool.key}`,
|
||||
label: `Exit ${label}`,
|
||||
when: 'proximity_snapshot',
|
||||
if: { kind: 'not', predicate: activePredicate },
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'clear', pool: pool.key }]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export const MECHANIC_RECIPE_REGISTRY: Readonly<MechanicRecipeRegistry> = {
|
||||
same_target_hits_within_window: {
|
||||
key: 'same_target_hits_within_window',
|
||||
createSeed: (params) => {
|
||||
const value = params as SameTargetHitsParams;
|
||||
return supported({ clauses: [{
|
||||
id: 'same-target-hit-window',
|
||||
when: 'weapon_hit',
|
||||
if: targetExists,
|
||||
reducer: {
|
||||
kind: 'sliding_count', threshold: value.hitsRequired, windowSeconds: value.windowSeconds,
|
||||
reset: 'zero', groupBy: { source: 'event', path: 'targetId' }
|
||||
},
|
||||
mutations: noMutation
|
||||
}] });
|
||||
}
|
||||
},
|
||||
distinct_enemies_within_window: {
|
||||
key: 'distinct_enemies_within_window',
|
||||
createSeed: (params) => {
|
||||
const value = params as DistinctEnemiesParams;
|
||||
return supported({ clauses: [{
|
||||
id: 'distinct-enemy-hit-window', when: 'weapon_hit', if: targetExists,
|
||||
reducer: {
|
||||
kind: 'distinct_count', threshold: value.enemiesRequired, windowSeconds: value.windowSeconds,
|
||||
distinctBy: { source: 'event', path: 'targetId' }
|
||||
},
|
||||
mutations: noMutation
|
||||
}] });
|
||||
}
|
||||
},
|
||||
consecutive_same_target_hits: {
|
||||
key: 'consecutive_same_target_hits',
|
||||
createSeed: (params) => {
|
||||
const value = params as ConsecutiveHitsParams;
|
||||
return supported({ clauses: [{
|
||||
id: 'consecutive-same-target-hits', when: 'weapon_hit', if: targetExists,
|
||||
reducer: {
|
||||
kind: 'consecutive_count', threshold: value.hitsRequired,
|
||||
groupBy: { source: 'event', path: 'targetId' }
|
||||
},
|
||||
mutations: noMutation
|
||||
}] });
|
||||
}
|
||||
},
|
||||
continuous_firing_duration: {
|
||||
key: 'continuous_firing_duration',
|
||||
createSeed: (params) => supported(firingLifecycleSeed('firing-active', (params as ContinuousFiringParams).seconds))
|
||||
},
|
||||
while_firing: {
|
||||
key: 'while_firing',
|
||||
createSeed: (_params) => supported(firingLifecycleSeed('firing-active'))
|
||||
},
|
||||
health_threshold_bands: {
|
||||
key: 'health_threshold_bands',
|
||||
createSeed: (params) => supported(healthBandSeed(params as HealthThresholdParams))
|
||||
},
|
||||
headshot: {
|
||||
key: 'headshot',
|
||||
createSeed: (_params) => supported({ clauses: [directClause('headshot', 'weapon_hit', {
|
||||
kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: true
|
||||
})] })
|
||||
},
|
||||
headshot_kill: {
|
||||
key: 'headshot_kill',
|
||||
createSeed: (_params) => supported({ clauses: [directClause('headshot-kill', 'enemy_killed', {
|
||||
kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: true
|
||||
})] })
|
||||
},
|
||||
weapon_kill: {
|
||||
key: 'weapon_kill',
|
||||
createSeed: (_params) => supported({ clauses: [directClause('weapon-kill', 'enemy_killed')] })
|
||||
},
|
||||
target_hit_within_distance: {
|
||||
key: 'target_hit_within_distance',
|
||||
createSeed: (params) => {
|
||||
const value = params as DistanceParams;
|
||||
return supported({ clauses: [directClause('target-hit-within-distance', 'weapon_hit', {
|
||||
kind: 'all', predicates: [targetExists, {
|
||||
kind: 'compare', left: eventValue('distanceMeters'), operator: 'lte', right: value.meters
|
||||
}]
|
||||
})] });
|
||||
}
|
||||
},
|
||||
damage_taken: {
|
||||
key: 'damage_taken',
|
||||
createSeed: (_params) => supported({ clauses: [directClause('damage-taken', 'health_damage_taken')] })
|
||||
},
|
||||
weapon_swap: {
|
||||
key: 'weapon_swap',
|
||||
createSeed: (_params) => supported({ clauses: [directClause('weapon-swap', 'weapon_swapped')] })
|
||||
},
|
||||
reload: {
|
||||
key: 'reload',
|
||||
createSeed: (_params) => supported({ clauses: [directClause('reload-started', 'reload_started')] })
|
||||
},
|
||||
within_distance: {
|
||||
key: 'within_distance',
|
||||
createSeed: (params) => {
|
||||
const value = params as DistanceParams;
|
||||
return supported(proximityLifecycleSeed(
|
||||
{ key: 'target-within-distance', label: 'Target within distance', scope: 'agent', initial: 0, min: 0, max: 1 },
|
||||
{ kind: 'compare', left: eventValue('distanceMeters'), operator: 'lte', right: value.meters },
|
||||
`target within ${value.meters}m`
|
||||
));
|
||||
}
|
||||
},
|
||||
extra_health_active: {
|
||||
key: 'extra_health_active',
|
||||
createSeed: (_params) => supported(proximityLifecycleSeed(
|
||||
{ key: 'extra-health-active', label: 'Extra Health active', scope: 'agent', initial: 0, min: 0, max: 1 },
|
||||
{ kind: 'compare', left: eventValue('extraHealthActive'), operator: 'eq', right: true },
|
||||
'Extra Health active'
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an editable mechanic fragment for parameters that have already been
|
||||
* validated by the trigger family registry.
|
||||
*/
|
||||
export function createMechanicRecipe<K extends TriggerActionKey>(
|
||||
action: K,
|
||||
params: TriggerParamsByAction[K]
|
||||
): MechanicRecipeResult {
|
||||
const definition = MECHANIC_RECIPE_REGISTRY[action] as MechanicRecipeDefinition<K>;
|
||||
const result = definition.createSeed(params);
|
||||
if (result.status === 'unsupported') return result;
|
||||
return {
|
||||
status: 'supported',
|
||||
seed: {
|
||||
...result.seed,
|
||||
clauses: result.seed.clauses.map((clause) => ({
|
||||
...clause,
|
||||
recipeKey: action
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createMechanicRecipeForTrigger(trigger: TriggerConfiguration): MechanicRecipeResult {
|
||||
return createMechanicRecipe(trigger.action, trigger.params);
|
||||
}
|
||||
@@ -0,0 +1,903 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
evaluateMechanicProgram,
|
||||
formatMechanicProgram,
|
||||
validateMechanicProgram,
|
||||
type MechanicEvent,
|
||||
type MechanicProgram
|
||||
} from './mechanic.js';
|
||||
|
||||
const custom = (encounterSeconds = 30, inputs: Record<string, number | boolean> = {}) => ({
|
||||
mode: 'custom' as const,
|
||||
encounterSeconds,
|
||||
inputs
|
||||
});
|
||||
|
||||
describe('structured mechanic runtime', () => {
|
||||
it('models Superior Firepower as same-target sliding hits and one shared expiring stack pool', () => {
|
||||
const superiorFirepower: MechanicProgram = {
|
||||
id: 'superior-firepower',
|
||||
name: 'Superior Firepower',
|
||||
pools: [{
|
||||
key: 'relentless',
|
||||
label: 'Relentless',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 10,
|
||||
expiresAfterSeconds: 10
|
||||
}],
|
||||
clauses: [{
|
||||
id: 'build-relentless',
|
||||
label: 'Build Relentless',
|
||||
when: 'weapon_hit',
|
||||
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
||||
reducer: {
|
||||
kind: 'sliding_count',
|
||||
threshold: 9,
|
||||
windowSeconds: 3,
|
||||
groupBy: { source: 'event', path: 'targetId' }
|
||||
},
|
||||
mutations: [{ kind: 'add', pool: 'relentless', value: 1 }],
|
||||
outcomes: [
|
||||
{
|
||||
id: 'relentless-weapon-damage',
|
||||
label: 'Weapon Damage',
|
||||
phase: 'while_active',
|
||||
kind: 'attribute_modifier',
|
||||
basis: 'flat',
|
||||
scope: 'equipped_weapon',
|
||||
attributeId: 'weapon-damage',
|
||||
value: 3,
|
||||
statePool: 'relentless',
|
||||
perStack: true
|
||||
},
|
||||
{
|
||||
id: 'relentless-critical-damage',
|
||||
label: 'Critical Damage',
|
||||
phase: 'while_active',
|
||||
kind: 'attribute_modifier',
|
||||
basis: 'flat',
|
||||
scope: 'equipped_weapon',
|
||||
attributeId: 'critical-damage',
|
||||
value: 1,
|
||||
statePool: 'relentless',
|
||||
perStack: true
|
||||
}
|
||||
]
|
||||
}]
|
||||
};
|
||||
const events: MechanicEvent[] = Array.from({ length: 18 }, (_, index) => ({
|
||||
kind: 'weapon_hit',
|
||||
at: index * 0.2,
|
||||
targetId: 'enemy-a'
|
||||
}));
|
||||
const result = evaluateMechanicProgram(superiorFirepower, events, custom(15));
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.emissions[0].at).toBeCloseTo(1.6);
|
||||
expect(result.emissions[1].at).toBeCloseTo(3.4);
|
||||
expect(result.finalState).toContainEqual(expect.objectContaining({
|
||||
pool: 'relentless',
|
||||
value: 0,
|
||||
expiresAt: null
|
||||
}));
|
||||
expect(result.outcomes.every((outcome) => outcome.attributeId !== null)).toBe(true);
|
||||
expect(result.outcomes.map((outcome) => outcome.value)).toEqual([3, 1, 6, 2]);
|
||||
expect(result.script).toContain('COUNT 9 MATCHES WITHIN 3s PER EVENT.targetId');
|
||||
expect(result.script).toContain('ADD 1 TO relentless');
|
||||
const maximum = evaluateMechanicProgram(superiorFirepower, [], {
|
||||
mode: 'maximum',
|
||||
encounterSeconds: 15
|
||||
});
|
||||
expect(maximum.finalState[0].value).toBe(10);
|
||||
expect(maximum.outcomes.map((outcome) => outcome.value)).toEqual([30, 10]);
|
||||
});
|
||||
|
||||
it('supports every-N-shot reducers without a bespoke action family', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'every-three-shots',
|
||||
name: 'Every Three Shots',
|
||||
pools: [{ key: 'charges', label: 'Charges', scope: 'agent', initial: 0, max: 10 }],
|
||||
clauses: [{
|
||||
id: 'charge',
|
||||
when: 'shot_fired',
|
||||
reducer: { kind: 'modulo', every: 3 },
|
||||
mutations: [{ kind: 'add', pool: 'charges', value: 1 }]
|
||||
}]
|
||||
};
|
||||
const events = Array.from({ length: 7 }, (_, index): MechanicEvent => ({
|
||||
kind: 'shot_fired',
|
||||
at: index
|
||||
}));
|
||||
const result = evaluateMechanicProgram(program, events, custom(10));
|
||||
expect(result.emissions.map((emission) => emission.at)).toEqual([2, 5]);
|
||||
expect(result.finalState[0].value).toBe(2);
|
||||
});
|
||||
|
||||
it('supports duration and periodic reducers over deterministic timeline snapshots', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'timed-movement',
|
||||
name: 'Timed Movement',
|
||||
pools: [
|
||||
{ key: 'settled', label: 'Settled', scope: 'agent', initial: 0, max: 1 },
|
||||
{ key: 'tempo', label: 'Tempo', scope: 'agent', initial: 0, max: 10 }
|
||||
],
|
||||
clauses: [
|
||||
{
|
||||
id: 'immobile-duration',
|
||||
when: 'movement_snapshot',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.state' },
|
||||
operator: 'eq',
|
||||
right: 'immobile'
|
||||
},
|
||||
reducer: { kind: 'duration', seconds: 3 },
|
||||
mutations: [{ kind: 'set', pool: 'settled', value: 1 }]
|
||||
},
|
||||
{
|
||||
id: 'moving-periodic',
|
||||
when: 'movement_snapshot',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.state' },
|
||||
operator: 'eq',
|
||||
right: 'moving'
|
||||
},
|
||||
reducer: { kind: 'periodic', intervalSeconds: 2 },
|
||||
mutations: [{ kind: 'add', pool: 'tempo', value: 1 }]
|
||||
}
|
||||
]
|
||||
};
|
||||
const result = evaluateMechanicProgram(program, [
|
||||
{ kind: 'movement_snapshot', at: 0, values: { state: 'immobile' } },
|
||||
{ kind: 'movement_snapshot', at: 2, values: { state: 'immobile' } },
|
||||
{ kind: 'movement_snapshot', at: 3, values: { state: 'immobile' } },
|
||||
{ kind: 'movement_snapshot', at: 5, values: { state: 'moving' } },
|
||||
{ kind: 'movement_snapshot', at: 7, values: { state: 'moving' } },
|
||||
{ kind: 'movement_snapshot', at: 9, values: { state: 'moving' } }
|
||||
], custom(12));
|
||||
expect(result.emissions.map(({ clauseId, at }) => ({ clauseId, at }))).toEqual([
|
||||
{ clauseId: 'immobile-duration', at: 3 },
|
||||
{ clauseId: 'moving-periodic', at: 7 },
|
||||
{ clauseId: 'moving-periodic', at: 9 }
|
||||
]);
|
||||
expect(result.finalState).toEqual([
|
||||
expect.objectContaining({ pool: 'settled', value: 1 }),
|
||||
expect.objectContaining({ pool: 'tempo', value: 2 })
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits outcomes in the declared event and state-transition phases', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'phases',
|
||||
name: 'Outcome Phases',
|
||||
pools: [{ key: 'active', label: 'Active', scope: 'agent', initial: 0, max: 1 }],
|
||||
clauses: [
|
||||
{
|
||||
id: 'activate',
|
||||
when: 'weapon_hit',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'set', pool: 'active', value: 1 }],
|
||||
outcomes: [
|
||||
{
|
||||
id: 'triggering-hit',
|
||||
label: 'Triggering Hit',
|
||||
phase: 'on_event',
|
||||
kind: 'marker',
|
||||
basis: 'flat',
|
||||
scope: 'equipped_weapon',
|
||||
value: 1
|
||||
},
|
||||
{
|
||||
id: 'entered',
|
||||
label: 'Entered',
|
||||
phase: 'on_enter',
|
||||
kind: 'marker',
|
||||
basis: 'flat',
|
||||
scope: 'self',
|
||||
statePool: 'active',
|
||||
value: 1
|
||||
},
|
||||
{
|
||||
id: 'active-modifier',
|
||||
label: 'Active Modifier',
|
||||
phase: 'while_active',
|
||||
kind: 'attribute_modifier',
|
||||
basis: 'flat',
|
||||
scope: 'equipped_weapon',
|
||||
attributeId: 'weapon-damage',
|
||||
statePool: 'active',
|
||||
value: 10
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'deactivate',
|
||||
when: 'firing_stopped',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'clear', pool: 'active' }],
|
||||
outcomes: [{
|
||||
id: 'exited',
|
||||
label: 'Exited',
|
||||
phase: 'on_exit',
|
||||
kind: 'marker',
|
||||
basis: 'flat',
|
||||
scope: 'self',
|
||||
statePool: 'active',
|
||||
value: 1
|
||||
}]
|
||||
}
|
||||
]
|
||||
};
|
||||
const result = evaluateMechanicProgram(program, [
|
||||
{ kind: 'weapon_hit', at: 1, targetId: 'enemy-a' },
|
||||
{ kind: 'firing_stopped', at: 2 }
|
||||
], custom());
|
||||
expect(result.outcomes.map((outcome) => `${outcome.outcomeId}:${outcome.phase}`)).toEqual([
|
||||
'triggering-hit:on_event',
|
||||
'entered:on_enter',
|
||||
'active-modifier:while_active',
|
||||
'exited:on_exit'
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits on-exit outcomes at pool expiry and applies their resource side effects', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'expiry-exit',
|
||||
name: 'Expiry Exit',
|
||||
pools: [{
|
||||
key: 'active',
|
||||
label: 'Active',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
max: 1,
|
||||
expiresAfterSeconds: 2
|
||||
}],
|
||||
clauses: [{
|
||||
id: 'activate',
|
||||
when: 'weapon_hit',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'set', pool: 'active', value: 1 }],
|
||||
outcomes: [{
|
||||
id: 'expiry-health-cost',
|
||||
label: 'Expiry Health Cost',
|
||||
phase: 'on_exit',
|
||||
kind: 'resource_cost',
|
||||
scope: 'self',
|
||||
resource: 'health',
|
||||
basis: 'max_health_percent',
|
||||
statePool: 'active',
|
||||
value: 10
|
||||
}]
|
||||
}]
|
||||
};
|
||||
const result = evaluateMechanicProgram(
|
||||
program,
|
||||
[{ kind: 'weapon_hit', at: 1, targetId: 'enemy-a' }],
|
||||
custom(10),
|
||||
{ maxHealth: 100, currentHealth: 100 }
|
||||
);
|
||||
expect(result.outcomes).toMatchObject([{
|
||||
outcomeId: 'expiry-health-cost',
|
||||
phase: 'on_exit',
|
||||
at: 3,
|
||||
appliedValue: 10,
|
||||
combatBefore: { currentHealth: 100 },
|
||||
combatAfter: { currentHealth: 90 }
|
||||
}]);
|
||||
expect(result.finalCombat?.currentHealth).toBe(90);
|
||||
expect(result.finalState).toContainEqual(expect.objectContaining({
|
||||
pool: 'active',
|
||||
value: 0,
|
||||
expiresAt: null
|
||||
}));
|
||||
});
|
||||
|
||||
it('uses a custom percentage as a weighted estimate and resolves 100% in maximum mode', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'full-health-hit',
|
||||
name: 'Full Health Hit',
|
||||
pools: [{
|
||||
key: 'estimated-procs',
|
||||
label: 'Estimated Procs',
|
||||
scope: 'agent',
|
||||
initial: 0
|
||||
}],
|
||||
scenarioInputs: [{
|
||||
key: 'fullHealthHitPercent',
|
||||
label: 'Hits received at full Health',
|
||||
kind: 'slider',
|
||||
defaultValue: 25,
|
||||
bestCaseValue: 100,
|
||||
min: 0,
|
||||
max: 100
|
||||
}],
|
||||
clauses: [{
|
||||
id: 'estimate-full-health-hits',
|
||||
when: 'agent_hit',
|
||||
reducer: { kind: 'none' },
|
||||
weight: {
|
||||
op: 'divide',
|
||||
left: { ref: { source: 'scenario', key: 'fullHealthHitPercent' } },
|
||||
right: 100
|
||||
},
|
||||
mutations: [{ kind: 'add', pool: 'estimated-procs', value: 1 }]
|
||||
}]
|
||||
};
|
||||
const events: MechanicEvent[] = [0, 5, 10, 15].map((at) => ({ kind: 'agent_hit', at }));
|
||||
const estimate = evaluateMechanicProgram(program, events, custom(20, { fullHealthHitPercent: 25 }));
|
||||
expect(estimate.estimatedActivationCount).toBe(1);
|
||||
expect(estimate.emissions.every((emission) => emission.weight === 0.25)).toBe(true);
|
||||
expect(estimate.emissions.map((emission) => emission.mutations[0].after)).toEqual([
|
||||
0.25,
|
||||
0.5,
|
||||
0.75,
|
||||
1
|
||||
]);
|
||||
expect(estimate.finalState[0].value).toBe(1);
|
||||
const maximum = evaluateMechanicProgram(program, events, { mode: 'maximum', encounterSeconds: 20 });
|
||||
expect(maximum.estimatedActivationCount).toBe(4);
|
||||
expect(maximum.scenarioInputs.fullHealthHitPercent).toBe(100);
|
||||
expect(maximum.finalState[0].value).toBe(4);
|
||||
});
|
||||
|
||||
it('derives proximity stacks directly from a builder-controlled snapshot', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'nearby-allies',
|
||||
name: 'Nearby Allies',
|
||||
pools: [{
|
||||
key: 'nearby-ally-stacks',
|
||||
label: 'Nearby Ally Stacks',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 3,
|
||||
bestCaseValue: 3
|
||||
}],
|
||||
clauses: [{
|
||||
id: 'derive-nearby-allies',
|
||||
when: 'proximity_snapshot',
|
||||
reducer: {
|
||||
kind: 'derived_scalar',
|
||||
value: { ref: { source: 'event', path: 'values.alliesWithinRadius' } }
|
||||
},
|
||||
mutations: [{
|
||||
kind: 'derive',
|
||||
pool: 'nearby-ally-stacks',
|
||||
value: { ref: { source: 'reducer' } }
|
||||
}]
|
||||
}]
|
||||
};
|
||||
const result = evaluateMechanicProgram(program, [{
|
||||
kind: 'proximity_snapshot',
|
||||
at: 0,
|
||||
values: { alliesWithinRadius: 2 }
|
||||
}], custom());
|
||||
expect(result.finalState[0].value).toBe(2);
|
||||
const maximum = evaluateMechanicProgram(program, [], { mode: 'maximum', encounterSeconds: 30 });
|
||||
expect(maximum.finalState[0].value).toBe(3);
|
||||
});
|
||||
|
||||
it('shares one movement charge pool across generation and consumption clauses', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'mobile-charge',
|
||||
name: 'Mobile Charge',
|
||||
pools: [{
|
||||
key: 'mobile-charge',
|
||||
label: 'Mobile Charge',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 20
|
||||
}],
|
||||
clauses: [
|
||||
{
|
||||
id: 'gain-moving',
|
||||
when: 'movement_snapshot',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.state' },
|
||||
operator: 'eq',
|
||||
right: 'moving'
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'add', pool: 'mobile-charge', value: 1 }]
|
||||
},
|
||||
{
|
||||
id: 'gain-sprinting',
|
||||
when: 'movement_snapshot',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.state' },
|
||||
operator: 'eq',
|
||||
right: 'sprinting'
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'add', pool: 'mobile-charge', value: 2 }]
|
||||
},
|
||||
{
|
||||
id: 'spend-on-shot',
|
||||
when: 'shot_fired',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'pool', pool: 'mobile-charge' },
|
||||
operator: 'gt',
|
||||
right: 0
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'consume', pool: 'mobile-charge', value: 1 }]
|
||||
},
|
||||
{
|
||||
id: 'spend-on-skill',
|
||||
when: 'skill_deployed',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'consume', pool: 'mobile-charge', value: 10 }]
|
||||
}
|
||||
]
|
||||
};
|
||||
const result = evaluateMechanicProgram(program, [
|
||||
{ kind: 'movement_snapshot', at: 0, values: { state: 'moving' } },
|
||||
{ kind: 'movement_snapshot', at: 1, values: { state: 'sprinting' } },
|
||||
{ kind: 'movement_snapshot', at: 2, values: { state: 'sprinting' } },
|
||||
{ kind: 'shot_fired', at: 3 },
|
||||
{ kind: 'skill_deployed', at: 4 }
|
||||
], custom());
|
||||
expect(result.finalState[0].value).toBe(0);
|
||||
expect(result.emissions.map((emission) => emission.clauseId)).toEqual([
|
||||
'gain-moving',
|
||||
'gain-sprinting',
|
||||
'gain-sprinting',
|
||||
'spend-on-shot',
|
||||
'spend-on-skill'
|
||||
]);
|
||||
});
|
||||
|
||||
it('transfers a target-scoped mark to the target named by the death event', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'transferring-mark',
|
||||
name: 'Transferring Mark',
|
||||
pools: [{
|
||||
key: 'mark',
|
||||
label: 'Mark',
|
||||
scope: 'target',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 1
|
||||
}],
|
||||
clauses: [
|
||||
{
|
||||
id: 'apply-mark',
|
||||
when: 'weapon_hit',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{
|
||||
kind: 'set',
|
||||
pool: 'mark',
|
||||
entity: { kind: 'event_target' },
|
||||
value: 1
|
||||
}]
|
||||
},
|
||||
{
|
||||
id: 'transfer-mark',
|
||||
when: 'enemy_killed',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'pool', pool: 'mark', entity: { kind: 'event_target' } },
|
||||
operator: 'gt',
|
||||
right: 0
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{
|
||||
kind: 'transfer',
|
||||
pool: 'mark',
|
||||
from: { kind: 'event_target' },
|
||||
to: { kind: 'event_field', path: 'values.transferTargetId' },
|
||||
value: 'all'
|
||||
}]
|
||||
}
|
||||
]
|
||||
};
|
||||
const result = evaluateMechanicProgram(program, [
|
||||
{ kind: 'weapon_hit', at: 1, targetId: 'enemy-a' },
|
||||
{
|
||||
kind: 'enemy_killed',
|
||||
at: 2,
|
||||
targetId: 'enemy-a',
|
||||
values: { transferTargetId: 'enemy-b' }
|
||||
}
|
||||
], custom());
|
||||
expect(result.finalState).toEqual([
|
||||
expect.objectContaining({ entityId: 'enemy-a', value: 0 }),
|
||||
expect.objectContaining({ entityId: 'enemy-b', value: 1 })
|
||||
]);
|
||||
});
|
||||
|
||||
it('models scaled OS Protocol restoration, an unscaled Health cost, and post-cost bonus damage', () => {
|
||||
const upgradeScale = {
|
||||
op: 'add' as const,
|
||||
left: 1,
|
||||
right: {
|
||||
op: 'divide' as const,
|
||||
left: { ref: { source: 'equipment' as const, path: 'protocolTalentUpgrades' } },
|
||||
right: 5
|
||||
}
|
||||
};
|
||||
const program: MechanicProgram = {
|
||||
id: 'os-protocol-health-engine',
|
||||
name: 'OS Protocol Health Engine',
|
||||
scenarioInputs: [{
|
||||
key: 'restorationIntervalSeconds',
|
||||
label: 'Restoration interval',
|
||||
description: 'The game cadence is not yet verified; Custom Scenario must supply it.',
|
||||
kind: 'number',
|
||||
readiness: 'requires_assumption',
|
||||
min: 0.01
|
||||
}],
|
||||
pools: [{
|
||||
key: 'nearby-enemies',
|
||||
label: 'Nearby Enemies',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 20
|
||||
}],
|
||||
clauses: [
|
||||
{
|
||||
id: 'sample-nearby-enemies',
|
||||
when: 'proximity_snapshot',
|
||||
reducer: {
|
||||
kind: 'derived_scalar',
|
||||
value: { ref: { source: 'event', path: 'values.enemiesWithinRadius' } }
|
||||
},
|
||||
mutations: [{
|
||||
kind: 'derive',
|
||||
pool: 'nearby-enemies',
|
||||
value: { ref: { source: 'reducer' } }
|
||||
}]
|
||||
},
|
||||
{
|
||||
id: 'restore-nearby-enemies',
|
||||
when: 'tick',
|
||||
reducer: {
|
||||
kind: 'periodic',
|
||||
intervalSeconds: {
|
||||
ref: {
|
||||
source: 'scenario',
|
||||
key: 'restorationIntervalSeconds'
|
||||
}
|
||||
}
|
||||
},
|
||||
mutations: [{ kind: 'none' }],
|
||||
outcomes: [{
|
||||
id: 'nearby-restoration',
|
||||
label: 'Nearby Enemy Restoration',
|
||||
phase: 'after_event',
|
||||
kind: 'restoration',
|
||||
scope: 'self',
|
||||
resource: 'health',
|
||||
basis: 'max_health_percent',
|
||||
value: {
|
||||
op: 'multiply',
|
||||
left: {
|
||||
op: 'clamp',
|
||||
value: {
|
||||
op: 'multiply',
|
||||
left: { ref: { source: 'pool', pool: 'nearby-enemies' } },
|
||||
right: 0.5
|
||||
},
|
||||
min: 0,
|
||||
max: 2.5
|
||||
},
|
||||
right: upgradeScale
|
||||
}
|
||||
}]
|
||||
},
|
||||
{
|
||||
id: 'shot-health-exchange',
|
||||
when: 'shot_fired',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'none' }],
|
||||
outcomes: [
|
||||
{
|
||||
id: 'shot-health-cost',
|
||||
label: 'Shot Health Cost',
|
||||
phase: 'on_event',
|
||||
kind: 'resource_cost',
|
||||
scope: 'self',
|
||||
resource: 'health',
|
||||
basis: 'max_health_percent',
|
||||
value: 2
|
||||
},
|
||||
{
|
||||
id: 'shot-health-damage',
|
||||
label: 'Current Health Bonus Damage',
|
||||
phase: 'on_event',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
basis: 'current_health_percent',
|
||||
combatTiming: 'current',
|
||||
value: {
|
||||
op: 'multiply',
|
||||
left: 0.6,
|
||||
right: upgradeScale
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
const events: MechanicEvent[] = [
|
||||
{ kind: 'tick', at: 0 },
|
||||
{ kind: 'shot_fired', at: 1 },
|
||||
{ kind: 'proximity_snapshot', at: 2, values: { enemiesWithinRadius: 9 } },
|
||||
{ kind: 'tick', at: 3 }
|
||||
];
|
||||
const result = evaluateMechanicProgram(
|
||||
program,
|
||||
events,
|
||||
custom(10, { restorationIntervalSeconds: 3 }),
|
||||
{ maxHealth: 10_000, currentHealth: 10_000, weaponBaseDamage: 500 },
|
||||
{ protocolTalentUpgrades: 5 }
|
||||
);
|
||||
expect(result.status).toBe('OK');
|
||||
const cost = result.outcomes.find((outcome) => outcome.outcomeId === 'shot-health-cost');
|
||||
const damage = result.outcomes.find((outcome) => outcome.outcomeId === 'shot-health-damage');
|
||||
const restoration = result.outcomes.find((outcome) => outcome.outcomeId === 'nearby-restoration');
|
||||
expect(cost).toMatchObject({
|
||||
value: 2,
|
||||
resolvedValue: 200,
|
||||
appliedValue: 200
|
||||
});
|
||||
expect(damage).toMatchObject({
|
||||
value: 1.2,
|
||||
resolvedValue: 117.6,
|
||||
appliedValue: 117.6
|
||||
});
|
||||
expect(restoration).toMatchObject({
|
||||
value: 5,
|
||||
resolvedValue: 500,
|
||||
appliedValue: 200
|
||||
});
|
||||
expect(result.finalCombat?.currentHealth).toBe(10_000);
|
||||
expect(result.equipment?.protocolTalentUpgrades).toBe(5);
|
||||
|
||||
const preCostProgram = structuredClone(program);
|
||||
const preCostDamage = preCostProgram.clauses[2].outcomes?.find(
|
||||
(outcome) => outcome.id === 'shot-health-damage'
|
||||
);
|
||||
if (preCostDamage) preCostDamage.combatTiming = 'pre_event';
|
||||
const preCost = evaluateMechanicProgram(
|
||||
preCostProgram,
|
||||
[{ kind: 'shot_fired', at: 1 }],
|
||||
custom(10, { restorationIntervalSeconds: 3 }),
|
||||
{ maxHealth: 10_000, currentHealth: 10_000 },
|
||||
{ protocolTalentUpgrades: 5 }
|
||||
);
|
||||
expect(preCost.outcomes.find((outcome) => outcome.outcomeId === 'shot-health-damage'))
|
||||
.toMatchObject({ resolvedValue: 120 });
|
||||
|
||||
const unresolved = evaluateMechanicProgram(
|
||||
program,
|
||||
[],
|
||||
{ mode: 'maximum', encounterSeconds: 10 },
|
||||
{ maxHealth: 10_000, currentHealth: 10_000 },
|
||||
{ protocolTalentUpgrades: 5 }
|
||||
);
|
||||
expect(unresolved.readiness).toEqual({
|
||||
status: 'REQUIRES_ASSUMPTIONS',
|
||||
missingInputs: ['restorationIntervalSeconds']
|
||||
});
|
||||
});
|
||||
|
||||
it('counts distinct values in a rolling window, resets consecutive runs, and applies named resources', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'runtime-gaps',
|
||||
name: 'Runtime gaps',
|
||||
clauses: [
|
||||
{
|
||||
id: 'distinct-targets',
|
||||
recipeKey: 'distinct-enemies-within-window',
|
||||
when: 'weapon_hit',
|
||||
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
||||
reducer: {
|
||||
kind: 'distinct_count',
|
||||
threshold: 3,
|
||||
windowSeconds: 2,
|
||||
distinctBy: { source: 'event', path: 'targetId' }
|
||||
},
|
||||
mutations: [{ kind: 'none' }],
|
||||
outcomes: [{
|
||||
id: 'distinct-marker',
|
||||
label: 'Distinct target threshold',
|
||||
phase: 'after_event',
|
||||
kind: 'marker',
|
||||
scope: 'all_skills',
|
||||
basis: 'flat',
|
||||
value: 1
|
||||
}]
|
||||
},
|
||||
{
|
||||
id: 'consecutive-targets',
|
||||
when: 'shot_fired',
|
||||
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
||||
reducer: {
|
||||
kind: 'consecutive_count',
|
||||
threshold: 3,
|
||||
groupBy: { source: 'event', path: 'targetId' }
|
||||
},
|
||||
mutations: [{ kind: 'none' }],
|
||||
outcomes: [{
|
||||
id: 'consecutive-marker',
|
||||
label: 'Consecutive target threshold',
|
||||
phase: 'after_event',
|
||||
kind: 'marker',
|
||||
scope: 'assigned_ability',
|
||||
basis: 'flat',
|
||||
value: 1
|
||||
}]
|
||||
},
|
||||
{
|
||||
id: 'resources',
|
||||
when: 'tick',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'none' }],
|
||||
outcomes: [
|
||||
{
|
||||
id: 'health-cost', label: 'Health cost', phase: 'on_event', kind: 'resource_cost',
|
||||
scope: 'self', resource: 'health', basis: 'flat', value: 10
|
||||
},
|
||||
{
|
||||
id: 'armor-restore', label: 'Armor restore', phase: 'on_event', kind: 'restoration',
|
||||
scope: 'self_and_allies', resource: 'armor', basis: 'flat', value: 20
|
||||
},
|
||||
{
|
||||
id: 'ammo-restore', label: 'Ammo restore', phase: 'on_event', kind: 'restoration',
|
||||
scope: 'equipped_weapon', resource: 'ammo', basis: 'flat', value: 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
const result = evaluateMechanicProgram(program, [
|
||||
{ kind: 'weapon_hit', at: 0, targetId: 'alpha' },
|
||||
{ kind: 'weapon_hit', at: 0.2, targetId: 'alpha' },
|
||||
{ kind: 'weapon_hit', at: 0.4, targetId: 'bravo' },
|
||||
{ kind: 'weapon_hit', at: 0.6, targetId: 'charlie' },
|
||||
{ kind: 'shot_fired', at: 1, targetId: 'alpha' },
|
||||
{ kind: 'shot_fired', at: 1.1, targetId: 'alpha' },
|
||||
{ kind: 'shot_fired', at: 1.2 },
|
||||
{ kind: 'shot_fired', at: 1.3, targetId: 'bravo' },
|
||||
{ kind: 'shot_fired', at: 1.4, targetId: 'bravo' },
|
||||
{ kind: 'shot_fired', at: 1.5, targetId: 'bravo' },
|
||||
{ kind: 'tick', at: 2 }
|
||||
], custom(), { maxHealth: 100, currentHealth: 50, maxArmor: 50, currentArmor: 30, maxAmmo: 10, currentAmmo: 7 });
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.emissions.filter(({ clauseId }) => clauseId === 'distinct-targets')).toMatchObject([{ at: 0.6, reducerValue: 3 }]);
|
||||
expect(result.emissions.filter(({ clauseId }) => clauseId === 'consecutive-targets')).toMatchObject([{ at: 1.5, reducerValue: 3 }]);
|
||||
expect(result.outcomes.filter(({ clauseId }) => clauseId === 'resources')).toMatchObject([
|
||||
{ outcomeId: 'health-cost', resource: 'health', appliedValue: 10 },
|
||||
{ outcomeId: 'armor-restore', resource: 'armor', appliedValue: 20 },
|
||||
{ outcomeId: 'ammo-restore', resource: 'ammo', appliedValue: 3 }
|
||||
]);
|
||||
expect(result.finalCombat).toMatchObject({ currentHealth: 40, currentArmor: 50, currentAmmo: 10 });
|
||||
expect(result.script).toContain('COUNT 3 DISTINCT EVENT.targetId WITHIN 2s');
|
||||
expect(result.script).toContain('COUNT 3 CONSECUTIVE MATCHES PER EVENT.targetId');
|
||||
expect(result.script).toContain('[RECIPE distinct-enemies-within-window]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mechanic validation and pseudo-script', () => {
|
||||
it('enforces clause cooldowns without counting events received during the cooldown', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'cooldown',
|
||||
name: 'Cooldown',
|
||||
clauses: [{
|
||||
id: 'shot-proc',
|
||||
when: 'shot_fired',
|
||||
reducer: { kind: 'none' },
|
||||
cooldownSeconds: 2,
|
||||
mutations: [{ kind: 'none' }],
|
||||
outcomes: [{
|
||||
id: 'proc',
|
||||
label: 'Proc',
|
||||
phase: 'after_event',
|
||||
kind: 'marker',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
value: 1
|
||||
}]
|
||||
}]
|
||||
};
|
||||
const result = evaluateMechanicProgram(program, [
|
||||
{ kind: 'shot_fired', at: 0 },
|
||||
{ kind: 'shot_fired', at: 1 },
|
||||
{ kind: 'shot_fired', at: 2 },
|
||||
{ kind: 'shot_fired', at: 3 }
|
||||
], custom(4));
|
||||
expect(result.emissions.map(({ at }) => at)).toEqual([0, 2]);
|
||||
expect(result.outcomes.map(({ at }) => at)).toEqual([0, 2]);
|
||||
expect(result.script).toContain('COOLDOWN 2s');
|
||||
});
|
||||
|
||||
it('rejects invalid references, reducers, and target transfer scopes', () => {
|
||||
const invalid: MechanicProgram = {
|
||||
id: 'invalid',
|
||||
name: 'Invalid',
|
||||
pools: [{ key: 'agent-state', label: 'Agent State', scope: 'agent', initial: 0 }],
|
||||
clauses: [{
|
||||
id: 'bad',
|
||||
recipeKey: '',
|
||||
when: 'shot_fired',
|
||||
reducer: { kind: 'modulo', every: 0 },
|
||||
mutations: [{
|
||||
kind: 'transfer',
|
||||
pool: 'agent-state',
|
||||
from: { kind: 'agent' },
|
||||
to: { kind: 'event_target' }
|
||||
}],
|
||||
outcomes: [{
|
||||
id: 'missing-attribute',
|
||||
label: 'Missing',
|
||||
phase: 'after_event',
|
||||
kind: 'attribute_modifier',
|
||||
basis: 'flat',
|
||||
scope: 'self',
|
||||
value: { ref: { source: 'scenario', key: 'missing-input' } }
|
||||
}, {
|
||||
id: 'missing-resource',
|
||||
label: 'Missing resource',
|
||||
phase: 'after_event',
|
||||
kind: 'restoration',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
value: 1
|
||||
}]
|
||||
}]
|
||||
};
|
||||
const result = validateMechanicProgram(invalid);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors).toContain('Clause 1 modulo interval must be a positive integer.');
|
||||
expect(result.errors).toContain('Clause 1 recipe key cannot be empty.');
|
||||
expect(result.errors).toContain('Clause 1 mutation 1 can only transfer a target-scoped pool.');
|
||||
expect(result.errors).toContain('Clause 1 outcome 1 requires an Attribute ID.');
|
||||
expect(result.errors).toContain('Clause 1 outcome 1 value references unknown scenario input "missing-input".');
|
||||
expect(result.errors).toContain('Clause 1 outcome 2 requires a resource.');
|
||||
}
|
||||
});
|
||||
|
||||
it('formats nested conditions and state operations deterministically', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'format',
|
||||
name: 'Prepared',
|
||||
pools: [{ key: 'prepared', label: 'Prepared', scope: 'agent', initial: 0, max: 10 }],
|
||||
clauses: [{
|
||||
id: 'gain',
|
||||
label: 'Gain Prepared',
|
||||
when: 'agent_hit',
|
||||
if: {
|
||||
kind: 'all',
|
||||
predicates: [
|
||||
{
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.healthPercentBefore' },
|
||||
operator: 'eq',
|
||||
right: 100
|
||||
},
|
||||
{ kind: 'not', predicate: { kind: 'exists', value: { source: 'event', path: 'values.blocked' } } }
|
||||
]
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'add', pool: 'prepared', value: 1 }]
|
||||
}]
|
||||
};
|
||||
expect(formatMechanicProgram(program)).toBe([
|
||||
'MECHANIC PREPARED',
|
||||
'STATE prepared [AGENT] 0..10',
|
||||
'',
|
||||
'WHEN AGENT HIT // Gain Prepared',
|
||||
'IF (EVENT.values.healthPercentBefore == 100) AND (NOT (EXISTS EVENT.values.blocked))',
|
||||
'ADD 1 TO prepared'
|
||||
].join('\n'));
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,268 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
calculateSkillDps,
|
||||
type SkillAbilityInput,
|
||||
type SkillCalculationInput
|
||||
} from './skill.js';
|
||||
|
||||
const directAbility = (
|
||||
overrides: Partial<SkillAbilityInput> = {}
|
||||
): SkillAbilityInput => ({
|
||||
id: 'seeker',
|
||||
baseDamage: 100,
|
||||
damageType: 'blast',
|
||||
delivery: { type: 'direct', hits: 2 },
|
||||
castSeconds: 0,
|
||||
cooldownSeconds: 5,
|
||||
targetCap: 3,
|
||||
chargePolicy: { type: 'none' },
|
||||
activation: { mode: 'on_cooldown' },
|
||||
...overrides
|
||||
});
|
||||
|
||||
const base = (overrides: Partial<SkillCalculationInput> = {}): SkillCalculationInput => ({
|
||||
rules: {
|
||||
revisionId: 'skill-rules-1',
|
||||
bindings: [
|
||||
{ attributeId: 'skillDamage', stage: 'skill_damage', operator: 'add' },
|
||||
{ attributeId: 'criticalChance', stage: 'critical_chance', operator: 'add' },
|
||||
{ attributeId: 'criticalDamage', stage: 'critical_damage', operator: 'add' },
|
||||
{ attributeId: 'multishotChance', stage: 'multishot_chance', operator: 'add' },
|
||||
{ attributeId: 'multishotDamage', stage: 'multishot_damage', operator: 'add' }
|
||||
]
|
||||
},
|
||||
abilities: [directAbility()],
|
||||
attributes: {
|
||||
skillDamage: 20,
|
||||
criticalChance: 25,
|
||||
criticalDamage: 50,
|
||||
multishotChance: 20,
|
||||
multishotDamage: 100
|
||||
},
|
||||
assumptions: { encounterSeconds: 10, targetCount: 5 },
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('calculateSkillDps', () => {
|
||||
it('calculates critical and multishot expected damage without a headshot model', () => {
|
||||
const result = calculateSkillDps(base());
|
||||
expect(result.status).toBe('OK');
|
||||
if (result.status !== 'OK') return;
|
||||
|
||||
const ability = result.abilities[0];
|
||||
expect(result.revisionId).toBe('skill-rules-1');
|
||||
expect(ability.activationCount).toBe(2);
|
||||
expect(ability.damageEventCount).toBe(4);
|
||||
expect(ability.effectiveTargets).toBe(3);
|
||||
expect(ability.damage.averageExpectedDamagePerHit).toBe(162);
|
||||
expect(ability.damage.singleTargetDamagePerActivation).toBe(324);
|
||||
expect(ability.damage.singleTargetEncounterDamage).toBe(648);
|
||||
expect(ability.damage.aggregateEncounterDamage).toBe(1944);
|
||||
expect(ability.damage.singleTargetDps).toBe(64.8);
|
||||
expect(ability.damage.aggregateDps).toBe(194.4);
|
||||
});
|
||||
|
||||
it('reads current modifiers on every periodic tick instead of snapshotting at cast time', () => {
|
||||
const result = calculateSkillDps(base({
|
||||
abilities: [directAbility({
|
||||
id: 'turret',
|
||||
delivery: { type: 'periodic', ticks: 4, intervalSeconds: 1 },
|
||||
cooldownSeconds: 100,
|
||||
targetCap: 1
|
||||
})],
|
||||
attributes: {},
|
||||
rules: {
|
||||
revisionId: 'dynamic-ticks',
|
||||
bindings: [{ attributeId: 'skillDamage', stage: 'skill_damage', operator: 'add' }]
|
||||
},
|
||||
modifierWindows: [{
|
||||
startSeconds: 2,
|
||||
endSeconds: 4,
|
||||
attributes: { skillDamage: 100 },
|
||||
abilityIds: ['turret']
|
||||
}],
|
||||
assumptions: { encounterSeconds: 5, targetCount: 1 }
|
||||
}));
|
||||
expect(result.status).toBe('OK');
|
||||
if (result.status !== 'OK') return;
|
||||
|
||||
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(600);
|
||||
expect(result.abilities[0].damage.averageExpectedDamagePerHit).toBe(150);
|
||||
});
|
||||
|
||||
it('supports multiple delivered hits on each periodic tick', () => {
|
||||
const result = calculateSkillDps(base({
|
||||
abilities: [directAbility({
|
||||
id: 'multi-hit-dot',
|
||||
delivery: { type: 'periodic', ticks: 2, hitsPerTick: 3, intervalSeconds: 1 },
|
||||
cooldownSeconds: 100,
|
||||
targetCap: 1
|
||||
})],
|
||||
attributes: {},
|
||||
rules: { revisionId: 'periodic-hits', bindings: [] },
|
||||
assumptions: { encounterSeconds: 5, targetCount: 1 }
|
||||
}));
|
||||
expect(result.status).toBe('OK');
|
||||
if (result.status !== 'OK') return;
|
||||
|
||||
expect(result.abilities[0].damageEventCount).toBe(6);
|
||||
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(600);
|
||||
});
|
||||
|
||||
it('applies published cooldown recovery and duration stages to cadence', () => {
|
||||
const result = calculateSkillDps(base({
|
||||
abilities: [directAbility({
|
||||
id: 'cadence',
|
||||
delivery: {
|
||||
type: 'periodic',
|
||||
ticks: 2,
|
||||
hitsPerTick: 1,
|
||||
intervalSeconds: 1,
|
||||
firstTickDelaySeconds: 1
|
||||
},
|
||||
cooldownSeconds: 10,
|
||||
targetCap: 1
|
||||
})],
|
||||
attributes: { recovery: 100, duration: 100 },
|
||||
rules: {
|
||||
revisionId: 'skill-cadence',
|
||||
bindings: [
|
||||
{ attributeId: 'recovery', stage: 'cooldown_recovery', operator: 'add' },
|
||||
{ attributeId: 'duration', stage: 'duration', operator: 'add' }
|
||||
]
|
||||
},
|
||||
assumptions: { encounterSeconds: 11, targetCount: 1 }
|
||||
}));
|
||||
expect(result.status).toBe('OK');
|
||||
if (result.status !== 'OK') return;
|
||||
|
||||
expect(result.abilities[0].activationCount).toBe(3);
|
||||
expect(result.abilities[0].damageEventCount).toBe(8);
|
||||
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(800);
|
||||
});
|
||||
|
||||
it('supports custom uses per minute and clips damage events at encounter end', () => {
|
||||
const result = calculateSkillDps(base({
|
||||
abilities: [directAbility({
|
||||
delivery: { type: 'periodic', ticks: 3, intervalSeconds: 2 },
|
||||
cooldownSeconds: 0,
|
||||
targetCap: 1,
|
||||
activation: { mode: 'custom_per_minute', usesPerMinute: 4 }
|
||||
})],
|
||||
attributes: {},
|
||||
rules: { revisionId: 'custom-rate', bindings: [] },
|
||||
assumptions: { encounterSeconds: 31, targetCount: 1 }
|
||||
}));
|
||||
expect(result.status).toBe('OK');
|
||||
if (result.status !== 'OK') return;
|
||||
|
||||
expect(result.abilities[0].activationCount).toBe(3);
|
||||
expect(result.abilities[0].damageEventCount).toBe(7);
|
||||
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(700);
|
||||
});
|
||||
|
||||
it('models independently recharging charges and full-set recharge differently', () => {
|
||||
const shared = {
|
||||
delivery: { type: 'direct', hits: 1 } as const,
|
||||
castSeconds: 2,
|
||||
cooldownSeconds: 10,
|
||||
targetCap: 1,
|
||||
activation: { mode: 'on_cooldown' } as const
|
||||
};
|
||||
const result = calculateSkillDps(base({
|
||||
abilities: [
|
||||
directAbility({
|
||||
...shared,
|
||||
id: 'independent',
|
||||
chargePolicy: { type: 'independent', charges: 3 }
|
||||
}),
|
||||
directAbility({
|
||||
...shared,
|
||||
id: 'full-set',
|
||||
chargePolicy: { type: 'full_set', charges: 3 }
|
||||
})
|
||||
],
|
||||
attributes: {},
|
||||
rules: { revisionId: 'charges', bindings: [] },
|
||||
assumptions: { encounterSeconds: 13, targetCount: 1 }
|
||||
}));
|
||||
expect(result.status).toBe('OK');
|
||||
if (result.status !== 'OK') return;
|
||||
|
||||
expect(result.abilities.find((item) => item.abilityId === 'independent')?.activationCount).toBe(5);
|
||||
expect(result.abilities.find((item) => item.abilityId === 'full-set')?.activationCount).toBe(3);
|
||||
});
|
||||
|
||||
it('combines every enabled ability and excludes disabled abilities', () => {
|
||||
const result = calculateSkillDps(base({
|
||||
abilities: [
|
||||
directAbility({ id: 'one', delivery: { type: 'direct', hits: 1 } }),
|
||||
directAbility({ id: 'two', delivery: { type: 'direct', hits: 1 }, baseDamage: 200 }),
|
||||
directAbility({
|
||||
id: 'off',
|
||||
baseDamage: undefined,
|
||||
activation: { mode: 'off' }
|
||||
})
|
||||
],
|
||||
attributes: {},
|
||||
rules: { revisionId: 'combined', bindings: [] },
|
||||
assumptions: { encounterSeconds: 10, targetCount: 1 }
|
||||
}));
|
||||
expect(result.status).toBe('OK');
|
||||
if (result.status !== 'OK') return;
|
||||
|
||||
expect(result.abilities.find((item) => item.abilityId === 'off')).toMatchObject({
|
||||
enabled: false,
|
||||
activationCount: 0
|
||||
});
|
||||
expect(result.combined.singleTargetEncounterDamage).toBe(600);
|
||||
expect(result.combined.singleTargetDps).toBe(60);
|
||||
});
|
||||
|
||||
it('uses each ability target assumption before applying its target cap', () => {
|
||||
const result = calculateSkillDps(base({
|
||||
abilities: [
|
||||
directAbility({ id: 'single', targetCount: 1 }),
|
||||
directAbility({ id: 'crowd', targetCount: 2 })
|
||||
],
|
||||
attributes: {},
|
||||
rules: { revisionId: 'per-ability-targets', bindings: [] },
|
||||
assumptions: { encounterSeconds: 10, targetCount: 5 }
|
||||
}));
|
||||
expect(result.status).toBe('OK');
|
||||
if (result.status !== 'OK') return;
|
||||
|
||||
expect(result.abilities.find((item) => item.abilityId === 'single')?.effectiveTargets).toBe(1);
|
||||
expect(result.abilities.find((item) => item.abilityId === 'crowd')?.effectiveTargets).toBe(2);
|
||||
});
|
||||
|
||||
it('returns explicit missing requirements instead of partial damage', () => {
|
||||
const result = calculateSkillDps({
|
||||
abilities: [directAbility({
|
||||
baseDamage: undefined,
|
||||
targetCap: undefined,
|
||||
chargePolicy: undefined,
|
||||
activation: { mode: 'custom_per_minute', usesPerMinute: 0 }
|
||||
})],
|
||||
attributes: { unknownSkillAttribute: 10 },
|
||||
assumptions: {}
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
status: 'INSUFFICIENT_DATA',
|
||||
revisionId: null,
|
||||
abilities: [],
|
||||
combined: null
|
||||
});
|
||||
expect(result.missing).toEqual(expect.arrayContaining([
|
||||
'rules.revisionId',
|
||||
'rules.bindings',
|
||||
'assumptions.encounterSeconds',
|
||||
'assumptions.targetCount',
|
||||
'ability:seeker:baseDamage',
|
||||
'ability:seeker:targetCap',
|
||||
'ability:seeker:chargePolicy',
|
||||
'ability:seeker:activation.usesPerMinute',
|
||||
'binding:unknownSkillAttribute'
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,553 @@
|
||||
export type SkillStatValues = Record<string, number>;
|
||||
|
||||
export type SkillCalculationStage =
|
||||
| 'flat_damage'
|
||||
| 'skill_damage'
|
||||
| 'final_damage'
|
||||
| 'critical_chance'
|
||||
| 'critical_damage'
|
||||
| 'multishot_chance'
|
||||
| 'multishot_damage'
|
||||
| 'cooldown_recovery'
|
||||
| 'duration';
|
||||
|
||||
export type SkillAttributeBinding = {
|
||||
attributeId: string;
|
||||
stage: SkillCalculationStage;
|
||||
operator: 'add' | 'multiply';
|
||||
weight?: number;
|
||||
};
|
||||
|
||||
export type SkillCalculationRules = {
|
||||
revisionId: string;
|
||||
bindings: SkillAttributeBinding[];
|
||||
stageOrder?: SkillCalculationStage[];
|
||||
};
|
||||
|
||||
export type SkillDelivery =
|
||||
| { type: 'direct'; hits: number }
|
||||
| {
|
||||
type: 'periodic';
|
||||
ticks: number;
|
||||
durationSeconds?: number;
|
||||
hitsPerTick?: number;
|
||||
intervalSeconds: number;
|
||||
firstTickDelaySeconds?: number;
|
||||
};
|
||||
|
||||
export type SkillChargePolicy =
|
||||
| { type: 'none' }
|
||||
| { type: 'independent'; charges: number }
|
||||
| { type: 'full_set'; charges: number };
|
||||
|
||||
export type SkillActivation =
|
||||
| { mode: 'off' }
|
||||
| { mode: 'on_cooldown' }
|
||||
| { mode: 'custom_per_minute'; usesPerMinute: number };
|
||||
|
||||
export type SkillAbilityInput = {
|
||||
id: string;
|
||||
baseDamage?: number;
|
||||
damageType?: string;
|
||||
delivery: SkillDelivery;
|
||||
castSeconds?: number;
|
||||
cooldownSeconds?: number;
|
||||
targetCap?: number;
|
||||
targetCount?: number;
|
||||
chargePolicy?: SkillChargePolicy;
|
||||
activation: SkillActivation;
|
||||
};
|
||||
|
||||
export type SkillModifierWindow = {
|
||||
startSeconds: number;
|
||||
endSeconds: number;
|
||||
attributes: SkillStatValues;
|
||||
abilityIds?: string[];
|
||||
};
|
||||
|
||||
export type SkillCalculationInput = {
|
||||
rules?: SkillCalculationRules;
|
||||
abilities: SkillAbilityInput[];
|
||||
attributes?: SkillStatValues;
|
||||
attributeRules?: Record<string, {
|
||||
combination: 'additive' | 'multiplicative_percent';
|
||||
cap?: number | null;
|
||||
}>;
|
||||
modifierWindows?: SkillModifierWindow[];
|
||||
assumptions: {
|
||||
encounterSeconds?: number;
|
||||
targetCount?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type SkillDamageSummary = {
|
||||
unmodifiedBaseDamage: number;
|
||||
averageExpectedDamagePerHit: number;
|
||||
singleTargetDamagePerActivation: number;
|
||||
aggregateDamagePerActivation: number;
|
||||
singleTargetEncounterDamage: number;
|
||||
aggregateEncounterDamage: number;
|
||||
singleTargetDps: number;
|
||||
aggregateDps: number;
|
||||
};
|
||||
|
||||
export type SkillAbilityCalculation = {
|
||||
abilityId: string;
|
||||
damageType: string;
|
||||
enabled: boolean;
|
||||
activationCount: number;
|
||||
damageEventCount: number;
|
||||
effectiveTargets: number;
|
||||
damage: SkillDamageSummary;
|
||||
};
|
||||
|
||||
export type SkillCombinedCalculation = {
|
||||
singleTargetEncounterDamage: number;
|
||||
aggregateEncounterDamage: number;
|
||||
singleTargetDps: number;
|
||||
aggregateDps: number;
|
||||
};
|
||||
|
||||
export type SkillCalculation =
|
||||
| {
|
||||
status: 'INSUFFICIENT_DATA';
|
||||
revisionId: string | null;
|
||||
missing: string[];
|
||||
abilities: [];
|
||||
combined: null;
|
||||
}
|
||||
| {
|
||||
status: 'OK';
|
||||
revisionId: string;
|
||||
missing: [];
|
||||
abilities: SkillAbilityCalculation[];
|
||||
combined: SkillCombinedCalculation;
|
||||
};
|
||||
|
||||
type DamageEvent = {
|
||||
activationIndex: number;
|
||||
time: number;
|
||||
};
|
||||
|
||||
const EPSILON = 0.000_001;
|
||||
const MAX_EVENTS = 100_000;
|
||||
|
||||
const isFiniteNumber = (value: unknown) => Number.isFinite(Number(value));
|
||||
const isNonNegative = (value: unknown) => isFiniteNumber(value) && Number(value) >= 0;
|
||||
const isPositive = (value: unknown) => isFiniteNumber(value) && Number(value) > 0;
|
||||
const isPositiveInteger = (value: unknown) => Number.isInteger(Number(value)) && Number(value) > 0;
|
||||
const clamp = (value: number, minimum: number, maximum: number) =>
|
||||
Math.min(maximum, Math.max(minimum, value));
|
||||
|
||||
function requiredData(input: SkillCalculationInput) {
|
||||
const missing: string[] = [];
|
||||
const enabled = input.abilities.filter((ability) => ability.activation.mode !== 'off');
|
||||
|
||||
if (enabled.length) {
|
||||
if (!input.rules?.revisionId?.trim()) missing.push('rules.revisionId');
|
||||
if (!Array.isArray(input.rules?.bindings)) missing.push('rules.bindings');
|
||||
if (!isPositive(input.assumptions.encounterSeconds)) missing.push('assumptions.encounterSeconds');
|
||||
if (!isPositiveInteger(input.assumptions.targetCount)) missing.push('assumptions.targetCount');
|
||||
}
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
for (const ability of enabled) {
|
||||
const prefix = `ability:${ability.id || 'unknown'}`;
|
||||
if (!ability.id.trim()) missing.push(`${prefix}:id`);
|
||||
if (seenIds.has(ability.id)) missing.push(`${prefix}:duplicate`);
|
||||
seenIds.add(ability.id);
|
||||
if (!isPositive(ability.baseDamage)) missing.push(`${prefix}:baseDamage`);
|
||||
if (!ability.damageType?.trim()) missing.push(`${prefix}:damageType`);
|
||||
if (!isNonNegative(ability.castSeconds)) missing.push(`${prefix}:castSeconds`);
|
||||
if (!isNonNegative(ability.cooldownSeconds)) missing.push(`${prefix}:cooldownSeconds`);
|
||||
if (!isPositiveInteger(ability.targetCap)) missing.push(`${prefix}:targetCap`);
|
||||
if (ability.targetCount !== undefined && !isPositiveInteger(ability.targetCount)) {
|
||||
missing.push(`${prefix}:targetCount`);
|
||||
}
|
||||
if (!ability.chargePolicy) missing.push(`${prefix}:chargePolicy`);
|
||||
if (ability.chargePolicy?.type !== 'none' && !isPositiveInteger(ability.chargePolicy?.charges)) {
|
||||
missing.push(`${prefix}:chargePolicy.charges`);
|
||||
}
|
||||
if (ability.delivery.type === 'direct') {
|
||||
if (!isPositiveInteger(ability.delivery.hits)) missing.push(`${prefix}:delivery.hits`);
|
||||
} else {
|
||||
if (!isPositiveInteger(ability.delivery.ticks)) missing.push(`${prefix}:delivery.ticks`);
|
||||
if (
|
||||
ability.delivery.durationSeconds !== undefined
|
||||
&& !isPositive(ability.delivery.durationSeconds)
|
||||
) {
|
||||
missing.push(`${prefix}:delivery.durationSeconds`);
|
||||
}
|
||||
if (!isPositiveInteger(ability.delivery.hitsPerTick ?? 1)) {
|
||||
missing.push(`${prefix}:delivery.hitsPerTick`);
|
||||
}
|
||||
if (!isNonNegative(ability.delivery.intervalSeconds)) {
|
||||
missing.push(`${prefix}:delivery.intervalSeconds`);
|
||||
}
|
||||
if (!isNonNegative(ability.delivery.firstTickDelaySeconds ?? 0)) {
|
||||
missing.push(`${prefix}:delivery.firstTickDelaySeconds`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
ability.activation.mode === 'custom_per_minute'
|
||||
&& !isPositive(ability.activation.usesPerMinute)
|
||||
) {
|
||||
missing.push(`${prefix}:activation.usesPerMinute`);
|
||||
}
|
||||
if (
|
||||
ability.activation.mode === 'on_cooldown'
|
||||
&& !isPositive(ability.cooldownSeconds)
|
||||
&& !isPositive(ability.castSeconds)
|
||||
) {
|
||||
missing.push(`${prefix}:activation.cadence`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [index, window] of (input.modifierWindows ?? []).entries()) {
|
||||
if (!isNonNegative(window.startSeconds)) {
|
||||
missing.push(`modifierWindow:${index}:startSeconds`);
|
||||
}
|
||||
if (!isPositive(window.endSeconds) || window.endSeconds <= window.startSeconds) {
|
||||
missing.push(`modifierWindow:${index}:endSeconds`);
|
||||
}
|
||||
for (const [attributeId, value] of Object.entries(window.attributes)) {
|
||||
if (!isFiniteNumber(value)) missing.push(`modifierWindow:${index}:attribute:${attributeId}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [attributeId, value] of Object.entries(input.attributes ?? {})) {
|
||||
if (!isFiniteNumber(value)) missing.push(`attribute:${attributeId}`);
|
||||
}
|
||||
|
||||
const boundIds = new Set(input.rules?.bindings.map((binding) => binding.attributeId) ?? []);
|
||||
const suppliedIds = new Set([
|
||||
...Object.keys(input.attributes ?? {}),
|
||||
...(input.modifierWindows ?? []).flatMap((window) => Object.keys(window.attributes))
|
||||
]);
|
||||
for (const attributeId of suppliedIds) {
|
||||
if (!boundIds.has(attributeId)) missing.push(`binding:${attributeId}`);
|
||||
}
|
||||
|
||||
return [...new Set(missing)];
|
||||
}
|
||||
|
||||
function activationInterval(ability: SkillAbilityInput) {
|
||||
return ability.activation.mode === 'custom_per_minute'
|
||||
? 60 / ability.activation.usesPerMinute
|
||||
: 0;
|
||||
}
|
||||
|
||||
function stageFactorAt(
|
||||
input: SkillCalculationInput,
|
||||
rules: SkillCalculationRules,
|
||||
abilityId: string,
|
||||
time: number,
|
||||
stage: 'cooldown_recovery' | 'duration'
|
||||
) {
|
||||
const values = stageValues(attributesAt(input, abilityId, time), rules.bindings, stage);
|
||||
return Math.max(EPSILON, (1 + values.additive / 100) * values.multiplicative);
|
||||
}
|
||||
|
||||
function activationTimes(
|
||||
ability: SkillAbilityInput,
|
||||
encounterSeconds: number,
|
||||
input: SkillCalculationInput,
|
||||
rules: SkillCalculationRules
|
||||
) {
|
||||
if (ability.activation.mode === 'off') return [];
|
||||
|
||||
const castSeconds = Number(ability.castSeconds);
|
||||
const cooldownAt = (time: number) => Number(ability.cooldownSeconds)
|
||||
/ stageFactorAt(input, rules, ability.id, time, 'cooldown_recovery');
|
||||
const requestedInterval = activationInterval(ability);
|
||||
const policy = ability.chargePolicy!;
|
||||
const times: number[] = [];
|
||||
let lastActivation = -Infinity;
|
||||
let nextRequested = 0;
|
||||
|
||||
if (policy.type === 'none') {
|
||||
for (
|
||||
let time = 0;
|
||||
time < encounterSeconds - EPSILON && times.length < MAX_EVENTS;
|
||||
time += Math.max(castSeconds, cooldownAt(time), requestedInterval)
|
||||
) {
|
||||
times.push(time);
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
if (policy.type === 'independent') {
|
||||
const readyAt = Array.from({ length: policy.charges }, () => 0);
|
||||
while (times.length < MAX_EVENTS) {
|
||||
const chargeIndex = readyAt.reduce(
|
||||
(earliest, ready, index) => ready < readyAt[earliest] ? index : earliest,
|
||||
0
|
||||
);
|
||||
const time = Math.max(
|
||||
lastActivation + castSeconds,
|
||||
readyAt[chargeIndex],
|
||||
ability.activation.mode === 'custom_per_minute' ? nextRequested : 0
|
||||
);
|
||||
if (time >= encounterSeconds - EPSILON) break;
|
||||
times.push(time);
|
||||
lastActivation = time;
|
||||
readyAt[chargeIndex] = time + cooldownAt(time);
|
||||
if (ability.activation.mode === 'custom_per_minute') nextRequested = time + requestedInterval;
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
let charges = policy.charges;
|
||||
let fullRechargeAt = Infinity;
|
||||
while (times.length < MAX_EVENTS) {
|
||||
let time = Math.max(
|
||||
lastActivation + castSeconds,
|
||||
ability.activation.mode === 'custom_per_minute' ? nextRequested : 0
|
||||
);
|
||||
if (charges === 0) {
|
||||
time = Math.max(time, fullRechargeAt);
|
||||
charges = policy.charges;
|
||||
fullRechargeAt = Infinity;
|
||||
}
|
||||
if (time >= encounterSeconds - EPSILON) break;
|
||||
times.push(time);
|
||||
lastActivation = time;
|
||||
charges -= 1;
|
||||
if (charges === 0) fullRechargeAt = time + cooldownAt(time);
|
||||
if (ability.activation.mode === 'custom_per_minute') nextRequested = time + requestedInterval;
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
function damageEvents(
|
||||
ability: SkillAbilityInput,
|
||||
activations: number[],
|
||||
encounterSeconds: number,
|
||||
input: SkillCalculationInput,
|
||||
rules: SkillCalculationRules
|
||||
) {
|
||||
const events: DamageEvent[] = [];
|
||||
for (const [activationIndex, activationTime] of activations.entries()) {
|
||||
const deliveryTime = activationTime + Number(ability.castSeconds);
|
||||
if (ability.delivery.type === 'direct') {
|
||||
for (let hit = 0; hit < ability.delivery.hits; hit += 1) {
|
||||
if (deliveryTime < encounterSeconds - EPSILON) {
|
||||
events.push({ activationIndex, time: deliveryTime });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const firstTick = deliveryTime + Number(ability.delivery.firstTickDelaySeconds ?? 0);
|
||||
const durationFactor = stageFactorAt(input, rules, ability.id, activationTime, 'duration');
|
||||
const baseDuration = Number(
|
||||
ability.delivery.durationSeconds
|
||||
?? ability.delivery.ticks * ability.delivery.intervalSeconds
|
||||
);
|
||||
const ticks = Math.max(
|
||||
1,
|
||||
Math.floor(baseDuration * durationFactor / ability.delivery.intervalSeconds)
|
||||
);
|
||||
for (let tick = 0; tick < ticks; tick += 1) {
|
||||
const time = firstTick + tick * ability.delivery.intervalSeconds;
|
||||
if (time < encounterSeconds - EPSILON) {
|
||||
for (let hit = 0; hit < Number(ability.delivery.hitsPerTick ?? 1); hit += 1) {
|
||||
events.push({ activationIndex, time });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function attributesAt(
|
||||
input: SkillCalculationInput,
|
||||
abilityId: string,
|
||||
time: number
|
||||
) {
|
||||
const attributes = { ...(input.attributes ?? {}) };
|
||||
for (const window of input.modifierWindows ?? []) {
|
||||
const appliesToAbility = !window.abilityIds?.length || window.abilityIds.includes(abilityId);
|
||||
if (!appliesToAbility || time < window.startSeconds || time >= window.endSeconds) continue;
|
||||
for (const [attributeId, value] of Object.entries(window.attributes)) {
|
||||
const current = attributes[attributeId] ?? 0;
|
||||
const rule = input.attributeRules?.[attributeId];
|
||||
const combined = rule?.combination === 'multiplicative_percent'
|
||||
? ((1 + current / 100) * (1 + value / 100) - 1) * 100
|
||||
: current + value;
|
||||
attributes[attributeId] = rule?.cap === null || rule?.cap === undefined
|
||||
? combined
|
||||
: Math.min(combined, rule.cap);
|
||||
}
|
||||
}
|
||||
return attributes;
|
||||
}
|
||||
|
||||
function stageValues(
|
||||
attributes: SkillStatValues,
|
||||
bindings: SkillAttributeBinding[],
|
||||
stage: SkillCalculationStage
|
||||
) {
|
||||
let additive = 0;
|
||||
let multiplicative = 1;
|
||||
for (const binding of bindings) {
|
||||
if (binding.stage !== stage) continue;
|
||||
const raw = Number(attributes[binding.attributeId] ?? 0);
|
||||
const value = raw * (Number.isFinite(binding.weight) ? Number(binding.weight) : 1);
|
||||
if (binding.operator === 'add') additive += value;
|
||||
else multiplicative *= value;
|
||||
}
|
||||
return { additive, multiplicative };
|
||||
}
|
||||
|
||||
function expectedDamage(
|
||||
baseDamage: number,
|
||||
attributes: SkillStatValues,
|
||||
rules: SkillCalculationRules
|
||||
) {
|
||||
let damage = baseDamage;
|
||||
const stageOrder = rules.stageOrder?.length
|
||||
? rules.stageOrder
|
||||
: ['flat_damage', 'skill_damage', 'final_damage'] satisfies SkillCalculationStage[];
|
||||
|
||||
for (const stage of stageOrder) {
|
||||
const values = stageValues(attributes, rules.bindings, stage);
|
||||
if (stage === 'flat_damage') {
|
||||
damage = (damage + values.additive) * values.multiplicative;
|
||||
} else if (stage === 'skill_damage' || stage === 'final_damage') {
|
||||
damage *= (1 + values.additive / 100) * values.multiplicative;
|
||||
}
|
||||
}
|
||||
|
||||
const criticalChance = stageValues(attributes, rules.bindings, 'critical_chance');
|
||||
const criticalDamage = stageValues(attributes, rules.bindings, 'critical_damage');
|
||||
const multishotChance = stageValues(attributes, rules.bindings, 'multishot_chance');
|
||||
const multishotDamage = stageValues(attributes, rules.bindings, 'multishot_damage');
|
||||
const criticalChanceRate = clamp(
|
||||
criticalChance.additive * criticalChance.multiplicative / 100,
|
||||
0,
|
||||
1
|
||||
);
|
||||
const criticalDamageBonus =
|
||||
criticalDamage.additive * criticalDamage.multiplicative / 100;
|
||||
const multishotChanceRate = clamp(
|
||||
multishotChance.additive * multishotChance.multiplicative / 100,
|
||||
0,
|
||||
1
|
||||
);
|
||||
const multishotDamageFactor =
|
||||
multishotDamage.additive * multishotDamage.multiplicative / 100;
|
||||
|
||||
return damage
|
||||
* (1 + criticalChanceRate * criticalDamageBonus)
|
||||
* (1 + multishotChanceRate * multishotDamageFactor);
|
||||
}
|
||||
|
||||
function zeroDamage(baseDamage = 0): SkillDamageSummary {
|
||||
return {
|
||||
unmodifiedBaseDamage: baseDamage,
|
||||
averageExpectedDamagePerHit: 0,
|
||||
singleTargetDamagePerActivation: 0,
|
||||
aggregateDamagePerActivation: 0,
|
||||
singleTargetEncounterDamage: 0,
|
||||
aggregateEncounterDamage: 0,
|
||||
singleTargetDps: 0,
|
||||
aggregateDps: 0
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateSkillDps(input: SkillCalculationInput): SkillCalculation {
|
||||
const missing = requiredData(input);
|
||||
if (missing.length) {
|
||||
return {
|
||||
status: 'INSUFFICIENT_DATA',
|
||||
revisionId: input.rules?.revisionId ?? null,
|
||||
missing,
|
||||
abilities: [],
|
||||
combined: null
|
||||
};
|
||||
}
|
||||
|
||||
const rules = input.rules!;
|
||||
const encounterSeconds = Number(input.assumptions.encounterSeconds);
|
||||
const targetCount = Number(input.assumptions.targetCount);
|
||||
const abilities: SkillAbilityCalculation[] = input.abilities.map((ability) => {
|
||||
if (ability.activation.mode === 'off') {
|
||||
return {
|
||||
abilityId: ability.id,
|
||||
damageType: ability.damageType ?? '',
|
||||
enabled: false,
|
||||
activationCount: 0,
|
||||
damageEventCount: 0,
|
||||
effectiveTargets: 0,
|
||||
damage: zeroDamage(Number(ability.baseDamage ?? 0))
|
||||
};
|
||||
}
|
||||
|
||||
const activations = activationTimes(ability, encounterSeconds, input, rules);
|
||||
const events = damageEvents(ability, activations, encounterSeconds, input, rules);
|
||||
const damageByEvent = events.map((event) =>
|
||||
expectedDamage(
|
||||
Number(ability.baseDamage),
|
||||
attributesAt(input, ability.id, event.time),
|
||||
rules
|
||||
)
|
||||
);
|
||||
const singleTargetEncounterDamage = damageByEvent.reduce((total, damage) => total + damage, 0);
|
||||
const effectiveTargets = Math.min(
|
||||
Number(ability.targetCount ?? targetCount),
|
||||
Number(ability.targetCap)
|
||||
);
|
||||
const aggregateEncounterDamage = singleTargetEncounterDamage * effectiveTargets;
|
||||
const deliveredActivations = new Set(events.map((event) => event.activationIndex)).size;
|
||||
const averageExpectedDamagePerHit = damageByEvent.length
|
||||
? singleTargetEncounterDamage / damageByEvent.length
|
||||
: 0;
|
||||
const singleTargetDamagePerActivation = deliveredActivations
|
||||
? singleTargetEncounterDamage / deliveredActivations
|
||||
: 0;
|
||||
|
||||
return {
|
||||
abilityId: ability.id,
|
||||
damageType: ability.damageType!,
|
||||
enabled: true,
|
||||
activationCount: activations.length,
|
||||
damageEventCount: events.length,
|
||||
effectiveTargets,
|
||||
damage: {
|
||||
unmodifiedBaseDamage: Number(ability.baseDamage),
|
||||
averageExpectedDamagePerHit,
|
||||
singleTargetDamagePerActivation,
|
||||
aggregateDamagePerActivation: singleTargetDamagePerActivation * effectiveTargets,
|
||||
singleTargetEncounterDamage,
|
||||
aggregateEncounterDamage,
|
||||
singleTargetDps: singleTargetEncounterDamage / encounterSeconds,
|
||||
aggregateDps: aggregateEncounterDamage / encounterSeconds
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const combined = abilities.reduce<SkillCombinedCalculation>(
|
||||
(total, ability) => ({
|
||||
singleTargetEncounterDamage:
|
||||
total.singleTargetEncounterDamage + ability.damage.singleTargetEncounterDamage,
|
||||
aggregateEncounterDamage:
|
||||
total.aggregateEncounterDamage + ability.damage.aggregateEncounterDamage,
|
||||
singleTargetDps: total.singleTargetDps + ability.damage.singleTargetDps,
|
||||
aggregateDps: total.aggregateDps + ability.damage.aggregateDps
|
||||
}),
|
||||
{
|
||||
singleTargetEncounterDamage: 0,
|
||||
aggregateEncounterDamage: 0,
|
||||
singleTargetDps: 0,
|
||||
aggregateDps: 0
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'OK',
|
||||
revisionId: rules.revisionId,
|
||||
missing: [],
|
||||
abilities,
|
||||
combined
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import PseudoScript from './PseudoScript.svelte';
|
||||
|
||||
let {
|
||||
fullReferenceHref = '/isac/mechanic-reference',
|
||||
buttonLabel = 'SCRIPT REFERENCE'
|
||||
}: {
|
||||
fullReferenceHref?: string;
|
||||
buttonLabel?: string;
|
||||
} = $props();
|
||||
|
||||
let dialog: HTMLDialogElement;
|
||||
const example = [
|
||||
'WHEN weapon_hit',
|
||||
'IF same_target_hits >= 9 WITHIN 3s',
|
||||
'ADD 1 STACK TO relentless',
|
||||
'MAX 10 STACKS',
|
||||
'REFRESH 10s ON STACK',
|
||||
'APPLY weapon_damage += 3% PER STACK'
|
||||
];
|
||||
</script>
|
||||
|
||||
<button type="button" class="script-help-trigger" onclick={() => dialog.showModal()}>
|
||||
<span aria-hidden="true">?</span>{buttonLabel}
|
||||
</button>
|
||||
|
||||
<dialog bind:this={dialog} aria-labelledby="mechanic-language-help-title">
|
||||
<div class="dialog-shell">
|
||||
<header>
|
||||
<div>
|
||||
<p>ISAC SCRIPT LANGUAGE</p>
|
||||
<h2 id="mechanic-language-help-title">How calculation scripts work</h2>
|
||||
</div>
|
||||
<button class="close" type="button" aria-label="Close script reference" onclick={() => dialog.close()}>×</button>
|
||||
</header>
|
||||
<div class="help-content">
|
||||
<p>ISAC generates these scripts from structured controls. You review the script, while typed data remains the calculation source.</p>
|
||||
<dl>
|
||||
<div><dt>WHEN</dt><dd>The event that begins a mechanic branch.</dd></div>
|
||||
<div><dt>IF</dt><dd>Conditions checked against the event, Agent, target, team, or encounter.</dd></div>
|
||||
<div><dt>TRACK</dt><dd>A count, duration, scalar, mark, or stack pool shared by branches.</dd></div>
|
||||
<div><dt>ADD / SET / CONSUME</dt><dd>How a matching branch changes tracked state.</dd></div>
|
||||
<div><dt>APPLY</dt><dd>The Attribute or direct result and the phase when it takes effect.</dd></div>
|
||||
<div><dt>UNTIL</dt><dd>The expiry, reset, or transfer policy for tracked state.</dd></div>
|
||||
<div><dt>EVERY</dt><dd>An explicit periodic interval. ISAC does not infer a missing cadence.</dd></div>
|
||||
<div><dt>PHASE</dt><dd>Whether an outcome runs before state changes, after them, or while state remains active.</dd></div>
|
||||
</dl>
|
||||
<PseudoScript title="MECHANIC EXAMPLE" lines={example} compact />
|
||||
<section class="calculation-language">
|
||||
<h3>Combat calculation statements</h3>
|
||||
<p><code>TAKE</code>, <code>ADD</code>, <code>APPLY</code>, <code>WEIGHT</code>, and <code>RETURN</code> explain the ordered deterministic damage pipeline.</p>
|
||||
</section>
|
||||
</div>
|
||||
<footer>
|
||||
<a class="secondary" href={fullReferenceHref} onclick={() => dialog.close()}>OPEN FULL REFERENCE →</a>
|
||||
<button class="primary" type="button" onclick={() => dialog.close()}>RETURN TO EDITOR</button>
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.script-help-trigger{display:inline-flex;align-items:center;gap:7px;min-height:40px;padding:0 10px;border:1px solid var(--shd-border);background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;cursor:pointer;transition:var(--shd-transition)}
|
||||
.script-help-trigger>span{display:grid;place-items:center;width:18px;height:18px;border:1px solid currentColor;border-radius:50%;font-size:11px}
|
||||
.script-help-trigger:hover,.script-help-trigger:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange)}
|
||||
.script-help-trigger:active{transform:translateY(1px);filter:brightness(.88)}
|
||||
.script-help-trigger:focus-visible,.close:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
|
||||
dialog{width:min(780px,calc(100vw - 24px));max-height:calc(100vh - 24px);padding:0;border:1px solid var(--shd-border-strong);background:var(--shd-surface);color:var(--shd-text);box-shadow:10px 12px 34px rgb(0 0 0 / .48)}
|
||||
dialog::backdrop{background:rgb(0 0 0 / .76)}
|
||||
.dialog-shell{display:grid;max-height:calc(100vh - 26px)}
|
||||
.dialog-shell>header{display:flex;align-items:start;justify-content:space-between;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}
|
||||
header p{margin:0 0 3px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.1px}
|
||||
h2{margin:0;font-size:var(--shd-type-body);text-transform:uppercase}
|
||||
.close{display:grid;place-items:center;width:40px;height:40px;border:1px solid var(--shd-border);background:transparent;color:var(--shd-text);font-size:24px;cursor:pointer}
|
||||
.close:hover{border-color:var(--shd-orange);color:var(--shd-orange)}
|
||||
.help-content{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);overflow:auto}
|
||||
.help-content>p,.calculation-language p{margin:0;color:var(--shd-text-muted);line-height:1.5}
|
||||
dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;margin:0;background:var(--shd-border)}
|
||||
dl>div{display:grid;grid-template-columns:100px 1fr;gap:8px;padding:9px;background:var(--shd-surface-raised)}
|
||||
dt{color:var(--shd-success);font-family:var(--shd-font-mono);font-size:var(--shd-type-supporting);font-weight:900}
|
||||
dd{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
|
||||
.calculation-language{padding:10px;border:1px solid var(--shd-border)}
|
||||
.calculation-language h3{margin:0 0 5px;font-size:var(--shd-type-body);text-transform:uppercase}
|
||||
.calculation-language code{color:var(--shd-success);font-family:var(--shd-font-mono);font-weight:900}
|
||||
footer{display:flex;justify-content:flex-end;gap:var(--shd-gap-sm);padding:var(--shd-gap-md);border-top:1px solid var(--shd-border);background:var(--shd-surface-raised)}
|
||||
footer a,footer button{display:inline-flex;align-items:center;justify-content:center;min-height:40px;padding:0 14px}
|
||||
@media(max-width:620px){.script-help-trigger{min-height:44px}dl{grid-template-columns:1fr}footer{display:grid}footer a,footer button{width:100%;min-height:44px}}
|
||||
@media(prefers-reduced-motion:reduce){.script-help-trigger{transition:none}}
|
||||
</style>
|
||||
@@ -105,6 +105,8 @@
|
||||
const proposedEffects = $derived((jsonValue(proposed.effectsJson) ?? []) as any[]);
|
||||
const proposedRanks = $derived((jsonValue(proposed.ranksJson) ?? []) as any[]);
|
||||
const currentAbilityEffects = $derived((currentSnapshot?.effects ?? []) as any[]);
|
||||
const currentAbilityAttack = $derived((currentSnapshot?.attack ?? null) as Record<string, unknown> | null);
|
||||
const proposedAbilityAttack = $derived((jsonValue(proposed.attackJson) ?? null) as Record<string, unknown> | null);
|
||||
const currentNodeRanks = $derived((currentSnapshot?.ranks ?? []) as any[]);
|
||||
const currentNodeEffects = $derived((currentSnapshot?.effects ?? []) as any[]);
|
||||
const currentNodeCosts = $derived((currentSnapshot?.costs ?? []) as any[]);
|
||||
@@ -112,11 +114,129 @@
|
||||
const calculationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
|
||||
const proposedCalculationStages = $derived((jsonValue(proposed.stagesJson) ?? []) as any[]);
|
||||
const proposedCalculationProfiles = $derived((jsonValue(proposed.profilesJson) ?? []) as any[]);
|
||||
const proposedSkillCalculationProfiles = $derived((jsonValue(proposed.skillProfilesJson) ?? []) as any[]);
|
||||
const effectSummary = (effect: any) => {
|
||||
const target = effect.statId || effect.stat_attribute_id || effect.abilityId || effect.ability_id || effect.label || 'CUSTOM EFFECT';
|
||||
const value = effect.value === null || effect.value === undefined || effect.value === '' ? '' : `${effect.value}${effect.unit ? ` ${effect.unit}` : ''}`;
|
||||
return `${target}${value ? ` · ${value}` : ''}`;
|
||||
};
|
||||
const conditionalRuleSummary = (effect: any) => {
|
||||
const rule = effect.conditionalRule ?? {};
|
||||
const clauses = Array.isArray(rule.clauses) ? rule.clauses : [];
|
||||
const outcomes = clauses.reduce((total: number, clause: any) =>
|
||||
total + (Array.isArray(clause.outcomes) ? clause.outcomes.length : 0), 0);
|
||||
return `${effect.effectId} · ${readableToken(rule.simulationReadiness)} · ${clauses.length} BRANCH${clauses.length === 1 ? '' : 'ES'} · ${outcomes} OUTCOME${outcomes === 1 ? '' : 'S'}`;
|
||||
};
|
||||
const readableToken = (value: unknown) => shown(value).replaceAll('_', ' ').toUpperCase();
|
||||
const storedJson = (value: unknown, fallback: unknown) => {
|
||||
if (typeof value !== 'string') return value ?? fallback;
|
||||
try { return JSON.parse(value); } catch { return fallback; }
|
||||
};
|
||||
const attackProfileLines = (attack: Record<string, unknown> | null) => {
|
||||
if (!attack || ('enabled' in attack && !attack.enabled)) return [];
|
||||
const delivery = String(attack.delivery ?? '').toLowerCase();
|
||||
const baseDamage = attack.baseDamage ?? attack.base_damage;
|
||||
const damageType = attack.damageType ?? attack.damage_type;
|
||||
const hits = attack.hitsPerActivation ?? attack.hits_per_activation;
|
||||
const castSeconds = attack.castSeconds ?? attack.cast_seconds;
|
||||
const targetCap = attack.targetCap ?? attack.target_cap;
|
||||
const chargeCount = attack.chargeCount ?? attack.charge_count;
|
||||
const chargePolicy = attack.chargePolicy ?? attack.charge_policy;
|
||||
const lines = [
|
||||
`BASE DAMAGE · ${shown(baseDamage)} · ${readableToken(damageType)}`,
|
||||
`DELIVERY · ${readableToken(delivery)} · ${shown(hits)} HIT${Number(hits) === 1 ? '' : 'S'} PER ACTIVATION · TARGET CAP ${shown(targetCap)}`,
|
||||
`TIMING · ${shown(castSeconds)}s CAST`,
|
||||
`CHARGES · ${shown(chargeCount)} · ${readableToken(chargePolicy)}`
|
||||
];
|
||||
if (delivery === 'periodic') {
|
||||
const interval = attack.tickIntervalSeconds ?? attack.tick_interval_seconds;
|
||||
const duration = attack.damageDurationSeconds ?? attack.damage_duration_seconds;
|
||||
lines.splice(3, 0, `PERIODIC WINDOW · EVERY ${shown(interval)}s FOR ${shown(duration)}s`);
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
const conditionalRuleLines = (source: unknown) => {
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) return [];
|
||||
const wrapper = source as Record<string, any>;
|
||||
const persisted = wrapper.rule && typeof wrapper.rule === 'object';
|
||||
const rule = persisted ? wrapper.rule : wrapper;
|
||||
const inputs = persisted
|
||||
? storedJson(rule.scenario_inputs_json, [])
|
||||
: rule.scenarioInputs;
|
||||
const pools = persisted ? wrapper.statePools : rule.pools;
|
||||
const clauses = persisted ? wrapper.clauses : rule.clauses;
|
||||
const lines = [
|
||||
`READINESS · ${readableToken(rule.simulationReadiness ?? rule.simulation_readiness)}`,
|
||||
`MODEL · ${Array.isArray(inputs) ? inputs.length : 0} SCENARIO INPUTS · ${Array.isArray(pools) ? pools.length : 0} STATE POOLS · ${Array.isArray(clauses) ? clauses.length : 0} BRANCHES`
|
||||
];
|
||||
if (Array.isArray(inputs)) {
|
||||
for (const input of inputs) {
|
||||
lines.push(`INPUT · ${shown(input.label ?? input.key)} · ${readableToken(input.kind)} · ${readableToken(input.readiness ?? 'ready')}`);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(pools)) {
|
||||
for (const pool of pools) {
|
||||
const poolKey = pool.key ?? pool.state_key;
|
||||
const scope = pool.scope ?? pool.state_scope;
|
||||
const maximum = pool.max ?? pool.maximum_value;
|
||||
const expiry = pool.expiresAfterSeconds ?? pool.duration_seconds;
|
||||
lines.push(`STATE · ${shown(poolKey)} · ${readableToken(scope)} · MAX ${shown(maximum)}${expiry ? ` · EXPIRES ${expiry}s` : ''}`);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(clauses)) {
|
||||
for (const [index, clause] of clauses.entries()) {
|
||||
const event = clause.event ?? clause.event_kind;
|
||||
const recipe = clause.recipeKey ?? clause.recipe_key;
|
||||
const reducer = persisted
|
||||
? storedJson(clause.reducer_json, { kind: 'none' })
|
||||
: clause.reducer;
|
||||
const mutations = persisted
|
||||
? storedJson(clause.mutations_json, [])
|
||||
: clause.mutations;
|
||||
const branchOutcomes = persisted
|
||||
? (wrapper.outcomes ?? []).filter((outcome: any) => outcome.clause_id === clause.id)
|
||||
: clause.outcomes ?? [];
|
||||
lines.push([
|
||||
`BRANCH ${index + 1}`,
|
||||
readableToken(event),
|
||||
recipe ? `RECIPE ${readableToken(recipe)}` : '',
|
||||
`REDUCER ${readableToken(reducer?.kind)}`,
|
||||
clause.cooldownSeconds ?? clause.cooldown_seconds
|
||||
? `${clause.cooldownSeconds ?? clause.cooldown_seconds}s COOLDOWN`
|
||||
: '',
|
||||
`${Array.isArray(mutations) ? mutations.length : 0} MUTATIONS`,
|
||||
`${branchOutcomes.length} OUTCOMES`
|
||||
].filter(Boolean).join(' · '));
|
||||
for (const outcome of branchOutcomes) {
|
||||
if (persisted) {
|
||||
lines.push([
|
||||
`↳ ${readableToken(outcome.variant_key)}`,
|
||||
readableToken(outcome.application_kind),
|
||||
readableToken(outcome.effect_scope),
|
||||
outcome.stat_attribute_id || readableToken(outcome.resource_kind ?? outcome.value_basis),
|
||||
`${shown(outcome.value)}${outcome.value_expression_json ? ' · EXPRESSION' : ''}`,
|
||||
readableToken(outcome.application_phase)
|
||||
].join(' · '));
|
||||
continue;
|
||||
}
|
||||
const variants = outcome.variants && typeof outcome.variants === 'object'
|
||||
? Object.entries(outcome.variants).map(([variant, configured]: [string, any]) =>
|
||||
`${readableToken(variant)} ${shown(configured?.value)}${configured?.expression ? ' (EXPRESSION)' : ''}`
|
||||
).join(' · ')
|
||||
: 'NO VARIANTS';
|
||||
lines.push([
|
||||
`↳ ${shown(outcome.label ?? outcome.key)}`,
|
||||
readableToken(outcome.kind),
|
||||
readableToken(outcome.scope),
|
||||
outcome.attributeId || readableToken(outcome.resource ?? outcome.basis),
|
||||
variants,
|
||||
readableToken(outcome.phase)
|
||||
].join(' · '));
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="proposal-change-preview" aria-label="Proposed data comparison">
|
||||
@@ -168,7 +288,10 @@
|
||||
<div class="proposal-field-summary">
|
||||
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>{/each}
|
||||
<div><span>ROLL POOLS</span><strong>{(currentSnapshot?.primaryOptions as any[])?.length ?? 0} PRIMARY · {(currentSnapshot?.secondaryOptions as any[])?.length ?? 0} SECONDARY</strong></div>
|
||||
<div><span>TALENT STRUCTURE</span><strong>{(currentSnapshot?.talentTriggers as any[])?.length ?? 0} TRIGGERS · {(currentSnapshot?.talentEffects as any[])?.length ?? 0} EFFECTS</strong></div>
|
||||
</div>
|
||||
<div class="proposal-structured-list">
|
||||
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
|
||||
{#each conditionalRuleLines(currentSnapshot?.conditional) as line}<p>{line}</p>{:else}<p>NO TYPED RULE CONFIGURED</p>{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="proposal-empty-state"><b>NEW SKILL CHIP</b><span>No live registry record will be replaced.</span></div>
|
||||
@@ -179,7 +302,10 @@
|
||||
<div class="proposal-field-summary">
|
||||
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>{/each}
|
||||
<div><span>ROLL POOLS</span><strong>{Array.isArray(proposed.primaryStatIds) ? proposed.primaryStatIds.length : Number(Boolean(proposed.primaryStatIds))} PRIMARY · {Array.isArray(proposed.secondaryStatIds) ? proposed.secondaryStatIds.length : Number(Boolean(proposed.secondaryStatIds))} SECONDARY</strong></div>
|
||||
<div><span>TALENT STRUCTURE</span><strong>{Array.isArray(proposed.triggerIds) ? proposed.triggerIds.length : Number(Boolean(proposed.triggerIds))} TRIGGERS · {((jsonValue(proposed.talentEffects) ?? []) as any[]).length} EFFECTS</strong></div>
|
||||
</div>
|
||||
<div class="proposal-structured-list">
|
||||
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
|
||||
{#each conditionalRuleLines(jsonValue(proposed.conditionalRule)) as line}<p>{line}</p>{:else}<p>NO TYPED RULE CONFIGURED</p>{/each}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -187,11 +313,11 @@
|
||||
<div class="proposal-compare-grid calculation-proposal-preview">
|
||||
<article class="proposal-editor-preview before">
|
||||
<header><span>CURRENT LIVE · {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</span><b>{calculationRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
|
||||
{#if calculationRecord}<div class="proposal-field-summary"><div><span>NAME</span><strong>{shown(calculationRecord.name)}</strong></div><div><span>STATE</span><strong>{shown(calculationRecord.status)}</strong></div>{#if action === 'saveRevision'}<div><span>STAGES</span><strong>{(currentSnapshot?.stages as any[])?.length ?? 0}</strong></div><div><span>ATTRIBUTE PROFILES</span><strong>{(currentSnapshot?.profiles as any[])?.length ?? 0}</strong></div>{/if}</div>{:else}<div class="proposal-empty-state"><b>NEW {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</b><span>No live registry record will be replaced.</span></div>{/if}
|
||||
{#if calculationRecord}<div class="proposal-field-summary"><div><span>NAME</span><strong>{shown(calculationRecord.name)}</strong></div><div><span>STATE</span><strong>{shown(calculationRecord.status)}</strong></div>{#if action === 'saveRevision'}<div><span>STAGES</span><strong>{(currentSnapshot?.stages as any[])?.length ?? 0}</strong></div><div><span>WEAPON PROFILES</span><strong>{(currentSnapshot?.profiles as any[])?.length ?? 0}</strong></div><div><span>SKILL PROFILES</span><strong>{(currentSnapshot?.skillProfiles as any[])?.length ?? 0}</strong></div>{/if}</div>{:else}<div class="proposal-empty-state"><b>NEW {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</b><span>No live registry record will be replaced.</span></div>{/if}
|
||||
</article>
|
||||
<article class="proposal-editor-preview after">
|
||||
<header><span>PROPOSED · {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</span><b>AFTER</b></header>
|
||||
<div class="proposal-field-summary"><div><span>NAME</span><strong>{shown(proposed.name)}</strong></div>{#if action === 'saveRevision'}<div><span>STAGES</span><strong>{proposedCalculationStages.length}</strong></div><div><span>ATTRIBUTE PROFILES</span><strong>{proposedCalculationProfiles.length}</strong></div><div><span>BASE REVISION</span><strong>{shown(proposed.basedOnRevisionId)}</strong></div>{:else}<div><span>STATE</span><strong>{shown(proposed.status)}</strong></div><div><span>MEASURED WEAPON</span><strong>{proposed.selectedWeapon === '1' ? 'SECONDARY' : 'PRIMARY'}</strong></div><div><span>CORE OBSERVATIONS</span><strong>{['bodyHit','criticalBodyHit','headshot','criticalHeadshot','magazineSeconds','reloadSeconds'].filter((field) => proposed[field]).length} / 6</strong></div>{/if}</div>
|
||||
<div class="proposal-field-summary"><div><span>NAME</span><strong>{shown(proposed.name)}</strong></div>{#if action === 'saveRevision'}<div><span>STAGES</span><strong>{proposedCalculationStages.length}</strong></div><div><span>WEAPON PROFILES</span><strong>{proposedCalculationProfiles.length}</strong></div><div><span>SKILL PROFILES</span><strong>{proposedSkillCalculationProfiles.length}</strong></div><div><span>BASE REVISION</span><strong>{shown(proposed.basedOnRevisionId)}</strong></div>{:else}<div><span>STATE</span><strong>{shown(proposed.status)}</strong></div><div><span>MEASURED WEAPON</span><strong>{proposed.selectedWeapon === '1' ? 'SECONDARY' : 'PRIMARY'}</strong></div><div><span>CORE OBSERVATIONS</span><strong>{['bodyHit','criticalBodyHit','headshot','criticalHeadshot','magazineSeconds','reloadSeconds'].filter((field) => proposed[field]).length} / 6</strong></div>{/if}</div>
|
||||
{#if action === 'saveRevision'}<div class="proposal-rank-preview">{#each proposedCalculationStages as stage}<section><header><span>{stage.label}</span><b>{stage.operator?.replaceAll('_',' ').toUpperCase()}</b></header><p>ORDER {stage.displayOrder} · {stage.lockedPosition?.toUpperCase() ?? 'REORDERABLE'}</p></section>{/each}</div>{/if}
|
||||
</article>
|
||||
</div>
|
||||
@@ -210,6 +336,10 @@
|
||||
<header><span>STRUCTURED EFFECTS</span><b>{currentAbilityEffects.length}</b></header>
|
||||
{#each currentAbilityEffects as effect}<p>{effectSummary(effect)}</p>{:else}<p>NO STRUCTURED EFFECTS</p>{/each}
|
||||
</div>
|
||||
<div class="proposal-structured-list">
|
||||
<header><span>INTRINSIC ATTACK PROFILE</span><b>{currentAbilityAttack ? 'CONFIGURED' : 'NONE'}</b></header>
|
||||
{#each attackProfileLines(currentAbilityAttack) as line}<p>{line}</p>{:else}<p>NO INTRINSIC ATTACK PROFILE</p>{/each}
|
||||
</div>
|
||||
{:else if action === 'saveNode'}
|
||||
<div class="proposal-rank-preview">
|
||||
{#each currentNodeRanks as rank}
|
||||
@@ -239,6 +369,10 @@
|
||||
<header><span>STRUCTURED EFFECTS</span><b>{proposedEffects.length}</b></header>
|
||||
{#each proposedEffects as effect}<p>{effectSummary(effect)}</p>{:else}<p>NO STRUCTURED EFFECTS</p>{/each}
|
||||
</div>
|
||||
<div class="proposal-structured-list">
|
||||
<header><span>INTRINSIC ATTACK PROFILE</span><b>{attackProfileLines(proposedAbilityAttack).length ? 'CONFIGURED' : 'NONE'}</b></header>
|
||||
{#each attackProfileLines(proposedAbilityAttack) as line}<p>{line}</p>{:else}<p>NO INTRINSIC ATTACK PROFILE</p>{/each}
|
||||
</div>
|
||||
{:else if action === 'saveNode'}
|
||||
<div class="proposal-rank-preview">
|
||||
{#each proposedRanks as rank}
|
||||
@@ -246,7 +380,7 @@
|
||||
<header><span>RANK {rank.rank}</span><b>{rank.effects?.length ?? 0} STATIC · {rank.combatEffects?.length ?? 0} CONDITIONAL</b></header>
|
||||
<p>{rank.description || 'NO RANK DESCRIPTION'}</p>
|
||||
{#each rank.effects ?? [] as effect}<small>{effectSummary(effect)}</small>{/each}
|
||||
{#each rank.combatEffects ?? [] as effect}<small>{effect.effectId} · {effect.triggerCount} HITS / {effect.triggerWindowSeconds}s · {effect.maxStacks} STACKS · {effect.modifiers?.length ?? 0} MODIFIERS</small>{/each}
|
||||
{#each rank.combatEffects ?? [] as effect}<small>{conditionalRuleSummary(effect)}</small>{/each}
|
||||
<small>{Object.entries(rank.costs ?? {}).filter(([, quantity]) => Number(quantity) > 0).map(([resource, quantity]) => `${resource} × ${quantity}`).join(' · ') || 'CREDITS ONLY'}</small>
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
title = 'GENERATED SCRIPT',
|
||||
lines,
|
||||
label = title,
|
||||
compact = false,
|
||||
readiness = 'READY'
|
||||
}: {
|
||||
title?: string;
|
||||
lines: string[];
|
||||
label?: string;
|
||||
compact?: boolean;
|
||||
readiness?: 'READY' | 'REQUIRES ASSUMPTIONS' | 'CAPTURED ONLY';
|
||||
} = $props();
|
||||
|
||||
const keyword = (line: string) => line.trim().split(/\s+/, 1)[0] ?? '';
|
||||
const remainder = (line: string) => line.trim().slice(keyword(line).length).trimStart();
|
||||
</script>
|
||||
|
||||
<section class:compact class="pseudo-script" aria-label={label}>
|
||||
<header>
|
||||
<span aria-hidden="true">▤</span>
|
||||
<strong>{title}</strong>
|
||||
<small>{readiness} · READ-ONLY · GENERATED FROM TYPED DATA</small>
|
||||
</header>
|
||||
<ol>
|
||||
{#each lines as line}
|
||||
<li class:blank={!line.trim()}>
|
||||
{#if line.trim()}
|
||||
<code><b>{keyword(line)}</b>{#if remainder(line)}{' '}<span>{remainder(line)}</span>{/if}</code>
|
||||
{:else}
|
||||
<span aria-hidden="true"> </span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.pseudo-script{min-width:0;border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-info);background:var(--shd-model-surface)}
|
||||
header{display:flex;align-items:center;gap:7px;min-height:38px;padding:6px 9px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}
|
||||
header>span{color:var(--shd-success);font-size:var(--shd-type-body)}
|
||||
header strong{color:var(--shd-text);font-size:var(--shd-type-telemetry);letter-spacing:.9px}
|
||||
header small{margin-left:auto;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
|
||||
ol{display:grid;gap:2px;margin:0;padding:8px 9px 9px 3.4rem;counter-reset:script-line;color:var(--shd-text-dim)}
|
||||
li{min-height:1.45em;padding-left:8px;border-left:1px solid var(--shd-border-soft);font-family:var(--shd-font-mono);font-size:var(--shd-type-supporting);line-height:1.45}
|
||||
li::marker{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-variant-numeric:tabular-nums}
|
||||
li.blank{min-height:.55em}
|
||||
code{font:inherit;color:var(--shd-text);white-space:pre-wrap;overflow-wrap:anywhere}
|
||||
code b{color:var(--shd-success);font-weight:900}
|
||||
code span{color:var(--shd-text)}
|
||||
.compact header small{display:none}.compact ol{max-height:260px;overflow:auto}
|
||||
@media(max-width:620px){header{align-items:flex-start;flex-wrap:wrap}header small{flex-basis:100%;margin-left:24px}ol{padding-left:2.8rem}li{font-size:var(--shd-type-telemetry)}}
|
||||
</style>
|
||||
@@ -1,41 +1,92 @@
|
||||
<script lang="ts">
|
||||
import RangeValueControl from '$lib/components/RangeValueControl.svelte';
|
||||
|
||||
export type CombatProfile = {
|
||||
encounterSeconds: number;
|
||||
hitRate: number;
|
||||
headshotRate: number;
|
||||
firingActivity: number;
|
||||
};
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import type { CalculationMode, CombatProfile } from '$lib/builder/loadout';
|
||||
export type { CalculationMode, CombatProfile } from '$lib/builder/loadout';
|
||||
|
||||
let {
|
||||
mode = $bindable<CalculationMode>(),
|
||||
profile = $bindable<CombatProfile>(),
|
||||
disabled = false
|
||||
}: {
|
||||
mode: CalculationMode;
|
||||
profile: CombatProfile;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
const modes = [
|
||||
{ value: 'maximum', label: 'MAXIMUM POTENTIAL' },
|
||||
{ value: 'custom', label: 'CUSTOM SCENARIO' }
|
||||
];
|
||||
|
||||
function setMode(values: string[]) {
|
||||
mode = values[0] as CalculationMode;
|
||||
}
|
||||
|
||||
function update(key: keyof CombatProfile, value: number) {
|
||||
profile = { ...profile, [key]: value };
|
||||
}
|
||||
|
||||
function setExtraHealth(values: string[]) {
|
||||
profile = { ...profile, extraHealthActive: values[0] === 'active' };
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="combat-assumptions" aria-labelledby="combat-assumptions-title">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">COMBAT PROFILE</p>
|
||||
<h3 id="combat-assumptions-title">Encounter assumptions</h3>
|
||||
<p class="eyebrow">CALCULATION MODEL</p>
|
||||
<h3 id="combat-assumptions-title">Combat conditions</h3>
|
||||
</div>
|
||||
<small>MODELED, NOT LIVE COMBAT</small>
|
||||
<small>{mode === 'maximum' ? 'BEST-CASE OUTPUT' : 'MODELED SCENARIO'}</small>
|
||||
</div>
|
||||
<p class="supporting">These assumptions drive encounter-average calculations. Individual effects can override them when needed.</p>
|
||||
<fieldset class="assumption-grid" disabled={disabled}>
|
||||
<RangeValueControl label="Encounter duration" min={10} max={300} step={5} value={profile.encounterSeconds} onchange={(value) => update('encounterSeconds', value)} />
|
||||
<RangeValueControl label="Hit rate" min={0} max={100} value={profile.hitRate} onchange={(value) => update('hitRate', value)} />
|
||||
<RangeValueControl label="Headshot rate" min={0} max={100} value={profile.headshotRate} onchange={(value) => update('headshotRate', value)} />
|
||||
<RangeValueControl label="Firing activity" min={0} max={100} value={profile.firingActivity} onchange={(value) => update('firingActivity', value)} />
|
||||
<p class="supporting">{mode === 'maximum' ? 'Calculates best-case output with every configured effect at maximum stacks and selected skills used on cooldown.' : 'Uses your encounter assumptions and individual effect settings to estimate a repeatable combat scenario.'}</p>
|
||||
<fieldset class="model-control" disabled={disabled}>
|
||||
<SegmentedChoice
|
||||
legend="DAMAGE CALCULATION MODEL"
|
||||
name="builder-calculation-model"
|
||||
options={modes}
|
||||
value={mode}
|
||||
columns={2}
|
||||
onchange={setMode}
|
||||
required
|
||||
/>
|
||||
</fieldset>
|
||||
{#if mode === 'custom'}
|
||||
<div class="custom-scenario" id="custom-combat-scenario">
|
||||
<div class="custom-head">
|
||||
<strong>CUSTOM SCENARIO INPUTS</strong>
|
||||
<span>Saved with this loadout</span>
|
||||
</div>
|
||||
<fieldset class="assumption-grid" disabled={disabled}>
|
||||
<RangeValueControl label="Encounter duration" min={10} max={300} step={5} value={profile.encounterSeconds} onchange={(value) => update('encounterSeconds', value)} />
|
||||
<RangeValueControl label="Hit rate" min={0} max={100} value={profile.hitRate} onchange={(value) => update('hitRate', value)} />
|
||||
<RangeValueControl label="Headshot rate" min={0} max={100} value={profile.headshotRate} onchange={(value) => update('headshotRate', value)} />
|
||||
<RangeValueControl label="Shots per burst" min={1} max={200} step={1} value={profile.shotsPerBurst} onchange={(value) => update('shotsPerBurst', value)} />
|
||||
<RangeValueControl label="Pause between bursts" min={0} max={15} step={0.1} value={profile.burstPauseSeconds} onchange={(value) => update('burstPauseSeconds', value)} />
|
||||
<RangeValueControl label="Time on the same target" min={0.5} max={60} step={0.5} value={profile.sameTargetSeconds} onchange={(value) => update('sameTargetSeconds', value)} />
|
||||
<RangeValueControl label="Distinct targets per minute" min={0} max={120} step={1} value={profile.distinctTargetsPerMinute} onchange={(value) => update('distinctTargetsPerMinute', value)} />
|
||||
<RangeValueControl label="Kills per minute" min={0} max={60} step={1} value={profile.killsPerMinute} onchange={(value) => update('killsPerMinute', value)} />
|
||||
<RangeValueControl label="Headshot kill share" min={0} max={100} step={1} value={profile.headshotKillRate} onchange={(value) => update('headshotKillRate', value)} />
|
||||
<RangeValueControl label="Damage events per minute" min={0} max={120} step={1} value={profile.damageTakenPerMinute} onchange={(value) => update('damageTakenPerMinute', value)} />
|
||||
<RangeValueControl label="Weapon swaps per minute" min={0} max={30} step={1} value={profile.swapsPerMinute} onchange={(value) => update('swapsPerMinute', value)} />
|
||||
<RangeValueControl label="Target distance" min={0} max={100} step={1} value={profile.targetDistanceMeters} onchange={(value) => update('targetDistanceMeters', value)} />
|
||||
<RangeValueControl label="Agent health" min={1} max={100} step={1} value={profile.healthPercent} onchange={(value) => update('healthPercent', value)} />
|
||||
<SegmentedChoice
|
||||
legend="EXTRA HEALTH"
|
||||
name="combat-extra-health"
|
||||
options={[{ value: 'inactive', label: 'INACTIVE' }, { value: 'active', label: 'ACTIVE' }]}
|
||||
value={profile.extraHealthActive ? 'active' : 'inactive'}
|
||||
onchange={setExtraHealth}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="maximum-model" role="status">
|
||||
<strong>MAXIMUM POTENTIAL ACTIVE</strong>
|
||||
<span>Effect ramp-up and encounter downtime are intentionally excluded.</span>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@@ -45,6 +96,15 @@
|
||||
h3{margin:0;color:var(--shd-text);font-size:var(--shd-type-body);text-transform:uppercase;letter-spacing:.7px}
|
||||
.section-head small{padding:4px 6px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;white-space:nowrap}
|
||||
.supporting{margin:var(--shd-gap-sm) 0 var(--shd-gap-md);color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}
|
||||
.model-control{margin:0;padding:0;border:0}
|
||||
.custom-scenario{display:grid;gap:var(--shd-gap-md);margin-top:var(--shd-gap-md);padding-top:var(--shd-gap-md);border-top:1px solid var(--shd-border-soft)}
|
||||
.custom-head{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm)}
|
||||
.custom-head strong,.custom-head span,.maximum-model strong,.maximum-model span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
|
||||
.custom-head strong,.maximum-model strong{color:var(--shd-text)}
|
||||
.custom-head span,.maximum-model span{color:var(--shd-text-muted)}
|
||||
.assumption-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-md);margin:0;padding:0;border:0}.assumption-grid:disabled{opacity:.55}
|
||||
.maximum-model{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-md);margin-top:var(--shd-gap-md);padding:var(--shd-gap-sm);border:1px solid #74552a;background:#1d180e}
|
||||
.maximum-model strong{color:var(--shd-orange)}
|
||||
@media(max-width:620px){.section-head{display:grid}.section-head small{width:max-content}.assumption-grid{grid-template-columns:1fr}}
|
||||
@media(max-width:620px){.custom-head,.maximum-model{align-items:start;flex-direction:column}}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
<script lang="ts">
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import RangeValueControl from '$lib/components/RangeValueControl.svelte';
|
||||
import type { CombatProfile } from './CombatAssumptions.svelte';
|
||||
|
||||
export type EffectMode = 'off' | 'peak' | 'encounter';
|
||||
export type ConditionalEffectState = {
|
||||
mode: EffectMode;
|
||||
overrides?: Partial<CombatProfile>;
|
||||
};
|
||||
import type { CalculationMode, CombatProfile, ConditionalEffectSelection, EffectMode } from '$lib/builder/loadout';
|
||||
import type { ScenarioInputDefinition } from '$lib/calculations/mechanic';
|
||||
export type ConditionalEffectState = ConditionalEffectSelection;
|
||||
|
||||
let {
|
||||
id,
|
||||
@@ -15,7 +11,9 @@
|
||||
description,
|
||||
facts,
|
||||
appliedState,
|
||||
scenarioInputs = [],
|
||||
effectState = $bindable<ConditionalEffectState>(),
|
||||
calculationMode = 'custom',
|
||||
disabled = false
|
||||
}: {
|
||||
id: string;
|
||||
@@ -23,7 +21,9 @@
|
||||
description?: string;
|
||||
facts: string[];
|
||||
appliedState: string;
|
||||
scenarioInputs?: ScenarioInputDefinition[];
|
||||
effectState: ConditionalEffectState;
|
||||
calculationMode?: CalculationMode;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
@@ -42,8 +42,15 @@
|
||||
effectState = { ...effectState, overrides: { ...effectState.overrides, [key]: value } };
|
||||
}
|
||||
|
||||
function updateScenarioInput(key: string, value: number | boolean) {
|
||||
effectState = {
|
||||
...effectState,
|
||||
inputs: { ...effectState.inputs, [key]: value }
|
||||
};
|
||||
}
|
||||
|
||||
function clearOverrides() {
|
||||
effectState = { ...effectState, overrides: undefined };
|
||||
effectState = { ...effectState, overrides: undefined, inputs: undefined };
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -59,20 +66,51 @@
|
||||
<div class="facts" aria-label={`${name} facts`}>
|
||||
{#each facts as fact}<span>{fact}</span>{/each}
|
||||
</div>
|
||||
<fieldset class="mode-control" disabled={disabled}>
|
||||
<SegmentedChoice legend={`${name} calculation mode`} name={`${id}-mode`} options={modes} value={effectState.mode} columns={3} onchange={setMode} />
|
||||
</fieldset>
|
||||
{#if effectState.mode === 'encounter'}
|
||||
{#if calculationMode === 'maximum'}
|
||||
<div class="maximum-effect">
|
||||
<strong>FULL EFFECT APPLIED</strong>
|
||||
<span>Maximum stacks · best-case uptime</span>
|
||||
</div>
|
||||
{:else}
|
||||
<fieldset class="mode-control" disabled={disabled}>
|
||||
<SegmentedChoice legend={`${name} calculation mode`} name={`${id}-mode`} options={modes} value={effectState.mode} columns={3} onchange={setMode} />
|
||||
</fieldset>
|
||||
{/if}
|
||||
{#if calculationMode === 'custom' && effectState.mode === 'encounter'}
|
||||
<div class="advanced-head">
|
||||
<button type="button" aria-expanded={advanced} aria-controls={`${id}-overrides`} onclick={() => advanced = !advanced} disabled={disabled}>
|
||||
{advanced ? 'HIDE' : 'ADVANCED'} OVERRIDES <span aria-hidden="true">{advanced ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if effectState.overrides}<button class="clear" type="button" onclick={clearOverrides} disabled={disabled}>RESET</button>{/if}
|
||||
{#if effectState.overrides || effectState.inputs}<button class="clear" type="button" onclick={clearOverrides} disabled={disabled}>RESET</button>{/if}
|
||||
</div>
|
||||
{#if advanced}
|
||||
<div class="override-grid" id={`${id}-overrides`}>
|
||||
<RangeValueControl label="Hit rate" min={0} max={100} value={effectState.overrides?.hitRate ?? 100} onchange={(value) => updateOverride('hitRate', value)} />
|
||||
<RangeValueControl label="Firing activity" min={0} max={100} value={effectState.overrides?.firingActivity ?? 100} onchange={(value) => updateOverride('firingActivity', value)} />
|
||||
<RangeValueControl label="Shots per burst" min={1} max={200} step={1} value={effectState.overrides?.shotsPerBurst ?? 30} onchange={(value) => updateOverride('shotsPerBurst', value)} />
|
||||
<RangeValueControl label="Pause between bursts" min={0} max={15} step={0.1} value={effectState.overrides?.burstPauseSeconds ?? 0} onchange={(value) => updateOverride('burstPauseSeconds', value)} />
|
||||
{#each scenarioInputs as input}
|
||||
<div class="program-override">
|
||||
{#if input.kind === 'toggle'}
|
||||
<SegmentedChoice
|
||||
legend={input.label}
|
||||
name={`${id}-scenario-${input.key}`}
|
||||
options={[{ value: 'false', label: 'OFF' }, { value: 'true', label: 'ON' }]}
|
||||
value={String(effectState.inputs?.[input.key] ?? input.defaultValue ?? false)}
|
||||
onchange={(values) => updateScenarioInput(input.key, values[0] === 'true')}
|
||||
/>
|
||||
{:else}
|
||||
<RangeValueControl
|
||||
label={input.label}
|
||||
min={input.min ?? 0}
|
||||
max={input.max ?? 100}
|
||||
step={input.kind === 'slider' ? 1 : 0.1}
|
||||
value={Number(effectState.inputs?.[input.key] ?? input.defaultValue ?? input.min ?? 0)}
|
||||
onchange={(value) => updateScenarioInput(input.key, value)}
|
||||
/>
|
||||
{/if}
|
||||
{#if input.description}<small>{input.description}</small>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -89,9 +127,12 @@
|
||||
header>b.active{border-color:#74552a;color:var(--shd-orange);background:#1d180e}
|
||||
.facts{display:flex;flex-wrap:wrap;gap:5px}.facts span{padding:4px 6px;border:1px solid var(--shd-border-soft);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.6px}
|
||||
.mode-control{margin:0;padding:0;border:0}.advanced-head{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);border-top:1px solid var(--shd-border-soft);padding-top:var(--shd-gap-sm)}
|
||||
.maximum-effect{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);padding:7px 8px;border:1px solid #74552a;background:#1d180e}
|
||||
.maximum-effect strong,.maximum-effect span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.maximum-effect strong{color:var(--shd-orange)}.maximum-effect span{color:var(--shd-text-muted)}
|
||||
button{min-height:40px;border:1px solid var(--shd-border);background:transparent;color:var(--shd-text-muted);padding:0 10px;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;cursor:pointer;transition:var(--shd-transition)}
|
||||
button:hover,button:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange)}button:active{transform:translateY(1px);filter:brightness(.88)}button:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}.clear{border-color:#563734;color:var(--shd-danger)}.clear:hover,.clear:focus-visible{border-color:var(--shd-danger);color:var(--shd-danger)}
|
||||
.override-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-md);padding:var(--shd-gap-sm);border:1px solid var(--shd-border-soft);background:var(--shd-surface-raised)}
|
||||
@media(max-width:620px){header{display:grid}.override-grid{grid-template-columns:1fr}button{min-height:44px}}
|
||||
.program-override{display:grid;gap:5px}.program-override small{color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
|
||||
@media(max-width:620px){header{display:grid}.override-grid{grid-template-columns:1fr}.maximum-effect{align-items:start;flex-direction:column}button{min-height:44px}}
|
||||
@media(prefers-reduced-motion:reduce){button{transition:none}}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import type { SkillCalculation } from '$lib/calculations/skill';
|
||||
|
||||
let {
|
||||
result,
|
||||
abilityNames = {}
|
||||
}: {
|
||||
result: SkillCalculation;
|
||||
abilityNames?: Record<string, string>;
|
||||
} = $props();
|
||||
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });
|
||||
</script>
|
||||
|
||||
<article class:incomplete={result.status === 'INSUFFICIENT_DATA'} class="skill-calculation" aria-live="polite">
|
||||
<header>
|
||||
<div><p>SKILL OUTPUT</p><h3>Specialization abilities</h3></div>
|
||||
<b>{result.status === 'OK' ? 'CALCULATED' : 'INSUFFICIENT DATA'}</b>
|
||||
</header>
|
||||
{#if result.status === 'INSUFFICIENT_DATA'}
|
||||
<div class="missing">
|
||||
<strong>Skill calculation unavailable</strong>
|
||||
<span>ISAC requires the following data:</span>
|
||||
<ul>{#each result.missing as item}<li>{item.replaceAll(':', ' · ')}</li>{/each}</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="combined">
|
||||
<span><small>SINGLE-TARGET DPS</small><b>{number.format(result.combined.singleTargetDps)}</b></span>
|
||||
<span><small>ALL-TARGET DPS</small><b>{number.format(result.combined.aggregateDps)}</b></span>
|
||||
<span><small>ENCOUNTER DAMAGE</small><b>{number.format(result.combined.aggregateEncounterDamage)}</b></span>
|
||||
</div>
|
||||
<div class="ability-results">
|
||||
{#each result.abilities as ability}
|
||||
<div class:disabled={!ability.enabled}>
|
||||
<strong>{abilityNames[ability.abilityId] ?? ability.abilityId}</strong>
|
||||
<span>{ability.enabled ? `${ability.activationCount} ACTIVATIONS` : 'OFF'}</span>
|
||||
<small>{number.format(ability.damage.singleTargetDps)} SINGLE DPS</small>
|
||||
<small>{number.format(ability.damage.aggregateDps)} TOTAL DPS</small>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<footer>SKILLS CAN CRIT AND MULTI-SHOT · SKILLS DO NOT HEADSHOT · ENCOUNTER TRIGGERS FOLLOW THE PRIMARY WEAPON TIMELINE</footer>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.skill-calculation{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border:1px solid var(--shd-border);border-top:2px solid var(--shd-info);background:var(--shd-surface)}
|
||||
.skill-calculation.incomplete{border-top-color:var(--shd-danger)}
|
||||
header{display:flex;justify-content:space-between;align-items:start;gap:var(--shd-gap-md)}
|
||||
header p,h3{margin:0}header p{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.1px}h3{text-transform:uppercase;font-size:var(--shd-type-body);letter-spacing:.7px}
|
||||
header>b{padding:5px 7px;border:1px solid var(--shd-info);color:var(--shd-info);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.incomplete header>b{border-color:var(--shd-danger);color:var(--shd-danger)}
|
||||
.combined{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--shd-border)}.combined span{display:grid;gap:4px;padding:9px;border-left:1px solid var(--shd-border)}.combined span:first-child{border-left:0}.combined b{color:var(--shd-info);font-size:20px}
|
||||
small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
|
||||
.ability-results{display:grid;border:1px solid var(--shd-border)}.ability-results>div{display:grid;grid-template-columns:minmax(140px,1fr) repeat(3,auto);gap:var(--shd-gap-sm);padding:7px 8px;border-top:1px solid var(--shd-border);align-items:center}.ability-results>div:first-child{border-top:0}.ability-results>div.disabled{opacity:.55}.ability-results span{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}
|
||||
.missing{display:grid;gap:4px;padding:10px;border:1px solid var(--shd-danger);border-left-width:2px;background:#1d1212;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.missing strong{color:var(--shd-danger);text-transform:uppercase}.missing ul{margin:2px 0 0;padding-left:18px}
|
||||
footer{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.6px}
|
||||
@media(max-width:700px){header,.combined{display:grid;grid-template-columns:1fr}.combined span{border-left:0;border-top:1px solid var(--shd-border)}.combined span:first-child{border-top:0}.ability-results>div{grid-template-columns:1fr 1fr}.ability-results strong{grid-column:1/-1}}
|
||||
</style>
|
||||
@@ -20,6 +20,9 @@
|
||||
stacksAtEnd: number;
|
||||
buildUpSeconds: number | null;
|
||||
maintained: boolean;
|
||||
uptimePercent?: number;
|
||||
activationCount?: number;
|
||||
instantTotal?: number;
|
||||
}>;
|
||||
missing?: string[];
|
||||
revisionLabel?: string;
|
||||
@@ -52,6 +55,7 @@
|
||||
<span>{effect.mode.toUpperCase()}</span>
|
||||
<small>AVG {effect.averageStacks.toFixed(1)} / {effect.maxStacks}</small>
|
||||
<small>PEAK {effect.maxStacksReached} · END {effect.stacksAtEnd}</small>
|
||||
<small>{(effect.uptimePercent ?? 0).toFixed(0)}% UPTIME · {effect.activationCount ?? 0} PROCS</small>
|
||||
<em>{effect.mode === 'off' ? 'EXCLUDED' : effect.buildUpSeconds === null ? 'MAX NOT REACHED' : `MAX IN ${effect.buildUpSeconds.toFixed(1)}s`}</em>
|
||||
<b class:maintained={effect.maintained}>{effect.maintained ? 'MAINTAINED' : 'NOT MAINTAINED'}</b>
|
||||
</div>
|
||||
@@ -66,7 +70,7 @@
|
||||
.weapon-calculation{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-surface)}
|
||||
.weapon-calculation.incomplete{border-top-color:var(--shd-danger)}header{display:flex;justify-content:space-between;gap:var(--shd-gap-md);align-items:start}header p{margin:0 0 3px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.1px}h3{margin:0;font-size:var(--shd-type-body);letter-spacing:.7px;text-transform:uppercase}header>b{padding:5px 7px;border:1px solid #74552a;background:#1d180e;color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px;white-space:nowrap}.incomplete header>b{border-color:#563734;background:#1d1212;color:var(--shd-danger)}
|
||||
.damage-grid,.dps-grid{display:grid;border:1px solid var(--shd-border)}.damage-grid{grid-template-columns:repeat(2,1fr)}.dps-grid{grid-template-columns:repeat(3,1fr)}.damage-grid span,.dps-grid span{display:grid;gap:4px;padding:9px;border-left:1px solid var(--shd-border);border-top:1px solid var(--shd-border)}.damage-grid span:nth-child(odd),.dps-grid span:first-child{border-left:0}.damage-grid span:nth-child(-n+2){border-top:0}.dps-grid b{color:var(--shd-orange);font-size:20px}.damage-grid b{font-size:var(--shd-type-body)}small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.missing{display:grid;gap:4px;padding:10px;border:1px solid #563734;border-left:2px solid var(--shd-danger);background:#1d1212;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.missing strong{color:var(--shd-danger);text-transform:uppercase;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.missing ul{margin:2px 0 0;padding-left:18px}.missing li+li{margin-top:2px}footer{display:flex;justify-content:space-between;gap:var(--shd-gap-sm);color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.6px}
|
||||
.stack-telemetry{display:grid;border:1px solid var(--shd-model-border);background:var(--shd-model-surface)}.stack-telemetry>header{display:flex;align-items:center;justify-content:space-between;padding:6px 8px;border-bottom:1px solid var(--shd-model-border);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.stack-telemetry>header span{color:var(--shd-info)}.stack-telemetry>header b{color:var(--shd-text-muted)}.stack-telemetry>div{display:grid;grid-template-columns:minmax(120px,1.2fr) .45fr .7fr .8fr .85fr .8fr;gap:7px;align-items:center;padding:7px 8px;border-top:1px solid var(--shd-border-soft);font-size:var(--shd-type-telemetry)}.stack-telemetry>div:first-of-type{border-top:0}.stack-telemetry>div>span,.stack-telemetry>div>small,.stack-telemetry>div>em,.stack-telemetry>div>b{font-size:inherit;font-style:normal;letter-spacing:.5px}.stack-telemetry>div>span{color:var(--shd-orange);font-weight:900}.stack-telemetry>div>em{color:var(--shd-text-muted)}.stack-telemetry>div>b{color:var(--shd-danger);text-align:right}.stack-telemetry>div>b.maintained{color:var(--shd-success)}
|
||||
.stack-telemetry{display:grid;border:1px solid var(--shd-model-border);background:var(--shd-model-surface)}.stack-telemetry>header{display:flex;align-items:center;justify-content:space-between;padding:6px 8px;border-bottom:1px solid var(--shd-model-border);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.stack-telemetry>header span{color:var(--shd-info)}.stack-telemetry>header b{color:var(--shd-text-muted)}.stack-telemetry>div{display:grid;grid-template-columns:minmax(120px,1.2fr) .45fr .7fr .8fr .9fr .85fr .8fr;gap:7px;align-items:center;padding:7px 8px;border-top:1px solid var(--shd-border-soft);font-size:var(--shd-type-telemetry)}.stack-telemetry>div:first-of-type{border-top:0}.stack-telemetry>div>span,.stack-telemetry>div>small,.stack-telemetry>div>em,.stack-telemetry>div>b{font-size:inherit;font-style:normal;letter-spacing:.5px}.stack-telemetry>div>span{color:var(--shd-orange);font-weight:900}.stack-telemetry>div>em{color:var(--shd-text-muted)}.stack-telemetry>div>b{color:var(--shd-danger);text-align:right}.stack-telemetry>div>b.maintained{color:var(--shd-success)}
|
||||
@media(max-width:820px){.stack-telemetry>div{grid-template-columns:1fr 1fr}.stack-telemetry>div>strong{grid-column:1/-1}.stack-telemetry>div>b{text-align:left}}
|
||||
@media(max-width:620px){header{display:grid}.dps-grid{grid-template-columns:1fr}.dps-grid span{border-left:0;border-top:1px solid var(--shd-border)}.dps-grid span:first-child{border-top:0}footer{display:grid}.damage-grid span{min-height:44px}}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
<script lang="ts">
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
|
||||
type RegistryItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
unit?: string;
|
||||
pending?: boolean;
|
||||
proposerName?: string;
|
||||
};
|
||||
|
||||
export type ConditionalApplication =
|
||||
| 'active_modifier'
|
||||
| 'direct_damage'
|
||||
| 'restore_health'
|
||||
| 'restore_armor'
|
||||
| 'restore_ammo';
|
||||
|
||||
export type ConditionalScope =
|
||||
| 'self'
|
||||
| 'equipped_weapon'
|
||||
| 'target'
|
||||
| 'allies'
|
||||
| 'self_and_allies'
|
||||
| 'assigned_ability'
|
||||
| 'all_skills';
|
||||
|
||||
export type ConditionalOutcomeDraft = {
|
||||
id?: string;
|
||||
application: ConditionalApplication;
|
||||
scope: ConditionalScope;
|
||||
statId: string;
|
||||
damageBasis: 'flat' | 'weapon_base_percent';
|
||||
values: Record<string, number | string | null | undefined>;
|
||||
};
|
||||
|
||||
export type ConditionalValueColumn = {
|
||||
key: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
outcomes = $bindable([]),
|
||||
attributes,
|
||||
valueColumns = [
|
||||
{ key: 'tierI', label: 'TIER I', required: true },
|
||||
{ key: 'tierII', label: 'TIER II', required: true }
|
||||
],
|
||||
idPrefix = 'conditional-outcome',
|
||||
addLabel = 'ADD OUTCOME',
|
||||
maxOutcomes = 12,
|
||||
allowAssignedAbility = false
|
||||
}: {
|
||||
outcomes?: ConditionalOutcomeDraft[];
|
||||
attributes: RegistryItem[];
|
||||
valueColumns?: ConditionalValueColumn[];
|
||||
idPrefix?: string;
|
||||
addLabel?: string;
|
||||
maxOutcomes?: number;
|
||||
allowAssignedAbility?: boolean;
|
||||
} = $props();
|
||||
|
||||
const applicationOptions = [
|
||||
{ value: 'active_modifier', label: 'ATTRIBUTE' },
|
||||
{ value: 'direct_damage', label: 'DIRECT DAMAGE' },
|
||||
{ value: 'restore_health', label: 'RESTORE HEALTH' },
|
||||
{ value: 'restore_armor', label: 'RESTORE ARMOR' },
|
||||
{ value: 'restore_ammo', label: 'RESTORE AMMO' }
|
||||
];
|
||||
const scopeOptions = $derived([
|
||||
{ id: 'self', name: 'Self' },
|
||||
{ id: 'equipped_weapon', name: 'Equipped weapon' },
|
||||
{ id: 'target', name: 'Target' },
|
||||
{ id: 'allies', name: 'Allies' },
|
||||
{ id: 'self_and_allies', name: 'Self and allies' },
|
||||
...(allowAssignedAbility ? [{ id: 'assigned_ability', name: 'Assigned ability' }] : []),
|
||||
{ id: 'all_skills', name: 'All skills' }
|
||||
]);
|
||||
const damageBasisOptions = [
|
||||
{ value: 'flat', label: 'FLAT' },
|
||||
{ value: 'weapon_base_percent', label: '% WEAPON BASE' }
|
||||
];
|
||||
|
||||
function blankOutcome(): ConditionalOutcomeDraft {
|
||||
return {
|
||||
application: 'active_modifier',
|
||||
scope: 'self',
|
||||
statId: '',
|
||||
damageBasis: 'flat',
|
||||
values: Object.fromEntries(valueColumns.map((column) => [column.key, '']))
|
||||
};
|
||||
}
|
||||
|
||||
function setApplication(index: number, application: string) {
|
||||
const current = outcomes[index];
|
||||
outcomes = outcomes.map((outcome, outcomeIndex) =>
|
||||
outcomeIndex === index
|
||||
? {
|
||||
...current,
|
||||
application: application as ConditionalApplication,
|
||||
statId: application === 'active_modifier' ? current.statId : '',
|
||||
damageBasis:
|
||||
application === 'direct_damage' ? current.damageBasis || 'flat' : 'flat'
|
||||
}
|
||||
: outcome
|
||||
);
|
||||
}
|
||||
|
||||
function setValue(index: number, key: string, rawValue: string) {
|
||||
const parsed = rawValue === '' ? '' : Number(rawValue);
|
||||
outcomes = outcomes.map((outcome, outcomeIndex) =>
|
||||
outcomeIndex === index
|
||||
? { ...outcome, values: { ...outcome.values, [key]: parsed } }
|
||||
: outcome
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="conditional-outcome-editor">
|
||||
{#each outcomes as outcome, index}
|
||||
<section class="outcome-row" aria-labelledby={`${idPrefix}-${index}-title`}>
|
||||
<header>
|
||||
<strong id={`${idPrefix}-${index}-title`}>OUTCOME {String(index + 1).padStart(2, '0')}</strong>
|
||||
<button
|
||||
type="button"
|
||||
class="remove-outcome"
|
||||
aria-label={`Remove outcome ${index + 1}`}
|
||||
onclick={() => (outcomes = outcomes.filter((_, outcomeIndex) => outcomeIndex !== index))}
|
||||
>
|
||||
REMOVE
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="outcome-grid">
|
||||
<SegmentedChoice
|
||||
legend="APPLICATION"
|
||||
name={`${idPrefix}-${index}-application`}
|
||||
options={applicationOptions}
|
||||
value={outcome.application}
|
||||
onchange={(values) => setApplication(index, values[0])}
|
||||
required
|
||||
/>
|
||||
|
||||
<div class="field assignment-field">
|
||||
<span>APPLIES TO</span>
|
||||
<SearchableAssignment
|
||||
items={scopeOptions}
|
||||
mode="single"
|
||||
compact
|
||||
subjectLabel="EFFECT SCOPE"
|
||||
searchPlaceholder="Search effect scopes…"
|
||||
emptySelectionLabel="NO EFFECT SCOPE SELECTED"
|
||||
bind:value={outcome.scope}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if outcome.application === 'active_modifier'}
|
||||
<div class="field assignment-field">
|
||||
<span>ATTRIBUTE</span>
|
||||
<SearchableAssignment
|
||||
items={attributes}
|
||||
mode="single"
|
||||
compact
|
||||
subjectLabel="ATTRIBUTE"
|
||||
searchPlaceholder="Search Attributes…"
|
||||
emptySelectionLabel="NO ATTRIBUTE SELECTED"
|
||||
bind:value={outcome.statId}
|
||||
/>
|
||||
</div>
|
||||
{:else if outcome.application === 'direct_damage'}
|
||||
<SegmentedChoice
|
||||
legend="DAMAGE BASIS"
|
||||
name={`${idPrefix}-${index}-damage-basis`}
|
||||
options={damageBasisOptions}
|
||||
bind:value={outcome.damageBasis}
|
||||
required
|
||||
/>
|
||||
{:else}
|
||||
<div class="event-note">
|
||||
<strong>INSTANT EVENT</strong>
|
||||
<span>Tracked per proc and across the encounter. It does not become a permanent Attribute.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="outcome-values" style={`--outcome-value-columns:${Math.max(valueColumns.length, 1)}`}>
|
||||
{#each valueColumns as column}
|
||||
<label class="field">
|
||||
{column.label} VALUE
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={outcome.values[column.key] ?? ''}
|
||||
required={column.required ?? false}
|
||||
aria-label={`${column.label} value for outcome ${index + 1}`}
|
||||
oninput={(event) => setValue(index, column.key, event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<div class="outcome-empty">
|
||||
<strong>NO CALCULATION OUTCOMES</strong>
|
||||
<span>Add at least one outcome before this conditional mechanic can affect a build.</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="secondary add-outcome"
|
||||
disabled={outcomes.length >= maxOutcomes}
|
||||
onclick={() => (outcomes = [...outcomes, blankOutcome()])}
|
||||
>
|
||||
{addLabel} +
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.conditional-outcome-editor {
|
||||
display: grid;
|
||||
gap: var(--shd-gap-sm);
|
||||
}
|
||||
|
||||
.outcome-row {
|
||||
border: 1px solid var(--shd-border);
|
||||
background: var(--shd-surface);
|
||||
}
|
||||
|
||||
.outcome-row > header {
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--shd-gap-sm);
|
||||
padding: 5px 8px;
|
||||
border-bottom: 1px solid var(--shd-border);
|
||||
background: var(--shd-surface-raised);
|
||||
}
|
||||
|
||||
.outcome-row > header strong {
|
||||
color: var(--shd-orange);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.remove-outcome {
|
||||
min-height: 28px;
|
||||
padding: 0 7px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--shd-danger);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
transition: var(--shd-transition);
|
||||
}
|
||||
|
||||
.remove-outcome:hover,
|
||||
.remove-outcome:focus-visible {
|
||||
border-color: var(--shd-danger);
|
||||
}
|
||||
|
||||
.remove-outcome:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.remove-outcome:focus-visible,
|
||||
.add-outcome:focus-visible {
|
||||
outline: 2px solid var(--shd-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.outcome-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 1.1fr) minmax(220px, 0.8fr);
|
||||
align-items: end;
|
||||
gap: var(--shd-gap-sm);
|
||||
padding: var(--shd-gap-sm);
|
||||
}
|
||||
|
||||
.outcome-grid > :global(.segmented-choice),
|
||||
.assignment-field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.outcome-grid > :global(.segmented-choice:first-child),
|
||||
.event-note,
|
||||
.outcome-values {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.assignment-field > span {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.event-note,
|
||||
.outcome-empty {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 9px;
|
||||
border: 1px solid var(--shd-border-soft);
|
||||
background: var(--shd-surface-raised);
|
||||
}
|
||||
|
||||
.event-note strong,
|
||||
.outcome-empty strong {
|
||||
color: var(--shd-text);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
|
||||
.event-note span,
|
||||
.outcome-empty span {
|
||||
color: var(--shd-text-muted);
|
||||
font-size: var(--shd-type-supporting);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.outcome-values {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--outcome-value-columns), minmax(100px, 1fr));
|
||||
gap: var(--shd-gap-sm);
|
||||
}
|
||||
|
||||
.outcome-values .field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.outcome-empty {
|
||||
border-left: 2px solid var(--shd-orange);
|
||||
}
|
||||
|
||||
.add-outcome {
|
||||
width: max-content;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.add-outcome:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.outcome-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.outcome-grid > * {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.outcome-values {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.add-outcome {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.remove-outcome {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,735 @@
|
||||
<script lang="ts">
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import MechanicLanguageHelp from '$lib/components/MechanicLanguageHelp.svelte';
|
||||
import PseudoScript from '$lib/components/PseudoScript.svelte';
|
||||
import {
|
||||
blankConditionalMechanicDraft,
|
||||
parseConditionalMechanicDraft,
|
||||
type ConditionalMechanicClauseDraft,
|
||||
type ConditionalMechanicDraftV1,
|
||||
type ConditionalMechanicOutcomeDraft,
|
||||
type ConditionalMechanicPoolDraft
|
||||
} from '$lib/calculations/mechanic-editor';
|
||||
import {
|
||||
formatMechanicProgram,
|
||||
MECHANIC_EVENT_KINDS,
|
||||
MECHANIC_EVENT_REGISTRY,
|
||||
type ClauseMutation,
|
||||
type MechanicOutcomeScope,
|
||||
type NumericExpression,
|
||||
type OutcomeBasis,
|
||||
type OutcomePhase,
|
||||
type Reducer,
|
||||
type ScenarioInputDefinition
|
||||
} from '$lib/calculations/mechanic';
|
||||
import {
|
||||
TRIGGER_ACTION_KEYS,
|
||||
TRIGGER_ACTION_REGISTRY,
|
||||
type TriggerActionKey
|
||||
} from '$lib/calculations/conditional';
|
||||
import { createMechanicRecipe } from '$lib/calculations/mechanic-recipes';
|
||||
|
||||
export type MechanicValueColumn = { key: string; variantKey: string; label: string };
|
||||
type Attribute = { id: string; name: string; unit?: string };
|
||||
|
||||
let {
|
||||
rule = $bindable(blankConditionalMechanicDraft()),
|
||||
attributes,
|
||||
valueColumns,
|
||||
idPrefix = 'mechanic',
|
||||
allowAssignedAbility = false,
|
||||
allowProtocolUpgradeScaling = false,
|
||||
disabled = false
|
||||
}: {
|
||||
rule?: ConditionalMechanicDraftV1;
|
||||
attributes: Attribute[];
|
||||
valueColumns: MechanicValueColumn[];
|
||||
idPrefix?: string;
|
||||
allowAssignedAbility?: boolean;
|
||||
allowProtocolUpgradeScaling?: boolean;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
let recipeKey = $state<TriggerActionKey>('same_target_hits_within_window');
|
||||
let recipeCount = $state(9);
|
||||
let recipeWindow = $state(3);
|
||||
let recipeDistance = $state(10);
|
||||
let replaceConfirmation = $state(false);
|
||||
|
||||
const readinessOptions = [
|
||||
{ value: 'ready', label: 'READY' },
|
||||
{ value: 'requires_assumptions', label: 'ASSUMPTIONS' },
|
||||
{ value: 'captured_only', label: 'CAPTURE ONLY' }
|
||||
];
|
||||
const eventItems = MECHANIC_EVENT_KINDS.map((id) => ({
|
||||
id,
|
||||
name: MECHANIC_EVENT_REGISTRY[id].label
|
||||
}));
|
||||
const reducerItems = [
|
||||
{ id: 'none', name: 'Every matching event' },
|
||||
{ id: 'count', name: 'Count matches' },
|
||||
{ id: 'distinct_count', name: 'Count distinct targets' },
|
||||
{ id: 'consecutive_count', name: 'Count consecutive matches' },
|
||||
{ id: 'sliding_count', name: 'Count within a time window' },
|
||||
{ id: 'duration', name: 'Condition held for a duration' },
|
||||
{ id: 'periodic', name: 'Repeat on an interval' },
|
||||
{ id: 'derived_scalar', name: 'Derive a scalar value' }
|
||||
];
|
||||
const recipeItems = TRIGGER_ACTION_KEYS.map((id) => ({
|
||||
id,
|
||||
name: TRIGGER_ACTION_REGISTRY[id].label
|
||||
}));
|
||||
const outcomeKinds = [
|
||||
{ value: 'attribute_modifier', label: 'ATTRIBUTE' },
|
||||
{ value: 'direct_damage', label: 'DAMAGE' },
|
||||
{ value: 'restoration', label: 'RESTORE' },
|
||||
{ value: 'resource_cost', label: 'COST' },
|
||||
{ value: 'marker', label: 'MARKER' }
|
||||
];
|
||||
const scopeOptions = $derived([
|
||||
{ value: 'self', label: 'SELF' },
|
||||
{ value: 'equipped_weapon', label: 'WEAPON' },
|
||||
{ value: 'target', label: 'TARGET' },
|
||||
{ value: 'allies', label: 'ALLIES' },
|
||||
{ value: 'self_and_allies', label: 'SELF + ALLIES' },
|
||||
...(allowAssignedAbility ? [{ value: 'assigned_ability', label: 'ABILITY' }] : []),
|
||||
{ value: 'all_skills', label: 'ALL SKILLS' }
|
||||
]);
|
||||
|
||||
const slug = (value: string) => value.toLocaleLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'branch';
|
||||
const clone = <T,>(value: T): T => structuredClone(value);
|
||||
const setRule = (next: ConditionalMechanicDraftV1) => { rule = next; };
|
||||
|
||||
function recipeParams() {
|
||||
if (recipeKey === 'same_target_hits_within_window') return { hitsRequired: recipeCount, windowSeconds: recipeWindow };
|
||||
if (recipeKey === 'distinct_enemies_within_window') return { enemiesRequired: recipeCount, windowSeconds: recipeWindow };
|
||||
if (recipeKey === 'consecutive_same_target_hits') return { hitsRequired: recipeCount };
|
||||
if (recipeKey === 'continuous_firing_duration') return { seconds: recipeWindow };
|
||||
if (recipeKey === 'target_hit_within_distance' || recipeKey === 'within_distance') return { meters: recipeDistance };
|
||||
if (recipeKey === 'health_threshold_bands') return { bands: [{ thresholdPercent: 50, outcomeKey: 'active' }] };
|
||||
return {};
|
||||
}
|
||||
|
||||
function applyRecipe(replace = false) {
|
||||
const result = createMechanicRecipe(recipeKey, recipeParams() as never);
|
||||
if (result.status !== 'supported') return;
|
||||
const clauses = result.seed.clauses.map((seed, index): ConditionalMechanicClauseDraft => ({
|
||||
key: `${slug(recipeKey)}-${rule.clauses.length + index + 1}`,
|
||||
label: seed.label ?? TRIGGER_ACTION_REGISTRY[recipeKey].label,
|
||||
event: seed.when,
|
||||
recipeKey,
|
||||
if: seed.if ?? { kind: 'true' },
|
||||
reducer: seed.reducer,
|
||||
cooldownSeconds: seed.cooldownSeconds,
|
||||
weight: seed.weight,
|
||||
mutations: seed.mutations,
|
||||
outcomes: []
|
||||
}));
|
||||
setRule({
|
||||
...rule,
|
||||
scenarioInputs: replace
|
||||
? clone(result.seed.scenarioInputs ?? [])
|
||||
: [...rule.scenarioInputs, ...clone(result.seed.scenarioInputs ?? [])],
|
||||
pools: replace
|
||||
? (result.seed.pools ?? []).map((pool) => ({ ...clone(pool), stateKind: 'stack' as const }))
|
||||
: [
|
||||
...rule.pools,
|
||||
...(result.seed.pools ?? [])
|
||||
.filter((pool) => !rule.pools.some((current) => current.key === pool.key))
|
||||
.map((pool) => ({ ...clone(pool), stateKind: 'stack' as const }))
|
||||
],
|
||||
clauses: replace ? clauses : [...rule.clauses, ...clauses]
|
||||
});
|
||||
replaceConfirmation = false;
|
||||
}
|
||||
|
||||
function blankClause(): ConditionalMechanicClauseDraft {
|
||||
const number = rule.clauses.length + 1;
|
||||
return {
|
||||
key: `branch-${number}`,
|
||||
label: `Branch ${number}`,
|
||||
event: 'weapon_hit',
|
||||
if: { kind: 'true' },
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: []
|
||||
};
|
||||
}
|
||||
|
||||
function blankOutcome(index: number): ConditionalMechanicOutcomeDraft {
|
||||
return {
|
||||
key: `effect-${index + 1}`,
|
||||
label: `Effect ${index + 1}`,
|
||||
phase: 'while_active',
|
||||
kind: 'attribute_modifier',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
perStack: true,
|
||||
descriptionSlot: index + 1,
|
||||
attributeId: '',
|
||||
variants: Object.fromEntries(valueColumns.map((column) => [
|
||||
column.variantKey,
|
||||
{ value: 0 }
|
||||
]))
|
||||
};
|
||||
}
|
||||
|
||||
function updateClause(index: number, patch: Partial<ConditionalMechanicClauseDraft>) {
|
||||
const clauses = [...rule.clauses];
|
||||
clauses[index] = { ...clauses[index], ...patch };
|
||||
setRule({ ...rule, clauses });
|
||||
}
|
||||
|
||||
function reducerKind(index: number, kind: string) {
|
||||
const reducer: Reducer =
|
||||
kind === 'count' ? { kind: 'count', threshold: 1 }
|
||||
: kind === 'distinct_count' ? { kind: 'distinct_count', threshold: 1, windowSeconds: 1, distinctBy: { source: 'event', path: 'targetId' } }
|
||||
: kind === 'consecutive_count' ? { kind: 'consecutive_count', threshold: 1, groupBy: { source: 'event', path: 'targetId' } }
|
||||
: kind === 'sliding_count' ? { kind: 'sliding_count', threshold: 1, windowSeconds: 1 }
|
||||
: kind === 'duration' ? { kind: 'duration', seconds: 1 }
|
||||
: kind === 'periodic' ? { kind: 'periodic', intervalSeconds: 1 }
|
||||
: kind === 'derived_scalar' ? { kind: 'derived_scalar', value: 1 }
|
||||
: { kind: 'none' };
|
||||
updateClause(index, { reducer });
|
||||
}
|
||||
|
||||
function setReducerNumber(index: number, property: string, raw: string) {
|
||||
const value = Number(raw);
|
||||
const reducer = { ...rule.clauses[index].reducer, [property]: Number.isFinite(value) ? value : 0 } as Reducer;
|
||||
updateClause(index, { reducer });
|
||||
}
|
||||
|
||||
function updateOutcome(clauseIndex: number, outcomeIndex: number, patch: Partial<ConditionalMechanicOutcomeDraft>) {
|
||||
const outcomes = [...rule.clauses[clauseIndex].outcomes];
|
||||
outcomes[outcomeIndex] = { ...outcomes[outcomeIndex], ...patch };
|
||||
updateClause(clauseIndex, { outcomes });
|
||||
}
|
||||
|
||||
function updateVariant(clauseIndex: number, outcomeIndex: number, variant: string, raw: string) {
|
||||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||||
const value = Number(raw);
|
||||
const previous = outcome.variants[variant]?.expression;
|
||||
updateOutcome(clauseIndex, outcomeIndex, {
|
||||
variants: {
|
||||
...outcome.variants,
|
||||
[variant]: {
|
||||
...outcome.variants[variant],
|
||||
value,
|
||||
...(previous === undefined ? {} : { expression: replaceExpressionBase(previous, value) })
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type OutcomeValueModel =
|
||||
| 'static'
|
||||
| 'event'
|
||||
| 'reducer'
|
||||
| 'scenario'
|
||||
| 'protocol_upgrades'
|
||||
| 'advanced';
|
||||
|
||||
const valueModelOptions = $derived([
|
||||
{ value: 'static', label: 'STATIC' },
|
||||
{ value: 'event', label: 'EVENT FIELD' },
|
||||
{ value: 'reducer', label: 'REDUCER' },
|
||||
{ value: 'scenario', label: 'SCENARIO' },
|
||||
...(allowProtocolUpgradeScaling
|
||||
? [{ value: 'protocol_upgrades', label: 'PROTOCOL UPGRADES' }]
|
||||
: [])
|
||||
]);
|
||||
const eventValueOptions = [
|
||||
{ value: 'values.enemyCount', label: 'ENEMY COUNT' },
|
||||
{ value: 'values.allyCount', label: 'ALLY COUNT' },
|
||||
{ value: 'values.magazineRemaining', label: 'ROUNDS LEFT' },
|
||||
{ value: 'values.magazinePercent', label: 'MAGAZINE %' },
|
||||
{ value: 'values.distanceMeters', label: 'DISTANCE' }
|
||||
];
|
||||
|
||||
function referenceExpression(source: 'event' | 'scenario', path: string, value: number): NumericExpression {
|
||||
return {
|
||||
op: 'multiply',
|
||||
left: value,
|
||||
right: {
|
||||
ref: source === 'event'
|
||||
? { source, path }
|
||||
: { source, key: path }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function protocolUpgradeExpression(value: number): NumericExpression {
|
||||
return {
|
||||
op: 'multiply',
|
||||
left: value,
|
||||
right: {
|
||||
op: 'add',
|
||||
left: 1,
|
||||
right: {
|
||||
op: 'divide',
|
||||
left: { ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
|
||||
right: 5
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function outcomeValueModel(outcome: ConditionalMechanicOutcomeDraft): OutcomeValueModel {
|
||||
const expression = Object.values(outcome.variants)[0]?.expression;
|
||||
if (expression === undefined) return 'static';
|
||||
if (
|
||||
typeof expression === 'object'
|
||||
&& 'op' in expression
|
||||
&& expression.op === 'multiply'
|
||||
&& typeof expression.left === 'number'
|
||||
&& typeof expression.right === 'object'
|
||||
) {
|
||||
if ('ref' in expression.right) {
|
||||
if (expression.right.ref.source === 'event') return 'event';
|
||||
if (expression.right.ref.source === 'scenario') return 'scenario';
|
||||
if (expression.right.ref.source === 'reducer') return 'reducer';
|
||||
}
|
||||
if (
|
||||
'op' in expression.right
|
||||
&& expression.right.op === 'add'
|
||||
&& typeof expression.right.right === 'object'
|
||||
&& 'op' in expression.right.right
|
||||
&& expression.right.right.op === 'divide'
|
||||
&& typeof expression.right.right.left === 'object'
|
||||
&& 'ref' in expression.right.right.left
|
||||
&& expression.right.right.left.ref.source === 'equipment'
|
||||
&& expression.right.right.left.ref.path === 'protocolTalentUpgrades'
|
||||
) return 'protocol_upgrades';
|
||||
}
|
||||
return 'advanced';
|
||||
}
|
||||
|
||||
function outcomeReference(outcome: ConditionalMechanicOutcomeDraft, source: 'event' | 'scenario') {
|
||||
const expression = Object.values(outcome.variants)[0]?.expression;
|
||||
if (
|
||||
expression
|
||||
&& typeof expression === 'object'
|
||||
&& 'op' in expression
|
||||
&& expression.op === 'multiply'
|
||||
&& typeof expression.right === 'object'
|
||||
&& 'ref' in expression.right
|
||||
&& expression.right.ref.source === source
|
||||
) {
|
||||
if (expression.right.ref.source === 'event') return expression.right.ref.path;
|
||||
if (expression.right.ref.source === 'scenario') return expression.right.ref.key;
|
||||
}
|
||||
return source === 'event' ? 'values.enemyCount' : rule.scenarioInputs[0]?.key ?? '';
|
||||
}
|
||||
|
||||
function expressionForModel(
|
||||
model: OutcomeValueModel,
|
||||
value: number,
|
||||
reference = ''
|
||||
): NumericExpression | undefined {
|
||||
if (model === 'event') return referenceExpression('event', reference || 'values.enemyCount', value);
|
||||
if (model === 'scenario') return reference
|
||||
? referenceExpression('scenario', reference, value)
|
||||
: undefined;
|
||||
if (model === 'reducer') {
|
||||
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
||||
}
|
||||
if (model === 'protocol_upgrades') return protocolUpgradeExpression(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function setOutcomeValueModel(
|
||||
clauseIndex: number,
|
||||
outcomeIndex: number,
|
||||
model: OutcomeValueModel,
|
||||
reference = ''
|
||||
) {
|
||||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||||
const variants = Object.fromEntries(Object.entries(outcome.variants).map(([key, variant]) => {
|
||||
const expression = expressionForModel(model, Number(variant.value), reference);
|
||||
return [key, expression === undefined ? { value: Number(variant.value) } : {
|
||||
value: Number(variant.value),
|
||||
expression
|
||||
}];
|
||||
}));
|
||||
updateOutcome(clauseIndex, outcomeIndex, { variants });
|
||||
}
|
||||
|
||||
function replaceExpressionBase(expression: NumericExpression, value: number): NumericExpression {
|
||||
const model = outcomeValueModel({
|
||||
key: 'temporary',
|
||||
label: 'Temporary',
|
||||
phase: 'on_event',
|
||||
kind: 'marker',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
variants: { base: { value, expression } }
|
||||
});
|
||||
if (model === 'event') {
|
||||
return referenceExpression('event', (
|
||||
expression as { right: { ref: { path: string } } }
|
||||
).right.ref.path, value);
|
||||
}
|
||||
if (model === 'scenario') {
|
||||
return referenceExpression('scenario', (
|
||||
expression as { right: { ref: { key: string } } }
|
||||
).right.ref.key, value);
|
||||
}
|
||||
if (model === 'reducer') {
|
||||
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
||||
}
|
||||
if (model === 'protocol_upgrades') return protocolUpgradeExpression(value);
|
||||
return expression;
|
||||
}
|
||||
|
||||
function addMutation(clauseIndex: number) {
|
||||
const pool = rule.pools[0]?.key;
|
||||
if (!pool) return;
|
||||
updateClause(clauseIndex, {
|
||||
mutations: [...rule.clauses[clauseIndex].mutations, { kind: 'add', pool, value: 1 }]
|
||||
});
|
||||
}
|
||||
|
||||
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'clear', pool?: string, value?: number) {
|
||||
const mutations = [...rule.clauses[clauseIndex].mutations];
|
||||
const current = mutations[mutationIndex];
|
||||
const nextPool = pool ?? ('pool' in current ? current.pool : rule.pools[0]?.key ?? '');
|
||||
mutations[mutationIndex] = kind === 'clear'
|
||||
? { kind, pool: nextPool }
|
||||
: { kind, pool: nextPool, value: value ?? ('value' in current && typeof current.value === 'number' ? current.value : 1) };
|
||||
updateClause(clauseIndex, { mutations });
|
||||
}
|
||||
|
||||
function updatePool(index: number, patch: Partial<ConditionalMechanicPoolDraft>) {
|
||||
const pools = [...rule.pools];
|
||||
pools[index] = { ...pools[index], ...patch };
|
||||
setRule({ ...rule, pools });
|
||||
}
|
||||
|
||||
function updateScenarioInput(index: number, patch: Partial<ScenarioInputDefinition>) {
|
||||
const scenarioInputs = [...rule.scenarioInputs];
|
||||
scenarioInputs[index] = { ...scenarioInputs[index], ...patch };
|
||||
setRule({ ...rule, scenarioInputs });
|
||||
}
|
||||
|
||||
function addScenarioInput() {
|
||||
const number = rule.scenarioInputs.length + 1;
|
||||
setRule({
|
||||
...rule,
|
||||
scenarioInputs: [
|
||||
...rule.scenarioInputs,
|
||||
{
|
||||
key: `assumption-${number}`,
|
||||
label: `Assumption ${number}`,
|
||||
kind: 'slider',
|
||||
readiness: 'requires_assumption',
|
||||
description: '',
|
||||
defaultValue: 0,
|
||||
bestCaseValue: 100,
|
||||
min: 0,
|
||||
max: 100
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
const validation = $derived(parseConditionalMechanicDraft(rule, {
|
||||
variantKeys: valueColumns.map((column) => column.variantKey),
|
||||
allowedAttributeIds: new Set(attributes.map((attribute) => attribute.id))
|
||||
}));
|
||||
const scriptLines = $derived(validation.ok
|
||||
? formatMechanicProgram(validation.value.programs[valueColumns[0]?.variantKey]).split('\n')
|
||||
: ['MECHANIC INCOMPLETE', '', ...validation.errors.map((error) => `ERROR ${error}`)]);
|
||||
const readinessLabel = $derived(
|
||||
rule.simulationReadiness === 'ready'
|
||||
? 'READY'
|
||||
: rule.simulationReadiness === 'requires_assumptions'
|
||||
? 'REQUIRES ASSUMPTIONS'
|
||||
: 'CAPTURED ONLY'
|
||||
);
|
||||
</script>
|
||||
|
||||
<fieldset class="mechanic-editor" {disabled}>
|
||||
<legend>MECHANIC PROGRAM</legend>
|
||||
|
||||
<div class="mechanic-heading">
|
||||
<div>
|
||||
<span>STRUCTURED COMBAT LOGIC</span>
|
||||
<strong>{rule.clauses.length} {rule.clauses.length === 1 ? 'BRANCH' : 'BRANCHES'}</strong>
|
||||
<p>Events are code-defined. Every branch, condition, state change, and outcome belongs to this item.</p>
|
||||
</div>
|
||||
<MechanicLanguageHelp />
|
||||
</div>
|
||||
|
||||
<SegmentedChoice
|
||||
legend="SIMULATION READINESS"
|
||||
name={`${idPrefix}-readiness`}
|
||||
options={readinessOptions}
|
||||
bind:value={rule.simulationReadiness}
|
||||
/>
|
||||
|
||||
<section class="scenario-panel">
|
||||
<header><div><span>BUILDER CONTROLS</span><strong>SCENARIO ASSUMPTIONS</strong></div><button type="button" class="secondary compact-action" onclick={addScenarioInput}>ADD CONTROL +</button></header>
|
||||
<p>These controls appear in a build’s advanced mechanic settings. Maximum Potential uses the best-case value; Custom Scenario uses the Agent’s saved value.</p>
|
||||
{#each rule.scenarioInputs as input, index}
|
||||
<div class="scenario-row">
|
||||
<label class="field">LABEL<input value={input.label} oninput={(event) => updateScenarioInput(index,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
|
||||
<SegmentedChoice legend="CONTROL TYPE" name={`${idPrefix}-scenario-kind-${index}`} options={[{value:'toggle',label:'TOGGLE'},{value:'slider',label:'SLIDER'},{value:'number',label:'NUMBER'}]} value={input.kind} onchange={(values) => updateScenarioInput(index,{kind:values[0] as ScenarioInputDefinition['kind'],defaultValue:values[0] === 'toggle' ? false : Number(input.defaultValue ?? 0),bestCaseValue:values[0] === 'toggle' ? true : Number(input.bestCaseValue ?? 100),min:values[0] === 'toggle' ? undefined : input.min ?? 0,max:values[0] === 'toggle' ? undefined : input.max ?? 100})}/>
|
||||
<label class="field description-field">AGENT GUIDANCE<input value={input.description ?? ''} placeholder="Explain what the Agent should estimate." oninput={(event) => updateScenarioInput(index,{description:event.currentTarget.value})}/></label>
|
||||
{#if input.kind === 'toggle'}
|
||||
<SegmentedChoice legend="DEFAULT" name={`${idPrefix}-scenario-default-${index}`} options={[{value:'false',label:'OFF'},{value:'true',label:'ON'}]} value={String(input.defaultValue ?? false)} onchange={(values) => updateScenarioInput(index,{defaultValue:values[0] === 'true'})}/>
|
||||
<SegmentedChoice legend="BEST CASE" name={`${idPrefix}-scenario-best-${index}`} options={[{value:'false',label:'OFF'},{value:'true',label:'ON'}]} value={String(input.bestCaseValue ?? true)} onchange={(values) => updateScenarioInput(index,{bestCaseValue:values[0] === 'true'})}/>
|
||||
{:else}
|
||||
<label class="field">MIN<input type="number" step="any" value={input.min ?? 0} oninput={(event) => updateScenarioInput(index,{min:Number(event.currentTarget.value)})}/></label>
|
||||
<label class="field">MAX<input type="number" step="any" value={input.max ?? 100} oninput={(event) => updateScenarioInput(index,{max:Number(event.currentTarget.value)})}/></label>
|
||||
<label class="field">DEFAULT<input type="number" step="any" value={Number(input.defaultValue ?? 0)} oninput={(event) => updateScenarioInput(index,{defaultValue:Number(event.currentTarget.value)})}/></label>
|
||||
<label class="field">BEST CASE<input type="number" step="any" value={Number(input.bestCaseValue ?? input.max ?? 100)} oninput={(event) => updateScenarioInput(index,{bestCaseValue:Number(event.currentTarget.value)})}/></label>
|
||||
{/if}
|
||||
<button type="button" class="remove" onclick={() => setRule({...rule,scenarioInputs:rule.scenarioInputs.filter((_,current) => current !== index)})}>REMOVE</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-state">No source-specific assumptions. This mechanic uses only the shared encounter timeline.</p>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section class="recipe-panel">
|
||||
<header><div><span>RECIPE STARTER</span><strong>SEED AN EDITABLE BRANCH</strong></div><small>Recipes copy typed data into this item; they are not shared runtime dependencies.</small></header>
|
||||
<div class="recipe-grid">
|
||||
<SearchableAssignment
|
||||
items={recipeItems}
|
||||
mode="single"
|
||||
compact
|
||||
subjectLabel="RECIPE"
|
||||
bind:value={recipeKey}
|
||||
/>
|
||||
{#if ['same_target_hits_within_window', 'distinct_enemies_within_window', 'consecutive_same_target_hits'].includes(recipeKey)}
|
||||
<label class="field">COUNT<input type="number" min="1" step="1" bind:value={recipeCount}/></label>
|
||||
{/if}
|
||||
{#if ['same_target_hits_within_window', 'distinct_enemies_within_window', 'continuous_firing_duration'].includes(recipeKey)}
|
||||
<label class="field">SECONDS<input type="number" min="0.01" step="any" bind:value={recipeWindow}/></label>
|
||||
{/if}
|
||||
{#if ['target_hit_within_distance', 'within_distance'].includes(recipeKey)}
|
||||
<label class="field">METERS<input type="number" min="0" step="any" bind:value={recipeDistance}/></label>
|
||||
{/if}
|
||||
<button type="button" class="secondary" onclick={() => applyRecipe(false)}>ADD RECIPE BRANCH +</button>
|
||||
{#if rule.clauses.length}
|
||||
<button type="button" class="danger-quiet" onclick={() => replaceConfirmation = true}>REPLACE PROGRAM</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if replaceConfirmation}
|
||||
<div class="replace-confirm" role="alert">
|
||||
<p><strong>REPLACE ALL CURRENT BRANCHES?</strong><span>This keeps no existing program data.</span></p>
|
||||
<div><button type="button" class="secondary" onclick={() => replaceConfirmation = false}>CANCEL</button><button type="button" class="primary" onclick={() => applyRecipe(true)}>CONFIRM REPLACE</button></div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="state-panel">
|
||||
<header><div><span>STATE</span><strong>POOLS</strong></div><button type="button" class="secondary compact-action" onclick={() => setRule({...rule,pools:[...rule.pools,{key:`state-${rule.pools.length + 1}`,label:`State ${rule.pools.length + 1}`,stateKind:'stack',scope:'agent',initial:0,min:0,max:1,bestCaseValue:1}]})}>ADD POOL +</button></header>
|
||||
{#each rule.pools as pool, index}
|
||||
<div class="pool-row">
|
||||
<label class="field">LABEL<input value={pool.label} oninput={(event) => updatePool(index,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
|
||||
<SegmentedChoice legend="SCOPE" name={`${idPrefix}-pool-scope-${index}`} options={[{value:'agent',label:'AGENT'},{value:'target',label:'TARGET'}]} value={pool.scope} onchange={(values) => updatePool(index,{scope:values[0] as 'agent'|'target'})}/>
|
||||
<label class="field">INITIAL<input type="number" value={pool.initial} oninput={(event) => updatePool(index,{initial:Number(event.currentTarget.value)})}/></label>
|
||||
<label class="field">MAX<input type="number" min="1" value={pool.max ?? 1} oninput={(event) => updatePool(index,{max:Number(event.currentTarget.value)})}/></label>
|
||||
<label class="field">BEST CASE<input type="number" value={pool.bestCaseValue ?? pool.max ?? 1} oninput={(event) => updatePool(index,{bestCaseValue:Number(event.currentTarget.value)})}/></label>
|
||||
<label class="field">EXPIRY · SECONDS<input type="number" min="0.01" step="any" value={pool.expiresAfterSeconds ?? ''} oninput={(event) => updatePool(index,{expiresAfterSeconds:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
|
||||
<button type="button" class="remove" onclick={() => setRule({...rule,pools:rule.pools.filter((_,current) => current !== index)})}>REMOVE</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-state">No state pools. Add one for stacks, timers, counters, resources, or target marks.</p>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<div class="branch-stack">
|
||||
{#each rule.clauses as clause, clauseIndex}
|
||||
<section class="branch">
|
||||
<header>
|
||||
<div><span>{String(clauseIndex + 1).padStart(2, '0')}</span><label class="field">BRANCH LABEL<input value={clause.label ?? ''} oninput={(event) => updateClause(clauseIndex,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label></div>
|
||||
<button type="button" class="remove" onclick={() => setRule({...rule,clauses:rule.clauses.filter((_,index) => index !== clauseIndex)})}>REMOVE BRANCH</button>
|
||||
</header>
|
||||
<div class="branch-grid">
|
||||
<div class="field"><span>DIRECT EVENT</span><SearchableAssignment items={eventItems} mode="single" compact subjectLabel="EVENT" bind:value={clause.event}/></div>
|
||||
<div class="branch-readout"><span>MATCH POLICY</span><strong>{reducerItems.find((item) => item.id === clause.reducer.kind)?.name ?? clause.reducer.kind}</strong></div>
|
||||
<label class="field">COOLDOWN · SECONDS<input type="number" min="0.01" step="any" value={clause.cooldownSeconds ?? ''} oninput={(event) => updateClause(clauseIndex,{cooldownSeconds:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
|
||||
</div>
|
||||
<div class="reducer-controls">
|
||||
<SegmentedChoice
|
||||
legend="MATCH POLICY"
|
||||
name={`${idPrefix}-reducer-${clauseIndex}`}
|
||||
options={reducerItems.map((item) => ({value:item.id,label:item.name.replace(' matches','').replace(' within a time window',' window').toUpperCase()}))}
|
||||
value={clause.reducer.kind}
|
||||
columns={4}
|
||||
onchange={(values) => reducerKind(clauseIndex,values[0])}
|
||||
/>
|
||||
{#if ['count','distinct_count','consecutive_count','sliding_count'].includes(clause.reducer.kind)}
|
||||
<label class="field">THRESHOLD<input type="number" min="1" step="1" value={'threshold' in clause.reducer ? clause.reducer.threshold : 1} oninput={(event) => setReducerNumber(clauseIndex,'threshold',event.currentTarget.value)}/></label>
|
||||
{/if}
|
||||
{#if ['distinct_count','sliding_count'].includes(clause.reducer.kind)}
|
||||
<label class="field">WINDOW · SECONDS<input type="number" min="0.01" step="any" value={'windowSeconds' in clause.reducer ? clause.reducer.windowSeconds : 1} oninput={(event) => setReducerNumber(clauseIndex,'windowSeconds',event.currentTarget.value)}/></label>
|
||||
{/if}
|
||||
{#if clause.reducer.kind === 'duration'}
|
||||
<label class="field">DURATION · SECONDS<input type="number" min="0.01" step="any" value={clause.reducer.seconds} oninput={(event) => setReducerNumber(clauseIndex,'seconds',event.currentTarget.value)}/></label>
|
||||
{:else if clause.reducer.kind === 'periodic'}
|
||||
<label class="field">INTERVAL · SECONDS<input type="number" min="0.01" step="any" value={typeof clause.reducer.intervalSeconds === 'number' ? clause.reducer.intervalSeconds : 1} oninput={(event) => setReducerNumber(clauseIndex,'intervalSeconds',event.currentTarget.value)}/></label>
|
||||
{:else if clause.reducer.kind === 'derived_scalar'}
|
||||
<label class="field">DERIVED VALUE<input type="number" step="any" value={typeof clause.reducer.value === 'number' ? clause.reducer.value : 1} oninput={(event) => setReducerNumber(clauseIndex,'value',event.currentTarget.value)}/></label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mutation-panel">
|
||||
<div class="outcome-heading"><div><span>STATE CHANGES</span><strong>{clause.mutations.length}</strong></div><button type="button" class="secondary compact-action" onclick={() => addMutation(clauseIndex)} disabled={!rule.pools.length}>ADD STATE CHANGE +</button></div>
|
||||
{#each clause.mutations as mutation, mutationIndex}
|
||||
<div class="mutation-row">
|
||||
<SegmentedChoice
|
||||
legend="OPERATION"
|
||||
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
|
||||
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
|
||||
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
|
||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
|
||||
/>
|
||||
<SegmentedChoice
|
||||
legend="STATE POOL"
|
||||
name={`${idPrefix}-mutation-pool-${clauseIndex}-${mutationIndex}`}
|
||||
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
|
||||
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
|
||||
columns={Math.min(4,Math.max(1,rule.pools.length))}
|
||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
|
||||
/>
|
||||
{#if mutation.kind !== 'clear'}
|
||||
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
|
||||
{/if}
|
||||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-state">This branch emits outcomes without changing shared state.</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="outcome-heading"><div><span>OUTCOMES</span><strong>{clause.outcomes.length}</strong></div><button type="button" class="secondary compact-action" onclick={() => updateClause(clauseIndex,{outcomes:[...clause.outcomes,blankOutcome(clause.outcomes.length)]})}>ADD OUTCOME +</button></div>
|
||||
{#each clause.outcomes as outcome, outcomeIndex}
|
||||
<div class="outcome-card">
|
||||
<div class="outcome-grid">
|
||||
<label class="field">LABEL<input value={outcome.label} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
|
||||
<SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{kind:values[0] as ConditionalMechanicOutcomeDraft['kind'],resource:values[0] === 'restoration' || values[0] === 'resource_cost' ? outcome.resource ?? 'health' : undefined,attributeId:values[0] === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined})}/>
|
||||
<label class="field">DESCRIPTION SLOT<input type="number" min="1" step="1" value={outcome.descriptionSlot ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{descriptionSlot:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
|
||||
</div>
|
||||
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{scope:values[0] as MechanicOutcomeScope})}/>
|
||||
{#if outcome.kind === 'attribute_modifier'}
|
||||
<SearchableAssignment items={attributes} mode="single" compact subjectLabel="ATTRIBUTE" bind:value={outcome.attributeId}/>
|
||||
{:else if outcome.kind === 'restoration' || outcome.kind === 'resource_cost'}
|
||||
<SegmentedChoice legend="RESOURCE" name={`${idPrefix}-resource-${clauseIndex}-${outcomeIndex}`} options={[{value:'health',label:'HEALTH'},{value:'armor',label:'ARMOR'},{value:'ammo',label:'AMMO'}]} value={outcome.resource ?? 'health'} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{resource:values[0] as 'health'|'armor'|'ammo'})}/>
|
||||
{/if}
|
||||
{#if outcome.kind === 'direct_damage' || outcome.kind === 'restoration' || outcome.kind === 'resource_cost'}
|
||||
<SegmentedChoice legend="VALUE BASIS" name={`${idPrefix}-basis-${clauseIndex}-${outcomeIndex}`} options={[{value:'flat',label:'FLAT'},{value:'max_health_percent',label:'MAX HEALTH %'},{value:'current_health_percent',label:'CURRENT HEALTH %'},{value:'weapon_base_percent',label:'WEAPON BASE %'}]} value={outcome.basis} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{basis:values[0] as OutcomeBasis})}/>
|
||||
{/if}
|
||||
<SegmentedChoice
|
||||
legend="VALUE MODEL"
|
||||
name={`${idPrefix}-value-model-${clauseIndex}-${outcomeIndex}`}
|
||||
options={outcomeValueModel(outcome) === 'advanced' ? [...valueModelOptions,{value:'advanced',label:'ADVANCED',disabled:true}] : valueModelOptions}
|
||||
value={outcomeValueModel(outcome)}
|
||||
columns={3}
|
||||
onchange={(values) => setOutcomeValueModel(clauseIndex,outcomeIndex,values[0] as OutcomeValueModel,values[0] === 'event' ? 'values.enemyCount' : values[0] === 'scenario' ? rule.scenarioInputs[0]?.key ?? '' : '')}
|
||||
/>
|
||||
{#if outcomeValueModel(outcome) === 'event'}
|
||||
<SegmentedChoice
|
||||
legend="EVENT FIELD"
|
||||
name={`${idPrefix}-event-value-${clauseIndex}-${outcomeIndex}`}
|
||||
options={eventValueOptions}
|
||||
value={outcomeReference(outcome,'event')}
|
||||
columns={3}
|
||||
onchange={(values) => setOutcomeValueModel(clauseIndex,outcomeIndex,'event',values[0])}
|
||||
/>
|
||||
{:else if outcomeValueModel(outcome) === 'scenario' && rule.scenarioInputs.length}
|
||||
<SegmentedChoice
|
||||
legend="SCENARIO CONTROL"
|
||||
name={`${idPrefix}-scenario-value-${clauseIndex}-${outcomeIndex}`}
|
||||
options={rule.scenarioInputs.map((input) => ({value:input.key,label:input.label.toUpperCase()}))}
|
||||
value={outcomeReference(outcome,'scenario')}
|
||||
columns={Math.min(4,rule.scenarioInputs.length)}
|
||||
onchange={(values) => setOutcomeValueModel(clauseIndex,outcomeIndex,'scenario',values[0])}
|
||||
/>
|
||||
{/if}
|
||||
{#if rule.pools.length}
|
||||
<div class="state-scaling">
|
||||
<SegmentedChoice
|
||||
legend="STATE POOL"
|
||||
name={`${idPrefix}-outcome-pool-${clauseIndex}-${outcomeIndex}`}
|
||||
options={[{value:'',label:'NONE'},...rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))]}
|
||||
value={outcome.statePool ?? ''}
|
||||
columns={Math.min(4,rule.pools.length + 1)}
|
||||
onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{statePool:values[0] || undefined,perStack:values[0] ? outcome.perStack ?? true : false,minStacks:values[0] ? outcome.minStacks : undefined,maxStacks:values[0] ? outcome.maxStacks : undefined})}
|
||||
/>
|
||||
{#if outcome.statePool}
|
||||
<SegmentedChoice legend="SCALING" name={`${idPrefix}-outcome-scaling-${clauseIndex}-${outcomeIndex}`} options={[{value:'false',label:'FLAT'},{value:'true',label:'PER STACK'}]} value={String(outcome.perStack ?? true)} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{perStack:values[0] === 'true'})}/>
|
||||
<label class="field">MIN STACK<input type="number" min="0" step="1" value={outcome.minStacks ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{minStacks:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
|
||||
<label class="field">MAX STACK<input type="number" min="0" step="1" value={outcome.maxStacks ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{maxStacks:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="variant-grid">
|
||||
{#each valueColumns as column}
|
||||
<label class="field">{column.label} VALUE<input type="number" step="any" value={outcome.variants[column.variantKey]?.value ?? 0} oninput={(event) => updateVariant(clauseIndex,outcomeIndex,column.variantKey,event.currentTarget.value)}/></label>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="outcome-footer">
|
||||
<SegmentedChoice legend="PHASE" name={`${idPrefix}-phase-${clauseIndex}-${outcomeIndex}`} options={[{value:'on_event',label:'ON EVENT'},{value:'after_event',label:'AFTER EVENT'},{value:'while_active',label:'WHILE ACTIVE'},{value:'on_enter',label:'ON ENTER'},{value:'on_exit',label:'ON EXIT'}]} value={outcome.phase} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{phase:values[0] as OutcomePhase})}/>
|
||||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{outcomes:clause.outcomes.filter((_,index) => index !== outcomeIndex)})}>REMOVE OUTCOME</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-state">This branch has no outcomes yet.</p>
|
||||
{/each}
|
||||
</section>
|
||||
{/each}
|
||||
<button type="button" class="secondary add-branch" onclick={() => setRule({...rule,clauses:[...rule.clauses,blankClause()]})}>ADD DIRECT EVENT BRANCH +</button>
|
||||
</div>
|
||||
|
||||
<section class="script-preview">
|
||||
<div><span>GENERATED MECHANIC</span><strong>READ-ONLY PSEUDO-SCRIPT</strong><p>The server validates the same typed structure for every configured value variant.</p></div>
|
||||
<PseudoScript title="CURRENT MECHANIC PROGRAM" lines={scriptLines} readiness={readinessLabel}/>
|
||||
</section>
|
||||
</fieldset>
|
||||
|
||||
<style>
|
||||
.mechanic-editor{display:grid;gap:var(--shd-gap-sm);margin:0;padding:var(--shd-gap-sm);border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-orange);background:var(--shd-model-surface)}
|
||||
.mechanic-editor>legend{padding:0 7px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.2px}
|
||||
.mechanic-heading,.recipe-panel>header,.scenario-panel>header,.state-panel>header,.branch>header,.outcome-heading{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm)}
|
||||
.mechanic-heading{padding:8px 10px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}
|
||||
.mechanic-heading>div,.recipe-panel>header>div,.scenario-panel>header>div,.state-panel>header>div,.outcome-heading>div{display:grid;gap:2px}
|
||||
.mechanic-heading span,.recipe-panel header span,.scenario-panel header span,.state-panel header span,.outcome-heading span{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.9px}
|
||||
.mechanic-heading p,.recipe-panel header small,.empty-state,.script-preview>div p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
|
||||
.recipe-panel,.scenario-panel,.state-panel,.branch{border:1px solid var(--shd-border);background:var(--shd-surface)}
|
||||
.recipe-panel>header,.scenario-panel>header,.state-panel>header,.branch>header,.outcome-heading{min-height:42px;padding:7px 9px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}
|
||||
.scenario-panel>p{margin:0;padding:8px 9px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4;border-bottom:1px solid var(--shd-border-soft)}
|
||||
.scenario-row{display:grid;grid-template-columns:minmax(170px,1fr) minmax(260px,1.25fr) minmax(220px,1.3fr) repeat(4,minmax(85px,.45fr)) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}
|
||||
.scenario-row>.field{margin:0}.scenario-row>.description-field{min-width:0}
|
||||
.recipe-grid{display:grid;grid-template-columns:minmax(260px,1fr) repeat(3,minmax(110px,.35fr)) auto auto;align-items:end;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}
|
||||
.recipe-grid>.field,.pool-row>.field,.branch-grid>.field,.outcome-grid>.field,.variant-grid>.field{margin:0}
|
||||
.replace-confirm{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);padding:9px;border-top:1px solid var(--shd-danger);background:color-mix(in srgb,var(--shd-danger) 10%,var(--shd-surface))}
|
||||
.replace-confirm p{display:grid;gap:2px;margin:0}.replace-confirm span{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.replace-confirm>div{display:flex;gap:7px}
|
||||
.pool-row{display:grid;grid-template-columns:1.2fr .8fr repeat(4,.55fr) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}
|
||||
.branch-stack{display:grid;gap:var(--shd-gap-sm)}
|
||||
.branch>header>div{display:flex;align-items:end;gap:9px}.branch>header>div>span{align-self:center;color:var(--shd-orange);font:900 var(--shd-type-telemetry)/1 var(--shd-font-mono)}.branch>header .field{min-width:min(360px,55vw);margin:0}
|
||||
.branch-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr)) minmax(160px,.45fr);align-items:end;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}
|
||||
.field>span{display:block;margin-bottom:5px;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
|
||||
.branch-readout{display:grid;align-content:center;gap:3px;min-height:44px;padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}.branch-readout span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.branch-readout strong{font-size:var(--shd-type-supporting)}
|
||||
.reducer-controls{display:grid;grid-template-columns:minmax(0,1fr) repeat(2,minmax(130px,.25fr));align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)}
|
||||
.mutation-panel{margin:0 var(--shd-gap-sm) var(--shd-gap-sm);border:1px solid var(--shd-border-soft)}
|
||||
.mutation-row{display:grid;grid-template-columns:minmax(300px,1fr) minmax(260px,1fr) minmax(110px,.35fr) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}
|
||||
.outcome-card{display:grid;gap:8px;margin:var(--shd-gap-sm);padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
|
||||
.outcome-grid{display:grid;grid-template-columns:minmax(200px,1fr) minmax(360px,1.4fr) minmax(120px,.3fr);align-items:end;gap:8px}
|
||||
.variant-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px}
|
||||
.state-scaling{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr) repeat(2,minmax(110px,.3fr));align-items:end;gap:8px}
|
||||
.outcome-footer{display:flex;align-items:end;justify-content:space-between;gap:8px}
|
||||
.remove,.danger-quiet{min-height:38px;padding:0 10px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}
|
||||
.remove:hover,.remove:focus-visible,.danger-quiet:hover,.danger-quiet:focus-visible{background:var(--shd-danger);color:var(--shd-surface)}
|
||||
.compact-action{min-height:36px}.add-branch{min-height:44px}.empty-state{padding:10px}.script-preview{display:grid;grid-template-columns:minmax(190px,.28fr) minmax(0,1fr);gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);border-top:2px solid var(--shd-success);background:var(--shd-surface)}
|
||||
.script-preview>div{display:grid;align-content:start;gap:3px}.script-preview>div span{color:var(--shd-success);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
|
||||
@media(max-width:1300px){.scenario-row{grid-template-columns:repeat(2,minmax(0,1fr))}.scenario-row>.description-field{grid-column:1/-1}}
|
||||
@media(max-width:1000px){.recipe-grid,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.script-preview{grid-template-columns:1fr 1fr}.recipe-grid>:first-child,.script-preview>:last-child{grid-column:1/-1}.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:1/-1}}
|
||||
@media(max-width:620px){.mechanic-heading,.recipe-panel>header,.scenario-panel>header,.state-panel>header,.branch>header,.outcome-heading,.replace-confirm,.outcome-footer{align-items:stretch;flex-direction:column}.recipe-grid,.scenario-row,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.script-preview{grid-template-columns:1fr}.recipe-grid>:first-child,.script-preview>:last-child,.scenario-row>.description-field,.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:auto}.branch>header>div{align-items:stretch;flex-direction:column}.branch>header .field{min-width:0}.remove,.danger-quiet,.compact-action,.add-branch{width:100%;min-height:44px}}
|
||||
@media(prefers-reduced-motion:reduce){.remove,.danger-quiet{transition:none}}
|
||||
</style>
|
||||
@@ -30,7 +30,6 @@ export async function auditRecord(table: string, id: string): Promise<Record<str
|
||||
if (![
|
||||
'stat_attributes',
|
||||
'talents',
|
||||
'talent_triggers',
|
||||
'brand_sets',
|
||||
'gear',
|
||||
'weapons',
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { conditionalRuleDraftFromRows } from './conditional-effects.js';
|
||||
|
||||
const rule = {
|
||||
id: 'rule-1',
|
||||
simulation_readiness: 'simulation_ready',
|
||||
scenario_inputs_json: '[]'
|
||||
};
|
||||
|
||||
const clause = {
|
||||
id: 'clause-1',
|
||||
rule_id: 'rule-1',
|
||||
clause_key: 'hit',
|
||||
label: 'Hit',
|
||||
event_kind: 'weapon_hit',
|
||||
conditions_json: '{"kind":"true"}',
|
||||
reducer_json: '{"kind":"none"}',
|
||||
mutations_json: '[]',
|
||||
sort_order: 1,
|
||||
active: 1
|
||||
};
|
||||
|
||||
const outcome = {
|
||||
rule_id: 'rule-1',
|
||||
clause_id: 'clause-1',
|
||||
effect_key: 'damage',
|
||||
outcome_key: 'Damage',
|
||||
application_kind: 'active_modifier',
|
||||
effect_scope: 'self',
|
||||
stat_attribute_id: 'weapon-damage',
|
||||
value_basis: 'flat',
|
||||
value_scaling: 'flat',
|
||||
damage_basis: null,
|
||||
application_phase: 'while_active',
|
||||
combat_timing: 'current',
|
||||
resource_kind: null,
|
||||
state_pool_id: null,
|
||||
min_state_value: null,
|
||||
max_state_value: null,
|
||||
description_slot: 1,
|
||||
value: 3,
|
||||
value_expression_json: null
|
||||
};
|
||||
|
||||
describe('conditional mechanic persistence adapter', () => {
|
||||
it('rejects divergent metadata across value variants', () => {
|
||||
expect(() => conditionalRuleDraftFromRows(
|
||||
rule,
|
||||
[],
|
||||
[clause],
|
||||
[
|
||||
{ ...outcome, variant_key: 'I' },
|
||||
{ ...outcome, variant_key: 'II', application_phase: 'after_event', value: 5 }
|
||||
],
|
||||
['I', 'II']
|
||||
)).toThrow(/inconsistent application_phase metadata/);
|
||||
});
|
||||
|
||||
it('loads consistent value variants into one outcome', () => {
|
||||
const draft = conditionalRuleDraftFromRows(
|
||||
rule,
|
||||
[],
|
||||
[clause],
|
||||
[
|
||||
{ ...outcome, variant_key: 'I' },
|
||||
{ ...outcome, variant_key: 'II', value: 5 }
|
||||
],
|
||||
['I', 'II']
|
||||
);
|
||||
|
||||
expect(draft?.clauses[0].outcomes[0].variants).toEqual({
|
||||
I: { value: 3 },
|
||||
II: { value: 5 }
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
import {
|
||||
parseConditionalMechanicDraft,
|
||||
type ConditionalMechanicClauseDraft,
|
||||
type ConditionalMechanicDraftV1,
|
||||
type ConditionalMechanicOutcomeDraft,
|
||||
type ConditionalMechanicPoolDraft
|
||||
} from '$lib/calculations/mechanic-editor';
|
||||
import type { MechanicOutcome, MechanicResource, NumericExpression } from '$lib/calculations/mechanic';
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const sourceTypes = [
|
||||
'talent',
|
||||
'os_protocol_talent',
|
||||
'skill_chip_talent',
|
||||
'specialization_tree_rank'
|
||||
] as const;
|
||||
|
||||
export type ConditionalSourceType = (typeof sourceTypes)[number];
|
||||
export type ConditionalValueColumn = { key: string; variantKey: string };
|
||||
|
||||
type RuleRow = Record<string, unknown>;
|
||||
type PoolRow = Record<string, unknown>;
|
||||
type ClauseRow = Record<string, unknown>;
|
||||
type OutcomeRow = Record<string, unknown>;
|
||||
|
||||
const parseJson = <T>(value: unknown, fallback: T): T => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(value ?? ''));
|
||||
return parsed === null || parsed === undefined ? fallback : parsed as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const applicationKind = (outcome: ConditionalMechanicOutcomeDraft) => {
|
||||
if (outcome.kind === 'attribute_modifier') return 'active_modifier';
|
||||
if (outcome.kind === 'direct_damage') return 'direct_damage';
|
||||
if (outcome.kind === 'resource_cost') return 'resource_cost';
|
||||
if (outcome.kind === 'marker') return 'marker';
|
||||
if (outcome.resource === 'armor') return 'restore_armor';
|
||||
if (outcome.resource === 'ammo') return 'restore_ammo';
|
||||
return 'restore_health';
|
||||
};
|
||||
|
||||
const outcomeKind = (row: OutcomeRow): MechanicOutcome['kind'] => {
|
||||
if (row.application_kind === 'active_modifier') return 'attribute_modifier';
|
||||
if (row.application_kind === 'direct_damage') return 'direct_damage';
|
||||
if (row.application_kind === 'resource_cost') return 'resource_cost';
|
||||
if (row.application_kind === 'marker') return 'marker';
|
||||
return 'restoration';
|
||||
};
|
||||
|
||||
const outcomeResource = (row: OutcomeRow): MechanicResource | undefined => {
|
||||
if (row.resource_kind === 'health' || row.resource_kind === 'armor' || row.resource_kind === 'ammo') {
|
||||
return row.resource_kind;
|
||||
}
|
||||
if (row.application_kind === 'restore_health') return 'health';
|
||||
if (row.application_kind === 'restore_armor') return 'armor';
|
||||
if (row.application_kind === 'restore_ammo') return 'ammo';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const outcomeMetadataColumns = [
|
||||
'outcome_key',
|
||||
'application_kind',
|
||||
'effect_scope',
|
||||
'stat_attribute_id',
|
||||
'value_basis',
|
||||
'value_scaling',
|
||||
'damage_basis',
|
||||
'application_phase',
|
||||
'combat_timing',
|
||||
'resource_kind',
|
||||
'state_pool_id',
|
||||
'min_state_value',
|
||||
'max_state_value',
|
||||
'description_slot'
|
||||
] as const;
|
||||
|
||||
const comparableMetadataValue = (value: unknown) => value === undefined ? null : value;
|
||||
|
||||
function assertVariantMetadataConsistent(effectKey: string, variants: OutcomeRow[]) {
|
||||
const first = variants[0];
|
||||
for (const variant of variants.slice(1)) {
|
||||
const mismatch = outcomeMetadataColumns.find((column) =>
|
||||
comparableMetadataValue(variant[column]) !== comparableMetadataValue(first[column])
|
||||
);
|
||||
if (mismatch) {
|
||||
throw new Error(
|
||||
`Conditional outcome "${effectKey}" has inconsistent ${mismatch} metadata across value variants.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseConditionalRulePayload(
|
||||
raw: FormDataEntryValue | string | null,
|
||||
valueColumns: ConditionalValueColumn[],
|
||||
options: {
|
||||
allowedAttributeIds?: ReadonlySet<string>;
|
||||
allowedRecipeKeys?: ReadonlySet<string>;
|
||||
programId?: string;
|
||||
programName?: string;
|
||||
} = {}
|
||||
) {
|
||||
const result = parseConditionalMechanicDraft(raw, {
|
||||
variantKeys: valueColumns.map((column) => column.variantKey),
|
||||
allowedAttributeIds: options.allowedAttributeIds,
|
||||
allowedRecipeKeys: options.allowedRecipeKeys,
|
||||
programId: options.programId,
|
||||
programName: options.programName
|
||||
});
|
||||
if (!result.ok) throw new Error(result.errors.join(' '));
|
||||
return result.value.draft;
|
||||
}
|
||||
|
||||
export function conditionalRuleDraftFromRows(
|
||||
rule: RuleRow | null | undefined,
|
||||
poolRows: PoolRow[],
|
||||
clauseRows: ClauseRow[],
|
||||
outcomeRows: OutcomeRow[],
|
||||
variantKeys: readonly string[]
|
||||
): ConditionalMechanicDraftV1 | null {
|
||||
if (!rule) return null;
|
||||
const pools = poolRows
|
||||
.filter((row) => row.rule_id === rule.id)
|
||||
.sort((left, right) => Number(left.sort_order) - Number(right.sort_order))
|
||||
.map((row): ConditionalMechanicPoolDraft => ({
|
||||
key: String(row.state_key),
|
||||
label: String(row.label),
|
||||
stateKind: String(row.state_kind) as ConditionalMechanicPoolDraft['stateKind'],
|
||||
scope: String(row.state_scope) as ConditionalMechanicPoolDraft['scope'],
|
||||
initial: Number(row.initial_value),
|
||||
...(row.minimum_value === null || row.minimum_value === undefined ? {} : { min: Number(row.minimum_value) }),
|
||||
...(row.maximum_value === null || row.maximum_value === undefined ? {} : { max: Number(row.maximum_value) }),
|
||||
...(row.best_case_value === null || row.best_case_value === undefined ? {} : { bestCaseValue: Number(row.best_case_value) }),
|
||||
...(row.duration_seconds === null || row.duration_seconds === undefined ? {} : { expiresAfterSeconds: Number(row.duration_seconds) })
|
||||
}));
|
||||
const poolKeyById = new Map(poolRows.map((row) => [String(row.id), String(row.state_key)]));
|
||||
const rowsByClause = new Map<string, OutcomeRow[]>();
|
||||
for (const row of outcomeRows.filter((item) => item.rule_id === rule.id)) {
|
||||
const key = String(row.clause_id ?? '');
|
||||
rowsByClause.set(key, [...(rowsByClause.get(key) ?? []), row]);
|
||||
}
|
||||
const clauses = clauseRows
|
||||
.filter((row) => row.rule_id === rule.id && Number(row.active ?? 1) === 1)
|
||||
.sort((left, right) => Number(left.sort_order) - Number(right.sort_order))
|
||||
.map((row): ConditionalMechanicClauseDraft => {
|
||||
const grouped = new Map<string, OutcomeRow[]>();
|
||||
for (const outcome of rowsByClause.get(String(row.id)) ?? []) {
|
||||
const key = String(outcome.effect_key);
|
||||
grouped.set(key, [...(grouped.get(key) ?? []), outcome]);
|
||||
}
|
||||
const outcomes = [...grouped.entries()].map(([key, variants]): ConditionalMechanicOutcomeDraft => {
|
||||
assertVariantMetadataConsistent(key, variants);
|
||||
const first = variants[0];
|
||||
const resource = outcomeResource(first);
|
||||
return {
|
||||
key,
|
||||
label: String(first.outcome_key || key),
|
||||
phase: String(first.application_phase) as ConditionalMechanicOutcomeDraft['phase'],
|
||||
kind: outcomeKind(first),
|
||||
scope: String(first.effect_scope) as ConditionalMechanicOutcomeDraft['scope'],
|
||||
basis: String(first.value_basis) as ConditionalMechanicOutcomeDraft['basis'],
|
||||
...(resource ? { resource } : {}),
|
||||
...(first.combat_timing ? { combatTiming: String(first.combat_timing) as 'pre_event' | 'current' } : {}),
|
||||
...(first.stat_attribute_id ? { attributeId: String(first.stat_attribute_id) } : {}),
|
||||
...(first.state_pool_id && poolKeyById.has(String(first.state_pool_id))
|
||||
? { statePool: poolKeyById.get(String(first.state_pool_id)) }
|
||||
: {}),
|
||||
perStack: first.value_scaling === 'per_state_unit',
|
||||
...(first.min_state_value === null || first.min_state_value === undefined ? {} : { minStacks: Number(first.min_state_value) }),
|
||||
...(first.max_state_value === null || first.max_state_value === undefined ? {} : { maxStacks: Number(first.max_state_value) }),
|
||||
...(first.description_slot === null || first.description_slot === undefined ? {} : { descriptionSlot: Number(first.description_slot) }),
|
||||
variants: Object.fromEntries(variantKeys.map((variant) => {
|
||||
const variantRow = variants.find((candidate) => candidate.variant_key === variant);
|
||||
return [variant, {
|
||||
value: Number(variantRow?.value ?? 0),
|
||||
...(variantRow?.value_expression_json
|
||||
? { expression: parseJson<NumericExpression>(variantRow.value_expression_json, Number(variantRow.value ?? 0)) }
|
||||
: {})
|
||||
}];
|
||||
}))
|
||||
};
|
||||
});
|
||||
return {
|
||||
key: String(row.clause_key),
|
||||
label: String(row.label),
|
||||
event: String(row.event_kind) as ConditionalMechanicClauseDraft['event'],
|
||||
...(row.recipe_key ? { recipeKey: String(row.recipe_key) } : {}),
|
||||
if: parseJson(row.conditions_json, { kind: 'true' }),
|
||||
reducer: parseJson(row.reducer_json, { kind: 'none' }),
|
||||
...(row.weight_expression_json
|
||||
? { weight: parseJson<NumericExpression>(row.weight_expression_json, 1) }
|
||||
: {}),
|
||||
...(row.cooldown_seconds === null || row.cooldown_seconds === undefined
|
||||
? {}
|
||||
: { cooldownSeconds: Number(row.cooldown_seconds) }),
|
||||
mutations: parseJson(row.mutations_json, []),
|
||||
outcomes
|
||||
};
|
||||
});
|
||||
return {
|
||||
version: 1,
|
||||
simulationReadiness: String(rule.simulation_readiness ?? 'captured_only') as ConditionalMechanicDraftV1['simulationReadiness'],
|
||||
scenarioInputs: parseJson(rule.scenario_inputs_json, []),
|
||||
pools,
|
||||
clauses
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadConditionalRuleDraft(
|
||||
database: Database,
|
||||
sourceType: ConditionalSourceType,
|
||||
sourceId: string,
|
||||
variantKeys: readonly string[]
|
||||
) {
|
||||
const rule = await (await database.prepare(
|
||||
'SELECT * FROM conditional_effect_rules WHERE source_type=? AND source_id=?'
|
||||
)).get(sourceType, sourceId);
|
||||
if (!rule) return null;
|
||||
const [pools, clauses, outcomes] = await Promise.all([
|
||||
(await database.prepare('SELECT * FROM conditional_effect_state_pools WHERE rule_id=? ORDER BY sort_order')).all(rule.id),
|
||||
(await database.prepare('SELECT * FROM conditional_effect_clauses WHERE rule_id=? ORDER BY sort_order')).all(rule.id),
|
||||
(await database.prepare('SELECT * FROM conditional_effect_outcomes WHERE rule_id=? ORDER BY sort_order,variant_key')).all(rule.id)
|
||||
]);
|
||||
return conditionalRuleDraftFromRows(rule, pools, clauses, outcomes, variantKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces one source-owned mechanic program inside the caller's active transaction.
|
||||
* Every ISAC source mutation must include this write in the same transaction as its
|
||||
* source record and append-only audit event.
|
||||
*/
|
||||
export async function saveConditionalRuleInTransaction(
|
||||
database: Database,
|
||||
sourceType: ConditionalSourceType,
|
||||
sourceId: string,
|
||||
draft: ConditionalMechanicDraftV1
|
||||
) {
|
||||
if (!sourceTypes.includes(sourceType)) throw new Error('Unsupported conditional source.');
|
||||
const existing = await (await database.prepare(
|
||||
'SELECT id,created_at FROM conditional_effect_rules WHERE source_type=? AND source_id=?'
|
||||
)).get(sourceType, sourceId);
|
||||
const now = new Date().toISOString();
|
||||
const ruleId = String(existing?.id ?? randomUUID());
|
||||
await (await database.prepare(`INSERT INTO conditional_effect_rules
|
||||
(id,source_type,source_id,configured,script_version,simulation_readiness,
|
||||
scenario_inputs_json,created_at,updated_at)
|
||||
VALUES(?,?,?,1,1,?,?,?,?)
|
||||
ON CONFLICT(source_type,source_id) DO UPDATE SET
|
||||
configured=1,
|
||||
script_version=1,
|
||||
simulation_readiness=excluded.simulation_readiness,
|
||||
scenario_inputs_json=excluded.scenario_inputs_json,
|
||||
updated_at=excluded.updated_at`)).run(
|
||||
ruleId,
|
||||
sourceType,
|
||||
sourceId,
|
||||
draft.simulationReadiness,
|
||||
JSON.stringify(draft.scenarioInputs),
|
||||
String(existing?.created_at ?? now),
|
||||
now
|
||||
);
|
||||
await (await database.prepare('DELETE FROM conditional_effect_outcomes WHERE rule_id=?')).run(ruleId);
|
||||
await (await database.prepare('DELETE FROM conditional_effect_clauses WHERE rule_id=?')).run(ruleId);
|
||||
await (await database.prepare('DELETE FROM conditional_effect_state_pools WHERE rule_id=?')).run(ruleId);
|
||||
|
||||
const poolIds = new Map<string, string>();
|
||||
for (const [index, pool] of draft.pools.entries()) {
|
||||
const poolId = randomUUID();
|
||||
poolIds.set(pool.key, poolId);
|
||||
await (await database.prepare(`INSERT INTO conditional_effect_state_pools
|
||||
(id,rule_id,state_key,label,state_kind,state_scope,initial_value,minimum_value,
|
||||
maximum_value,best_case_value,duration_seconds,expiry_policy,refresh_policy,
|
||||
parameters_json,sort_order,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
|
||||
poolId,
|
||||
ruleId,
|
||||
pool.key,
|
||||
pool.label,
|
||||
pool.stateKind,
|
||||
pool.scope,
|
||||
pool.initial,
|
||||
pool.min ?? null,
|
||||
pool.max ?? Math.max(1, pool.bestCaseValue ?? pool.initial ?? 1),
|
||||
pool.bestCaseValue ?? null,
|
||||
pool.expiresAfterSeconds ?? null,
|
||||
'clear_all',
|
||||
'refresh',
|
||||
'{}',
|
||||
index + 1,
|
||||
now,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
let outcomeSortOrder = 0;
|
||||
for (const [clauseIndex, clause] of draft.clauses.entries()) {
|
||||
const clauseId = randomUUID();
|
||||
await (await database.prepare(`INSERT INTO conditional_effect_clauses
|
||||
(id,rule_id,clause_key,label,event_kind,recipe_key,recipe_parameters_json,
|
||||
conditions_json,reducer_json,weight_expression_json,emission_kind,state_pool_id,
|
||||
mutations_json,cooldown_seconds,output_branch,sort_order,active,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
|
||||
clauseId,
|
||||
ruleId,
|
||||
clause.key,
|
||||
clause.label || clause.key,
|
||||
clause.event,
|
||||
clause.recipeKey ?? null,
|
||||
'{}',
|
||||
JSON.stringify(clause.if ?? { kind: 'true' }),
|
||||
JSON.stringify(clause.reducer),
|
||||
clause.weight === undefined ? null : JSON.stringify(clause.weight),
|
||||
'each_match',
|
||||
null,
|
||||
JSON.stringify(clause.mutations),
|
||||
clause.cooldownSeconds ?? null,
|
||||
'active',
|
||||
clauseIndex + 1,
|
||||
1,
|
||||
now,
|
||||
now
|
||||
);
|
||||
for (const [outcomeIndex, outcome] of clause.outcomes.entries()) {
|
||||
for (const [variantKey, variant] of Object.entries(outcome.variants)) {
|
||||
outcomeSortOrder += 1;
|
||||
await (await database.prepare(`INSERT INTO conditional_effect_outcomes
|
||||
(id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key,
|
||||
application_kind,effect_scope,stat_attribute_id,value,value_expression_json,
|
||||
value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing,
|
||||
resource_kind,min_state_value,max_state_value,description_slot,sort_order,
|
||||
created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
|
||||
randomUUID(),
|
||||
ruleId,
|
||||
clauseId,
|
||||
outcome.statePool ? poolIds.get(outcome.statePool) ?? null : null,
|
||||
outcome.key,
|
||||
variantKey,
|
||||
outcome.label,
|
||||
applicationKind(outcome),
|
||||
outcome.scope,
|
||||
outcome.attributeId ?? null,
|
||||
variant.value,
|
||||
variant.expression === undefined ? null : JSON.stringify(variant.expression),
|
||||
outcome.basis,
|
||||
outcome.perStack === false ? 'flat' : 'per_state_unit',
|
||||
null,
|
||||
outcome.kind === 'direct_damage'
|
||||
? outcome.basis === 'weapon_base_percent' ? 'weapon_base_percent' : 'flat'
|
||||
: null,
|
||||
outcome.phase,
|
||||
outcome.combatTiming ?? 'current',
|
||||
outcome.resource ?? null,
|
||||
outcome.minStacks ?? null,
|
||||
outcome.maxStacks ?? null,
|
||||
outcome.descriptionSlot ?? null,
|
||||
outcomeSortOrder,
|
||||
now,
|
||||
now
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ruleId;
|
||||
}
|
||||
|
||||
export async function conditionalRuleSnapshot(
|
||||
database: Database,
|
||||
sourceType: ConditionalSourceType,
|
||||
sourceId: string
|
||||
) {
|
||||
const rule = await (await database.prepare(
|
||||
'SELECT * FROM conditional_effect_rules WHERE source_type=? AND source_id=?'
|
||||
)).get(sourceType, sourceId);
|
||||
if (!rule) return null;
|
||||
return {
|
||||
rule,
|
||||
statePools: await (await database.prepare(
|
||||
'SELECT * FROM conditional_effect_state_pools WHERE rule_id=? ORDER BY sort_order'
|
||||
)).all(rule.id),
|
||||
clauses: await (await database.prepare(
|
||||
'SELECT * FROM conditional_effect_clauses WHERE rule_id=? ORDER BY sort_order'
|
||||
)).all(rule.id),
|
||||
outcomes: await (await database.prepare(
|
||||
'SELECT * FROM conditional_effect_outcomes WHERE rule_id=? ORDER BY variant_key,outcome_key,sort_order'
|
||||
)).all(rule.id)
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { recordIsacAudit } from '$lib/server/audit';
|
||||
import { getContributorMode } from '$lib/server/contributor-mode';
|
||||
import { notifyOversightReview } from '$lib/server/oversight-notifications';
|
||||
|
||||
export const DATA_MODULES = ['configuration', 'calculations', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips', 'specializations'] as const;
|
||||
export const DATA_MODULES = ['configuration', 'calculations', 'specializations', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips'] as const;
|
||||
export type DataModule = typeof DATA_MODULES[number];
|
||||
|
||||
const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: string; mode?: string }>>> = {
|
||||
@@ -24,10 +24,7 @@ const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: str
|
||||
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
|
||||
weaponmods: { save: { saved: 'weaponModSaved', mode: 'weaponModMode' } },
|
||||
gearmods: { save: { saved: 'gearModSaved', mode: 'gearModMode' } },
|
||||
talents: {
|
||||
saveTrigger: { saved: 'triggerSaved', mode: 'triggerMode' },
|
||||
saveTalent: { saved: 'talentSaved', mode: 'talentMode' }
|
||||
},
|
||||
talents: { saveTalent: { saved: 'talentSaved', mode: 'talentMode' } },
|
||||
protocols: {
|
||||
save: { saved: 'protocolSaved', mode: 'protocolMode' },
|
||||
saveProfile: { saved: 'profileSaved' }
|
||||
|
||||
@@ -13,6 +13,10 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
talentTiers,
|
||||
talentEffects,
|
||||
talentTemplates,
|
||||
conditionalRules,
|
||||
conditionalPools,
|
||||
conditionalClauses,
|
||||
conditionalOutcomes,
|
||||
chipSets,
|
||||
chipTwoPiece,
|
||||
chipTalents,
|
||||
@@ -26,6 +30,7 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
specializations,
|
||||
specializationFocuses,
|
||||
specializationAbilities,
|
||||
specializationAbilityAttacks,
|
||||
focusAbilities,
|
||||
specializationNodes,
|
||||
specializationRanks,
|
||||
@@ -35,7 +40,8 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
specializationCombatModifiers,
|
||||
activeCalculationRevision,
|
||||
calculationStages,
|
||||
calculationProfiles
|
||||
calculationProfiles,
|
||||
skillCalculationProfiles
|
||||
] = await Promise.all([
|
||||
(await database.prepare('SELECT * FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM gear WHERE active=1 ORDER BY slot,name COLLATE NOCASE')).all(),
|
||||
@@ -48,6 +54,10 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
(await database.prepare('SELECT * FROM talent_tiers ORDER BY tier')).all(),
|
||||
(await database.prepare('SELECT * FROM talent_tier_effects')).all(),
|
||||
(await database.prepare('SELECT * FROM talent_description_templates')).all(),
|
||||
(await database.prepare('SELECT * FROM conditional_effect_rules WHERE configured=1')).all(),
|
||||
(await database.prepare('SELECT * FROM conditional_effect_state_pools ORDER BY rule_id,sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM conditional_effect_clauses WHERE active=1 ORDER BY rule_id,sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM conditional_effect_outcomes ORDER BY rule_id,variant_key,outcome_key,sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
|
||||
@@ -61,6 +71,7 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
(await database.prepare('SELECT * FROM specializations WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_focuses WHERE active=1 ORDER BY specialization_id,display_order,name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_abilities WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_ability_attacks')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_nodes WHERE active=1 ORDER BY focus_id,row_number,column_number')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_node_ranks ORDER BY node_id,rank')).all(),
|
||||
@@ -73,10 +84,23 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
JOIN calculation_revisions revision ON revision.id=stage.revision_id
|
||||
WHERE revision.status='active' ORDER BY stage.display_order`)).all(),
|
||||
(await database.prepare(`SELECT profile.* FROM attribute_calculation_profiles profile
|
||||
JOIN calculation_revisions revision ON revision.id=profile.revision_id
|
||||
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)).all(),
|
||||
(await database.prepare(`SELECT profile.* FROM skill_attribute_calculation_profiles profile
|
||||
JOIN calculation_revisions revision ON revision.id=profile.revision_id
|
||||
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)).all()
|
||||
]);
|
||||
|
||||
const hydrateRule = (sourceType: string, sourceId: unknown) => {
|
||||
const rule = conditionalRules.find((item) => item.source_type === sourceType && item.source_id === sourceId);
|
||||
return rule ? {
|
||||
rule,
|
||||
statePools: conditionalPools.filter((pool) => pool.rule_id === rule.id),
|
||||
clauses: conditionalClauses.filter((clause) => clause.rule_id === rule.id),
|
||||
outcomes: conditionalOutcomes.filter((outcome) => outcome.rule_id === rule.id),
|
||||
triggers: []
|
||||
} : null;
|
||||
};
|
||||
const configuredTalents = talents.map((talent) => ({
|
||||
...talent,
|
||||
template: talentTemplates.find((item) => item.talent_id === talent.id)?.template ?? '',
|
||||
@@ -85,7 +109,8 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
.map((tier) => ({
|
||||
...tier,
|
||||
effects: talentEffects.filter((effect) => effect.talent_tier_id === tier.id)
|
||||
}))
|
||||
})),
|
||||
rule: hydrateRule('talent', talent.id)
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -109,7 +134,8 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
talent: talent
|
||||
? {
|
||||
...talent,
|
||||
effects: chipTalentEffects.filter((effect) => effect.talent_id === talent.id)
|
||||
effects: chipTalentEffects.filter((effect) => effect.talent_id === talent.id),
|
||||
rule: hydrateRule('skill_chip_talent', talent.id)
|
||||
}
|
||||
: null
|
||||
};
|
||||
@@ -130,7 +156,8 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
talent: talent
|
||||
? {
|
||||
...talent,
|
||||
effects: protocolTalentEffects.filter((effect) => effect.talent_id === talent.id)
|
||||
effects: protocolTalentEffects.filter((effect) => effect.talent_id === talent.id),
|
||||
rule: hydrateRule('os_protocol_talent', talent.id)
|
||||
}
|
||||
: null
|
||||
};
|
||||
@@ -143,7 +170,13 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
const abilities = focusAbilities
|
||||
.filter((link) => link.focus_id === focus.id)
|
||||
.sort((left, right) => Number(left.ability_slot) - Number(right.ability_slot))
|
||||
.map((link) => specializationAbilities.find((ability) => ability.id === link.ability_id))
|
||||
.map((link) => {
|
||||
const ability = specializationAbilities.find((entry) => entry.id === link.ability_id);
|
||||
return ability ? {
|
||||
...ability,
|
||||
attack: specializationAbilityAttacks.find((attack) => attack.ability_id === ability.id) ?? null
|
||||
} : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
const nodes = specializationNodes
|
||||
.filter((node) => node.focus_id === focus.id)
|
||||
@@ -171,7 +204,8 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
),
|
||||
modifiers: specializationCombatModifiers.filter(
|
||||
(modifier) => modifier.rank_combat_effect_id === effect.id
|
||||
)
|
||||
),
|
||||
rule: hydrateRule('specialization_tree_rank', effect.id)
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
@@ -181,7 +215,8 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
calculation: {
|
||||
revision: activeCalculationRevision ?? null,
|
||||
stages: calculationStages,
|
||||
profiles: calculationProfiles
|
||||
profiles: calculationProfiles,
|
||||
skillProfiles: skillCalculationProfiles
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,7 +89,6 @@ export function actionFeedback(
|
||||
const actionMessages: Record<string, string> = {
|
||||
save: 'Changes saved',
|
||||
saveTalent: 'Talent saved',
|
||||
saveTrigger: 'Trigger saved',
|
||||
savePreferences: 'Preferences saved',
|
||||
saveCoreRanges: 'Configuration saved',
|
||||
saveProfile: 'Profile saved',
|
||||
|
||||
Reference in New Issue
Block a user