Add ISAC Skill Chips data module

This commit is contained in:
JB
2026-07-25 16:13:34 -04:00
parent 8b7b747866
commit 60c3618173
21 changed files with 495 additions and 35 deletions
+5 -4
View File
@@ -10,16 +10,17 @@
const dataModeClass = $derived(dataMode.toLowerCase().replace(' ', '-'));
let readOnlyBlocked = $state(false);
const dataSections = [
{ href: '/admin/stats', label: 'Stats & attributes', code: 'ATTR', module: 'stats' },
{ href: '/admin/talents', label: 'Talents & triggers', code: 'TLNT', module: 'talents' },
{ href: '/admin/attributes', label: 'Attributes', code: 'ATTR', module: 'attributes' },
{ href: '/admin/talents', label: 'Talents', code: 'TLNT', module: 'talents' },
{ href: '/admin/brands', label: 'Brand sets', code: 'BRND', module: 'brands' },
{ href: '/admin/gear', label: 'Gear', code: 'GEAR', module: 'gear' },
{ href: '/admin/weapons', label: 'Weapons', code: 'WPN', module: 'weapons' },
{ href: '/admin/protocols', label: 'OS Protocols', code: 'OSPR', module: 'protocols' }
{ href: '/admin/protocols', label: 'OS Protocols', code: 'OSPR', module: 'protocols' },
{ href: '/admin/skillchips', label: 'Skill Chips', code: 'CHIP', module: 'skillchips' }
];
const userSections = [
{ href: '/admin/users', label: 'User operations', code: 'USER', permission: 'users.view' },
{ href: '/admin/roles', label: 'Roles & permissions', code: 'ROLE', permission: 'users.roles' }
{ href: '/admin/roles', label: 'Roles', code: 'ROLE', permission: 'users.roles' }
];
const visibleUserSections = $derived(userSections.filter((section) => can(section.permission)));
const oversightSections = [
+1 -1
View File
@@ -7,7 +7,7 @@ export const load: PageServerLoad = async ({ cookies }) => {
const permissions = await getPermissions(await getSessionUser(cookies));
if (permissions.includes('*') || permissions.includes('users.view')) redirect(303, '/admin/users');
if (permissions.includes('users.roles')) redirect(303, '/admin/roles');
if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/admin/stats');
if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/admin/attributes');
if (permissions.includes('audit.view')) redirect(303, '/admin/audit');
if (permissions.includes('metrics.view')) redirect(303, '/admin/metrics');
redirect(303, '/account');
@@ -14,8 +14,8 @@
}
});
</script>
<svelte:head><title>Stats & attributes — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">ATTRIBUTE REGISTRY</p><h2>STATS & ATTRIBUTES</h2><p>Builder values, hidden calculation targets, source ranges, and caps.</p></div><button class="primary" onclick={() => editing=blank()}>ADD ATTRIBUTE <span>+</span></button></div>
<svelte:head><title>Attributes — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">ATTRIBUTE REGISTRY</p><h2>ATTRIBUTES</h2><p>Builder values, hidden calculation targets, source ranges, and caps.</p></div><button class="primary" onclick={() => editing=blank()}>ADD ATTRIBUTE <span>+</span></button></div>
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL RECORDS',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to systems'},{value:'offensive',label:'OFFENSIVE',count:data.summary.offensive,description:'Damage-oriented'},{value:'defensive',label:'DEFENSIVE',count:data.summary.defensive,description:'Survival-oriented'}]}/>
{#if editing}<div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><label class="field">TYPE<select name="type" bind:value={editing.type}><option>Offensive</option><option>Defensive</option></select></label><label class="field">CATEGORY<select name="category" bind:value={editing.category}><option>Weapon</option><option>Skill</option><option>Character</option></select></label><label class="field">CALCULATION<select name="calculationType" bind:value={editing.calculation_type}><option>Additive</option><option>Multiplicative</option></select></label><fieldset class="unit-field"><legend>UNIT</legend><div class="unit-picker">{#each [['','NONE'],['%','%'],['s','s'],['Shots','SHOTS'],['m','m']] as option}<label><input type="radio" name="unit" value={option[0]} bind:group={editing.unit}/><span>{option[1]}</span></label>{/each}</div></fieldset>{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]==='skill_chip'?'chip':source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]==='skill_chip'?'chip':source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>{/if}
<RegistryPagination pagination={data.pagination}/>
@@ -8,8 +8,8 @@ import { recordIsacAudit } from '$lib/server/audit';
import type { Actions, PageServerLoad } from './$types';
const safeReturn = (value: FormDataEntryValue | null) => {
const path = String(value ?? '/admin/stats');
return path.startsWith('/admin/') && !path.startsWith('//') ? path : '/admin/stats';
const path = String(value ?? '/admin/attributes');
return path.startsWith('/admin/') && !path.startsWith('//') ? path : '/admin/attributes';
};
export const load: PageServerLoad = async ({ cookies, url }) => {
@@ -17,7 +17,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
if (!user) redirect(303, '/sign-in');
const permissions = await getPermissions(user);
const eligible = permissions.includes('*') || permissions.includes('data.propose');
if (!eligible) redirect(303, '/admin/stats');
if (!eligible) redirect(303, '/admin/attributes');
return {
contributorMode: await getContributorMode(user.id),
returnTo: safeReturn(url.searchParams.get('returnTo'))
@@ -10,7 +10,7 @@ import type { Actions, PageServerLoad } from './$types';
import { createNotification } from '$lib/server/notifications';
const statuses = ['pending', 'approved', 'rejected', 'failed'];
const modules = ['stats', 'talents', 'brands', 'gear', 'weapons', 'protocols'];
const modules = ['attributes', 'talents', 'brands', 'gear', 'weapons', 'protocols', 'skillchips'];
type SerializedEntry =
| { name: string; kind: 'text'; value: string }
| { name: string; kind: 'file'; value: string; fileName: string; type: string };
@@ -12,6 +12,7 @@
entry.kind === 'file' ? { name: entry.name, kind: 'file', fileName: entry.fileName, type: entry.type, encodedBytes: entry.value.length } : entry
);
const operationLabel = (value: string) => value.replace(/([A-Z])/g, ' $1').replaceAll('_', ' ').trim().toUpperCase();
const moduleLabel = (value: string) => value === 'skillchips' ? 'SKILL CHIPS' : value.toUpperCase();
const openReview = (proposal: any) => {
reviewing = proposal;
decision = 'approve';
@@ -42,7 +43,7 @@
]}/>
<form class="audit-filter moderation-filter">
<input name="search" value={data.search} placeholder="CHANGE, RECORD, OR CONTRIBUTOR"/>
<select name="module"><option value="">ALL DATA MODULES</option>{#each data.modules as module}<option value={module} selected={data.module === module}>{module.toUpperCase()}</option>{/each}</select>
<select name="module"><option value="">ALL DATA MODULES</option>{#each data.modules as module}<option value={module} selected={data.module === module}>{moduleLabel(module)}</option>{/each}</select>
<input type="hidden" name="status" value={data.status}/>
<button class="secondary">SEARCH QUEUE →</button>
</form>
@@ -51,7 +52,7 @@
<div class="dashboard-row moderation-row table-heading"><span>CHANGE</span><span>CONTRIBUTOR</span><span>SUBMITTED</span><span>STATUS</span><span></span></div>
{#each data.proposals as proposal}
<div class="dashboard-row moderation-row mobile-detail-row">
<div class="change-identity"><span>{proposal.module.slice(0,4).toUpperCase()}</span><div><strong>{proposal.summary}</strong><small>{proposal.module.toUpperCase()} / {operationLabel(proposal.action)}</small></div></div>
<div class="change-identity"><span>{proposal.module === 'skillchips' ? 'CHIP' : proposal.module.slice(0,4).toUpperCase()}</span><div><strong>{proposal.summary}</strong><small>{moduleLabel(proposal.module)} / {operationLabel(proposal.action)}</small></div></div>
<div><strong>{proposal.proposer_name}</strong><small>AGENT {String(proposal.proposed_by).slice(0,8).toUpperCase()}</small></div>
<span>{date(proposal.created_at)}</span>
<span class="proposal-state" class:pending={proposal.status === 'pending'} class:approved={proposal.status === 'approved'} class:declined={proposal.status === 'rejected' || proposal.status === 'failed'}><i></i>{proposal.status.toUpperCase()}</span>
@@ -65,7 +66,7 @@
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal moderation-review" role="dialog" aria-modal="true" aria-labelledby="moderation-title">
<div class="editor-title"><div><p class="eyebrow">CHANGE CONTROL / {reviewing.status.toUpperCase()}</p><h3 id="moderation-title">{reviewing.summary}</h3></div><button aria-label="Close review" onclick={() => reviewing = null}>×</button></div>
<div class="moderation-metadata"><span>DATA MODULE <b>{reviewing.module.toUpperCase()}</b></span><span>REQUESTED ACTION <b>{operationLabel(reviewing.action)}</b></span><span>CONTRIBUTOR <b>{reviewing.proposer_name}</b></span><span>SUBMITTED <b>{date(reviewing.created_at)}</b></span></div>
<div class="moderation-metadata"><span>DATA MODULE <b>{moduleLabel(reviewing.module)}</b></span><span>REQUESTED ACTION <b>{operationLabel(reviewing.action)}</b></span><span>CONTRIBUTOR <b>{reviewing.proposer_name}</b></span><span>SUBMITTED <b>{date(reviewing.created_at)}</b></span></div>
<div class="evidence-header"><div><span>PROPOSED FORM PAYLOAD</span><small>CANONICAL VALIDATION RUNS WHEN APPROVED</small></div><b>{evidence(reviewing.payload_json).length} FIELDS</b></div>
<pre class="moderation-evidence">{JSON.stringify(evidence(reviewing.payload_json), null, 2)}</pre>
{#if reviewing.review_note}<div class="review-note"><span>OVERSIGHT NOTE</span><p>{reviewing.review_note}</p></div>{/if}
+2 -2
View File
@@ -15,8 +15,8 @@
});
</script>
<svelte:head><title>Roles & permissions — ISAC</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">ACCESS POLICY REGISTRY</p><h2>ROLES & PERMISSIONS</h2><p>Compose reusable access roles from discrete SHD.build capabilities.</p></div><button class="primary" onclick={() => editing = blank()}>ADD ROLE <span>+</span></button></div>
<svelte:head><title>Roles — ISAC</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">ACCESS POLICY REGISTRY</p><h2>ROLES</h2><p>Compose reusable access roles from discrete SHD.build capabilities.</p></div><button class="primary" onclick={() => editing = blank()}>ADD ROLE <span>+</span></button></div>
<p class="access-baseline-note"><b>REGISTERED AGENT BASELINE</b><span>Every active account inherently receives isac.access, data.view, and data.propose eligibility. Submitting proposals additionally requires accepted guidelines and enabled contributor mode. Roles add elevated capabilities and cannot remove this baseline.</span></p>
<KpiFilters className="role-kpis" active={data.filter} items={[
{value:'',label:'TOTAL ROLES',count:data.summary.total,description:'Show all role definitions'},
+188
View File
@@ -0,0 +1,188 @@
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
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 } from '$lib/server/pagination';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
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 [];
}
};
async function snapshot(id: string) {
const database = await getDatabase();
const talent = await (await database.prepare('SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?')).get(id);
return {
record: await auditRecord('skill_chip_sets', id),
icon: await gameDataIconMetadata(database, 'skill_chip', id),
twoPiece: await (await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?')).get(id) ?? null,
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) : []
};
}
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 where = filter === 'active' ? 'WHERE sets.active=1'
: filter === 'archived' ? 'WHERE sets.active=0'
: filter === 'configured' ? `WHERE EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=sets.id)
AND EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id)` : '';
const [summary, filtered, pendingStats, pendingTriggers] = 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 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(),
getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'stat', fields: ['unit', 'chipMin', 'chipMax'] }]),
getPendingEntities(user.id, [{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] }])
]);
const pagination = getPagination(url, Number(filtered?.total ?? 0));
const [sets, twoPiece, primaryOptions, secondaryOptions, talents, talentTriggers, talentEffects, stats, triggers, 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(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 entity_id,updated_at FROM game_data_icons WHERE entity_type='skill_chip'")).all()
]);
const availableStats = [...pendingStats.map((item) => ({
...item,
unit: String(item.unit ?? ''),
skill_chip_min: item.chipMin === '' ? null : Number(item.chipMin),
skill_chip_max: item.chipMax === '' ? null : Number(item.chipMax)
})), ...stats];
return {
sets: sets.map((set) => {
const talent = talents.find((item) => item.skill_chip_set_id === set.id);
const icon = icons.find((item) => item.entity_id === set.id);
return {
...set,
iconUrl: icon ? gameDataIconUrl('skill_chip', String(set.id), icon.updated_at) : '',
twoPiece: twoPiece.find((item) => item.skill_chip_set_id === set.id) ?? null,
primaryStatIds: primaryOptions.filter((item) => item.skill_chip_set_id === set.id).map((item) => String(item.stat_attribute_id)),
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)
} : null
};
}),
stats: availableStats,
rollStats: availableStats.filter((stat) => stat.skill_chip_min !== null && stat.skill_chip_max !== null),
triggers: [...pendingTriggers, ...triggers],
summary, filter, pagination
};
};
export const actions: Actions = {
save: 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, { skillChipError: error.message }); throw error; }
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const twoPieceStatId = String(form.get('twoPieceStatId') ?? '');
const twoPieceMin = Number(form.get('twoPieceMin'));
const twoPieceMax = Number(form.get('twoPieceMax'));
const primaryStatIds = [...new Set(form.getAll('primaryStatIds').map(String).filter(Boolean))];
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'));
if (name.length < 2 || name.length > 80) return fail(400, { skillChipError: 'Skill Chip names must be 2–80 characters.' });
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 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 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 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) {
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(`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);
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() };
},
archive: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
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);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'skill_chip.archived', entityId: id, before, after: await snapshot(id) });
return { skillChipArchived: true };
},
unarchive: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
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);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'skill_chip.unarchived', entityId: id, before, after: await snapshot(id) });
return { skillChipUnarchived: true };
}
};
+109
View File
@@ -0,0 +1,109 @@
<script lang="ts">
import IconUploadField from '$lib/components/IconUploadField.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
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 blank = () => ({ id: '', name: '', 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');
let handledResultToken = $state('');
const qualitySteps = [
{ value: '0', label: '0 HIGH-END' },
{ value: '1', label: '1 HIGH-END' },
{ value: '2', label: '2 HIGH-END' },
{ value: '3', label: '3 HIGH-END' }
];
const stat = (id: string) => data.stats.find((item: any) => item.id === id);
const format = (value: number | string, statId: string) => {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return '—';
const rendered = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, '');
const unit = stat(statId)?.unit ?? '';
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 edit = (item: any) => {
editing = {
...item,
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()
};
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;
editing = form.skillChipMode === 'add' ? blank() : null;
highEndCount = '0';
}
});
</script>
<svelte:head><title>Skill Chips — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">SKILL MODIFICATION SYNERGY REGISTRY</p><h2>SKILL CHIPS</h2><p>Configure Superior and High-end chip rolls, 2-piece bonuses, and quality-scaled 3-piece talents.</p></div><button class="primary" onclick={() => editing = blank()}>ADD SKILL CHIP <span>+</span></button></div>
<KpiFilters active={data.filter} items={[
{value:'',label:'TOTAL SETS',count:data.summary.total,description:'Show all definitions'},
{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},
{value:'configured',label:'CONFIGURED',count:data.summary.configured,description:'Both bonuses defined'},
{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'}
]}/>
{#if editing}
<div class="dashboard-editor skill-chip-editor">
<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 })))}/>
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON"/><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-bonus">
<div class="selection-head"><div><span>2-PIECE BONUS</span><small>MINIMUM AT ZERO HIGH-END · LINEAR TO MAXIMUM AT THREE HIGH-END</small></div><b>{format(scaled(editing.twoPiece.min_value, editing.twoPiece.max_value), editing.twoPiece.stat_attribute_id)}</b></div>
<div class="skill-chip-bonus-grid"><div><SearchableAssignment items={data.stats} fieldName="twoPieceStatId" mode="single" subjectLabel="BONUS ATTRIBUTE" searchPlaceholder="Search attributes…" emptySelectionLabel="NO BONUS ATTRIBUTE SELECTED" bind:value={editing.twoPiece.stat_attribute_id}/></div><div class="skill-chip-range-fields"><label class="field">MINIMUM VALUE<input name="twoPieceMin" type="number" step="any" bind:value={editing.twoPiece.min_value} required/></label><label class="field">MAXIMUM VALUE<input name="twoPieceMax" type="number" step="any" bind:value={editing.twoPiece.max_value} required/></label></div></div>
</section>
<div class="skill-chip-pools">
<div><div class="selection-head"><div><span>PRIMARY STAT POOL</span><small>ONE RANDOM STAT · USES ATTRIBUTE SKILL CHIP RANGE</small></div><b>{editing.primaryStatIds.length}</b></div><SearchableAssignment items={data.rollStats} fieldName="primaryStatIds" subjectLabel="PRIMARY STATS" searchPlaceholder="Search Skill Chip stats…" excludedIds={editing.secondaryStatIds} bind:selected={editing.primaryStatIds}/></div>
<div><div class="selection-head"><div><span>SECONDARY STAT POOL</span><small>TWO DISTINCT RANDOM STATS · USE ATTRIBUTE RANGES</small></div><b>{editing.secondaryStatIds.length}</b></div><SearchableAssignment items={data.rollStats} fieldName="secondaryStatIds" subjectLabel="SECONDARY STATS" searchPlaceholder="Search Skill Chip stats…" excludedIds={editing.primaryStatIds} bind:selected={editing.secondaryStatIds}/></div>
</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="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><select bind:value={effect.stat_attribute_id} required><option value="" disabled>SELECT ATTRIBUTE</option>{#each data.stats as item}<option value={item.id}>{item.name}</option>{/each}</select><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>
</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.primaryStatIds.length || editing.secondaryStatIds.length < 2 || !data.rollStats.length || !data.triggers.length}>SAVE SKILL CHIP →</button></div></div>
</PendingForm>
</div>
{/if}
<RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table">
<div class="dashboard-row skill-chip-row table-heading"><span>SKILL CHIP</span><span>QUALITY</span><span>2-PIECE BONUS</span><span>3-PIECE TALENT</span><span>ROLL POOLS</span><span>STATE</span><span></span></div>
{#each data.sets as item}
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1}><div class="registry-icon-identity">{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{item.name}</strong><small>SUPERIOR · HIGH-END</small></div></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="state-dot">{Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(item)}>EDIT</button></div>
{: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 -1
View File
@@ -48,7 +48,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
FROM talents`)).get(),
getPendingEntities(user.id, [
{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] },
{ module: 'stats', action: 'save', kind: 'stat', fields: ['unit'] }
{ module: 'attributes', action: 'save', kind: 'stat', fields: ['unit'] }
])
]);
const pendingTriggers = pending.filter((item) => item.kind === 'trigger');
+2 -2
View File
@@ -56,8 +56,8 @@
});
</script>
<svelte:head><title>Talents & triggers — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">TALENT REGISTRY</p><h2>TALENTS & TRIGGERS</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>
<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>
<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}