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
+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>