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
+11 -1
View File
@@ -220,7 +220,7 @@ INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moder
CREATE TABLE IF NOT EXISTS oauth_states (state_hash TEXT PRIMARY KEY, provider TEXT NOT NULL, mode TEXT NOT NULL, user_id TEXT REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS data_change_proposals (
id TEXT PRIMARY KEY,
module TEXT NOT NULL CHECK(module IN ('attributes','talents','brands','gear','weapons','protocols','skillchips')),
module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','protocols','skillchips')),
action TEXT NOT NULL,
entity_id TEXT,
draft_entity_id TEXT,
@@ -455,6 +455,16 @@ CREATE TABLE IF NOT EXISTS weapons (
)
);
CREATE UNIQUE INDEX IF NOT EXISTS weapons_name_nocase_idx ON weapons(name COLLATE NOCASE);
CREATE TABLE IF NOT EXISTS core_attribute_tier_ranges (
tier INTEGER PRIMARY KEY CHECK(tier BETWEEN 0 AND 2),
nominal_min REAL NOT NULL CHECK(nominal_min >= 0),
nominal_max REAL NOT NULL CHECK(nominal_max >= nominal_min),
reference_level INTEGER NOT NULL DEFAULT 50 CHECK(reference_level BETWEEN 1 AND 50),
updated_at TEXT NOT NULL
);
INSERT OR IGNORE INTO core_attribute_tier_ranges(tier,nominal_min,nominal_max,reference_level,updated_at) VALUES(0,30,78,50,CURRENT_TIMESTAMP);
INSERT OR IGNORE INTO core_attribute_tier_ranges(tier,nominal_min,nominal_max,reference_level,updated_at) VALUES(1,36,94,50,CURRENT_TIMESTAMP);
INSERT OR IGNORE INTO core_attribute_tier_ranges(tier,nominal_min,nominal_max,reference_level,updated_at) VALUES(2,44,112,50,CURRENT_TIMESTAMP);
CREATE TABLE IF NOT EXISTS weapon_tier_ranges (
weapon_id TEXT NOT NULL REFERENCES weapons(id) ON DELETE CASCADE,
tier INTEGER NOT NULL CHECK(tier BETWEEN 0 AND 2),
+45
View File
@@ -757,6 +757,51 @@ each keystroke does not create a navigation entry. Retain an explicit Search
button for immediate submission and keyboard/accessibility fallback. During
navigation, expose a textual pending state and suppress duplicate submissions.
Builder option grids support an icon-only square mode for small, icon-led
catalogs such as a gear item's two-to-four compatible Brand Sets. In this mode,
the compact 58-pixel cells reset the ordinary text-and-icon grid to a single
centered track, and the transparent icon fills that square. Align the fieldset
with adjacent compact controls rather than inheriting the standard radio-grid
top margin. Visible truncated names are omitted. Preserve the complete name as
the radio control's accessible label and
as hover text. Selection, hover, pressed, and keyboard-focus states remain on
the square perimeter; missing icons fall back to the entity initials.
Builder level controls enforce item availability without changing the scaling
curve's origin. T0 and T1 gear and weapons allow levels 1–50; T2 gear and
weapons allow levels 40–50 because lower-level T2 items are not found in game.
Their base-value progression still follows the same level-1-based curve.
Selecting T2 on a weapon below level 40 raises the configured level to 40 and
calculates the ordinary level-40 curve value; never rebase level 40 as T2 level
1. Slider bounds, direct-input validation, displayed bounds, and clamping must
all enforce the same availability minimum.
ISAC weapon-tier damage and core ranges are observations recorded at their
explicit reference level. The progression curve still has a level-1 origin,
but builder scaling must calculate the ratio between the target level and the
stored observation's `reference_level`. Do not treat every stored endpoint as a
level-1 value merely because the curve begins there.
### Shared core allocation
Firepower, Toughness, and Engineering use one system-wide tier profile shared
by gear and weapons. Maintain the T0/T1/T2 nominal ranges in ISAC Global
Configuration rather than repeating them on every item editor. The initial
level-50 profiles are T0 `30–78`, T1 `36–94`, and T2 `44–112`. Global
configuration mutations follow ordinary contributor moderation and append-only
ISAC audit rules.
The three nominal ranges define a combined allocation pool, not three hard
per-stat clamps. At a given level, the valid total lies between three times the
scaled nominal minimum and three times the scaled nominal maximum. An
individual core may be zero or exceed the nominal maximum when points are
redistributed, up to the entire pool maximum. Builder gear and weapon editors
show three sliders/direct inputs plus an explicit current-total versus
valid-pool readout; out-of-pool totals use textual red/restricted feedback.
Both equipment families contribute their configured cores to the build summary.
Treat this redistribution model as empirical and keep it configurable as more
in-game edge cases are observed.
Pending records may be used as dependencies in later proposals by any
contributor. Render these choices in the ordinary assignment control with a
restrained orange border/background and the explicit text `PENDING` plus the
+13 -1
View File
@@ -150,6 +150,19 @@ targetRaw =
/ weaponMultiplier(referenceLevel)
```
SHD.build stores the reference level associated with each weapon-tier range.
Builder calculations use that recorded observation level in the ratio above.
The curve's level-1 origin does not imply that every stored range was observed
at level 1. T2 availability limits likewise do not change a range's recorded
reference level.
Firepower, Toughness, and Engineering nominal tier ranges are stored once in
the shared core-allocation configuration at reference level 50. Scale those
nominal endpoints to the target level using the appropriate equipment-family
multiplier, then derive the combined pool bounds by multiplying each endpoint
by three. Redistribution changes the split between the three cores without
changing the selected item's total allocation.
The `1.14` offset is provisional and is currently validated only for the
level-40-to-level-50 interval. Preserve enough decimal precision to revise it
without migrating rounded data.
@@ -217,4 +230,3 @@ Useful known outputs include:
`2187 DMG`, `139 FP`, `142 TGH`, `36 ENG`.
- Weapon sample: level 40 `753 DMG`, `44 FP`, `15 TGH`, `45 ENG`; level 50
`873 DMG`, `51 FP`, `18 TGH`, `52 ENG`.
+3 -3
View File
@@ -96,12 +96,12 @@ await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_dra
const proposalDefinition = await (await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'"
)).get();
if (typeof proposalDefinition?.sql === 'string' && !proposalDefinition.sql.includes("'attributes'")) {
console.log('Renaming the moderated Stats module to Attributes.');
if (typeof proposalDefinition?.sql === 'string' && !proposalDefinition.sql.includes("'configuration'")) {
console.log('Expanding moderated data modules for Global Configuration.');
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec(`CREATE TABLE data_change_proposals_expanded (
id TEXT PRIMARY KEY,
module TEXT NOT NULL CHECK(module IN ('attributes','talents','brands','gear','weapons','protocols','skillchips')),
module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','protocols','skillchips')),
action TEXT NOT NULL,
entity_id TEXT,
draft_entity_id TEXT,
+2 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,5 +1,5 @@
export type Roll = { statId: string; value: number };
export type GearSelection = { itemId: string; level: number; brandId: string; talentId: string; health: number; armor: number; rolls: [Roll, Roll]; augmentSlots: number[] };
export type GearSelection = { itemId: string; level: number; brandId: string; talentId: string; health: number; armor: number; firepower: number; toughness: number; engineering: number; rolls: [Roll, Roll]; augmentSlots: number[] };
export type WeaponSelection = { itemId: string; level: number; tier: number; damage: number; firepower: number; toughness: number; engineering: number; talent1Id: string; talent2Id: string; rolls: [Roll, Roll] };
export type ChipSelection = { setId: string; quality: 'Superior' | 'High-end'; primary: Roll; secondary: [Roll, Roll] };
export type ProtocolSelection = { itemId: string; level: number; talentUpgrades: number };
@@ -15,7 +15,7 @@ export type LoadoutDraft = {
export const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'] as const;
export const blankRoll = (): Roll => ({ statId: '', value: 0 });
export const blankGear = (): GearSelection => ({ itemId: '', level: 50, brandId: '', talentId: '', health: 0, armor: 0, rolls: [blankRoll(), blankRoll()], augmentSlots: [] });
export const blankGear = (): GearSelection => ({ itemId: '', level: 50, brandId: '', talentId: '', health: 0, armor: 0, firepower: 0, toughness: 0, engineering: 0, rolls: [blankRoll(), blankRoll()], augmentSlots: [] });
export const blankWeapon = (): WeaponSelection => ({ itemId: '', level: 50, tier: 0, damage: 0, firepower: 0, toughness: 0, engineering: 0, talent1Id: '', talent2Id: '', rolls: [blankRoll(), blankRoll()] });
export const blankChip = (): ChipSelection => ({ setId: '', quality: 'Superior', primary: blankRoll(), secondary: [blankRoll(), blankRoll()] });
export const createDraft = (): LoadoutDraft => ({
+4 -2
View File
@@ -6,6 +6,7 @@
value = $bindable(''),
columns = Math.min(items.length, 4),
searchable = false,
iconOnly = false,
required = false,
onchange
}: {
@@ -15,6 +16,7 @@
value?: string;
columns?: number;
searchable?: boolean;
iconOnly?: boolean;
required?: boolean;
onchange?: (id: string) => void;
} = $props();
@@ -30,7 +32,7 @@
}
</script>
<fieldset class="builder-radio-grid" style={`--builder-radio-columns:${Math.max(1, columns)}`}>
<fieldset class="builder-radio-grid" class:icon-only={iconOnly} style={`--builder-radio-columns:${Math.max(1, columns)}`}>
<legend>{label}</legend>
{#if searchable}
<input class="builder-radio-search" type="search" bind:value={query} placeholder="SEARCH AVAILABLE OPTIONS" aria-label={`Search ${label.toLocaleLowerCase()}`}/>
@@ -38,7 +40,7 @@
<div>
{#each visible as item}
<label title={item.name}>
<input type="radio" {name} value={item.id} checked={value === item.id} {required} onchange={() => choose(item.id)}/>
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
<span>
{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<i>{item.name.slice(0,2).toUpperCase()}</i>{/if}
<b>{item.name}</b>
+2 -1
View File
@@ -5,10 +5,11 @@ import type { AuthUser } from '$lib/server/auth/session';
import { recordIsacAudit } from '$lib/server/audit';
import { getContributorMode } from '$lib/server/contributor-mode';
export const DATA_MODULES = ['attributes', 'talents', 'brands', 'gear', 'weapons', 'protocols', 'skillchips'] as const;
export const DATA_MODULES = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'protocols', 'skillchips'] as const;
export type DataModule = typeof DATA_MODULES[number];
const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: string; mode?: string }>>> = {
configuration: { saveCoreRanges: { saved: 'configurationSaved' } },
attributes: { save: { saved: 'saved', mode: 'editorMode' } },
brands: { save: { saved: 'brandSaved', mode: 'brandMode' } },
gear: {
+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">
+1
View File
@@ -10,6 +10,7 @@
const dataModeClass = $derived(dataMode.toLowerCase().replace(' ', '-'));
let readOnlyBlocked = $state(false);
const dataSections = [
{ href: '/isac/configuration', label: 'Global configuration', code: 'CONF', module: 'configuration' },
{ href: '/isac/attributes', label: 'Attributes', code: 'ATTR', module: 'attributes' },
{ href: '/isac/talents', label: 'Talents', code: 'TLNT', module: 'talents' },
{ href: '/isac/brands', label: 'Brand sets', code: 'BRND', module: 'brands' },
+1 -1
View File
@@ -7,7 +7,7 @@ export const load: PageServerLoad = async ({ cookies }) => {
const permissions = await getPermissions(await getSessionUser(cookies));
if (permissions.includes('*') || permissions.includes('users.view')) redirect(303, '/isac/users');
if (permissions.includes('users.roles')) redirect(303, '/isac/roles');
if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/isac/attributes');
if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/isac/configuration');
if (permissions.includes('audit.view')) redirect(303, '/isac/audit');
if (permissions.includes('metrics.view')) redirect(303, '/isac/metrics');
redirect(303, '/account');
@@ -0,0 +1,39 @@
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import { getSessionUser } from '$lib/server/auth/session';
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import { recordIsacAudit } from '$lib/server/audit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
await requireDataView(await getSessionUser(cookies));
const ranges = await (await (await getDatabase()).prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')).all();
return { ranges };
};
export const actions: Actions = {
saveCoreRanges: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const ranges = [0, 1, 2].map((tier) => ({
tier,
min: Number(form.get(`tier${tier}Min`)),
max: Number(form.get(`tier${tier}Max`))
}));
if (ranges.some((range) => !Number.isFinite(range.min) || !Number.isFinite(range.max) || range.min < 0 || range.max < range.min)) {
return fail(400, { configurationError: 'Every tier requires a valid nominal minimum and maximum.' });
}
const database = await getDatabase();
const before = await (await database.prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')).all();
const now = new Date().toISOString();
for (const range of ranges) {
await (await database.prepare(`INSERT INTO core_attribute_tier_ranges(tier,nominal_min,nominal_max,reference_level,updated_at)
VALUES(?,?,?,50,?) ON CONFLICT(tier) DO UPDATE SET nominal_min=excluded.nominal_min,nominal_max=excluded.nominal_max,updated_at=excluded.updated_at`))
.run(range.tier, range.min, range.max, now);
}
const after = await (await database.prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')).all();
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'configuration.core_ranges_updated', entityId: 'core-attribute-tier-ranges', before, after });
return { configurationSaved: true, resultToken: randomUUID() };
}
};
@@ -0,0 +1,25 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import GameIcon from '$lib/components/GameIcon.svelte';
let { data, form } = $props();
const range = (tier: number) => data.ranges.find((item) => Number(item.tier) === tier) ?? { nominal_min: 0, nominal_max: 0 };
</script>
<svelte:head><title>Global Configuration — ISAC</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">SYSTEM-WIDE BUILDER VARIABLES</p><h2>GLOBAL CONFIGURATION</h2><p>Shared game rules consumed across gear, weapons, and calculation surfaces.</p></div></div>
<div class="dashboard-editor global-configuration-editor">
<div class="editor-title"><div><p class="eyebrow">SHARED ALLOCATION PROFILE</p><h3>CORE ATTRIBUTE TIER RANGES</h3></div><b>REFERENCE LEVEL 50</b></div>
<p class="configuration-note">Firepower, Toughness, and Engineering share these nominal armory ranges. Builder allocations may redistribute the combined pool, including zero in one core and values above the nominal individual maximum.</p>
<PendingForm action="?/saveCoreRanges">
<input type="hidden" name="id" value="core-attribute-tier-ranges"/>
<input type="hidden" name="name" value="Core attribute tier ranges"/>
<div class="core-configuration-grid">
{#each [0,1,2] as tier}
<fieldset><legend>T{tier}</legend><div class="core-config-icons"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></div><label class="field">NOMINAL MIN<input name={`tier${tier}Min`} type="number" min="0" step="any" value={range(tier).nominal_min} required/></label><label class="field">NOMINAL MAX<input name={`tier${tier}Max`} type="number" min="0" step="any" value={range(tier).nominal_max} required/></label><small>POOL {Number(range(tier).nominal_min) * 3}–{Number(range(tier).nominal_max) * 3}</small></fieldset>
{/each}
</div>
{#if form?.configurationError}<p class="error">{form.configurationError}</p>{/if}
{#if form?.configurationSaved}<p class="success">Shared core allocation ranges updated.</p>{/if}
<div class="form-actions"><button class="primary">SAVE GLOBAL RANGES →</button></div>
</PendingForm>
</div>
@@ -10,7 +10,7 @@ import type { Actions, PageServerLoad } from './$types';
import { createNotification } from '$lib/server/notifications';
const statuses = ['pending', 'approved', 'rejected', 'failed'];
const modules = ['attributes', 'talents', 'brands', 'gear', 'weapons', 'protocols', 'skillchips'];
const modules = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'protocols', 'skillchips'];
type SerializedEntry =
| { name: string; kind: 'text'; value: string }
| { name: string; kind: 'file'; value: string; fileName: string; type: string };
+7 -8
View File
@@ -11,7 +11,7 @@ import { getPendingEntities } from '$lib/server/data-moderation';
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
const rangeNames = ['damage', 'core'] as const;
const rangeNames = ['damage'] as const;
type ExoticEffect = { statId: string; valueI: number; valueII: number };
const mechanicValue = (value: number, singular: string) => `${value} ${value === 1 ? singular : `${singular}s`}`;
@@ -236,17 +236,13 @@ export const actions: Actions = {
const tiers = [];
try {
for (let tier = nativeTier; tier <= 2; tier++) {
const core = readRange(form, tier, 'core');
tiers.push({
tier,
damage: readRange(form, tier, 'damage'),
firepower: core,
toughness: core,
engineering: core
damage: readRange(form, tier, 'damage')
});
}
} catch {
return fail(400, { weaponError: 'Every available tier requires valid Damage and shared Core ranges.' });
return fail(400, { weaponError: 'Every available tier requires a valid Damage range.' });
}
const augmentEffects = [];
@@ -369,10 +365,13 @@ export const actions: Actions = {
}
await (await database.prepare('DELETE FROM weapon_tier_ranges WHERE weapon_id=?')).run(weaponId);
const sharedCoreRanges = await (await database.prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')).all();
for (const tier of tiers) {
const core = sharedCoreRanges.find((range) => Number(range.tier) === tier.tier);
if (!core) return fail(500, { weaponError: `Global core configuration is missing T${tier.tier}.` });
await (await database.prepare(`INSERT INTO weapon_tier_ranges(weapon_id,tier,damage_min,damage_max,firepower_min,
firepower_max,toughness_min,toughness_max,engineering_min,engineering_max) VALUES(?,?,?,?,?,?,?,?,?,?)`))
.run(weaponId, tier.tier, tier.damage.min, tier.damage.max, tier.firepower.min, tier.firepower.max, tier.toughness.min, tier.toughness.max, tier.engineering.min, tier.engineering.max);
.run(weaponId, tier.tier, tier.damage.min, tier.damage.max, core.nominal_min, core.nominal_max, core.nominal_min, core.nominal_max, core.nominal_min, core.nominal_max);
}
await (await database.prepare('DELETE FROM weapon_talent_options WHERE weapon_id=?')).run(weaponId);
for (const talentId of talent1Ids) await (await database.prepare('INSERT INTO weapon_talent_options(weapon_id,talent_slot,talent_id) VALUES(?,?,?)')).run(weaponId, 1, talentId);
+3 -4
View File
@@ -14,7 +14,7 @@
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
const iconName = (value: string): GameIconName => value.toLowerCase() as GameIconName;
const damageTypeOptions = damageTypes.map((value) => ({ value, label: value, icon: iconName(value), iconOnly: true }));
const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0, core_min: 0, core_max: 0 });
const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0 });
const blankExoticTalent = () => ({ id: '', name: '', iconUrl: '', cooldown_seconds: '', duration_seconds: '', max_stacks: '', descriptionTemplate: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] });
const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: '0', damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', exoticTalent: blankExoticTalent(), active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }], baseBonuses: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
let editing = $state<any | null>(null);
@@ -24,7 +24,7 @@
const edit = (weapon: any) => {
const tiers = [0, 1, 2].map((tier) => {
const saved = weapon.tiers.find((item: any) => Number(item.tier) === tier);
return { ...blankTier(tier), ...saved, core_min: saved?.firepower_min ?? 0, core_max: saved?.firepower_max ?? 0 };
return { ...blankTier(tier), ...saved };
});
editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })), baseBonuses: weapon.baseBonuses.map((effect: any) => ({ ...effect })), exoticTalent: weapon.exoticTalent ? { ...weapon.exoticTalent, triggerIds: [...weapon.exoticTalent.triggerIds], effects: weapon.exoticTalent.effects.map((effect: any) => ({ ...effect })) } : blankExoticTalent() };
};
@@ -129,13 +129,12 @@
<div class="base-bonus-row"><strong>{String(index + 1).padStart(2, '0')}</strong><div class="field"><span>ATTRIBUTE</span><SearchableAssignment items={data.stats} fieldName={`baseBonusStat${index + 1}`} mode="single" compact subjectLabel="ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={bonus.stat_attribute_id}/></div><label class="field">VALUE<input name={`baseBonusValue${index + 1}`} type="number" step="any" bind:value={bonus.value} required/></label><button type="button" aria-label={`Remove base bonus ${index + 1}`} onclick={() => removeBaseBonus(index)} disabled={editing.baseBonuses.length === 1}>×</button></div>
{/each}
</div>
<div class="selection-head"><div><span>TIER PROGRESSION</span><small>DAMAGE AND CORE ATTRIBUTE RANGES</small></div><b>3 TIERS REQUIRED</b></div>
<div class="selection-head"><div><span>TIER PROGRESSION</span><small>DAMAGE RANGES · CORE ATTRIBUTES USE GLOBAL CONFIGURATION</small></div><b>3 TIERS REQUIRED</b></div>
<div class="weapon-tier-grid">
{#each [0, 1, 2] as tier}
<fieldset class="weapon-tier-card" class:unavailable={tier < Number(editing.native_tier)} disabled={tier < Number(editing.native_tier)}><legend>TIER {tier}</legend>
{#if tier < Number(editing.native_tier)}<p class="tier-unavailable">BELOW NATIVE TIER · NOT AVAILABLE</p>{/if}
<div class="weapon-range-row"><strong><GameIcon name={iconName(editing.damage_type)} decorative/><span>DMG<small>{editing.damage_type.toUpperCase()} DAMAGE</small></span></strong><label>MIN<input name={`tier${tier}damageMin`} type="number" min="0" step="any" value={tierAt(tier).damage_min} oninput={(event) => tierAt(tier).damage_min = event.currentTarget.valueAsNumber} required/></label><label>MAX<input name={`tier${tier}damageMax`} type="number" min="0" step="any" value={tierAt(tier).damage_max} oninput={(event) => tierAt(tier).damage_max = event.currentTarget.valueAsNumber} required/></label></div>
<div class="weapon-range-row core-range-row"><strong><span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span><span>CORE<small>FP · TGH · ENG</small></span></strong><label>MIN<input name={`tier${tier}coreMin`} type="number" min="0" step="any" value={tierAt(tier).core_min} oninput={(event) => tierAt(tier).core_min = event.currentTarget.valueAsNumber} required/></label><label>MAX<input name={`tier${tier}coreMax`} type="number" min="0" step="any" value={tierAt(tier).core_max} oninput={(event) => tierAt(tier).core_max = event.currentTarget.valueAsNumber} required/></label></div>
</fieldset>
{/each}
</div>