Simplify tiered talent editing

This commit is contained in:
JB
2026-07-24 23:08:22 -04:00
parent e605e4c0ee
commit 8ecdfaa319
7 changed files with 178 additions and 59 deletions
+6 -1
View File
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS stat_attributes (
type TEXT NOT NULL CHECK(type IN ('Offensive', 'Defensive')),
category TEXT NOT NULL CHECK(category IN ('Weapon', 'Skill', 'Character')),
calculation_type TEXT NOT NULL CHECK(calculation_type IN ('Additive', 'Multiplicative')),
unit TEXT NOT NULL DEFAULT 'flat',
unit TEXT NOT NULL DEFAULT '',
gear_min REAL,
gear_max REAL,
weapon_min REAL,
@@ -91,6 +91,11 @@ CREATE TABLE IF NOT EXISTS talent_tier_effects (
created_at TEXT NOT NULL,
UNIQUE(talent_tier_id, stat_attribute_id)
);
CREATE TABLE IF NOT EXISTS talent_description_templates (
talent_id TEXT PRIMARY KEY REFERENCES talents(id) ON DELETE CASCADE,
template TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS brand_sets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
+6
View File
@@ -96,12 +96,18 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps
- When compatibility is conditional on another field, show only valid choices,
clear stale selections when the controlling field changes, and state required
minimum selections in both the section label and validation feedback.
- Tiered records with shared structure use one shared selector and side-by-side
value columns rather than duplicating the entire editor. Template-driven copy
must show live previews for every tier and document its placeholder syntax.
### Forms
- Labels are uppercase and letter-spaced.
- Inputs use dark surfaces, one-pixel borders, and clear disabled styling.
- Related min/max fields remain visually paired.
- Small mutually exclusive option sets use a compact segmented radio control,
even when visually presented like selectable checkbox cells. Include an
explicit “None” option when the field is optional.
- Primary save actions sit at the lower right. Cancel is secondary.
- Destructive/archive actions are visually separated from save actions.
+2
View File
@@ -64,5 +64,7 @@
.brand-name{display:block;max-width:420px;margin:10px 0}.effect-editor{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.effect-band{display:grid;grid-template-columns:58px minmax(0,1fr) 100px;align-items:end;gap:8px;padding:9px;background:#101314;border:1px solid #343a3b}.effect-band>strong{align-self:stretch;display:grid;place-content:center;text-align:center;color:#f6a821;font-size:23px;border-right:1px solid #343a3b}.effect-band>strong small{display:block;color:#707779;font-size:6px;letter-spacing:1px}.effect-band .field{margin:0}.split-actions{justify-content:space-between}.split-actions>div{display:flex;gap:8px}.brand-set-row{grid-template-columns:1.1fr 1.2fr 1.2fr 1.2fr .6fr .3fr}.empty-dashboard{min-height:120px;display:grid;place-content:center;text-align:center;gap:6px;color:#626a6c}.empty-dashboard strong{font-size:10px;letter-spacing:1.2px}.empty-dashboard span{font-size:8px}
.gear-identity{display:grid;grid-template-columns:2fr 1fr .65fr .65fr;gap:8px}.gear-ranges{display:grid;grid-template-columns:1fr 1fr;gap:8px}.range-band{grid-template-columns:58px 1fr 1fr}.effect-band.unavailable{opacity:.4}.selection-head{display:flex;justify-content:space-between;align-items:end;margin:14px 0 6px}.selection-head>div{display:grid}.selection-head span{font-size:8px;font-weight:bold;letter-spacing:1.2px}.selection-head small{font-size:7px;color:#697174;margin-top:3px}.selection-head b{color:#f6a821;font-size:8px;letter-spacing:1px}.selection-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid #303536}.selection-grid label{min-height:38px;display:grid;grid-template-columns:20px 1fr auto;align-items:center;gap:5px;padding:6px 8px;border:1px solid transparent;border-right-color:#282d2e;border-bottom-color:#282d2e;cursor:pointer;font-size:9px}.selection-grid label:hover{background:#171b1c;border-color:#4b4f50}.selection-grid input{width:auto;margin:0;accent-color:#f6a821}.selection-grid small{font-size:6px;color:#646c6e;letter-spacing:.8px}.selection-grid input:checked~small{color:#75c08c}.gear-row{min-width:1050px;grid-template-columns:1.2fr .8fr .8fr 1.35fr .8fr .6fr .3fr}.gear-row>span small{font-size:8px;color:#858b8d}
.unit-field{min-width:0;margin:8px 0;padding:0;border:0}.unit-field legend{font-size:8px;font-weight:bold;letter-spacing:1px;color:#858b8d}.unit-picker{display:grid;grid-template-columns:repeat(5,1fr);height:32px;margin-top:5px;border:1px solid #424849}.unit-picker label{display:grid;place-items:center;border-left:1px solid #303536;cursor:pointer}.unit-picker label:first-child{border-left:0}.unit-picker input{position:absolute;opacity:0;pointer-events:none}.unit-picker span{width:100%;height:100%;display:grid;place-items:center;color:#777f81;font-size:8px;font-weight:bold}.unit-picker label:hover span{background:#191d1e;color:#ecebe5}.unit-picker input:checked+span{background:#2b2111;color:#f6a821;box-shadow:inset 0 -2px #f6a821}.unit-picker input:focus-visible+span{outline:2px solid #f6a821;outline-offset:2px}
.template-field{display:grid;margin-top:10px}.template-field small{margin-top:5px;color:#697174;font-size:7px}.tier-preview{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:8px 0 12px}.tier-preview>div{padding:9px 11px;border:1px solid #343a3b;background:#101314}.tier-preview span,.effect-heading{color:#697174;font-size:7px;letter-spacing:1.1px}.tier-preview p{min-height:30px;margin:5px 0 0;color:#d9d9d3;font-size:10px;line-height:1.45}.shared-trigger-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));max-height:170px}.shared-effects{border:1px solid #303536}.shared-effect{display:grid;grid-template-columns:minmax(180px,1fr) 120px 120px 30px;gap:6px;align-items:center;padding:6px;border-top:1px solid #292f30}.shared-effect:first-child{border-top:0}.shared-effect select,.shared-effect input{min-width:0;margin:0;padding:7px;font-size:9px}.shared-effect button{height:30px;border:1px solid #563734;background:#1d1212;color:#da746c}.effect-heading{min-height:28px;background:#0d1011}.effect-heading span:last-child{display:none}
@media(max-width:1100px){.effect-editor{grid-template-columns:1fr}.selection-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.gear-identity,.gear-ranges{grid-template-columns:1fr 1fr}}@media(max-width:560px){.effect-band{grid-template-columns:48px 1fr}.effect-band .field:last-child{grid-column:2}.split-actions{align-items:stretch}.split-actions,.split-actions>div{width:100%}.split-actions>div:last-child button{flex:1}.gear-identity,.gear-ranges,.selection-grid{grid-template-columns:1fr}.range-band{grid-template-columns:48px 1fr}.range-band .field:last-child{grid-column:2}}
+4 -2
View File
@@ -7,16 +7,18 @@ import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const database = await getDatabase();
return { stats: await (await database.prepare(`SELECT * FROM stat_attributes ORDER BY active DESC, name COLLATE NOCASE`)).all() };
const stats = await (await database.prepare(`SELECT * FROM stat_attributes ORDER BY active DESC, name COLLATE NOCASE`)).all();
return { stats: stats.map((stat) => ({ ...stat, unit: stat.unit === 'flat' ? '' : stat.unit })) };
};
export const actions: Actions = {
save: async ({ request, cookies }) => {
requireAdmin(await getSessionUser(cookies));
const form = await request.formData();
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').normalize('NFKC').trim();
const type = String(form.get('type') ?? ''), category = String(form.get('category') ?? ''), calculation = String(form.get('calculationType') ?? ''), unit = String(form.get('unit') ?? 'flat');
const type = String(form.get('type') ?? ''), category = String(form.get('category') ?? ''), calculation = String(form.get('calculationType') ?? ''), unit = String(form.get('unit') ?? '');
if (name.length < 2 || name.length > 80) return fail(400, { error: 'Attribute names must be 2–80 characters.' });
if (!['Offensive','Defensive'].includes(type) || !['Weapon','Skill','Character'].includes(category) || !['Additive','Multiplicative'].includes(calculation)) return fail(400, { error: 'Choose valid attribute options.' });
if (!['','%','s','Shots','m'].includes(unit)) return fail(400, { error: 'Choose a valid unit.' });
const number = (key: string) => { const raw = String(form.get(key) ?? '').trim(); if (!raw) return null; const value = Number(raw); if (!Number.isFinite(value) || value < 0) throw new Error(); return value; };
let values: Array<number|null>;
try { values = ['gearMin','gearMax','weaponMin','weaponMax','chipMin','chipMax','capValue'].map(number); } catch { return fail(400, { error: 'Ranges must be positive numbers.' }); }
+3 -3
View File
@@ -2,11 +2,11 @@
import PendingForm from '$lib/components/PendingForm.svelte';
let { data, form } = $props();
let editing = $state<any>(null);
const blank = () => ({id:'',name:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'%',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',skill_chip_min:'',skill_chip_max:'',cap_value:''});
const blank = () => ({id:'',name:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',skill_chip_min:'',skill_chip_max:'',cap_value:''});
const value = (input: unknown) => input ?? '';
</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>
<div class="module-kpis"><div><span>TOTAL RECORDS</span><strong>{data.stats.length}</strong><small>All definitions</small></div><div><span>ACTIVE</span><strong>{data.stats.filter((item)=>Number(item.active)===1).length}</strong><small>Available to systems</small></div><div><span>OFFENSIVE</span><strong>{data.stats.filter((item)=>item.type==='Offensive').length}</strong><small>Damage-oriented</small></div><div><span>DEFENSIVE</span><strong>{data.stats.filter((item)=>item.type==='Defensive').length}</strong><small>Survival-oriented</small></div></div>
{#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><label class="field">UNIT<input name="unit" bind:value={editing.unit}/></label>{#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}
<div class="dashboard-table"><div class="dashboard-row table-heading"><span>ATTRIBUTE</span><span>CLASSIFICATION</span><span>CATEGORY</span><span>CALCULATION</span><span>STATE</span><span></span></div>{#each data.stats as stat}<div class="dashboard-row" class:archived={Number(stat.active)!==1}><div><strong>{stat.name}</strong><small>{stat.unit}</small></div><span class:type-offensive={stat.type==='Offensive'} class:type-defensive={stat.type==='Defensive'}>{stat.type}</span><span>{stat.category}</span><span>{stat.calculation_type}</span><b class="state-dot">{Number(stat.active)===1?'ACTIVE':'ARCHIVED'}</b><button onclick={() => editing={...stat}}>EDIT</button></div>{/each}</div>
{#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}
<div class="dashboard-table"><div class="dashboard-row table-heading"><span>ATTRIBUTE</span><span>CLASSIFICATION</span><span>CATEGORY</span><span>CALCULATION</span><span>STATE</span><span></span></div>{#each data.stats as stat}<div class="dashboard-row" class:archived={Number(stat.active)!==1}><div><strong>{stat.name}</strong><small>{stat.unit || 'NO UNIT'}</small></div><span class:type-offensive={stat.type==='Offensive'} class:type-defensive={stat.type==='Defensive'}>{stat.type}</span><span>{stat.category}</span><span>{stat.calculation_type}</span><b class="state-dot">{Number(stat.active)===1?'ACTIVE':'ARCHIVED'}</b><button onclick={() => editing={...stat}}>EDIT</button></div>{/each}</div>
+93 -37
View File
@@ -5,56 +5,112 @@ import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
type SharedEffect = { statId: string; valueI: number; valueII: number };
export const load: PageServerLoad = async () => {
const db = await getDatabase();
const [talents, tiers, triggers, links, effects, stats] = await Promise.all([
const [talents, tiers, triggers, links, effects, stats, templates] = await Promise.all([
(await db.prepare('SELECT * FROM talents ORDER BY active DESC,name COLLATE NOCASE')).all(),
(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 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()
]);
return { talents: talents.map((talent) => ({...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category, 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, stats };
return {
talents: talents.map((talent) => ({
...talent,
category: talent.category === 'Vest' ? 'Body Armor' : talent.category,
descriptionTemplate: templates.find((item) => item.talent_id === talent.id)?.template ?? '',
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,
stats
};
};
const parseEffects = (raw: FormDataEntryValue | null) => {
const value = JSON.parse(String(raw ?? '[]')) as Array<{statId:string;value:string|number}>;
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),value:Number(effect.value)}));
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 renderDescription = (
template: string,
effects: SharedEffect[],
units: Map<string, unknown>,
tier: 'I' | 'II'
) => template.replace(/\{(\d+)\}/g, (placeholder, rawIndex) => {
const effect = effects[Number(rawIndex) - 1];
if (!effect) return placeholder;
return displayValue(tier === 'I' ? effect.valueI : effect.valueII, units.get(effect.statId));
});
export const actions: Actions = {
saveTrigger: async ({request,cookies}) => {
requireAdmin(await getSessionUser(cookies)); 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();
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(randomUUID(),name,description,uptime?1:0,min,max,now,now);
return {triggerSaved:true};
saveTrigger: async ({ request, cookies }) => {
requireAdmin(await getSessionUser(cookies));
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();
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(randomUUID(), name, description, uptime ? 1 : 0, min, max, now, now);
return { triggerSaved: true };
},
saveTalent: async ({request,cookies}) => {
requireAdmin(await getSessionUser(cookies)); const form=await request.formData();
const id=String(form.get('id')??''),name=String(form.get('name')??'').trim(),category=String(form.get('category')??'');
const descriptions={I:String(form.get('tierIDescription')??'').trim(),II:String(form.get('tierIIDescription')??'').trim()};
const triggerIds={I:form.getAll('tierITriggerIds').map(String),II:form.getAll('tierIITriggerIds').map(String)};
let tierEffects:{I:Array<{statId:string;value:number}>;II:Array<{statId:string;value:number}>};
try{tierEffects={I:parseEffects(form.get('tierIEffects')),II:parseEffects(form.get('tierIIEffects'))};}catch{return fail(400,{talentError:'Effects could not be read.'});}
if(name.length<2||!['Weapon','Backpack','Body Armor'].includes(category))return fail(400,{talentError:'Provide a valid talent name and category.'});
for(const tier of ['I','II'] as const)if(!descriptions[tier]||!triggerIds[tier].length||!tierEffects[tier].length||tierEffects[tier].some((effect)=>!effect.statId||!Number.isFinite(effect.value)))return fail(400,{talentError:`Tier ${tier} requires a description, trigger, and valid attribute effects.`});
const storedCategory=category==='Body Armor'?'Vest':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 talentId=id||randomUUID(),now=new Date().toISOString();
if(id)await(await db.prepare('UPDATE talents SET name=?,category=?,updated_at=? WHERE id=?')).run(name,storedCategory,now,id);else await(await db.prepare('INSERT INTO talents(id,name,category,created_at,updated_at) VALUES(?,?,?,?,?)')).run(talentId,name,storedCategory,now,now);
for(const tier of ['I','II'] as const){
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,descriptions[tier],'','',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[tier])await(await db.prepare('INSERT INTO talent_tier_triggers(talent_tier_id,trigger_id) VALUES(?,?)')).run(tierId,triggerId);
for(const effect of tierEffects[tier])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,effect.value,now);
saveTalent: async ({ request, cookies }) => {
requireAdmin(await getSessionUser(cookies));
const form = await request.formData();
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 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.' }); }
if (name.length < 2 || !['Weapon', 'Backpack', 'Body Armor'].includes(category)) return fail(400, { talentError: 'Provide a valid talent name and category.' });
if (!descriptionTemplate || !triggerIds.length || !sharedEffects.length) return fail(400, { talentError: 'A description template, trigger, and at least one effect are required.' });
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.' });
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.' });
const storedCategory = category === 'Body Armor' ? 'Vest' : 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 units = new Map(statRows.map((stat) => [String(stat.id), stat.unit]));
const talentId = id || randomUUID(), now = new Date().toISOString();
if (id) await (await db.prepare('UPDATE talents SET name=?,category=?,updated_at=? WHERE id=?')).run(name, storedCategory, now, id);
else await (await db.prepare('INSERT INTO talents(id,name,category,created_at,updated_at) VALUES(?,?,?,?,?)')).run(talentId, name, storedCategory, now, now);
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);
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);
}
return {talentSaved:true};
return { talentSaved: true };
}
};
+64 -16
View File
@@ -1,20 +1,68 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
let {data,form}=$props();
type Effect={statId:string;value:string};
let talent=$state<any>(null),trigger=$state<any>(null),uptime=$state(false);
const blankTalent=()=>({id:'',name:'',category:'Weapon',I:{description:'',triggerIds:[],effects:[{statId:'',value:''}]},II:{description:'',triggerIds:[],effects:[{statId:'',value:''}]}});
const editTalent=(row:any)=>{const tier=(name:string)=>row.tiers.find((item:any)=>item.tier===name)??{description:'',triggerIds:[],effects:[]};const one=tier('I'),two=tier('II');talent={...row,I:{description:one.description,triggerIds:[...one.triggerIds],effects:one.effects.map((e:any)=>({statId:String(e.stat_attribute_id),value:String(e.value)}))},II:{description:two.description,triggerIds:[...two.triggerIds],effects:two.effects.map((e:any)=>({statId:String(e.stat_attribute_id),value:String(e.value)}))}}};
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 toggle=(tier:'I'|'II',id:string,checked:boolean)=>talent[tier].triggerIds=checked?[...talent[tier].triggerIds,id]:talent[tier].triggerIds.filter((value:string)=>value!==id);
const addEffect=(tier:'I'|'II')=>talent[tier].effects=[...talent[tier].effects,{statId:'',value:''}];
const removeEffect=(tier:'I'|'II',index:number)=>talent[tier].effects=talent[tier].effects.filter((_:Effect,row:number)=>row!==index);
const configured=(tiers:Array<{tier:unknown}>,name:string)=>tiers.some((item)=>item.tier===name);
let { data, form } = $props();
type Effect = { statId: string; valueI: string; valueII: string };
let talent = $state<any>(null), trigger = $state<any>(null), uptime = $state(false);
const blankTalent = () => ({ id: '', name: '', category: 'Weapon', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] });
const editTalent = (row: any) => {
const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', triggerIds: [], 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,
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 ?? '')
}))
};
};
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 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 unit = stat?.unit === 'flat' ? '' : stat?.unit ?? '';
return unit === 'Shots' ? `${value} ${unit}` : `${value}${unit}`;
};
const preview = (tier: 'I' | 'II') => talent.descriptionTemplate.replace(/\{(\d+)\}/g, (placeholder: string, rawIndex: string) => {
const effect = talent.effects[Number(rawIndex) - 1];
return effect ? displayValue(effect, tier) : placeholder;
});
</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>Tier behavior, reusable activation conditions, uptime, 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-kpis"><div><span>TALENTS</span><strong>{data.talents.length}</strong><small>Registered definitions</small></div><div><span>TRIGGERS</span><strong>{data.triggers.length}</strong><small>Reusable conditions</small></div><div><span>WEAPON</span><strong>{data.talents.filter((item)=>item.category==='Weapon').length}</strong><small>Weapon talents</small></div><div><span>GEAR</span><strong>{data.talents.filter((item)=>item.category!=='Weapon').length}</strong><small>Body armor and backpack</small></div></div>
{#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}
{#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"><input type="hidden" name="id" value={talent.id}/><input type="hidden" name="tierIEffects" value={JSON.stringify(talent.I.effects)}/><input type="hidden" name="tierIIEffects" value={JSON.stringify(talent.II.effects)}/><div class="form-grid talent-identity"><label class="field">NAME<input name="name" bind:value={talent.name} required/></label><label class="field">CATEGORY<select name="category" bind:value={talent.category}><option>Weapon</option><option>Backpack</option><option>Body Armor</option></select></label></div><div class="tier-grid">{#each ['I','II'] as tier}<fieldset class="tier-card"><legend>TIER {tier}</legend><label class="field">DISPLAY DESCRIPTION<textarea name={tier==='I'?'tierIDescription':'tierIIDescription'} bind:value={talent[tier].description} required></textarea></label><p class="label">ACTIVATION TRIGGERS</p><div class="trigger-list">{#each data.triggers.filter((item)=>Number(item.active)===1) as item}<div><label><input name={tier==='I'?'tierITriggerIds':'tierIITriggerIds'} type="checkbox" value={item.id} checked={talent[tier].triggerIds.includes(String(item.id))} onchange={(event)=>toggle(tier as 'I'|'II',String(item.id),event.currentTarget.checked)}/> <span>{item.name}</span></label><button type="button" onclick={()=>editTrigger(item)}>EDIT</button></div>{/each}</div><div class="effects-head"><p class="label">ATTRIBUTE EFFECTS</p><button type="button" onclick={()=>addEffect(tier as 'I'|'II')}>ADD EFFECT +</button></div>{#each talent[tier].effects as effect,index}<div class="talent-effect"><select bind:value={effect.statId} required><option value="">Select attribute…</option>{#each data.stats as stat}<option value={stat.id}>{stat.name}</option>{/each}</select><input type="number" step="any" bind:value={effect.value} placeholder="VALUE" required/><button type="button" onclick={()=>removeEffect(tier as 'I'|'II',index)}>×</button></div>{/each}</fieldset>{/each}</div>{#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></div>{/if}
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I</span><span>TIER II</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row"><div><strong>{item.name}</strong><small>Tiered talent</small></div><span>{item.category}</span><span class:configured={configured(item.tiers,'I')}>{configured(item.tiers,'I')?'CONFIGURED':'MISSING'}</span><span class:configured={configured(item.tiers,'II')}>{configured(item.tiers,'II')?'CONFIGURED':'MISSING'}</span><b class="state-dot">ACTIVE</b><button onclick={()=>editTalent(item)}>EDIT</button></div>{/each}</div>
<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>
<div class="module-kpis"><div><span>TALENTS</span><strong>{data.talents.length}</strong><small>Registered definitions</small></div><div><span>TRIGGERS</span><strong>{data.triggers.length}</strong><small>Reusable conditions</small></div><div><span>WEAPON</span><strong>{data.talents.filter((item) => item.category === 'Weapon').length}</strong><small>Weapon talents</small></div><div><span>GEAR</span><strong>{data.talents.filter((item) => item.category !== 'Weapon').length}</strong><small>Body armor and backpack</small></div></div>
{#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}
{#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">
<input type="hidden" name="id" value={talent.id}/><input type="hidden" name="effects" value={JSON.stringify(talent.effects)}/>
<div class="form-grid talent-identity"><label class="field">NAME<input name="name" bind:value={talent.name} required/></label><label class="field">CATEGORY<select name="category" bind:value={talent.category}><option>Weapon</option><option>Backpack</option><option>Body Armor</option></select></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}..." required></textarea><small>Use {'{1}'}, {'{2}'}, and so on to insert each effect value with its unit.</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><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><button type="button" onclick={() => editTrigger(item)}>EDIT</button></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"><select bind:value={effect.statId} required><option value="">Select attribute…</option>{#each data.stats as stat}<option value={stat.id}>{stat.name} ({stat.unit || 'no unit'})</option>{/each}</select><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>
{#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>
</div>
{/if}
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I</span><span>TIER II</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row"><div><strong>{item.name}</strong><small>Tiered talent</small></div><span>{item.category}</span><span class:configured={configured(item.tiers, 'I')}>{configured(item.tiers, 'I') ? 'CONFIGURED' : 'MISSING'}</span><span class:configured={configured(item.tiers, 'II')}>{configured(item.tiers, 'II') ? 'CONFIGURED' : 'MISSING'}</span><b class="state-dot">ACTIVE</b><button onclick={() => editTalent(item)}>EDIT</button></div>{/each}</div>