feat(isac): add OS protocol management

This commit is contained in:
JB
2026-07-25 05:20:25 -04:00
parent fa21913112
commit 9f712e7790
27 changed files with 555 additions and 11 deletions
+4 -1
View File
@@ -108,7 +108,10 @@ with `npm run db:backup`.
Database work must follow these rules: Database work must follow these rules:
1. Confirm which environment and exact database path are in scope. 1. Confirm which environment and exact database path are in scope.
2. Stop processes that hold the embedded database lock before backup or restore. 2. Before every database backup or restore, inspect running processes and stop
only this project's development-server process tree. The embedded Turso
database holds an exclusive file lock while the dev server is running, so do
not attempt the backup first and wait for a locking failure.
3. Create and verify a JSON backup before destructive work. 3. Create and verify a JSON backup before destructive work.
4. Never overwrite or delete the only available backup. 4. Never overwrite or delete the only available backup.
5. Keep backups out of Git; they can contain user records and password hashes. 5. Keep backups out of Git; they can contain user records and password hashes.
+56
View File
@@ -348,3 +348,59 @@ CREATE TABLE IF NOT EXISTS weapon_base_attribute_bonuses (
UNIQUE(weapon_id, stat_attribute_id), UNIQUE(weapon_id, stat_attribute_id),
UNIQUE(weapon_id, sort_order) UNIQUE(weapon_id, sort_order)
); );
CREATE TABLE IF NOT EXISTS os_protocols (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Standard', 'Specialized', 'Superior', 'High-end')),
primary_attribute_type TEXT NOT NULL CHECK(primary_attribute_type IN ('Firepower', 'Toughness', 'Engineering')),
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0, 1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS os_protocols_name_nocase_idx ON os_protocols(name COLLATE NOCASE);
CREATE TABLE IF NOT EXISTS os_protocol_primary_effects (
id TEXT PRIMARY KEY,
protocol_id TEXT NOT NULL UNIQUE REFERENCES os_protocols(id) ON DELETE CASCADE,
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
base_value REAL NOT NULL CHECK(base_value >= 0)
);
CREATE TABLE IF NOT EXISTS os_protocol_secondary_profiles (
id TEXT PRIMARY KEY,
quality TEXT NOT NULL CHECK(quality IN ('Standard', 'Specialized', 'Superior', 'High-end')),
primary_attribute_type TEXT NOT NULL CHECK(primary_attribute_type IN ('Firepower', 'Toughness', 'Engineering')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(quality, primary_attribute_type)
);
CREATE TABLE IF NOT EXISTS os_protocol_secondary_effects (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES os_protocol_secondary_profiles(id) ON DELETE CASCADE,
effect_role TEXT NOT NULL CHECK(effect_role IN ('Core', 'Damage', 'Health')),
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
value REAL NOT NULL CHECK(value >= 0),
UNIQUE(profile_id, effect_role),
UNIQUE(profile_id, stat_attribute_id)
);
CREATE TABLE IF NOT EXISTS os_protocol_talents (
id TEXT PRIMARY KEY,
protocol_id TEXT NOT NULL UNIQUE REFERENCES os_protocols(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description_template TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS os_protocol_talents_name_nocase_idx ON os_protocol_talents(name COLLATE NOCASE);
CREATE TABLE IF NOT EXISTS os_protocol_talent_triggers (
talent_id TEXT NOT NULL REFERENCES os_protocol_talents(id) ON DELETE CASCADE,
trigger_id TEXT NOT NULL REFERENCES talent_triggers(id),
PRIMARY KEY(talent_id, trigger_id)
);
CREATE TABLE IF NOT EXISTS os_protocol_talent_effects (
id TEXT PRIMARY KEY,
talent_id TEXT NOT NULL REFERENCES os_protocol_talents(id) ON DELETE CASCADE,
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
base_value REAL NOT NULL,
sort_order INTEGER NOT NULL CHECK(sort_order BETWEEN 1 AND 12),
UNIQUE(talent_id, stat_attribute_id),
UNIQUE(talent_id, sort_order)
);
+61
View File
@@ -366,6 +366,67 @@ control at the data boundary instead of presenting cross-slot choices. Weapon
talents are classified once as Talent 1 / Top or Talent 2 / Bottom in the talents are classified once as Talent 1 / Top or Talent 2 / Bottom in the
talent registry; weapon editors only curate subsets from the matching slot. talent registry; weapon editors only curate subsets from the matching slot.
### Game-semantic iconography
Canonical game icons are registered in `src/lib/game-icons.ts` and rendered
through `GameIcon.svelte`; do not repeat asset paths in individual features.
Use damage icons beside Blast, Pierce, and Shredding classifications, and core
attribute icons beside Firepower, Toughness, and Engineering values. The same
registry reserves the Bulwark, Combat Medic, Demolitionist, Tech Operator,
Vanguard, Legendary, and SHD-ticket assets for controls or data that explicitly
represent those concepts. Do not use semantic icons as unrelated decoration.
Interactive icon choices retain a visible text label, square engineered cells,
orange selected state, and hover, pressed, and focus-visible feedback. Icons
that repeat an adjacent visible label are decorative with an empty alternative;
standalone icons expose the registry's human-readable label. Preserve each
asset's aspect ratio with `object-fit: contain` and scale through the shared
`.game-icon` class rather than modifying source artwork.
### Item quality
Item quality is a global semantic scale: Standard uses
`--quality-standard` green, Specialized uses `--quality-specialized` blue,
Superior uses `--quality-superior` purple, and High-end uses
`--quality-high-end` yellow. Apply the matching `.quality-*` utility wherever
an item’s persisted quality is shown. Registry labels use `.quality-chip` so
quality remains explicit in text and never depends on color alone. These colors
identify item quality; orange remains reserved for actions and current
selection.
### OS Protocol editor
OS Protocol definitions separate shared secondary profiles from item-owned
progression. The twelve quality + attribute-type combinations are configured
once in a compact matrix and inherited by every matching protocol. Each profile
contains three fixed stat identities and editable values. Individual protocol
editors show the inherited profile read-only and store one unique primary stat
with a level-1 base value. That primary value scales linearly to exactly twice
its base at level 50.
Protocol identity forms stack the four-choice quality radio band above the
three-choice secondary-profile type band, with the protocol name occupying the
larger adjacent column. Quality choices use their global semantic color plus an
explicit text label and selected underline; profile types retain their
icon-backed labels. Both controls collapse below the name on narrow screens.
The shared secondary-profile matrix is collapsed by default because it is
configuration infrastructure rather than a frequent protocol-editing task. Its
disclosure header always exposes the configured total, uses a textual title,
and rotates the plus indicator when expanded. The full matrix remains keyboard
operable and follows the shared hover, focus-visible, and pressed states.
The dedicated unique-talent panel uses five interactive diamonds: no filled
diamonds is the base definition, and each selected upgrade adds one fifth of
the base until five upgrades show exactly twice the base effect.
Diamonds are both a progression preview and an explicit numeric state; expose
their upgrade number and `aria-pressed` state rather than relying on fill color.
Protocol talents own their effects but reuse the global stat and trigger
registries. Shared secondary values do not scale with the individual protocol’s
level. Store only the unique primary and talent base values, and derive their
progression consistently in calculation and display code.
### ISAC editor lifecycle ### ISAC editor lifecycle
Successful saves have a consistent outcome throughout data management: Successful saves have a consistent outcome throughout data management:
+19
View File
@@ -144,3 +144,22 @@ talent ID not already owned by the edited weapon and rejects any talent shared
by another weapon. Converting a weapon back to standard clears the relationship by another weapon. Converting a weapon back to standard clears the relationship
and archives its now-unowned exotic talent. Exotic talent creation and editing and archives its now-unowned exotic talent. Exotic talent creation and editing
are rejected by the shared Talent Registry so ownership cannot be bypassed. are rejected by the shared Talent Registry so ownership cannot be bypassed.
# OS Protocol progression
OS Protocols use dedicated normalized tables because their talent progression
has six calculated states (base plus five upgrades) rather than the shared
two-tier talent model. Each protocol owns one `os_protocol_primary_effects`
record and one `os_protocol_talents` record. Talent effects and reusable
triggers live in their respective join tables.
The three secondary attributes are normalized separately. One
`os_protocol_secondary_profiles` record exists for each configured quality +
attribute-type combination, with its three fixed stat/value pairs in
`os_protocol_secondary_effects`. Every matching protocol inherits that profile;
secondary values are not copied onto individual protocol records.
The unique primary value uses `base × (1 + (level − 1) / 49)` for levels 1–50, while
talent effects use `base × (1 + upgrades / 5)` for zero to five upgrades.
Server actions validate all relationships and append the full owned graph to
the existing `game_data` audit history.
+10 -2
View File
File diff suppressed because one or more lines are too long
+18
View File
@@ -0,0 +1,18 @@
<script lang="ts">
import { gameIcons, type GameIconName } from '$lib/game-icons';
let {
name,
decorative = false,
class: className = ''
}: { name: GameIconName; decorative?: boolean; class?: string } = $props();
const icon = $derived(gameIcons[name]);
</script>
<img
class={`game-icon ${className}`}
src={icon.src}
alt={decorative ? '' : icon.label}
aria-hidden={decorative ? 'true' : undefined}
/>
+17
View File
@@ -0,0 +1,17 @@
export const gameIcons = {
blast: { src: '/blast.png', label: 'Blast damage' },
pierce: { src: '/pierce.png', label: 'Pierce damage' },
shredding: { src: '/shredding.png', label: 'Shredding damage' },
firepower: { src: '/firepower.png', label: 'Firepower' },
toughness: { src: '/toughness.png', label: 'Toughness' },
engineering: { src: '/engineering.png', label: 'Engineering' },
bulwark: { src: '/bulwark.png', label: 'Bulwark specialization' },
combat_medic: { src: '/combat_medic.png', label: 'Combat Medic specialization' },
demolitionist: { src: '/demolitionist.png', label: 'Demolitionist specialization' },
tech_operator: { src: '/tech_operator.png', label: 'Tech Operator specialization' },
vanguard: { src: '/vanguard.png', label: 'Vanguard specialization' },
legendary: { src: '/legendary.png', label: 'Legendary difficulty' },
shd_ticket: { src: '/shd_ticket.png', label: 'SHD ticket' }
} as const;
export type GameIconName = keyof typeof gameIcons;
+1 -1
View File
@@ -27,7 +27,7 @@ export async function recordIsacAudit(input: {
} }
export async function auditRecord(table: string, id: string): Promise<Record<string, unknown> | null> { export async function auditRecord(table: string, id: string): Promise<Record<string, unknown> | null> {
if (!['stat_attributes', 'talents', 'talent_triggers', 'brand_sets', 'gear', 'weapons', 'roles'].includes(table)) { if (!['stat_attributes', 'talents', 'talent_triggers', 'brand_sets', 'gear', 'weapons', 'os_protocols', 'roles'].includes(table)) {
throw new Error('Unsupported audit table'); throw new Error('Unsupported audit table');
} }
const database = await getDatabase(); const database = await getDatabase();
+3 -2
View File
@@ -7,7 +7,8 @@
{ href: '/admin/talents', label: 'Talents & triggers', code: 'TLNT' }, { href: '/admin/talents', label: 'Talents & triggers', code: 'TLNT' },
{ href: '/admin/brands', label: 'Brand sets', code: 'BRND' }, { href: '/admin/brands', label: 'Brand sets', code: 'BRND' },
{ href: '/admin/gear', label: 'Gear', code: 'GEAR' }, { href: '/admin/gear', label: 'Gear', code: 'GEAR' },
{ href: '/admin/weapons', label: 'Weapons', code: 'WPN' } { href: '/admin/weapons', label: 'Weapons', code: 'WPN' },
{ href: '/admin/protocols', label: 'OS Protocols', code: 'OSPR' }
]; ];
const userSections = [ const userSections = [
{ href: '/admin/users', label: 'User operations', code: 'USER', permission: 'users.view' }, { href: '/admin/users', label: 'User operations', code: 'USER', permission: 'users.view' },
@@ -33,7 +34,7 @@
<nav aria-label="User operations">{#each visibleUserSections as section}<a class:active={page.url.pathname.startsWith(section.href)} href={section.href}><small>{section.code}</small><span>{section.label}</span><b>›</b></a>{/each}</nav> <nav aria-label="User operations">{#each visibleUserSections as section}<a class:active={page.url.pathname.startsWith(section.href)} href={section.href}><small>{section.code}</small><span>{section.label}</span><b>›</b></a>{/each}</nav>
{/if} {/if}
{#if can('data.manage')} {#if can('data.manage')}
<div class="sidebar-label" class:section-label={visibleUserSections.length > 0}><span>DATA MANAGEMENT</span><b>05 MODULES</b></div> <div class="sidebar-label" class:section-label={visibleUserSections.length > 0}><span>DATA MANAGEMENT</span><b>{String(dataSections.length).padStart(2, '0')} MODULES</b></div>
<nav aria-label="Data management"> <nav aria-label="Data management">
{#each dataSections as section}<a class:active={page.url.pathname === section.href} href={section.href}><small>{section.code}</small><span>{section.label}</span><b>›</b></a>{/each} {#each dataSections as section}<a class:active={page.url.pathname === section.href} href={section.href}><small>{section.code}</small><span>{section.label}</span><b>›</b></a>{/each}
</nav> </nav>
+1 -1
View File
@@ -43,7 +43,7 @@ export const load: PageServerLoad = async ({ cookies }) => {
(SELECT COUNT(*) FROM comments) AS comments, (SELECT COUNT(*) FROM comments) AS comments,
(SELECT COUNT(*) FROM audit_log) AS audit_events, (SELECT COUNT(*) FROM audit_log) AS audit_events,
(SELECT COUNT(*) FROM stat_attributes)+(SELECT COUNT(*) FROM talents)+(SELECT COUNT(*) FROM brand_sets)+ (SELECT COUNT(*) FROM stat_attributes)+(SELECT COUNT(*) FROM talents)+(SELECT COUNT(*) FROM brand_sets)+
(SELECT COUNT(*) FROM gear)+(SELECT COUNT(*) FROM weapons) AS game_records`)).get(new Date().toISOString()), (SELECT COUNT(*) FROM gear)+(SELECT COUNT(*) FROM weapons)+(SELECT COUNT(*) FROM os_protocols) AS game_records`)).get(new Date().toISOString()),
stat(databasePath).catch(() => null) stat(databasePath).catch(() => null)
]); ]);
const pages = numericPragma(pageCount); const pages = numericPragma(pageCount);
+192
View File
@@ -0,0 +1,192 @@
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { requirePermission } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { getPagination } from '$lib/server/pagination';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
const secondaryRoles = ['Core', 'Damage', 'Health'];
const expectedSecondaryAttributes: Record<string, Record<string, string>> = {
Firepower: { Core: 'Firepower', Damage: 'All Weapon Damage', Health: 'Health' },
Toughness: { Core: 'Toughness', Damage: 'All Weapon Damage', Health: 'Health' },
Engineering: { Core: 'Engineering', Damage: 'Skill Damage', Health: 'Health' }
};
const parseList = (value: FormDataEntryValue | null): any[] => {
try {
const parsed = JSON.parse(String(value ?? '[]'));
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
async function protocolSnapshot(id: string) {
const database = await getDatabase();
const talent = await (await database.prepare('SELECT * FROM os_protocol_talents WHERE protocol_id=?')).get(id);
return {
record: await auditRecord('os_protocols', id),
primaryEffect: await (await database.prepare('SELECT * FROM os_protocol_primary_effects WHERE protocol_id=?')).get(id) ?? null,
talent: talent ?? null,
talentTriggers: talent ? await (await database.prepare('SELECT trigger_id FROM os_protocol_talent_triggers WHERE talent_id=? ORDER BY trigger_id')).all(talent.id) : [],
talentEffects: talent ? await (await database.prepare('SELECT * FROM os_protocol_talent_effects WHERE talent_id=? ORDER BY sort_order')).all(talent.id) : []
};
}
async function profileSnapshot(id: string) {
const database = await getDatabase();
return {
record: await (await database.prepare('SELECT * FROM os_protocol_secondary_profiles WHERE id=?')).get(id) ?? null,
effects: await (await database.prepare('SELECT * FROM os_protocol_secondary_effects WHERE profile_id=? ORDER BY effect_role')).all(id)
};
}
export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage');
const database = await getDatabase();
const requestedFilter = url.searchParams.get('filter') ?? '';
const filter = [...qualities.map((quality) => quality.toLowerCase()), ...attributeTypes.map((type) => type.toLowerCase()), 'active', 'archived'].includes(requestedFilter)
? requestedFilter : '';
const conditions: string[] = [];
const args: unknown[] = [];
if (filter === 'active' || filter === 'archived') conditions.push(`active=${filter === 'active' ? 1 : 0}`);
else if (qualities.some((quality) => quality.toLowerCase() === filter)) {
conditions.push('quality=?');
args.push(qualities.find((quality) => quality.toLowerCase() === filter));
} else if (attributeTypes.some((type) => type.toLowerCase() === filter)) {
conditions.push('primary_attribute_type=?');
args.push(attributeTypes.find((type) => type.toLowerCase() === filter));
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const [summary, filtered] = await Promise.all([
(await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
SUM(CASE WHEN quality='Standard' THEN 1 ELSE 0 END) AS standard,
SUM(CASE WHEN quality='Specialized' THEN 1 ELSE 0 END) AS specialized,
SUM(CASE WHEN quality='Superior' THEN 1 ELSE 0 END) AS superior,
SUM(CASE WHEN quality='High-end' THEN 1 ELSE 0 END) AS high_end
FROM os_protocols`)).get(),
(await database.prepare(`SELECT COUNT(*) AS total FROM os_protocols ${where}`)).get(...args)
]);
const pagination = getPagination(url, Number(filtered?.total ?? 0));
const [protocols, primaryEffects, profiles, secondaryEffects, talents, talentTriggers, talentEffects, stats, triggers] = await Promise.all([
(await database.prepare(`SELECT * FROM os_protocols ${where} ORDER BY active DESC,quality,name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset),
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
(await database.prepare('SELECT * FROM os_protocol_secondary_profiles ORDER BY quality,primary_attribute_type')).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_talent_triggers')).all(),
(await database.prepare('SELECT * FROM os_protocol_talent_effects ORDER BY sort_order')).all(),
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT id,name,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE')).all()
]);
const configuredProfiles = profiles.map((profile) => ({ ...profile, effects: secondaryEffects.filter((effect) => effect.profile_id === profile.id) }));
return {
protocols: protocols.map((protocol) => {
const talent = talents.find((item) => item.protocol_id === protocol.id);
return {
...protocol,
primaryEffect: primaryEffects.find((item) => item.protocol_id === protocol.id) ?? null,
secondaryProfile: configuredProfiles.find((profile) => profile.quality === protocol.quality && profile.primary_attribute_type === protocol.primary_attribute_type) ?? null,
talent: talent ? {
...talent,
triggerIds: talentTriggers.filter((item) => item.talent_id === talent.id).map((item) => String(item.trigger_id)),
effects: talentEffects.filter((item) => item.talent_id === talent.id)
} : null
};
}),
profiles: qualities.flatMap((quality) => attributeTypes.map((attributeType) => configuredProfiles.find((profile) =>
profile.quality === quality && profile.primary_attribute_type === attributeType
) ?? { id: '', quality, primary_attribute_type: attributeType, effects: [] })),
stats, triggers, summary, filter, pagination
};
};
export const actions: Actions = {
saveProfile: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const quality = String(form.get('quality') ?? '');
const attributeType = String(form.get('attributeType') ?? '');
if (!qualities.includes(quality) || !attributeTypes.includes(attributeType)) return fail(400, { profileError: 'Choose a valid quality and attribute type.' });
const database = await getDatabase();
const expected = expectedSecondaryAttributes[attributeType];
const statRows = await (await database.prepare(`SELECT id,name FROM stat_attributes WHERE active=1 AND name IN (?,?,?) COLLATE NOCASE`)).all(expected.Core, expected.Damage, expected.Health);
const statByName = new Map(statRows.map((stat) => [String(stat.name).toLowerCase(), String(stat.id)]));
if (statRows.length !== 3) return fail(400, { profileError: `Create active stats named ${Object.values(expected).join(', ')} before configuring this profile.` });
const effects = secondaryRoles.map((role) => ({ role, statId: statByName.get(expected[role].toLowerCase()) ?? '', value: Number(form.get(`value${role}`)) }));
if (effects.some((effect) => !effect.statId || !Number.isFinite(effect.value) || effect.value < 0)) return fail(400, { profileError: 'Every shared secondary attribute requires a non-negative value.' });
const existing = await (await database.prepare('SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?')).get(quality, attributeType);
const profileId = String(existing?.id ?? randomUUID()), now = new Date().toISOString();
const before = existing ? await profileSnapshot(profileId) : null;
if (existing) await (await database.prepare('UPDATE os_protocol_secondary_profiles SET updated_at=? WHERE id=?')).run(now, profileId);
else await (await database.prepare('INSERT INTO os_protocol_secondary_profiles(id,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?)')).run(profileId, quality, attributeType, now, now);
await (await database.prepare('DELETE FROM os_protocol_secondary_effects WHERE profile_id=?')).run(profileId);
for (const effect of effects) await (await database.prepare('INSERT INTO os_protocol_secondary_effects(id,profile_id,effect_role,stat_attribute_id,value) VALUES(?,?,?,?,?)')).run(randomUUID(), profileId, effect.role, effect.statId, effect.value);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: existing ? 'os_protocol_profile.updated' : 'os_protocol_profile.created', entityId: profileId, before, after: await profileSnapshot(profileId) });
return { profileSaved: true, resultToken: randomUUID() };
},
save: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const quality = String(form.get('quality') ?? '');
const attributeType = String(form.get('attributeType') ?? '');
const primaryStatId = String(form.get('primaryStatId') ?? '');
const primaryBaseValue = Number(form.get('primaryBaseValue'));
const talentName = String(form.get('talentName') ?? '').normalize('NFKC').trim();
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').normalize('NFKC').trim();
const triggerIds = [...new Set(form.getAll('triggerIds').map(String).filter(Boolean))];
const talentEffects = parseList(form.get('talentEffects')).map((item, index) => ({
statId: String(item.statId ?? ''), baseValue: Number(item.baseValue), sortOrder: index + 1
}));
if (name.length < 2 || name.length > 80) return fail(400, { protocolError: 'Protocol names must be 2–80 characters.' });
if (!qualities.includes(quality) || !attributeTypes.includes(attributeType)) return fail(400, { protocolError: 'Choose a valid quality and primary attribute type.' });
if (!primaryStatId || !Number.isFinite(primaryBaseValue) || primaryBaseValue < 0) return fail(400, { protocolError: 'Choose the protocol’s unique primary stat and a non-negative level-1 base value.' });
if (talentName.length < 2 || talentName.length > 80 || !descriptionTemplate || !triggerIds.length || !talentEffects.length) return fail(400, { protocolError: 'The unique talent requires a name, description template, trigger, and at least one effect.' });
if (talentEffects.length > 12 || talentEffects.some((item) => !item.statId || !Number.isFinite(item.baseValue))) return fail(400, { protocolError: 'Talent effects require valid attributes and base values.' });
if (new Set(talentEffects.map((item) => item.statId)).size !== talentEffects.length) return fail(400, { protocolError: 'Each talent effect may reference an attribute only once.' });
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1]));
if (placeholders.some((index) => index < 1 || index > talentEffects.length)) return fail(400, { protocolError: 'Description placeholders must reference configured talent effects.' });
const database = await getDatabase();
if (!await (await database.prepare('SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?')).get(quality, attributeType)) return fail(400, { protocolError: 'Configure the shared secondary profile for this quality and attribute type first.' });
if (await (await database.prepare('SELECT id FROM os_protocols WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) return fail(409, { protocolError: 'That OS Protocol already exists.' });
const existingTalent = id ? await (await database.prepare('SELECT id FROM os_protocol_talents WHERE protocol_id=?')).get(id) : null;
if (await (await database.prepare('SELECT id FROM os_protocol_talents WHERE name=? COLLATE NOCASE AND id<>?')).get(talentName, existingTalent?.id ?? 'new')) return fail(409, { protocolError: 'That protocol talent name is already in use.' });
const statIds = [...new Set([primaryStatId, ...talentEffects.map((item) => item.statId)])];
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds);
if (Number(validStats?.count) !== statIds.length) return fail(400, { protocolError: 'One or more selected attributes are unavailable.' });
const validTriggers = await (await database.prepare(`SELECT COUNT(*) AS count FROM talent_triggers WHERE active=1 AND id IN (${triggerIds.map(() => '?').join(',')})`)).get(...triggerIds);
if (Number(validTriggers?.count) !== triggerIds.length) return fail(400, { protocolError: 'One or more selected triggers are unavailable.' });
const protocolId = id || randomUUID(), talentId = String(existingTalent?.id ?? randomUUID()), now = new Date().toISOString();
const before = id ? await protocolSnapshot(id) : null;
if (id) {
if (!await auditRecord('os_protocols', id)) return fail(404, { protocolError: 'OS Protocol not found.' });
await (await database.prepare('UPDATE os_protocols SET name=?,quality=?,primary_attribute_type=?,active=1,updated_at=? WHERE id=?')).run(name, quality, attributeType, now, id);
} else await (await database.prepare('INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?,?)')).run(protocolId, name, quality, attributeType, now, now);
await (await database.prepare(`INSERT INTO os_protocol_primary_effects(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,base_value=excluded.base_value`)).run(randomUUID(), protocolId, primaryStatId, primaryBaseValue);
await (await database.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)).run(talentId, protocolId, talentName, descriptionTemplate, now, now);
await (await database.prepare('DELETE FROM os_protocol_talent_triggers WHERE talent_id=?')).run(talentId);
for (const triggerId of triggerIds) await (await database.prepare('INSERT INTO os_protocol_talent_triggers(talent_id,trigger_id) VALUES(?,?)')).run(talentId, triggerId);
await (await database.prepare('DELETE FROM os_protocol_talent_effects WHERE talent_id=?')).run(talentId);
for (const effect of talentEffects) await (await database.prepare('INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)')).run(randomUUID(), talentId, effect.statId, effect.baseValue, effect.sortOrder);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'os_protocol.updated' : 'os_protocol.created', entityId: protocolId, before, after: await protocolSnapshot(protocolId) });
return { protocolSaved: true, protocolMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
archive: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const id = String((await request.formData()).get('id') ?? '');
const before = await protocolSnapshot(id);
if (!before.record) return fail(404, { protocolError: 'OS Protocol not found.' });
await (await (await getDatabase()).prepare('UPDATE os_protocols SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'os_protocol.archived', entityId: id, before, after: await protocolSnapshot(id) });
return { protocolArchived: true };
}
};
+165
View File
@@ -0,0 +1,165 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import GameIcon from '$lib/components/GameIcon.svelte';
import type { GameIconName } from '$lib/game-icons';
let { data, form } = $props();
type TalentEffect = { stat_attribute_id: string; base_value: number | string };
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
const secondaryRoles = ['Core', 'Damage', 'Health'];
const secondaryNames = (type: string): Record<string, string> => ({
Core: type,
Damage: type === 'Engineering' ? 'Skill Damage' : 'All Weapon Damage',
Health: 'Health'
});
const blankTalent = () => ({ id: '', name: '', description_template: '', triggerIds: [], effects: [{ stat_attribute_id: '', base_value: '' }] });
const blank = () => ({ id: '', name: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, talent: blankTalent() });
let editing = $state<any | null>(null);
let profileEditing = $state<any | null>(null);
let talentUpgrade = $state(0);
let protocolLevel = $state(1);
let handledResultToken = $state('');
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
const iconName = (type: string): GameIconName => type.toLowerCase() as GameIconName;
const stat = (id: string) => data.stats.find((item) => item.id === id);
const format = (value: number | string, statId: string) => {
const numeric = Number(value);
const unit = stat(statId)?.unit ?? '';
if (!Number.isFinite(numeric)) return '—';
const rendered = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, '');
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
};
const levelValue = (base: number | string, level: number) => Number(base) * (1 + (level - 1) / 49);
const upgradeValue = (base: number | string, upgrades: number) => Number(base) * (1 + upgrades / 5);
const profileFor = (quality: string, type: string) => data.profiles.find((profile) => profile.quality === quality && profile.primary_attribute_type === type);
const profileEffect = (profile: any, role: string) => profile.effects.find((effect: any) => effect.effect_role === role);
const configuredProfile = () => profileFor(editing.quality, editing.primary_attribute_type)?.effects.length === 3;
const talentPreview = (upgrades: number) => editing.talent.description_template.replace(/\{(\d+)\}/g, (placeholder: string, rawIndex: string) => {
const effect = editing.talent.effects[Number(rawIndex) - 1];
return effect ? format(upgradeValue(effect.base_value, upgrades), effect.stat_attribute_id) : placeholder;
});
const addTalentEffect = () => {
if (editing.talent.effects.length < 12) editing.talent.effects = [...editing.talent.effects, { stat_attribute_id: '', base_value: '' }];
};
const removeTalentEffect = (index: number) => {
if (editing.talent.effects.length > 1) editing.talent.effects = editing.talent.effects.filter((_: TalentEffect, effectIndex: number) => effectIndex !== index);
};
const editProfile = (profile: any) => {
profileEditing = {
...profile,
values: Object.fromEntries(secondaryRoles.map((role) => [role, profileEffect(profile, role)?.value ?? '']))
};
editing = null;
};
const edit = (protocol: any) => {
editing = {
...protocol,
primaryEffect: protocol.primaryEffect ? { ...protocol.primaryEffect } : { stat_attribute_id: '', base_value: '' },
talent: protocol.talent ? {
...protocol.talent,
triggerIds: [...protocol.talent.triggerIds],
effects: protocol.talent.effects.map((effect: TalentEffect) => ({ ...effect }))
} : blankTalent()
};
profileEditing = null;
talentUpgrade = 0;
protocolLevel = 1;
};
$effect(() => {
if (form?.profileSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
profileEditing = null;
}
if (form?.protocolSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
editing = form.protocolMode === 'add' ? blank() : null;
talentUpgrade = 0;
protocolLevel = 1;
}
});
</script>
<svelte:head><title>OS Protocols — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">OPERATING SYSTEM MODIFICATION REGISTRY</p><h2>OS PROTOCOLS</h2><p>Configure shared secondary profiles, unique level-scaled primary stats, and five-stage talents.</p></div><button class="primary" onclick={() => { editing = blank(); profileEditing = null; }}>ADD OS PROTOCOL <span>+</span></button></div>
<details class="protocol-profile-registry">
<summary class="selection-head"><div><span>SHARED ATTRIBUTE PROFILES</span><small>ONE PROFILE PER QUALITY + ATTRIBUTE TYPE · REUSED BY EVERY MATCHING PROTOCOL</small></div><div><b>{data.profiles.filter((profile) => profile.effects.length === 3).length} / 12 CONFIGURED</b><i aria-hidden="true">+</i></div></summary>
<div class="protocol-profile-grid">
{#each data.profiles as profile}
<button type="button" class:configured={profile.effects.length === 3} onclick={() => editProfile(profile)}>
<header><GameIcon name={iconName(profile.primary_attribute_type)} decorative/><div><b class={qualityClass(profile.quality)}>{profile.quality}</b><strong>{profile.primary_attribute_type}</strong></div><span>{profile.effects.length === 3 ? 'CONFIGURED' : 'REQUIRED'}</span></header>
<div>{#each secondaryRoles as role}{@const effect = profileEffect(profile, role)}<span><small>{secondaryNames(profile.primary_attribute_type)[role]}</small><b>{effect ? format(effect.value, effect.stat_attribute_id) : '—'}</b></span>{/each}</div>
</button>
{/each}
</div>
</details>
{#if profileEditing}
<div class="dashboard-editor protocol-profile-editor">
<div class="editor-title"><div><p class="eyebrow">SHARED PROFILE</p><h3>{profileEditing.quality.toUpperCase()} · {profileEditing.primary_attribute_type.toUpperCase()}</h3></div><button aria-label="Close profile editor" onclick={() => profileEditing = null}>×</button></div>
<PendingForm action="?/saveProfile">
<input type="hidden" name="quality" value={profileEditing.quality}/>
<input type="hidden" name="attributeType" value={profileEditing.primary_attribute_type}/>
<p class="console-note">These values apply to every {profileEditing.quality} {profileEditing.primary_attribute_type} OS Protocol. They do not scale with an individual protocol’s level.</p>
<div class="shared-secondary-editor">{#each secondaryRoles as role}<label class="field"><span>{secondaryNames(profileEditing.primary_attribute_type)[role]}</span><input name={`value${role}`} type="number" step="any" min="0" bind:value={profileEditing.values[role]} required/></label>{/each}</div>
{#if form?.profileError}<p class="error">{form.profileError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => profileEditing = null}>CANCEL</button><button class="primary">SAVE SHARED PROFILE →</button></div>
</PendingForm>
</div>
{/if}
<KpiFilters active={data.filter} items={[
{value:'',label:'TOTAL PROTOCOLS',count:data.summary.total,description:'Show all definitions'},
{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},
{value:'standard',label:'STANDARD',count:data.summary.standard,description:'Standard quality'},
{value:'specialized',label:'SPECIALIZED',count:data.summary.specialized,description:'Specialized quality'},
{value:'superior',label:'SUPERIOR',count:data.summary.superior,description:'Superior quality'},
{value:'high-end',label:'HIGH-END',count:data.summary.high_end,description:'High-end quality'}
]}/>
{#if editing}
<div class="dashboard-editor protocol-editor">
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY PROTOCOL' : 'NEW PROTOCOL'}</p><h3>{editing.id ? editing.name : 'OS PROTOCOL DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="talentEffects" value={JSON.stringify(editing.talent.effects.map((effect: TalentEffect) => ({ statId: effect.stat_attribute_id, baseValue: effect.base_value })))} />
<div class="protocol-identity-grid">
<label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label>
<div class="protocol-classification-stack">
<fieldset class="field protocol-quality-picker"><legend>QUALITY</legend><div>{#each qualities as quality}<label><input type="radio" name="quality" value={quality} bind:group={editing.quality}/><span class={qualityClass(quality)}><i></i><b>{quality}</b></span></label>{/each}</div></fieldset>
<fieldset class="field protocol-type-picker"><legend>TYPE</legend><div>{#each attributeTypes as type}<label><input type="radio" name="attributeType" value={type} bind:group={editing.primary_attribute_type}/><span><GameIcon name={iconName(type)} decorative/><b>{type}</b></span></label>{/each}</div></fieldset>
</div>
</div>
{@const selectedProfile = profileFor(editing.quality, editing.primary_attribute_type)}
<div class="selected-secondary-profile" class:unconfigured={!configuredProfile()}><header><span>INHERITED PROFILE</span><b>{editing.quality.toUpperCase()} · {editing.primary_attribute_type.toUpperCase()}</b></header>{#if configuredProfile()}<div>{#each secondaryRoles as role}{@const effect = profileEffect(selectedProfile, role)}<span><small>{secondaryNames(editing.primary_attribute_type)[role]}</small><b>{format(effect.value, effect.stat_attribute_id)}</b></span>{/each}</div>{:else}<p>Configure this quality and type combination in Shared Secondary Attribute Profiles before saving.</p>{/if}</div>
<div class="selection-head"><div><span>UNIQUE PRIMARY ATTRIBUTE</span><small>LEVEL 1 BASE VALUE · LINEAR TO 2× AT LEVEL 50</small></div><b>LEVEL {protocolLevel}</b></div>
<div class="protocol-level-preview"><input aria-label="Preview protocol level" type="range" min="1" max="50" step="1" bind:value={protocolLevel}/><div><span>01</span><i style={`width:${((protocolLevel - 1) / 49) * 100}%`}></i><span>50</span></div></div>
<div class="protocol-primary-editor"><label class="field">PRIMARY STAT<select name="primaryStatId" bind:value={editing.primaryEffect.stat_attribute_id} required><option value="" disabled>SELECT ATTRIBUTE</option>{#each data.stats as item}<option value={item.id}>{item.name} ({item.unit || 'no unit'})</option>{/each}</select></label><label class="field">LEVEL 1 BASE VALUE<input name="primaryBaseValue" type="number" step="any" min="0" bind:value={editing.primaryEffect.base_value} required/></label><div><span>LEVEL {protocolLevel} PREVIEW</span><b>{format(levelValue(editing.primaryEffect.base_value, protocolLevel), editing.primaryEffect.stat_attribute_id)}</b><small>LEVEL 50 · {format(levelValue(editing.primaryEffect.base_value, 50), editing.primaryEffect.stat_attribute_id)}</small></div></div>
<section class="protocol-talent">
<div class="selection-head"><div><span>UNIQUE PROTOCOL TALENT</span><small>OWNED EXCLUSIVELY BY THIS PROTOCOL · FIVE LINEAR UPGRADES</small></div><b>{editing.talent.id ? 'LINKED RECORD' : 'NEW RECORD'}</b></div>
<div class="protocol-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values and units.</small></label></div>
<div class="protocol-upgrade-preview"><div class="diamond-control" role="group" aria-label="Preview talent upgrade level"><button type="button" class:active={talentUpgrade === 0} aria-pressed={talentUpgrade === 0} onclick={() => talentUpgrade = 0}>BASE</button>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrade >= upgrade} aria-label={`Preview talent at upgrade ${upgrade}`} aria-pressed={talentUpgrade === upgrade} onclick={() => talentUpgrade = upgrade}><span>◆</span><small>{upgrade}</small></button>{/each}</div><div><span>UPGRADE {talentUpgrade} / 5 · {talentUpgrade === 5 ? '2× EFFECT VALUE' : `${Math.round((1 + talentUpgrade / 5) * 100)}% EFFECT VALUE`}</span><p>{talentPreview(talentUpgrade) || 'Talent description preview'}</p></div></div>
<div class="protocol-talent-grid">
<div><div class="selection-head"><div><span>ACTIVATION TRIGGERS</span><small>REUSABLE TRIGGER DEFINITIONS</small></div><b>{editing.talent.triggerIds.length}</b></div><SearchableAssignment items={data.triggers} fieldName="triggerIds" bind:selected={editing.talent.triggerIds}/></div>
<div><div class="selection-head"><div><span>TALENT EFFECTS</span><small>BASE VALUE AT ZERO UPGRADES</small></div><button type="button" class="row-action" onclick={addTalentEffect} disabled={editing.talent.effects.length >= 12}>ADD EFFECT +</button></div><div class="protocol-talent-effects">{#each editing.talent.effects as effect, index}<div><strong>{index + 1}</strong><select bind:value={effect.stat_attribute_id} required><option value="" disabled>SELECT ATTRIBUTE</option>{#each data.stats as item}<option value={item.id}>{item.name} ({item.unit || 'no unit'})</option>{/each}</select><input type="number" step="any" bind:value={effect.base_value} placeholder="BASE VALUE" required/><span>{format(upgradeValue(effect.base_value, 5), effect.stat_attribute_id)} AT +5</span><button type="button" aria-label={`Remove talent effect ${index + 1}`} onclick={() => removeTalentEffect(index)} disabled={editing.talent.effects.length === 1}>×</button></div>{/each}</div></div>
</div>
</section>
{#if form?.protocolError}<p class="error">{form.protocolError}</p>{/if}
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE PROTOCOL</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!configuredProfile() || data.stats.length === 0 || data.triggers.length === 0}>SAVE PROTOCOL →</button></div></div>
</PendingForm>
</div>
{/if}
<RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table protocol-dashboard-table">
<div class="dashboard-row protocol-row table-heading"><span>OS PROTOCOL</span><span>QUALITY</span><span>TYPE</span><span>UNIQUE PRIMARY</span><span>UNIQUE TALENT</span><span>STATE</span><span></span></div>
{#each data.protocols as protocol}
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1}><div><strong>{protocol.name}</strong><small>LEVEL 1–50 · PRIMARY 2× MAX</small></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT CONFIGURED'}</strong><small><span class="mini-diamonds">◆◆◆◆◆</span> 5 UPGRADES · 2× MAX</small></div><b class="state-dot">{Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(protocol)}>EDIT</button></div>
{:else}<div class="empty-dashboard"><strong>NO OS PROTOCOLS REGISTERED</strong><span>Configure shared secondary profiles, then add the first protocol.</span></div>{/each}
</div>
+6 -3
View File
@@ -3,10 +3,13 @@
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte'; import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte'; import KpiFilters from '$lib/components/KpiFilters.svelte';
import GameIcon from '$lib/components/GameIcon.svelte';
import type { GameIconName } from '$lib/game-icons';
let { data, form } = $props(); let { data, form } = $props();
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 ranges = [['damage', 'DMG', 'DAMAGE'], ['firepower', 'FP', 'FIREPOWER'], ['toughness', 'TGH', 'TOUGHNESS'], ['engineering', 'ENG', 'ENGINEERING']] as const; const ranges = [['damage', 'DMG', 'DAMAGE'], ['firepower', 'FP', 'FIREPOWER'], ['toughness', 'TGH', 'TOUGHNESS'], ['engineering', 'ENG', 'ENGINEERING']] as const;
const iconName = (value: string): GameIconName => value.toLowerCase() as GameIconName;
const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0, firepower_min: 0, firepower_max: 0, toughness_min: 0, toughness_max: 0, engineering_min: 0, engineering_max: 0 }); const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0, firepower_min: 0, firepower_max: 0, toughness_min: 0, toughness_max: 0, engineering_min: 0, engineering_max: 0 });
const blankExoticTalent = () => ({ id: '', name: '', descriptionTemplate: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] }); const blankExoticTalent = () => ({ id: '', name: '', 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 }] });
@@ -81,7 +84,7 @@
<label class="field weapon-name">NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label> <label class="field weapon-name">NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label>
<label class="field">CATEGORY<select name="category" value={editing.category} onchange={(event) => setCategory(event.currentTarget.value)}>{#each categories as category}<option>{category}</option>{/each}</select></label> <label class="field">CATEGORY<select name="category" value={editing.category} onchange={(event) => setCategory(event.currentTarget.value)}>{#each categories as category}<option>{category}</option>{/each}</select></label>
<label class="field">NATIVE TIER<select name="nativeTier" bind:value={editing.native_tier}><option value={0}>Tier 0</option><option value={1}>Tier 1</option><option value={2}>Tier 2</option></select></label> <label class="field">NATIVE TIER<select name="nativeTier" bind:value={editing.native_tier}><option value={0}>Tier 0</option><option value={1}>Tier 1</option><option value={2}>Tier 2</option></select></label>
<label class="field">DAMAGE TYPE<select name="damageType" bind:value={editing.damage_type}>{#each damageTypes as damageType}<option>{damageType}</option>{/each}</select></label> <fieldset class="field damage-type-picker"><legend>DAMAGE TYPE</legend><div>{#each damageTypes as damageType}<label title={`${damageType} damage`}><input name="damageType" type="radio" value={damageType} bind:group={editing.damage_type}/><span><GameIcon name={iconName(damageType)} decorative/><b>{damageType}</b></span></label>{/each}</div></fieldset>
<label class="field exotic-toggle">EXOTIC STATUS<span><input name="isExotic" type="checkbox" value="1" checked={Number(editing.is_exotic) === 1} onchange={(event) => setExotic(event.currentTarget.checked)}/> EXOTIC WEAPON</span></label> <label class="field exotic-toggle">EXOTIC STATUS<span><input name="isExotic" type="checkbox" value="1" checked={Number(editing.is_exotic) === 1} onchange={(event) => setExotic(event.currentTarget.checked)}/> EXOTIC WEAPON</span></label>
</div> </div>
{#if Number(editing.is_exotic)} {#if Number(editing.is_exotic)}
@@ -117,7 +120,7 @@
{#each [0, 1, 2] as tier} {#each [0, 1, 2] as tier}
<fieldset class="weapon-tier-card"><legend>TIER {tier}</legend> <fieldset class="weapon-tier-card"><legend>TIER {tier}</legend>
{#each ranges as range} {#each ranges as range}
<div class="weapon-range-row"><strong>{range[1]}<small>{range[2]}</small></strong><label>MIN<input name={`tier${tier}${range[0]}Min`} type="number" min="0" step="any" value={tierAt(tier)[`${range[0]}_min`]} oninput={(event) => tierAt(tier)[`${range[0]}_min`] = event.currentTarget.valueAsNumber} required/></label><label>MAX<input name={`tier${tier}${range[0]}Max`} type="number" min="0" step="any" value={tierAt(tier)[`${range[0]}_max`]} oninput={(event) => tierAt(tier)[`${range[0]}_max`] = event.currentTarget.valueAsNumber} required/></label></div> <div class="weapon-range-row"><strong>{#if range[0] !== 'damage'}<GameIcon name={iconName(range[0])} decorative/>{/if}<span>{range[1]}<small>{range[2]}</small></span></strong><label>MIN<input name={`tier${tier}${range[0]}Min`} type="number" min="0" step="any" value={tierAt(tier)[`${range[0]}_min`]} oninput={(event) => tierAt(tier)[`${range[0]}_min`] = event.currentTarget.valueAsNumber} required/></label><label>MAX<input name={`tier${tier}${range[0]}Max`} type="number" min="0" step="any" value={tierAt(tier)[`${range[0]}_max`]} oninput={(event) => tierAt(tier)[`${range[0]}_max`] = event.currentTarget.valueAsNumber} required/></label></div>
{/each} {/each}
</fieldset> </fieldset>
{/each} {/each}
@@ -145,6 +148,6 @@
<div class="dashboard-table weapon-dashboard-table"> <div class="dashboard-table weapon-dashboard-table">
<div class="dashboard-row weapon-row table-heading"><span>WEAPON</span><span>CLASS</span><span>FIXED ATTRIBUTES</span><span>TIER RANGE</span><span>TALENT POOLS</span><span>AUGMENTS</span><span>STATE</span><span></span></div> <div class="dashboard-row weapon-row table-heading"><span>WEAPON</span><span>CLASS</span><span>FIXED ATTRIBUTES</span><span>TIER RANGE</span><span>TALENT POOLS</span><span>AUGMENTS</span><span>STATE</span><span></span></div>
{#each data.weapons as weapon} {#each data.weapons as weapon}
<div class="dashboard-row weapon-row" class:archived={Number(weapon.active) !== 1}><div><strong>{weapon.name}</strong><small>{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES</small></div><span>{weapon.category} · T{weapon.native_tier}</span><span>{weapon.rpm} RPM · {weapon.magazine} MAG<small> · {weapon.reload_speed} RELOAD</small></span><span>{weapon.tiers.length}/3 CONFIGURED</span><span title={`T1: ${weapon.talent1Ids.map(talentLabel).join(', ')} · T2: ${weapon.talent2Ids.map(talentLabel).join(', ')}`}>{weapon.talent1Ids.length} / {weapon.talent2Ids.length}</span><span title={weapon.augmentEffects.map((effect: any) => `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS</span><b class="state-dot">{Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(weapon)}>EDIT</button></div> <div class="dashboard-row weapon-row" class:archived={Number(weapon.active) !== 1}><div><strong>{weapon.name}</strong><small class="weapon-damage-label"><GameIcon name={iconName(weapon.damage_type)} decorative/>{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES</small></div><span>{weapon.category} · T{weapon.native_tier}</span><span>{weapon.rpm} RPM · {weapon.magazine} MAG<small> · {weapon.reload_speed} RELOAD</small></span><span>{weapon.tiers.length}/3 CONFIGURED</span><span title={`T1: ${weapon.talent1Ids.map(talentLabel).join(', ')} · T2: ${weapon.talent2Ids.map(talentLabel).join(', ')}`}>{weapon.talent1Ids.length} / {weapon.talent2Ids.length}</span><span title={weapon.augmentEffects.map((effect: any) => `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS</span><b class="state-dot">{Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(weapon)}>EDIT</button></div>
{:else}<div class="empty-dashboard"><strong>NO WEAPONS REGISTERED</strong><span>Add active weapon talents and attributes, then create the first armament definition.</span></div>{/each} {:else}<div class="empty-dashboard"><strong>NO WEAPONS REGISTERED</strong><span>Add active weapon talents and attributes, then create the first armament definition.</span></div>{/each}
</div> </div>
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import GameIcon from '$lib/components/GameIcon.svelte';
type Slot = { name: string; item: string; brand: string; core: string }; type Slot = { name: string; item: string; brand: string; core: string };
let buildName = $state('Untitled loadout'); let buildName = $state('Untitled loadout');
let saved = $state(false); let saved = $state(false);
@@ -35,6 +36,6 @@
{#if message}<p class="success" role="status">{message}</p>{/if} {#if message}<p class="success" role="status">{message}</p>{/if}
<div class="builder-layout"> <div class="builder-layout">
<section class="slot-grid">{#each slots as slot}<button class="slot-card" onclick={() => (message = `${slot.name} editing will be added in the next builder increment.`)}><span class="slot-label">{slot.name}</span><span class="slot-icon">◇</span><strong>{slot.item}</strong><small>{slot.brand}</small><b>{slot.core}</b></button>{/each}</section> <section class="slot-grid">{#each slots as slot}<button class="slot-card" onclick={() => (message = `${slot.name} editing will be added in the next builder increment.`)}><span class="slot-label">{slot.name}</span><span class="slot-icon">◇</span><strong>{slot.item}</strong><small>{slot.brand}</small><b>{slot.core}</b></button>{/each}</section>
<aside class="summary"><p class="eyebrow"><span></span> LIVE SUMMARY</p><h2>AGENT STATS</h2><div class="stat"><span>Firepower</span><strong>4,218</strong><i style="width:76%"></i></div><div class="stat"><span>Armor</span><strong>2,840</strong><i style="width:53%"></i></div><div class="stat"><span>Skill power</span><strong>1,265</strong><i style="width:30%"></i></div><hr/><div class="mini"><span>Critical chance</span><b>52.5%</b></div><div class="mini"><span>Critical damage</span><b>118%</b></div><p class="notice">Anonymous drafts stay on this device. Sign in to publish and sync builds.</p></aside> <aside class="summary"><p class="eyebrow"><span></span> LIVE SUMMARY</p><h2>AGENT STATS</h2><div class="stat"><span><GameIcon name="firepower" decorative/>Firepower</span><strong>4,218</strong><i style="width:76%"></i></div><div class="stat"><span><GameIcon name="toughness" decorative/>Toughness</span><strong>2,840</strong><i style="width:53%"></i></div><div class="stat"><span><GameIcon name="engineering" decorative/>Engineering</span><strong>1,265</strong><i style="width:30%"></i></div><hr/><div class="mini"><span>Critical chance</span><b>52.5%</b></div><div class="mini"><span>Critical damage</span><b>118%</b></div><p class="notice">Anonymous drafts stay on this device. Sign in to publish and sync builds.</p></aside>
</div> </div>
</main> </main>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB