296 lines
18 KiB
TypeScript
296 lines
18 KiB
TypeScript
import { getSessionUser } from '$lib/server/auth/session';
|
|
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
|
import { getDatabase } from '$lib/server/database';
|
|
import { fail } from '@sveltejs/kit';
|
|
import { randomUUID } from 'node:crypto';
|
|
import type { Actions, PageServerLoad } from './$types';
|
|
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
|
|
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();
|
|
const totalRow = await (await db.prepare('SELECT COUNT(*) AS total FROM talents')).get();
|
|
const filter = ['slot1', 'slot2', 'exotic', 'backpack', 'body-armor'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
|
const { search, pattern } = getRegistrySearch(url);
|
|
const sort = getRegistrySort(url, {
|
|
name: { asc: 'talents.name COLLATE NOCASE ASC,talents.id ASC', desc: 'talents.name COLLATE NOCASE DESC,talents.id DESC' },
|
|
category: { asc: 'talents.category COLLATE NOCASE ASC,talents.name COLLATE NOCASE ASC', desc: 'talents.category COLLATE NOCASE DESC,talents.name COLLATE NOCASE ASC' },
|
|
tier1: { asc: "COALESCE((SELECT tier.description FROM talent_tiers tier WHERE tier.talent_id=talents.id AND tier.tier='I'),'') COLLATE NOCASE ASC,talents.name COLLATE NOCASE ASC", desc: "COALESCE((SELECT tier.description FROM talent_tiers tier WHERE tier.talent_id=talents.id AND tier.tier='I'),'') COLLATE NOCASE DESC,talents.name COLLATE NOCASE ASC" },
|
|
tier2: { asc: "COALESCE((SELECT tier.description FROM talent_tiers tier WHERE tier.talent_id=talents.id AND tier.tier='II'),'') COLLATE NOCASE ASC,talents.name COLLATE NOCASE ASC", desc: "COALESCE((SELECT tier.description FROM talent_tiers tier WHERE tier.talent_id=talents.id AND tier.tier='II'),'') COLLATE NOCASE DESC,talents.name COLLATE NOCASE ASC" },
|
|
state: { asc: 'talents.active ASC,talents.name COLLATE NOCASE ASC', desc: 'talents.active DESC,talents.name COLLATE NOCASE ASC', defaultDirection: 'desc' }
|
|
}, 'name');
|
|
const conditions = [
|
|
...(filter === 'slot1' ? ['EXISTS (SELECT 1 FROM weapon_talent_slots WHERE talent_id=talents.id AND slot=1)']
|
|
: filter === 'slot2' ? ['EXISTS (SELECT 1 FROM weapon_talent_slots WHERE talent_id=talents.id AND slot=2)']
|
|
: filter === 'exotic' ? ["EXISTS (SELECT 1 FROM talent_weapon_scopes WHERE talent_id=talents.id AND scope='Exotic')"]
|
|
: filter === 'backpack' ? ["category='Backpack'"] : filter === 'body-armor' ? ["category IN ('Vest','Body Armor')"] : []),
|
|
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
|
];
|
|
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
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, 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 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(),
|
|
(await db.prepare('SELECT talent_id,scope FROM talent_weapon_scopes')).all(),
|
|
(await db.prepare('SELECT talent_id,slot FROM weapon_talent_slots')).all(),
|
|
(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 [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(),
|
|
(await db.prepare("SELECT COUNT(*) AS total FROM talent_weapon_scopes WHERE scope='Exotic'")).get(),
|
|
(await db.prepare(`SELECT
|
|
SUM(CASE WHEN category='Backpack' THEN 1 ELSE 0 END) AS backpack,
|
|
SUM(CASE WHEN category IN ('Vest','Body Armor') THEN 1 ELSE 0 END) AS body_armor
|
|
FROM talents`)).get(),
|
|
getPendingEntities(user.id, [
|
|
{ module: 'attributes', action: 'save', kind: 'stat', fields: ['unit'] },
|
|
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category', 'descriptionTemplate', 'conditionalRule'] }
|
|
])
|
|
]);
|
|
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'
|
|
: filter === 'slot2' ? item.category === 'Weapon Talent 2'
|
|
: filter === 'backpack' ? item.category === 'Backpack'
|
|
: filter === 'body-armor' ? item.category === 'Body Armor'
|
|
: filter === 'exotic' ? false : true
|
|
).filter((item) =>
|
|
!search || String(item.name).toLocaleLowerCase().includes(search.toLocaleLowerCase())
|
|
).map((item) => ({
|
|
...item,
|
|
iconUrl: '',
|
|
cooldown_seconds: item.cooldownSeconds || null,
|
|
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') }))
|
|
}));
|
|
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'
|
|
? 'Body Armor'
|
|
: talent.category === 'Weapon' && weaponScopes.find((scope) => scope.talent_id === talent.id)?.scope === 'Exotic'
|
|
? 'Exotic Weapon'
|
|
: talent.category === 'Weapon' && Number(weaponSlots.find((slot) => slot.talent_id === talent.id)?.slot) === 1
|
|
? 'Weapon Talent 1'
|
|
: talent.category === 'Weapon' && Number(weaponSlots.find((slot) => slot.talent_id === talent.id)?.slot) === 2
|
|
? 'Weapon Talent 2'
|
|
: talent.category === 'Weapon'
|
|
? '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,
|
|
effects: effects.filter((effect) => effect.talent_tier_id === tier.id)
|
|
}))
|
|
})));
|
|
return {
|
|
talents: [...pendingTalents, ...configuredTalents],
|
|
stats: [...pendingStats, ...stats],
|
|
summary: {
|
|
total: Number(totalRow?.total ?? 0) + pending.filter((item) => item.kind === 'talent').length,
|
|
slot1: Number(slotSummary?.slot1 ?? 0),
|
|
slot2: Number(slotSummary?.slot2 ?? 0),
|
|
exotic: Number(exoticSummary?.total ?? 0),
|
|
backpack: Number(gearSummary?.backpack ?? 0),
|
|
bodyArmor: Number(gearSummary?.body_armor ?? 0)
|
|
},
|
|
pagination,
|
|
filter,
|
|
search,
|
|
sort
|
|
};
|
|
};
|
|
|
|
const parseEffects = (raw: FormDataEntryValue | null): SharedEffect[] => {
|
|
const value = JSON.parse(String(raw ?? '[]')) as Array<{ statId: unknown; valueI: unknown; valueII: unknown }>;
|
|
if (!Array.isArray(value)) throw new Error();
|
|
return value.map((effect) => ({
|
|
statId: String(effect.statId),
|
|
valueI: Number(effect.valueI),
|
|
valueII: Number(effect.valueII)
|
|
}));
|
|
};
|
|
|
|
const displayValue = (value: number, unit: unknown) => {
|
|
const normalizedUnit = unit === 'flat' ? '' : String(unit ?? '');
|
|
return normalizedUnit === 'Shots' ? `${value} ${normalizedUnit}` : `${value}${normalizedUnit}`;
|
|
};
|
|
|
|
const optionalPositiveNumber = (form: FormData, name: string) => {
|
|
const raw = String(form.get(name) ?? '').trim();
|
|
return raw ? Number(raw) : null;
|
|
};
|
|
|
|
const renderMechanicValue = (value: number, singular: string, plural = `${singular}s`) =>
|
|
`${value} ${value === 1 ? singular : plural}`;
|
|
|
|
const renderDescription = (
|
|
template: string,
|
|
effects: SharedEffect[],
|
|
units: Map<string, unknown>,
|
|
tier: 'I' | 'II',
|
|
durationSeconds: number | null,
|
|
maxStacks: number | null
|
|
) => template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder, expression) => {
|
|
if (expression === 'duration') return durationSeconds === null ? placeholder : renderMechanicValue(durationSeconds, 'second');
|
|
if (expression === 'max_stacks') return maxStacks === null ? placeholder : renderMechanicValue(maxStacks, 'stack');
|
|
const effect = effects[Number(expression) - 1];
|
|
return effect ? displayValue(tier === 'I' ? effect.valueI : effect.valueII, units.get(effect.statId)) : placeholder;
|
|
});
|
|
|
|
export const actions: Actions = {
|
|
saveTalent: async ({ request, cookies }) => {
|
|
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
|
const form = await request.formData();
|
|
let iconMutation;
|
|
try { iconMutation = await readIconMutation(form); }
|
|
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 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 || !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;
|
|
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 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),
|
|
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;
|
|
}
|
|
return { talentSaved: true, talentMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
|
}
|
|
};
|