feat(isac): add OS protocol management
This commit is contained in:
+10
-2
File diff suppressed because one or more lines are too long
@@ -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}
|
||||
/>
|
||||
@@ -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;
|
||||
@@ -27,7 +27,7 @@ export async function recordIsacAudit(input: {
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
const database = await getDatabase();
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
{ href: '/admin/talents', label: 'Talents & triggers', code: 'TLNT' },
|
||||
{ href: '/admin/brands', label: 'Brand sets', code: 'BRND' },
|
||||
{ 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 = [
|
||||
{ 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>
|
||||
{/if}
|
||||
{#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">
|
||||
{#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>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
||||
(SELECT COUNT(*) FROM comments) AS comments,
|
||||
(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 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)
|
||||
]);
|
||||
const pages = numericPragma(pageCount);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -3,10 +3,13 @@
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.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();
|
||||
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
|
||||
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
||||
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 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 }] });
|
||||
@@ -81,7 +84,7 @@
|
||||
<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">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>
|
||||
</div>
|
||||
{#if Number(editing.is_exotic)}
|
||||
@@ -117,7 +120,7 @@
|
||||
{#each [0, 1, 2] as tier}
|
||||
<fieldset class="weapon-tier-card"><legend>TIER {tier}</legend>
|
||||
{#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}
|
||||
</fieldset>
|
||||
{/each}
|
||||
@@ -145,6 +148,6 @@
|
||||
<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>
|
||||
{#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}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import GameIcon from '$lib/components/GameIcon.svelte';
|
||||
type Slot = { name: string; item: string; brand: string; core: string };
|
||||
let buildName = $state('Untitled loadout');
|
||||
let saved = $state(false);
|
||||
@@ -35,6 +36,6 @@
|
||||
{#if message}<p class="success" role="status">{message}</p>{/if}
|
||||
<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>
|
||||
<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>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user