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
+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>