feat(isac): add specialization data management
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user