diff --git a/database/schema.sql b/database/schema.sql index 571d5c7..db736be 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -271,6 +271,9 @@ CREATE TABLE IF NOT EXISTS talents ( id TEXT PRIMARY KEY, name TEXT NOT NULL, category TEXT NOT NULL CHECK(category IN ('Weapon', 'Backpack', 'Body Armor', 'Vest')), + cooldown_seconds REAL CHECK(cooldown_seconds IS NULL OR cooldown_seconds > 0), + duration_seconds REAL CHECK(duration_seconds IS NULL OR duration_seconds > 0), + max_stacks INTEGER CHECK(max_stacks IS NULL OR max_stacks > 0), active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index b260d3e..1ba0f66 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -511,6 +511,17 @@ text is operationally useful. Use compact wrapping description cells rather than replacing meaningful data with a generic “configured” label; retain an explicit textual missing state when a tier has no rendered description. +Talent mechanics are structured metadata, not free-form prose. Present optional +cooldown, duration, and maximum-stack inputs together in a compact three-column +mechanics strip that collapses to one column on narrow screens. Description +templates use numbered expressions (`{1}`, `{2}`) for tiered attribute effects +and named expressions (`{duration}`, `{max_stacks}`) for shared mechanics. +Named expressions are valid only when their corresponding value is defined. +Cooldown is never embedded in the description: render it consistently as +`Talent Cooldown: [value] seconds` beneath the talent identity or description. +Do not imply an absent mechanic with a zero value; show no cooldown line and +leave optional editor fields explicitly marked as not defined. + ### Registry pagination Every primary ISAC registry uses server-side pagination. The shared control diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index 8b9690e..2b62210 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -222,6 +222,18 @@ if (gearColumns.some((column) => column.name === 'level')) { await database.exec('ALTER TABLE gear DROP COLUMN level'); } +const talentColumns = await (await database.prepare("PRAGMA table_info('talents')")).all(); +for (const [name, definition] of [ + ['cooldown_seconds', 'REAL CHECK(cooldown_seconds IS NULL OR cooldown_seconds > 0)'], + ['duration_seconds', 'REAL CHECK(duration_seconds IS NULL OR duration_seconds > 0)'], + ['max_stacks', 'INTEGER CHECK(max_stacks IS NULL OR max_stacks > 0)'] +]) { + if (!talentColumns.some((column) => column.name === name)) { + console.log(`Adding optional Talent mechanic: ${name}.`); + await database.exec(`ALTER TABLE talents ADD COLUMN ${name} ${definition}`); + } +} + console.log(`Applied ${statements.length} schema statements to ${databasePath}`); if (!databaseAlreadyExisted) { diff --git a/src/app.css b/src/app.css index 5ee4cd0..f9e7450 100644 --- a/src/app.css +++ b/src/app.css @@ -98,6 +98,7 @@ .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} .talent-row{grid-template-columns:minmax(150px,.8fr) minmax(105px,.45fr) minmax(260px,1.35fr) minmax(260px,1.35fr) 80px 45px;min-width:980px}.talent-row:not(.table-heading){align-items:start;padding-top:10px;padding-bottom:10px}.talent-description{margin:0;color:#b7bcba;font-size:9px;line-height:1.45;white-space:normal}.talent-description.unconfigured{color:#df8069;font-size:7px;letter-spacing:.8px}.talent-row .state-dot{margin-top:2px}.talent-row>button{margin-top:-3px} +.talent-mechanics-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:8px;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.talent-mechanics-grid .field{margin:0}.talent-template-note{margin:6px 0 0;color:var(--shd-text-muted);font-size:7px;letter-spacing:.55px}.talent-cooldown{display:block!important;margin-top:3px;color:var(--shd-orange)!important;font-style:normal;font-size:7px!important;letter-spacing:.55px}.summary-talent .talent-cooldown{margin-top:5px}@media(max-width:700px){.talent-mechanics-grid{grid-template-columns:1fr}} .talent-kpis{grid-template-columns:repeat(6,minmax(105px,1fr))} @media(max-width:1200px){.talent-kpis{grid-template-columns:repeat(3,minmax(105px,1fr))}}@media(max-width:650px){.talent-kpis{grid-template-columns:repeat(2,minmax(105px,1fr))}} .section-label{margin-top:10px;border-top:1px solid #2c3132}.user-search{display:flex;gap:6px}.user-search input{height:40px;min-width:240px;margin:0;padding:0 10px;font-size:9px}.user-search button{height:40px}.user-row{min-width:1160px;grid-template-columns:1.1fr .65fr 1.1fr 1fr 1fr .9fr .3fr}.auth-method-summary{display:flex;flex-wrap:wrap;gap:3px}.auth-method-summary b{padding:3px 4px;border:1px solid #3a4840;color:#75c08c;font-size:5px;letter-spacing:.5px}.user-row>span>small{display:block;margin-bottom:3px;color:#f6a821;font-size:6px}.restricted{color:#df8069!important}.row-action{justify-self:end;color:#f6a821;font-size:8px;font-weight:bold}.button-link{display:grid;place-items:center;height:40px;padding:0 14px;border:1px solid #424849;color:#d7d9d5;font-size:8px;font-weight:bold}.metric-date{font-size:11px!important}.user-console-grid{display:grid;grid-template-columns:minmax(0,1fr) 380px;gap:10px}.user-console-grid .dashboard-editor{margin-top:0}.permission-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.moderation-panel .field{display:block}.console-note{padding:20px;color:#747c7e;font-size:9px}.isac-auth-method-list{display:grid;border:1px solid #303536}.isac-auth-method{position:relative;min-height:45px;display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:6px;padding:7px 8px;border-top:1px solid #292e2f;background:#0d1011;overflow:hidden}.isac-auth-method:first-child{border-top:0}.isac-auth-method>div{display:grid;gap:3px}.isac-auth-method b{font-size:8px}.isac-auth-method small{color:#697174;font-size:6px;line-height:1.35}.isac-auth-method>span{color:#626a6c;font-size:6px;font-weight:900;letter-spacing:.8px}.isac-auth-method>span.linked{color:#75c08c}.isac-auth-method.planned{opacity:.55}.isac-auth-method form,.isac-auth-method fieldset{height:100%}.isac-auth-method button{min-height:28px;height:100%;padding:0 7px;font-size:6px}.isac-method-confirm{position:absolute;z-index:2;inset:0;display:grid!important;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;padding:6px 8px;background:#211414;border:1px solid #9a5043}.isac-method-confirm>div:first-child{display:grid;gap:3px}.isac-method-confirm>div:first-child b{color:var(--shd-danger)}.isac-method-confirm>div:last-child{height:30px;display:grid;grid-template-columns:60px 96px;gap:5px}.isac-method-confirm .secondary{border:1px solid #565d5f;background:#101314;color:#d7d9d5}.isac-method-confirm .primary{background:var(--shd-orange);color:#111}.user-history-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.user-history-grid>section{min-width:0;border:1px solid #303536;background:#111415}.history-head{display:flex;justify-content:space-between;padding:10px;background:#0d1011;color:#727a7c;font-size:7px;letter-spacing:1px}.history-list{max-height:300px;overflow:auto}.history-list>div{display:grid;grid-template-columns:1fr auto auto;gap:8px;padding:9px 10px;border-top:1px solid #292e2f;font-size:8px}.history-list span{color:#767e80}.history-list b{font-size:7px}.history-list>p{padding:28px;text-align:center;color:#626a6c;font-size:8px}.comment-history>div{grid-template-columns:1fr}.comment-history span{white-space:normal;line-height:1.4} diff --git a/src/routes/admin/talents/+page.server.ts b/src/routes/admin/talents/+page.server.ts index 552d977..cb2482e 100644 --- a/src/routes/admin/talents/+page.server.ts +++ b/src/routes/admin/talents/+page.server.ts @@ -108,15 +108,26 @@ const displayValue = (value: number, unit: unknown) => { return normalizedUnit === 'Shots' ? `${value} ${normalizedUnit}` : `${value}${normalizedUnit}`; }; +const optionalPositiveNumber = (form: FormData, name: string) => { + const raw = String(form.get(name) ?? '').trim(); + return raw ? Number(raw) : null; +}; + +const renderMechanicValue = (value: number, singular: string, plural = `${singular}s`) => + `${value} ${value === 1 ? singular : plural}`; + const renderDescription = ( template: string, effects: SharedEffect[], units: Map, - 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)); + tier: 'I' | 'II', + durationSeconds: number | null, + maxStacks: number | null +) => template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder, expression) => { + if (expression === 'duration') return durationSeconds === null ? placeholder : renderMechanicValue(durationSeconds, 'second'); + if (expression === 'max_stacks') return maxStacks === null ? placeholder : renderMechanicValue(maxStacks, 'stack'); + const effect = effects[Number(expression) - 1]; + return effect ? displayValue(tier === 'I' ? effect.valueI : effect.valueII, units.get(effect.statId)) : placeholder; }); export const actions: Actions = { @@ -144,15 +155,22 @@ export const actions: Actions = { catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { talentError: error.message }); throw error; } const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? ''); const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').trim(); + const cooldownSeconds = optionalPositiveNumber(form, 'cooldownSeconds'); + const durationSeconds = optionalPositiveNumber(form, 'durationSeconds'); + const maxStacks = optionalPositiveNumber(form, 'maxStacks'); 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 Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].includes(category)) return fail(400, { talentError: 'Provide a valid talent name and category. Exotic talents are managed from their owning weapon.' }); if (!descriptionTemplate || !triggerIds.length || !sharedEffects.length) return fail(400, { talentError: 'A description template, trigger, and at least one effect are required.' }); + if ([cooldownSeconds, durationSeconds].some((value) => value !== null && (!Number.isFinite(value) || value <= 0))) return fail(400, { talentError: 'Cooldown and duration must be positive numbers when defined.' }); + if (maxStacks !== null && (!Number.isInteger(maxStacks) || maxStacks <= 0)) return fail(400, { talentError: 'Maximum stacks must be a positive whole number when defined.' }); 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.' }); + if (descriptionTemplate.includes('{duration}') && durationSeconds === null) return fail(400, { talentError: 'The description uses {duration}, but no duration is defined.' }); + if (descriptionTemplate.includes('{max_stacks}') && maxStacks === null) return fail(400, { talentError: 'The description uses {max_stacks}, but no maximum stack count is defined.' }); const storedCategory = category === 'Body Armor' ? 'Vest' : category.startsWith('Weapon Talent') ? 'Weapon' : category; 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.' }); @@ -161,8 +179,8 @@ export const actions: Actions = { const units = new Map(statRows.map((stat) => [String(stat.id), stat.unit])); const talentId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString(); const before = id ? { record: await auditRecord('talents', id), icon: await gameDataIconMetadata(db, 'talent', id), template: await (await db.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(id), tiers: await (await db.prepare('SELECT tier,description FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(id) } : null; - 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); + if (id) await (await db.prepare('UPDATE talents SET name=?,category=?,cooldown_seconds=?,duration_seconds=?,max_stacks=?,updated_at=? WHERE id=?')).run(name, storedCategory, cooldownSeconds, durationSeconds, maxStacks, now, id); + else await (await db.prepare('INSERT INTO talents(id,name,category,cooldown_seconds,duration_seconds,max_stacks,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)')).run(talentId, name, storedCategory, cooldownSeconds, durationSeconds, maxStacks, now, now); if (storedCategory === 'Weapon') { await (await db.prepare(`INSERT INTO talent_weapon_scopes(talent_id,scope) VALUES(?,?) ON CONFLICT(talent_id) DO UPDATE SET scope=excluded.scope`)) @@ -177,7 +195,7 @@ export const actions: Actions = { 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); + const description = renderDescription(descriptionTemplate, sharedEffects, units, tier, durationSeconds, maxStacks); await (await db.prepare(`INSERT INTO talent_tiers(id,talent_id,tier,description,trigger_text,effect_text,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(talent_id,tier) DO UPDATE SET description=excluded.description,updated_at=excluded.updated_at`)).run(randomUUID(), talentId, tier, description, '', '', now, now); const tierRow = await (await db.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(talentId, tier); diff --git a/src/routes/admin/talents/+page.svelte b/src/routes/admin/talents/+page.svelte index 86b0f90..890f510 100644 --- a/src/routes/admin/talents/+page.svelte +++ b/src/routes/admin/talents/+page.svelte @@ -9,13 +9,16 @@ let talent = $state(null), trigger = $state(null), uptime = $state(false); let handledResultToken = $state(''); let iconResetToken = $state(0); - const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] }); + const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', cooldown_seconds: '', duration_seconds: '', max_stacks: '', 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, + cooldown_seconds: row.cooldown_seconds ?? '', + duration_seconds: row.duration_seconds ?? '', + max_stacks: row.max_stacks ?? '', descriptionTemplate: row.descriptionTemplate || one.description, tierIDescription: one.description, tierIIDescription: two.description, @@ -43,8 +46,12 @@ 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]; + const mechanicValue = (value: string | number, singular: string) => `${value} ${Number(value) === 1 ? singular : `${singular}s`}`; + const cooldownLine = (value: unknown) => value === null || value === undefined || value === '' ? '' : `Talent Cooldown: ${mechanicValue(value as string | number, 'second')}`; + const preview = (tier: 'I' | 'II') => talent.descriptionTemplate.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => { + if (expression === 'duration') return talent.duration_seconds === '' ? placeholder : mechanicValue(talent.duration_seconds, 'second'); + if (expression === 'max_stacks') return talent.max_stacks === '' ? placeholder : mechanicValue(talent.max_stacks, 'stack'); + const effect = talent.effects[Number(expression) - 1]; return effect ? displayValue(effect, tier) : placeholder; }); $effect(() => { @@ -75,7 +82,8 @@
- +
+
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}
{#if item.pending}PENDING · {item.proposerName}{:else}{/if}
{/each}
@@ -88,4 +96,4 @@ {/if} -
TALENTCATEGORYTIER I EFFECTTIER II EFFECTSTATE
{#each data.talents as item}
{#if item.iconUrl}{:else}◇{/if}
{item.name}{item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}
{item.category}

{tierDescription(item.tiers, 'I')}

{tierDescription(item.tiers, 'II')}

ACTIVE{#if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}EDIT WEAPON{:else}{/if}{:else}{/if}
{/each}
+
TALENTCATEGORYTIER I EFFECTTIER II EFFECTSTATE
{#each data.talents as item}
{#if item.iconUrl}{:else}◇{/if}
{item.name}{item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}{#if cooldownLine(item.cooldown_seconds)}{cooldownLine(item.cooldown_seconds)}{/if}
{item.category}

{tierDescription(item.tiers, 'I')}

{tierDescription(item.tiers, 'II')}

ACTIVE{#if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}EDIT WEAPON{:else}{/if}{:else}{/if}
{/each}
diff --git a/src/routes/admin/weapons/+page.server.ts b/src/routes/admin/weapons/+page.server.ts index c72ee9a..4e6d18f 100644 --- a/src/routes/admin/weapons/+page.server.ts +++ b/src/routes/admin/weapons/+page.server.ts @@ -13,9 +13,12 @@ const damageTypes = ['Blast', 'Pierce', 'Shredding']; const rangeNames = ['damage', 'firepower', 'toughness', 'engineering'] as const; type ExoticEffect = { statId: string; valueI: number; valueII: number }; -const renderTalentDescription = (template: string, effects: ExoticEffect[], units: Map, tier: 'I' | 'II') => - template.replace(/\{(\d+)\}/g, (placeholder, rawIndex) => { - const effect = effects[Number(rawIndex) - 1]; +const mechanicValue = (value: number, singular: string) => `${value} ${value === 1 ? singular : `${singular}s`}`; +const renderTalentDescription = (template: string, effects: ExoticEffect[], units: Map, tier: 'I' | 'II', duration: number | null, maxStacks: number | null) => + template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder, expression) => { + if (expression === 'duration') return duration === null ? placeholder : mechanicValue(duration, 'second'); + if (expression === 'max_stacks') return maxStacks === null ? placeholder : mechanicValue(maxStacks, 'stack'); + const effect = effects[Number(expression) - 1]; if (!effect) return placeholder; const value = tier === 'I' ? effect.valueI : effect.valueII; const unit = units.get(effect.statId) === 'flat' ? '' : String(units.get(effect.statId) ?? ''); @@ -53,7 +56,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => { ORDER BY talents.name COLLATE NOCASE`)).all(), (await database.prepare("SELECT id,name,unit 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 talent_description_templates.talent_id,talent_description_templates.template,talents.name + (await database.prepare(`SELECT talent_description_templates.talent_id,talent_description_templates.template,talents.name, + talents.cooldown_seconds,talents.duration_seconds,talents.max_stacks FROM talent_description_templates JOIN talents ON talents.id=talent_description_templates.talent_id`)).all(), (await database.prepare(`SELECT talent_tiers.id,talent_tiers.talent_id,talent_tiers.tier,talent_tiers.description FROM talent_tiers JOIN talents ON talents.id=talent_tiers.talent_id WHERE talents.category='Weapon'`)).all(), @@ -82,6 +86,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => { id: talentId, iconUrl: (() => { const icon = talentIcons.find((item) => item.entity_id === talentId); return icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : ''; })(), name: String(exoticTemplates.find((item) => item.talent_id === talentId)?.name ?? ''), + cooldown_seconds: exoticTemplates.find((item) => item.talent_id === talentId)?.cooldown_seconds ?? '', + duration_seconds: exoticTemplates.find((item) => item.talent_id === talentId)?.duration_seconds ?? '', + max_stacks: exoticTemplates.find((item) => item.talent_id === talentId)?.max_stacks ?? '', descriptionTemplate: String(exoticTemplates.find((item) => item.talent_id === talentId)?.template ?? ''), triggerIds: [...new Set([ ...exoticTierTriggers.filter((link) => link.talent_tier_id === tierI?.id), @@ -109,6 +116,11 @@ function number(form: FormData, name: string): number { return Number(form.get(name)); } +function optionalPositiveNumber(form: FormData, name: string): number | null { + const raw = String(form.get(name) ?? '').trim(); + return raw ? Number(raw) : null; +} + function readRange(form: FormData, tier: number, range: (typeof rangeNames)[number]) { const min = number(form, `tier${tier}${range}Min`); const max = number(form, `tier${tier}${range}Max`); @@ -141,6 +153,9 @@ export const actions: Actions = { const existingExoticTalentId = isExotic ? String(form.get('exoticTalentId') ?? '') : ''; const exoticTalentName = isExotic ? String(form.get('exoticTalentName') ?? '').normalize('NFKC').trim() : ''; const exoticDescriptionTemplate = isExotic ? String(form.get('exoticDescriptionTemplate') ?? '').trim() : ''; + const exoticCooldownSeconds = isExotic ? optionalPositiveNumber(form, 'exoticCooldownSeconds') : null; + const exoticDurationSeconds = isExotic ? optionalPositiveNumber(form, 'exoticDurationSeconds') : null; + const exoticMaxStacks = isExotic ? optionalPositiveNumber(form, 'exoticMaxStacks') : null; const exoticTriggerIds = isExotic ? [...new Set(form.getAll('exoticTriggerIds').map(String))] : []; let exoticEffects: ExoticEffect[] = []; try { @@ -168,6 +183,12 @@ export const actions: Actions = { if (isExotic && (exoticTalentName.length < 2 || !exoticDescriptionTemplate || !exoticTriggerIds.length || !exoticEffects.length)) { return fail(400, { weaponError: 'Exotic weapons require a named talent, description template, trigger, and at least one effect.' }); } + if (isExotic && [exoticCooldownSeconds, exoticDurationSeconds].some((value) => value !== null && (!Number.isFinite(value) || value <= 0))) { + return fail(400, { weaponError: 'Exotic talent cooldown and duration must be positive numbers when defined.' }); + } + if (isExotic && exoticMaxStacks !== null && (!Number.isInteger(exoticMaxStacks) || exoticMaxStacks <= 0)) { + return fail(400, { weaponError: 'Exotic talent maximum stacks must be a positive whole number when defined.' }); + } if (isExotic && (exoticEffects.some((effect) => !effect.statId || !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII)) || new Set(exoticEffects.map((effect) => effect.statId)).size !== exoticEffects.length)) { return fail(400, { weaponError: 'Every exotic talent effect requires one unique attribute and valid Tier I and Tier II values.' }); @@ -175,6 +196,8 @@ export const actions: Actions = { if (isExotic) { const placeholders = [...exoticDescriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1])); if (placeholders.some((index) => index < 1 || index > exoticEffects.length)) return fail(400, { weaponError: 'The exotic talent description references a missing effect.' }); + if (exoticDescriptionTemplate.includes('{duration}') && exoticDurationSeconds === null) return fail(400, { weaponError: 'The exotic talent description uses {duration}, but no duration is defined.' }); + if (exoticDescriptionTemplate.includes('{max_stacks}') && exoticMaxStacks === null) return fail(400, { weaponError: 'The exotic talent description uses {max_stacks}, but no maximum stack count is defined.' }); } const tiers = []; @@ -268,9 +291,9 @@ export const actions: Actions = { } : null; if (isExotic && exoticTalentId) { if (existingExoticTalentId) { - await (await database.prepare("UPDATE talents SET name=?,category='Weapon',active=1,updated_at=? WHERE id=?")).run(exoticTalentName, now, exoticTalentId); + await (await database.prepare("UPDATE talents SET name=?,category='Weapon',cooldown_seconds=?,duration_seconds=?,max_stacks=?,active=1,updated_at=? WHERE id=?")).run(exoticTalentName, exoticCooldownSeconds, exoticDurationSeconds, exoticMaxStacks, now, exoticTalentId); } else { - await (await database.prepare("INSERT INTO talents(id,name,category,active,created_at,updated_at) VALUES(?,?,'Weapon',1,?,?)")).run(exoticTalentId, exoticTalentName, now, now); + await (await database.prepare("INSERT INTO talents(id,name,category,cooldown_seconds,duration_seconds,max_stacks,active,created_at,updated_at) VALUES(?,?,'Weapon',?,?,?,1,?,?)")).run(exoticTalentId, exoticTalentName, exoticCooldownSeconds, exoticDurationSeconds, exoticMaxStacks, now, now); } await (await database.prepare(`INSERT INTO talent_weapon_scopes(talent_id,scope) VALUES(?,'Exotic') ON CONFLICT(talent_id) DO UPDATE SET scope='Exotic'`)).run(exoticTalentId); @@ -280,7 +303,7 @@ export const actions: Actions = { const unitRows = await (await database.prepare(`SELECT id,unit FROM stat_attributes WHERE id IN (${exoticEffects.map(() => '?').join(',')})`)).all(...exoticEffects.map((effect) => effect.statId)); const units = new Map(unitRows.map((row) => [String(row.id), row.unit])); for (const talentTier of ['I', 'II'] as const) { - const description = renderTalentDescription(exoticDescriptionTemplate, exoticEffects, units, talentTier); + const description = renderTalentDescription(exoticDescriptionTemplate, exoticEffects, units, talentTier, exoticDurationSeconds, exoticMaxStacks); await (await database.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(), exoticTalentId, talentTier, description, '', '', now, now); @@ -330,7 +353,7 @@ export const actions: Actions = { await (await database.prepare('INSERT INTO weapon_base_attribute_bonuses(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)')) .run(randomUUID(), weaponId, effect.statId, effect.value, effect.order); } - await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, talent1Ids, talent2Ids, augmentEffects, baseBonuses, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), descriptionTemplate: exoticDescriptionTemplate, triggerIds: exoticTriggerIds, effects: exoticEffects } : null } }); + await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, talent1Ids, talent2Ids, augmentEffects, baseBonuses, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), cooldownSeconds: exoticCooldownSeconds, durationSeconds: exoticDurationSeconds, maxStacks: exoticMaxStacks, descriptionTemplate: exoticDescriptionTemplate, triggerIds: exoticTriggerIds, effects: exoticEffects } : null } }); return { weaponSaved: true, weaponMode: id ? 'edit' : 'add', resultToken: randomUUID() }; }, archive: async ({ request, cookies }) => { diff --git a/src/routes/admin/weapons/+page.svelte b/src/routes/admin/weapons/+page.svelte index 5bbace8..ba5ce73 100644 --- a/src/routes/admin/weapons/+page.svelte +++ b/src/routes/admin/weapons/+page.svelte @@ -12,7 +12,7 @@ const ranges = [['damage', 'DMG', 'DAMAGE'], ['firepower', 'FP', 'FIREPOWER'], ['toughness', 'TGH', 'TOUGHNESS'], ['engineering', 'ENG', 'ENGINEERING']] as const; const iconName = (value: string): GameIconName => value.toLowerCase() as GameIconName; const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0, firepower_min: 0, firepower_max: 0, toughness_min: 0, toughness_max: 0, engineering_min: 0, engineering_max: 0 }); - const blankExoticTalent = () => ({ id: '', name: '', iconUrl: '', descriptionTemplate: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] }); + const blankExoticTalent = () => ({ id: '', name: '', iconUrl: '', cooldown_seconds: '', duration_seconds: '', max_stacks: '', descriptionTemplate: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] }); const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: 0, damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', exoticTalent: blankExoticTalent(), active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }], baseBonuses: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] }); let editing = $state(null); let handledResultToken = $state(''); @@ -40,8 +40,11 @@ const removeExoticEffect = (index: number) => { if (editing?.exoticTalent?.effects.length > 1) editing.exoticTalent.effects = editing.exoticTalent.effects.filter((_: any, effectIndex: number) => effectIndex !== index); }; - const exoticPreview = (tier: 'I' | 'II') => editing.exoticTalent.descriptionTemplate.replace(/\{(\d+)\}/g, (placeholder: string, rawIndex: string) => { - const effect = editing.exoticTalent.effects[Number(rawIndex) - 1]; + const mechanicValue = (value: string | number, singular: string) => `${value} ${Number(value) === 1 ? singular : `${singular}s`}`; + const exoticPreview = (tier: 'I' | 'II') => editing.exoticTalent.descriptionTemplate.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => { + if (expression === 'duration') return editing.exoticTalent.duration_seconds === '' ? placeholder : mechanicValue(editing.exoticTalent.duration_seconds, 'second'); + if (expression === 'max_stacks') return editing.exoticTalent.max_stacks === '' ? placeholder : mechanicValue(editing.exoticTalent.max_stacks, 'stack'); + const effect = editing.exoticTalent.effects[Number(expression) - 1]; if (!effect) return placeholder; const stat = data.stats.find((item) => item.id === effect.statId); const value = tier === 'I' ? effect.valueI : effect.valueII; @@ -95,7 +98,9 @@
DEDICATED EXOTIC TALENTOWNED EXCLUSIVELY BY THIS WEAPON · TIER II VALUES REQUIRED
{editing.exoticTalent.id ? 'LINKED RECORD' : 'NEW RECORD'}
-
+
+
+

DESCRIPTION EXPRESSIONS · {'{duration}'} and {'{max_stacks}'} render the defined mechanics. Cooldown is displayed separately as a standardized Talent Cooldown line.

TIER I PREVIEW

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

TIER II PREVIEW

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

ACTIVATION TRIGGERSSHARED BETWEEN BOTH TIERS
{editing.exoticTalent.triggerIds.length}
diff --git a/src/routes/builder/+page.svelte b/src/routes/builder/+page.svelte index a27cc05..4bd9674 100644 --- a/src/routes/builder/+page.svelte +++ b/src/routes/builder/+page.svelte @@ -92,6 +92,11 @@ for (const key of ['damage', 'firepower', 'toughness', 'engineering'] as const) selection[key] = Number(range?.[`${key}_min`] ?? 0); }; const activeParts = $derived(active.split(':')); + const talentCooldown = (id: string) => { + const value = talent(id)?.cooldown_seconds; + if (value === null || value === undefined || value === '') return ''; + return `Talent Cooldown: ${value} ${Number(value) === 1 ? 'second' : 'seconds'}`; + }; const summary = $derived.by(() => { const values = new Map(); const sources = new Map>(); @@ -269,7 +274,7 @@
COMBINED ATTRIBUTES{summary.stats.length}
{#each summary.stats as stat}
{attribute(stat.id)?.name}{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}{unitValue(stat.value,stat.id)}
{:else}

Equip and configure items to calculate attributes.

{/each}
ACTIVE SET BONUSES{summary.activeBonuses.length}
{#each summary.activeBonuses as bonus}
{bonus.name}{bonus.pieces}-PIECE · {bonus.count} EQUIPPED+{unitValue(bonus.value,bonus.statId)} {attribute(bonus.statId)?.name}
{:else}

Matching Brand Sets and Skill Chips activate bonuses here.

{/each}
-
EQUIPPED TALENTS{summary.equippedTalents.length}
{#each summary.equippedTalents as equipped}{:else}

Selected equipment talents appear here.

{/each}
+
EQUIPPED TALENTS{summary.equippedTalents.length}
{#each summary.equippedTalents as equipped}{:else}

Selected equipment talents appear here.

{/each}

Talent effects enter totals only while marked trigger active. Anonymous drafts remain on this device.