feat: expand ISAC content modeling
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 28s
Validate and deploy staging / deploy (push) Successful in 1m3s

This commit is contained in:
jb
2026-08-03 01:23:20 -04:00
parent d9924a95b9
commit 57068b61cd
90 changed files with 9635 additions and 1270 deletions
@@ -0,0 +1,128 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import {
SKILL_CHIP_QUALITIES,
SKILL_CHIP_SECONDARY_CAPACITY,
SKILL_CHIP_SECONDARY_QUALITIES,
type SkillChipProfile
} from '$lib/skill-chips/profiles';
let {
primaryProfiles,
primaryProfileBase,
secondaryPool,
stats,
primaryError,
secondaryError,
primarySaved = false,
secondarySaved = false,
resultToken = ''
}: {
primaryProfiles: SkillChipProfile[];
primaryProfileBase: SkillChipProfile[];
secondaryPool: SkillChipProfile[];
stats: Array<{ id: string; name: string; unit: string }>;
primaryError?: string;
secondaryError?: string;
primarySaved?: boolean;
secondarySaved?: boolean;
resultToken?: string;
} = $props();
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
const blankValues = (qualities: readonly string[]) => Object.fromEntries(
qualities.map((quality) => [quality, { min: '', max: '' }])
);
const hydrated = (profile: SkillChipProfile, qualities: readonly string[]) => ({
statId: profile.statId,
values: Object.fromEntries(qualities.map((quality) => [quality, {
min: profile.values[quality]?.min ?? '',
max: profile.values[quality]?.max ?? ''
}]))
});
const blankSecondary = () => ({ statId: '', values: blankValues(SKILL_CHIP_SECONDARY_QUALITIES) });
let primaryEditing = $state(false);
let secondaryEditing = $state(false);
let primaryDraft = $state<any[]>([]);
let secondaryDraft = $state<any[]>([]);
let handledPrimaryToken = $state('');
let handledSecondaryToken = $state('');
function openPrimary() {
primaryDraft = primaryProfiles.map((profile) => hydrated(profile, SKILL_CHIP_QUALITIES));
secondaryEditing = false;
primaryEditing = true;
}
function openSecondary() {
const loaded: any[] = secondaryPool.map((profile) =>
hydrated(profile, SKILL_CHIP_SECONDARY_QUALITIES)
);
while (loaded.length < 2) loaded.push(blankSecondary());
secondaryDraft = loaded;
primaryEditing = false;
secondaryEditing = true;
}
$effect(() => {
if (primarySaved && resultToken && resultToken !== handledPrimaryToken) {
handledPrimaryToken = resultToken;
primaryEditing = false;
}
if (secondarySaved && resultToken && resultToken !== handledSecondaryToken) {
handledSecondaryToken = resultToken;
secondaryEditing = false;
}
});
</script>
<div class="skill-chip-shared-config">
<section class="mod-secondary-pool-registry">
<div class="selection-head"><div><span>SHARED PRIMARY PROFILE</span><small>FOUR ABILITY-SCOPED STATS · ONE RANGE PER QUALITY</small></div><button type="button" class="row-action" onclick={openPrimary}>EDIT PRIMARY RANGES →</button></div>
<div class="mod-secondary-capacity">
{#each SKILL_CHIP_QUALITIES as quality}<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{primaryProfiles.filter((profile) => profile.values[quality]).length}</strong><small>OF 4 CONFIGURED</small></span>{/each}
<span class="pool-total"><b>CALCULATION</b><strong>—</strong><small>NOT YET DEFINED</small></span>
</div>
</section>
<section class="mod-secondary-pool-registry">
<div class="selection-head"><div><span>SHARED SECONDARY POOL</span><small>ONE CANONICAL ATTRIBUTE POOL FOR EVERY SKILL CHIP</small></div><button type="button" class="row-action" onclick={openSecondary}>EDIT SHARED POOL →</button></div>
<div class="mod-secondary-capacity">
{#each SKILL_CHIP_QUALITIES as quality}<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{SKILL_CHIP_SECONDARY_CAPACITY[quality]}</strong><small>SECONDARY {SKILL_CHIP_SECONDARY_CAPACITY[quality] === 1 ? 'ROLL' : 'ROLLS'}</small></span>{/each}
<span class="pool-total"><b>ATTRIBUTE POOL</b><strong>{secondaryPool.length}</strong><small>SHARED OPTIONS</small></span>
</div>
</section>
</div>
{#if primaryEditing}
<div class="dashboard-editor skill-chip-profile-editor">
<div class="editor-title"><div><p class="eyebrow">SHARED CONFIGURATION</p><h3>SKILL CHIP PRIMARY RANGES</h3></div><button aria-label="Close primary profile editor" onclick={() => primaryEditing = false}>×</button></div>
<PendingForm action="?/savePrimaryProfile">
<input type="hidden" name="id" value="skill-chip-primary-profile"/><input type="hidden" name="name" value="Shared Skill Chip Primary Profile"/><input type="hidden" name="baseProfiles" value={JSON.stringify(primaryProfileBase)}/><input type="hidden" name="profiles" value={JSON.stringify(primaryDraft)}/>
<p class="console-note">Every Skill Chip rolls one of these four ability-scoped primary stats. Configure the direct minimum and maximum for each quality. Current combat calculations do not apply these values.</p>
<div class="skill-chip-profile-options fixed">
{#each primaryDraft as profile}
<div class="skill-chip-profile-option"><div><strong>{stats.find((item) => item.id === profile.statId)?.name ?? 'UNKNOWN ATTRIBUTE'}</strong><small>{stats.find((item) => item.id === profile.statId)?.unit || 'FLAT'} · ABILITY SCOPED</small></div>{#each SKILL_CHIP_QUALITIES as quality}<label><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><span><input type="number" min="0" step="any" bind:value={profile.values[quality].min} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? 'attribute'} minimum`} required/><i>–</i><input type="number" min="0" step="any" bind:value={profile.values[quality].max} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? 'attribute'} maximum`} required/></span></label>{/each}</div>
{/each}
</div>
{#if primaryError}<p class="error">{primaryError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => primaryEditing = false}>CANCEL</button><button class="primary">SAVE PRIMARY PROFILE →</button></div>
</PendingForm>
</div>
{/if}
{#if secondaryEditing}
<div class="dashboard-editor skill-chip-profile-editor">
<div class="editor-title"><div><p class="eyebrow">SHARED CONFIGURATION</p><h3>SKILL CHIP SECONDARY POOL</h3></div><button aria-label="Close secondary pool editor" onclick={() => secondaryEditing = false}>×</button></div>
<PendingForm action="?/saveSecondaryPool">
<input type="hidden" name="id" value="skill-chip-secondary-pool"/><input type="hidden" name="name" value="Shared Skill Chip Secondary Pool"/><input type="hidden" name="baseProfiles" value={JSON.stringify(secondaryPool)}/><input type="hidden" name="profiles" value={JSON.stringify(secondaryDraft)}/>
<p class="console-note">Standard has no secondary roll. Specialized selects one option; Superior and High-end select two distinct options. Skill Chips have no item level, so each quality uses one direct range.</p>
<div class="selection-head pool-editor-head"><div><span>AVAILABLE ATTRIBUTES</span><small>SPECIALIZED · SUPERIOR · HIGH-END RANGES</small></div><button type="button" class="row-action" onclick={() => secondaryDraft = [...secondaryDraft, blankSecondary()]} disabled={secondaryDraft.length >= 50}>ADD ATTRIBUTE +</button></div>
<div class="skill-chip-profile-options">
{#each secondaryDraft as profile, index}
<div class="skill-chip-profile-option selectable"><strong>{String(index + 1).padStart(2, '0')}</strong><SearchableAssignment items={stats} mode="single" compact subjectLabel="POSSIBLE ATTRIBUTE" searchPlaceholder="Search attributes…" excludedIds={secondaryDraft.filter((_, itemIndex) => itemIndex !== index).map((item) => item.statId)} bind:value={profile.statId}/>{#each SKILL_CHIP_SECONDARY_QUALITIES as quality}<label><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><span><input type="number" min="0" step="any" bind:value={profile.values[quality].min} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? `option ${index + 1}`} minimum`} required/><i>–</i><input type="number" min="0" step="any" bind:value={profile.values[quality].max} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? `option ${index + 1}`} maximum`} required/></span></label>{/each}<button type="button" aria-label={`Remove secondary option ${index + 1}`} onclick={() => secondaryDraft = secondaryDraft.filter((_, itemIndex) => itemIndex !== index)} disabled={secondaryDraft.length <= 2}>×</button></div>
{/each}
</div>
{#if secondaryError}<p class="error">{secondaryError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => secondaryEditing = false}>CANCEL</button><button class="primary">SAVE SHARED POOL →</button></div>
</PendingForm>
</div>
{/if}