Add shared core attribute allocation
This commit is contained in:
+11
-1
@@ -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 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 (
|
CREATE TABLE IF NOT EXISTS data_change_proposals (
|
||||||
id TEXT PRIMARY KEY,
|
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,
|
action TEXT NOT NULL,
|
||||||
entity_id TEXT,
|
entity_id TEXT,
|
||||||
draft_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 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 (
|
CREATE TABLE IF NOT EXISTS weapon_tier_ranges (
|
||||||
weapon_id TEXT NOT NULL REFERENCES weapons(id) ON DELETE CASCADE,
|
weapon_id TEXT NOT NULL REFERENCES weapons(id) ON DELETE CASCADE,
|
||||||
tier INTEGER NOT NULL CHECK(tier BETWEEN 0 AND 2),
|
tier INTEGER NOT NULL CHECK(tier BETWEEN 0 AND 2),
|
||||||
|
|||||||
@@ -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
|
button for immediate submission and keyboard/accessibility fallback. During
|
||||||
navigation, expose a textual pending state and suppress duplicate submissions.
|
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
|
Pending records may be used as dependencies in later proposals by any
|
||||||
contributor. Render these choices in the ordinary assignment control with a
|
contributor. Render these choices in the ordinary assignment control with a
|
||||||
restrained orange border/background and the explicit text `PENDING` plus the
|
restrained orange border/background and the explicit text `PENDING` plus the
|
||||||
|
|||||||
@@ -150,6 +150,19 @@ targetRaw =
|
|||||||
/ weaponMultiplier(referenceLevel)
|
/ 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
|
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
|
level-40-to-level-50 interval. Preserve enough decimal precision to revise it
|
||||||
without migrating rounded data.
|
without migrating rounded data.
|
||||||
@@ -217,4 +230,3 @@ Useful known outputs include:
|
|||||||
`2187 DMG`, `139 FP`, `142 TGH`, `36 ENG`.
|
`2187 DMG`, `139 FP`, `142 TGH`, `36 ENG`.
|
||||||
- Weapon sample: level 40 `753 DMG`, `44 FP`, `15 TGH`, `45 ENG`; level 50
|
- Weapon sample: level 40 `753 DMG`, `44 FP`, `15 TGH`, `45 ENG`; level 50
|
||||||
`873 DMG`, `51 FP`, `18 TGH`, `52 ENG`.
|
`873 DMG`, `51 FP`, `18 TGH`, `52 ENG`.
|
||||||
|
|
||||||
|
|||||||
@@ -96,12 +96,12 @@ await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_dra
|
|||||||
const proposalDefinition = await (await database.prepare(
|
const proposalDefinition = await (await database.prepare(
|
||||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'"
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'"
|
||||||
)).get();
|
)).get();
|
||||||
if (typeof proposalDefinition?.sql === 'string' && !proposalDefinition.sql.includes("'attributes'")) {
|
if (typeof proposalDefinition?.sql === 'string' && !proposalDefinition.sql.includes("'configuration'")) {
|
||||||
console.log('Renaming the moderated Stats module to Attributes.');
|
console.log('Expanding moderated data modules for Global Configuration.');
|
||||||
await database.exec('PRAGMA foreign_keys=OFF');
|
await database.exec('PRAGMA foreign_keys=OFF');
|
||||||
await database.exec(`CREATE TABLE data_change_proposals_expanded (
|
await database.exec(`CREATE TABLE data_change_proposals_expanded (
|
||||||
id TEXT PRIMARY KEY,
|
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,
|
action TEXT NOT NULL,
|
||||||
entity_id TEXT,
|
entity_id TEXT,
|
||||||
draft_entity_id TEXT,
|
draft_entity_id TEXT,
|
||||||
|
|||||||
+2
-1
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
|||||||
export type Roll = { statId: string; value: number };
|
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 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 ChipSelection = { setId: string; quality: 'Superior' | 'High-end'; primary: Roll; secondary: [Roll, Roll] };
|
||||||
export type ProtocolSelection = { itemId: string; level: number; talentUpgrades: number };
|
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 gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'] as const;
|
||||||
export const blankRoll = (): Roll => ({ statId: '', value: 0 });
|
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 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 blankChip = (): ChipSelection => ({ setId: '', quality: 'Superior', primary: blankRoll(), secondary: [blankRoll(), blankRoll()] });
|
||||||
export const createDraft = (): LoadoutDraft => ({
|
export const createDraft = (): LoadoutDraft => ({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
value = $bindable(''),
|
value = $bindable(''),
|
||||||
columns = Math.min(items.length, 4),
|
columns = Math.min(items.length, 4),
|
||||||
searchable = false,
|
searchable = false,
|
||||||
|
iconOnly = false,
|
||||||
required = false,
|
required = false,
|
||||||
onchange
|
onchange
|
||||||
}: {
|
}: {
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
value?: string;
|
value?: string;
|
||||||
columns?: number;
|
columns?: number;
|
||||||
searchable?: boolean;
|
searchable?: boolean;
|
||||||
|
iconOnly?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
onchange?: (id: string) => void;
|
onchange?: (id: string) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
@@ -30,7 +32,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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>
|
<legend>{label}</legend>
|
||||||
{#if searchable}
|
{#if searchable}
|
||||||
<input class="builder-radio-search" type="search" bind:value={query} placeholder="SEARCH AVAILABLE OPTIONS" aria-label={`Search ${label.toLocaleLowerCase()}`}/>
|
<input class="builder-radio-search" type="search" bind:value={query} placeholder="SEARCH AVAILABLE OPTIONS" aria-label={`Search ${label.toLocaleLowerCase()}`}/>
|
||||||
@@ -38,7 +40,7 @@
|
|||||||
<div>
|
<div>
|
||||||
{#each visible as item}
|
{#each visible as item}
|
||||||
<label title={item.name}>
|
<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>
|
<span>
|
||||||
{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<i>{item.name.slice(0,2).toUpperCase()}</i>{/if}
|
{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<i>{item.name.slice(0,2).toUpperCase()}</i>{/if}
|
||||||
<b>{item.name}</b>
|
<b>{item.name}</b>
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import type { AuthUser } from '$lib/server/auth/session';
|
|||||||
import { recordIsacAudit } from '$lib/server/audit';
|
import { recordIsacAudit } from '$lib/server/audit';
|
||||||
import { getContributorMode } from '$lib/server/contributor-mode';
|
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];
|
export type DataModule = typeof DATA_MODULES[number];
|
||||||
|
|
||||||
const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: string; mode?: string }>>> = {
|
const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: string; mode?: string }>>> = {
|
||||||
|
configuration: { saveCoreRanges: { saved: 'configurationSaved' } },
|
||||||
attributes: { save: { saved: 'saved', mode: 'editorMode' } },
|
attributes: { save: { saved: 'saved', mode: 'editorMode' } },
|
||||||
brands: { save: { saved: 'brandSaved', mode: 'brandMode' } },
|
brands: { save: { saved: 'brandSaved', mode: 'brandMode' } },
|
||||||
gear: {
|
gear: {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const load: PageServerLoad = async () => {
|
|||||||
weapons, weaponRanges, weaponTalentOptions, weaponBonuses,
|
weapons, weaponRanges, weaponTalentOptions, weaponBonuses,
|
||||||
talents, talentTiers, talentEffects, talentTemplates,
|
talents, talentTiers, talentEffects, talentTemplates,
|
||||||
chipSets, chipTwoPiece, chipPrimary, chipSecondary, chipTalents, chipTalentEffects,
|
chipSets, chipTwoPiece, chipPrimary, chipSecondary, chipTalents, chipTalentEffects,
|
||||||
protocols, protocolPrimary, protocolProfiles, protocolSecondary, protocolTalents, protocolTalentEffects,
|
protocols, protocolPrimary, protocolProfiles, protocolSecondary, protocolTalents, protocolTalentEffects, coreRanges,
|
||||||
icons
|
icons
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
(await database.prepare('SELECT * FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).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_secondary_effects ORDER BY effect_role')).all(),
|
||||||
(await database.prepare('SELECT * FROM os_protocol_talents')).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 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()
|
(await database.prepare("SELECT * FROM game_data_icons")).all()
|
||||||
]);
|
]);
|
||||||
const iconUrl = (type: 'talent' | 'brand_set' | 'os_protocol' | 'skill_chip', id: unknown) => {
|
const iconUrl = (type: 'talent' | 'brand_set' | 'os_protocol' | 'skill_chip', id: unknown) => {
|
||||||
@@ -57,6 +58,7 @@ export const load: PageServerLoad = async () => {
|
|||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
attributes,
|
attributes,
|
||||||
|
coreRanges,
|
||||||
gear: gear.map((item) => ({
|
gear: gear.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
brandIds: gearBrands.filter((link) => link.gear_id === item.id).map((link) => String(link.brand_set_id)),
|
brandIds: gearBrands.filter((link) => link.gear_id === item.id).map((link) => String(link.brand_set_id)),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
let saved = $state(false);
|
let saved = $state(false);
|
||||||
let message = $state('');
|
let message = $state('');
|
||||||
const weaponRangeKeys = ['damage','firepower','toughness','engineering'] as const;
|
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 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 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 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) =>
|
const gearBaseRange = (item: any, key: 'health' | 'armor', level: number) =>
|
||||||
scaledRange(item?.[`base_${key}_min`], item?.[`base_${key}_max`], level, item?.range_reference_level, 'gear');
|
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) =>
|
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) =>
|
const attributeRange = (stat: any, source: 'gear' | 'weapon', level: number) =>
|
||||||
scaledRange(stat?.[`${source}_min`], stat?.[`${source}_max`], level, stat?.[`${source}_reference_level`], source);
|
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' | 'chip', level = 50) => {
|
||||||
@@ -75,12 +83,14 @@
|
|||||||
selection.talentId = item?.talentIds[0] ?? '';
|
selection.talentId = item?.talentIds[0] ?? '';
|
||||||
selection.health = Number(item?.base_health_min ?? 0);
|
selection.health = Number(item?.base_health_min ?? 0);
|
||||||
selection.armor = Number(item?.base_armor_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.rolls = [blankRoll(), blankRoll()];
|
||||||
selection.augmentSlots = [];
|
selection.augmentSlots = [];
|
||||||
};
|
};
|
||||||
const changeGearLevel = (slot: string, rawLevel: unknown) => {
|
const changeGearLevel = (slot: string, rawLevel: unknown) => {
|
||||||
const selection = draft.gear[slot], item = gearItem(slot);
|
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) {
|
if (!item || nextLevel === selection.level) {
|
||||||
selection.level = nextLevel;
|
selection.level = nextLevel;
|
||||||
return;
|
return;
|
||||||
@@ -95,6 +105,9 @@
|
|||||||
gearBaseRange(item, 'armor', selection.level),
|
gearBaseRange(item, 'armor', selection.level),
|
||||||
gearBaseRange(item, 'armor', nextLevel)
|
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) {
|
for (const roll of selection.rolls) {
|
||||||
const stat = attribute(roll.statId);
|
const stat = attribute(roll.statId);
|
||||||
if (!stat) continue;
|
if (!stat) continue;
|
||||||
@@ -112,7 +125,9 @@
|
|||||||
selection.level = 50;
|
selection.level = 50;
|
||||||
selection.tier = Number(item?.native_tier ?? 0);
|
selection.tier = Number(item?.native_tier ?? 0);
|
||||||
const range = item?.ranges.find((entry: any) => Number(entry.tier) === selection.tier);
|
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.talent1Id = item?.talent1Ids[0] ?? '';
|
||||||
selection.talent2Id = item?.talent2Ids[0] ?? '';
|
selection.talent2Id = item?.talent2Ids[0] ?? '';
|
||||||
selection.rolls = [blankRoll(), blankRoll()];
|
selection.rolls = [blankRoll(), blankRoll()];
|
||||||
@@ -131,22 +146,24 @@
|
|||||||
const changeWeaponTier = (index: number, tier: number) => {
|
const changeWeaponTier = (index: number, tier: number) => {
|
||||||
const selection = draft.weapons[index], item = weaponItem(index);
|
const selection = draft.weapons[index], item = weaponItem(index);
|
||||||
selection.tier = tier;
|
selection.tier = tier;
|
||||||
|
if (tier === 2 && selection.level < 40) selection.level = 40;
|
||||||
const range = item?.ranges.find((entry: any) => Number(entry.tier) === tier);
|
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 changeWeaponLevel = (index: number, rawLevel: unknown) => {
|
||||||
const selection = draft.weapons[index], item = weaponItem(index);
|
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);
|
const range = item?.ranges.find((entry: any) => Number(entry.tier) === selection.tier);
|
||||||
if (!range || nextLevel === selection.level) {
|
if (!range || nextLevel === selection.level) {
|
||||||
selection.level = nextLevel;
|
selection.level = nextLevel;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const key of weaponRangeKeys) selection[key] = remapRangeValue(
|
selection.damage = remapRangeValue(selection.damage, weaponStatRange(range, 'damage', selection.level), weaponStatRange(range, 'damage', nextLevel));
|
||||||
selection[key],
|
const previousCore = coreAllocationRange(selection.tier, selection.level, 'weapon');
|
||||||
weaponStatRange(range, key, selection.level),
|
const nextCore = coreAllocationRange(selection.tier, nextLevel, 'weapon');
|
||||||
weaponStatRange(range, key, nextLevel)
|
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) {
|
for (const roll of selection.rolls) {
|
||||||
const stat = attribute(roll.statId);
|
const stat = attribute(roll.statId);
|
||||||
if (!stat) continue;
|
if (!stat) continue;
|
||||||
@@ -188,6 +205,7 @@
|
|||||||
add(byName('Health'), selection.health * (1 + basePercentage / 100), `${slot} base`);
|
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`);
|
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 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);
|
if (selection.brandId) brandCounts.set(selection.brandId, (brandCounts.get(selection.brandId) ?? 0) + 1);
|
||||||
for (const augment of enabledAugments.filter((entry) => entry.effect_type === 'Custom')) {
|
for (const augment of enabledAugments.filter((entry) => entry.effect_type === 'Custom')) {
|
||||||
add(augment.stat_attribute_id, augment.value, `${slot} augment ${augment.augment_slot}`);
|
add(augment.stat_attribute_id, augment.value, `${slot} augment ${augment.augment_slot}`);
|
||||||
@@ -273,6 +291,8 @@
|
|||||||
draft = parsed;
|
draft = parsed;
|
||||||
for (const slot of gearSlots) {
|
for (const slot of gearSlots) {
|
||||||
if (!Number.isInteger(draft.gear[slot].level)) draft.gear[slot].level = 50;
|
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];
|
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) {
|
for (const weapon of draft.weapons) {
|
||||||
@@ -313,12 +333,15 @@
|
|||||||
{@const healthRange = gearBaseRange(item, 'health', selection.level)}
|
{@const healthRange = gearBaseRange(item, 'health', selection.level)}
|
||||||
{@const armorRange = item.base_armor_min !== null ? gearBaseRange(item, 'armor', selection.level) : null}
|
{@const armorRange = item.base_armor_min !== null ? gearBaseRange(item, 'armor', selection.level) : null}
|
||||||
<div class="builder-equipment-controls">
|
<div class="builder-equipment-controls">
|
||||||
<RangeValueControl label="GEAR LEVEL" min={1} max={50} value={selection.level} onchange={(value) => changeGearLevel(slot,value)}/>
|
<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)} required/>
|
<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}
|
{#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>
|
</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>
|
<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>
|
<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-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>
|
<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)}
|
{@const augments = data.gearAugments.filter((entry) => entry.gear_slot === slot)}
|
||||||
@@ -331,12 +354,19 @@
|
|||||||
{#if item}
|
{#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}
|
{#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">
|
<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/>
|
<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 class="builder-fixed"><span>{item.rpm} RPM</span><span>{item.magazine} SHOTS</span><span>{item.reload_speed}s RELOAD</span></div>
|
||||||
</div>
|
</div>
|
||||||
{@const range = item.ranges.find((entry: any) => Number(entry.tier) === selection.tier)}
|
{@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-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-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">
|
<div class="builder-talent-grids">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
const dataModeClass = $derived(dataMode.toLowerCase().replace(' ', '-'));
|
const dataModeClass = $derived(dataMode.toLowerCase().replace(' ', '-'));
|
||||||
let readOnlyBlocked = $state(false);
|
let readOnlyBlocked = $state(false);
|
||||||
const dataSections = [
|
const dataSections = [
|
||||||
|
{ href: '/isac/configuration', label: 'Global configuration', code: 'CONF', module: 'configuration' },
|
||||||
{ href: '/isac/attributes', label: 'Attributes', code: 'ATTR', module: 'attributes' },
|
{ href: '/isac/attributes', label: 'Attributes', code: 'ATTR', module: 'attributes' },
|
||||||
{ href: '/isac/talents', label: 'Talents', code: 'TLNT', module: 'talents' },
|
{ href: '/isac/talents', label: 'Talents', code: 'TLNT', module: 'talents' },
|
||||||
{ href: '/isac/brands', label: 'Brand sets', code: 'BRND', module: 'brands' },
|
{ href: '/isac/brands', label: 'Brand sets', code: 'BRND', module: 'brands' },
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
|||||||
const permissions = await getPermissions(await getSessionUser(cookies));
|
const permissions = await getPermissions(await getSessionUser(cookies));
|
||||||
if (permissions.includes('*') || permissions.includes('users.view')) redirect(303, '/isac/users');
|
if (permissions.includes('*') || permissions.includes('users.view')) redirect(303, '/isac/users');
|
||||||
if (permissions.includes('users.roles')) redirect(303, '/isac/roles');
|
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('audit.view')) redirect(303, '/isac/audit');
|
||||||
if (permissions.includes('metrics.view')) redirect(303, '/isac/metrics');
|
if (permissions.includes('metrics.view')) redirect(303, '/isac/metrics');
|
||||||
redirect(303, '/account');
|
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';
|
import { createNotification } from '$lib/server/notifications';
|
||||||
|
|
||||||
const statuses = ['pending', 'approved', 'rejected', 'failed'];
|
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 =
|
type SerializedEntry =
|
||||||
| { name: string; kind: 'text'; value: string }
|
| { name: string; kind: 'text'; value: string }
|
||||||
| { name: string; kind: 'file'; value: string; fileName: string; type: string };
|
| { name: string; kind: 'file'; value: string; fileName: string; type: string };
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { getPendingEntities } from '$lib/server/data-moderation';
|
|||||||
|
|
||||||
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
|
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
|
||||||
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
||||||
const rangeNames = ['damage', 'core'] as const;
|
const rangeNames = ['damage'] as const;
|
||||||
type ExoticEffect = { statId: string; valueI: number; valueII: number };
|
type ExoticEffect = { statId: string; valueI: number; valueII: number };
|
||||||
|
|
||||||
const mechanicValue = (value: number, singular: string) => `${value} ${value === 1 ? singular : `${singular}s`}`;
|
const mechanicValue = (value: number, singular: string) => `${value} ${value === 1 ? singular : `${singular}s`}`;
|
||||||
@@ -236,17 +236,13 @@ export const actions: Actions = {
|
|||||||
const tiers = [];
|
const tiers = [];
|
||||||
try {
|
try {
|
||||||
for (let tier = nativeTier; tier <= 2; tier++) {
|
for (let tier = nativeTier; tier <= 2; tier++) {
|
||||||
const core = readRange(form, tier, 'core');
|
|
||||||
tiers.push({
|
tiers.push({
|
||||||
tier,
|
tier,
|
||||||
damage: readRange(form, tier, 'damage'),
|
damage: readRange(form, tier, 'damage')
|
||||||
firepower: core,
|
|
||||||
toughness: core,
|
|
||||||
engineering: core
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} 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 = [];
|
const augmentEffects = [];
|
||||||
@@ -369,10 +365,13 @@ export const actions: Actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await (await database.prepare('DELETE FROM weapon_tier_ranges WHERE weapon_id=?')).run(weaponId);
|
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) {
|
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,
|
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(?,?,?,?,?,?,?,?,?,?)`))
|
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);
|
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);
|
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);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
||||||
const iconName = (value: string): GameIconName => value.toLowerCase() as GameIconName;
|
const iconName = (value: string): GameIconName => value.toLowerCase() as GameIconName;
|
||||||
const damageTypeOptions = damageTypes.map((value) => ({ value, label: value, icon: iconName(value), iconOnly: true }));
|
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 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 }] });
|
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);
|
let editing = $state<any | null>(null);
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
const edit = (weapon: any) => {
|
const edit = (weapon: any) => {
|
||||||
const tiers = [0, 1, 2].map((tier) => {
|
const tiers = [0, 1, 2].map((tier) => {
|
||||||
const saved = weapon.tiers.find((item: any) => Number(item.tier) === 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() };
|
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>
|
<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}
|
{/each}
|
||||||
</div>
|
</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">
|
<div class="weapon-tier-grid">
|
||||||
{#each [0, 1, 2] as tier}
|
{#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>
|
<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}
|
{#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"><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>
|
</fieldset>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user