feat: add source-owned combat mechanic programs
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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 }) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user