172 lines
12 KiB
Svelte
172 lines
12 KiB
Svelte
<script lang="ts">
|
||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
|
||
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
|
||
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
||
import { tick } from 'svelte';
|
||
let { data, form } = $props();
|
||
const categoryOptions = ['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].map((value) => ({ value, label: value }));
|
||
let talent = $state<any>(null);
|
||
let handledResultToken = $state('');
|
||
let iconResetToken = $state(0);
|
||
let validationSummary = $state<HTMLElement | null>(null);
|
||
type TalentErrorArea = 'icon' | 'identity' | 'description' | 'mechanic' | 'outcomes';
|
||
type TalentValidationError = { area: TalentErrorArea; message: string };
|
||
const actionForm = $derived(form as Record<string, any> | null | undefined);
|
||
const talentErrors = $derived(
|
||
Array.isArray(actionForm?.talentErrors)
|
||
? actionForm.talentErrors as TalentValidationError[]
|
||
: actionForm?.talentError
|
||
? [{ area: 'mechanic', message: String(actionForm.talentError) }] as TalentValidationError[]
|
||
: []
|
||
);
|
||
const errorsFor = (area: TalentErrorArea) => talentErrors.filter((error) => error.area === area);
|
||
const hasErrors = (areas: TalentErrorArea[]) => areas.some((area) => errorsFor(area).length > 0);
|
||
const areaLabel = (area: TalentErrorArea) => ({
|
||
icon: 'Talent icon',
|
||
identity: 'Talent identity',
|
||
description: 'Description template',
|
||
mechanic: 'Mechanic program',
|
||
outcomes: 'Mechanic outcomes'
|
||
}[area]);
|
||
const areaTarget = (area: TalentErrorArea) =>
|
||
area === 'outcomes' ? 'talent-mechanic' : `talent-${area}`;
|
||
const blankRule = (): ConditionalMechanicDraftV1 => ({
|
||
version: 1,
|
||
simulationReadiness: 'captured_only',
|
||
scenarioInputs: [],
|
||
pools: [],
|
||
clauses: [{
|
||
key: 'primary',
|
||
label: 'Primary',
|
||
event: 'weapon_hit',
|
||
if: { kind: 'true' },
|
||
reducer: { kind: 'none' },
|
||
mutations: [],
|
||
outcomes: [{
|
||
key: 'effect-1',
|
||
label: 'Effect 1',
|
||
phase: 'while_active',
|
||
kind: 'attribute_modifier',
|
||
scope: 'equipped_weapon',
|
||
attributeId: '',
|
||
basis: 'flat',
|
||
perStack: false,
|
||
descriptionSlot: 1,
|
||
variants: { I: { value: 0 }, II: { value: 0 } }
|
||
}]
|
||
}]
|
||
});
|
||
const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', conditionalRule: blankRule() });
|
||
const editTalent = (row: any) => {
|
||
const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', effects: [] };
|
||
const one = tier('I'), two = tier('II');
|
||
talent = {
|
||
...row,
|
||
descriptionTemplate: row.descriptionTemplate || one.description,
|
||
tierIDescription: one.description,
|
||
tierIIDescription: two.description,
|
||
conditionalRule: row.conditionalRule ?? blankRule()
|
||
};
|
||
};
|
||
const configured = (tiers: Array<{ tier: unknown }>, name: string) => tiers.some((item) => item.tier === name);
|
||
const tierDescription = (tiers: Array<{ tier: unknown; description?: unknown }>, name: string) => {
|
||
const description = tiers.find((item) => item.tier === name)?.description;
|
||
return typeof description === 'string' && description.trim() ? description : 'NOT CONFIGURED';
|
||
};
|
||
const displayValue = (effect: any, tier: 'I' | 'II') => {
|
||
const stat = data.stats.find((item) => item.id === effect.attributeId);
|
||
const value = effect.variants?.[tier]?.value ?? '';
|
||
const unit = stat?.unit === 'flat' ? '' : stat?.unit ?? '';
|
||
return unit === 'Shots' ? `${value} ${unit}` : `${value}${unit}`;
|
||
};
|
||
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) => {
|
||
const duration = talent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds;
|
||
const maxStacks = talent.conditionalRule.pools.length ? Math.max(...talent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null;
|
||
if (expression === 'duration') return duration == null ? placeholder : mechanicValue(duration, 'second');
|
||
if (expression === 'max_stacks') return maxStacks == null ? placeholder : mechanicValue(maxStacks, 'stack');
|
||
const effect = talent.conditionalRule.clauses
|
||
.flatMap((clause: any) => clause.outcomes)
|
||
.find((outcome: any) => outcome.descriptionSlot === Number(expression));
|
||
return effect ? displayValue(effect, tier) : placeholder;
|
||
});
|
||
$effect(() => {
|
||
if (!form?.resultToken || form.resultToken === handledResultToken) return;
|
||
handledResultToken = form.resultToken;
|
||
if (form.talentSaved) {
|
||
if (form.talentMode === 'add') iconResetToken += 1;
|
||
talent = form.talentMode === 'add' ? blankTalent() : null;
|
||
} else if (talentErrors.length) {
|
||
void tick().then(() => validationSummary?.focus());
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<svelte:head><title>Talents — SHD.build admin</title></svelte:head>
|
||
<div class="module-toolbar"><div><p class="eyebrow">TALENT REGISTRY</p><h2>TALENTS</h2><p>Tier-specific Talent values and source-owned combat mechanics.</p></div><div><a class="secondary" href="/isac/mechanic-reference">MECHANIC REFERENCE →</a><button class="primary" onclick={() => talent = blankTalent()}>ADD TALENT +</button></div></div>
|
||
<KpiFilters className="talent-kpis" active={data.filter} items={[{value:'',label:'TALENTS',count:data.summary.total,description:'Show all definitions'},{value:'slot1',label:'TOP / TALENT 1',count:data.summary.slot1,description:'First-slot weapon talents'},{value:'slot2',label:'BOTTOM / TALENT 2',count:data.summary.slot2,description:'Second-slot weapon talents'},{value:'exotic',label:'EXOTIC',count:data.summary.exotic,description:'Exotic-only talents'},{value:'backpack',label:'BACKPACK',count:data.summary.backpack,description:'Backpack talents'},{value:'body-armor',label:'BODY ARMOR',count:data.summary.bodyArmor,description:'Body armor talents'}]}/>
|
||
|
||
{#snippet talentEditor()}
|
||
{#if talent}
|
||
<div class="dashboard-editor">
|
||
<div class="editor-title"><div><p class="eyebrow">{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}</p><h3>{talent.id ? talent.name : 'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={() => talent = null}>×</button></div>
|
||
<PendingForm action="?/saveTalent" enctype="multipart/form-data">
|
||
<input type="hidden" name="id" value={talent.id}/><input type="hidden" name="conditionalRule" value={JSON.stringify(talent.conditionalRule)}/>
|
||
{#if talentErrors.length}
|
||
<div class="validation-summary" role="alert" tabindex="-1" bind:this={validationSummary}>
|
||
<strong>CHECK {new Set(talentErrors.map((error) => error.area)).size} HIGHLIGHTED {new Set(talentErrors.map((error) => error.area)).size === 1 ? 'AREA' : 'AREAS'}</strong>
|
||
<ul>
|
||
{#each talentErrors as error}
|
||
<li><a href={`#${areaTarget(error.area)}`}><span>{areaLabel(error.area)}</span>{error.message}</a></li>
|
||
{/each}
|
||
</ul>
|
||
</div>
|
||
{/if}
|
||
<div class="talent-icon-identity">
|
||
<div id="talent-icon" class:validation-area-error={hasErrors(['icon'])}>
|
||
<IconUploadField currentUrl={talent.iconUrl} label="TALENT ICON" resetToken={iconResetToken}/>
|
||
{#each errorsFor('icon') as error}<small class="field-error">{error.message}</small>{/each}
|
||
</div>
|
||
<div id="talent-identity" class="talent-identity" class:validation-area-error={hasErrors(['identity'])}>
|
||
<label class="field">NAME<input name="name" bind:value={talent.name} required aria-invalid={hasErrors(['identity']) ? 'true' : undefined}/></label>
|
||
<SegmentedChoice legend="CATEGORY / SLOT" name="category" options={categoryOptions} bind:value={talent.category} required/>
|
||
{#each errorsFor('identity') as error}<small class="field-error">{error.message}</small>{/each}
|
||
</div>
|
||
</div>
|
||
<label id="talent-description" class="field template-field" class:validation-area-error={hasErrors(['description'])}>SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={talent.descriptionTemplate} placeholder={'Hitting an enemy {1} times grants +{2} for {duration}...'} required aria-invalid={hasErrors(['description']) ? 'true' : undefined}></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values. Use {'{duration}'} and {'{max_stacks}'} for defined mechanics. Cooldown is displayed separately.</small>{#each errorsFor('description') as error}<small class="field-error">{error.message}</small>{/each}</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>
|
||
<div id="talent-mechanic" class:validation-area-error={hasErrors(['mechanic', 'outcomes'])}>
|
||
<MechanicProgramEditor
|
||
attributes={data.stats}
|
||
valueColumns={[{key:'tierI',variantKey:'I',label:'TIER I'},{key:'tierII',variantKey:'II',label:'TIER II'}]}
|
||
idPrefix={`talent-${talent.id || 'new'}`}
|
||
aiContext={{
|
||
sourceKind: 'talent',
|
||
sourceName: talent?.name ?? '',
|
||
sourceDescription: talent?.descriptionTemplate ?? ''
|
||
}}
|
||
bind:rule={talent.conditionalRule}
|
||
/>
|
||
{#each [...errorsFor('mechanic'), ...errorsFor('outcomes')] as error}<small class="field-error mechanic-field-error">{error.message}</small>{/each}
|
||
</div>
|
||
<div class="form-actions"><button type="button" class="secondary" onclick={() => talent = null}>CANCEL</button><button class="primary">SAVE TALENT →</button></div>
|
||
</PendingForm>
|
||
</div>
|
||
{/if}
|
||
{/snippet}
|
||
|
||
{#if talent && !talent.id}
|
||
{@render talentEditor()}
|
||
{/if}
|
||
|
||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><SortableColumnHeader label="TALENT" column="name" sort={data.sort}/><SortableColumnHeader label="CATEGORY" column="category" sort={data.sort}/><SortableColumnHeader label="TIER I EFFECT" column="tier1" sort={data.sort}/><SortableColumnHeader label="TIER II EFFECT" column="tier2" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row" class:pending-record={item.pending} class:registry-row-editing={String(talent?.id ?? '') === String(item.id)}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 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:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : item.conditionalRule?.clauses?.length ? 'MODELED' : 'CONFIG REQUIRED'}</b>{#if item.pending}<span></span>{:else if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/isac/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button aria-expanded={String(talent?.id ?? '') === String(item.id)} aria-controls={`talent-editor-${item.id}`} onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{#if String(talent?.id ?? '') === String(item.id)}<RegistryInlineEditor id={`talent-editor-${item.id}`} label={`Edit ${item.name}`}>{@render talentEditor()}</RegistryInlineEditor>{/if}{/each}</div>
|