148 lines
14 KiB
Svelte
148 lines
14 KiB
Svelte
<script lang="ts">
|
||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
|
||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
|
||
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
||
|
||
let { data, form } = $props();
|
||
const talentValueColumns = [
|
||
{ key: 'minimum', variantKey: 'minimum', label: 'MINIMUM' },
|
||
{ key: 'maximum', variantKey: 'maximum', label: 'MAXIMUM' }
|
||
];
|
||
const blankRule = (): ConditionalMechanicDraftV1 => ({
|
||
version:1,simulationReadiness:'captured_only',scenarioInputs:[],pools:[],
|
||
clauses:[{key:'primary',label:'Primary',event:'skill_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'assigned_ability',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{minimum:{value:0},maximum:{value:0}}}]}]
|
||
});
|
||
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
|
||
const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, primaryStatIds: [], secondaryStatIds: [], talent: blankTalent() });
|
||
let editing = $state<any | null>(null);
|
||
let highEndCount = $state('0');
|
||
let handledResultToken = $state('');
|
||
let iconResetToken = $state(0);
|
||
const qualitySteps = [
|
||
{ value: '0', label: '0 HIGH-END' },
|
||
{ value: '1', label: '1 HIGH-END' },
|
||
{ value: '2', label: '2 HIGH-END' },
|
||
{ value: '3', label: '3 HIGH-END' }
|
||
];
|
||
const stat = (id: string) => data.stats.find((item: any) => item.id === id);
|
||
const ability = (id: string) => data.abilities.find((item: any) => item.id === id);
|
||
const format = (value: number | string, statId: string) => {
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric)) return '—';
|
||
const rendered = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, '');
|
||
const unit = stat(statId)?.unit ?? '';
|
||
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
|
||
};
|
||
const scaled = (minimum: number | string, maximum: number | string) => Number(minimum) + (Number(maximum) - Number(minimum)) * Number(highEndCount) / 3;
|
||
const previewOutcomes = () => editing.talent.conditionalRule.clauses.flatMap((clause: any) => clause.outcomes)
|
||
.filter((outcome: any) => outcome.descriptionSlot)
|
||
.sort((left: any, right: any) => left.descriptionSlot - right.descriptionSlot);
|
||
const talentPreview = () => editing.talent.description_template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
|
||
const duration = editing.talent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds;
|
||
const maxStacks = editing.talent.conditionalRule.pools.length ? Math.max(...editing.talent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null;
|
||
if (expression === 'duration') return duration == null ? placeholder : `${duration} seconds`;
|
||
if (expression === 'max_stacks') return maxStacks == null ? placeholder : `${maxStacks} stacks`;
|
||
const outcome = previewOutcomes().find((item: any) => item.descriptionSlot === Number(expression));
|
||
const minimum = outcome?.variants?.minimum?.value;
|
||
const maximum = outcome?.variants?.maximum?.value;
|
||
return outcome && minimum !== '' && maximum !== '' && minimum !== null && maximum !== null && minimum !== undefined && maximum !== undefined
|
||
? format(scaled(minimum, maximum), outcome.attributeId)
|
||
: placeholder;
|
||
});
|
||
const edit = (item: any) => {
|
||
editing = {
|
||
...item,
|
||
abilityId: String(item.specialization_ability_id),
|
||
twoPiece: item.twoPiece ? { ...item.twoPiece } : { stat_attribute_id: '', min_value: '', max_value: '' },
|
||
primaryStatIds: [...item.primaryStatIds],
|
||
secondaryStatIds: [...item.secondaryStatIds],
|
||
talent: item.talent ? { ...item.talent, conditionalRule: item.talent.conditionalRule ?? blankRule() } : blankTalent()
|
||
};
|
||
highEndCount = '0';
|
||
};
|
||
$effect(() => {
|
||
if (form?.skillChipSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||
handledResultToken = form.resultToken;
|
||
if (form.skillChipMode === 'add') iconResetToken += 1;
|
||
editing = form.skillChipMode === 'add' ? blank() : null;
|
||
highEndCount = '0';
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<svelte:head><title>Skill Chips — SHD.build admin</title></svelte:head>
|
||
<div class="module-toolbar"><div><p class="eyebrow">SKILL MODIFICATION SYNERGY REGISTRY</p><h2>SKILL CHIPS</h2><p>Configure Superior and High-end chip rolls, 2-piece bonuses, and quality-scaled 3-piece talents.</p></div><button class="primary" onclick={() => editing = blank()}>ADD SKILL CHIP <span>+</span></button></div>
|
||
|
||
<KpiFilters active={data.filter} items={[
|
||
{value:'',label:'TOTAL SETS',count:data.summary.total,description:'Show all definitions'},
|
||
{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},
|
||
{value:'assigned',label:'ABILITY LINKED',count:data.summary.assigned,description:'Assigned to a class ability'},
|
||
{value:'configured',label:'CONFIGURED',count:data.summary.configured,description:'Complete 2-piece and 3-piece effects'},
|
||
{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'}
|
||
]}/>
|
||
|
||
{#snippet skillChipEditor()}
|
||
{#if editing}
|
||
<div class="dashboard-editor skill-chip-editor">
|
||
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY CHIP SET' : 'NEW CHIP SET'}</p><h3>{editing.id ? editing.name : 'SKILL CHIP DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
|
||
<PendingForm action="?/save" enctype="multipart/form-data">
|
||
<input type="hidden" name="id" value={editing.id}/>
|
||
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)}/>
|
||
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Only these qualities participate in chip scaling.</small></div></div>
|
||
<section class="skill-chip-ability-assignment">
|
||
<div class="selection-head"><div><span>SPECIALIZATION ABILITY</span><small>EXCLUSIVE RELATIONSHIP · THREE ACTIVE SKILL CHIPS PER ABILITY</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 ASSIGNED` : 'REQUIRED'}</b></div>
|
||
<SearchableAssignment items={data.abilities} fieldName="abilityId" mode="single" compact subjectLabel="SPECIALIZATION ABILITY" searchPlaceholder="Search abilities or specializations…" emptySelectionLabel="NO ABILITY ASSIGNED" bind:value={editing.abilityId}/>
|
||
</section>
|
||
|
||
<section class="skill-chip-bonus">
|
||
<div class="selection-head"><div><span>2-PIECE BONUS</span><small>MINIMUM AT ZERO HIGH-END · LINEAR TO MAXIMUM AT THREE HIGH-END</small></div><b>{format(scaled(editing.twoPiece.min_value, editing.twoPiece.max_value), editing.twoPiece.stat_attribute_id)}</b></div>
|
||
<div class="skill-chip-bonus-grid"><div><SearchableAssignment items={data.stats} fieldName="twoPieceStatId" mode="single" subjectLabel="BONUS ATTRIBUTE" searchPlaceholder="Search attributes…" emptySelectionLabel="NO BONUS ATTRIBUTE SELECTED" bind:value={editing.twoPiece.stat_attribute_id}/></div><div class="skill-chip-range-fields"><label class="field">MINIMUM VALUE<input name="twoPieceMin" type="number" step="any" bind:value={editing.twoPiece.min_value} required/></label><label class="field">MAXIMUM VALUE<input name="twoPieceMax" type="number" step="any" bind:value={editing.twoPiece.max_value} required/></label></div></div>
|
||
</section>
|
||
|
||
<div class="skill-chip-pools">
|
||
<div><div class="selection-head"><div><span>PRIMARY STAT POOL</span><small>ONE RANDOM STAT · USES ATTRIBUTE SKILL CHIP RANGE</small></div><b>{editing.primaryStatIds.length}</b></div><SearchableAssignment items={data.rollStats} fieldName="primaryStatIds" subjectLabel="PRIMARY STATS" searchPlaceholder="Search Skill Chip stats…" excludedIds={editing.secondaryStatIds} bind:selected={editing.primaryStatIds}/></div>
|
||
<div><div class="selection-head"><div><span>SECONDARY STAT POOL</span><small>TWO DISTINCT RANDOM STATS · USE ATTRIBUTE RANGES</small></div><b>{editing.secondaryStatIds.length}</b></div><SearchableAssignment items={data.rollStats} fieldName="secondaryStatIds" subjectLabel="SECONDARY STATS" searchPlaceholder="Search Skill Chip stats…" excludedIds={editing.primaryStatIds} bind:selected={editing.secondaryStatIds}/></div>
|
||
</div>
|
||
|
||
<section class="skill-chip-talent">
|
||
<div class="selection-head"><div><span>3-PIECE TALENT</span><small>QUALITY-SCALED OUTCOMES · TYPED TRIGGER AND DESCRIPTION</small></div><b>{previewOutcomes().length} OUTCOME{previewOutcomes().length === 1 ? '' : 'S'}</b></div>
|
||
<div class="skill-chip-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} Skill Damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for scaled effect values and units.</small></label></div>
|
||
<div class="skill-chip-preview"><SegmentedChoice legend="QUALITY MIX PREVIEW" name="qualityPreview" options={qualitySteps} bind:value={highEndCount}/><div><span>{3 - Number(highEndCount)} SUPERIOR · {highEndCount} HIGH-END</span><p>{talentPreview() || 'Talent description preview'}</p></div></div>
|
||
<MechanicProgramEditor
|
||
attributes={data.stats}
|
||
allowAssignedAbility
|
||
valueColumns={talentValueColumns}
|
||
idPrefix={`skill-chip-talent-${editing.talent.id || 'new'}`}
|
||
aiContext={{
|
||
sourceKind: 'skill_chip_talent',
|
||
sourceName: editing?.talent?.name ?? '',
|
||
sourceDescription: editing?.talent?.description_template ?? ''
|
||
}}
|
||
bind:rule={editing.talent.conditionalRule}
|
||
/>
|
||
</section>
|
||
{#if form?.skillChipError}<p class="error">{form.skillChipError}</p>{/if}
|
||
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE CHIP SET' : 'UNARCHIVE CHIP SET'}</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!editing.abilityId || !editing.primaryStatIds.length || editing.secondaryStatIds.length < 2 || !data.rollStats.length}>SAVE SKILL CHIP →</button></div></div>
|
||
</PendingForm>
|
||
</div>
|
||
{/if}
|
||
{/snippet}
|
||
{#if editing && !editing.id}{@render skillChipEditor()}{/if}
|
||
|
||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||
<div class="dashboard-table">
|
||
<div class="dashboard-row skill-chip-row table-heading"><SortableColumnHeader label="SKILL CHIP" column="name" sort={data.sort}/><SortableColumnHeader label="ABILITY" column="ability" sort={data.sort}/><span>QUALITY</span><SortableColumnHeader label="2-PIECE BONUS" column="twoPiece" sort={data.sort}/><SortableColumnHeader label="3-PIECE TALENT" column="talent" sort={data.sort}/><SortableColumnHeader label="ROLL POOLS" column="rollPools" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||
{#each data.sets as item}
|
||
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>EXCLUSIVE ABILITY</small></div><span class="skill-chip-quality-pair"><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT CONFIGURED'}</strong><small>{item.talent?.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED'}</small></div><span>{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
||
{#if editing?.id === item.id}<RegistryInlineEditor id={`skill-chip-editor-${item.id}`} label={`Edit ${item.name}`}>{@render skillChipEditor()}</RegistryInlineEditor>{/if}
|
||
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first Superior and High-end Skill Chip definition.</span></div>{/each}
|
||
</div>
|