feat: expand ISAC content modeling
This commit is contained in:
+110
-25
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user