diff --git a/database/schema.sql b/database/schema.sql index 9e8a9c9..c255c77 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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, diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 1fa05cf..2b66449 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -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. diff --git a/src/app.css b/src/app.css index 1448483..616f95f 100644 --- a/src/app.css +++ b/src/app.css @@ -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}} diff --git a/src/routes/admin/stats/+page.server.ts b/src/routes/admin/stats/+page.server.ts index 3c20901..2e4d795 100644 --- a/src/routes/admin/stats/+page.server.ts +++ b/src/routes/admin/stats/+page.server.ts @@ -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; try { values = ['gearMin','gearMax','weaponMin','weaponMax','chipMin','chipMax','capValue'].map(number); } catch { return fail(400, { error: 'Ranges must be positive numbers.' }); } diff --git a/src/routes/admin/stats/+page.svelte b/src/routes/admin/stats/+page.svelte index f7b2c69..f70b99f 100644 --- a/src/routes/admin/stats/+page.svelte +++ b/src/routes/admin/stats/+page.svelte @@ -2,11 +2,11 @@ import PendingForm from '$lib/components/PendingForm.svelte'; let { data, form } = $props(); let editing = $state(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 ?? ''; Stats & attributes — SHD.build admin

ATTRIBUTE REGISTRY

STATS & ATTRIBUTES

Builder values, hidden calculation targets, source ranges, and caps.

TOTAL RECORDS{data.stats.length}All definitions
ACTIVE{data.stats.filter((item)=>Number(item.active)===1).length}Available to systems
OFFENSIVE{data.stats.filter((item)=>item.type==='Offensive').length}Damage-oriented
DEFENSIVE{data.stats.filter((item)=>item.type==='Defensive').length}Survival-oriented
-{#if editing}

{editing.id?'MODIFY RECORD':'NEW RECORD'}

{editing.id?editing.name:'ATTRIBUTE DEFINITION'}

{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
{source[1]} RANGE · OPTIONAL
{/each}
{#if form?.error}

{form.error}

{/if}
{/if} -
ATTRIBUTECLASSIFICATIONCATEGORYCALCULATIONSTATE
{#each data.stats as stat}
{stat.name}{stat.unit}
{stat.type}{stat.category}{stat.calculation_type}{Number(stat.active)===1?'ACTIVE':'ARCHIVED'}
{/each}
+{#if editing}

{editing.id?'MODIFY RECORD':'NEW RECORD'}

{editing.id?editing.name:'ATTRIBUTE DEFINITION'}

UNIT
{#each [['','NONE'],['%','%'],['s','s'],['Shots','SHOTS'],['m','m']] as option}{/each}
{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
{source[1]} RANGE · OPTIONAL
{/each}
{#if form?.error}

{form.error}

{/if}
{/if} +
ATTRIBUTECLASSIFICATIONCATEGORYCALCULATIONSTATE
{#each data.stats as stat}
{stat.name}{stat.unit || 'NO UNIT'}
{stat.type}{stat.category}{stat.calculation_type}{Number(stat.active)===1?'ACTIVE':'ARCHIVED'}
{/each}
diff --git a/src/routes/admin/talents/+page.server.ts b/src/routes/admin/talents/+page.server.ts index 609f846..fe6902c 100644 --- a/src/routes/admin/talents/+page.server.ts +++ b/src/routes/admin/talents/+page.server.ts @@ -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, + 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 }; } }; diff --git a/src/routes/admin/talents/+page.svelte b/src/routes/admin/talents/+page.svelte index b1c687d..1695d3d 100644 --- a/src/routes/admin/talents/+page.svelte +++ b/src/routes/admin/talents/+page.svelte @@ -1,20 +1,68 @@ + Talents & triggers — SHD.build admin -

TALENT REGISTRY

TALENTS & TRIGGERS

Tier behavior, reusable activation conditions, uptime, and attribute effects.

-
TALENTS{data.talents.length}Registered definitions
TRIGGERS{data.triggers.length}Reusable conditions
WEAPON{data.talents.filter((item)=>item.category==='Weapon').length}Weapon talents
GEAR{data.talents.filter((item)=>item.category!=='Weapon').length}Body armor and backpack
-{#if trigger}

{trigger.id?'MODIFY TRIGGER':'NEW TRIGGER'}

{trigger.id?trigger.name:'TRIGGER DEFINITION'}

CALCULATION SCRIPTReserved for the future calculation engine

{#if form?.triggerError}

{form.triggerError}

{/if}
{/if} -{#if talent}

{talent.id?'MODIFY TALENT':'NEW TALENT'}

{talent.id?talent.name:'TALENT DEFINITION'}

{#each ['I','II'] as tier}
TIER {tier}

ACTIVATION TRIGGERS

{#each data.triggers.filter((item)=>Number(item.active)===1) as item}
{/each}

ATTRIBUTE EFFECTS

{#each talent[tier].effects as effect,index}
{/each}
{/each}
{#if form?.talentError}

{form.talentError}

{/if}
{/if} -
TALENTCATEGORYTIER ITIER IISTATE
{#each data.talents as item}
{item.name}Tiered talent
{item.category}{configured(item.tiers,'I')?'CONFIGURED':'MISSING'}{configured(item.tiers,'II')?'CONFIGURED':'MISSING'}ACTIVE
{/each}
+

TALENT REGISTRY

TALENTS & TRIGGERS

Shared behavior with tier-specific values, reusable triggers, and attribute effects.

+
TALENTS{data.talents.length}Registered definitions
TRIGGERS{data.triggers.length}Reusable conditions
WEAPON{data.talents.filter((item) => item.category === 'Weapon').length}Weapon talents
GEAR{data.talents.filter((item) => item.category !== 'Weapon').length}Body armor and backpack
+ +{#if trigger} +

{trigger.id ? 'MODIFY TRIGGER' : 'NEW TRIGGER'}

{trigger.id ? trigger.name : 'TRIGGER DEFINITION'}

CALCULATION SCRIPTReserved for the future calculation engine

{#if form?.triggerError}

{form.triggerError}

{/if}
+{/if} + +{#if talent} +
+

{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}

{talent.id ? talent.name : 'TALENT DEFINITION'}

+ + +
+ +
TIER I PREVIEW

{preview('I') || 'Description preview'}

TIER II PREVIEW

{preview('II') || 'Description preview'}

+

SHARED ACTIVATION TRIGGERS

+
{#each data.triggers.filter((item) => Number(item.active) === 1) as item}
{/each}
+

SHARED ATTRIBUTE EFFECTS · TIER VALUES

+
ATTRIBUTETIER I VALUETIER II VALUE
{#each talent.effects as effect, index}
{/each}
+ {#if form?.talentError}

{form.talentError}

{/if} +
+
+
+{/if} + +
TALENTCATEGORYTIER ITIER IISTATE
{#each data.talents as item}
{item.name}Tiered talent
{item.category}{configured(item.tiers, 'I') ? 'CONFIGURED' : 'MISSING'}{configured(item.tiers, 'II') ? 'CONFIGURED' : 'MISSING'}ACTIVE
{/each}