feat: add source-owned combat mechanic programs

This commit is contained in:
JB
2026-07-27 13:35:00 -04:00
parent 5a67b252aa
commit 4eb754e7c8
62 changed files with 14815 additions and 905 deletions
+4 -2
View File
@@ -253,10 +253,12 @@
/* Specialization combat modeling: shared authoring and builder patterns. */
.combat-effect-definition-grid{grid-template-columns:minmax(220px,.7fr) minmax(360px,1.3fr)}.combat-effect-template textarea{min-height:86px}
.specialization-attack-profile{display:grid;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-info);background:var(--shd-model-surface)}.specialization-attack-profile>.segmented-choice{margin:0}.specialization-attack-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--shd-gap-sm);align-items:end}.specialization-attack-grid>.field,.specialization-attack-grid>.segmented-choice{margin:0}.calculation-note{margin:0;padding:8px;border:1px solid var(--shd-border-soft);color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}
.rank-combat-effects{display:grid;gap:var(--shd-gap-sm);margin-top:var(--shd-gap-md);padding-top:var(--shd-gap-md);border-top:1px solid var(--shd-border)}.rank-combat-effect{display:grid;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-info);background:var(--shd-model-surface)}.rank-combat-effect>header{display:flex;align-items:center;justify-content:space-between}.rank-combat-effect>header>div{display:grid;gap:2px}.rank-combat-effect>header span,.rank-combat-effect>header small{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.rank-combat-effect>header span{color:var(--shd-info)}.combat-trigger-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:var(--shd-gap-sm)}.combat-lifecycle-facts{display:flex;flex-wrap:wrap;gap:var(--shd-gap-xs)}.combat-lifecycle-facts span{padding:5px 7px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.6px}.combat-lifecycle-facts b{color:var(--shd-text)}.combat-modifier-list{display:grid;gap:var(--shd-gap-xs)}.combat-modifier-list>div{display:grid;grid-template-columns:minmax(220px,1fr) 150px 140px auto;gap:var(--shd-gap-sm);align-items:end;padding:6px;border-top:1px solid var(--shd-border-soft)}.combat-modifier-list>div>.field{margin:0}
.builder-remediation{display:grid;gap:3px;padding:9px;border:1px solid var(--shd-danger);border-left-width:2px;background:#1d1212}.builder-remediation strong{color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:.9px}.builder-remediation span{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.builder-specialization-identity{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:var(--shd-gap-md);align-items:center;padding:var(--shd-gap-md);border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-orange);background:var(--shd-model-surface)}.builder-specialization-identity h3,.builder-specialization-identity p{margin:0}.builder-specialization-identity small,.builder-specialization-identity>b{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.builder-specialization-identity p{margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.builder-specialization-identity>b{padding:5px 7px;border:1px solid var(--shd-border);color:var(--shd-text-muted)}.builder-specialization-identity>b.configured{border-color:#365d44;color:var(--shd-success)}.builder-ability-chip-panels{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--shd-gap-sm)}.builder-ability-chip-panels>section{display:grid;grid-template-rows:auto minmax(48px,auto) auto;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);background:var(--shd-surface)}.builder-ability-chip-panels header{display:grid;grid-template-columns:auto 1fr auto;gap:var(--shd-gap-sm);align-items:center}.builder-ability-chip-panels header img{width:38px;height:38px}.builder-ability-chip-panels header div{display:grid}.builder-ability-chip-panels header small,.builder-ability-chip-panels header b{font-size:var(--shd-type-telemetry);letter-spacing:.7px}.builder-ability-chip-panels>section>p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.builder-ability-chip-panels>section>div{display:grid;gap:4px}.builder-ability-chip-panels>section>div>button{min-height:54px;display:grid;grid-template-columns:70px 1fr;grid-template-rows:auto auto;gap:1px 8px;padding:7px;border:1px solid var(--shd-border);background:#0d1011;color:var(--shd-text);text-align:left;cursor:pointer}.builder-ability-chip-panels>section>div>button:hover,.builder-ability-chip-panels>section>div>button:focus-visible{border-color:var(--shd-orange)}.builder-ability-chip-panels button small{grid-row:1/3;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.builder-ability-chip-panels button span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.builder-specialization-telemetry{display:flex;flex-wrap:wrap;gap:var(--shd-gap-xs)}.builder-specialization-telemetry span{padding:6px 8px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.builder-specialization-telemetry b{color:var(--shd-orange)}.builder-focus-tree{display:grid;gap:var(--shd-gap-sm)}.builder-focus-tree>section{border:1px solid var(--shd-border);background:var(--shd-model-surface)}.builder-focus-tree>section>header{display:flex;justify-content:space-between;padding:6px 8px;border-bottom:1px solid var(--shd-border);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.builder-focus-tree>section>header span{color:var(--shd-orange)}.builder-focus-tree>section>div{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:var(--shd-gap-xs);padding:var(--shd-gap-xs)}.builder-focus-tree article{display:grid;grid-template-columns:auto 1fr;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);background:var(--shd-surface)}.builder-focus-tree article>img{width:42px;height:42px}.builder-focus-tree article>div{display:grid;gap:3px}.builder-focus-tree article small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.35}.builder-focus-tree article>.segmented-choice{grid-column:1/-1;margin:0}.segmented-choice span.unavailable{opacity:.38;cursor:not-allowed;filter:saturate(.25)}.builder-conditional-effects,.builder-combat-results{display:grid;gap:var(--shd-gap-sm)}.builder-combat-results{padding:var(--shd-gap-sm);border-bottom:1px solid var(--shd-border)}.builder-return-link{width:max-content;min-height:36px;padding:7px 9px;border:1px solid var(--shd-border);background:transparent;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;cursor:pointer}.builder-return-link:hover,.builder-return-link:focus-visible{border-color:var(--shd-orange)}
@media(max-width:1050px){.combat-trigger-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.combat-modifier-list>div{grid-template-columns:1fr 1fr}.builder-ability-chip-panels{grid-template-columns:1fr}.builder-specialization-identity{grid-template-columns:auto 1fr}.builder-specialization-identity>b{grid-column:1/-1;width:max-content}}
@media(max-width:650px){.combat-effect-definition-grid,.combat-trigger-grid,.combat-modifier-list>div{grid-template-columns:1fr}.rank-combat-effect button,.builder-return-link,.builder-ability-chip-panels button{min-height:44px}.builder-specialization-identity{grid-template-columns:1fr}.builder-specialization-identity .focus-composite-icon{justify-self:start}}
.builder-skill-activation{display:grid;gap:var(--shd-gap-sm)}.builder-skill-activation>section{display:grid;grid-template-columns:minmax(180px,.7fr) minmax(340px,1.3fr);gap:var(--shd-gap-sm);align-items:end;padding:var(--shd-gap-sm);border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-info);background:var(--shd-model-surface)}.builder-skill-activation>section>header{display:grid;grid-template-columns:auto 1fr;gap:var(--shd-gap-sm);align-items:center}.builder-skill-activation>section>header img{width:40px;height:40px}.builder-skill-activation>section>header div{display:grid;gap:2px}.builder-skill-activation>section>header small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.builder-skill-activation>section>.segmented-choice{margin:0}.builder-skill-assumptions{grid-column:1/-1;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-sm)}
@media(max-width:1050px){.combat-trigger-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.combat-modifier-list>div{grid-template-columns:1fr 1fr}.builder-ability-chip-panels{grid-template-columns:1fr}.builder-specialization-identity{grid-template-columns:auto 1fr}.builder-specialization-identity>b{grid-column:1/-1;width:max-content}.builder-skill-activation>section{grid-template-columns:1fr}.specialization-attack-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:650px){.combat-effect-definition-grid,.combat-trigger-grid,.combat-modifier-list>div,.specialization-attack-grid{grid-template-columns:1fr}.rank-combat-effect button,.builder-return-link,.builder-ability-chip-panels button{min-height:44px}.builder-specialization-identity{grid-template-columns:1fr}.builder-specialization-identity .focus-composite-icon{justify-self:start}.builder-skill-assumptions{grid-template-columns:1fr}}
.community-index-hero>div:last-child .primary{
font-size:var(--shd-type-telemetry);
}
+144
View File
@@ -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);
});
});
+790 -60
View File
@@ -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)))
}
});
}
+31 -4
View File
@@ -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
View File
@@ -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');
});
});
+131
View File
@@ -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'
];
}
+31 -1
View File
@@ -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',
+62 -2
View File
@@ -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
)
};
}
+401
View File
@@ -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);
});
});
+964
View File
@@ -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.']
});
});
});
+605
View File
@@ -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
});
});
});
+336
View File
@@ -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);
}
+903
View File
@@ -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
+268
View File
@@ -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'
]));
});
});
+553
View File
@@ -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>
+139 -5
View File
@@ -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}
+54
View File
@@ -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">&nbsp;</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>
-1
View File
@@ -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 }
});
});
});
+392
View File
@@ -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)
};
}
+2 -5
View File
@@ -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' }
+42 -7
View File
@@ -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
}
};
}
-1
View File
@@ -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',
+39 -9
View File
@@ -34,13 +34,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const [
attributes, gear, gearBrands, gearTalents, gearAugments, gearAugmentCosts, brands, brandEffects,
weapons, weaponRanges, weaponTalentOptions, weaponBonuses,
talents, talentTiers, talentEffects, talentTemplates,
talents, talentTiers, talentEffects, talentTemplates, conditionalRules, conditionalPools, conditionalClauses, conditionalOutcomes,
chipSets, chipTwoPiece, chipPrimary, chipSecondary, chipTalents, chipTalentEffects,
protocols, protocolPrimary, protocolProfiles, protocolSecondary, protocolTalents, protocolTalentEffects, coreRanges,
icons, specializations, specializationFocuses, specializationAbilities, focusAbilities,
icons, specializations, specializationFocuses, specializationAbilities, specializationAbilityAttacks, focusAbilities,
specializationNodes, specializationRanks, specializationRankEffects, specializationRankCosts,
specializationCombatEffects, specializationRankCombatEffects, specializationCombatModifiers,
progression, rankProfiles, activeCalculationRevision, calculationStages, calculationProfiles
progression, rankProfiles, activeCalculationRevision, calculationStages, 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(),
@@ -58,6 +58,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(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_primary_options')).all(),
@@ -75,6 +79,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(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(),
@@ -90,6 +95,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
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()
]);
@@ -97,6 +105,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const icon = icons.find((item) => item.entity_type === type && item.entity_id === id);
return icon ? gameDataIconUrl(type, String(id), icon.updated_at) : '';
};
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,
iconUrl: iconUrl('talent', talent.id),
@@ -104,7 +122,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
tiers: talentTiers.filter((tier) => tier.talent_id === talent.id).map((tier) => ({
...tier,
effects: talentEffects.filter((effect) => effect.talent_tier_id === tier.id)
}))
})),
rule: hydrateRule('talent', talent.id)
}));
return {
viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null,
@@ -141,7 +160,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
twoPiece: chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ?? null,
primaryStatIds: chipPrimary.filter((item) => item.skill_chip_set_id === set.id).map((item) => String(item.stat_attribute_id)),
secondaryStatIds: chipSecondary.filter((item) => item.skill_chip_set_id === set.id).map((item) => String(item.stat_attribute_id)),
talent: talent ? { ...talent, effects: chipTalentEffects.filter((effect) => effect.talent_id === talent.id) } : null
talent: talent ? {
...talent,
effects: chipTalentEffects.filter((effect) => effect.talent_id === talent.id),
rule: hydrateRule('skill_chip_talent', talent.id)
} : null
};
}),
protocols: protocols.map((protocol) => {
@@ -152,7 +175,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
iconUrl: iconUrl('os_protocol', protocol.id),
primaryEffect: protocolPrimary.find((item) => item.protocol_id === protocol.id) ?? null,
secondaryEffects: profile ? protocolSecondary.filter((effect) => effect.profile_id === profile.id) : [],
talent: talent ? { ...talent, effects: protocolTalentEffects.filter((effect) => effect.talent_id === talent.id) } : null
talent: talent ? {
...talent,
effects: protocolTalentEffects.filter((effect) => effect.talent_id === talent.id),
rule: hydrateRule('os_protocol_talent', talent.id)
} : null
};
}),
specializations: specializations.map((specialization) => ({
@@ -166,7 +193,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
return ability ? {
...ability,
slot: Number(link.ability_slot),
iconUrl: iconUrl('specialization_ability', ability.id)
iconUrl: iconUrl('specialization_ability', ability.id),
attack: specializationAbilityAttacks.find((attack) => attack.ability_id === ability.id) ?? null
} : null;
}).filter(Boolean);
const nodes = specializationNodes.filter((node) => node.focus_id === focus.id).map((node) => ({
@@ -181,7 +209,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
.map((effect) => ({
...effect,
definition: specializationCombatEffects.find((definition) => definition.id === effect.combat_effect_id),
modifiers: specializationCombatModifiers.filter((modifier) => modifier.rank_combat_effect_id === effect.id)
modifiers: specializationCombatModifiers.filter((modifier) => modifier.rank_combat_effect_id === effect.id),
rule: hydrateRule('specialization_tree_rank', effect.id)
}))
}))
}));
@@ -196,7 +225,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
calculation: {
revision: activeCalculationRevision,
stages: calculationStages,
profiles: calculationProfiles
profiles: calculationProfiles,
skillProfiles: skillCalculationProfiles
}
};
};
+175 -28
View File
@@ -11,8 +11,10 @@
import CombatAssumptions from '$lib/components/builder/CombatAssumptions.svelte';
import ConditionalEffectCard from '$lib/components/builder/ConditionalEffectCard.svelte';
import WeaponCalculationResult from '$lib/components/builder/WeaponCalculationResult.svelte';
import { evaluateLoadoutCombat, summarizeLoadout } from '$lib/builder/loadout-combat';
import SkillCalculationResult from '$lib/components/builder/SkillCalculationResult.svelte';
import { evaluateLoadoutCombat, evaluateLoadoutSkillCombat, summarizeLoadout } from '$lib/builder/loadout-combat';
import { toast } from '$lib/toast';
import { MECHANIC_EVENT_REGISTRY, type MechanicEventKind } from '$lib/calculations/mechanic';
import {
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, weaponBaseRange,
type LoadoutDraft, type Roll
@@ -52,7 +54,9 @@
return selectedRank ? [{ node, rank: node.ranks.find((entry: any) => Number(entry.rank) === selectedRank) }] : [];
}) ?? []);
const selectedCombatEffects = $derived(selectedNodeRanks.flatMap(({ node, rank }: any) =>
(rank?.combatEffects ?? []).map((effect: any) => ({ ...effect, nodeName: node.name }))
(rank?.combatEffects ?? [])
.filter((effect: any) => Number(effect.rule?.configured) === 1)
.map((effect: any) => ({ ...effect, nodeName: node.name }))
));
const talent = (id: string) => data.talents.find((item) => item.id === id);
const brand = (id: string) => data.brands.find((item) => item.id === id);
@@ -62,19 +66,69 @@
const unit = attribute(statId)?.unit ?? '';
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
};
const jsonObject = (value: unknown) => {
if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record<string, any>;
if (typeof value !== 'string') return {};
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, any>
: {};
} catch {
return {};
}
};
const mechanicScenarioInputs = (rule: any) => {
const raw = rule?.rule?.scenario_inputs_json;
if (Array.isArray(raw)) return raw;
if (typeof raw !== 'string') return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const combatEffectDescription = (effect: any) => {
const template = String(effect.definition?.description_template ?? effect.definition?.name ?? 'Conditional effect');
const rule = effect.rule;
const outcomes = (rule?.outcomes ?? []).filter((outcome: any) => outcome.variant_key === 'base');
const firstReducer = jsonObject(rule?.clauses?.[0]?.reducer_json);
const primaryPool = rule?.statePools?.[0];
return template
.replaceAll('{triggerCount}', String(effect.trigger_count))
.replaceAll('{triggerWindow}', `${effect.trigger_window_seconds}s`)
.replaceAll('{duration}', String(effect.duration_seconds))
.replaceAll('{maxStacks}', String(effect.max_stacks))
.replaceAll('{triggerCount}', String(firstReducer.threshold ?? ''))
.replaceAll('{triggerWindow}', firstReducer.windowSeconds ? `${firstReducer.windowSeconds}s` : '')
.replaceAll('{duration}', String(primaryPool?.duration_seconds ?? 0))
.replaceAll('{maxStacks}', String(primaryPool?.maximum_value ?? 1))
.replaceAll('{effectName}', String(effect.definition?.name ?? 'Effect'))
.replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => {
const modifier = effect.modifiers[Number(raw) - 1];
return modifier ? unitValue(modifier.value_per_stack, modifier.stat_attribute_id) : placeholder;
const outcome = outcomes[Number(raw) - 1];
return outcome?.stat_attribute_id
? unitValue(outcome.value, outcome.stat_attribute_id)
: placeholder;
});
};
const conditionalFacts = (rule: any) => {
const clauses = rule?.clauses ?? [];
const eventLabels = [...new Set(clauses.map((clause: any) => {
const event = String(clause.event_kind ?? '') as MechanicEventKind;
return MECHANIC_EVENT_REGISTRY[event]?.label;
}).filter(Boolean))];
const readiness = String(rule?.rule?.simulation_readiness ?? 'captured_only')
.replaceAll('_', ' ')
.toUpperCase();
const cooldowns = clauses
.map((clause: any) => Number(clause.cooldown_seconds ?? 0))
.filter((value: number) => value > 0);
const poolMaximum = Math.max(0, ...(rule?.statePools ?? [])
.map((pool: any) => Number(pool.maximum_value ?? 0)));
return [
`${clauses.length} BRANCH${clauses.length === 1 ? '' : 'ES'}`,
eventLabels.length ? eventLabels.join(' · ').toUpperCase() : 'NO EVENT BRANCHES',
poolMaximum > 1 ? `MAX ${poolMaximum} STACKS` : readiness,
cooldowns.length ? `${Math.max(...cooldowns)}s COOLDOWN` : 'NO COOLDOWN'
];
};
const scaledRange = (minimum: unknown, maximum: unknown, level: unknown, referenceLevel: unknown, source: 'gear' | 'weapon') =>
levelScaledRange(minimum, maximum, level, referenceLevel ?? 50, source);
const gearBaseRange = (item: any, key: 'health' | 'armor', level: number) =>
@@ -159,15 +213,23 @@
draft.specialization = {
...draft.specialization,
focusId,
nodeRanks: {},
effectStates: {}
nodeRanks: {}
};
active = 'specialization:focus';
};
const toggleTalent = (key: string) => {
draft.activeTalents = draft.activeTalents.includes(key)
? draft.activeTalents.filter((item) => item !== key)
: [...draft.activeTalents, key];
const current = draft.conditionalEffects[key]?.mode ?? 'off';
draft.conditionalEffects = {
...draft.conditionalEffects,
[key]: { mode: current === 'off' ? 'peak' : 'off' }
};
};
const setSkillActivation = (abilityId: string, patch: Record<string, unknown>) => {
const current = draft.skillActivations[abilityId] ?? { mode: 'off', targets: 1 };
draft.skillActivations = {
...draft.skillActivations,
[abilityId]: { ...current, ...patch }
};
};
const talentTier = (talentId: string, tier: number) => {
const item = talent(talentId);
@@ -289,19 +351,58 @@
return `Talent Cooldown: ${value} ${Number(value) === 1 ? 'second' : 'seconds'}`;
};
const summary = $derived(summarizeLoadout(draft, data));
const combatResults = $derived(evaluateLoadoutCombat(draft, data, summary));
const modeledTalents = $derived(summary.equippedTalents.filter(
(equipped: any) => Number(equipped.rule?.rule?.configured) === 1
&& (equipped.rule?.clauses?.length ?? 0) > 0
));
const calculationDraft = $derived.by(() => {
if (draft.calculationMode === 'custom') return draft;
const effectKeys = [
...selectedCombatEffects.map((effect: any) => String(effect.id)),
...modeledTalents.map((effect: any) => String(effect.key))
];
const conditionalEffects = {
...draft.conditionalEffects,
...Object.fromEntries(effectKeys.map((key) => [
key,
{ ...draft.conditionalEffects[key], mode: 'peak' as const }
]))
};
const skillActivations = { ...draft.skillActivations };
for (const ability of selectedFocus?.abilities ?? []) {
skillActivations[ability.id] = {
mode: 'on_cooldown',
targets: Math.max(1, Number(ability.attack?.target_cap ?? 1))
};
}
return { ...draft, conditionalEffects, skillActivations };
});
const combatResults = $derived(evaluateLoadoutCombat(calculationDraft, data, summary));
const skillCombatResult = $derived(evaluateLoadoutSkillCombat(calculationDraft, data, summary));
const skillAbilityNames = $derived(Object.fromEntries(
(selectedFocus?.abilities ?? []).map((ability: any) => [String(ability.id), String(ability.name)])
));
$effect(() => {
const missing = selectedCombatEffects.filter((effect: any) => !draft.specialization.effectStates[effect.id]);
if (!missing.length) return;
draft.specialization.effectStates = {
...draft.specialization.effectStates,
...Object.fromEntries(missing.map((effect: any) => [effect.id, { mode: 'off' }]))
const sources = [
...selectedCombatEffects.map((effect: any) => ({ key: String(effect.id) })),
...modeledTalents
];
const missing = sources.filter((source: any) => !draft.conditionalEffects[source.key]);
if (missing.length) draft.conditionalEffects = {
...draft.conditionalEffects,
...Object.fromEntries(missing.map((effect: any) => [effect.key, { mode: 'off' }]))
};
const missingAbilities = (selectedFocus?.abilities ?? []).filter((ability: any) => !draft.skillActivations[ability.id]);
if (missingAbilities.length) draft.skillActivations = {
...draft.skillActivations,
...Object.fromEntries(missingAbilities.map((ability: any) => [ability.id, { mode: 'off', targets: 1 }]))
};
});
onMount(() => {
if (data.initialBuild) return;
const stored = localStorage.getItem('shd-build-loadout-draft-v2');
localStorage.removeItem('shd-build-loadout-draft-v3');
const stored = localStorage.getItem('shd-build-loadout-draft-v4');
if (!stored) return;
try {
const parsed = parseLoadoutDraft(JSON.parse(stored));
@@ -309,9 +410,9 @@
draft = parsed;
if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
saved = true;
} else localStorage.removeItem('shd-build-loadout-draft-v2');
} else localStorage.removeItem('shd-build-loadout-draft-v4');
} catch {
localStorage.removeItem('shd-build-loadout-draft-v2');
localStorage.removeItem('shd-build-loadout-draft-v4');
}
});
$effect(() => {
@@ -327,7 +428,7 @@
: 'Build published to the SHD archive.';
});
function saveDraft() {
localStorage.setItem('shd-build-loadout-draft-v2', JSON.stringify(draft));
localStorage.setItem('shd-build-loadout-draft-v4', JSON.stringify(draft));
saved = true;
toast.success('Draft saved locally', 'This loadout is available on this device.');
}
@@ -365,7 +466,7 @@
{#if persistedSlug && persistedVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
</div>
{/if}
<div class="builder-status"><span><i></i> LIVE CALCULATION</span><span>18 EQUIPMENT POSITIONS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div>
<div class="builder-status"><span><i></i> LIVE CALCULATION</span><span>{draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : 'CUSTOM SCENARIO'}</span><span>18 EQUIPMENT POSITIONS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div>
</div>
{#if message}<p class="success" role="status">{message}</p>{/if}
{#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if}
@@ -394,6 +495,20 @@
</section>
{/each}
</div>
{#if draft.calculationMode === 'custom'}<div class="builder-skill-activation">
<div class="builder-section-title"><div><span>SKILL ACTIVATION PROFILE</span><small>ALL ENABLED ABILITIES CONTRIBUTE TO COMBINED SKILL DPS</small></div><b>{selectedFocus.abilities.filter((ability: any) => (draft.skillActivations[ability.id]?.mode ?? 'off') !== 'off').length} ENABLED</b></div>
{#each selectedFocus.abilities as ability}
{@const activation = draft.skillActivations[ability.id] ?? { mode: 'off', targets: 1 }}
<section>
<header><ItemIcon src={ability.iconUrl} alt=""/><div><strong>{ability.name}</strong><small>{ability.attack ? `${ability.attack.delivery.toUpperCase()} · ${ability.attack.damage_type}` : 'ATTACK PROFILE NOT CONFIGURED'}</small></div></header>
<SegmentedChoice legend={`${ability.name} activation`} name={`skill-${ability.id}-activation`} options={[{value:'off',label:'OFF'},{value:'on_cooldown',label:'ON COOLDOWN'},{value:'custom_per_minute',label:'CUSTOM / MIN'}]} value={activation.mode} onchange={(values) => setSkillActivation(ability.id,{mode:values[0]})}/>
<div class="builder-skill-assumptions">
{#if activation.mode === 'custom_per_minute'}<RangeValueControl label="Uses per minute" min={0.1} max={60} step={0.1} value={activation.usesPerMinute ?? 1} onchange={(value) => setSkillActivation(ability.id,{usesPerMinute:value})}/>{/if}
<RangeValueControl label="Targets hit" min={1} max={Math.max(1,Number(ability.attack?.target_cap ?? 1))} step={1} value={activation.targets} onchange={(value) => setSkillActivation(ability.id,{targets:value})}/>
</div>
</section>
{/each}
</div>{/if}
<div class="builder-specialization-telemetry"><span>POINTS <b>{selectedSpecializationPoints} / {data.specializationProgression.availablePoints}</b></span><span>CREDITS <b>{selectedSpecializationCosts.credits.toLocaleString()}</b></span>{#each selectedSpecializationCosts.resources as resource}<span>{String(resource[0]).replace('resource-electronics-','').replaceAll('-',' ').toUpperCase()} <b>× {resource[1]}</b></span>{/each}</div>
<div class="builder-focus-tree">
<div class="builder-section-title"><div><span>FOCUS UPGRADE TREE</span><small>PREVIOUS ROWS REQUIRE EVERY AVAILABLE RANK 1 + 2 BEFORE ADVANCING</small></div><b>{selectedFocus.nodes.length} NODES</b></div>
@@ -405,9 +520,40 @@
</div></section>
{/each}
</div>
<CombatAssumptions bind:profile={draft.specialization.combatProfile}/>
<CombatAssumptions bind:mode={draft.calculationMode} bind:profile={draft.combatProfile}/>
{#if selectedCombatEffects.length}
<div class="builder-conditional-effects"><div class="builder-section-title"><div><span>CONDITIONAL EFFECT PROFILES</span><small>CHOOSE HOW EACH UNLOCKED EFFECT CONTRIBUTES TO DPS</small></div><b>{selectedCombatEffects.length} EFFECTS</b></div>{#each selectedCombatEffects as effect}<ConditionalEffectCard id={effect.id} name={effect.definition?.name ?? effect.nodeName} description={combatEffectDescription(effect)} facts={[`${effect.trigger_count} HITS WITHIN ${effect.trigger_window_seconds}s`, `+${effect.stacks_per_trigger} STACK · MAX ${effect.max_stacks}`, `EXPIRES ${effect.duration_seconds}s AFTER LAST GAIN`, ...effect.modifiers.map((modifier: any) => `${unitValue(modifier.value_per_stack,modifier.stat_attribute_id)} ${attribute(modifier.stat_attribute_id)?.name ?? 'ATTRIBUTE'} PER STACK`)]} appliedState={draft.specialization.effectStates[effect.id]?.mode === 'peak' ? `APPLIED: ${effect.max_stacks}/${effect.max_stacks}` : draft.specialization.effectStates[effect.id]?.mode === 'encounter' ? `APPLIED: 0→${effect.max_stacks}/${effect.max_stacks}` : 'NOT APPLIED'} bind:effectState={draft.specialization.effectStates[effect.id]}/>{/each}</div>
<div class="builder-conditional-effects">
<div class="builder-section-title"><div><span>CONDITIONAL EFFECT PROFILES</span><small>CHOOSE HOW EACH UNLOCKED EFFECT CONTRIBUTES TO DPS</small></div><b>{selectedCombatEffects.length} EFFECTS</b></div>
{#each selectedCombatEffects as effect}
<ConditionalEffectCard
id={effect.id}
name={effect.definition?.name ?? effect.nodeName}
description={combatEffectDescription(effect)}
facts={conditionalFacts(effect.rule)}
scenarioInputs={mechanicScenarioInputs(effect.rule)}
appliedState={draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : draft.conditionalEffects[effect.id]?.mode === 'peak' ? 'PEAK MODE' : draft.conditionalEffects[effect.id]?.mode === 'encounter' ? 'ENCOUNTER MODE' : 'NOT APPLIED'}
bind:effectState={draft.conditionalEffects[effect.id]}
calculationMode={draft.calculationMode}
/>
{/each}
</div>
{/if}
{#if modeledTalents.length}
<div class="builder-conditional-effects">
<div class="builder-section-title"><div><span>EQUIPPED TALENT MODELING</span><small>MODEL EACH MECHANIC PROGRAM OR EXCLUDE IT FROM CALCULATIONS</small></div><b>{modeledTalents.length} TALENTS</b></div>
{#each modeledTalents as equipped}
<ConditionalEffectCard
id={equipped.key}
name={equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}
description={equipped.description}
facts={conditionalFacts(equipped.rule)}
scenarioInputs={mechanicScenarioInputs(equipped.rule)}
appliedState={draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : draft.conditionalEffects[equipped.key]?.mode === 'peak' ? 'PEAK MODE' : draft.conditionalEffects[equipped.key]?.mode === 'encounter' ? 'ENCOUNTER MODE' : 'NOT APPLIED'}
bind:effectState={draft.conditionalEffects[equipped.key]}
calculationMode={draft.calculationMode}
/>
{/each}
</div>
{/if}
{/if}
{:else if activeParts[0] === 'gear'}
@@ -478,12 +624,13 @@
<div class="summary-head"><div><p class="eyebrow"><span></span> LIVE SUMMARY</p><h2>AGENT STATS</h2></div><b>{summary.stats.length}</b></div>
<div class="summary-core">{#each ['Firepower','Toughness','Engineering'] as name}{@const stat = summary.stats.find((entry) => attribute(entry.id)?.name === name)}<div><GameIcon name={name.toLowerCase() as GameIconName} decorative/><span>{name}<b>{stat ? unitValue(stat.value,stat.id) : '0'}</b></span></div>{/each}</div>
<div class="builder-combat-results">{#each combatResults as result}<WeaponCalculationResult {result}/>{/each}</div>
<div class="builder-combat-results"><SkillCalculationResult result={skillCombatResult} abilityNames={skillAbilityNames}/></div>
<div class="summary-scroll">
<div class="summary-section"><header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header>{#each summary.stats as stat}<div class="summary-stat"><span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span><b>{unitValue(stat.value,stat.id)}</b></div>{:else}<p>Equip and configure items to calculate attributes.</p>{/each}</div>
<div class="summary-section"><header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>{#each summary.activeBonuses as bonus}<div class="summary-bonus"><span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span><em>+{unitValue(bonus.value,bonus.statId)} {attribute(bonus.statId)?.name}</em></div>{:else}<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>{/each}</div>
<div class="summary-section"><header><span>EQUIPPED TALENTS</span><b>{summary.equippedTalents.length}</b></header>{#each summary.equippedTalents as equipped}<label class="summary-talent"><input type="checkbox" checked={draft.activeTalents.includes(equipped.key)} onchange={() => toggleTalent(equipped.key)}/><span><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source} · {draft.activeTalents.includes(equipped.key) ? 'TRIGGER ACTIVE' : 'CONDITIONAL'}</small><em>{equipped.description}</em>{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}</span></label>{:else}<p>Selected equipment talents appear here.</p>{/each}</div>
<div class="summary-section"><header><span>EQUIPPED TALENTS</span><b>{summary.equippedTalents.length}</b></header>{#each summary.equippedTalents as equipped}<label class="summary-talent">{#if draft.calculationMode === 'custom'}<input type="checkbox" checked={(draft.conditionalEffects[equipped.key]?.mode ?? 'off') !== 'off'} onchange={() => toggleTalent(equipped.key)}/>{/if}<span><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source} · {draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : (draft.conditionalEffects[equipped.key]?.mode ?? 'off') !== 'off' ? 'INCLUDED' : 'CONDITIONAL'}</small><em>{equipped.description}</em>{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}</span></label>{:else}<p>Selected equipment talents appear here.</p>{/each}</div>
</div>
<p class="notice">Talent effects enter totals only while marked trigger active. Anonymous drafts remain on this device.</p>
<p class="notice">{draft.calculationMode === 'maximum' ? 'Maximum Potential applies every configured effect at best-case uptime. ' : 'Custom Scenario uses each mechanic program and the encounter settings saved with this loadout. '}Anonymous drafts remain on this device.</p>
</aside>
</div>
</main>
+77 -1
View File
@@ -6,6 +6,7 @@ import { getDatabase } from '$lib/server/database';
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { MECHANIC_EVENT_REGISTRY, type MechanicEventKind } from '$lib/calculations/mechanic';
const number = (value: unknown) => {
const numeric = Number(value);
@@ -18,6 +19,19 @@ const gearIcon = (slot: unknown) => String(slot).toLowerCase().replaceAll(' ', '
const weaponIcon = (category: unknown) => String(category).toLowerCase() as GameIconName;
const iconUrl = (type: GameDataIconType, id: unknown, updatedAt: unknown) =>
updatedAt ? gameDataIconUrl(type, String(id), updatedAt) : '';
const parsedObject = (value: unknown): Record<string, unknown> => {
try {
const parsed = JSON.parse(String(value ?? '{}'));
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: {};
} catch {
return {};
}
};
const humanized = (value: unknown) => String(value ?? '')
.replaceAll('_', ' ')
.replace(/\b\w/g, (character) => character.toUpperCase());
export const load: PageServerLoad = async ({ params, url }) => {
const category = databaseCategory(params.category);
@@ -330,6 +344,21 @@ export const load: PageServerLoad = async ({ params, url }) => {
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
WHERE effects.brand_set_id IN (${rows.map(() => '?').join(',')}) ORDER BY effects.pieces_required`)).all(...rows.map((row) => row.id))
: [];
const talentMechanics = category.slug === 'talents' && rows.length
? await (await database.prepare(`SELECT rules.source_id AS talent_id,rules.id AS rule_id,
clauses.id AS clause_id,clauses.label AS clause_label,clauses.event_kind,
clauses.conditions_json,clauses.reducer_json,clauses.cooldown_seconds,
outcomes.variant_key,outcomes.outcome_key,outcomes.application_kind,outcomes.effect_scope,
outcomes.value,outcomes.damage_basis,attributes.name AS attribute_name,attributes.unit AS attribute_unit,
outcomes.sort_order
FROM conditional_effect_rules rules
JOIN conditional_effect_clauses clauses ON clauses.rule_id=rules.id AND clauses.active=1
LEFT JOIN conditional_effect_outcomes outcomes ON outcomes.clause_id=clauses.id
LEFT JOIN stat_attributes attributes ON attributes.id=outcomes.stat_attribute_id
WHERE rules.source_type='talent' AND rules.configured=1
AND rules.source_id IN (${rows.map(() => '?').join(',')})
ORDER BY rules.source_id,clauses.sort_order,outcomes.outcome_key,outcomes.sort_order`)).all(...rows.map((row) => row.id))
: [];
const items = rows.map((row) => {
switch (category.slug) {
@@ -491,6 +520,49 @@ export const load: PageServerLoad = async ({ params, url }) => {
const isWeaponTalent = talentCategory === 'Weapon';
const normalizedCategory = talentCategory === 'Vest' ? 'Body Armor' : talentCategory;
const slotLabel = isWeaponTalent && row.weapon_slot ? `WEAPON ${row.weapon_slot}` : normalizedCategory.toUpperCase();
const mechanics = talentMechanics.filter((mechanic) => mechanic.talent_id === row.id);
const rule = mechanics[0];
const groupedOutcomes = new Map<string, any>();
for (const outcome of mechanics.filter((mechanic) => mechanic.variant_key)) {
const key = [
outcome.outcome_key,
outcome.application_kind,
outcome.effect_scope,
outcome.attribute_name ?? '',
outcome.damage_basis ?? ''
].join('|');
const current = groupedOutcomes.get(key) ?? {
outcomeKey: String(outcome.outcome_key),
application: String(outcome.application_kind),
scope: String(outcome.effect_scope),
attribute: String(outcome.attribute_name ?? ''),
unit: outcome.attribute_unit,
damageBasis: outcome.damage_basis,
values: {}
};
current.values[String(outcome.variant_key)] = Number(outcome.value);
groupedOutcomes.set(key, current);
}
const outcomeSummary = [...groupedOutcomes.values()].map((outcome) => {
const subject = outcome.application === 'active_modifier'
? outcome.attribute
: outcome.application === 'direct_damage'
? `Direct Damage · ${outcome.damageBasis === 'weapon_base_percent' ? 'Weapon Base' : 'Flat'}`
: humanized(outcome.application);
const values = ['I', 'II']
.filter((variant) => Number.isFinite(outcome.values[variant]))
.map((variant) => {
const raw = Number(outcome.values[variant]);
const prefix = outcome.application === 'active_modifier' && raw > 0 ? '+' : '';
return `T${variant} ${prefix}${valueWithUnit(raw, outcome.unit)}`;
})
.join(' · ');
return `${subject} · ${values}`;
}).join(' / ');
const activation = [...new Map(mechanics.map((mechanic) => [
String(mechanic.clause_id),
`${mechanic.clause_label || humanized(mechanic.event_kind)} · ${MECHANIC_EVENT_REGISTRY[mechanic.event_kind as MechanicEventKind]?.label ?? humanized(mechanic.event_kind)}`
])).values()].join(' / ');
return {
id: String(row.id), name: String(row.name), kicker: `${slotLabel} TALENT`,
iconUrl: iconUrl('talent',row.id,row.icon_updated_at),
@@ -507,7 +579,11 @@ export const load: PageServerLoad = async ({ params, url }) => {
description: row.tier_i ? String(row.tier_i) : 'Description is not available.',
secondaryDescription: row.tier_ii ? String(row.tier_ii) : 'Description is not available.',
descriptionStyle: 'data' as const,
afterLines: row.cooldown_seconds ? [{ label: 'TALENT COOLDOWN', value: `${number(row.cooldown_seconds)} SECONDS` }] : []
afterLines: [
...(activation ? [{ label: 'ACTIVATES WHEN', value: activation }] : []),
...(outcomeSummary ? [{ label: 'CALCULATION EFFECTS', value: outcomeSummary }] : []),
...(row.cooldown_seconds ? [{ label: 'TALENT COOLDOWN', value: `${number(row.cooldown_seconds)} SECONDS` }] : [])
]
};
}
case 'brands': {
+58 -3
View File
@@ -14,6 +14,12 @@ const operators = new Set([
'reload_speed', 'firing_cycle'
]);
const curves = new Set(['none', 'power', 'smoothstep']);
const skillStages = new Set([
'engineering', 'flat_damage', 'skill_damage', 'final_damage',
'critical_chance', 'critical_damage', 'multishot_chance', 'multishot_damage',
'cooldown_recovery', 'duration'
]);
const skillOperators = new Set(['add', 'multiply']);
type StageInput = {
stageKey?: string;
@@ -32,6 +38,12 @@ type ProfileInput = {
curveParameter?: number | string | null;
weaponCategory?: string;
};
type SkillProfileInput = {
statAttributeId?: string;
stageKey?: string;
operator?: string;
weight?: number | string;
};
const clean = (value: unknown) => String(value ?? '').normalize('NFKC').trim();
const optionalNumber = (value: unknown) => {
@@ -54,6 +66,9 @@ async function revisionSnapshot(database: Awaited<ReturnType<typeof getDatabase>
stages: await (await database.prepare('SELECT * FROM calculation_stages WHERE revision_id=? ORDER BY display_order')).all(id),
profiles: await (await database.prepare(
'SELECT * FROM attribute_calculation_profiles WHERE revision_id=? ORDER BY stage_key,stat_attribute_id'
)).all(id),
skillProfiles: await (await database.prepare(
'SELECT * FROM skill_attribute_calculation_profiles WHERE revision_id=? ORDER BY stage_key,stat_attribute_id'
)).all(id)
};
}
@@ -86,10 +101,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
`SELECT COUNT(*) AS count FROM calculation_calibration_cases calibration ${where}`
)).get(...args))?.count ?? 0);
const pagination = getPagination(url, total);
const [revisions, stages, profiles, attributes, calibrationCases, calibrationSummary] = await Promise.all([
const [revisions, stages, profiles, skillProfiles, attributes, calibrationCases, calibrationSummary] = await Promise.all([
(await database.prepare('SELECT * FROM calculation_revisions ORDER BY version DESC')).all(),
(await database.prepare('SELECT * FROM calculation_stages ORDER BY revision_id,display_order')).all(),
(await database.prepare('SELECT * FROM attribute_calculation_profiles ORDER BY revision_id,stage_key,stat_attribute_id')).all(),
(await database.prepare('SELECT * FROM skill_attribute_calculation_profiles ORDER BY revision_id,stage_key,stat_attribute_id')).all(),
(await database.prepare('SELECT id,name,unit,type,category FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare(`SELECT calibration.*,COALESCE(profile.display_name,user.username,calibration.created_by,'UNKNOWN') AS creator_name
FROM calculation_calibration_cases calibration
@@ -107,7 +123,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
revisions: revisions.map((revision) => ({
...revision,
stages: stages.filter((stage) => stage.revision_id === revision.id),
profiles: profiles.filter((profile) => profile.revision_id === revision.id)
profiles: profiles.filter((profile) => profile.revision_id === revision.id),
skillProfiles: skillProfiles.filter((profile) => profile.revision_id === revision.id)
})),
attributes,
calibrationCases,
@@ -142,6 +159,12 @@ export const actions: Actions = {
curveParameter: optionalNumber(profile.curveParameter),
weaponCategory: clean(profile.weaponCategory) || '*'
}));
const skillProfileInputs = parseArray<SkillProfileInput>(form.get('skillProfilesJson')).map((profile) => ({
statAttributeId: clean(profile.statAttributeId),
stageKey: clean(profile.stageKey),
operator: clean(profile.operator),
weight: Number(profile.weight)
}));
if (name.length < 3 || name.length > 120) return fail(400, { revisionError: 'Revision names must be 3–120 characters.' });
if (stageInputs.length < 2 || stageInputs.length > 30
|| new Set(stageInputs.map((stage) => stage.stageKey)).size !== stageInputs.length
@@ -166,8 +189,23 @@ export const actions: Actions = {
|| (profile.softCap !== null && profile.hardCap !== null && profile.hardCap < profile.softCap)
|| !curves.has(profile.curveType)
)) return fail(400, { revisionError: 'One or more Attribute calculation profiles are invalid.' });
if (skillProfileInputs.length > 2000
|| new Set(skillProfileInputs.map((profile) =>
`${profile.statAttributeId}\u0000${profile.stageKey}`
)).size !== skillProfileInputs.length
|| skillProfileInputs.some((profile) =>
!profile.statAttributeId || !skillStages.has(profile.stageKey)
|| !skillOperators.has(profile.operator)
|| !Number.isFinite(profile.weight)
|| profile.weight < -100 || profile.weight > 100
)) {
return fail(400, { revisionError: 'One or more Skill calculation profiles are invalid or duplicated.' });
}
const database = await getDatabase();
const attributeIds = [...new Set(profileInputs.map((profile) => profile.statAttributeId))];
const attributeIds = [...new Set([
...profileInputs.map((profile) => profile.statAttributeId),
...skillProfileInputs.map((profile) => profile.statAttributeId)
])];
if (attributeIds.length) {
const count = Number((await (await database.prepare(
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${attributeIds.map(() => '?').join(',')})`
@@ -208,6 +246,15 @@ export const actions: Actions = {
profile.softCap, profile.hardCap, profile.curveType, profile.curveParameter, profile.weaponCategory
);
}
await (await database.prepare('DELETE FROM skill_attribute_calculation_profiles WHERE revision_id=?')).run(revisionId);
for (const profile of skillProfileInputs) {
await (await database.prepare(`INSERT INTO skill_attribute_calculation_profiles
(id,revision_id,stat_attribute_id,stage_key,operator,weight)
VALUES(?,?,?,?,?,?)`)).run(
randomUUID(), revisionId, profile.statAttributeId, profile.stageKey,
profile.operator, profile.weight
);
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
@@ -383,6 +430,14 @@ export const actions: Actions = {
profile.curve_type, profile.curve_parameter, profile.weapon_category
);
}
for (const profile of activeSnapshot.skillProfiles) {
await (await database.prepare(`INSERT INTO skill_attribute_calculation_profiles
(id,revision_id,stat_attribute_id,stage_key,operator,weight,soft_cap,hard_cap,curve_type,curve_parameter)
VALUES(?,?,?,?,?,?,?,?,?,?)`)).run(
randomUUID(), revisionId, profile.stat_attribute_id, profile.stage_key, profile.operator,
profile.weight, profile.soft_cap, profile.hard_cap, profile.curve_type, profile.curve_parameter
);
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
+98 -5
View File
@@ -5,7 +5,10 @@
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
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 { createDraft } from '$lib/builder/loadout';
import { formatWeaponCalculationScript } from '$lib/calculations/calculation-script';
let { data, form } = $props();
type StageEditor = {
@@ -25,6 +28,12 @@
curveParameter: number | string;
weaponCategory: string;
};
type SkillProfileEditor = {
statAttributeId: string;
stageKey: string;
operator: 'add' | 'multiply';
weight: number | string;
};
type RevisionEditor = {
id: string;
basedOnRevisionId: string;
@@ -32,6 +41,7 @@
notes: string;
stages: StageEditor[];
profiles: ProfileEditor[];
skillProfiles: SkillProfileEditor[];
};
type CalibrationEditor = {
id: string;
@@ -53,6 +63,14 @@
};
const canPublish = $derived(data.permissions?.includes('*') || data.permissions?.includes('calculation.publish'));
const activeRevision = $derived(data.revisions.find((revision: any) => revision.status === 'active'));
const activeCalculationScript = $derived(activeRevision
? formatWeaponCalculationScript({
revision: activeRevision,
stages: activeRevision.stages,
profiles: activeRevision.profiles,
attributes: data.attributes
})
: []);
const stageItems = (revision: RevisionEditor) => revision.stages.map((stage) => ({
id: stage.stageKey,
name: stage.label,
@@ -65,6 +83,22 @@
];
const stateOptions = [{ value: 'draft', label: 'DRAFT' }, { value: 'ready', label: 'READY' }];
const weaponOptions = [{ value: '0', label: 'PRIMARY' }, { value: '1', label: 'SECONDARY' }];
const skillStageItems = [
{ id: 'engineering', name: 'Engineering', category: 'CORE ATTRIBUTE' },
{ id: 'flat_damage', name: 'Flat damage', category: 'DAMAGE' },
{ id: 'skill_damage', name: 'Skill damage', category: 'DAMAGE' },
{ id: 'final_damage', name: 'Final damage', category: 'DAMAGE' },
{ id: 'critical_chance', name: 'Critical chance', category: 'CRITICAL' },
{ id: 'critical_damage', name: 'Critical damage', category: 'CRITICAL' },
{ id: 'multishot_chance', name: 'Multishot chance', category: 'MULTISHOT' },
{ id: 'multishot_damage', name: 'Multishot damage', category: 'MULTISHOT' },
{ id: 'cooldown_recovery', name: 'Cooldown recovery', category: 'CADENCE' },
{ id: 'duration', name: 'Duration', category: 'CADENCE' }
];
const skillOperatorOptions = [
{ value: 'add', label: 'ADD' },
{ value: 'multiply', label: 'MULTIPLY' }
];
const revisionFrom = (revision: any, edit = false): RevisionEditor => ({
id: edit ? String(revision?.id ?? '') : '',
@@ -87,6 +121,12 @@
curveType: String(profile.curve_type),
curveParameter: profile.curve_parameter ?? '',
weaponCategory: String(profile.weapon_category ?? '*')
})),
skillProfiles: (revision?.skillProfiles ?? []).map((profile: any) => ({
statAttributeId: String(profile.stat_attribute_id),
stageKey: String(profile.stage_key),
operator: String(profile.operator) as 'add' | 'multiply',
weight: Number(profile.weight)
}))
});
const blankCalibration = (): CalibrationEditor => ({
@@ -111,6 +151,7 @@
let publishingId = $state('');
let publishConfirmed = $state(false);
let handledToken = $state('');
let scriptExpanded = $state(false);
function addProfile() {
if (!revisionEditing) return;
@@ -119,6 +160,15 @@
weight: 1, softCap: '', hardCap: '', curveType: 'none', curveParameter: '', weaponCategory: '*'
}];
}
function addSkillProfile() {
if (!revisionEditing) return;
revisionEditing.skillProfiles = [...revisionEditing.skillProfiles, {
statAttributeId: '',
stageKey: 'skill_damage',
operator: 'add',
weight: 1
}];
}
function moveStage(index: number, direction: -1 | 1) {
if (!revisionEditing) return;
const target = index + direction;
@@ -150,10 +200,40 @@
<div class="calculation-kpis">
<div><span>ACTIVE REVISION</span><b>{activeRevision ? `R${activeRevision.version}` : 'NONE'}</b><small>{activeRevision?.name ?? 'NO ACTIVE RULES'}</small></div>
<div><span>ATTRIBUTE PROFILES</span><b>{activeRevision?.profiles.length ?? 0}</b><small>TYPED BINDINGS</small></div>
<div><span>WEAPON PROFILES</span><b>{activeRevision?.profiles.length ?? 0}</b><small>ATTRIBUTE BINDINGS</small></div>
<div><span>SKILL PROFILES</span><b>{activeRevision?.skillProfiles.length ?? 0}</b><small>ABILITY BINDINGS</small></div>
<div><span>READY CASES</span><b>{Number(data.calibrationSummary?.ready ?? 0)}</b><small>{Number(data.calibrationSummary?.predicted ?? 0)} WITH PREDICTIONS</small></div>
</div>
<section class="calculation-script-disclosure" id="script-reference">
<header>
<div>
<p class="eyebrow">GENERATED CALCULATION LANGUAGE</p>
<h3>ACTIVE WEAPON DAMAGE SCRIPT</h3>
<span>The script explains the ordered typed revision. Editors remain the source of calculation data.</span>
</div>
<div>
<MechanicLanguageHelp />
<button
type="button"
class="secondary"
aria-expanded={scriptExpanded}
aria-controls="active-calculation-script"
onclick={() => scriptExpanded = !scriptExpanded}
>{scriptExpanded ? 'HIDE SCRIPT' : 'VIEW SCRIPT'}</button>
</div>
</header>
{#if scriptExpanded}
<div id="active-calculation-script">
{#if activeCalculationScript.length}
<PseudoScript title={`RULES R${activeRevision.version} · WEAPON PIPELINE`} lines={activeCalculationScript} />
{:else}
<p class="empty-script">No active calculation revision is available.</p>
{/if}
</div>
{/if}
</section>
{#if revisionEditing}
<div class="dashboard-editor calculation-revision-editor">
<div class="editor-title"><div><p class="eyebrow">{revisionEditing.id ? 'MODIFY DRAFT REVISION' : 'NEW DRAFT REVISION'}</p><h3>{revisionEditing.name || 'CALCULATION RULE SET'}</h3></div><button aria-label="Close revision editor" onclick={() => revisionEditing = null}>×</button></div>
@@ -162,6 +242,7 @@
<input type="hidden" name="basedOnRevisionId" value={revisionEditing.basedOnRevisionId}/>
<input type="hidden" name="stagesJson" value={JSON.stringify(revisionEditing.stages)}/>
<input type="hidden" name="profilesJson" value={JSON.stringify(revisionEditing.profiles)}/>
<input type="hidden" name="skillProfilesJson" value={JSON.stringify(revisionEditing.skillProfiles)}/>
<div class="calculation-revision-identity"><label class="field isac-record-name">REVISION NAME<input name="name" bind:value={revisionEditing.name} maxlength="120" required/></label><label class="field">NOTES<textarea name="notes" bind:value={revisionEditing.notes} maxlength="4000"></textarea></label></div>
<div class="selection-head"><div><span>ORDERED CALCULATION STAGES</span><small>BASE AND FIRING-CYCLE POSITIONS ARE LOCKED</small></div><b>{revisionEditing.stages.length} STAGES</b></div>
<div class="calculation-stage-list">{#each [...revisionEditing.stages].sort((left, right) => left.displayOrder - right.displayOrder) as stage, index}
@@ -182,6 +263,18 @@
<SegmentedChoice legend="CURVE" name={`profile-curve-${index}`} options={curveOptions} bind:value={profile.curveType}/>
</section>
{/each}</div>
<div class="selection-head"><div><span>SKILL ATTRIBUTE PROFILES</span><small>ABILITY DAMAGE, CRITICAL, MULTISHOT, AND CADENCE BINDINGS</small></div><button type="button" class="secondary" onclick={addSkillProfile}>ADD SKILL PROFILE +</button></div>
<div class="calculation-profile-list skill-profile-list">{#each revisionEditing.skillProfiles as profile, index}
<section>
<header><span>SKILL PROFILE {String(index + 1).padStart(2, '0')}</span><button type="button" class="moderation-action danger" onclick={() => revisionEditing && (revisionEditing.skillProfiles = revisionEditing.skillProfiles.filter((_, itemIndex) => itemIndex !== index))}>REMOVE</button></header>
<div class="field"><span>ATTRIBUTE</span><SearchableAssignment items={data.attributes} mode="single" compact fieldName={`skill-profile-attribute-${index}`} subjectLabel="ATTRIBUTE" searchPlaceholder="Search Attributes…" bind:value={profile.statAttributeId}/></div>
<div class="field"><span>SKILL CALCULATION STAGE</span><SearchableAssignment items={skillStageItems} mode="single" compact fieldName={`skill-profile-stage-${index}`} subjectLabel="STAGE" searchPlaceholder="Search skill stages…" bind:value={profile.stageKey}/></div>
<div class="skill-profile-controls">
<SegmentedChoice legend="OPERATOR" name={`skill-profile-operator-${index}`} options={skillOperatorOptions} bind:value={profile.operator}/>
<label class="field">WEIGHT<input type="number" min="-100" max="100" step="any" bind:value={profile.weight} required/></label>
</div>
</section>
{/each}</div>
{#if form?.revisionError}<p class="error">{form.revisionError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => revisionEditing = null}>CANCEL</button><button class="primary">SAVE DRAFT REVISION →</button></div>
</PendingForm>
@@ -225,7 +318,7 @@
<article class:active={revision.status === 'active'}>
<header><div><span>REVISION {revision.version}</span><h3>{revision.name}</h3></div><b>{revision.status.toUpperCase()}</b></header>
<p>{revision.notes || 'No revision notes.'}</p>
<footer><span>{revision.stages.length} STAGES · {revision.profiles.length} ATTRIBUTE PROFILES</span><div>{#if revision.status === 'draft'}<button class="secondary" onclick={() => revisionEditing = revisionFrom(revision, true)}>EDIT</button>{#if canPublish}<button class="primary" onclick={() => { publishingId = revision.id; publishConfirmed = false; }}>REVIEW + ACTIVATE</button>{/if}{/if}</div></footer>
<footer><span>{revision.stages.length} STAGES · {revision.profiles.length} WEAPON · {revision.skillProfiles.length} SKILL PROFILES</span><div>{#if revision.status === 'draft'}<button class="secondary" onclick={() => revisionEditing = revisionFrom(revision, true)}>EDIT</button>{#if canPublish}<button class="primary" onclick={() => { publishingId = revision.id; publishConfirmed = false; }}>REVIEW + ACTIVATE</button>{/if}{/if}</div></footer>
{#if publishingId === revision.id}
<div class="publish-confirmation">
<div><strong>ACTIVATE REVISION {revision.version}?</strong><span>Every saved and shared build will recalculate using these rules.</span></div>
@@ -252,7 +345,7 @@
</div>
<style>
.module-toolbar>div:first-child{min-width:0;padding-right:12px}.calculation-actions{flex:0 0 auto}.calculation-actions,.calculation-revision-registry article footer div{display:flex;gap:7px;align-items:center}.calculation-kpis{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px;margin-bottom:9px}.calculation-kpis>div{display:grid;gap:2px;padding:10px;border:1px solid var(--shd-border);background:var(--shd-surface)}.calculation-kpis span,.calculation-kpis small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.calculation-kpis b{color:var(--shd-orange);font-size:22px}.calculation-revision-identity,.calibration-identity{display:grid;grid-template-columns:1.2fr 1.8fr;gap:8px}.calculation-stage-list{display:grid;gap:4px}.calculation-stage-list>div{display:grid;grid-template-columns:42px minmax(220px,1fr) minmax(170px,.7fr) 90px 76px;gap:7px;align-items:center;padding:6px;border:1px solid var(--shd-border);background:#0d1011}.calculation-stage-list>div>b{color:var(--shd-orange);text-align:center}.calculation-stage-list>div>span,.calculation-stage-list>div>em{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.stage-order-actions{display:grid;grid-template-columns:1fr 1fr;gap:3px}.stage-order-actions button{min-width:0;height:32px;padding:0}.calculation-profile-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.calculation-profile-list>section{display:grid;gap:7px;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.calculation-profile-list header{display:flex;justify-content:space-between;align-items:center}.calculation-profile-values{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.calibration-identity{grid-template-columns:1.4fr 1fr 1fr 1fr 1fr}.calibration-snapshot textarea{min-height:180px;font-family:var(--shd-font-mono)}.calibration-prediction textarea{min-height:90px;font-family:var(--shd-font-mono)}.calibration-observations{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px}.calculation-revision-registry{display:grid;gap:7px;margin:10px 0}.calculation-revision-registry>article{border:1px solid var(--shd-border);border-left:2px solid var(--shd-border);background:var(--shd-surface)}.calculation-revision-registry>article.active{border-left-color:var(--shd-orange)}.calculation-revision-registry article>header,.calculation-revision-registry article>footer{display:flex;justify-content:space-between;gap:8px;align-items:center;padding:9px}.calculation-revision-registry article>header{border-bottom:1px solid var(--shd-border)}.calculation-revision-registry h3,.calculation-revision-registry p{margin:0}.calculation-revision-registry article>p{padding:9px;color:var(--shd-text-muted)}.calculation-revision-registry article>footer{border-top:1px solid var(--shd-border);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.publish-confirmation{display:grid;grid-template-columns:1fr auto auto;gap:12px;align-items:center;padding:10px;border-top:1px solid var(--shd-orange);background:#1b160d}.publish-confirmation>div{display:grid}.publish-confirmation label{font-size:var(--shd-type-supporting)}.calibration-row{grid-template-columns:minmax(230px,1.4fr) .5fr .65fr .8fr .55fr 70px}
@media(max-width:1000px){.calculation-profile-list{grid-template-columns:1fr}.calibration-identity,.calibration-observations{grid-template-columns:repeat(2,minmax(0,1fr))}.publish-confirmation{grid-template-columns:1fr}.calibration-row{grid-template-columns:1fr .5fr .6fr}.calibration-row>*:nth-child(4),.calibration-row>*:nth-child(5){display:none}}
@media(max-width:650px){.calculation-actions{display:grid}.calculation-kpis{grid-template-columns:1fr}.calculation-revision-identity,.calibration-identity,.calibration-observations,.calculation-stage-list>div,.calculation-profile-values{grid-template-columns:1fr}.calculation-stage-list>div>b{text-align:left}.calculation-revision-registry article>header,.calculation-revision-registry article>footer{align-items:start;display:grid}.calibration-row{grid-template-columns:1fr 70px}.calibration-row>*:nth-child(2),.calibration-row>*:nth-child(3){display:none}}
.module-toolbar>div:first-child{min-width:0;padding-right:12px}.calculation-actions{flex:0 0 auto}.calculation-actions,.calculation-revision-registry article footer div{display:flex;gap:7px;align-items:center}.calculation-kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px;margin-bottom:9px}.calculation-kpis>div{display:grid;gap:2px;padding:10px;border:1px solid var(--shd-border);background:var(--shd-surface)}.calculation-kpis span,.calculation-kpis small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.calculation-kpis b{color:var(--shd-orange);font-size:22px}.calculation-script-disclosure{display:grid;gap:var(--shd-gap-sm);margin-bottom:9px;padding:var(--shd-gap-sm);border:1px solid var(--shd-border);border-top:2px solid var(--shd-success);background:var(--shd-surface)}.calculation-script-disclosure>header{display:flex;justify-content:space-between;align-items:start;gap:var(--shd-gap-md)}.calculation-script-disclosure>header>div:first-child{min-width:0}.calculation-script-disclosure>header>div:last-child{display:flex;gap:7px;flex:0 0 auto}.calculation-script-disclosure h3,.calculation-script-disclosure span,.empty-script{margin:0}.calculation-script-disclosure h3{font-size:var(--shd-type-body);letter-spacing:.7px}.calculation-script-disclosure span,.empty-script{color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}.empty-script{padding:9px;border:1px solid var(--shd-border-soft)}.calculation-revision-identity,.calibration-identity{display:grid;grid-template-columns:1.2fr 1.8fr;gap:8px}.calculation-stage-list{display:grid;gap:4px}.calculation-stage-list>div{display:grid;grid-template-columns:42px minmax(220px,1fr) minmax(170px,.7fr) 90px 76px;gap:7px;align-items:center;padding:6px;border:1px solid var(--shd-border);background:#0d1011}.calculation-stage-list>div>b{color:var(--shd-orange);text-align:center}.calculation-stage-list>div>span,.calculation-stage-list>div>em{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.stage-order-actions{display:grid;grid-template-columns:1fr 1fr;gap:3px}.stage-order-actions button{min-width:0;height:32px;padding:0}.calculation-profile-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.calculation-profile-list>section{display:grid;gap:7px;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.calculation-profile-list header{display:flex;justify-content:space-between;align-items:center}.calculation-profile-values{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.skill-profile-controls{display:grid;grid-template-columns:minmax(0,1fr) minmax(120px,.42fr);gap:7px;align-items:end}.calibration-identity{grid-template-columns:1.4fr 1fr 1fr 1fr 1fr}.calibration-snapshot textarea{min-height:180px;font-family:var(--shd-font-mono)}.calibration-prediction textarea{min-height:90px;font-family:var(--shd-font-mono)}.calibration-observations{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px}.calculation-revision-registry{display:grid;gap:7px;margin:10px 0}.calculation-revision-registry>article{border:1px solid var(--shd-border);border-left:2px solid var(--shd-border);background:var(--shd-surface)}.calculation-revision-registry>article.active{border-left-color:var(--shd-orange)}.calculation-revision-registry article>header,.calculation-revision-registry article>footer{display:flex;justify-content:space-between;gap:8px;align-items:center;padding:9px}.calculation-revision-registry article>header{border-bottom:1px solid var(--shd-border)}.calculation-revision-registry h3,.calculation-revision-registry p{margin:0}.calculation-revision-registry article>p{padding:9px;color:var(--shd-text-muted)}.calculation-revision-registry article>footer{border-top:1px solid var(--shd-border);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.publish-confirmation{display:grid;grid-template-columns:1fr auto auto;gap:12px;align-items:center;padding:10px;border-top:1px solid var(--shd-orange);background:#1b160d}.publish-confirmation>div{display:grid}.publish-confirmation label{font-size:var(--shd-type-supporting)}.calibration-row{grid-template-columns:minmax(230px,1.4fr) .5fr .65fr .8fr .55fr 70px}
@media(max-width:1000px){.calculation-kpis{grid-template-columns:repeat(2,minmax(0,1fr))}.calculation-profile-list{grid-template-columns:1fr}.calibration-identity,.calibration-observations{grid-template-columns:repeat(2,minmax(0,1fr))}.publish-confirmation{grid-template-columns:1fr}.calibration-row{grid-template-columns:1fr .5fr .6fr}.calibration-row>*:nth-child(4),.calibration-row>*:nth-child(5){display:none}}
@media(max-width:650px){.calculation-actions{display:grid}.calculation-kpis{grid-template-columns:1fr}.calculation-script-disclosure>header,.calculation-script-disclosure>header>div:last-child{display:grid}.calculation-script-disclosure>header>div:last-child>*{width:100%;min-height:44px}.calculation-revision-identity,.calibration-identity,.calibration-observations,.calculation-stage-list>div,.calculation-profile-values,.skill-profile-controls{grid-template-columns:1fr}.calculation-stage-list>div>b{text-align:left}.calculation-revision-registry article>header,.calculation-revision-registry article>footer{align-items:start;display:grid}.calibration-row{grid-template-columns:1fr 70px}.calibration-row>*:nth-child(2),.calibration-row>*:nth-child(3){display:none}}
</style>
@@ -8,9 +8,10 @@ import { getPagination, getRegistrySort } from '$lib/server/pagination';
import { recordIsacAudit } from '$lib/server/audit';
import type { Actions, PageServerLoad } from './$types';
import { createNotification } from '$lib/server/notifications';
import { conditionalRuleSnapshot } from '$lib/server/conditional-effects';
const statuses = ['pending', 'approved', 'rejected', 'failed'];
const modules = ['configuration', 'calculations', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips', 'specializations'];
const modules = ['configuration', 'calculations', 'specializations', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips'];
type SerializedEntry =
| { name: string; kind: 'text'; value: string }
| { name: string; kind: 'file'; value: string; fileName: string; type: string };
@@ -33,6 +34,9 @@ async function specializationProposalSnapshot(database: any, proposal: any) {
if (proposal.action === 'saveAbility') {
return {
record: await (await database.prepare('SELECT * FROM specialization_abilities WHERE id=?')).get(id) ?? null,
attack: await (await database.prepare(
'SELECT * FROM specialization_ability_attacks WHERE ability_id=?'
)).get(id) ?? null,
effects: await (await database.prepare(
'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order'
)).all(id)
@@ -90,12 +94,12 @@ async function skillChipProposalSnapshot(database: any, id: string) {
'SELECT stat_attribute_id FROM skill_chip_secondary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id'
)).all(id),
talent: talent ?? null,
talentTriggers: talent ? await (await database.prepare(
'SELECT trigger_id FROM skill_chip_talent_triggers WHERE talent_id=? ORDER BY trigger_id'
)).all(talent.id) : [],
talentEffects: talent ? await (await database.prepare(
'SELECT * FROM skill_chip_talent_effects WHERE talent_id=? ORDER BY sort_order'
)).all(talent.id) : []
)).all(talent.id) : [],
conditional: talent
? await conditionalRuleSnapshot(database, 'skill_chip_talent', String(talent.id))
: null
};
}
@@ -107,6 +111,9 @@ async function calculationProposalSnapshot(database: any, proposal: any) {
stages: await (await database.prepare('SELECT * FROM calculation_stages WHERE revision_id=? ORDER BY display_order')).all(id),
profiles: await (await database.prepare(
'SELECT * FROM attribute_calculation_profiles WHERE revision_id=? ORDER BY stage_key,stat_attribute_id'
)).all(id),
skillProfiles: await (await database.prepare(
'SELECT * FROM skill_attribute_calculation_profiles WHERE revision_id=? ORDER BY stage_key,stat_attribute_id'
)).all(id)
};
if (proposal.action === 'saveCalibration') return {
@@ -0,0 +1,24 @@
import type { PageServerLoad } from './$types';
import { requireDataView } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
export const load: PageServerLoad = async ({ cookies }) => {
await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
const attributes = await (await database.prepare(`
SELECT id,name,description,unit,type,category,calculation_type,cap_value,
gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max
FROM stat_attributes
WHERE active=1
ORDER BY category COLLATE NOCASE,type COLLATE NOCASE,name COLLATE NOCASE
`)).all();
return {
attributes,
attributeSummary: {
total: attributes.length,
documented: attributes.filter((attribute) => String(attribute.description ?? '').trim()).length
}
};
};
File diff suppressed because it is too large Load Diff
+78 -49
View File
@@ -8,6 +8,13 @@ import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
import { getPendingEntities } from '$lib/server/data-moderation';
import {
conditionalRuleSnapshot,
loadConditionalRuleDraft,
parseConditionalRulePayload,
saveConditionalRuleInTransaction
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
@@ -18,14 +25,11 @@ const expectedSecondaryAttributes: Record<string, Record<string, string>> = {
Engineering: { Core: 'Engineering', Damage: 'Skill Damage', Health: 'Health' }
};
const parseList = (value: FormDataEntryValue | null): any[] => {
try {
const parsed = JSON.parse(String(value ?? '[]'));
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const mechanicOutcomes = (rule: ReturnType<typeof parseConditionalRulePayload>) =>
rule.clauses.flatMap((clause) => clause.outcomes);
const descriptionOutcomes = (rule: ReturnType<typeof parseConditionalRulePayload>) =>
mechanicOutcomes(rule).filter((outcome) => outcome.descriptionSlot)
.sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot));
async function protocolSnapshot(id: string) {
const database = await getDatabase();
@@ -35,8 +39,8 @@ async function protocolSnapshot(id: string) {
icon: await gameDataIconMetadata(database, 'os_protocol', id),
primaryEffect: await (await database.prepare('SELECT * FROM os_protocol_primary_effects WHERE protocol_id=?')).get(id) ?? null,
talent: talent ?? null,
talentTriggers: talent ? await (await database.prepare('SELECT trigger_id FROM os_protocol_talent_triggers WHERE talent_id=? ORDER BY trigger_id')).all(talent.id) : [],
talentEffects: talent ? await (await database.prepare('SELECT * FROM os_protocol_talent_effects WHERE talent_id=? ORDER BY sort_order')).all(talent.id) : []
talentEffects: talent ? await (await database.prepare('SELECT * FROM os_protocol_talent_effects WHERE talent_id=? ORDER BY sort_order')).all(talent.id) : [],
conditional: talent ? await conditionalRuleSnapshot(database, 'os_protocol_talent', String(talent.id)) : null
};
}
@@ -92,21 +96,18 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(await database.prepare(`SELECT COUNT(*) AS total FROM os_protocols ${where}`)).get(...args)
]);
const pagination = getPagination(url, Number(filtered?.total ?? 0));
const [protocols, primaryEffects, profiles, secondaryEffects, talents, talentTriggers, talentEffects, stats, triggers, icons, pending] = await Promise.all([
const [protocols, primaryEffects, profiles, secondaryEffects, talents, talentEffects, stats, icons, pending] = await Promise.all([
(await database.prepare(`SELECT * FROM os_protocols ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset),
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
(await database.prepare('SELECT * FROM os_protocol_secondary_profiles ORDER BY quality,primary_attribute_type')).all(),
(await database.prepare('SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role')).all(),
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
(await database.prepare('SELECT * FROM os_protocol_talent_triggers')).all(),
(await database.prepare('SELECT * FROM os_protocol_talent_effects ORDER BY sort_order')).all(),
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT id,name,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='os_protocol'")).all(),
getPendingEntities(user.id, [
{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] },
{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] },
{ module: 'protocols', action: 'save', kind: 'protocol', fields: ['quality', 'attributeType', 'primaryStatId', 'primaryBaseValue', 'talentName', 'descriptionTemplate', 'talentEffects', 'triggerIds'] }
{ module: 'protocols', action: 'save', kind: 'protocol', fields: ['quality', 'attributeType', 'primaryStatId', 'primaryBaseValue', 'talentName', 'descriptionTemplate', 'conditionalRule'] }
])
]);
const configuredProfiles = profiles.map((profile) => ({ ...profile, effects: secondaryEffects.filter((effect) => effect.profile_id === profile.id) }));
@@ -125,12 +126,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talent: {
name: protocol.talentName,
description_template: protocol.descriptionTemplate,
effects: (() => { try { return JSON.parse(String(protocol.talentEffects ?? '[]')); } catch { return []; } })(),
triggerIds: Array.isArray(protocol.triggerIds) ? protocol.triggerIds : [protocol.triggerIds].filter(Boolean)
conditionalRule: (() => { try { return JSON.parse(String(protocol.conditionalRule ?? 'null')); } catch { return null; } })()
}
}));
return {
protocols: [...pendingProtocols, ...protocols.map((protocol) => {
const configuredProtocols = await Promise.all(protocols.map(async (protocol) => {
const talent = talents.find((item) => item.protocol_id === protocol.id);
return {
...protocol,
@@ -139,16 +138,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
secondaryProfile: configuredProfiles.find((profile) => profile.quality === protocol.quality && profile.primary_attribute_type === protocol.primary_attribute_type) ?? null,
talent: talent ? {
...talent,
triggerIds: talentTriggers.filter((item) => item.talent_id === talent.id).map((item) => String(item.trigger_id)),
effects: talentEffects.filter((item) => item.talent_id === talent.id)
effects: talentEffects.filter((item) => item.talent_id === talent.id),
conditionalRule: await loadConditionalRuleDraft(database, 'os_protocol_talent', String(talent.id), ['base'])
} : null
};
})],
}));
return {
protocols: [...pendingProtocols, ...configuredProtocols],
profiles: qualities.flatMap((quality) => attributeTypes.map((attributeType) => configuredProfiles.find((profile) =>
profile.quality === quality && profile.primary_attribute_type === attributeType
) ?? { id: '', quality, primary_attribute_type: attributeType, effects: [] })),
stats: [...pending.filter((item) => item.kind === 'attribute'), ...stats],
triggers: [...pending.filter((item) => item.kind === 'trigger'), ...triggers],
summary: { ...summary, total: Number(summary?.total ?? 0) + pending.filter((item) => item.kind === 'protocol').length, pending: pending.filter((item) => item.kind === 'protocol').length }, filter, pagination, search, sort
};
};
@@ -191,44 +191,73 @@ export const actions: Actions = {
const primaryBaseValue = Number(form.get('primaryBaseValue'));
const talentName = String(form.get('talentName') ?? '').normalize('NFKC').trim();
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').normalize('NFKC').trim();
const triggerIds = [...new Set(form.getAll('triggerIds').map(String).filter(Boolean))];
const talentEffects = parseList(form.get('talentEffects')).map((item, index) => ({
statId: String(item.statId ?? ''), baseValue: Number(item.baseValue), sortOrder: index + 1
}));
const database = await getDatabase();
let conditionalRule;
try {
const conditionalRuleRaw = form.get('conditionalRule');
const activeStats = await (await database.prepare('SELECT id FROM stat_attributes WHERE active=1')).all();
conditionalRule = parseConditionalRulePayload(conditionalRuleRaw, [
{ key: 'base', variantKey: 'base' }
], {
allowedAttributeIds: new Set(activeStats.map((row) => String(row.id))),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY))
});
} catch (error) {
return fail(400, { protocolError: error instanceof Error ? error.message : 'The conditional mechanic is invalid.' });
}
if (name.length < 2 || name.length > 80) return fail(400, { protocolError: 'Protocol names must be 2–80 characters.' });
if (!qualities.includes(quality) || !attributeTypes.includes(attributeType)) return fail(400, { protocolError: 'Choose a valid quality and primary attribute type.' });
if (!primaryStatId || !Number.isFinite(primaryBaseValue) || primaryBaseValue < 0) return fail(400, { protocolError: 'Choose the protocol’s unique primary stat and a non-negative level-1 base value.' });
if (talentName.length < 2 || talentName.length > 80 || !descriptionTemplate || !triggerIds.length || !talentEffects.length) return fail(400, { protocolError: 'The unique talent requires a name, description template, trigger, and at least one effect.' });
if (talentEffects.length > 12 || talentEffects.some((item) => !item.statId || !Number.isFinite(item.baseValue))) return fail(400, { protocolError: 'Talent effects require valid attributes and base values.' });
if (new Set(talentEffects.map((item) => item.statId)).size !== talentEffects.length) return fail(400, { protocolError: 'Each talent effect may reference an attribute only once.' });
const outcomes = mechanicOutcomes(conditionalRule);
const numberedOutcomes = descriptionOutcomes(conditionalRule);
const durationSeconds = conditionalRule.pools.find((pool) => pool.expiresAfterSeconds)?.expiresAfterSeconds ?? null;
const maxStacks = conditionalRule.pools.length ? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1)) : null;
if (talentName.length < 2 || talentName.length > 80 || !descriptionTemplate || !conditionalRule.clauses.length || !outcomes.length) return fail(400, { protocolError: 'The unique talent requires a name, description template, mechanic branch, and at least one outcome.' });
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1]));
if (placeholders.some((index) => index < 1 || index > talentEffects.length)) return fail(400, { protocolError: 'Description placeholders must reference configured talent effects.' });
const database = await getDatabase();
if (placeholders.some((index) => !numberedOutcomes.some((outcome) => outcome.descriptionSlot === index))) return fail(400, { protocolError: 'Description placeholders must reference configured numbered outcomes.' });
if (descriptionTemplate.includes('{duration}') && durationSeconds === null) return fail(400, { protocolError: 'The description uses {duration}, but no expiring state pool is defined.' });
if (descriptionTemplate.includes('{max_stacks}') && !maxStacks) return fail(400, { protocolError: 'The description uses {max_stacks}, but no state pool maximum is defined.' });
if (!await (await database.prepare('SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?')).get(quality, attributeType)) return fail(400, { protocolError: 'Configure the shared secondary profile for this quality and attribute type first.' });
if (await (await database.prepare('SELECT id FROM os_protocols WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) return fail(409, { protocolError: 'That OS Protocol already exists.' });
const existingTalent = id ? await (await database.prepare('SELECT id FROM os_protocol_talents WHERE protocol_id=?')).get(id) : null;
if (await (await database.prepare('SELECT id FROM os_protocol_talents WHERE name=? COLLATE NOCASE AND id<>?')).get(talentName, existingTalent?.id ?? 'new')) return fail(409, { protocolError: 'That protocol talent name is already in use.' });
const statIds = [...new Set([primaryStatId, ...talentEffects.map((item) => item.statId)])];
const outcomeStatIds = outcomes.map((item) => item.attributeId).filter((value): value is string => Boolean(value));
const statIds = [...new Set([primaryStatId, ...outcomeStatIds])];
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds);
if (Number(validStats?.count) !== statIds.length) return fail(400, { protocolError: 'One or more selected attributes are unavailable.' });
const validTriggers = await (await database.prepare(`SELECT COUNT(*) AS count FROM talent_triggers WHERE active=1 AND id IN (${triggerIds.map(() => '?').join(',')})`)).get(...triggerIds);
if (Number(validTriggers?.count) !== triggerIds.length) return fail(400, { protocolError: 'One or more selected triggers are unavailable.' });
const protocolId = id || randomUUID(), talentId = String(existingTalent?.id ?? randomUUID()), now = new Date().toISOString();
const before = id ? await protocolSnapshot(id) : null;
if (id) {
if (!await auditRecord('os_protocols', id)) return fail(404, { protocolError: 'OS Protocol not found.' });
await (await database.prepare('UPDATE os_protocols SET name=?,quality=?,primary_attribute_type=?,active=1,updated_at=? WHERE id=?')).run(name, quality, attributeType, now, id);
} else await (await database.prepare('INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?,?)')).run(protocolId, name, quality, attributeType, now, now);
await (await database.prepare(`INSERT INTO os_protocol_primary_effects(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,base_value=excluded.base_value`)).run(randomUUID(), protocolId, primaryStatId, primaryBaseValue);
await (await database.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)).run(talentId, protocolId, talentName, descriptionTemplate, now, now);
await (await database.prepare('DELETE FROM os_protocol_talent_triggers WHERE talent_id=?')).run(talentId);
for (const triggerId of triggerIds) await (await database.prepare('INSERT INTO os_protocol_talent_triggers(talent_id,trigger_id) VALUES(?,?)')).run(talentId, triggerId);
await (await database.prepare('DELETE FROM os_protocol_talent_effects WHERE talent_id=?')).run(talentId);
for (const effect of talentEffects) await (await database.prepare('INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)')).run(randomUUID(), talentId, effect.statId, effect.baseValue, effect.sortOrder);
await applyIconMutation(database, 'os_protocol', protocolId, iconMutation, operator.id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'os_protocol.updated' : 'os_protocol.created', entityId: protocolId, before, after: await protocolSnapshot(protocolId) });
if (id && !before?.record) return fail(404, { protocolError: 'OS Protocol not found.' });
await database.exec('BEGIN');
try {
if (id) {
await (await database.prepare('UPDATE os_protocols SET name=?,quality=?,primary_attribute_type=?,active=1,updated_at=? WHERE id=?')).run(name, quality, attributeType, now, id);
} else await (await database.prepare('INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?,?)')).run(protocolId, name, quality, attributeType, now, now);
await (await database.prepare(`INSERT INTO os_protocol_primary_effects(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,base_value=excluded.base_value`)).run(randomUUID(), protocolId, primaryStatId, primaryBaseValue);
await (await database.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)).run(talentId, protocolId, talentName, descriptionTemplate, now, now);
await (await database.prepare('DELETE FROM os_protocol_talent_effects WHERE talent_id=?')).run(talentId);
let compatibilitySortOrder = 0;
for (const outcome of numberedOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId) continue;
compatibilitySortOrder += 1;
await (await database.prepare('INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)')).run(
randomUUID(),
talentId,
outcome.attributeId,
outcome.variants.base.value,
compatibilitySortOrder
);
}
await saveConditionalRuleInTransaction(database, 'os_protocol_talent', talentId, conditionalRule);
await applyIconMutation(database, 'os_protocol', protocolId, iconMutation, operator.id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'os_protocol.updated' : 'os_protocol.created', entityId: protocolId, before, after: await protocolSnapshot(protocolId) });
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return { protocolSaved: true, protocolMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
archive: async ({ request, cookies }) => {
+40 -22
View File
@@ -10,9 +10,10 @@
import IconUploadField from '$lib/components/IconUploadField.svelte';
import ItemIcon from '$lib/components/ItemIcon.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
let { data, form } = $props();
type TalentEffect = { stat_attribute_id: string; base_value: number | string };
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
const secondaryRoles = ['Core', 'Damage', 'Health'];
@@ -21,7 +22,15 @@
Damage: type === 'Engineering' ? 'Skill Damage' : 'All Weapon Damage',
Health: 'Health'
});
const blankTalent = () => ({ id: '', name: '', description_template: '', triggerIds: [], effects: [{ stat_attribute_id: '', base_value: '' }] });
const talentValueColumns = [{ key: 'base', variantKey: 'base', label: 'BASE' }];
const blankRule = (): ConditionalMechanicDraftV1 => ({
version: 1,
simulationReadiness: 'captured_only',
scenarioInputs: [],
pools: [],
clauses: [{key:'primary',label:'Primary',event:'tick',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'self',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{base:{value:0}}}]}]
});
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
const blank = () => ({ id: '', name: '', iconUrl: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, talent: blankTalent() });
let editing = $state<any | null>(null);
let profileEditing = $state<any | null>(null);
@@ -46,16 +55,23 @@
const profileFor = (quality: string, type: string) => data.profiles.find((profile) => profile.quality === quality && profile.primary_attribute_type === type);
const profileEffect = (profile: any, role: string) => profile.effects.find((effect: any) => effect.effect_role === role);
const configuredProfile = () => profileFor(editing.quality, editing.primary_attribute_type)?.effects.length === 3;
const talentPreview = (upgrades: number) => editing.talent.description_template.replace(/\{(\d+)\}/g, (placeholder: string, rawIndex: string) => {
const effect = editing.talent.effects[Number(rawIndex) - 1];
return effect ? format(upgradeValue(effect.base_value, upgrades), effect.stat_attribute_id) : placeholder;
const previewOutcomes = () => editing.talent.conditionalRule.clauses
.flatMap((clause: any) => clause.outcomes)
.filter((outcome: any) => outcome.descriptionSlot)
.sort((left: any, right: any) => left.descriptionSlot - right.descriptionSlot);
const talentPreview = (upgrades: number) => editing.talent.description_template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
const duration = editing.talent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds;
const maxStacks = editing.talent.conditionalRule.pools.length ? Math.max(...editing.talent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null;
if (expression === 'duration') return duration == null ? placeholder : `${duration} seconds`;
if (expression === 'max_stacks') return maxStacks == null ? placeholder : `${maxStacks} stacks`;
const outcome = previewOutcomes().find((item: any) => item.descriptionSlot === Number(expression));
const baseValue = outcome?.variants?.base?.value;
const scalesWithUpgrades = JSON.stringify(outcome?.variants?.base?.expression ?? {})
.includes('protocolTalentUpgrades');
return outcome && baseValue !== '' && baseValue !== null && baseValue !== undefined
? format(scalesWithUpgrades ? upgradeValue(baseValue, upgrades) : baseValue, outcome.attributeId)
: placeholder;
});
const addTalentEffect = () => {
if (editing.talent.effects.length < 12) editing.talent.effects = [...editing.talent.effects, { stat_attribute_id: '', base_value: '' }];
};
const removeTalentEffect = (index: number) => {
if (editing.talent.effects.length > 1) editing.talent.effects = editing.talent.effects.filter((_: TalentEffect, effectIndex: number) => effectIndex !== index);
};
const editProfile = (profile: any) => {
profileEditing = {
...profile,
@@ -69,8 +85,7 @@
primaryEffect: protocol.primaryEffect ? { ...protocol.primaryEffect } : { stat_attribute_id: '', base_value: '' },
talent: protocol.talent ? {
...protocol.talent,
triggerIds: [...protocol.talent.triggerIds],
effects: protocol.talent.effects.map((effect: TalentEffect) => ({ ...effect }))
conditionalRule: protocol.talent.conditionalRule ?? blankRule()
} : blankTalent()
};
profileEditing = null;
@@ -136,7 +151,7 @@
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY PROTOCOL' : 'NEW PROTOCOL'}</p><h3>{editing.id ? editing.name : 'OS PROTOCOL DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="talentEffects" value={JSON.stringify(editing.talent.effects.map((effect: TalentEffect) => ({ statId: effect.stat_attribute_id, baseValue: effect.base_value })))} />
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)} />
<div class="protocol-identity-grid">
<div class="icon-editor-identity"><IconUploadField currentUrl={editing.iconUrl} label="OS PROTOCOL ICON" resetToken={iconResetToken}/><label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label></div>
<div class="protocol-classification-stack">
@@ -152,16 +167,19 @@
<div class="protocol-primary-editor"><div class="field"><span>PRIMARY STAT</span><SearchableAssignment items={data.stats} fieldName="primaryStatId" mode="single" compact subjectLabel="PRIMARY ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={editing.primaryEffect.stat_attribute_id}/></div><label class="field">LEVEL 1 BASE VALUE<input name="primaryBaseValue" type="number" step="any" min="0" bind:value={editing.primaryEffect.base_value} required/></label><div><span>LEVEL {protocolLevel} PREVIEW</span><b>{format(levelValue(editing.primaryEffect.base_value, protocolLevel), editing.primaryEffect.stat_attribute_id)}</b><small>LEVEL 50 · {format(levelValue(editing.primaryEffect.base_value, 50), editing.primaryEffect.stat_attribute_id)}</small></div></div>
<section class="protocol-talent">
<div class="selection-head"><div><span>UNIQUE PROTOCOL TALENT</span><small>OWNED EXCLUSIVELY BY THIS PROTOCOL · FIVE LINEAR UPGRADES</small></div><b>{editing.talent.id ? 'LINKED RECORD' : 'NEW RECORD'}</b></div>
<div class="selection-head"><div><span>UNIQUE PROTOCOL TALENT</span><small>OWNED EXCLUSIVELY BY THIS PROTOCOL · FIVE LINEAR UPGRADES FOR MARKED VALUES</small></div><b>{editing.talent.id ? 'LINKED RECORD' : 'NEW RECORD'}</b></div>
<div class="protocol-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values and units.</small></label></div>
<div class="protocol-upgrade-preview"><div class="diamond-control" role="group" aria-label="Preview talent upgrade level"><button type="button" class:active={talentUpgrade === 0} aria-pressed={talentUpgrade === 0} onclick={() => talentUpgrade = 0}>BASE</button>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrade >= upgrade} aria-label={`Preview talent at upgrade ${upgrade}`} aria-pressed={talentUpgrade === upgrade} onclick={() => talentUpgrade = upgrade}><span>◆</span><small>{upgrade}</small></button>{/each}</div><div><span>UPGRADE {talentUpgrade} / 5 · {talentUpgrade === 5 ? '2× EFFECT VALUE' : `${Math.round((1 + talentUpgrade / 5) * 100)}% EFFECT VALUE`}</span><p>{talentPreview(talentUpgrade) || 'Talent description preview'}</p></div></div>
<div class="protocol-talent-grid">
<div><div class="selection-head"><div><span>ACTIVATION TRIGGERS</span><small>REUSABLE TRIGGER DEFINITIONS</small></div><b>{editing.talent.triggerIds.length}</b></div><SearchableAssignment items={data.triggers} fieldName="triggerIds" bind:selected={editing.talent.triggerIds}/></div>
<div><div class="selection-head"><div><span>TALENT EFFECTS</span><small>BASE VALUE AT ZERO UPGRADES</small></div><button type="button" class="row-action" onclick={addTalentEffect} disabled={editing.talent.effects.length >= 12}>ADD EFFECT +</button></div><div class="protocol-talent-effects">{#each editing.talent.effects as effect, index}<div><strong>{index + 1}</strong><SearchableAssignment items={data.stats} mode="single" compact subjectLabel="ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={effect.stat_attribute_id}/><input type="number" step="any" bind:value={effect.base_value} placeholder="BASE VALUE" required/><span>{format(upgradeValue(effect.base_value, 5), effect.stat_attribute_id)} AT +5</span><button type="button" aria-label={`Remove talent effect ${index + 1}`} onclick={() => removeTalentEffect(index)} disabled={editing.talent.effects.length === 1}>×</button></div>{/each}</div></div>
</div>
<div class="protocol-upgrade-preview"><div class="diamond-control" role="group" aria-label="Preview talent upgrade level"><button type="button" class:active={talentUpgrade === 0} aria-pressed={talentUpgrade === 0} onclick={() => talentUpgrade = 0}>BASE</button>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrade >= upgrade} aria-label={`Preview talent at upgrade ${upgrade}`} aria-pressed={talentUpgrade === upgrade} onclick={() => talentUpgrade = upgrade}><span>◆</span><small>{upgrade}</small></button>{/each}</div><div><span>UPGRADE {talentUpgrade} / 5 · {talentUpgrade === 5 ? '2× SCALABLE VALUES' : `${Math.round((1 + talentUpgrade / 5) * 100)}% SCALABLE VALUES`}</span><p>{talentPreview(talentUpgrade) || 'Talent description preview'}</p></div></div>
<MechanicProgramEditor
attributes={data.stats}
valueColumns={talentValueColumns}
idPrefix={`protocol-talent-${editing.talent.id || 'new'}`}
allowProtocolUpgradeScaling
bind:rule={editing.talent.conditionalRule}
/>
</section>
{#if form?.protocolError}<p class="error">{form.protocolError}</p>{/if}
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE PROTOCOL</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!configuredProfile() || data.stats.length === 0 || data.triggers.length === 0}>SAVE PROTOCOL →</button></div></div>
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE PROTOCOL</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!configuredProfile() || data.stats.length === 0}>SAVE PROTOCOL →</button></div></div>
</PendingForm>
</div>
{/if}
@@ -172,7 +190,7 @@
<div class="dashboard-table protocol-dashboard-table">
<div class="dashboard-row protocol-row table-heading"><SortableColumnHeader label="OS PROTOCOL" column="name" sort={data.sort}/><SortableColumnHeader label="QUALITY" column="quality" sort={data.sort}/><SortableColumnHeader label="TYPE" column="type" sort={data.sort}/><SortableColumnHeader label="UNIQUE PRIMARY" column="primary" sort={data.sort}/><SortableColumnHeader label="UNIQUE TALENT" column="talent" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.protocols as protocol}
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1} class:pending-record={protocol.pending} class:registry-row-editing={editing?.id === protocol.id}><div class="registry-icon-identity"><ItemIcon src={protocol.iconUrl} alt=""/><div><strong>{protocol.name}</strong><small>{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT CONFIGURED'}</strong><small><span class="mini-diamonds">◆◆◆◆◆</span> 5 UPGRADES · 2× MAX</small></div><b class:pending={protocol.pending} class="state-dot">{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if protocol.pending}<span></span>{:else}<button aria-expanded={editing?.id === protocol.id} aria-controls={`protocol-editor-${protocol.id}`} onclick={() => edit(protocol)}>EDIT</button>{/if}</div>
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1} class:pending-record={protocol.pending} class:registry-row-editing={editing?.id === protocol.id}><div class="registry-icon-identity"><ItemIcon src={protocol.iconUrl} alt=""/><div><strong>{protocol.name}</strong><small>{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT CONFIGURED'}</strong><small><span class="mini-diamonds">◆◆◆◆◆</span> {protocol.talent?.conditionalRule?.clauses?.length || protocol.pending ? 'MODELED · ' : 'CONFIG REQUIRED · '}5 UPGRADES</small></div><b class:pending={protocol.pending} class="state-dot">{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if protocol.pending}<span></span>{:else}<button aria-expanded={editing?.id === protocol.id} aria-controls={`protocol-editor-${protocol.id}`} onclick={() => edit(protocol)}>EDIT</button>{/if}</div>
{#if editing?.id === protocol.id}<RegistryInlineEditor id={`protocol-editor-${protocol.id}`} label={`Edit ${protocol.name}`}>{@render protocolEditor()}</RegistryInlineEditor>{/if}
{:else}<div class="empty-dashboard"><strong>NO OS PROTOCOLS REGISTERED</strong><span>Configure shared secondary profiles, then add the first protocol.</span></div>{/each}
</div>
+89 -63
View File
@@ -9,23 +9,19 @@ import { refreshAbilityFocuses } from '$lib/server/specialization-readiness';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import {
conditionalRuleSnapshot,
loadConditionalRuleDraft,
parseConditionalRulePayload,
saveConditionalRuleInTransaction
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
type Effect = { statId: string; minValue: number; maxValue: number; sortOrder: number };
const parseEffects = (value: FormDataEntryValue | null): Effect[] => {
try {
const parsed = JSON.parse(String(value ?? '[]'));
if (!Array.isArray(parsed)) return [];
return parsed.map((item, index) => ({
statId: String(item.statId ?? ''),
minValue: Number(item.minValue),
maxValue: Number(item.maxValue),
sortOrder: index + 1
}));
} catch {
return [];
}
};
const mechanicOutcomes = (rule: ReturnType<typeof parseConditionalRulePayload>) =>
rule.clauses.flatMap((clause) => clause.outcomes);
const descriptionOutcomes = (rule: ReturnType<typeof parseConditionalRulePayload>) =>
mechanicOutcomes(rule).filter((outcome) => outcome.descriptionSlot)
.sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot));
async function snapshot(id: string) {
const database = await getDatabase();
@@ -37,8 +33,8 @@ async function snapshot(id: string) {
primaryOptions: await (await database.prepare('SELECT stat_attribute_id FROM skill_chip_primary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id')).all(id),
secondaryOptions: await (await database.prepare('SELECT stat_attribute_id FROM skill_chip_secondary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id')).all(id),
talent: talent ?? null,
talentTriggers: talent ? await (await database.prepare('SELECT trigger_id FROM skill_chip_talent_triggers WHERE talent_id=? ORDER BY trigger_id')).all(talent.id) : [],
talentEffects: talent ? await (await database.prepare('SELECT * FROM skill_chip_talent_effects WHERE talent_id=? ORDER BY sort_order')).all(talent.id) : []
talentEffects: talent ? await (await database.prepare('SELECT * FROM skill_chip_talent_effects WHERE talent_id=? ORDER BY sort_order')).all(talent.id) : [],
conditional: talent ? await conditionalRuleSnapshot(database, 'skill_chip_talent', String(talent.id)) : null
};
}
@@ -64,7 +60,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
];
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const args = search ? [pattern] : [];
const [summary, filtered, pendingStats, pendingTriggers, pendingSets, pendingAbilities, pendingSpecializations] = await Promise.all([
const [summary, filtered, pendingStats, pendingSets, pendingAbilities, pendingSpecializations] = await Promise.all([
(await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived,
@@ -73,22 +69,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
FROM skill_chip_sets`)).get(),
(await database.prepare(`SELECT COUNT(*) AS total FROM skill_chip_sets sets ${where}`)).get(...args),
getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'stat', fields: ['unit', 'chipMin', 'chipMax'] }]),
getPendingEntities(user.id, [{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] }]),
getPendingEntities(user.id, [{ module: 'skillchips', action: 'save', kind: 'skillchip', fields: ['abilityId', 'twoPieceStatId', 'twoPieceMin', 'twoPieceMax', 'primaryStatIds', 'secondaryStatIds', 'talentName', 'descriptionTemplate', 'talentEffects', 'triggerIds'] }]),
getPendingEntities(user.id, [{ module: 'skillchips', action: 'save', kind: 'skillchip', fields: ['abilityId', 'twoPieceStatId', 'twoPieceMin', 'twoPieceMax', 'primaryStatIds', 'secondaryStatIds', 'talentName', 'descriptionTemplate', 'conditionalRule'] }]),
getPendingEntities(user.id, [{ module: 'specializations', action: 'saveAbility', kind: 'ability', fields: ['specializationId'] }]),
getPendingEntities(user.id, [{ module: 'specializations', action: 'saveSpecialization', kind: 'specialization' }])
]);
const pagination = getPagination(url, Number(filtered?.total ?? 0));
const [sets, twoPiece, primaryOptions, secondaryOptions, talents, talentTriggers, talentEffects, stats, triggers, abilities, specializations, abilityChipCounts, icons] = await Promise.all([
const [sets, twoPiece, primaryOptions, secondaryOptions, talents, talentEffects, stats, abilities, specializations, abilityChipCounts, icons] = await Promise.all([
(await database.prepare(`SELECT sets.* FROM skill_chip_sets sets ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset),
(await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')).all(),
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
(await database.prepare('SELECT * FROM skill_chip_secondary_options')).all(),
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
(await database.prepare('SELECT * FROM skill_chip_talent_triggers')).all(),
(await database.prepare('SELECT * FROM skill_chip_talent_effects ORDER BY sort_order')).all(),
(await database.prepare('SELECT id,name,unit,skill_chip_min,skill_chip_max FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT id,name,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT id,name,specialization_id FROM specialization_abilities WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT id,name FROM specializations WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare(`SELECT specialization_ability_id,COUNT(*) AS count FROM skill_chip_sets
@@ -121,7 +114,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
Boolean(set.abilityId)
&& Array.isArray(set.primaryStatIds)
&& Array.isArray(set.secondaryStatIds)
&& Array.isArray(set.triggerIds)
&& Boolean(set.conditionalRule)
);
const pendingSetRows = filter === 'archived' ? [] : currentPendingSets
.filter((set) => filter !== 'assigned' || Boolean(set.abilityId))
@@ -140,12 +133,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talent: {
name: set.talentName,
description_template: set.descriptionTemplate,
effects: (() => { try { return JSON.parse(String(set.talentEffects ?? '[]')); } catch { return []; } })(),
triggerIds: set.triggerIds
conditionalRule: (() => { try { return JSON.parse(String(set.conditionalRule ?? 'null')); } catch { return null; } })()
}
}));
return {
sets: [...pendingSetRows, ...sets.map((set) => {
const configuredSets = await Promise.all(sets.map(async (set) => {
const talent = talents.find((item) => item.skill_chip_set_id === set.id);
const icon = icons.find((item) => item.entity_id === set.id);
return {
@@ -156,14 +147,15 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
secondaryStatIds: secondaryOptions.filter((item) => item.skill_chip_set_id === set.id).map((item) => String(item.stat_attribute_id)),
talent: talent ? {
...talent,
triggerIds: talentTriggers.filter((item) => item.talent_id === talent.id).map((item) => String(item.trigger_id)),
effects: talentEffects.filter((item) => item.talent_id === talent.id)
effects: talentEffects.filter((item) => item.talent_id === talent.id),
conditionalRule: await loadConditionalRuleDraft(database, 'skill_chip_talent', String(talent.id), ['minimum', 'maximum'])
} : null
};
})],
}));
return {
sets: [...pendingSetRows, ...configuredSets],
stats: availableStats,
rollStats: availableStats.filter((stat) => stat.skill_chip_min !== null && stat.skill_chip_max !== null),
triggers: [...pendingTriggers, ...triggers],
abilities: loadedAbilities,
summary: {
active: Number(summary?.active ?? 0),
@@ -194,20 +186,37 @@ export const actions: Actions = {
const secondaryStatIds = [...new Set(form.getAll('secondaryStatIds').map(String).filter(Boolean))];
const talentName = String(form.get('talentName') ?? '').normalize('NFKC').trim();
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').normalize('NFKC').trim();
const triggerIds = [...new Set(form.getAll('triggerIds').map(String).filter(Boolean))];
const effects = parseEffects(form.get('talentEffects'));
const database = await getDatabase();
let conditionalRule;
try {
const conditionalRuleRaw = form.get('conditionalRule');
const activeStats = await (await database.prepare('SELECT id FROM stat_attributes WHERE active=1')).all();
conditionalRule = parseConditionalRulePayload(conditionalRuleRaw, [
{ key: 'minimum', variantKey: 'minimum' },
{ key: 'maximum', variantKey: 'maximum' }
], {
allowedAttributeIds: new Set(activeStats.map((row) => String(row.id))),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY))
});
} catch (error) {
return fail(400, { skillChipError: error instanceof Error ? error.message : 'The conditional mechanic is invalid.' });
}
if (name.length < 2 || name.length > 80) return fail(400, { skillChipError: 'Skill Chip names must be 2–80 characters.' });
if (!abilityId) return fail(400, { skillChipError: 'Assign this Skill Chip to a specialization ability.' });
if (!twoPieceStatId || !Number.isFinite(twoPieceMin) || !Number.isFinite(twoPieceMax) || twoPieceMax < twoPieceMin) return fail(400, { skillChipError: 'Configure a valid 2-piece stat range.' });
if (!primaryStatIds.length) return fail(400, { skillChipError: 'Configure at least one possible primary stat.' });
if (secondaryStatIds.length < 2) return fail(400, { skillChipError: 'Configure at least two possible secondary stats.' });
if (primaryStatIds.some((statId) => secondaryStatIds.includes(statId))) return fail(400, { skillChipError: 'Primary and secondary stat pools must be distinct.' });
if (talentName.length < 2 || talentName.length > 80 || !descriptionTemplate || !triggerIds.length || !effects.length) return fail(400, { skillChipError: 'The 3-piece talent requires a name, description, trigger, and effect.' });
if (effects.length > 12 || effects.some((effect) => !effect.statId || !Number.isFinite(effect.minValue) || !Number.isFinite(effect.maxValue) || effect.maxValue < effect.minValue)) return fail(400, { skillChipError: 'Every talent effect requires a valid minimum and maximum.' });
if (new Set(effects.map((effect) => effect.statId)).size !== effects.length) return fail(400, { skillChipError: 'Each talent effect may reference a stat only once.' });
const outcomes = mechanicOutcomes(conditionalRule);
const numberedOutcomes = descriptionOutcomes(conditionalRule);
const durationSeconds = conditionalRule.pools.find((pool) => pool.expiresAfterSeconds)?.expiresAfterSeconds ?? null;
const maxStacks = conditionalRule.pools.length ? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1)) : null;
if (talentName.length < 2 || talentName.length > 80 || !descriptionTemplate || !conditionalRule.clauses.length || !outcomes.length) return fail(400, { skillChipError: 'The 3-piece talent requires a name, description, mechanic branch, and outcome.' });
if (outcomes.some((outcome) => outcome.variants.maximum.value < outcome.variants.minimum.value)) return fail(400, { skillChipError: 'Every talent outcome maximum must be at least its minimum.' });
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1]));
if (placeholders.some((index) => index < 1 || index > effects.length)) return fail(400, { skillChipError: 'Description placeholders must reference configured talent effects.' });
const database = await getDatabase();
if (placeholders.some((index) => !numberedOutcomes.some((outcome) => outcome.descriptionSlot === index))) return fail(400, { skillChipError: 'Description placeholders must reference configured numbered outcomes.' });
if (descriptionTemplate.includes('{duration}') && durationSeconds === null) return fail(400, { skillChipError: 'The description uses {duration}, but no expiring state pool is defined.' });
if (descriptionTemplate.includes('{max_stacks}') && !maxStacks) return fail(400, { skillChipError: 'The description uses {max_stacks}, but no state pool maximum is defined.' });
if (!await (await database.prepare('SELECT id FROM specialization_abilities WHERE id=? AND active=1')).get(abilityId)) {
return fail(400, { skillChipError: 'Choose an active specialization ability.' });
}
@@ -219,39 +228,56 @@ export const actions: Actions = {
if (await (await database.prepare('SELECT id FROM skill_chip_sets WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) return fail(409, { skillChipError: 'That Skill Chip set already exists.' });
const existingTalent = id ? await (await database.prepare('SELECT id FROM skill_chip_talents WHERE skill_chip_set_id=?')).get(id) : null;
if (await (await database.prepare('SELECT id FROM skill_chip_talents WHERE name=? COLLATE NOCASE AND id<>?')).get(talentName, existingTalent?.id ?? 'new')) return fail(409, { skillChipError: 'That Skill Chip talent name is already in use.' });
const allStatIds = [...new Set([twoPieceStatId, ...primaryStatIds, ...secondaryStatIds, ...effects.map((effect) => effect.statId)])];
const outcomeStatIds = outcomes.map((effect) => effect.attributeId).filter((value): value is string => Boolean(value));
const allStatIds = [...new Set([twoPieceStatId, ...primaryStatIds, ...secondaryStatIds, ...outcomeStatIds])];
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${allStatIds.map(() => '?').join(',')})`)).get(...allStatIds);
if (Number(validStats?.count) !== allStatIds.length) return fail(400, { skillChipError: 'One or more selected stats are unavailable.' });
const rollStatIds = [...primaryStatIds, ...secondaryStatIds];
const validRollStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND skill_chip_min IS NOT NULL AND skill_chip_max IS NOT NULL AND id IN (${rollStatIds.map(() => '?').join(',')})`)).get(...rollStatIds);
if (Number(validRollStats?.count) !== rollStatIds.length) return fail(400, { skillChipError: 'Every primary and secondary option requires a configured Skill Chip range.' });
const validTriggers = await (await database.prepare(`SELECT COUNT(*) AS count FROM talent_triggers WHERE active=1 AND id IN (${triggerIds.map(() => '?').join(',')})`)).get(...triggerIds);
if (Number(validTriggers?.count) !== triggerIds.length) return fail(400, { skillChipError: 'One or more selected triggers are unavailable.' });
const setId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const talentId = String(existingTalent?.id ?? randomUUID());
const now = new Date().toISOString();
const before = id ? await snapshot(id) : null;
if (id && !before?.record) return fail(404, { skillChipError: 'Skill Chip set not found.' });
const previousAbilityId = String(before?.record?.specialization_ability_id ?? '');
if (id) {
if (!await auditRecord('skill_chip_sets', id)) return fail(404, { skillChipError: 'Skill Chip set not found.' });
await (await database.prepare('UPDATE skill_chip_sets SET name=?,specialization_ability_id=?,active=1,updated_at=? WHERE id=?')).run(name, abilityId, now, id);
} else await (await database.prepare('INSERT INTO skill_chip_sets(id,name,specialization_ability_id,created_at,updated_at) VALUES(?,?,?,?,?)')).run(setId, name, abilityId, now, now);
await (await database.prepare(`INSERT INTO skill_chip_two_piece_bonuses(id,skill_chip_set_id,stat_attribute_id,min_value,max_value) VALUES(?,?,?,?,?)
ON CONFLICT(skill_chip_set_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,min_value=excluded.min_value,max_value=excluded.max_value`)).run(randomUUID(), setId, twoPieceStatId, twoPieceMin, twoPieceMax);
await (await database.prepare('DELETE FROM skill_chip_primary_options WHERE skill_chip_set_id=?')).run(setId);
for (const statId of primaryStatIds) await (await database.prepare('INSERT INTO skill_chip_primary_options(skill_chip_set_id,stat_attribute_id) VALUES(?,?)')).run(setId, statId);
await (await database.prepare('DELETE FROM skill_chip_secondary_options WHERE skill_chip_set_id=?')).run(setId);
for (const statId of secondaryStatIds) await (await database.prepare('INSERT INTO skill_chip_secondary_options(skill_chip_set_id,stat_attribute_id) VALUES(?,?)')).run(setId, statId);
await (await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(skill_chip_set_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)).run(talentId, setId, talentName, descriptionTemplate, now, now);
await (await database.prepare('DELETE FROM skill_chip_talent_triggers WHERE talent_id=?')).run(talentId);
for (const triggerId of triggerIds) await (await database.prepare('INSERT INTO skill_chip_talent_triggers(talent_id,trigger_id) VALUES(?,?)')).run(talentId, triggerId);
await (await database.prepare('DELETE FROM skill_chip_talent_effects WHERE talent_id=?')).run(talentId);
for (const effect of effects) await (await database.prepare('INSERT INTO skill_chip_talent_effects(id,talent_id,stat_attribute_id,min_value,max_value,sort_order) VALUES(?,?,?,?,?,?)')).run(randomUUID(), talentId, effect.statId, effect.minValue, effect.maxValue, effect.sortOrder);
await applyIconMutation(database, 'skill_chip', setId, iconMutation, operator.id);
await refreshAbilityFocuses(database, abilityId);
if (previousAbilityId && previousAbilityId !== abilityId) await refreshAbilityFocuses(database, previousAbilityId);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'skill_chip.updated' : 'skill_chip.created', entityId: setId, before, after: await snapshot(setId) });
await database.exec('BEGIN');
try {
if (id) {
await (await database.prepare('UPDATE skill_chip_sets SET name=?,specialization_ability_id=?,active=1,updated_at=? WHERE id=?')).run(name, abilityId, now, id);
} else await (await database.prepare('INSERT INTO skill_chip_sets(id,name,specialization_ability_id,created_at,updated_at) VALUES(?,?,?,?,?)')).run(setId, name, abilityId, now, now);
await (await database.prepare(`INSERT INTO skill_chip_two_piece_bonuses(id,skill_chip_set_id,stat_attribute_id,min_value,max_value) VALUES(?,?,?,?,?)
ON CONFLICT(skill_chip_set_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,min_value=excluded.min_value,max_value=excluded.max_value`)).run(randomUUID(), setId, twoPieceStatId, twoPieceMin, twoPieceMax);
await (await database.prepare('DELETE FROM skill_chip_primary_options WHERE skill_chip_set_id=?')).run(setId);
for (const statId of primaryStatIds) await (await database.prepare('INSERT INTO skill_chip_primary_options(skill_chip_set_id,stat_attribute_id) VALUES(?,?)')).run(setId, statId);
await (await database.prepare('DELETE FROM skill_chip_secondary_options WHERE skill_chip_set_id=?')).run(setId);
for (const statId of secondaryStatIds) await (await database.prepare('INSERT INTO skill_chip_secondary_options(skill_chip_set_id,stat_attribute_id) VALUES(?,?)')).run(setId, statId);
await (await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(skill_chip_set_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)).run(talentId, setId, talentName, descriptionTemplate, now, now);
await (await database.prepare('DELETE FROM skill_chip_talent_effects WHERE talent_id=?')).run(talentId);
let compatibilitySortOrder = 0;
for (const outcome of numberedOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId) continue;
compatibilitySortOrder += 1;
await (await database.prepare('INSERT INTO skill_chip_talent_effects(id,talent_id,stat_attribute_id,min_value,max_value,sort_order) VALUES(?,?,?,?,?,?)')).run(
randomUUID(),
talentId,
outcome.attributeId,
outcome.variants.minimum.value,
outcome.variants.maximum.value,
compatibilitySortOrder
);
}
await saveConditionalRuleInTransaction(database, 'skill_chip_talent', talentId, conditionalRule);
await applyIconMutation(database, 'skill_chip', setId, iconMutation, operator.id);
await refreshAbilityFocuses(database, abilityId);
if (previousAbilityId && previousAbilityId !== abilityId) await refreshAbilityFocuses(database, previousAbilityId);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'skill_chip.updated' : 'skill_chip.created', entityId: setId, before, after: await snapshot(setId) });
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return { skillChipSaved: true, skillChipMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
archive: async ({ request, cookies }) => {
+37 -20
View File
@@ -8,10 +8,19 @@
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
let { data, form } = $props();
type TalentEffect = { stat_attribute_id: string; min_value: number | string; max_value: number | string };
const blankTalent = () => ({ id: '', name: '', description_template: '', triggerIds: [], effects: [{ stat_attribute_id: '', min_value: '', max_value: '' }] });
const talentValueColumns = [
{ key: 'minimum', variantKey: 'minimum', label: 'MINIMUM' },
{ key: 'maximum', variantKey: 'maximum', label: 'MAXIMUM' }
];
const blankRule = (): ConditionalMechanicDraftV1 => ({
version:1,simulationReadiness:'captured_only',scenarioInputs:[],pools:[],
clauses:[{key:'primary',label:'Primary',event:'skill_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'assigned_ability',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{minimum:{value:0},maximum:{value:0}}}]}]
});
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, primaryStatIds: [], secondaryStatIds: [], talent: blankTalent() });
let editing = $state<any | null>(null);
let highEndCount = $state('0');
@@ -33,9 +42,20 @@
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
};
const scaled = (minimum: number | string, maximum: number | string) => Number(minimum) + (Number(maximum) - Number(minimum)) * Number(highEndCount) / 3;
const talentPreview = () => editing.talent.description_template.replace(/\{(\d+)\}/g, (placeholder: string, rawIndex: string) => {
const effect = editing.talent.effects[Number(rawIndex) - 1];
return effect ? format(scaled(effect.min_value, effect.max_value), effect.stat_attribute_id) : placeholder;
const previewOutcomes = () => editing.talent.conditionalRule.clauses.flatMap((clause: any) => clause.outcomes)
.filter((outcome: any) => outcome.descriptionSlot)
.sort((left: any, right: any) => left.descriptionSlot - right.descriptionSlot);
const talentPreview = () => editing.talent.description_template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
const duration = editing.talent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds;
const maxStacks = editing.talent.conditionalRule.pools.length ? Math.max(...editing.talent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null;
if (expression === 'duration') return duration == null ? placeholder : `${duration} seconds`;
if (expression === 'max_stacks') return maxStacks == null ? placeholder : `${maxStacks} stacks`;
const outcome = previewOutcomes().find((item: any) => item.descriptionSlot === Number(expression));
const minimum = outcome?.variants?.minimum?.value;
const maximum = outcome?.variants?.maximum?.value;
return outcome && minimum !== '' && maximum !== '' && minimum !== null && maximum !== null && minimum !== undefined && maximum !== undefined
? format(scaled(minimum, maximum), outcome.attributeId)
: placeholder;
});
const edit = (item: any) => {
editing = {
@@ -44,16 +64,10 @@
twoPiece: item.twoPiece ? { ...item.twoPiece } : { stat_attribute_id: '', min_value: '', max_value: '' },
primaryStatIds: [...item.primaryStatIds],
secondaryStatIds: [...item.secondaryStatIds],
talent: item.talent ? { ...item.talent, triggerIds: [...item.talent.triggerIds], effects: item.talent.effects.map((effect: TalentEffect) => ({ ...effect })) } : blankTalent()
talent: item.talent ? { ...item.talent, conditionalRule: item.talent.conditionalRule ?? blankRule() } : blankTalent()
};
highEndCount = '0';
};
const addEffect = () => {
if (editing.talent.effects.length < 12) editing.talent.effects = [...editing.talent.effects, { stat_attribute_id: '', min_value: '', max_value: '' }];
};
const removeEffect = (index: number) => {
if (editing.talent.effects.length > 1) editing.talent.effects = editing.talent.effects.filter((_: TalentEffect, effectIndex: number) => effectIndex !== index);
};
$effect(() => {
if (form?.skillChipSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
@@ -81,7 +95,7 @@
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY CHIP SET' : 'NEW CHIP SET'}</p><h3>{editing.id ? editing.name : 'SKILL CHIP DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="talentEffects" value={JSON.stringify(editing.talent.effects.map((effect: TalentEffect) => ({ statId: effect.stat_attribute_id, minValue: effect.min_value, maxValue: effect.max_value })))}/>
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)}/>
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Only these qualities participate in chip scaling.</small></div></div>
<section class="skill-chip-ability-assignment">
<div class="selection-head"><div><span>SPECIALIZATION ABILITY</span><small>EXCLUSIVE RELATIONSHIP · THREE ACTIVE SKILL CHIPS PER ABILITY</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 ASSIGNED` : 'REQUIRED'}</b></div>
@@ -99,16 +113,19 @@
</div>
<section class="skill-chip-talent">
<div class="selection-head"><div><span>3-PIECE TALENT</span><small>QUALITY-SCALED EFFECTS · SHARED TRIGGERS AND DESCRIPTION</small></div><b>{editing.talent.effects.length} EFFECT{editing.talent.effects.length === 1 ? '' : 'S'}</b></div>
<div class="selection-head"><div><span>3-PIECE TALENT</span><small>QUALITY-SCALED OUTCOMES · TYPED TRIGGER AND DESCRIPTION</small></div><b>{previewOutcomes().length} OUTCOME{previewOutcomes().length === 1 ? '' : 'S'}</b></div>
<div class="skill-chip-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} Skill Damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for scaled effect values and units.</small></label></div>
<div class="skill-chip-preview"><SegmentedChoice legend="QUALITY MIX PREVIEW" name="qualityPreview" options={qualitySteps} bind:value={highEndCount}/><div><span>{3 - Number(highEndCount)} SUPERIOR · {highEndCount} HIGH-END</span><p>{talentPreview() || 'Talent description preview'}</p></div></div>
<div class="skill-chip-talent-grid">
<div><div class="selection-head"><div><span>ACTIVATION TRIGGERS</span><small>REUSABLE TRIGGER DEFINITIONS</small></div><b>{editing.talent.triggerIds.length}</b></div><SearchableAssignment items={data.triggers} fieldName="triggerIds" bind:selected={editing.talent.triggerIds}/></div>
<div><div class="selection-head"><div><span>TALENT EFFECTS</span><small>MINIMUM TO MAXIMUM ACROSS QUALITY MIX</small></div><button type="button" class="row-action" onclick={addEffect} disabled={editing.talent.effects.length >= 12}>ADD EFFECT +</button></div><div class="skill-chip-effects">{#each editing.talent.effects as effect, index}<div><strong>{index + 1}</strong><SearchableAssignment items={data.stats} mode="single" compact subjectLabel="ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={effect.stat_attribute_id}/><input type="number" step="any" bind:value={effect.min_value} placeholder="MIN" required/><input type="number" step="any" bind:value={effect.max_value} placeholder="MAX" required/><button type="button" aria-label={`Remove talent effect ${index + 1}`} onclick={() => removeEffect(index)} disabled={editing.talent.effects.length === 1}>×</button></div>{/each}</div></div>
</div>
<MechanicProgramEditor
attributes={data.stats}
allowAssignedAbility
valueColumns={talentValueColumns}
idPrefix={`skill-chip-talent-${editing.talent.id || 'new'}`}
bind:rule={editing.talent.conditionalRule}
/>
</section>
{#if form?.skillChipError}<p class="error">{form.skillChipError}</p>{/if}
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE CHIP SET' : 'UNARCHIVE CHIP SET'}</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!editing.abilityId || !editing.primaryStatIds.length || editing.secondaryStatIds.length < 2 || !data.rollStats.length || !data.triggers.length}>SAVE SKILL CHIP →</button></div></div>
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE CHIP SET' : 'UNARCHIVE CHIP SET'}</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!editing.abilityId || !editing.primaryStatIds.length || editing.secondaryStatIds.length < 2 || !data.rollStats.length}>SAVE SKILL CHIP →</button></div></div>
</PendingForm>
</div>
{/if}
@@ -119,7 +136,7 @@
<div class="dashboard-table">
<div class="dashboard-row skill-chip-row table-heading"><SortableColumnHeader label="SKILL CHIP" column="name" sort={data.sort}/><SortableColumnHeader label="ABILITY" column="ability" sort={data.sort}/><span>QUALITY</span><SortableColumnHeader label="2-PIECE BONUS" column="twoPiece" sort={data.sort}/><SortableColumnHeader label="3-PIECE TALENT" column="talent" sort={data.sort}/><SortableColumnHeader label="ROLL POOLS" column="rollPools" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.sets as item}
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>EXCLUSIVE ABILITY</small></div><span class="skill-chip-quality-pair"><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT CONFIGURED'}</strong><small>{item.talent?.effects.length ?? 0} SCALED EFFECTS</small></div><span>{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>EXCLUSIVE ABILITY</small></div><span class="skill-chip-quality-pair"><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT CONFIGURED'}</strong><small>{item.talent?.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED'}</small></div><span>{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
{#if editing?.id === item.id}<RegistryInlineEditor id={`skill-chip-editor-${item.id}`} label={`Edit ${item.name}`}>{@render skillChipEditor()}</RegistryInlineEditor>{/if}
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first Superior and High-end Skill Chip definition.</span></div>{/each}
</div>
+288 -72
View File
@@ -1,5 +1,7 @@
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import { mkdir, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { Database } from '@tursodatabase/database';
import type { Actions, PageServerLoad } from './$types';
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
@@ -10,15 +12,26 @@ import { deserializeProposal, getPendingEntities } from '$lib/server/data-modera
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import {
applyIconMutation,
gameDataIconStorageDirectory,
gameDataIconMetadata,
gameDataIconUrl,
GameDataIconValidationError,
readIconMutation
readGameDataIcon,
readIconMutation,
type GameDataIconType
} from '$lib/server/game-data-icons';
import {
refreshSpecializationFocusReady,
refreshSpecializationFocuses
} from '$lib/server/specialization-readiness';
import {
conditionalRuleSnapshot,
loadConditionalRuleDraft,
parseConditionalRulePayload,
saveConditionalRuleInTransaction,
} from '$lib/server/conditional-effects';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
const PRIMARY_ATTRIBUTES = ['Firepower', 'Toughness', 'Engineering'] as const;
const RANK_TWO_RESOURCES = new Set([
@@ -42,20 +55,9 @@ type RankEffectInput = AbilityEffectInput & {
abilityId?: string;
};
type RankCombatModifierInput = {
statId?: string;
valuePerStack?: number | string | null;
unit?: string;
};
type RankCombatEffectInput = {
effectId?: string;
triggerCount?: number | string;
triggerWindowSeconds?: number | string;
durationSeconds?: number | string;
maxStacks?: number | string;
stacksPerTrigger?: number | string;
modifiers?: RankCombatModifierInput[];
conditionalRule?: unknown;
};
type RankInput = {
@@ -82,6 +84,36 @@ const parseJsonArray = <T>(value: unknown): T[] => {
}
};
const parsedJsonObject = (value: unknown) => {
try {
const parsed = JSON.parse(String(value ?? '{}'));
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: {};
} catch {
return {};
}
};
async function restoreIconFile(
type: GameDataIconType,
entityId: string,
previousIcon: Buffer | null
) {
const iconPath = path.join(gameDataIconStorageDirectory(), `${type}-${entityId}.webp`);
if (previousIcon) {
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
await writeFile(iconPath, previousIcon, { flag: 'w', mode: 0o600 });
return;
}
await unlink(iconPath).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
});
}
const mechanicOutcomes = (rule: ConditionalMechanicDraftV1) =>
rule.clauses.flatMap((clause) => clause.outcomes);
async function specializationSnapshot(database: Database, id: string) {
const record = await auditRecord('specializations', id);
const focusIds = (await (await database.prepare(
@@ -112,6 +144,9 @@ async function focusSnapshot(database: Database, id: string) {
async function abilitySnapshot(database: Database, id: string) {
return {
record: await auditRecord('specialization_abilities', id),
attack: await (await database.prepare(
'SELECT * FROM specialization_ability_attacks WHERE ability_id=?'
)).get(id) ?? null,
effects: await (await database.prepare(
'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order'
)).all(id),
@@ -123,6 +158,9 @@ async function nodeSnapshot(database: Database, id: string) {
const combatEffects = await (await database.prepare(
'SELECT * FROM specialization_tree_rank_combat_effects WHERE node_id=? ORDER BY rank,effect_order'
)).all(id);
const conditionalRules = await Promise.all(combatEffects.map((effect) =>
conditionalRuleSnapshot(database, 'specialization_tree_rank', String(effect.id))
));
return {
record: await auditRecord('specialization_tree_nodes', id),
ranks: await (await database.prepare(
@@ -140,6 +178,7 @@ async function nodeSnapshot(database: Database, id: string) {
JOIN specialization_tree_rank_combat_effects attachment ON attachment.id=modifier.rank_combat_effect_id
WHERE attachment.node_id=? ORDER BY attachment.rank,attachment.effect_order,modifier.modifier_order`
)).all(id) : [],
conditionalRules,
icon: await gameDataIconMetadata(database, 'specialization_node', id)
};
}
@@ -199,7 +238,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const pending = await getPendingEntities(user.id, [
{ module: 'specializations', action: 'saveSpecialization', kind: 'specialization', fields: ['description'] },
{ module: 'specializations', action: 'saveFocus', kind: 'focus', fields: ['specializationId', 'description', 'primaryAttribute', 'displayOrder'] },
{ module: 'specializations', action: 'saveAbility', kind: 'ability', fields: ['specializationId', 'description', 'durationSeconds', 'cooldownSeconds', 'effectsJson'] },
{ module: 'specializations', action: 'saveAbility', kind: 'ability', fields: ['specializationId', 'description', 'durationSeconds', 'cooldownSeconds', 'effectsJson', 'attackJson'] },
{ module: 'specializations', action: 'saveCombatEffect', kind: 'combatEffect', fields: ['specializationId', 'descriptionTemplate'] },
{ module: 'specializations', action: 'saveNode', kind: 'node', fields: ['focusId', 'descriptionTemplate', 'rowNumber', 'columnNumber', 'maxRank', 'ranksJson'] }
]);
@@ -223,12 +262,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const pendingOnPage = pagination.page === 1 ? pendingSpecializations.slice(0, pagination.perPage) : [];
const databaseOffset = Math.max(0, pagination.offset - pendingSpecializations.length);
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
const [specializations, focuses, abilities, abilityEffects, focusAbilities, nodes, nodeRanks, nodeEffects, nodeCosts, combatEffects, nodeCombatEffects, nodeCombatModifiers, resources, rankProfiles, progression, attributes, chipCounts, icons, summary] = await Promise.all([
const [specializations, focuses, abilities, abilityAttacks, abilityEffects, focusAbilities, nodes, nodeRanks, nodeEffects, nodeCosts, combatEffects, nodeCombatEffects, nodeCombatModifiers, resources, rankProfiles, progression, attributes, chipCounts, icons, summary] = await Promise.all([
databaseLimit ? (await database.prepare(
`SELECT * FROM specializations ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`
)).all(...args, databaseLimit, databaseOffset) : [],
(await database.prepare('SELECT * FROM specialization_focuses ORDER BY specialization_id,display_order,name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM specialization_abilities ORDER BY specialization_id,active DESC,name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM specialization_ability_attacks')).all(),
(await database.prepare('SELECT * FROM specialization_ability_effects ORDER BY ability_id,effect_order')).all(),
(await database.prepare('SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot')).all(),
(await database.prepare('SELECT * FROM specialization_tree_nodes ORDER BY focus_id,row_number,column_number')).all(),
@@ -265,6 +305,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const pendingAbilities = pending.filter((item) => item.kind === 'ability');
const pendingCombatEffects = pending.filter((item) => item.kind === 'combatEffect');
const pendingNodes = pending.filter((item) => item.kind === 'node');
const conditionalDrafts = new Map(await Promise.all(nodeCombatEffects.map(async (effect) => [
String(effect.id),
await loadConditionalRuleDraft(database, 'specialization_tree_rank', String(effect.id), ['base'])
] as const)));
const iconUrl = (type: 'specialization' | 'specialization_ability' | 'specialization_node', id: string) => {
const icon = icons.find((item) => item.entity_type === type && item.entity_id === id);
return icon ? gameDataIconUrl(type, id, icon.updated_at) : '';
@@ -291,6 +335,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
specialization_id: item.specializationId,
duration_seconds: item.durationSeconds || null,
cooldown_seconds: item.cooldownSeconds || null,
attack: item.attackJson ? JSON.parse(String(item.attackJson)) : null,
effects: parseJsonArray<AbilityEffectInput>(item.effectsJson),
active: 1,
pending: true,
@@ -299,6 +344,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
})),
...abilities.map((ability) => ({
...ability,
attack: abilityAttacks.find((attack) => attack.ability_id === ability.id) ?? null,
effects: abilityEffects.filter((effect) => effect.ability_id === ability.id),
skillChipCount: Number(chipCounts.find((item) => item.specialization_ability_id === ability.id)?.count ?? 0),
iconUrl: iconUrl('specialization_ability', String(ability.id))
@@ -326,7 +372,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
.filter((effect) => effect.node_id === node.id && Number(effect.rank) === Number(rank.rank))
.map((effect) => ({
...effect,
modifiers: nodeCombatModifiers.filter((modifier) => modifier.rank_combat_effect_id === effect.id)
modifiers: nodeCombatModifiers.filter((modifier) => modifier.rank_combat_effect_id === effect.id),
conditionalRule: conditionalDrafts.get(String(effect.id)) ?? null
})),
costs: Object.fromEntries(nodeCosts.filter((cost) => cost.node_id === node.id && Number(cost.rank) === Number(rank.rank))
.map((cost) => [String(cost.resource_id), Number(cost.quantity)]))
@@ -553,6 +600,37 @@ export const actions: Actions = {
value: effect.value === '' || effect.value === null || effect.value === undefined ? null : Number(effect.value),
unit: cleanName(effect.unit)
}));
let attack: null | {
baseDamage: number;
damageType: string;
delivery: string;
hitsPerActivation: number;
tickIntervalSeconds: number | null;
damageDurationSeconds: number | null;
castSeconds: number;
targetCap: number;
chargeCount: number;
chargePolicy: string;
} = null;
try {
const raw = JSON.parse(String(form.get('attackJson') ?? '{"enabled":false}'));
if (raw?.enabled) {
attack = {
baseDamage: Number(raw.baseDamage),
damageType: String(raw.damageType ?? ''),
delivery: String(raw.delivery ?? ''),
hitsPerActivation: Number(raw.hitsPerActivation ?? 1),
tickIntervalSeconds: raw.delivery === 'periodic' ? Number(raw.tickIntervalSeconds) : null,
damageDurationSeconds: raw.delivery === 'periodic' ? Number(raw.damageDurationSeconds) : null,
castSeconds: Number(raw.castSeconds ?? 0),
targetCap: Number(raw.targetCap ?? 1),
chargeCount: raw.chargePolicy === 'none' ? 1 : Number(raw.chargeCount ?? 1),
chargePolicy: String(raw.chargePolicy ?? 'none')
};
}
} catch {
return fail(400, { abilityError: 'The ability attack profile could not be read.' });
}
if (name.length < 2 || name.length > 100) return fail(400, { abilityError: 'Ability names must be 2–100 characters.' });
if (description.length < 3 || description.length > 2400) return fail(400, { abilityError: 'Ability descriptions must be 3–2,400 characters.' });
if (Number.isNaN(durationSeconds) || Number.isNaN(cooldownSeconds)) {
@@ -568,6 +646,20 @@ export const actions: Actions = {
if (effects.some((effect) => effect.type === 'Custom' && effect.label.length < 2)) {
return fail(400, { abilityError: 'Custom effects require a concise label because they have no Attribute name to use.' });
}
if (attack && (
!Number.isFinite(attack.baseDamage) || attack.baseDamage <= 0
|| !['Blast', 'Pierce', 'Shredding'].includes(attack.damageType)
|| !['direct', 'periodic'].includes(attack.delivery)
|| !Number.isInteger(attack.hitsPerActivation) || attack.hitsPerActivation <= 0
|| !Number.isFinite(attack.castSeconds) || attack.castSeconds < 0
|| !Number.isInteger(attack.targetCap) || attack.targetCap <= 0
|| !['none', 'independent', 'full_set'].includes(attack.chargePolicy)
|| !Number.isInteger(attack.chargeCount) || attack.chargeCount <= 0
|| (attack.delivery === 'periodic' && (
!Number.isFinite(attack.tickIntervalSeconds) || Number(attack.tickIntervalSeconds) <= 0
|| !Number.isFinite(attack.damageDurationSeconds) || Number(attack.damageDurationSeconds) <= 0
))
)) return fail(400, { abilityError: 'Provide a complete and valid intrinsic attack profile.' });
const database = await getDatabase();
if (id) {
const existing = await (await database.prepare(
@@ -611,6 +703,10 @@ export const actions: Actions = {
}
const abilityId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await abilitySnapshot(database, id) : null;
const iconChanged = Boolean(iconMutation.output || iconMutation.remove);
const previousIcon = iconChanged
? await readGameDataIcon(database, 'specialization_ability', abilityId)
: null;
const now = new Date().toISOString();
await database.exec('BEGIN');
try {
@@ -630,21 +726,55 @@ export const actions: Actions = {
(id,ability_id,effect_order,stat_attribute_id,label,value,unit) VALUES(?,?,?,?,?,?,?)`))
.run(randomUUID(), abilityId, index + 1, effect.statId || null, effect.label, effect.value, effect.unit || null);
}
if (attack) {
await (await database.prepare(`INSERT INTO specialization_ability_attacks
(ability_id,base_damage,damage_type,delivery,hits_per_activation,tick_interval_seconds,
damage_duration_seconds,cast_seconds,target_cap,charge_count,charge_policy,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(ability_id) DO UPDATE SET
base_damage=excluded.base_damage,
damage_type=excluded.damage_type,
delivery=excluded.delivery,
hits_per_activation=excluded.hits_per_activation,
tick_interval_seconds=excluded.tick_interval_seconds,
damage_duration_seconds=excluded.damage_duration_seconds,
cast_seconds=excluded.cast_seconds,
target_cap=excluded.target_cap,
charge_count=excluded.charge_count,
charge_policy=excluded.charge_policy,
updated_at=excluded.updated_at`)).run(
abilityId,
attack.baseDamage,
attack.damageType,
attack.delivery,
attack.hitsPerActivation,
attack.tickIntervalSeconds,
attack.damageDurationSeconds,
attack.castSeconds,
attack.targetCap,
attack.chargeCount,
attack.chargePolicy,
now
);
} else {
await (await database.prepare('DELETE FROM specialization_ability_attacks WHERE ability_id=?')).run(abilityId);
}
await applyIconMutation(database, 'specialization_ability', abilityId, iconMutation, operator.id);
await refreshSpecializationFocuses(database, specializationId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'specialization_ability.updated' : 'specialization_ability.created',
entityId: abilityId,
before,
after: await abilitySnapshot(database, abilityId)
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
if (iconChanged) await restoreIconFile('specialization_ability', abilityId, previousIcon);
throw error;
}
await applyIconMutation(database, 'specialization_ability', abilityId, iconMutation, operator.id);
await refreshSpecializationFocuses(database, specializationId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'specialization_ability.updated' : 'specialization_ability.created',
entityId: abilityId,
before,
after: await abilitySnapshot(database, abilityId)
});
return { abilitySaved: true, abilityMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
@@ -768,16 +898,7 @@ export const actions: Actions = {
})) : [],
combatEffects: Array.isArray(rank.combatEffects) ? rank.combatEffects.map((effect) => ({
effectId: String(effect.effectId ?? ''),
triggerCount: Number(effect.triggerCount),
triggerWindowSeconds: Number(effect.triggerWindowSeconds),
durationSeconds: Number(effect.durationSeconds),
maxStacks: Number(effect.maxStacks),
stacksPerTrigger: Number(effect.stacksPerTrigger ?? 1),
modifiers: Array.isArray(effect.modifiers) ? effect.modifiers.map((modifier) => ({
statId: String(modifier.statId ?? ''),
valuePerStack: Number(modifier.valuePerStack),
unit: cleanName(modifier.unit)
})) : []
conditionalRule: effect.conditionalRule
})) : [],
costs: Object.fromEntries(Object.entries(rank.costs ?? {}).map(([resourceId, quantity]) => [resourceId, Number(quantity)]))
}));
@@ -806,16 +927,9 @@ export const actions: Actions = {
return fail(400, { nodeError: 'Every rank effect requires a valid type, target, label, and optional numeric value.' });
}
if (allCombatEffects.length > 18 || allCombatEffects.some((effect) =>
!effect.effectId
|| !Number.isInteger(effect.triggerCount) || effect.triggerCount < 1 || effect.triggerCount > 1000
|| !Number.isFinite(effect.triggerWindowSeconds) || effect.triggerWindowSeconds <= 0 || effect.triggerWindowSeconds > 3600
|| !Number.isFinite(effect.durationSeconds) || effect.durationSeconds <= 0 || effect.durationSeconds > 86400
|| !Number.isInteger(effect.maxStacks) || effect.maxStacks < 1 || effect.maxStacks > 1000
|| !Number.isInteger(effect.stacksPerTrigger) || effect.stacksPerTrigger < 1 || effect.stacksPerTrigger > effect.maxStacks
|| effect.modifiers.length < 1 || effect.modifiers.length > 12
|| effect.modifiers.some((modifier) => !modifier.statId || !Number.isFinite(modifier.valuePerStack) || !EFFECT_UNITS.has(modifier.unit))
!effect.effectId || !effect.conditionalRule
)) {
return fail(400, { nodeError: 'Every conditional effect requires a valid trigger, stack lifecycle, and at least one Attribute modifier.' });
return fail(400, { nodeError: 'Every conditional effect requires a shared effect and a complete calculation rule.' });
}
const rankOneCosts = ranks[0]?.costs ?? {};
if (Object.values(rankOneCosts).some((quantity) => !Number.isInteger(quantity) || quantity !== 0)) {
@@ -842,6 +956,26 @@ export const actions: Actions = {
}
}
const database = await getDatabase();
const parsedCombatRules = new Map<(typeof allCombatEffects)[number], ConditionalMechanicDraftV1>();
try {
const activeAttributes = await (await database.prepare('SELECT id FROM stat_attributes WHERE active=1')).all();
for (const effect of allCombatEffects) {
parsedCombatRules.set(effect, parseConditionalRulePayload(
JSON.stringify(effect.conditionalRule),
[{ key: 'base', variantKey: 'base' }],
{
allowedAttributeIds: new Set(activeAttributes.map((row) => String(row.id))),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY))
}
));
}
} catch (error) {
return fail(400, {
nodeError: error instanceof Error
? error.message
: 'One or more conditional mechanics are invalid.'
});
}
if (id) {
const existing = await (await database.prepare(
'SELECT focus_id FROM specialization_tree_nodes WHERE id=?'
@@ -858,7 +992,9 @@ export const actions: Actions = {
if (!await validateAttributeIds(database, allEffects.map((effect) => effect.statId))) {
return fail(400, { nodeError: 'One or more effect Attributes are unavailable.' });
}
if (!await validateAttributeIds(database, allCombatEffects.flatMap((effect) => effect.modifiers.map((modifier) => modifier.statId)))) {
const conditionalAttributeIds = [...parsedCombatRules.values()]
.flatMap((rule) => mechanicOutcomes(rule).map((outcome) => outcome.attributeId).filter((id): id is string => Boolean(id)));
if (!await validateAttributeIds(database, conditionalAttributeIds)) {
return fail(400, { nodeError: 'One or more conditional-effect Attributes are unavailable.' });
}
if (!await validateAbilityIds(database, String(focus.specialization_id), allEffects.map((effect) => effect.abilityId))) {
@@ -888,6 +1024,10 @@ export const actions: Actions = {
}
const nodeId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await nodeSnapshot(database, id) : null;
const iconChanged = Boolean(iconMutation.output || iconMutation.remove);
const previousIcon = iconChanged
? await readGameDataIcon(database, 'specialization_node', nodeId)
: null;
const now = new Date().toISOString();
await database.exec('BEGIN');
try {
@@ -901,6 +1041,14 @@ export const actions: Actions = {
VALUES(?,?,?,?,?,?,?,?,?)`))
.run(nodeId, focusId, name, descriptionTemplate, rowNumber, columnNumber, maxRank, now, now);
}
const replacedCombatIds = (await (await database.prepare(
'SELECT id FROM specialization_tree_rank_combat_effects WHERE node_id=?'
)).all(nodeId)).map((row) => String(row.id));
for (const attachmentId of replacedCombatIds) {
await (await database.prepare(
"DELETE FROM conditional_effect_rules WHERE source_type='specialization_tree_rank' AND source_id=?"
)).run(attachmentId);
}
await (await database.prepare('DELETE FROM specialization_tree_node_ranks WHERE node_id=?')).run(nodeId);
for (const rank of ranks) {
await (await database.prepare(
@@ -915,18 +1063,57 @@ export const actions: Actions = {
}
for (const [index, effect] of rank.combatEffects.entries()) {
const attachmentId = randomUUID();
const conditionalRule = parsedCombatRules.get(effect);
if (!conditionalRule) throw new Error('A validated conditional mechanic was not available during save.');
const primaryClause = conditionalRule.clauses[0];
const reducer = primaryClause?.reducer;
const legacyTriggerCount = Number(
reducer && 'threshold' in reducer ? reducer.threshold : 1
);
const legacyWindowSeconds = Number(
reducer && 'windowSeconds' in reducer
? reducer.windowSeconds
: reducer?.kind === 'duration' ? reducer.seconds : 1
);
const legacyPool = conditionalRule.pools[0];
await (await database.prepare(`INSERT INTO specialization_tree_rank_combat_effects
(id,node_id,rank,combat_effect_id,effect_order,trigger_type,trigger_count,trigger_window_seconds,
duration_seconds,max_stacks,stacks_per_trigger,counter_policy,refresh_policy,expiry_policy)
VALUES(?,?,?,?,?,'enemy_hits_within_window',?,?,?,?,?,'reset_on_proc','refresh_on_stack_gain','clear_all')`))
.run(attachmentId, nodeId, rank.rank, effect.effectId, index + 1, effect.triggerCount,
effect.triggerWindowSeconds, effect.durationSeconds, effect.maxStacks, effect.stacksPerTrigger);
for (const [modifierIndex, modifier] of effect.modifiers.entries()) {
.run(
attachmentId,
nodeId,
rank.rank,
effect.effectId,
index + 1,
Number.isInteger(legacyTriggerCount) && legacyTriggerCount > 0 ? legacyTriggerCount : 1,
Number.isFinite(legacyWindowSeconds) && legacyWindowSeconds > 0 ? legacyWindowSeconds : 1,
legacyPool?.expiresAfterSeconds ?? 0.001,
legacyPool?.max ?? 1,
1
);
const legacyModifiers = mechanicOutcomes(conditionalRule).filter((outcome) =>
outcome.kind === 'attribute_modifier' && outcome.attributeId
);
for (const [modifierIndex, modifier] of legacyModifiers.entries()) {
await (await database.prepare(`INSERT INTO specialization_tree_rank_combat_modifiers
(id,rank_combat_effect_id,modifier_order,stat_attribute_id,value_per_stack,unit)
VALUES(?,?,?,?,?,?)`))
.run(randomUUID(), attachmentId, modifierIndex + 1, modifier.statId, modifier.valuePerStack, modifier.unit || null);
.run(
randomUUID(),
attachmentId,
modifierIndex + 1,
modifier.attributeId,
modifier.variants.base.value,
null
);
}
await saveConditionalRuleInTransaction(
database,
'specialization_tree_rank',
attachmentId,
conditionalRule
);
}
for (const [resourceId, quantity] of Object.entries(rank.costs)) {
if (Number(quantity) > 0) {
@@ -936,21 +1123,22 @@ export const actions: Actions = {
}
}
}
await applyIconMutation(database, 'specialization_node', nodeId, iconMutation, operator.id);
const readiness = await refreshSpecializationFocusReady(database, focusId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'specialization_node.updated' : 'specialization_node.created',
entityId: nodeId,
before,
after: { ...(await nodeSnapshot(database, nodeId)), readiness }
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
if (iconChanged) await restoreIconFile('specialization_node', nodeId, previousIcon);
throw error;
}
await applyIconMutation(database, 'specialization_node', nodeId, iconMutation, operator.id);
const readiness = await refreshSpecializationFocusReady(database, focusId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'specialization_node.updated' : 'specialization_node.created',
entityId: nodeId,
before,
after: { ...(await nodeSnapshot(database, nodeId)), readiness }
});
return { nodeSaved: true, nodeMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
@@ -1003,9 +1191,16 @@ export const actions: Actions = {
const database = await getDatabase();
const ability = await (await database.prepare('SELECT specialization_id FROM specialization_abilities WHERE id=?')).get(id);
const before = await abilitySnapshot(database, id);
await (await database.prepare('UPDATE specialization_abilities SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
if (ability) await refreshSpecializationFocuses(database, String(ability.specialization_id));
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_ability.archived', entityId: id, before, after: await abilitySnapshot(database, id) });
await database.exec('BEGIN');
try {
await (await database.prepare('UPDATE specialization_abilities SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
if (ability) await refreshSpecializationFocuses(database, String(ability.specialization_id));
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_ability.archived', entityId: id, before, after: await abilitySnapshot(database, id) });
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return { abilityArchived: true, resultToken: randomUUID() };
},
@@ -1015,9 +1210,16 @@ export const actions: Actions = {
const database = await getDatabase();
const ability = await (await database.prepare('SELECT specialization_id FROM specialization_abilities WHERE id=?')).get(id);
const before = await abilitySnapshot(database, id);
await (await database.prepare('UPDATE specialization_abilities SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
if (ability) await refreshSpecializationFocuses(database, String(ability.specialization_id));
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_ability.unarchived', entityId: id, before, after: await abilitySnapshot(database, id) });
await database.exec('BEGIN');
try {
await (await database.prepare('UPDATE specialization_abilities SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
if (ability) await refreshSpecializationFocuses(database, String(ability.specialization_id));
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_ability.unarchived', entityId: id, before, after: await abilitySnapshot(database, id) });
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return { abilityUnarchived: true, resultToken: randomUUID() };
},
@@ -1027,9 +1229,16 @@ export const actions: Actions = {
const database = await getDatabase();
const node = await (await database.prepare('SELECT focus_id FROM specialization_tree_nodes WHERE id=?')).get(id);
const before = await nodeSnapshot(database, id);
await (await database.prepare('UPDATE specialization_tree_nodes SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
const readiness = node ? await refreshSpecializationFocusReady(database, String(node.focus_id)) : null;
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_node.archived', entityId: id, before, after: { ...(await nodeSnapshot(database, id)), readiness } });
await database.exec('BEGIN');
try {
await (await database.prepare('UPDATE specialization_tree_nodes SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
const readiness = node ? await refreshSpecializationFocusReady(database, String(node.focus_id)) : null;
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_node.archived', entityId: id, before, after: { ...(await nodeSnapshot(database, id)), readiness } });
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return { nodeArchived: true, resultToken: randomUUID() };
},
@@ -1039,9 +1248,16 @@ export const actions: Actions = {
const database = await getDatabase();
const node = await (await database.prepare('SELECT focus_id FROM specialization_tree_nodes WHERE id=?')).get(id);
const before = await nodeSnapshot(database, id);
await (await database.prepare('UPDATE specialization_tree_nodes SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
const readiness = node ? await refreshSpecializationFocusReady(database, String(node.focus_id)) : null;
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_node.unarchived', entityId: id, before, after: { ...(await nodeSnapshot(database, id)), readiness } });
await database.exec('BEGIN');
try {
await (await database.prepare('UPDATE specialization_tree_nodes SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
const readiness = node ? await refreshSpecializationFocusReady(database, String(node.focus_id)) : null;
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_node.unarchived', entityId: id, before, after: { ...(await nodeSnapshot(database, id)), readiness } });
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return { nodeUnarchived: true, resultToken: randomUUID() };
}
};
+79 -60
View File
@@ -10,6 +10,8 @@
import ItemIcon from '$lib/components/ItemIcon.svelte';
import GameIcon from '$lib/components/GameIcon.svelte';
import StructuredEffectEditor, { type StructuredEffect } from '$lib/components/StructuredEffectEditor.svelte';
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
let { data, form } = $props();
type SpecializationEditor = { id: string; name: string; description: string; active: number; iconUrl?: string };
@@ -29,6 +31,19 @@
description: string;
durationSeconds: number | string;
cooldownSeconds: number | string;
attack: {
enabled: boolean;
baseDamage: number | string;
damageType: string;
delivery: string;
hitsPerActivation: number | string;
tickIntervalSeconds: number | string;
damageDurationSeconds: number | string;
castSeconds: number | string;
targetCap: number | string;
chargeCount: number | string;
chargePolicy: string;
};
effects: StructuredEffect[];
active: number;
iconUrl?: string;
@@ -40,19 +55,9 @@
descriptionTemplate: string;
active: number;
};
type RankCombatModifier = {
statId: string;
valuePerStack: number | string;
unit: string;
};
type RankCombatEffect = {
effectId: string;
triggerCount: number | string;
triggerWindowSeconds: number | string;
durationSeconds: number | string;
maxStacks: number | string;
stacksPerTrigger: number | string;
modifiers: RankCombatModifier[];
conditionalRule: ConditionalMechanicDraftV1;
};
type RankEditor = {
rank: number;
@@ -80,28 +85,33 @@
{ value: 'Engineering', label: 'ENGINEERING', icon: 'engineering' as const, iconOnly: true }
];
const rankOptions = [1, 2, 3].map((rank) => ({ value: String(rank), label: `RANK ${rank}` }));
const unitOptions = [
{ value: '', label: 'NONE' },
{ value: '%', label: '%' },
{ value: 's', label: 'SECONDS' },
{ value: 'Shots', label: 'SHOTS' },
{ value: 'm', label: 'METERS' }
];
const blankEffect = (): StructuredEffect => ({ type: 'Custom', statId: '', abilityId: '', label: '', value: '', unit: '' });
const damageTypeOptions = ['Blast', 'Pierce', 'Shredding'].map((value) => ({ value, label: value.toUpperCase() }));
const deliveryOptions = [{ value: 'direct', label: 'DIRECT' }, { value: 'periodic', label: 'PERIODIC' }];
const chargePolicyOptions = [{ value: 'none', label: 'NO CHARGES' }, { value: 'independent', label: 'INDEPENDENT' }, { value: 'full_set', label: 'FULL SET' }];
const conditionalValueColumns = [{ key: 'base', variantKey: 'base', label: 'VALUE PER STACK' }];
const blankSpecialization = (): SpecializationEditor => ({ id: '', name: '', description: '', active: 1, iconUrl: '' });
const blankFocus = (specializationId = ''): FocusEditor => ({
id: '', specializationId, name: '', description: '', primaryAttribute: 'Firepower', displayOrder: 0, active: 1
});
const blankAbility = (specializationId = ''): AbilityEditor => ({
id: '', specializationId, name: '', description: '', durationSeconds: '', cooldownSeconds: '', effects: [], active: 1, iconUrl: ''
id: '', specializationId, name: '', description: '', durationSeconds: '', cooldownSeconds: '',
attack: {
enabled: false, baseDamage: '', damageType: 'Blast', delivery: 'direct',
hitsPerActivation: 1, tickIntervalSeconds: '', damageDurationSeconds: '',
castSeconds: 0, targetCap: 1, chargeCount: 1, chargePolicy: 'none'
},
effects: [], active: 1, iconUrl: ''
});
const blankCombatEffect = (specializationId = ''): CombatEffectEditor => ({
id: '', specializationId, name: '', descriptionTemplate: '', active: 1
});
const blankCombatModifier = (): RankCombatModifier => ({ statId: '', valuePerStack: '', unit: '%' });
const blankConditionalRule = (): ConditionalMechanicDraftV1 => ({
version:1,simulationReadiness:'captured_only',scenarioInputs:[],pools:[],
clauses:[{key:'primary',label:'Primary',event:'weapon_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'self',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{base:{value:0}}}]}]
});
const blankRankCombatEffect = (): RankCombatEffect => ({
effectId: '', triggerCount: 1, triggerWindowSeconds: 1, durationSeconds: 10,
maxStacks: 1, stacksPerTrigger: 1, modifiers: [blankCombatModifier()]
effectId: '',
conditionalRule: blankConditionalRule()
});
const blankRank = (rank: number): RankEditor => ({
rank,
@@ -124,6 +134,9 @@
value: effect.value ?? '',
unit: String(effect.unit ?? '')
});
const setAbilityAttackEnabled = (enabled: boolean) => {
if (abilityEditing) abilityEditing.attack.enabled = enabled;
};
let specializationEditing = $state<SpecializationEditor | null>(null);
let focusEditing = $state<FocusEditor | null>(null);
@@ -216,6 +229,19 @@
description: item.description,
durationSeconds: item.duration_seconds ?? '',
cooldownSeconds: item.cooldown_seconds ?? '',
attack: item.attack ? {
enabled: true,
baseDamage: item.attack.base_damage,
damageType: item.attack.damage_type,
delivery: item.attack.delivery,
hitsPerActivation: item.attack.hits_per_activation,
tickIntervalSeconds: item.attack.tick_interval_seconds ?? '',
damageDurationSeconds: item.attack.damage_duration_seconds ?? '',
castSeconds: item.attack.cast_seconds,
targetCap: item.attack.target_cap,
chargeCount: item.attack.charge_count,
chargePolicy: item.attack.charge_policy
} : blankAbility(item.specialization_id).attack,
effects: (item.effects ?? []).map(normalizedEffect),
active: Number(item.active),
iconUrl: item.iconUrl ?? ''
@@ -248,16 +274,7 @@
effects: (rank.effects ?? []).map(normalizedEffect),
combatEffects: (rank.combatEffects ?? []).map((effect: any) => ({
effectId: String(effect.combat_effect_id ?? effect.effectId ?? ''),
triggerCount: effect.trigger_count ?? effect.triggerCount ?? 1,
triggerWindowSeconds: effect.trigger_window_seconds ?? effect.triggerWindowSeconds ?? 1,
durationSeconds: effect.duration_seconds ?? effect.durationSeconds ?? 10,
maxStacks: effect.max_stacks ?? effect.maxStacks ?? 1,
stacksPerTrigger: effect.stacks_per_trigger ?? effect.stacksPerTrigger ?? 1,
modifiers: (effect.modifiers ?? []).map((modifier: any) => ({
statId: String(modifier.stat_attribute_id ?? modifier.statId ?? ''),
valuePerStack: modifier.value_per_stack ?? modifier.valuePerStack ?? '',
unit: String(modifier.unit ?? '%')
}))
conditionalRule: effect.conditionalRule ?? blankConditionalRule()
})),
costs: { ...(rank.costs ?? {}) }
})),
@@ -280,13 +297,6 @@
const removeRankCombatEffect = (rank: RankEditor, index: number) => {
rank.combatEffects = rank.combatEffects.filter((_, itemIndex) => itemIndex !== index);
};
const addCombatModifier = (effect: RankCombatEffect) => {
effect.modifiers = [...effect.modifiers, blankCombatModifier()];
};
const removeCombatModifier = (effect: RankCombatEffect, index: number) => {
if (effect.modifiers.length <= 1) return;
effect.modifiers = effect.modifiers.filter((_, itemIndex) => itemIndex !== index);
};
const openAbilityConfiguration = () => {
if (!selectedFocus) return;
abilitySlots = [...(selectedFocus.abilityIds ?? ['', '', ''])];
@@ -451,6 +461,7 @@
<PendingForm action="?/saveAbility" enctype="multipart/form-data">
<input type="hidden" name="id" value={abilityEditing.id}/>
<input type="hidden" name="effectsJson" value={JSON.stringify(abilityEditing.effects)}/>
<input type="hidden" name="attackJson" value={JSON.stringify(abilityEditing.attack)}/>
<div class="isac-record-identity specialization-record-identity">
<IconUploadField currentUrl={abilityEditing.iconUrl} label="ABILITY ICON" resetToken={iconResetToken}/>
<div>
@@ -468,6 +479,27 @@
<label class="field">COOLDOWN · SECONDS<input name="cooldownSeconds" type="number" min="0.01" step="any" bind:value={abilityEditing.cooldownSeconds} placeholder="OPTIONAL"/></label>
<div><span>AVAILABILITY</span><strong>IMMEDIATE · NO POINT OR MATERIAL COST</strong><small>Assigned ability slots 1 and 3 are mirrored into the top-left of the focus tree.</small></div>
</div>
<div class="specialization-attack-profile">
<div class="selection-head"><div><span>INTRINSIC ATTACK PROFILE</span><small>UNMODIFIED DAMAGE BEFORE ENGINEERING, ATTRIBUTES, TALENTS, AND TREE UNLOCKS</small></div><b>{abilityEditing.attack.enabled ? 'DAMAGING ABILITY' : 'NO DIRECT DAMAGE'}</b></div>
<SegmentedChoice legend="DAMAGE MODEL" name="ability-damage-model" options={[{value:'disabled',label:'NO DAMAGE'},{value:'enabled',label:'MODELS DAMAGE'}]} value={abilityEditing.attack.enabled ? 'enabled' : 'disabled'} onchange={(values) => setAbilityAttackEnabled(values[0] === 'enabled')}/>
{#if abilityEditing.attack.enabled}
<div class="specialization-attack-grid">
<label class="field">UNMODIFIED BASE DAMAGE<input type="number" min="0.01" step="any" bind:value={abilityEditing.attack.baseDamage} required/></label>
<SegmentedChoice legend="DAMAGE TYPE" name="ability-damage-type" options={damageTypeOptions} bind:value={abilityEditing.attack.damageType} required/>
<SegmentedChoice legend="DELIVERY" name="ability-delivery" options={deliveryOptions} bind:value={abilityEditing.attack.delivery} required/>
<label class="field">{abilityEditing.attack.delivery === 'periodic' ? 'HITS PER TICK' : 'HITS PER ACTIVATION'}<input type="number" min="1" step="1" bind:value={abilityEditing.attack.hitsPerActivation} required/></label>
{#if abilityEditing.attack.delivery === 'periodic'}
<label class="field">TICK INTERVAL · SECONDS<input type="number" min="0.01" step="any" bind:value={abilityEditing.attack.tickIntervalSeconds} required/></label>
<label class="field">DAMAGING DURATION · SECONDS<input type="number" min="0.01" step="any" bind:value={abilityEditing.attack.damageDurationSeconds} required/></label>
{/if}
<label class="field">CAST TIME · SECONDS<input type="number" min="0" step="any" bind:value={abilityEditing.attack.castSeconds} required/></label>
<label class="field">TARGET CAP<input type="number" min="1" step="1" bind:value={abilityEditing.attack.targetCap} required/></label>
<SegmentedChoice legend="CHARGE POLICY" name="ability-charge-policy" options={chargePolicyOptions} bind:value={abilityEditing.attack.chargePolicy} required/>
{#if abilityEditing.attack.chargePolicy !== 'none'}<label class="field">CHARGE COUNT<input type="number" min="1" step="1" bind:value={abilityEditing.attack.chargeCount} required/></label>{/if}
</div>
<p class="calculation-note">All damaging skills can critically hit and Multi-Shot. Skill attacks do not headshot. Periodic damage reads active modifiers again on every tick.</p>
{/if}
</div>
<div class="selection-head"><div><span>STRUCTURED COMBAT FACTS</span><small>ATTRIBUTE REFERENCES OR CUSTOM DISPLAY VALUES</small></div><b>{abilityEditing.effects.length} EFFECTS</b></div>
<StructuredEffectEditor bind:effects={abilityEditing.effects} attributes={data.attributes} idPrefix="ability-effect" addLabel="ADD COMBAT FACT" allowTargetLabelFallback/>
{#if form?.abilityError}<p class="error">{form.abilityError}</p>{/if}
@@ -493,7 +525,7 @@
<label class="field isac-record-name">NAME<input name="name" bind:value={combatEffectEditing.name} maxlength="100" required placeholder="Relentless"/></label>
<label class="field combat-effect-template">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={combatEffectEditing.descriptionTemplate} maxlength="2400" required placeholder={'Grants {1} Weapon Damage and {2} Critical Damage for {duration} seconds, up to {maxStacks} stacks.'}></textarea></label>
</div>
<p class="configuration-note">Use {`{triggerCount}`}, {`{triggerWindow}`}, {`{duration}`}, {`{maxStacks}`}, and {`{1}`}, {`{2}`} for ordered Attribute modifiers. Trigger and stack values are configured on each tree rank that grants this effect.</p>
<p class="configuration-note">Use {`{duration}`}, {`{maxStacks}`}, and {`{1}`}, {`{2}`} for ordered calculation outcomes. Each tree rank owns its event branches, lifecycle, scope, and values.</p>
{#if form?.combatEffectError}<p class="error">{form.combatEffectError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => combatEffectEditing = null}>CANCEL</button><button class="primary">SAVE COMBAT EFFECT →</button></div>
</PendingForm>
@@ -538,25 +570,12 @@
<section class="rank-combat-effect">
<header><div><span>EFFECT {combatIndex + 1}</span><small>{combatEffectById(combatEffect.effectId)?.name ?? 'SELECT SHARED EFFECT'}</small></div><button type="button" class="moderation-action danger" onclick={() => removeRankCombatEffect(rank, combatIndex)}>REMOVE</button></header>
<div class="field"><span>SHARED EFFECT</span><SearchableAssignment items={combatEffectList(String(focusById(nodeEditing.focusId)?.specialization_id ?? ''))} mode="single" compact fieldName={`rank-${rank.rank}-combat-${combatIndex}`} subjectLabel="COMBAT EFFECT" searchPlaceholder="Search shared effects…" bind:value={combatEffect.effectId}/></div>
<div class="combat-trigger-grid">
<label class="field">ENEMY HITS<input type="number" min="1" max="1000" step="1" bind:value={combatEffect.triggerCount} required/></label>
<label class="field">WITHIN · SECONDS<input type="number" min="0.01" max="3600" step="any" bind:value={combatEffect.triggerWindowSeconds} required/></label>
<label class="field">BUFF DURATION · SECONDS<input type="number" min="0.01" max="86400" step="any" bind:value={combatEffect.durationSeconds} required/></label>
<label class="field">MAX STACKS<input type="number" min="1" max="1000" step="1" bind:value={combatEffect.maxStacks} required/></label>
<label class="field">STACKS PER TRIGGER<input type="number" min="1" max={Number(combatEffect.maxStacks) || 1} step="1" bind:value={combatEffect.stacksPerTrigger} required/></label>
</div>
<div class="combat-lifecycle-facts"><span>COUNTER <b>RESETS AFTER PROC</b></span><span>STACK GAIN <b>REFRESHES TIMER</b></span><span>EXPIRY <b>CLEARS ALL STACKS</b></span></div>
<div class="combat-modifier-list">
{#each combatEffect.modifiers as modifier, modifierIndex}
<div>
<div class="field"><span>ATTRIBUTE PER STACK</span><SearchableAssignment items={data.attributes} mode="single" compact fieldName={`rank-${rank.rank}-combat-${combatIndex}-modifier-${modifierIndex}`} subjectLabel="ATTRIBUTE" searchPlaceholder="Search Attributes…" bind:value={modifier.statId}/></div>
<label class="field">VALUE PER STACK<input type="number" step="any" bind:value={modifier.valuePerStack} required/></label>
<SegmentedChoice legend="UNIT" name={`rank-${rank.rank}-combat-${combatIndex}-modifier-${modifierIndex}-unit`} options={unitOptions} columns={5} bind:value={modifier.unit}/>
<button type="button" class="moderation-action danger" disabled={combatEffect.modifiers.length <= 1} onclick={() => removeCombatModifier(combatEffect, modifierIndex)}>REMOVE</button>
</div>
{/each}
<button type="button" class="secondary" onclick={() => addCombatModifier(combatEffect)}>ADD ATTRIBUTE MODIFIER +</button>
</div>
<MechanicProgramEditor
attributes={data.attributes}
valueColumns={conditionalValueColumns}
idPrefix={`specialization-rank-${rank.rank}-conditional-${combatIndex}`}
bind:rule={combatEffect.conditionalRule}
/>
</section>
{/each}
</div>
+121 -72
View File
@@ -8,9 +8,23 @@ import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/p
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
import { getPendingEntities } from '$lib/server/data-moderation';
import {
conditionalRuleSnapshot,
loadConditionalRuleDraft,
parseConditionalRulePayload,
saveConditionalRuleInTransaction
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
type SharedEffect = { statId: string; valueI: number; valueII: number };
const descriptionOutcomes = (draft: ReturnType<typeof parseConditionalRulePayload>) => {
const outcomes = draft.clauses.flatMap((clause) => clause.outcomes)
.filter((outcome) => outcome.descriptionSlot)
.sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot));
return [...new Map(outcomes.map((outcome) => [outcome.descriptionSlot, outcome])).values()];
};
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies));
const db = await getDatabase();
@@ -35,11 +49,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const args = search ? [pattern] : [];
const filteredRow = await (await db.prepare(`SELECT COUNT(*) AS total FROM talents ${where}`)).get(...args);
const pagination = getPagination(url, Number(filteredRow?.total ?? 0));
const [talents, tiers, triggers, links, effects, stats, templates, weaponScopes, weaponSlots, exoticOwners, icons] = await Promise.all([
const [talents, tiers, effects, stats, templates, weaponScopes, weaponSlots, exoticOwners, icons] = await Promise.all([
(await db.prepare(`SELECT * FROM talents ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset),
(await db.prepare('SELECT id,talent_id,tier,description FROM talent_tiers')).all(),
(await db.prepare('SELECT * FROM talent_triggers ORDER BY active DESC,name COLLATE NOCASE')).all(),
(await db.prepare('SELECT talent_tier_id,trigger_id FROM talent_tier_triggers')).all(),
(await db.prepare('SELECT talent_tier_id,stat_attribute_id,value FROM talent_tier_effects')).all(),
(await db.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await db.prepare('SELECT talent_id,template FROM talent_description_templates')).all(),
@@ -48,8 +60,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(await db.prepare('SELECT id,name,exotic_talent_id FROM weapons WHERE exotic_talent_id IS NOT NULL')).all(),
(await db.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all()
]);
const [triggerSummary, slotSummary, exoticSummary, gearSummary, pending] = await Promise.all([
(await db.prepare('SELECT COUNT(*) AS total FROM talent_triggers')).get(),
const [slotSummary, exoticSummary, gearSummary, pending] = await Promise.all([
(await db.prepare(`SELECT
SUM(CASE WHEN slot=1 THEN 1 ELSE 0 END) AS slot1,
SUM(CASE WHEN slot=2 THEN 1 ELSE 0 END) AS slot2 FROM weapon_talent_slots`)).get(),
@@ -59,12 +70,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
SUM(CASE WHEN category IN ('Vest','Body Armor') THEN 1 ELSE 0 END) AS body_armor
FROM talents`)).get(),
getPendingEntities(user.id, [
{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] },
{ module: 'attributes', action: 'save', kind: 'stat', fields: ['unit'] },
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category', 'descriptionTemplate', 'cooldownSeconds', 'durationSeconds', 'maxStacks'] }
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category', 'descriptionTemplate', 'conditionalRule'] }
])
]);
const pendingTriggers = pending.filter((item) => item.kind === 'trigger');
const pendingStats = pending.filter((item) => item.kind === 'stat');
const pendingTalents = pending.filter((item) => item.kind === 'talent').filter((item) =>
filter === 'slot1' ? item.category === 'Weapon Talent 1'
@@ -81,12 +90,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
duration_seconds: item.durationSeconds || null,
max_stacks: item.maxStacks || null,
descriptionTemplate: item.descriptionTemplate,
conditionalRule: item.conditionalRule ?? null,
ownerWeaponId: null,
ownerWeaponName: null,
tiers: ['I', 'II'].map((tier) => ({ tier, description: String(item.descriptionTemplate || 'PENDING TALENT CONFIGURATION') }))
}));
return {
talents: [...pendingTalents, ...talents.map((talent) => ({
const configuredTalents = await Promise.all(talents.map(async (talent) => ({
...talent,
iconUrl: (() => { const icon = icons.find((item) => item.entity_id === talent.id); return icon ? gameDataIconUrl('talent', String(talent.id), icon.updated_at) : ''; })(),
category: talent.category === 'Vest'
@@ -101,19 +110,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
? 'Weapon · Slot Unassigned'
: talent.category,
descriptionTemplate: templates.find((item) => item.talent_id === talent.id)?.template ?? '',
conditionalRule: await loadConditionalRuleDraft(db, 'talent', String(talent.id), ['I', 'II']),
ownerWeaponId: exoticOwners.find((weapon) => weapon.exotic_talent_id === talent.id)?.id ?? null,
ownerWeaponName: exoticOwners.find((weapon) => weapon.exotic_talent_id === talent.id)?.name ?? null,
tiers: tiers.filter((tier) => tier.talent_id === talent.id).map((tier) => ({
...tier,
triggerIds: links.filter((link) => link.talent_tier_id === tier.id).map((link) => String(link.trigger_id)),
effects: effects.filter((effect) => effect.talent_tier_id === tier.id)
}))
}))],
triggers: [...pendingTriggers, ...triggers],
})));
return {
talents: [...pendingTalents, ...configuredTalents],
stats: [...pendingStats, ...stats],
summary: {
total: Number(totalRow?.total ?? 0) + pending.filter((item) => item.kind === 'talent').length,
triggers: Number(triggerSummary?.total ?? 0),
slot1: Number(slotSummary?.slot1 ?? 0),
slot2: Number(slotSummary?.slot2 ?? 0),
exotic: Number(exoticSummary?.total ?? 0),
@@ -165,22 +174,6 @@ const renderDescription = (
});
export const actions: Actions = {
saveTrigger: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), description = String(form.get('description') ?? '').trim(), uptime = form.get('supportsUptime') === 'on';
const min = uptime ? Number(form.get('minUptime')) : null, max = uptime ? Number(form.get('maxUptime')) : null;
if (name.length < 2 || !description) return fail(400, { triggerError: 'A trigger name and description are required.' });
if (uptime && (!Number.isFinite(min) || !Number.isFinite(max) || Number(min) < 0 || Number(max) > 100 || Number(min) > Number(max))) return fail(400, { triggerError: 'Uptime must be a valid 0–100% range.' });
const db = await getDatabase();
if (await (await db.prepare('SELECT id FROM talent_triggers WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) return fail(409, { triggerError: 'That trigger already exists.' });
const now = new Date().toISOString(), triggerId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await auditRecord('talent_triggers', id) : null;
if (id) await (await db.prepare('UPDATE talent_triggers SET name=?,description=?,supports_uptime=?,min_uptime=?,max_uptime=?,updated_at=? WHERE id=?')).run(name, description, uptime ? 1 : 0, min, max, now, id);
else await (await db.prepare('INSERT INTO talent_triggers(id,name,description,supports_uptime,min_uptime,max_uptime,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)')).run(triggerId, name, description, uptime ? 1 : 0, min, max, now, now);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'talent_trigger.updated' : 'talent_trigger.created', entityId: triggerId, before, after: await auditRecord('talent_triggers', triggerId) });
return { triggerSaved: true, triggerMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
saveTalent: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
@@ -189,58 +182,114 @@ export const actions: Actions = {
catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { talentError: error.message }); throw error; }
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? '');
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').trim();
const cooldownSeconds = optionalPositiveNumber(form, 'cooldownSeconds');
const durationSeconds = optionalPositiveNumber(form, 'durationSeconds');
const maxStacks = optionalPositiveNumber(form, 'maxStacks');
const triggerIds = [...new Set(form.getAll('triggerIds').map(String))];
let sharedEffects: SharedEffect[];
try { sharedEffects = parseEffects(form.get('effects')); } catch { return fail(400, { talentError: 'Effects could not be read.' }); }
const db = await getDatabase();
const conditionalRuleRaw = form.get('conditionalRule');
let conditionalRule;
try {
const activeStats = await (await db.prepare('SELECT id FROM stat_attributes WHERE active=1')).all();
conditionalRule = parseConditionalRulePayload(conditionalRuleRaw, [
{ key: 'tierI', variantKey: 'I' },
{ key: 'tierII', variantKey: 'II' }
], {
allowedAttributeIds: new Set(activeStats.map((row) => String(row.id))),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY))
});
} catch (error) {
return fail(400, { talentError: error instanceof Error ? error.message : 'The conditional mechanic is invalid.' });
}
const cooldownSeconds = conditionalRule.clauses.find((clause) => clause.cooldownSeconds)?.cooldownSeconds ?? null;
const durationSeconds = conditionalRule.pools.find((pool) => pool.expiresAfterSeconds)?.expiresAfterSeconds ?? null;
const maxStacks = conditionalRule.pools.length
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
: null;
const sharedEffects: SharedEffect[] = descriptionOutcomes(conditionalRule)
.filter((effect) => effect.kind === 'attribute_modifier' && effect.attributeId)
.map((effect) => ({
statId: effect.attributeId ?? '',
valueI: effect.variants.I.value,
valueII: effect.variants.II.value
}));
if (name.length < 2 || !['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].includes(category)) return fail(400, { talentError: 'Provide a valid talent name and category. Exotic talents are managed from their owning weapon.' });
if (!descriptionTemplate || !triggerIds.length || !sharedEffects.length) return fail(400, { talentError: 'A description template, trigger, and at least one effect are required.' });
if ([cooldownSeconds, durationSeconds].some((value) => value !== null && (!Number.isFinite(value) || value <= 0))) return fail(400, { talentError: 'Cooldown and duration must be positive numbers when defined.' });
if (maxStacks !== null && (!Number.isInteger(maxStacks) || maxStacks <= 0)) return fail(400, { talentError: 'Maximum stacks must be a positive whole number when defined.' });
if (sharedEffects.some((effect) => !effect.statId || !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))) return fail(400, { talentError: 'Every effect needs valid Tier I and Tier II values.' });
if (new Set(sharedEffects.map((effect) => effect.statId)).size !== sharedEffects.length) return fail(400, { talentError: 'Each affected attribute can only be selected once.' });
if (!descriptionTemplate || !conditionalRule.clauses.length || !sharedEffects.length) return fail(400, { talentError: 'A description template, mechanic branch, and at least one numbered Attribute outcome are required.' });
if (sharedEffects.some((effect) => !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))) return fail(400, { talentError: 'Every outcome needs valid Tier I and Tier II values.' });
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1]));
if (placeholders.some((index) => index < 1 || index > sharedEffects.length)) return fail(400, { talentError: 'Description placeholders must reference an existing effect.' });
if (descriptionTemplate.includes('{duration}') && durationSeconds === null) return fail(400, { talentError: 'The description uses {duration}, but no duration is defined.' });
if (descriptionTemplate.includes('{max_stacks}') && maxStacks === null) return fail(400, { talentError: 'The description uses {max_stacks}, but no maximum stack count is defined.' });
const storedCategory = category === 'Body Armor' ? 'Vest' : category.startsWith('Weapon Talent') ? 'Weapon' : category;
const db = await getDatabase();
if (await (await db.prepare('SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?')).get(name, storedCategory, id || 'new')) return fail(409, { talentError: 'That talent already exists in this category.' });
const statRows = await (await db.prepare(`SELECT id,unit FROM stat_attributes WHERE active=1 AND id IN (${sharedEffects.map(() => '?').join(',')})`)).all(...sharedEffects.map((effect) => effect.statId));
if (statRows.length !== sharedEffects.length) return fail(400, { talentError: 'One or more selected attributes are unavailable.' });
const statIds = conditionalRule.clauses.flatMap((clause) => clause.outcomes)
.map((effect) => effect.attributeId)
.filter((value): value is string => Boolean(value));
const statRows = statIds.length
? await (await db.prepare(`SELECT id,unit FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).all(...statIds)
: [];
if (statRows.length !== new Set(statIds).size) return fail(400, { talentError: 'One or more selected attributes are unavailable.' });
const units = new Map(statRows.map((stat) => [String(stat.id), stat.unit]));
const talentId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString();
const before = id ? { record: await auditRecord('talents', id), icon: await gameDataIconMetadata(db, 'talent', id), template: await (await db.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(id), tiers: await (await db.prepare('SELECT tier,description FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(id) } : null;
if (id) await (await db.prepare('UPDATE talents SET name=?,category=?,cooldown_seconds=?,duration_seconds=?,max_stacks=?,updated_at=? WHERE id=?')).run(name, storedCategory, cooldownSeconds, durationSeconds, maxStacks, now, id);
else await (await db.prepare('INSERT INTO talents(id,name,category,cooldown_seconds,duration_seconds,max_stacks,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)')).run(talentId, name, storedCategory, cooldownSeconds, durationSeconds, maxStacks, now, now);
if (storedCategory === 'Weapon') {
await (await db.prepare(`INSERT INTO talent_weapon_scopes(talent_id,scope) VALUES(?,?)
ON CONFLICT(talent_id) DO UPDATE SET scope=excluded.scope`))
.run(talentId, 'Standard');
await (await db.prepare(`INSERT INTO weapon_talent_slots(talent_id,slot) VALUES(?,?)
ON CONFLICT(talent_id) DO UPDATE SET slot=excluded.slot`))
.run(talentId, category === 'Weapon Talent 1' ? 1 : 2);
} else {
await (await db.prepare('DELETE FROM talent_weapon_scopes WHERE talent_id=?')).run(talentId);
await (await db.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(talentId);
const before = id ? {
record: await auditRecord('talents', id),
icon: await gameDataIconMetadata(db, 'talent', id),
template: await (await db.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(id),
tiers: await (await db.prepare('SELECT tier,description FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(id),
conditional: await conditionalRuleSnapshot(db, 'talent', id)
} : null;
await db.exec('BEGIN');
try {
if (id) await (await db.prepare('UPDATE talents SET name=?,category=?,cooldown_seconds=?,duration_seconds=?,max_stacks=?,updated_at=? WHERE id=?')).run(name, storedCategory, cooldownSeconds, durationSeconds, maxStacks, now, id);
else await (await db.prepare('INSERT INTO talents(id,name,category,cooldown_seconds,duration_seconds,max_stacks,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)')).run(talentId, name, storedCategory, cooldownSeconds, durationSeconds, maxStacks, now, now);
if (storedCategory === 'Weapon') {
await (await db.prepare(`INSERT INTO talent_weapon_scopes(talent_id,scope) VALUES(?,?)
ON CONFLICT(talent_id) DO UPDATE SET scope=excluded.scope`))
.run(talentId, 'Standard');
await (await db.prepare(`INSERT INTO weapon_talent_slots(talent_id,slot) VALUES(?,?)
ON CONFLICT(talent_id) DO UPDATE SET slot=excluded.slot`))
.run(talentId, category === 'Weapon Talent 1' ? 1 : 2);
} else {
await (await db.prepare('DELETE FROM talent_weapon_scopes WHERE talent_id=?')).run(talentId);
await (await db.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(talentId);
}
await (await db.prepare(`INSERT INTO talent_description_templates(talent_id,template,updated_at) VALUES(?,?,?)
ON CONFLICT(talent_id) DO UPDATE SET template=excluded.template,updated_at=excluded.updated_at`)).run(talentId, descriptionTemplate, now);
for (const tier of ['I', 'II'] as const) {
const description = renderDescription(descriptionTemplate, sharedEffects, units, tier, durationSeconds, maxStacks);
await (await db.prepare(`INSERT INTO talent_tiers(id,talent_id,tier,description,trigger_text,effect_text,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(talent_id,tier) DO UPDATE SET description=excluded.description,updated_at=excluded.updated_at`)).run(randomUUID(), talentId, tier, description, '', '', now, now);
const tierRow = await (await db.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(talentId, tier);
const tierId = String(tierRow?.id);
await (await db.prepare('DELETE FROM talent_tier_effects WHERE talent_tier_id=?')).run(tierId);
for (const outcome of descriptionOutcomes(conditionalRule)) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId) continue;
await (await db.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')).run(
randomUUID(),
tierId,
outcome.attributeId,
tier === 'I' ? outcome.variants.I.value : outcome.variants.II.value,
now
);
}
}
await saveConditionalRuleInTransaction(db, 'talent', talentId, conditionalRule);
await applyIconMutation(db, 'talent', talentId, iconMutation, operator.id);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'talent.updated' : 'talent.created',
entityId: talentId,
before,
after: {
record: await auditRecord('talents', talentId),
icon: await gameDataIconMetadata(db, 'talent', talentId),
category,
descriptionTemplate,
conditional: await conditionalRuleSnapshot(db, 'talent', talentId)
}
});
await db.exec('COMMIT');
} catch (error) {
await db.exec('ROLLBACK');
throw error;
}
await (await db.prepare(`INSERT INTO talent_description_templates(talent_id,template,updated_at) VALUES(?,?,?)
ON CONFLICT(talent_id) DO UPDATE SET template=excluded.template,updated_at=excluded.updated_at`)).run(talentId, descriptionTemplate, now);
for (const tier of ['I', 'II'] as const) {
const description = renderDescription(descriptionTemplate, sharedEffects, units, tier, durationSeconds, maxStacks);
await (await db.prepare(`INSERT INTO talent_tiers(id,talent_id,tier,description,trigger_text,effect_text,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(talent_id,tier) DO UPDATE SET description=excluded.description,updated_at=excluded.updated_at`)).run(randomUUID(), talentId, tier, description, '', '', now, now);
const tierRow = await (await db.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(talentId, tier);
const tierId = String(tierRow?.id);
await (await db.prepare('DELETE FROM talent_tier_triggers WHERE talent_tier_id=?')).run(tierId);
await (await db.prepare('DELETE FROM talent_tier_effects WHERE talent_tier_id=?')).run(tierId);
for (const triggerId of triggerIds) await (await db.prepare('INSERT INTO talent_tier_triggers(talent_tier_id,trigger_id) VALUES(?,?)')).run(tierId, triggerId);
for (const effect of sharedEffects) await (await db.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')).run(randomUUID(), tierId, effect.statId, tier === 'I' ? effect.valueI : effect.valueII, now);
}
await applyIconMutation(db, 'talent', talentId, iconMutation, operator.id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'talent.updated' : 'talent.created', entityId: talentId, before, after: { record: await auditRecord('talents', talentId), icon: await gameDataIconMetadata(db, 'talent', talentId), category, descriptionTemplate, triggerIds, effects: sharedEffects } });
return { talentSaved: true, talentMode: id ? 'edit' : 'add', resultToken: randomUUID() };
}
};
+51 -42
View File
@@ -6,57 +6,73 @@
import ItemIcon from '$lib/components/ItemIcon.svelte';
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
let { data, form } = $props();
type Effect = { statId: string; valueI: string; valueII: string };
const categoryOptions = ['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].map((value) => ({ value, label: value }));
let talent = $state<any>(null), trigger = $state<any>(null), uptime = $state(false);
let talent = $state<any>(null);
let handledResultToken = $state('');
let iconResetToken = $state(0);
const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', cooldown_seconds: '', duration_seconds: '', max_stacks: '', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] });
const blankRule = (): ConditionalMechanicDraftV1 => ({
version: 1,
simulationReadiness: 'captured_only',
scenarioInputs: [],
pools: [],
clauses: [{
key: 'primary',
label: 'Primary',
event: 'weapon_hit',
if: { kind: 'true' },
reducer: { kind: 'none' },
mutations: [],
outcomes: [{
key: 'effect-1',
label: 'Effect 1',
phase: 'while_active',
kind: 'attribute_modifier',
scope: 'equipped_weapon',
attributeId: '',
basis: 'flat',
perStack: true,
descriptionSlot: 1,
variants: { I: { value: 0 }, II: { value: 0 } }
}]
}]
});
const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', conditionalRule: blankRule() });
const editTalent = (row: any) => {
const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', triggerIds: [], effects: [] };
const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', effects: [] };
const one = tier('I'), two = tier('II');
const statIds = [...new Set([...one.effects, ...two.effects].map((effect: any) => String(effect.stat_attribute_id)))];
talent = {
...row,
cooldown_seconds: row.cooldown_seconds ?? '',
duration_seconds: row.duration_seconds ?? '',
max_stacks: row.max_stacks ?? '',
descriptionTemplate: row.descriptionTemplate || one.description,
tierIDescription: one.description,
tierIIDescription: two.description,
triggerIds: [...new Set([...one.triggerIds, ...two.triggerIds])],
effects: statIds.map((statId) => ({
statId,
valueI: String(one.effects.find((effect: any) => String(effect.stat_attribute_id) === statId)?.value ?? ''),
valueII: String(two.effects.find((effect: any) => String(effect.stat_attribute_id) === statId)?.value ?? '')
}))
conditionalRule: row.conditionalRule ?? blankRule()
};
};
const blankTrigger = () => { uptime = false; trigger = { id: '', name: '', description: '', min_uptime: '', max_uptime: '' }; };
const editTrigger = (row: any) => { uptime = Number(row.supports_uptime) === 1; trigger = { ...row }; };
const toggleTrigger = (id: string, checked: boolean) => talent.triggerIds = checked ? [...talent.triggerIds, id] : talent.triggerIds.filter((value: string) => value !== id);
const addEffect = () => talent.effects = [...talent.effects, { statId: '', valueI: '', valueII: '' }];
const removeEffect = (index: number) => talent.effects = talent.effects.filter((_: Effect, row: number) => row !== index);
const configured = (tiers: Array<{ tier: unknown }>, name: string) => tiers.some((item) => item.tier === name);
const tierDescription = (tiers: Array<{ tier: unknown; description?: unknown }>, name: string) => {
const description = tiers.find((item) => item.tier === name)?.description;
return typeof description === 'string' && description.trim() ? description : 'NOT CONFIGURED';
};
const displayValue = (effect: Effect, tier: 'I' | 'II') => {
const stat = data.stats.find((item) => item.id === effect.statId);
const value = tier === 'I' ? effect.valueI : effect.valueII;
const displayValue = (effect: any, tier: 'I' | 'II') => {
const stat = data.stats.find((item) => item.id === effect.attributeId);
const value = effect.variants?.[tier]?.value ?? '';
const unit = stat?.unit === 'flat' ? '' : stat?.unit ?? '';
return unit === 'Shots' ? `${value} ${unit}` : `${value}${unit}`;
};
const mechanicValue = (value: string | number, singular: string) => `${value} ${Number(value) === 1 ? singular : `${singular}s`}`;
const cooldownLine = (value: unknown) => value === null || value === undefined || value === '' ? '' : `Talent Cooldown: ${mechanicValue(value as string | number, 'second')}`;
const preview = (tier: 'I' | 'II') => talent.descriptionTemplate.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
if (expression === 'duration') return talent.duration_seconds === '' ? placeholder : mechanicValue(talent.duration_seconds, 'second');
if (expression === 'max_stacks') return talent.max_stacks === '' ? placeholder : mechanicValue(talent.max_stacks, 'stack');
const effect = talent.effects[Number(expression) - 1];
const duration = talent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds;
const maxStacks = talent.conditionalRule.pools.length ? Math.max(...talent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null;
if (expression === 'duration') return duration == null ? placeholder : mechanicValue(duration, 'second');
if (expression === 'max_stacks') return maxStacks == null ? placeholder : mechanicValue(maxStacks, 'stack');
const effect = talent.conditionalRule.clauses
.flatMap((clause: any) => clause.outcomes)
.find((outcome: any) => outcome.descriptionSlot === Number(expression));
return effect ? displayValue(effect, tier) : placeholder;
});
$effect(() => {
@@ -66,35 +82,28 @@
if (form.talentMode === 'add') iconResetToken += 1;
talent = form.talentMode === 'add' ? blankTalent() : null;
}
if (form.triggerSaved) {
if (form.triggerMode === 'add') blankTrigger();
else trigger = null;
}
});
</script>
<svelte:head><title>Talents — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">TALENT REGISTRY</p><h2>TALENTS</h2><p>Shared behavior with tier-specific values, reusable triggers, and attribute effects.</p></div><div><button class="secondary" onclick={blankTrigger}>ADD TRIGGER +</button><button class="primary" onclick={() => talent = blankTalent()}>ADD TALENT +</button></div></div>
<div class="module-toolbar"><div><p class="eyebrow">TALENT REGISTRY</p><h2>TALENTS</h2><p>Tier-specific Talent values and source-owned combat mechanics.</p></div><div><a class="secondary" href="/isac/mechanic-reference">MECHANIC REFERENCE →</a><button class="primary" onclick={() => talent = blankTalent()}>ADD TALENT +</button></div></div>
<KpiFilters className="talent-kpis" active={data.filter} items={[{value:'',label:'TALENTS',count:data.summary.total,description:'Show all definitions'},{value:'slot1',label:'TOP / TALENT 1',count:data.summary.slot1,description:'First-slot weapon talents'},{value:'slot2',label:'BOTTOM / TALENT 2',count:data.summary.slot2,description:'Second-slot weapon talents'},{value:'exotic',label:'EXOTIC',count:data.summary.exotic,description:'Exotic-only talents'},{value:'backpack',label:'BACKPACK',count:data.summary.backpack,description:'Backpack talents'},{value:'body-armor',label:'BODY ARMOR',count:data.summary.bodyArmor,description:'Body armor talents'}]}/>
{#if trigger}
<div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{trigger.id ? 'MODIFY TRIGGER' : 'NEW TRIGGER'}</p><h3>{trigger.id ? trigger.name : 'TRIGGER DEFINITION'}</h3></div><button aria-label="Close trigger editor" onclick={() => trigger = null}>×</button></div><PendingForm action="?/saveTrigger"><input type="hidden" name="id" value={trigger.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={trigger.name} required/></label><label class="field">DESCRIPTION<textarea name="description" bind:value={trigger.description} required></textarea></label><label class="field uptime-field"><span><input name="supportsUptime" type="checkbox" bind:checked={uptime}/> ENABLE THEORETICAL UPTIME</span><div><input name="minUptime" type="number" min="0" max="100" bind:value={trigger.min_uptime} disabled={!uptime} placeholder="MIN %"/><input name="maxUptime" type="number" min="0" max="100" bind:value={trigger.max_uptime} disabled={!uptime} placeholder="MAX %"/></div></label><p class="script-note"><b>CALCULATION SCRIPT</b><span>Reserved for the future calculation engine</span></p></div>{#if form?.triggerError}<p class="error">{form.triggerError}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => trigger = null}>CANCEL</button><button class="primary">SAVE TRIGGER →</button></div></PendingForm></div>
{/if}
{#snippet talentEditor()}
{#if talent}
<div class="dashboard-editor">
<div class="editor-title"><div><p class="eyebrow">{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}</p><h3>{talent.id ? talent.name : 'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={() => talent = null}>×</button></div>
<PendingForm action="?/saveTalent" enctype="multipart/form-data">
<input type="hidden" name="id" value={talent.id}/><input type="hidden" name="effects" value={JSON.stringify(talent.effects)}/>
<input type="hidden" name="id" value={talent.id}/><input type="hidden" name="conditionalRule" value={JSON.stringify(talent.conditionalRule)}/>
<div class="talent-icon-identity"><IconUploadField currentUrl={talent.iconUrl} label="TALENT ICON" resetToken={iconResetToken}/><div class="talent-identity"><label class="field">NAME<input name="name" bind:value={talent.name} required/></label><SegmentedChoice legend="CATEGORY / SLOT" name="category" options={categoryOptions} bind:value={talent.category} required/></div></div>
<div class="talent-mechanics-grid"><label class="field">COOLDOWN · SECONDS<input name="cooldownSeconds" type="number" min="0.01" step="any" bind:value={talent.cooldown_seconds} placeholder="NOT DEFINED"/></label><label class="field">DURATION · SECONDS<input name="durationSeconds" type="number" min="0.01" step="any" bind:value={talent.duration_seconds} placeholder="NOT DEFINED"/></label><label class="field">MAX STACKS<input name="maxStacks" type="number" min="1" step="1" bind:value={talent.max_stacks} placeholder="NOT DEFINED"/></label></div>
<label class="field template-field">SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={talent.descriptionTemplate} placeholder={'Hitting an enemy {1} times grants +{2} for {duration}...'} required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values. Use {'{duration}'} and {'{max_stacks}'} for defined mechanics. Cooldown is displayed separately.</small></label>
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{preview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{preview('II') || 'Description preview'}</p></div></div>
<p class="label">SHARED ACTIVATION TRIGGERS</p>
<div class="trigger-list shared-trigger-list">{#each data.triggers.filter((item) => Number(item.active) === 1) as item}<div class:pending-reference={item.pending}><label><input name="triggerIds" type="checkbox" value={item.id} checked={talent.triggerIds.includes(String(item.id))} onchange={(event) => toggleTrigger(String(item.id), event.currentTarget.checked)}/> <span>{item.name}</span></label>{#if item.pending}<b class="pending-reference-label">PENDING · {item.proposerName}</b>{:else}<button type="button" onclick={() => editTrigger(item)}>EDIT</button>{/if}</div>{/each}</div>
<div class="effects-head"><p class="label">SHARED ATTRIBUTE EFFECTS · TIER VALUES</p><button type="button" onclick={addEffect}>ADD EFFECT +</button></div>
<div class="shared-effects"><div class="shared-effect effect-heading"><span>ATTRIBUTE</span><span>TIER I VALUE</span><span>TIER II VALUE</span><span></span></div>{#each talent.effects as effect, index}<div class="shared-effect"><SearchableAssignment items={data.stats} mode="single" compact subjectLabel="ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={effect.statId}/><input type="number" step="any" bind:value={effect.valueI} placeholder="TIER I" required/><input type="number" step="any" bind:value={effect.valueII} placeholder="TIER II" required/><button type="button" aria-label={`Remove effect ${index + 1}`} onclick={() => removeEffect(index)}>×</button></div>{/each}</div>
<MechanicProgramEditor
attributes={data.stats}
valueColumns={[{key:'tierI',variantKey:'I',label:'TIER I'},{key:'tierII',variantKey:'II',label:'TIER II'}]}
idPrefix={`talent-${talent.id || 'new'}`}
bind:rule={talent.conditionalRule}
/>
{#if form?.talentError}<p class="error">{form.talentError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => talent = null}>CANCEL</button><button class="primary">SAVE TALENT →</button></div>
</PendingForm>
@@ -107,4 +116,4 @@
{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><SortableColumnHeader label="TALENT" column="name" sort={data.sort}/><SortableColumnHeader label="CATEGORY" column="category" sort={data.sort}/><SortableColumnHeader label="TIER I EFFECT" column="tier1" sort={data.sort}/><SortableColumnHeader label="TIER II EFFECT" column="tier2" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row" class:pending-record={item.pending} class:registry-row-editing={String(talent?.id ?? '') === String(item.id)}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : 'ACTIVE'}</b>{#if item.pending}<span></span>{:else if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/isac/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button aria-expanded={String(talent?.id ?? '') === String(item.id)} aria-controls={`talent-editor-${item.id}`} onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{#if String(talent?.id ?? '') === String(item.id)}<RegistryInlineEditor id={`talent-editor-${item.id}`} label={`Edit ${item.name}`}>{@render talentEditor()}</RegistryInlineEditor>{/if}{/each}</div>
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><SortableColumnHeader label="TALENT" column="name" sort={data.sort}/><SortableColumnHeader label="CATEGORY" column="category" sort={data.sort}/><SortableColumnHeader label="TIER I EFFECT" column="tier1" sort={data.sort}/><SortableColumnHeader label="TIER II EFFECT" column="tier2" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row" class:pending-record={item.pending} class:registry-row-editing={String(talent?.id ?? '') === String(item.id)}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : item.conditionalRule?.clauses?.length ? 'MODELED' : 'CONFIG REQUIRED'}</b>{#if item.pending}<span></span>{:else if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/isac/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button aria-expanded={String(talent?.id ?? '') === String(item.id)} aria-controls={`talent-editor-${item.id}`} onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{#if String(talent?.id ?? '') === String(item.id)}<RegistryInlineEditor id={`talent-editor-${item.id}`} label={`Edit ${item.name}`}>{@render talentEditor()}</RegistryInlineEditor>{/if}{/each}</div>
+90 -74
View File
@@ -8,12 +8,19 @@ import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/p
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
import { getPendingEntities } from '$lib/server/data-moderation';
import {
conditionalRuleSnapshot,
loadConditionalRuleDraft,
parseConditionalRulePayload,
saveConditionalRuleInTransaction
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
const rangeNames = ['damage'] as const;
const modSlotTypes = ['Optics', 'Muzzle', 'Underbarrel', 'Magazine', 'Barrel', 'Upper Receiver', 'Lower Receiver', 'Trigger'] as const;
type ExoticEffect = { statId: string; valueI: number; valueII: number };
type ExoticEffect = { statId: string | null; valueI: number; valueII: number };
const mechanicValue = (value: number, singular: string) => `${value} ${value === 1 ? singular : `${singular}s`}`;
const renderTalentDescription = (template: string, effects: ExoticEffect[], units: Map<string, unknown>, tier: 'I' | 'II', duration: number | null, maxStacks: number | null) =>
@@ -23,10 +30,17 @@ const renderTalentDescription = (template: string, effects: ExoticEffect[], unit
const effect = effects[Number(expression) - 1];
if (!effect) return placeholder;
const value = tier === 'I' ? effect.valueI : effect.valueII;
const unit = units.get(effect.statId) === 'flat' ? '' : String(units.get(effect.statId) ?? '');
const unitValue = effect.statId ? units.get(effect.statId) : '';
const unit = unitValue === 'flat' ? '' : String(unitValue ?? '');
return unit === 'Shots' ? `${value} ${unit}` : `${value}${unit}`;
});
const mechanicOutcomes = (rule: ReturnType<typeof parseConditionalRulePayload>) =>
rule.clauses.flatMap((clause) => clause.outcomes);
const descriptionOutcomes = (rule: ReturnType<typeof parseConditionalRulePayload>) =>
mechanicOutcomes(rule).filter((outcome) => outcome.descriptionSlot)
.sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot));
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
@@ -62,7 +76,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const whereArgs = [...(requestedWeaponId ? [requestedWeaponId] : []), ...(!requestedWeaponId && search ? [pattern] : [])];
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs);
const pagination = getPagination(url, Number(filtered?.total ?? 0));
const [weapons, tierRanges, modSlots, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, triggers, exoticTemplates, exoticTiers, exoticTierTriggers, exoticTierEffects, talentIcons, pending] = await Promise.all([
const [weapons, tierRanges, modSlots, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, exoticTemplates, talentIcons, pending] = await Promise.all([
(await database.prepare(`SELECT * FROM weapons ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...whereArgs, pagination.perPage, pagination.offset),
(await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(),
(await database.prepare('SELECT * FROM weapon_mod_slots ORDER BY tier,slot_type')).all(),
@@ -80,20 +94,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
WHERE talents.active=1 AND talents.category='Weapon' AND talent_weapon_scopes.scope='Standard' AND weapon_talent_slots.slot=2
ORDER BY talents.name COLLATE NOCASE`)).all(),
(await database.prepare("SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE")).all(),
(await database.prepare("SELECT id,name,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE")).all(),
(await database.prepare(`SELECT talent_description_templates.talent_id,talent_description_templates.template,talents.name,
talents.cooldown_seconds,talents.duration_seconds,talents.max_stacks
FROM talent_description_templates JOIN talents ON talents.id=talent_description_templates.talent_id`)).all(),
(await database.prepare(`SELECT talent_tiers.id,talent_tiers.talent_id,talent_tiers.tier,talent_tiers.description
FROM talent_tiers JOIN talents ON talents.id=talent_tiers.talent_id WHERE talents.category='Weapon'`)).all(),
(await database.prepare('SELECT talent_tier_id,trigger_id FROM talent_tier_triggers')).all(),
(await database.prepare('SELECT talent_tier_id,stat_attribute_id,value FROM talent_tier_effects')).all(),
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all(),
getPendingEntities(user.id, [
{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] },
{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] },
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] },
{ module: 'weapons', action: 'save', kind: 'weapon', fields: ['category', 'nativeTier', 'damageType', 'isExotic', 'rpm', 'magazine', 'reloadSpeed', 'accuracy', 'stability', 'talent1Ids', 'talent2Ids', 'modSlots'] }
{ module: 'weapons', action: 'save', kind: 'weapon', fields: ['category', 'nativeTier', 'damageType', 'isExotic', 'rpm', 'magazine', 'reloadSpeed', 'accuracy', 'stability', 'talent1Ids', 'talent2Ids', 'modSlots', 'exoticTalentName', 'exoticDescriptionTemplate', 'exoticConditionalRule'] }
])
]);
const pendingTalents = pending.filter((item) => item.kind === 'talent');
@@ -130,16 +138,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
: String(leftValue).localeCompare(String(rightValue), undefined, { sensitivity: 'base', numeric: true });
return (sort.direction === 'asc' ? result : -result) || String(left.name).localeCompare(String(right.name), undefined, { sensitivity: 'base' });
});
return {
weapons: [...pendingWeapons, ...weapons.map((weapon) => {
const configuredWeapons = await Promise.all(weapons.map(async (weapon) => {
const talentId = typeof weapon.exotic_talent_id === 'string' ? weapon.exotic_talent_id : '';
const talentRows = exoticTiers.filter((tier) => tier.talent_id === talentId);
const tier = (name: string) => talentRows.find((item) => item.tier === name);
const tierI = tier('I'), tierII = tier('II');
const effectIds = [...new Set([
...exoticTierEffects.filter((effect) => effect.talent_tier_id === tierI?.id),
...exoticTierEffects.filter((effect) => effect.talent_tier_id === tierII?.id)
].map((effect) => String(effect.stat_attribute_id)))];
const conditionalRule = talentId
? await loadConditionalRuleDraft(database, 'talent', talentId, ['I', 'II'])
: null;
return {
...weapon,
tiers: tierRanges.filter((range) => range.weapon_id === weapon.id),
@@ -156,21 +159,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
duration_seconds: exoticTemplates.find((item) => item.talent_id === talentId)?.duration_seconds ?? '',
max_stacks: exoticTemplates.find((item) => item.talent_id === talentId)?.max_stacks ?? '',
descriptionTemplate: String(exoticTemplates.find((item) => item.talent_id === talentId)?.template ?? ''),
triggerIds: [...new Set([
...exoticTierTriggers.filter((link) => link.talent_tier_id === tierI?.id),
...exoticTierTriggers.filter((link) => link.talent_tier_id === tierII?.id)
].map((link) => String(link.trigger_id)))],
effects: effectIds.map((statId) => ({
statId,
valueI: exoticTierEffects.find((effect) => effect.talent_tier_id === tierI?.id && String(effect.stat_attribute_id) === statId)?.value ?? '',
valueII: exoticTierEffects.find((effect) => effect.talent_tier_id === tierII?.id && String(effect.stat_attribute_id) === statId)?.value ?? ''
}))
conditionalRule
} : null
}})],
}}));
return {
weapons: [...pendingWeapons, ...configuredWeapons],
talent1Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 1'), ...talent1Options],
talent2Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 2'), ...talent2Options],
stats: [...pending.filter((item) => item.kind === 'attribute'), ...stats],
triggers: [...pending.filter((item) => item.kind === 'trigger'), ...triggers],
summary: { ...summary, total: Number(summary?.total ?? 0) + pending.filter((item) => item.kind === 'weapon').length, pending: pending.filter((item) => item.kind === 'weapon').length },
pagination,
filter,
@@ -185,11 +181,6 @@ function number(form: FormData, name: string): number {
return Number(form.get(name));
}
function optionalPositiveNumber(form: FormData, name: string): number | null {
const raw = String(form.get(name) ?? '').trim();
return raw ? Number(raw) : null;
}
function readRange(form: FormData, tier: number, range: (typeof rangeNames)[number]) {
const min = number(form, `tier${tier}${range}Min`);
const max = number(form, `tier${tier}${range}Max`);
@@ -228,18 +219,35 @@ export const actions: Actions = {
const existingExoticTalentId = isExotic ? String(form.get('exoticTalentId') ?? '') : '';
const exoticTalentName = isExotic ? String(form.get('exoticTalentName') ?? '').normalize('NFKC').trim() : '';
const exoticDescriptionTemplate = isExotic ? String(form.get('exoticDescriptionTemplate') ?? '').trim() : '';
const exoticCooldownSeconds = isExotic ? optionalPositiveNumber(form, 'exoticCooldownSeconds') : null;
const exoticDurationSeconds = isExotic ? optionalPositiveNumber(form, 'exoticDurationSeconds') : null;
const exoticMaxStacks = isExotic ? optionalPositiveNumber(form, 'exoticMaxStacks') : null;
const exoticTriggerIds = isExotic ? [...new Set(form.getAll('exoticTriggerIds').map(String))] : [];
let exoticEffects: ExoticEffect[] = [];
try {
const parsed = JSON.parse(String(form.get('exoticEffects') ?? '[]')) as Array<Record<string, unknown>>;
if (!Array.isArray(parsed)) throw new Error();
exoticEffects = parsed.map((effect) => ({ statId: String(effect.statId ?? ''), valueI: Number(effect.valueI), valueII: Number(effect.valueII) }));
} catch {
return fail(400, { weaponError: 'The exotic talent effects could not be read.' });
const database = await getDatabase();
let exoticConditionalRule = null;
if (isExotic) {
const rawRule = form.get('exoticConditionalRule');
try {
const activeStats = await (await database.prepare('SELECT id FROM stat_attributes WHERE active=1')).all();
exoticConditionalRule = parseConditionalRulePayload(rawRule, [
{ key: 'tierI', variantKey: 'I' },
{ key: 'tierII', variantKey: 'II' }
], {
allowedAttributeIds: new Set(activeStats.map((row) => String(row.id))),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY))
});
} catch (error) {
return fail(400, { weaponError: error instanceof Error ? error.message : 'The exotic talent mechanic is invalid.' });
}
}
const exoticCooldownSeconds = exoticConditionalRule?.clauses.find((clause) => clause.cooldownSeconds)?.cooldownSeconds ?? null;
const exoticDurationSeconds = exoticConditionalRule?.pools.find((pool) => pool.expiresAfterSeconds)?.expiresAfterSeconds ?? null;
const exoticMaxStacks = exoticConditionalRule?.pools.length
? Math.max(...exoticConditionalRule.pools.map((pool) => pool.max ?? 1))
: null;
const exoticEffects: ExoticEffect[] = exoticConditionalRule ? descriptionOutcomes(exoticConditionalRule)
.filter((outcome) => outcome.kind === 'attribute_modifier')
.map((outcome) => ({
statId: outcome.attributeId ?? null,
valueI: outcome.variants.I.value,
valueII: outcome.variants.II.value
})) : [];
if (name.length < 2 || name.length > 80) return fail(400, { weaponError: 'Weapon names must be 2–80 characters.' });
if (!categories.includes(category) || !damageTypes.includes(damageType) || !Number.isInteger(nativeTier) || nativeTier < 0 || nativeTier > 2) {
@@ -262,18 +270,8 @@ export const actions: Actions = {
return fail(400, { weaponError: `Tier ${tier} requires exactly ${required} unique Weapon Mod slots.` });
}
}
if (isExotic && (exoticTalentName.length < 2 || !exoticDescriptionTemplate || !exoticTriggerIds.length || !exoticEffects.length)) {
return fail(400, { weaponError: 'Exotic weapons require a named talent, description template, trigger, and at least one effect.' });
}
if (isExotic && [exoticCooldownSeconds, exoticDurationSeconds].some((value) => value !== null && (!Number.isFinite(value) || value <= 0))) {
return fail(400, { weaponError: 'Exotic talent cooldown and duration must be positive numbers when defined.' });
}
if (isExotic && exoticMaxStacks !== null && (!Number.isInteger(exoticMaxStacks) || exoticMaxStacks <= 0)) {
return fail(400, { weaponError: 'Exotic talent maximum stacks must be a positive whole number when defined.' });
}
if (isExotic && (exoticEffects.some((effect) => !effect.statId || !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))
|| new Set(exoticEffects.map((effect) => effect.statId)).size !== exoticEffects.length)) {
return fail(400, { weaponError: 'Every exotic talent effect requires one unique attribute and valid Tier I and Tier II values.' });
if (isExotic && (exoticTalentName.length < 2 || !exoticDescriptionTemplate || !exoticConditionalRule)) {
return fail(400, { weaponError: 'Exotic weapons require a named talent, description template, and modeled conditional mechanic.' });
}
if (isExotic) {
const placeholders = [...exoticDescriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1]));
@@ -321,7 +319,6 @@ export const actions: Actions = {
return fail(400, { weaponError: 'Each base attribute bonus must reference a different attribute.' });
}
const database = await getDatabase();
if (await (await database.prepare('SELECT id FROM weapons WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
return fail(409, { weaponError: 'That weapon name already exists.' });
}
@@ -333,12 +330,14 @@ export const actions: Actions = {
AND weapon_talent_slots.slot=? AND talents.id IN (${ids.map(() => '?').join(',')})`)).get(slot, ...ids);
if (Number(validTalents?.count) !== ids.length) return fail(400, { weaponError: `One or more selected Talent ${slot} options are unavailable or assigned to the wrong slot.` });
}
const statIds = [...new Set([...augmentEffects.map((effect) => effect.statId), ...baseBonuses.map((effect) => effect.statId), ...exoticEffects.map((effect) => effect.statId)])];
const statIds = [...new Set([
...augmentEffects.map((effect) => effect.statId),
...baseBonuses.map((effect) => effect.statId),
...exoticEffects.map((effect) => effect.statId).filter((value): value is string => Boolean(value))
])];
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds);
if (Number(validStats?.count) !== statIds.length) return fail(400, { weaponError: 'One or more tactical augmentation attributes are unavailable.' });
if (isExotic) {
const validTriggers = await (await database.prepare(`SELECT COUNT(*) AS count FROM talent_triggers WHERE active=1 AND id IN (${exoticTriggerIds.map(() => '?').join(',')})`)).get(...exoticTriggerIds);
if (Number(validTriggers?.count) !== exoticTriggerIds.length) return fail(400, { weaponError: 'One or more exotic talent triggers are unavailable.' });
if (existingExoticTalentId) {
const owned = id ? await (await database.prepare('SELECT id FROM weapons WHERE id=? AND exotic_talent_id=?')).get(id, existingExoticTalentId) : null;
if (!owned) return fail(400, { weaponError: 'The submitted exotic talent is not owned by this weapon.' });
@@ -349,6 +348,17 @@ export const actions: Actions = {
return fail(409, { weaponError: 'An exotic talent with that name already exists.' });
}
}
if (id && !await (await database.prepare('SELECT id FROM weapons WHERE id=?')).get(id)) {
return fail(404, { weaponError: 'Weapon not found.' });
}
const sharedCoreRanges = await (await database.prepare(
'SELECT * FROM core_attribute_tier_ranges ORDER BY tier'
)).all();
for (const tier of tiers) {
if (!sharedCoreRanges.some((range) => Number(range.tier) === tier.tier)) {
return fail(500, { weaponError: `Global core configuration is missing T${tier.tier}.` });
}
}
const weaponId = id || randomUUID();
const now = new Date().toISOString();
@@ -365,10 +375,11 @@ export const actions: Actions = {
icon: await gameDataIconMetadata(database, 'talent', existingExoticTalentId),
template: await (await database.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(existingExoticTalentId),
tiers: await (await database.prepare('SELECT * FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(existingExoticTalentId),
triggers: await (await database.prepare(`SELECT talent_tier_triggers.* FROM talent_tier_triggers JOIN talent_tiers ON talent_tiers.id=talent_tier_triggers.talent_tier_id WHERE talent_tiers.talent_id=?`)).all(existingExoticTalentId),
effects: await (await database.prepare(`SELECT talent_tier_effects.* FROM talent_tier_effects JOIN talent_tiers ON talent_tiers.id=talent_tier_effects.talent_tier_id WHERE talent_tiers.talent_id=?`)).all(existingExoticTalentId)
conditional: await conditionalRuleSnapshot(database, 'talent', existingExoticTalentId)
} : null
} : null;
await database.exec('BEGIN');
try {
if (isExotic && exoticTalentId) {
if (existingExoticTalentId) {
await (await database.prepare("UPDATE talents SET name=?,category='Weapon',cooldown_seconds=?,duration_seconds=?,max_stacks=?,active=1,updated_at=? WHERE id=?")).run(exoticTalentName, exoticCooldownSeconds, exoticDurationSeconds, exoticMaxStacks, now, exoticTalentId);
@@ -380,7 +391,10 @@ export const actions: Actions = {
await (await database.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(exoticTalentId);
await (await database.prepare(`INSERT INTO talent_description_templates(talent_id,template,updated_at) VALUES(?,?,?)
ON CONFLICT(talent_id) DO UPDATE SET template=excluded.template,updated_at=excluded.updated_at`)).run(exoticTalentId, exoticDescriptionTemplate, now);
const unitRows = await (await database.prepare(`SELECT id,unit FROM stat_attributes WHERE id IN (${exoticEffects.map(() => '?').join(',')})`)).all(...exoticEffects.map((effect) => effect.statId));
const effectStatIds = exoticEffects.map((effect) => effect.statId).filter((value): value is string => Boolean(value));
const unitRows = effectStatIds.length
? await (await database.prepare(`SELECT id,unit FROM stat_attributes WHERE id IN (${effectStatIds.map(() => '?').join(',')})`)).all(...effectStatIds)
: [];
const units = new Map(unitRows.map((row) => [String(row.id), row.unit]));
for (const talentTier of ['I', 'II'] as const) {
const description = renderTalentDescription(exoticDescriptionTemplate, exoticEffects, units, talentTier, exoticDurationSeconds, exoticMaxStacks);
@@ -389,17 +403,16 @@ export const actions: Actions = {
.run(randomUUID(), exoticTalentId, talentTier, description, '', '', now, now);
const tierRow = await (await database.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(exoticTalentId, talentTier);
const tierId = String(tierRow?.id);
await (await database.prepare('DELETE FROM talent_tier_triggers WHERE talent_tier_id=?')).run(tierId);
await (await database.prepare('DELETE FROM talent_tier_effects WHERE talent_tier_id=?')).run(tierId);
for (const triggerId of exoticTriggerIds) await (await database.prepare('INSERT INTO talent_tier_triggers(talent_tier_id,trigger_id) VALUES(?,?)')).run(tierId, triggerId);
for (const effect of exoticEffects) await (await database.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)'))
.run(randomUUID(), tierId, effect.statId, talentTier === 'I' ? effect.valueI : effect.valueII, now);
for (const effect of exoticEffects.filter((outcome) => outcome.statId)) {
await (await database.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)'))
.run(randomUUID(), tierId, effect.statId, talentTier === 'I' ? effect.valueI : effect.valueII, now);
}
}
await saveConditionalRuleInTransaction(database, 'talent', exoticTalentId, exoticConditionalRule!);
await applyIconMutation(database, 'talent', exoticTalentId, exoticIconMutation, operator.id);
}
if (id) {
const exists = await (await database.prepare('SELECT id FROM weapons WHERE id=?')).get(id);
if (!exists) return fail(404, { weaponError: 'Weapon not found.' });
await (await database.prepare(`UPDATE weapons SET name=?,category=?,is_exotic=?,native_tier=?,damage_type=?,rpm=?,magazine=?,
reload_speed=?,accuracy=?,stability=?,exotic_talent_id=?,active=1,updated_at=? WHERE id=?`))
.run(name, category, isExotic ? 1 : 0, nativeTier, damageType, fixed.rpm, fixed.magazine, fixed.reloadSpeed, fixed.accuracy, fixed.stability, exoticTalentId, now, id);
@@ -415,10 +428,8 @@ export const actions: Actions = {
}
await (await database.prepare('DELETE FROM weapon_tier_ranges WHERE weapon_id=?')).run(weaponId);
const sharedCoreRanges = await (await database.prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')).all();
for (const tier of tiers) {
const core = sharedCoreRanges.find((range) => Number(range.tier) === tier.tier);
if (!core) return fail(500, { weaponError: `Global core configuration is missing T${tier.tier}.` });
await (await database.prepare(`INSERT INTO weapon_tier_ranges(weapon_id,tier,damage_min,damage_max,firepower_min,
firepower_max,toughness_min,toughness_max,engineering_min,engineering_max) VALUES(?,?,?,?,?,?,?,?,?,?)`))
.run(weaponId, tier.tier, tier.damage.min, tier.damage.max, core.nominal_min, core.nominal_max, core.nominal_min, core.nominal_max, core.nominal_min, core.nominal_max);
@@ -440,7 +451,12 @@ export const actions: Actions = {
await (await database.prepare('INSERT INTO weapon_base_attribute_bonuses(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)'))
.run(randomUUID(), weaponId, effect.statId, effect.value, effect.order);
}
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, modSlots, talent1Ids, talent2Ids, augmentEffects, baseBonuses, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), cooldownSeconds: exoticCooldownSeconds, durationSeconds: exoticDurationSeconds, maxStacks: exoticMaxStacks, descriptionTemplate: exoticDescriptionTemplate, triggerIds: exoticTriggerIds, effects: exoticEffects } : null } });
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, modSlots, talent1Ids, talent2Ids, augmentEffects, baseBonuses, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), descriptionTemplate: exoticDescriptionTemplate, conditional: await conditionalRuleSnapshot(database, 'talent', String(exoticTalentId)) } : null } });
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return { weaponSaved: true, weaponMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
archive: async ({ request, cookies }) => {
+29 -21
View File
@@ -9,6 +9,8 @@
import type { GameIconName } from '$lib/game-icons';
import IconUploadField from '$lib/components/IconUploadField.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
let { data, form } = $props();
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
const categoryOptions = categories.map((value) => ({ value, label: value, icon: value.toLowerCase() as GameIconName, iconOnly: true }));
@@ -17,7 +19,11 @@
const iconName = (value: string): GameIconName => value.toLowerCase() as GameIconName;
const damageTypeOptions = damageTypes.map((value) => ({ value, label: value, icon: iconName(value), iconOnly: true }));
const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0 });
const blankExoticTalent = () => ({ id: '', name: '', iconUrl: '', cooldown_seconds: '', duration_seconds: '', max_stacks: '', descriptionTemplate: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] });
const blankConditionalRule = (): ConditionalMechanicDraftV1 => ({
version:1,simulationReadiness:'captured_only',scenarioInputs:[],pools:[],
clauses:[{key:'primary',label:'Primary',event:'weapon_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'equipped_weapon',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{I:{value:0},II:{value:0}}}]}]
});
const blankExoticTalent = () => ({ id: '', name: '', iconUrl: '', descriptionTemplate: '', conditionalRule: blankConditionalRule() });
const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: '0', damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', exoticTalent: blankExoticTalent(), active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], modSlots: [0,1,2].map((tier) => ({ tier, slots: [] })), talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }], baseBonuses: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
let editing = $state<any | null>(null);
let handledResultToken = $state('');
@@ -29,7 +35,7 @@
return { ...blankTier(tier), ...saved };
});
const modSlots = [0,1,2].map((tier) => ({ tier, slots: [...(weapon.modSlots?.find((row: any) => Number(row.tier) === tier)?.slots ?? [])] }));
editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, modSlots, talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })), baseBonuses: weapon.baseBonuses.map((effect: any) => ({ ...effect })), exoticTalent: weapon.exoticTalent ? { ...weapon.exoticTalent, triggerIds: [...weapon.exoticTalent.triggerIds], effects: weapon.exoticTalent.effects.map((effect: any) => ({ ...effect })) } : blankExoticTalent() };
editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, modSlots, talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })), baseBonuses: weapon.baseBonuses.map((effect: any) => ({ ...effect })), exoticTalent: weapon.exoticTalent ? { ...weapon.exoticTalent, conditionalRule: weapon.exoticTalent.conditionalRule ?? blankConditionalRule() } : blankExoticTalent() };
};
const tierAt = (tier: number) => editing.tiers.find((item: any) => Number(item.tier) === tier);
const modSlotsAt = (tier: number) => editing.modSlots.find((item: any) => Number(item.tier) === tier);
@@ -44,22 +50,23 @@
editing.is_exotic = isExotic ? 1 : 0;
if (isExotic && !editing.exoticTalent) editing.exoticTalent = blankExoticTalent();
};
const addExoticEffect = () => {
if (editing?.exoticTalent && editing.exoticTalent.effects.length < 12) editing.exoticTalent.effects = [...editing.exoticTalent.effects, { statId: '', valueI: '', valueII: '' }];
};
const removeExoticEffect = (index: number) => {
if (editing?.exoticTalent?.effects.length > 1) editing.exoticTalent.effects = editing.exoticTalent.effects.filter((_: any, effectIndex: number) => effectIndex !== index);
};
const mechanicValue = (value: string | number, singular: string) => `${value} ${Number(value) === 1 ? singular : `${singular}s`}`;
const exoticPreview = (tier: 'I' | 'II') => editing.exoticTalent.descriptionTemplate.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
if (expression === 'duration') return editing.exoticTalent.duration_seconds === '' ? placeholder : mechanicValue(editing.exoticTalent.duration_seconds, 'second');
if (expression === 'max_stacks') return editing.exoticTalent.max_stacks === '' ? placeholder : mechanicValue(editing.exoticTalent.max_stacks, 'stack');
const effect = editing.exoticTalent.effects[Number(expression) - 1];
if (!effect) return placeholder;
const stat = data.stats.find((item) => item.id === effect.statId);
const value = tier === 'I' ? effect.valueI : effect.valueII;
const exoticOutcomeValue = (effect: any, tier: 'I' | 'II') => {
const value = effect?.variants?.[tier]?.value;
if (value === '' || value === null || value === undefined) return '';
if (effect.kind !== 'attribute_modifier') return String(value);
const stat = data.stats.find((item) => item.id === effect.attributeId);
const unit = stat?.unit === 'flat' ? '' : stat?.unit ?? '';
return unit === 'Shots' ? `${value} ${unit}` : `${value}${unit}`;
};
const exoticPreview = (tier: 'I' | 'II') => editing.exoticTalent.descriptionTemplate.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
const duration = editing.exoticTalent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds;
const maxStacks = editing.exoticTalent.conditionalRule.pools.length ? Math.max(...editing.exoticTalent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null;
if (expression === 'duration') return duration == null ? placeholder : mechanicValue(duration, 'second');
if (expression === 'max_stacks') return maxStacks == null ? placeholder : mechanicValue(maxStacks, 'stack');
const available = editing.exoticTalent.conditionalRule.clauses.flatMap((clause: any) => clause.outcomes);
const value = exoticOutcomeValue(available.find((outcome: any) => outcome.descriptionSlot === Number(expression)), tier);
return value || placeholder;
});
const addAugmentEffect = () => {
if (editing && editing.augmentEffects.length < 2) editing.augmentEffects = [...editing.augmentEffects, { stat_attribute_id: '', value: 0, sort_order: 2 }];
@@ -106,17 +113,18 @@
</div>
{#if Number(editing.is_exotic)}
<input type="hidden" name="exoticTalentId" value={editing.exoticTalent.id}/>
<input type="hidden" name="exoticEffects" value={JSON.stringify(editing.exoticTalent.effects)}/>
<input type="hidden" name="exoticConditionalRule" value={JSON.stringify(editing.exoticTalent.conditionalRule)}/>
<section class="weapon-exotic-talent">
<div class="selection-head"><div><span>DEDICATED EXOTIC TALENT</span><small>OWNED EXCLUSIVELY BY THIS WEAPON · TIER II VALUES REQUIRED</small></div><b>{editing.exoticTalent.id ? 'LINKED RECORD' : 'NEW RECORD'}</b></div>
<div class="exotic-icon-identity"><IconUploadField currentUrl={editing.exoticTalent.iconUrl} label="EXOTIC TALENT ICON" resetToken={iconResetToken}/><div class="form-grid exotic-talent-identity"><label class="field">TALENT NAME<input name="exoticTalentName" bind:value={editing.exoticTalent.name} maxlength="80" required/></label><label class="field">SHARED DESCRIPTION TEMPLATE<textarea name="exoticDescriptionTemplate" bind:value={editing.exoticTalent.descriptionTemplate} placeholder={'Hits grant +{1} for {duration}…'} required></textarea><small>Use numbered expressions for effect values and named expressions for duration and stacks.</small></label></div></div>
<div class="talent-mechanics-grid"><label class="field">COOLDOWN · SECONDS<input name="exoticCooldownSeconds" type="number" min="0.01" step="any" bind:value={editing.exoticTalent.cooldown_seconds} placeholder="NOT DEFINED"/></label><label class="field">DURATION · SECONDS<input name="exoticDurationSeconds" type="number" min="0.01" step="any" bind:value={editing.exoticTalent.duration_seconds} placeholder="NOT DEFINED"/></label><label class="field">MAX STACKS<input name="exoticMaxStacks" type="number" min="1" step="1" bind:value={editing.exoticTalent.max_stacks} placeholder="NOT DEFINED"/></label></div>
<p class="talent-template-note">DESCRIPTION EXPRESSIONS · {'{duration}'} and {'{max_stacks}'} render the defined mechanics. Cooldown is displayed separately as a standardized Talent Cooldown line.</p>
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{exoticPreview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{exoticPreview('II') || 'Description preview'}</p></div></div>
<div class="weapon-exotic-grid">
<div><div class="selection-head"><div><span>ACTIVATION TRIGGERS</span><small>SHARED BETWEEN BOTH TIERS</small></div><b>{editing.exoticTalent.triggerIds.length}</b></div><SearchableAssignment items={data.triggers} fieldName="exoticTriggerIds" bind:selected={editing.exoticTalent.triggerIds}/></div>
<div><div class="selection-head"><div><span>ATTRIBUTE EFFECTS</span><small>ONE ROW PER TEMPLATE PLACEHOLDER</small></div><button type="button" class="row-action" onclick={addExoticEffect} disabled={editing.exoticTalent.effects.length >= 12}>ADD EFFECT +</button></div><div class="exotic-effect-list">{#each editing.exoticTalent.effects as effect: any, index}<div class="exotic-effect-row"><strong>{index + 1}</strong><SearchableAssignment items={data.stats} mode="single" compact subjectLabel="ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={effect.statId}/><input type="number" step="any" bind:value={effect.valueI} placeholder="TIER I" required/><input type="number" step="any" bind:value={effect.valueII} placeholder="TIER II" required/><button type="button" aria-label={`Remove exotic effect ${index + 1}`} onclick={() => removeExoticEffect(index)} disabled={editing.exoticTalent.effects.length === 1}>×</button></div>{/each}</div></div>
</div>
<MechanicProgramEditor
attributes={data.stats}
valueColumns={[{key:'tierI',variantKey:'I',label:'TIER I'},{key:'tierII',variantKey:'II',label:'TIER II'}]}
idPrefix={`weapon-exotic-${editing.exoticTalent.id || 'new'}`}
bind:rule={editing.exoticTalent.conditionalRule}
/>
</section>
{/if}
<div class="selection-head"><div><span>FIXED WEAPON ATTRIBUTES</span><small>UNCHANGED ACROSS TIERS</small></div><b>BASE HANDLING</b></div>