feat(isac): add specialization data management

This commit is contained in:
JB
2026-07-26 16:20:04 -04:00
parent ed31748102
commit 6722665f5c
25 changed files with 3369 additions and 25 deletions
+4 -2
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
import { MISSING_ITEM_ICON_URL } from '$lib/item-icons';
let {
currentUrl = '',
label = 'ICON',
@@ -9,7 +11,7 @@
<fieldset class="icon-upload-field">
<legend>{label}</legend>
<ImageUploadInput name="icon" accept="image/svg+xml,image/png,image/webp,image/avif,image/jpeg" {currentUrl} placeholderUrl="/missing-item-icon.svg" {resetToken} emptyLabel="ICON AWAITING CONTRIBUTION" browseLabel={currentUrl ? 'REPLACE ICON' : 'ADD ICON'}/>
<ImageUploadInput name="icon" accept="image/svg+xml,image/png,image/webp,image/avif,image/jpeg" {currentUrl} placeholderUrl={MISSING_ITEM_ICON_URL} {resetToken} emptyLabel="ICON AWAITING CONTRIBUTION" browseLabel={currentUrl ? 'REPLACE ICON' : 'ADD ICON'}/>
{#if currentUrl}<label class="icon-remove"><input name="removeIcon" type="checkbox" value="1"/><span>REMOVE CURRENT ICON</span></label>{/if}
<small>OPTIONAL · SVG, PNG, WEBP, AVIF, OR JPEG · OPAQUE BACKGROUNDS REMOVED WHEN NEEDED · 2 MiB MAX</small>
<small>OPTIONAL · SAVE NOW, ADD AN ICON LATER · SVG, PNG, WEBP, AVIF, OR JPEG · BACKGROUNDS REMOVED WHEN NEEDED · 2 MiB MAX</small>
</fieldset>
+5 -1
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { MISSING_ITEM_ICON_URL } from '$lib/item-icons';
let {
src = '',
alt = '',
@@ -8,6 +10,8 @@
alt?: string;
className?: string;
} = $props();
const resolvedAlt = $derived(src || !alt ? alt : `${alt} — icon awaiting contribution`);
</script>
<img src={src || '/missing-item-icon.svg'} {alt} class:missing-item-icon={!src} class={className}/>
<img src={src || MISSING_ITEM_ICON_URL} alt={resolvedAlt} class:missing-item-icon={!src} class={className}/>
@@ -35,6 +35,70 @@
};
return proposed[names[key] ?? key];
};
const specializationKind = $derived(
action === 'saveProgression' ? 'PROGRESSION PROFILE'
: action === 'saveSpecialization' ? 'SPECIALIZATION'
: action === 'saveFocus' ? 'FOCUS'
: action === 'saveAbility' ? 'CLASS ABILITY'
: action === 'saveFocusAbilities' ? 'FOCUS ABILITY SET'
: 'TREE NODE'
);
const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const specializationFields = $derived(
action === 'saveProgression'
? [
{ label: 'XP POINTS', current: specializationRecord?.xp_points, proposed: proposed.xpPoints },
{ label: 'SHD TECH CACHE POINTS', current: specializationRecord?.shd_cache_points, proposed: proposed.cachePoints }
]
: action === 'saveSpecialization'
? [
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
]
: action === 'saveFocus'
? [
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
{ label: 'PRIMARY ATTRIBUTE', current: specializationRecord?.primary_attribute, proposed: proposed.primaryAttribute },
{ label: 'DISPLAY ORDER', current: specializationRecord?.display_order, proposed: proposed.displayOrder },
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
]
: action === 'saveAbility'
? [
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
{ label: 'DURATION · SECONDS', current: specializationRecord?.duration_seconds, proposed: proposed.durationSeconds },
{ label: 'COOLDOWN · SECONDS', current: specializationRecord?.cooldown_seconds, proposed: proposed.cooldownSeconds },
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
]
: action === 'saveNode'
? [
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
{ label: 'FOCUS', current: specializationRecord?.focus_id, proposed: proposed.focusId },
{ label: 'ROW', current: specializationRecord?.row_number, proposed: proposed.rowNumber },
{ label: 'COLUMN', current: specializationRecord?.column_number, proposed: proposed.columnNumber },
{ label: 'MAXIMUM RANK', current: specializationRecord?.max_rank, proposed: proposed.maxRank },
{ label: 'DESCRIPTION TEMPLATE', current: specializationRecord?.description_template, proposed: proposed.descriptionTemplate }
]
: [
{ label: 'FOCUS', current: specializationRecord?.name ?? specializationRecord?.id, proposed: proposed.id },
{ label: 'ABILITY SLOT 1 · TREE', current: (currentSnapshot?.abilities as any[])?.find((item) => Number(item.ability_slot) === 1)?.ability_id, proposed: proposed.abilityId1 },
{ label: 'ABILITY SLOT 2 · COMBAT SKILLS', current: (currentSnapshot?.abilities as any[])?.find((item) => Number(item.ability_slot) === 2)?.ability_id, proposed: proposed.abilityId2 },
{ label: 'ABILITY SLOT 3 · TREE', current: (currentSnapshot?.abilities as any[])?.find((item) => Number(item.ability_slot) === 3)?.ability_id, proposed: proposed.abilityId3 }
]
);
const proposedEffects = $derived((jsonValue(proposed.effectsJson) ?? []) as any[]);
const proposedRanks = $derived((jsonValue(proposed.ranksJson) ?? []) as any[]);
const currentAbilityEffects = $derived((currentSnapshot?.effects ?? []) as any[]);
const currentNodeRanks = $derived((currentSnapshot?.ranks ?? []) as any[]);
const currentNodeEffects = $derived((currentSnapshot?.effects ?? []) as any[]);
const currentNodeCosts = $derived((currentSnapshot?.costs ?? []) as any[]);
const currentRankProfiles = $derived((currentSnapshot?.rankProfiles ?? []) as any[]);
const effectSummary = (effect: any) => {
const target = effect.statId || effect.stat_attribute_id || effect.abilityId || effect.ability_id || effect.label || 'CUSTOM EFFECT';
const value = effect.value === null || effect.value === undefined || effect.value === '' ? '' : `${effect.value}${effect.unit ? ` ${effect.unit}` : ''}`;
return `${target}${value ? ` · ${value}` : ''}`;
};
</script>
<section class="proposal-change-preview" aria-label="Proposed data comparison">
@@ -50,6 +114,7 @@
{#if currentSnapshot}
<div class="proposal-attribute-grid">
<div class="preview-field identity"><span>NAME</span><strong>{shown(attributeValue('current', 'name'))}</strong></div>
<div class="preview-field description"><span>DESCRIPTION · OPTIONAL</span><strong>{shown(attributeValue('current', 'description'))}</strong></div>
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('current', field[0]))}</strong></div>
{/each}
@@ -66,6 +131,7 @@
<header><span>PROPOSED DEFINITION</span><b>AFTER</b></header>
<div class="proposal-attribute-grid">
<div class="preview-field identity"><span>NAME</span><strong>{shown(attributeValue('proposed', 'name'))}</strong></div>
<div class="preview-field description"><span>DESCRIPTION · OPTIONAL</span><strong>{shown(attributeValue('proposed', 'description'))}</strong></div>
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('proposed', field[0]))}</strong></div>
{/each}
@@ -76,6 +142,70 @@
</div>
</article>
</div>
{:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveFocusAbilities','saveNode'].includes(action)}
<div class="proposal-compare-grid specialization-proposal-preview">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · {specializationKind}</span><b>{specializationRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
{#if specializationRecord}
<div class="proposal-field-summary specialization-fields">
{#each specializationFields as field}
<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>
{/each}
</div>
{#if action === 'saveAbility'}
<div class="proposal-structured-list">
<header><span>STRUCTURED EFFECTS</span><b>{currentAbilityEffects.length}</b></header>
{#each currentAbilityEffects as effect}<p>{effectSummary(effect)}</p>{:else}<p>NO STRUCTURED EFFECTS</p>{/each}
</div>
{:else if action === 'saveNode'}
<div class="proposal-rank-preview">
{#each currentNodeRanks as rank}
<section><header><span>RANK {rank.rank}</span><b>{currentNodeEffects.filter((effect) => Number(effect.rank) === Number(rank.rank)).length} EFFECTS</b></header><p>{rank.description}</p><small>{currentNodeCosts.filter((cost) => Number(cost.rank) === Number(rank.rank)).map((cost) => `${cost.resource_id} × ${cost.quantity}`).join(' · ') || 'CREDITS ONLY'}</small></section>
{/each}
</div>
{:else if action === 'saveProgression'}
<div class="proposal-rank-preview">
{#each currentRankProfiles as profile}
<section><header><span>RANK {profile.rank}</span><b>{profile.point_cost} POINTS</b></header><p>{Number(profile.credit_cost).toLocaleString()} CREDITS</p></section>
{/each}
</div>
{/if}
{:else}
<div class="proposal-empty-state"><b>NEW {specializationKind}</b><span>No live registry record will be replaced.</span></div>
{/if}
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED · {specializationKind}</span><b>AFTER</b></header>
<div class="proposal-field-summary specialization-fields">
{#each specializationFields as field}
<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>
{/each}
</div>
{#if action === 'saveAbility'}
<div class="proposal-structured-list">
<header><span>STRUCTURED EFFECTS</span><b>{proposedEffects.length}</b></header>
{#each proposedEffects as effect}<p>{effectSummary(effect)}</p>{:else}<p>NO STRUCTURED EFFECTS</p>{/each}
</div>
{:else if action === 'saveNode'}
<div class="proposal-rank-preview">
{#each proposedRanks as rank}
<section>
<header><span>RANK {rank.rank}</span><b>{rank.effects?.length ?? 0} EFFECTS</b></header>
<p>{rank.description || 'NO RANK DESCRIPTION'}</p>
{#each rank.effects ?? [] as effect}<small>{effectSummary(effect)}</small>{/each}
<small>{Object.entries(rank.costs ?? {}).filter(([, quantity]) => Number(quantity) > 0).map(([resource, quantity]) => `${resource} × ${quantity}`).join(' · ') || 'CREDITS ONLY'}</small>
</section>
{/each}
</div>
{:else if action === 'saveProgression'}
<div class="proposal-rank-preview">
{#each [1,2,3] as rank}
<section><header><span>RANK {rank}</span><b>{shown(proposed[`rank${rank}PointCost`])} POINTS</b></header><p>{Number(proposed[`rank${rank}CreditCost`] || 0).toLocaleString()} CREDITS</p></section>
{/each}
</div>
{/if}
</article>
</div>
{:else}
<div class="proposal-editor-preview generic after">
<header><span>PROPOSED {label(module)} RECORD</span><b>AFTER</b></header>
@@ -0,0 +1,102 @@
<script lang="ts">
import SearchableAssignment from './SearchableAssignment.svelte';
import SegmentedChoice from './SegmentedChoice.svelte';
type RegistryItem = { id: string; name: string; pending?: boolean; proposerName?: string };
export type StructuredEffect = {
type: 'Attribute' | 'Ability' | 'Custom';
statId: string;
abilityId: string;
label: string;
value: number | string | null;
unit: string;
};
let {
effects = $bindable([]),
attributes,
abilities = [],
idPrefix = 'effect',
emptyLabel = 'NO STRUCTURED EFFECTS',
addLabel = 'ADD EFFECT',
allowTargetLabelFallback = false
}: {
effects?: StructuredEffect[];
attributes: RegistryItem[];
abilities?: RegistryItem[];
idPrefix?: string;
emptyLabel?: string;
addLabel?: string;
allowTargetLabelFallback?: boolean;
} = $props();
const effectTypes = $derived([
{ value: 'Attribute', label: 'ATTRIBUTE' },
...(abilities.length ? [{ value: 'Ability', label: 'ABILITY' }] : []),
{ value: 'Custom', label: 'CUSTOM' }
]);
const units = [
{ value: '', label: 'NONE' },
{ value: '%', label: '%' },
{ value: 's', label: 'SECONDS' },
{ value: 'Shots', label: 'SHOTS' },
{ value: 'm', label: 'METERS' }
];
const blank = (): StructuredEffect => ({
type: 'Custom',
statId: '',
abilityId: '',
label: '',
value: '',
unit: ''
});
const changeType = (index: number, type: string) => {
effects[index] = {
...effects[index],
type: type as StructuredEffect['type'],
statId: type === 'Attribute' ? effects[index].statId : '',
abilityId: type === 'Ability' ? effects[index].abilityId : ''
};
};
</script>
<div class="structured-effect-editor">
{#each effects as effect, index}
{@const labelCanUseTarget = allowTargetLabelFallback && ['Attribute', 'Ability'].includes(effect.type)}
<section class="structured-effect-row">
<header><strong>EFFECT {String(index + 1).padStart(2, '0')}</strong><button type="button" class="danger-link" onclick={() => effects = effects.filter((_, itemIndex) => itemIndex !== index)}>REMOVE</button></header>
<div class="structured-effect-grid">
<SegmentedChoice
legend="EFFECT TYPE"
name={`${idPrefix}-type-${index}`}
options={effectTypes}
bind:value={effect.type}
onchange={(values) => changeType(index, values[0])}
showIndicator={false}
required
/>
{#if effect.type === 'Attribute'}
<div class="field"><span>ATTRIBUTE TARGET</span><SearchableAssignment items={attributes} mode="single" compact subjectLabel="ATTRIBUTE" searchPlaceholder="Search Attributes…" bind:value={effect.statId}/></div>
{:else if effect.type === 'Ability'}
<div class="field"><span>CLASS ABILITY TARGET</span><SearchableAssignment items={abilities} mode="single" compact subjectLabel="CLASS ABILITY" searchPlaceholder="Search abilities…" bind:value={effect.abilityId}/></div>
{:else}
<div class="effect-custom-target"><span>CUSTOM MECHANIC</span><small>Recorded for display now and calculation support later.</small></div>
{/if}
<label class="field effect-label">
EFFECT LABEL{labelCanUseTarget ? ' · OPTIONAL' : ''}
<input
bind:value={effect.label}
maxlength="100"
placeholder={labelCanUseTarget ? 'Uses the selected target name when blank' : 'e.g. Damage Reduction'}
required={!labelCanUseTarget}
/>
</label>
<label class="field">VALUE<input type="number" step="any" bind:value={effect.value} placeholder="OPTIONAL"/></label>
<SegmentedChoice legend="UNIT" name={`${idPrefix}-unit-${index}`} options={units} bind:value={effect.unit} showIndicator={false}/>
</div>
</section>
{:else}
<div class="structured-effect-empty"><strong>{emptyLabel}</strong><span>Add structured values when this mechanic should contribute to calculations or an ability preview.</span></div>
{/each}
<button type="button" class="secondary structured-effect-add" onclick={() => effects = [...effects, blank()]}>{addLabel} +</button>
</div>