feat(isac): link skill chips to abilities
This commit is contained in:
@@ -5,6 +5,7 @@ import { getDatabase } from '$lib/server/database';
|
||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
||||
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
|
||||
import { refreshAbilityFocuses } from '$lib/server/specialization-readiness';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
@@ -45,28 +46,34 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const requestedFilter = url.searchParams.get('filter') ?? '';
|
||||
const filter = ['active', 'archived', 'configured'].includes(requestedFilter) ? requestedFilter : '';
|
||||
const filter = ['active', 'archived', 'configured', 'assigned', 'unassigned'].includes(requestedFilter) ? requestedFilter : '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const conditions = [
|
||||
...(filter === 'active' ? ['sets.active=1'] : filter === 'archived' ? ['sets.active=0']
|
||||
: filter === 'assigned' ? ['sets.specialization_ability_id IS NOT NULL']
|
||||
: filter === 'unassigned' ? ['sets.specialization_ability_id IS NULL']
|
||||
: filter === 'configured' ? ['EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=sets.id)', 'EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id)'] : []),
|
||||
...(search ? ["sets.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = search ? [pattern] : [];
|
||||
const [summary, filtered, pendingStats, pendingTriggers, pendingSets] = await Promise.all([
|
||||
const [summary, filtered, pendingStats, pendingTriggers, 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,
|
||||
SUM(CASE WHEN specialization_ability_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned,
|
||||
SUM(CASE WHEN specialization_ability_id IS NULL THEN 1 ELSE 0 END) AS unassigned,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=skill_chip_sets.id) THEN 1 ELSE 0 END) AS configured
|
||||
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: ['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', 'talentEffects', 'triggerIds'] }]),
|
||||
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, icons] = await Promise.all([
|
||||
const [sets, twoPiece, primaryOptions, secondaryOptions, talents, talentTriggers, talentEffects, stats, triggers, abilities, specializations, abilityChipCounts, icons] = await Promise.all([
|
||||
(await database.prepare(`SELECT sets.* FROM skill_chip_sets sets ${where} ORDER BY sets.active DESC,sets.name COLLATE NOCASE 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(),
|
||||
@@ -76,6 +83,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
(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
|
||||
WHERE active=1 AND specialization_ability_id IS NOT NULL GROUP BY specialization_ability_id`)).all(),
|
||||
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='skill_chip'")).all()
|
||||
]);
|
||||
const availableStats = [...pendingStats.map((item) => ({
|
||||
@@ -84,8 +95,29 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
skill_chip_min: item.chipMin === '' ? null : Number(item.chipMin),
|
||||
skill_chip_max: item.chipMax === '' ? null : Number(item.chipMax)
|
||||
})), ...stats];
|
||||
const pendingSetRows = filter === 'archived' ? [] : pendingSets.filter((set) => !search || String(set.name).toLocaleLowerCase().includes(search.toLocaleLowerCase())).map((set) => ({
|
||||
const specializationNames = new Map([
|
||||
...pendingSpecializations.map((item) => [String(item.id), String(item.name)] as const),
|
||||
...specializations.map((item) => [String(item.id), String(item.name)] as const)
|
||||
]);
|
||||
const loadedAbilities = [
|
||||
...pendingAbilities.map((ability) => ({
|
||||
...ability,
|
||||
name: `${ability.name} · ${specializationNames.get(String(ability.specializationId)) ?? 'PENDING SPECIALIZATION'}`,
|
||||
chipCount: 0
|
||||
})),
|
||||
...abilities.map((ability) => ({
|
||||
...ability,
|
||||
name: `${ability.name} · ${specializationNames.get(String(ability.specialization_id)) ?? 'SPECIALIZATION'}`,
|
||||
chipCount: Number(abilityChipCounts.find((item) => item.specialization_ability_id === ability.id)?.count ?? 0)
|
||||
}))
|
||||
];
|
||||
const pendingSetRows = filter === 'archived' ? [] : pendingSets
|
||||
.filter((set) => filter !== 'assigned' || Boolean(set.abilityId))
|
||||
.filter((set) => filter !== 'unassigned' || !set.abilityId)
|
||||
.filter((set) => !search || String(set.name).toLocaleLowerCase().includes(search.toLocaleLowerCase()))
|
||||
.map((set) => ({
|
||||
...set,
|
||||
specialization_ability_id: set.abilityId,
|
||||
iconUrl: '',
|
||||
twoPiece: {
|
||||
stat_attribute_id: set.twoPieceStatId,
|
||||
@@ -121,7 +153,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
stats: availableStats,
|
||||
rollStats: availableStats.filter((stat) => stat.skill_chip_min !== null && stat.skill_chip_max !== null),
|
||||
triggers: [...pendingTriggers, ...triggers],
|
||||
summary: { ...summary, total: Number(summary?.total ?? 0) + pendingSets.length, pending: pendingSets.length }, filter, pagination, search
|
||||
abilities: loadedAbilities,
|
||||
summary: {
|
||||
active: Number(summary?.active ?? 0),
|
||||
archived: Number(summary?.archived ?? 0),
|
||||
configured: Number(summary?.configured ?? 0),
|
||||
total: Number(summary?.total ?? 0) + pendingSets.length,
|
||||
assigned: Number(summary?.assigned ?? 0) + pendingSets.filter((set) => Boolean(set.abilityId)).length,
|
||||
unassigned: Number(summary?.unassigned ?? 0) + pendingSets.filter((set) => !set.abilityId).length,
|
||||
pending: pendingSets.length
|
||||
},
|
||||
filter, pagination, search
|
||||
};
|
||||
};
|
||||
|
||||
@@ -134,6 +176,7 @@ export const actions: Actions = {
|
||||
catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { skillChipError: error.message }); throw error; }
|
||||
const id = String(form.get('id') ?? '');
|
||||
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||||
const abilityId = String(form.get('abilityId') ?? '');
|
||||
const twoPieceStatId = String(form.get('twoPieceStatId') ?? '');
|
||||
const twoPieceMin = Number(form.get('twoPieceMin'));
|
||||
const twoPieceMax = Number(form.get('twoPieceMax'));
|
||||
@@ -144,6 +187,7 @@ export const actions: Actions = {
|
||||
const triggerIds = [...new Set(form.getAll('triggerIds').map(String).filter(Boolean))];
|
||||
const effects = parseEffects(form.get('talentEffects'));
|
||||
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.' });
|
||||
@@ -154,6 +198,14 @@ export const actions: Actions = {
|
||||
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 (!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.' });
|
||||
}
|
||||
const assignedCount = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets
|
||||
WHERE specialization_ability_id=? AND active=1 AND id<>?`)).get(abilityId, id || 'new'))?.count ?? 0);
|
||||
if (assignedCount >= 3) {
|
||||
return fail(409, { skillChipError: 'That ability already has all three active Skill Chips assigned.' });
|
||||
}
|
||||
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.' });
|
||||
@@ -169,10 +221,11 @@ export const actions: Actions = {
|
||||
const talentId = String(existingTalent?.id ?? randomUUID());
|
||||
const now = new Date().toISOString();
|
||||
const before = id ? await snapshot(id) : null;
|
||||
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=?,active=1,updated_at=? WHERE id=?')).run(name, now, id);
|
||||
} else await (await database.prepare('INSERT INTO skill_chip_sets(id,name,created_at,updated_at) VALUES(?,?,?,?)')).run(setId, name, now, now);
|
||||
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);
|
||||
@@ -186,6 +239,8 @@ export const actions: Actions = {
|
||||
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);
|
||||
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) });
|
||||
return { skillChipSaved: true, skillChipMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||
},
|
||||
@@ -194,7 +249,9 @@ export const actions: Actions = {
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const before = await snapshot(id);
|
||||
if (!before.record) return fail(404, { skillChipError: 'Skill Chip set not found.' });
|
||||
await (await (await getDatabase()).prepare('UPDATE skill_chip_sets SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||
const database = await getDatabase();
|
||||
await (await database.prepare('UPDATE skill_chip_sets SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||
await refreshAbilityFocuses(database, String(before.record.specialization_ability_id ?? ''));
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'skill_chip.archived', entityId: id, before, after: await snapshot(id) });
|
||||
return { skillChipArchived: true };
|
||||
},
|
||||
@@ -203,7 +260,17 @@ export const actions: Actions = {
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const before = await snapshot(id);
|
||||
if (!before.record) return fail(404, { skillChipError: 'Skill Chip set not found.' });
|
||||
await (await (await getDatabase()).prepare('UPDATE skill_chip_sets SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||
const abilityId = String(before.record.specialization_ability_id ?? '');
|
||||
if (!abilityId) return fail(400, { skillChipError: 'Assign this legacy Skill Chip to an ability before restoring it.' });
|
||||
const database = await getDatabase();
|
||||
if (!await (await database.prepare('SELECT id FROM specialization_abilities WHERE id=? AND active=1')).get(abilityId)) {
|
||||
return fail(400, { skillChipError: 'The assigned specialization ability must be active before restoring this Skill Chip.' });
|
||||
}
|
||||
const assignedCount = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets
|
||||
WHERE specialization_ability_id=? AND active=1 AND id<>?`)).get(abilityId, id))?.count ?? 0);
|
||||
if (assignedCount >= 3) return fail(409, { skillChipError: 'That ability already has all three active Skill Chips assigned.' });
|
||||
await (await database.prepare('UPDATE skill_chip_sets SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||
await refreshAbilityFocuses(database, abilityId);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'skill_chip.unarchived', entityId: id, before, after: await snapshot(id) });
|
||||
return { skillChipUnarchived: true };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user