Add shared core attribute allocation

This commit is contained in:
JB
2026-07-25 22:11:46 -04:00
parent f0fbdc2853
commit 478a37c17a
17 changed files with 206 additions and 40 deletions
+3 -1
View File
@@ -9,7 +9,7 @@ export const load: PageServerLoad = async () => {
weapons, weaponRanges, weaponTalentOptions, weaponBonuses,
talents, talentTiers, talentEffects, talentTemplates,
chipSets, chipTwoPiece, chipPrimary, chipSecondary, chipTalents, chipTalentEffects,
protocols, protocolPrimary, protocolProfiles, protocolSecondary, protocolTalents, protocolTalentEffects,
protocols, protocolPrimary, protocolProfiles, protocolSecondary, protocolTalents, protocolTalentEffects, coreRanges,
icons
] = await Promise.all([
(await database.prepare('SELECT * FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
@@ -40,6 +40,7 @@ export const load: PageServerLoad = async () => {
(await database.prepare('SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role')).all(),
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
(await database.prepare('SELECT * FROM os_protocol_talent_effects ORDER BY sort_order')).all(),
(await database.prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')).all(),
(await database.prepare("SELECT * FROM game_data_icons")).all()
]);
const iconUrl = (type: 'talent' | 'brand_set' | 'os_protocol' | 'skill_chip', id: unknown) => {
@@ -57,6 +58,7 @@ export const load: PageServerLoad = async () => {
}));
return {
attributes,
coreRanges,
gear: gear.map((item) => ({
...item,
brandIds: gearBrands.filter((link) => link.gear_id === item.id).map((link) => String(link.brand_set_id)),
+44 -14
View File
@@ -17,6 +17,7 @@
let saved = $state(false);
let message = $state('');
const weaponRangeKeys = ['damage','firepower','toughness','engineering'] as const;
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'}` })));
@@ -38,7 +39,14 @@
const gearBaseRange = (item: any, key: 'health' | 'armor', level: number) =>
scaledRange(item?.[`base_${key}_min`], item?.[`base_${key}_max`], level, item?.range_reference_level, 'gear');
const weaponStatRange = (range: any, key: typeof weaponRangeKeys[number], level: number) =>
scaledRange(range?.[`${key}_min`], range?.[`${key}_max`], level, range?.reference_level, 'weapon');
scaledRange(range?.[`${key}_min`], range?.[`${key}_max`], level, range?.reference_level ?? 50, 'weapon');
const coreAllocationRange = (tier: number, level: number, source: 'gear' | 'weapon') => {
const profile = data.coreRanges.find((item) => Number(item.tier) === Number(tier));
const nominal = scaledRange(profile?.nominal_min, profile?.nominal_max, level, profile?.reference_level ?? 50, source);
return { ...nominal, poolMin: nominal.min * 3, poolMax: nominal.max * 3 };
};
const coreTotal = (selection: { firepower: number; toughness: number; engineering: number }) =>
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) => {
@@ -75,12 +83,14 @@
selection.talentId = item?.talentIds[0] ?? '';
selection.health = Number(item?.base_health_min ?? 0);
selection.armor = Number(item?.base_armor_min ?? 0);
const core = coreAllocationRange(Number(item?.tier ?? 0), selection.level, 'gear');
for (const key of coreKeys) selection[key] = core.min;
selection.rolls = [blankRoll(), blankRoll()];
selection.augmentSlots = [];
};
const changeGearLevel = (slot: string, rawLevel: unknown) => {
const selection = draft.gear[slot], item = gearItem(slot);
const nextLevel = Math.round(clamp(rawLevel, 1, 50));
const nextLevel = Math.round(clamp(rawLevel, Number(item?.tier) === 2 ? 40 : 1, 50));
if (!item || nextLevel === selection.level) {
selection.level = nextLevel;
return;
@@ -95,6 +105,9 @@
gearBaseRange(item, 'armor', selection.level),
gearBaseRange(item, 'armor', nextLevel)
);
const previousCore = coreAllocationRange(Number(item.tier), selection.level, 'gear');
const nextCore = coreAllocationRange(Number(item.tier), nextLevel, 'gear');
for (const key of coreKeys) selection[key] = remapRangeValue(selection[key], { ...previousCore, min: 0, max: previousCore.poolMax }, { ...nextCore, min: 0, max: nextCore.poolMax });
for (const roll of selection.rolls) {
const stat = attribute(roll.statId);
if (!stat) continue;
@@ -112,7 +125,9 @@
selection.level = 50;
selection.tier = Number(item?.native_tier ?? 0);
const range = item?.ranges.find((entry: any) => Number(entry.tier) === selection.tier);
for (const key of weaponRangeKeys) selection[key] = weaponStatRange(range, key, selection.level).min;
selection.damage = weaponStatRange(range, 'damage', selection.level).min;
const core = coreAllocationRange(selection.tier, selection.level, 'weapon');
for (const key of coreKeys) selection[key] = core.min;
selection.talent1Id = item?.talent1Ids[0] ?? '';
selection.talent2Id = item?.talent2Ids[0] ?? '';
selection.rolls = [blankRoll(), blankRoll()];
@@ -131,22 +146,24 @@
const changeWeaponTier = (index: number, tier: number) => {
const selection = draft.weapons[index], item = weaponItem(index);
selection.tier = tier;
if (tier === 2 && selection.level < 40) selection.level = 40;
const range = item?.ranges.find((entry: any) => Number(entry.tier) === tier);
for (const key of weaponRangeKeys) selection[key] = weaponStatRange(range, key, selection.level).min;
selection.damage = weaponStatRange(range, 'damage', selection.level).min;
const core = coreAllocationRange(tier, selection.level, 'weapon');
for (const key of coreKeys) selection[key] = core.min;
};
const changeWeaponLevel = (index: number, rawLevel: unknown) => {
const selection = draft.weapons[index], item = weaponItem(index);
const nextLevel = Math.round(clamp(rawLevel, 1, 50));
const nextLevel = Math.round(clamp(rawLevel, selection.tier === 2 ? 40 : 1, 50));
const range = item?.ranges.find((entry: any) => Number(entry.tier) === selection.tier);
if (!range || nextLevel === selection.level) {
selection.level = nextLevel;
return;
}
for (const key of weaponRangeKeys) selection[key] = remapRangeValue(
selection[key],
weaponStatRange(range, key, selection.level),
weaponStatRange(range, key, nextLevel)
);
selection.damage = remapRangeValue(selection.damage, weaponStatRange(range, 'damage', selection.level), weaponStatRange(range, 'damage', nextLevel));
const previousCore = coreAllocationRange(selection.tier, selection.level, 'weapon');
const nextCore = coreAllocationRange(selection.tier, nextLevel, 'weapon');
for (const key of coreKeys) selection[key] = remapRangeValue(selection[key], { ...previousCore, min: 0, max: previousCore.poolMax }, { ...nextCore, min: 0, max: nextCore.poolMax });
for (const roll of selection.rolls) {
const stat = attribute(roll.statId);
if (!stat) continue;
@@ -188,6 +205,7 @@
add(byName('Health'), selection.health * (1 + basePercentage / 100), `${slot} base`);
if (item.base_armor_min !== null) add(byName('Armor'), selection.armor * (1 + basePercentage / 100), `${slot} base`);
for (const roll of selection.rolls) add(roll.statId, roll.value, `${slot} roll`);
for (const key of coreKeys) add(byName(key), selection[key], `${slot} core`);
if (selection.brandId) brandCounts.set(selection.brandId, (brandCounts.get(selection.brandId) ?? 0) + 1);
for (const augment of enabledAugments.filter((entry) => entry.effect_type === 'Custom')) {
add(augment.stat_attribute_id, augment.value, `${slot} augment ${augment.augment_slot}`);
@@ -273,6 +291,8 @@
draft = parsed;
for (const slot of gearSlots) {
if (!Number.isInteger(draft.gear[slot].level)) draft.gear[slot].level = 50;
const item = gearItem(slot), core = coreAllocationRange(Number(item?.tier ?? 0), draft.gear[slot].level, 'gear');
for (const key of coreKeys) if (!Number.isFinite(draft.gear[slot][key])) draft.gear[slot][key] = core.min;
draft.gear[slot].rolls = [...(draft.gear[slot].rolls ?? []).slice(0, 2), blankRoll(), blankRoll()].slice(0, 2) as [Roll, Roll];
}
for (const weapon of draft.weapons) {
@@ -313,12 +333,15 @@
{@const healthRange = gearBaseRange(item, 'health', selection.level)}
{@const armorRange = item.base_armor_min !== null ? gearBaseRange(item, 'armor', selection.level) : null}
<div class="builder-equipment-controls">
<RangeValueControl label="GEAR LEVEL" min={1} max={50} value={selection.level} onchange={(value) => changeGearLevel(slot,value)}/>
<BuilderRadioGrid label="BRAND SET" name={`gear-${slot}-brand`} items={item.brandIds.map((id: string) => brand(id)).filter(Boolean)} bind:value={selection.brandId} columns={Math.min(item.brandIds.length,4)} required/>
<RangeValueControl label="GEAR LEVEL" min={Number(item.tier) === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeGearLevel(slot,value)}/>
<BuilderRadioGrid label="BRAND SET" name={`gear-${slot}-brand`} items={item.brandIds.map((id: string) => brand(id)).filter(Boolean)} bind:value={selection.brandId} columns={Math.min(item.brandIds.length,4)} iconOnly required/>
{#if item.talentIds.length}<BuilderRadioGrid label="EQUIPPED TALENT" name={`gear-${slot}-talent`} items={item.talentIds.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talentId} columns={Math.min(item.talentIds.length,4)} searchable={item.talentIds.length > 6} required/>{/if}
</div>
<p class:provisional={healthRange.provisional} class="builder-scaling-note">{healthRange.provisional ? 'Estimated range: levels 1–9 remain provisional while additional game samples are collected.' : `Ranges scaled from the stored level-${item.range_reference_level ?? 50} decimal endpoints. Display values are truncated to match the game.`}</p>
<div class="builder-range-grid"><RangeValueControl label="BASE HEALTH" min={healthRange.min} max={healthRange.max} provisional={healthRange.provisional} bind:value={selection.health}/>{#if armorRange}<RangeValueControl label="BASE ARMOR" min={armorRange.min} max={armorRange.max} provisional={armorRange.provisional} bind:value={selection.armor}/>{/if}</div>
{@const gearCore = coreAllocationRange(Number(item.tier), selection.level, 'gear')}
<div class="builder-section-title"><div><span>CORE ATTRIBUTE ALLOCATION</span><small>SHARED T{item.tier} POOL · VALUES MAY BE REDISTRIBUTED BETWEEN CORES</small></div><b class:restricted={coreTotal(selection) < gearCore.poolMin || coreTotal(selection) > gearCore.poolMax}>{coreTotal(selection)} / {gearCore.poolMin}–{gearCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={gearCore.poolMax} bind:value={selection[key]}/>{/each}</div>
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS · VALUES ARE LIMITED TO GEAR RANGES</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, index}{@const rollRange = attributeRange(attribute(roll.statId), 'gear', selection.level)}<div><BuilderPicker items={gearAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'gear',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== index).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${index + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
{@const augments = data.gearAugments.filter((entry) => entry.gear_slot === slot)}
@@ -331,12 +354,19 @@
{#if item}
{#if Number(item.is_exotic) && other && Number(other.is_exotic)}<p class="error">Only one exotic weapon may be equipped. Choose a standard weapon in either slot.</p>{/if}
<div class="builder-weapon-controls">
<RangeValueControl label="WEAPON LEVEL" min={1} max={50} value={selection.level} onchange={(value) => changeWeaponLevel(index,value)}/>
<RangeValueControl label="WEAPON LEVEL" min={selection.tier === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeWeaponLevel(index,value)}/>
<SegmentedChoice legend="TIER" name={`weapon-${index}-tier`} options={item.ranges.map((range: any) => ({ value: String(range.tier), label: `T${range.tier}` }))} value={String(selection.tier)} onchange={(values) => changeWeaponTier(index,Number(values[0]))} showIndicator={false} required/>
<div class="builder-fixed"><span>{item.rpm} RPM</span><span>{item.magazine} SHOTS</span><span>{item.reload_speed}s RELOAD</span></div>
</div>
{@const range = item.ranges.find((entry: any) => Number(entry.tier) === selection.tier)}
{#if range}<p class:provisional={selection.level < 40} class="builder-scaling-note">{selection.level < 40 ? 'Estimated range: the weapon curve below level 40 is provisional and may change as observations are added.' : `Ranges scaled from the stored level-${range.reference_level ?? 50} decimal endpoints. Display values are truncated to match the game.`}</p><div class="builder-range-grid weapon">{#each weaponRangeKeys as key}{@const statRange = weaponStatRange(range,key,selection.level)}<RangeValueControl label={key.toUpperCase()} min={statRange.min} max={statRange.max} provisional={statRange.provisional} bind:value={selection[key]}/>{/each}</div>{/if}
{#if range}
<p class:provisional={selection.level < 40} class="builder-scaling-note">{selection.level < 40 ? 'Estimated range: the weapon curve below level 40 is provisional and may change as observations are added.' : `Damage scaled from the stored level-${range.reference_level ?? 50} decimal endpoints. Core attributes use the shared tier allocation pool.`}</p>
{@const damageRange = weaponStatRange(range,'damage',selection.level)}
<div class="builder-range-grid weapon-damage"><RangeValueControl label="DAMAGE" min={damageRange.min} max={damageRange.max} provisional={damageRange.provisional} bind:value={selection.damage}/></div>
{@const weaponCore = coreAllocationRange(selection.tier, selection.level, 'weapon')}
<div class="builder-section-title"><div><span>CORE ATTRIBUTE ALLOCATION</span><small>SHARED T{selection.tier} POOL · VALUES MAY BE REDISTRIBUTED BETWEEN CORES</small></div><b class:restricted={coreTotal(selection) < weaponCore.poolMin || coreTotal(selection) > weaponCore.poolMax}>{coreTotal(selection)} / {weaponCore.poolMin}–{weaponCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={weaponCore.poolMax} bind:value={selection[key]}/>{/each}</div>
{/if}
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS · VALUES ARE LIMITED TO WEAPON RANGES</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, rollIndex}{@const rollRange = attributeRange(attribute(roll.statId), 'weapon', selection.level)}<div><BuilderPicker items={weaponAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'weapon',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== rollIndex).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${rollIndex + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
<div class="builder-talent-grids">