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
+110 -25
View File
@@ -15,8 +15,8 @@
import { summarizeLoadout } from '$lib/builder/loadout-summary';
import { toast } from '$lib/toast';
import {
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, weaponBaseRange,
type LoadoutDraft, type Roll
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, skillChipQualities, skillChipSecondaryCapacity, weaponBaseRange,
type LoadoutDraft, type Roll, type SkillChipQuality
} from '$lib/builder/loadout';
let { data, form } = $props();
@@ -35,8 +35,23 @@
const coreKeys = ['firepower','toughness','engineering'] as const;
const gearAttributes = $derived(data.attributes.filter((item) => item.gear_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const weaponAttributes = $derived(data.attributes.filter((item) => item.weapon_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const chipAttributes = $derived(data.attributes.filter((item) => item.skill_chip_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const attribute = (id: string) => data.attributes.find((item) => item.id === id);
const chipPrimaryAttributes = $derived(data.skillChipProfile.primaryOptions.map((option: any) => attribute(option.statId)).filter(Boolean).map((item: any) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const chipSecondaryAttributes = $derived(data.skillChipProfile.secondaryOptions.map((option: any) => attribute(option.statId)).filter(Boolean).map((item: any) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const chipRange = (kind: 'primary' | 'secondary', statId: string, quality: SkillChipQuality) =>
data.skillChipProfile[`${kind}Options`].find((option: any) => option.statId === statId)?.ranges?.[quality] ?? null;
type ChipRange = { min: number; max: number };
const validChipRange = (range: any): range is ChipRange => Number.isFinite(Number(range?.min)) && Number.isFinite(Number(range?.max)) && Number(range.max) >= Number(range.min);
const chipRangeMin = (kind: 'primary' | 'secondary', statId: string, quality: SkillChipQuality) => Number(chipRange(kind, statId, quality)?.min ?? 0);
const chipRangeMax = (kind: 'primary' | 'secondary', statId: string, quality: SkillChipQuality) => Number(chipRange(kind, statId, quality)?.max ?? 0);
const chipProfileConfigured = $derived(
data.skillChipProfile.primaryOptions.length === 4
&& chipPrimaryAttributes.length === data.skillChipProfile.primaryOptions.length
&& data.skillChipProfile.primaryOptions.every((option: any) => skillChipQualities.every((quality) => validChipRange(option.ranges?.[quality])))
&& data.skillChipProfile.secondaryOptions.length >= 2
&& chipSecondaryAttributes.length === data.skillChipProfile.secondaryOptions.length
&& data.skillChipProfile.secondaryOptions.every((option: any) => ['Specialized','Superior','High-end'].every((quality) => validChipRange(option.ranges?.[quality])))
);
const gearItem = (slot: string) => data.gear.find((item) => item.id === draft.gear[slot].itemId);
const weaponItem = (index: number) => data.weapons.find((item) => item.id === draft.weapons[index].itemId);
const chipItem = (index: number) => data.chipSets.find((item) => item.id === draft.chips[index].setId);
@@ -80,13 +95,35 @@
coreKeys.reduce((total, key) => total + Number(selection[key] ?? 0), 0);
const attributeRange = (stat: any, source: 'gear' | 'weapon', level: number) =>
scaledRange(stat?.[`${source}_min`], stat?.[`${source}_max`], level, stat?.[`${source}_reference_level`], source);
const setRollStat = (roll: Roll, id: string, source: 'gear' | 'weapon' | 'chip', level = 50) => {
const setRollStat = (roll: Roll, id: string, source: 'gear' | 'weapon', level = 50) => {
roll.statId = id;
const stat = attribute(id);
const min = source === 'chip'
? stat?.skill_chip_min
: attributeRange(stat, source, level).min;
roll.value = Number(min ?? 0);
roll.value = Number(attributeRange(stat, source, level).min ?? 0);
};
const setChipRoll = (roll: Roll, id: string, kind: 'primary' | 'secondary', quality: SkillChipQuality) => {
roll.statId = id;
const range = chipRange(kind, id, quality);
roll.value = validChipRange(range) ? Number(range.min) : 0;
};
const chipRollConfigured = (roll: Roll, kind: 'primary' | 'secondary', quality: SkillChipQuality) => {
const options = data.skillChipProfile[`${kind}Options`];
const range = chipRange(kind, roll.statId, quality);
const value = Number(roll.value);
return options.some((option: any) => option.statId === roll.statId)
&& validChipRange(range)
&& Number.isFinite(value)
&& value >= Number(range.min)
&& value <= Number(range.max);
};
const chipSelectionConfigured = (chip: LoadoutDraft['chips'][number]) => {
const capacity = skillChipSecondaryCapacity[chip.quality];
const secondaryIds = chip.secondary.map((roll) => roll.statId);
return Boolean(data.chipSets.some((item: any) => item.id === chip.setId))
&& chipProfileConfigured
&& chipRollConfigured(chip.primary, 'primary', chip.quality)
&& chip.secondary.length === capacity
&& new Set(secondaryIds).size === secondaryIds.length
&& chip.secondary.every((roll) => chipRollConfigured(roll, 'secondary', chip.quality));
};
const rankPointCost = (rank: number) => Number(data.specializationProgression.rankProfiles.find((profile: any) => Number(profile.rank) === rank)?.point_cost ?? rank);
const selectedSpecializationPoints = $derived(selectedFocus?.nodes.reduce((total: number, node: any) => {
@@ -222,15 +259,41 @@
selection.rolls = [blankRoll(), blankRoll()];
};
const configureChip = (index: number, id: string) => {
const chip = draft.chips[index], item = data.chipSets.find((entry) => entry.id === id);
const chip = draft.chips[index];
chip.setId = id;
chip.primary = { statId: item?.primaryStatIds[0] ?? '', value: 0 };
chip.secondary = [
{ statId: item?.secondaryStatIds[0] ?? '', value: 0 },
{ statId: item?.secondaryStatIds[1] ?? '', value: 0 }
];
if (chip.primary.statId) setRollStat(chip.primary, chip.primary.statId, 'chip');
for (const roll of chip.secondary) if (roll.statId) setRollStat(roll, roll.statId, 'chip');
chip.primary = blankRoll();
chip.secondary = [];
changeChipQuality(index, chip.quality);
};
const changeChipQuality = (index: number, quality: SkillChipQuality) => {
if (!skillChipQualities.includes(quality)) return;
const chip = draft.chips[index];
chip.quality = quality;
const primaryOption = data.skillChipProfile.primaryOptions.find((option: any) => option.statId === chip.primary.statId && validChipRange(option.ranges?.[quality]))
?? data.skillChipProfile.primaryOptions.find((option: any) => validChipRange(option.ranges?.[quality]));
if (primaryOption) {
const range = primaryOption.ranges[quality];
chip.primary = {
statId: primaryOption.statId,
value: clamp(chip.primary.statId === primaryOption.statId ? chip.primary.value : range.min, range.min, range.max)
};
} else chip.primary = blankRoll();
const capacity = skillChipSecondaryCapacity[quality];
const next: Roll[] = [];
for (const roll of chip.secondary) {
if (next.length >= capacity || next.some((entry) => entry.statId === roll.statId)) continue;
const range = chipRange('secondary', roll.statId, quality);
if (!validChipRange(range)) continue;
next.push({ statId: roll.statId, value: clamp(roll.value, range.min, range.max) });
}
for (const option of data.skillChipProfile.secondaryOptions) {
if (next.length >= capacity) break;
if (next.some((entry) => entry.statId === option.statId)) continue;
const range = option.ranges?.[quality];
if (validChipRange(range)) next.push({ statId: option.statId, value: Number(range.min) });
}
while (next.length < capacity) next.push(blankRoll());
chip.secondary = next;
};
const changeWeaponTier = (index: number, tier: number) => {
const selection = draft.weapons[index], item = weaponItem(index);
@@ -297,7 +360,8 @@
});
const abilityCells = $derived.by((): MatrixCell[] => [0, 1, 2].map((index) => {
const ability = selectedFocus?.abilities[index];
const configuredChips = draft.chips.slice(index * 3, index * 3 + 3).filter((chip) => chip.setId);
const abilityChips = draft.chips.slice(index * 3, index * 3 + 3);
const configuredChips = abilityChips.filter((chip) => chip.setId);
const chipNames = configuredChips.map((chip) => data.chipSets.find((item) => item.id === chip.setId)?.name).filter(Boolean);
return {
id: `ability:${index}`,
@@ -306,7 +370,7 @@
meta: ability?.attack?.damage_type ?? '',
summary: chipNames.length ? chipNames.join(' · ') : `${configuredChips.length} / 3 SKILL CHIPS`,
iconUrl: ability?.iconUrl,
issue: !ability ? 'ABILITY NOT CONFIGURED' : configuredChips.length < 3 ? `${3 - configuredChips.length} CHIP${configuredChips.length === 2 ? '' : 'S'} REQUIRED` : undefined
issue: !ability ? 'ABILITY NOT CONFIGURED' : configuredChips.length < 3 ? `${3 - configuredChips.length} CHIP${configuredChips.length === 2 ? '' : 'S'} REQUIRED` : abilityChips.some((chip) => !chipSelectionConfigured(chip)) ? 'CONFIG REQUIRED' : undefined
};
}));
const weaponCells = $derived.by((): MatrixCell[] => draft.weapons.map((selection, index) => {
@@ -357,14 +421,15 @@
&& selectedFocus.abilities.length === 3
&& draft.weapons.every((item) => item.itemId)
&& gearSlots.every((slot) => draft.gear[slot].itemId)
&& draft.chips.every((item) => item.setId)
&& draft.chips.every(chipSelectionConfigured)
&& draft.protocol.itemId
));
onMount(() => {
if (data.initialBuild) return;
localStorage.removeItem('shd-build-loadout-draft-v3');
const stored = localStorage.getItem('shd-build-loadout-draft-v4');
localStorage.removeItem('shd-build-loadout-draft-v4');
if (data.initialBuild) return;
const stored = localStorage.getItem('shd-build-loadout-draft-v5');
if (!stored) return;
try {
const parsed = parseLoadoutDraft(JSON.parse(stored));
@@ -372,9 +437,9 @@
draft = parsed;
if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
saved = true;
} else localStorage.removeItem('shd-build-loadout-draft-v4');
} else localStorage.removeItem('shd-build-loadout-draft-v5');
} catch {
localStorage.removeItem('shd-build-loadout-draft-v4');
localStorage.removeItem('shd-build-loadout-draft-v5');
}
});
$effect(() => {
@@ -392,7 +457,7 @@
: 'Build published to the SHD archive.';
});
function saveDraft() {
localStorage.setItem('shd-build-loadout-draft-v4', JSON.stringify(draft));
localStorage.setItem('shd-build-loadout-draft-v5', JSON.stringify(draft));
saved = true;
toast.success('Draft saved locally', 'This loadout is available on this device.');
}
@@ -548,7 +613,27 @@
<div class="loadout-editor-head"><div><span>ABILITY {abilityIndex + 1} · CHIP {(index % 3) + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'SKILL CHIP'}</h2></div><b>{chip.quality.toUpperCase()}</b></div>
<button class="builder-return-link" type="button" onclick={() => active = `ability:${abilityIndex}`}>← RETURN TO {ability?.name?.toUpperCase() ?? 'ABILITY'}</button>
{#if ability}<BuilderPicker items={data.chipSets.filter((entry: any) => entry.specialization_ability_id === ability.id)} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="ABILITY-COMPATIBLE SKILL CHIP SET"/>{:else}<p class="error">Select a focus with three configured class abilities before equipping Skill Chips.</p>{/if}
{#if item}<div class="builder-field-grid"><label>QUALITY<select bind:value={chip.quality}><option>Superior</option><option>High-end</option></select></label></div><div class="builder-chip-rolls"><div><BuilderPicker items={chipAttributes.filter((entry) => item.primaryStatIds.includes(entry.id))} bind:value={chip.primary.statId} onchange={(id) => setRollStat(chip.primary,id,'chip')} label="PRIMARY ATTRIBUTE"/><label>VALUE<input type="number" min={attribute(chip.primary.statId)?.skill_chip_min ?? 0} max={attribute(chip.primary.statId)?.skill_chip_max ?? 0} bind:value={chip.primary.value} onblur={() => chip.primary.value = clamp(chip.primary.value,attribute(chip.primary.statId)?.skill_chip_min,attribute(chip.primary.statId)?.skill_chip_max)}/></label></div>{#each chip.secondary as roll,index}<div><BuilderPicker items={chipAttributes.filter((entry) => item.secondaryStatIds.includes(entry.id))} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'chip')} disabledIds={chip.secondary.filter((_,i) => i !== index).map((entry) => entry.statId)} label={`SECONDARY ATTRIBUTE ${index + 1}`}/><label>VALUE<input type="number" min={attribute(roll.statId)?.skill_chip_min ?? 0} max={attribute(roll.statId)?.skill_chip_max ?? 0} bind:value={roll.value} onblur={() => roll.value = clamp(roll.value,attribute(roll.statId)?.skill_chip_min,attribute(roll.statId)?.skill_chip_max)}/></label></div>{/each}</div>{/if}
{#if !chipProfileConfigured}<p class="error">SHARED SKILL CHIP PROFILE · CONFIG REQUIRED. Complete every global quality range in ISAC before this loadout can be complete.</p>{/if}
{#if item}
<div class="builder-field-grid">
<SegmentedChoice legend="QUALITY" name={`chip-${index}-quality`} options={skillChipQualities.map((quality) => ({ value: quality, label: quality.toUpperCase() }))} value={chip.quality} onchange={(values) => changeChipQuality(index, values[0] as SkillChipQuality)} required/>
</div>
<div class="builder-section-title"><div><span>ABILITY-SCOPED PRIMARY</span><small>{ability?.name?.toUpperCase() ?? 'ASSIGNED ABILITY'} ONLY · EXCLUDED FROM KNOWN AGENT STATS</small></div><b>NOT YET DEFINED</b></div>
<p class="builder-scaling-note">The primary roll belongs to this ability. Its calculation application is NOT YET DEFINED, so it does not contribute to current Agent totals.</p>
<div class="builder-chip-rolls">
<div>
<BuilderPicker items={chipPrimaryAttributes} bind:value={chip.primary.statId} onchange={(id) => setChipRoll(chip.primary,id,'primary',chip.quality)} label="PRIMARY ATTRIBUTE"/>
{#if validChipRange(chipRange('primary', chip.primary.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('primary', chip.primary.statId, chip.quality)} max={chipRangeMax('primary', chip.primary.statId, chip.quality)} step={0.01} bind:value={chip.primary.value}/>{:else}<p class="error">PRIMARY RANGE · CONFIG REQUIRED</p>{/if}
</div>
{#each chip.secondary as roll, secondaryIndex}
<div>
<BuilderPicker items={chipSecondaryAttributes} bind:value={roll.statId} onchange={(id) => setChipRoll(roll,id,'secondary',chip.quality)} disabledIds={chip.secondary.filter((_,i) => i !== secondaryIndex).map((entry) => entry.statId).filter(Boolean)} label={`SECONDARY ATTRIBUTE ${secondaryIndex + 1}`}/>
{#if validChipRange(chipRange('secondary', roll.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('secondary', roll.statId, chip.quality)} max={chipRangeMax('secondary', roll.statId, chip.quality)} step={0.01} bind:value={roll.value}/>{:else}<p class="error">SECONDARY RANGE · CONFIG REQUIRED</p>{/if}
</div>
{/each}
</div>
{#if skillChipSecondaryCapacity[chip.quality] === 0}<p class="builder-scaling-note">STANDARD QUALITY · NO SECONDARY ATTRIBUTES</p>{/if}
{/if}
{:else}
{@const protocol = protocolItem()}
<div class="loadout-editor-head"><div><span>AGENT SYSTEM</span><h2>OS PROTOCOL</h2></div><b>{protocol?.quality?.toUpperCase() ?? 'UNASSIGNED'}</b></div>