Add structured talent mechanics
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<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));
|
||||
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);
|
||||
|
||||
@@ -9,13 +9,16 @@
|
||||
let talent = $state<any>(null), trigger = $state<any>(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 @@
|
||||
<PendingForm action="?/saveTalent" enctype="multipart/form-data">
|
||||
<input type="hidden" name="id" value={talent.id}/><input type="hidden" name="effects" value={JSON.stringify(talent.effects)}/>
|
||||
<div class="talent-icon-identity"><IconUploadField currentUrl={talent.iconUrl} label="TALENT ICON" resetToken={iconResetToken}/><div class="form-grid talent-identity"><label class="field">NAME<input name="name" bind:value={talent.name} required/></label><label class="field">CATEGORY / SLOT<select name="category" bind:value={talent.category}><option value="Weapon · Slot Unassigned" disabled>Weapon · Choose a slot</option><option>Weapon Talent 1</option><option>Weapon Talent 2</option><option>Backpack</option><option>Body Armor</option></select></label></div></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="talent-mechanics-grid"><label class="field">COOLDOWN · SECONDS<input name="cooldownSeconds" type="number" min="0.01" step="any" bind:value={talent.cooldown_seconds} placeholder="NOT DEFINED"/></label><label class="field">DURATION · SECONDS<input name="durationSeconds" type="number" min="0.01" step="any" bind:value={talent.duration_seconds} placeholder="NOT DEFINED"/></label><label class="field">MAX STACKS<input name="maxStacks" type="number" min="1" step="1" bind:value={talent.max_stacks} placeholder="NOT DEFINED"/></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} for {duration}...'} required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values. Use {'{duration}'} and {'{max_stacks}'} for defined mechanics. Cooldown is displayed separately.</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 class:pending-reference={item.pending}><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>{#if item.pending}<b class="pending-reference-label">PENDING · {item.proposerName}</b>{:else}<button type="button" onclick={() => editTrigger(item)}>EDIT</button>{/if}</div>{/each}</div>
|
||||
@@ -88,4 +96,4 @@
|
||||
{/if}
|
||||
|
||||
<RegistryPagination pagination={data.pagination}/>
|
||||
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I EFFECT</span><span>TIER II EFFECT</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row"><div class="registry-icon-identity">{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{item.name}</strong><small>{item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small></div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class="state-dot">ACTIVE</b>{#if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/admin/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{/each}</div>
|
||||
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I EFFECT</span><span>TIER II EFFECT</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row"><div class="registry-icon-identity">{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{item.name}</strong><small>{item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class="state-dot">ACTIVE</b>{#if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/admin/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{/each}</div>
|
||||
|
||||
@@ -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<string, unknown>, 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<string, unknown>, 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 }) => {
|
||||
|
||||
@@ -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<any | null>(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 @@
|
||||
<input type="hidden" name="exoticEffects" value={JSON.stringify(editing.exoticTalent.effects)}/>
|
||||
<section class="weapon-exotic-talent">
|
||||
<div class="selection-head"><div><span>DEDICATED EXOTIC TALENT</span><small>OWNED EXCLUSIVELY BY THIS WEAPON · TIER II VALUES REQUIRED</small></div><b>{editing.exoticTalent.id ? 'LINKED RECORD' : 'NEW RECORD'}</b></div>
|
||||
<div class="exotic-icon-identity"><IconUploadField currentUrl={editing.exoticTalent.iconUrl} label="EXOTIC TALENT ICON" resetToken={iconResetToken}/><div class="form-grid exotic-talent-identity"><label class="field">TALENT NAME<input name="exoticTalentName" bind:value={editing.exoticTalent.name} maxlength="80" required/></label><label class="field">SHARED DESCRIPTION TEMPLATE<textarea name="exoticDescriptionTemplate" bind:value={editing.exoticTalent.descriptionTemplate} placeholder="Hits grant +{1} damage for {2} seconds…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values and units.</small></label></div></div>
|
||||
<div class="exotic-icon-identity"><IconUploadField currentUrl={editing.exoticTalent.iconUrl} label="EXOTIC TALENT ICON" resetToken={iconResetToken}/><div class="form-grid exotic-talent-identity"><label class="field">TALENT NAME<input name="exoticTalentName" bind:value={editing.exoticTalent.name} maxlength="80" required/></label><label class="field">SHARED DESCRIPTION TEMPLATE<textarea name="exoticDescriptionTemplate" bind:value={editing.exoticTalent.descriptionTemplate} placeholder={'Hits grant +{1} for {duration}…'} required></textarea><small>Use numbered expressions for effect values and named expressions for duration and stacks.</small></label></div></div>
|
||||
<div class="talent-mechanics-grid"><label class="field">COOLDOWN · SECONDS<input name="exoticCooldownSeconds" type="number" min="0.01" step="any" bind:value={editing.exoticTalent.cooldown_seconds} placeholder="NOT DEFINED"/></label><label class="field">DURATION · SECONDS<input name="exoticDurationSeconds" type="number" min="0.01" step="any" bind:value={editing.exoticTalent.duration_seconds} placeholder="NOT DEFINED"/></label><label class="field">MAX STACKS<input name="exoticMaxStacks" type="number" min="1" step="1" bind:value={editing.exoticTalent.max_stacks} placeholder="NOT DEFINED"/></label></div>
|
||||
<p class="talent-template-note">DESCRIPTION EXPRESSIONS · {'{duration}'} and {'{max_stacks}'} render the defined mechanics. Cooldown is displayed separately as a standardized Talent Cooldown line.</p>
|
||||
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{exoticPreview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{exoticPreview('II') || 'Description preview'}</p></div></div>
|
||||
<div class="weapon-exotic-grid">
|
||||
<div><div class="selection-head"><div><span>ACTIVATION TRIGGERS</span><small>SHARED BETWEEN BOTH TIERS</small></div><b>{editing.exoticTalent.triggerIds.length}</b></div><SearchableAssignment items={data.triggers} fieldName="exoticTriggerIds" bind:selected={editing.exoticTalent.triggerIds}/></div>
|
||||
|
||||
@@ -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<string, number>();
|
||||
const sources = new Map<string, Set<string>>();
|
||||
@@ -269,7 +274,7 @@
|
||||
<div class="summary-scroll">
|
||||
<div class="summary-section"><header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header>{#each summary.stats as stat}<div class="summary-stat"><span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span><b>{unitValue(stat.value,stat.id)}</b></div>{:else}<p>Equip and configure items to calculate attributes.</p>{/each}</div>
|
||||
<div class="summary-section"><header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>{#each summary.activeBonuses as bonus}<div class="summary-bonus"><span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span><em>+{unitValue(bonus.value,bonus.statId)} {attribute(bonus.statId)?.name}</em></div>{:else}<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>{/each}</div>
|
||||
<div class="summary-section"><header><span>EQUIPPED TALENTS</span><b>{summary.equippedTalents.length}</b></header>{#each summary.equippedTalents as equipped}<label class="summary-talent"><input type="checkbox" checked={draft.activeTalents.includes(equipped.key)} onchange={() => toggleTalent(equipped.key)}/><span><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source} · {draft.activeTalents.includes(equipped.key) ? 'TRIGGER ACTIVE' : 'CONDITIONAL'}</small><em>{equipped.description}</em></span></label>{:else}<p>Selected equipment talents appear here.</p>{/each}</div>
|
||||
<div class="summary-section"><header><span>EQUIPPED TALENTS</span><b>{summary.equippedTalents.length}</b></header>{#each summary.equippedTalents as equipped}<label class="summary-talent"><input type="checkbox" checked={draft.activeTalents.includes(equipped.key)} onchange={() => toggleTalent(equipped.key)}/><span><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source} · {draft.activeTalents.includes(equipped.key) ? 'TRIGGER ACTIVE' : 'CONDITIONAL'}</small><em>{equipped.description}</em>{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}</span></label>{:else}<p>Selected equipment talents appear here.</p>{/each}</div>
|
||||
</div>
|
||||
<p class="notice">Talent effects enter totals only while marked trigger active. Anonymous drafts remain on this device.</p>
|
||||
</aside>
|
||||
|
||||
Reference in New Issue
Block a user