Add shared core attribute allocation
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user