feat: model specialization combat effects

This commit is contained in:
JB
2026-07-26 19:04:26 -04:00
parent b1656c430f
commit 338ea8761d
34 changed files with 3396 additions and 146 deletions
+1
View File
@@ -13,6 +13,7 @@
let readOnlyBlocked = $state(false);
const dataSections = [
{ href: '/isac/configuration', label: 'Global configuration', module: 'configuration' },
{ href: '/isac/calculations', label: 'Combat calculations', module: 'calculations' },
{ href: '/isac/specializations', label: 'Specializations', module: 'specializations' },
{ href: '/isac/attributes', label: 'Attributes', module: 'attributes' },
{ href: '/isac/talents', label: 'Talents', module: 'talents' },
+2 -1
View File
@@ -73,7 +73,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
SUM(CASE WHEN entity_type='data_moderation' THEN 1 ELSE 0 END) AS data_moderation,
SUM(CASE WHEN entity_type IN ('user','user_access','user_moderation','identity') THEN 1 ELSE 0 END) AS users,
SUM(CASE WHEN entity_type IN ('role','role_management') THEN 1 ELSE 0 END) AS access,
SUM(CASE WHEN entity_type='system_configuration' THEN 1 ELSE 0 END) AS system_configuration
SUM(CASE WHEN entity_type='system_configuration' THEN 1 ELSE 0 END) AS system_configuration,
SUM(CASE WHEN entity_type='calculation_configuration' THEN 1 ELSE 0 END) AS calculation_configuration
FROM audit_log`)).get()
]);
const pagination = getPagination(url, Number(total?.total ?? 0));
+1
View File
@@ -32,6 +32,7 @@
<KpiFilters className="audit-kpis" parameter="category" active={data.filters.category} items={[
{value:'',label:'ALL EVENTS',count:data.summary.total,description:'Complete audit history'},
{value:'game_data',label:'GAME DATA',count:data.summary.game_data,description:'Builder-source mutations'},
{value:'calculation_configuration',label:'CALCULATION RULES',count:data.summary.calculation_configuration,description:'Combat model revisions and calibration'},
{value:'data_moderation',label:'DATA MODERATION',count:data.summary.data_moderation,description:'Proposal lifecycle decisions'},
{value:'user',label:'USER OPERATIONS',count:data.summary.users,description:'Access, moderation, identities'},
{value:'access_policy',label:'ACCESS POLICY',count:data.summary.access,description:'Role definition changes'}
@@ -0,0 +1,382 @@
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { recordIsacAudit } from '$lib/server/audit';
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
import { migrateLoadoutDraft } from '$lib/builder/loadout';
const operators = new Set([
'base_damage', 'flat_add', 'additive_percent', 'multiplicative_percent',
'expected_critical', 'expected_headshot', 'rate_of_fire', 'magazine_size',
'reload_speed', 'firing_cycle'
]);
const curves = new Set(['none', 'power', 'smoothstep']);
type StageInput = {
stageKey?: string;
label?: string;
operator?: string;
displayOrder?: number | string;
lockedPosition?: string | null;
};
type ProfileInput = {
statAttributeId?: string;
stageKey?: string;
weight?: number | string;
softCap?: number | string | null;
hardCap?: number | string | null;
curveType?: string;
curveParameter?: number | string | null;
weaponCategory?: string;
};
const clean = (value: unknown) => String(value ?? '').normalize('NFKC').trim();
const optionalNumber = (value: unknown) => {
if (value === '' || value === null || value === undefined) return null;
const number = Number(value);
return Number.isFinite(number) ? number : Number.NaN;
};
const parseArray = <T>(value: unknown): T[] => {
try {
const parsed = JSON.parse(String(value ?? '[]'));
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
async function revisionSnapshot(database: Awaited<ReturnType<typeof getDatabase>>, id: string) {
return {
record: await (await database.prepare('SELECT * FROM calculation_revisions WHERE id=?')).get(id) ?? null,
stages: await (await database.prepare('SELECT * FROM calculation_stages WHERE revision_id=? ORDER BY display_order')).all(id),
profiles: await (await database.prepare(
'SELECT * FROM attribute_calculation_profiles WHERE revision_id=? ORDER BY stage_key,stat_attribute_id'
)).all(id)
};
}
export const load: PageServerLoad = async ({ cookies, url }) => {
await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
const { search, pattern } = getRegistrySearch(url);
const where = search ? "WHERE calibration.name LIKE ? ESCAPE '\\' COLLATE NOCASE" : '';
const args = search ? [pattern] : [];
const total = Number((await (await database.prepare(
`SELECT COUNT(*) AS count FROM calculation_calibration_cases calibration ${where}`
)).get(...args))?.count ?? 0);
const pagination = getPagination(url, total);
const [revisions, stages, profiles, attributes, calibrationCases, calibrationSummary] = await Promise.all([
(await database.prepare('SELECT * FROM calculation_revisions ORDER BY version DESC')).all(),
(await database.prepare('SELECT * FROM calculation_stages ORDER BY revision_id,display_order')).all(),
(await database.prepare('SELECT * FROM attribute_calculation_profiles ORDER BY revision_id,stage_key,stat_attribute_id')).all(),
(await database.prepare('SELECT id,name,unit,type,category FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare(`SELECT calibration.*,COALESCE(profile.display_name,user.username,calibration.created_by,'UNKNOWN') AS creator_name
FROM calculation_calibration_cases calibration
LEFT JOIN users user ON user.id=calibration.created_by
LEFT JOIN user_profiles profile ON profile.user_id=user.id
${where} ORDER BY calibration.updated_at DESC LIMIT ? OFFSET ?`
)).all(...args, pagination.perPage, pagination.offset),
(await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN status='ready' THEN 1 ELSE 0 END) AS ready,
SUM(CASE WHEN status='draft' THEN 1 ELSE 0 END) AS draft,
SUM(CASE WHEN predicted_json IS NOT NULL THEN 1 ELSE 0 END) AS predicted
FROM calculation_calibration_cases`)).get()
]);
return {
revisions: revisions.map((revision) => ({
...revision,
stages: stages.filter((stage) => stage.revision_id === revision.id),
profiles: profiles.filter((profile) => profile.revision_id === revision.id)
})),
attributes,
calibrationCases,
calibrationSummary,
pagination,
search
};
};
export const actions: Actions = {
saveRevision: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = clean(form.get('id'));
const name = clean(form.get('name'));
const notes = clean(form.get('notes')).slice(0, 4000);
const stageInputs = parseArray<StageInput>(form.get('stagesJson')).map((stage) => ({
stageKey: clean(stage.stageKey),
label: clean(stage.label),
operator: clean(stage.operator),
displayOrder: Number(stage.displayOrder),
lockedPosition: clean(stage.lockedPosition) || null
}));
const profileInputs = parseArray<ProfileInput>(form.get('profilesJson')).map((profile) => ({
statAttributeId: clean(profile.statAttributeId),
stageKey: clean(profile.stageKey),
weight: Number(profile.weight),
softCap: optionalNumber(profile.softCap),
hardCap: optionalNumber(profile.hardCap),
curveType: clean(profile.curveType) || 'none',
curveParameter: optionalNumber(profile.curveParameter),
weaponCategory: clean(profile.weaponCategory) || '*'
}));
if (name.length < 3 || name.length > 120) return fail(400, { revisionError: 'Revision names must be 3–120 characters.' });
if (stageInputs.length < 2 || stageInputs.length > 30
|| new Set(stageInputs.map((stage) => stage.stageKey)).size !== stageInputs.length
|| new Set(stageInputs.map((stage) => stage.displayOrder)).size !== stageInputs.length
|| stageInputs.some((stage) => !stage.stageKey || stage.label.length < 2 || !operators.has(stage.operator)
|| !Number.isInteger(stage.displayOrder) || stage.displayOrder < 1
|| (stage.lockedPosition !== null && !['first', 'last'].includes(stage.lockedPosition)))) {
return fail(400, { revisionError: 'Every calculation stage requires a unique key, order, label, and supported operator.' });
}
const ordered = [...stageInputs].sort((left, right) => left.displayOrder - right.displayOrder);
if (ordered[0].operator !== 'base_damage' || ordered[0].lockedPosition !== 'first'
|| ordered.at(-1)?.operator !== 'firing_cycle' || ordered.at(-1)?.lockedPosition !== 'last') {
return fail(400, { revisionError: 'Base damage must remain first and the firing cycle must remain last.' });
}
const stageKeys = new Set(stageInputs.map((stage) => stage.stageKey));
if (profileInputs.length > 2000 || profileInputs.some((profile) =>
!profile.statAttributeId || !stageKeys.has(profile.stageKey) || !Number.isFinite(profile.weight)
|| profile.weight < -100 || profile.weight > 100
|| Number.isNaN(profile.softCap) || Number.isNaN(profile.hardCap) || Number.isNaN(profile.curveParameter)
|| (profile.softCap !== null && profile.softCap < 0)
|| (profile.hardCap !== null && profile.hardCap < 0)
|| (profile.softCap !== null && profile.hardCap !== null && profile.hardCap < profile.softCap)
|| !curves.has(profile.curveType)
)) return fail(400, { revisionError: 'One or more Attribute calculation profiles are invalid.' });
const database = await getDatabase();
const attributeIds = [...new Set(profileInputs.map((profile) => profile.statAttributeId))];
if (attributeIds.length) {
const count = Number((await (await database.prepare(
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${attributeIds.map(() => '?').join(',')})`
)).get(...attributeIds))?.count ?? 0);
if (count !== attributeIds.length) return fail(400, { revisionError: 'One or more calculation Attributes are unavailable.' });
}
const existing = id ? await (await database.prepare('SELECT * FROM calculation_revisions WHERE id=?')).get(id) : null;
if (id && !existing) return fail(404, { revisionError: 'That calculation revision no longer exists.' });
if (existing?.status !== 'draft') return fail(409, { revisionError: 'Active and retired revisions are immutable. Create a new draft.' });
const revisionId = id || clean(form.get('_proposedEntityId')) || randomUUID();
const before = id ? await revisionSnapshot(database, revisionId) : null;
const now = new Date().toISOString();
await database.exec('BEGIN');
try {
if (id) {
await (await database.prepare('UPDATE calculation_revisions SET name=?,notes=? WHERE id=?'))
.run(name, notes || null, revisionId);
} else {
const version = Number((await (await database.prepare('SELECT COALESCE(MAX(version),0)+1 AS version FROM calculation_revisions')).get())?.version ?? 1);
const basedOn = clean(form.get('basedOnRevisionId')) || null;
await (await database.prepare(`INSERT INTO calculation_revisions
(id,version,name,status,notes,based_on_revision_id,created_by,created_at)
VALUES(?,?,?,'draft',?,?,?,?)`))
.run(revisionId, version, name, notes || null, basedOn, operator.id, now);
}
await (await database.prepare('DELETE FROM calculation_stages WHERE revision_id=?')).run(revisionId);
for (const stage of ordered) {
await (await database.prepare(`INSERT INTO calculation_stages
(id,revision_id,stage_key,label,operator,display_order,locked_position) VALUES(?,?,?,?,?,?,?)`))
.run(randomUUID(), revisionId, stage.stageKey, stage.label, stage.operator, stage.displayOrder, stage.lockedPosition);
}
await (await database.prepare('DELETE FROM attribute_calculation_profiles WHERE revision_id=?')).run(revisionId);
for (const profile of profileInputs) {
await (await database.prepare(`INSERT INTO attribute_calculation_profiles
(id,revision_id,stat_attribute_id,stage_key,weight,soft_cap,hard_cap,curve_type,curve_parameter,weapon_category)
VALUES(?,?,?,?,?,?,?,?,?,?)`)).run(
randomUUID(), revisionId, profile.statAttributeId, profile.stageKey, profile.weight,
profile.softCap, profile.hardCap, profile.curveType, profile.curveParameter, profile.weaponCategory
);
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
await recordIsacAudit({
actorId: operator.id,
category: 'calculation_configuration',
action: id ? 'calculation_revision.updated' : 'calculation_revision.created',
entityId: revisionId,
before,
after: await revisionSnapshot(database, revisionId)
});
return { revisionSaved: true, revisionMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
publishRevision: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'calculation.publish');
const form = await request.formData();
const id = clean(form.get('id'));
if (form.get('confirmation') !== 'activate-current-rules') {
return fail(400, { publishError: 'Confirm that all saved builds will use this revision.' });
}
const database = await getDatabase();
const candidate = await (await database.prepare("SELECT * FROM calculation_revisions WHERE id=? AND status='draft'")).get(id);
if (!candidate) return fail(404, { publishError: 'That draft revision is no longer available.' });
const before = {
active: await (await database.prepare("SELECT * FROM calculation_revisions WHERE status='active'")).get() ?? null,
candidate: await revisionSnapshot(database, id)
};
const now = new Date().toISOString();
await database.exec('BEGIN');
try {
await (await database.prepare("UPDATE calculation_revisions SET status='retired' WHERE status='active'")).run();
await (await database.prepare(`UPDATE calculation_revisions SET status='active',published_by=?,published_at=? WHERE id=?`))
.run(operator.id, now, id);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
await recordIsacAudit({
actorId: operator.id,
category: 'calculation_configuration',
action: 'calculation_revision.published',
entityId: id,
before,
after: { active: await revisionSnapshot(database, id) }
});
return { revisionPublished: true, resultToken: randomUUID() };
},
saveCalibration: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = clean(form.get('id'));
const name = clean(form.get('name'));
const status = clean(form.get('status')) || 'draft';
const snapshotText = String(form.get('loadoutSnapshot') ?? '').trim();
let snapshot: unknown;
try { snapshot = JSON.parse(snapshotText); } catch { return fail(400, { calibrationError: 'The loadout snapshot is not valid JSON.' }); }
const migrated = migrateLoadoutDraft(snapshot);
if (!migrated) return fail(400, { calibrationError: 'The loadout snapshot is not a recognized SHD.build loadout.' });
const observed = {
bodyHit: optionalNumber(form.get('bodyHit')),
criticalBodyHit: optionalNumber(form.get('criticalBodyHit')),
headshot: optionalNumber(form.get('headshot')),
criticalHeadshot: optionalNumber(form.get('criticalHeadshot')),
magazineSeconds: optionalNumber(form.get('magazineSeconds')),
reloadSeconds: optionalNumber(form.get('reloadSeconds')),
encounterDps: optionalNumber(form.get('encounterDps'))
};
if (name.length < 3 || name.length > 120 || !['draft', 'ready'].includes(status)) {
return fail(400, { calibrationError: 'Calibration cases require a 3–120 character name and valid state.' });
}
if (Object.values(observed).some((value) => Number.isNaN(value) || (value !== null && value < 0))
|| (observed.magazineSeconds !== null && observed.magazineSeconds <= 0)
|| (observed.reloadSeconds !== null && observed.reloadSeconds <= 0)) {
return fail(400, { calibrationError: 'Observed values must be valid non-negative measurements.' });
}
const core = [observed.bodyHit, observed.criticalBodyHit, observed.headshot, observed.criticalHeadshot, observed.magazineSeconds, observed.reloadSeconds];
if (status === 'ready' && core.some((value) => value === null)) {
return fail(400, { calibrationError: 'Ready cases require the four hit values, magazine time, and reload time.' });
}
const database = await getDatabase();
const caseId = id || clean(form.get('_proposedEntityId')) || randomUUID();
const before = id ? await (await database.prepare('SELECT * FROM calculation_calibration_cases WHERE id=?')).get(id) ?? null : null;
if (id && !before) return fail(404, { calibrationError: 'That calibration case no longer exists.' });
const now = new Date().toISOString();
const values = [
name, status, JSON.stringify(migrated), Number(form.get('selectedWeapon') ?? 0),
clean(form.get('gameVersion')) || null, clean(form.get('observedAt')) || null,
clean(form.get('notes')).slice(0, 4000) || null,
observed.bodyHit, observed.criticalBodyHit, observed.headshot, observed.criticalHeadshot,
observed.magazineSeconds, observed.reloadSeconds, observed.encounterDps,
clean(form.get('predictedJson')) || null
];
if (id) {
await (await database.prepare(`UPDATE calculation_calibration_cases SET
name=?,status=?,loadout_snapshot_json=?,selected_weapon=?,game_version=?,observed_at=?,notes=?,
body_hit=?,critical_body_hit=?,headshot=?,critical_headshot=?,magazine_seconds=?,reload_seconds=?,
encounter_dps=?,predicted_json=?,updated_at=? WHERE id=?`)).run(...values, now, caseId);
} else {
const activeRevision = await (await database.prepare("SELECT id FROM calculation_revisions WHERE status='active'")).get();
await (await database.prepare(`INSERT INTO calculation_calibration_cases
(id,name,status,loadout_snapshot_json,selected_weapon,game_version,observed_at,notes,
body_hit,critical_body_hit,headshot,critical_headshot,magazine_seconds,reload_seconds,
encounter_dps,predicted_json,calculation_revision_id,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`))
.run(caseId, ...values, activeRevision?.id ?? null, operator.id, now, now);
}
const after = await (await database.prepare('SELECT * FROM calculation_calibration_cases WHERE id=?')).get(caseId) ?? null;
await recordIsacAudit({
actorId: operator.id,
category: 'calculation_configuration',
action: id ? 'calibration_case.updated' : 'calibration_case.created',
entityId: caseId,
before,
after
});
return { calibrationSaved: true, calibrationMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
suggestRevision: async ({ cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const database = await getDatabase();
const cases = await (await database.prepare(
"SELECT * FROM calculation_calibration_cases WHERE status='ready' AND predicted_json IS NOT NULL"
)).all();
if (!cases.length) return fail(409, { suggestionError: 'Add at least one ready calibration case with a prediction before requesting suggestions.' });
const ratios: number[] = [];
for (const item of cases) {
try {
const predicted = JSON.parse(String(item.predicted_json));
const observed = Number(item.body_hit);
const modeled = Number(predicted.bodyHit ?? predicted.hitValues?.normal);
if (observed > 0 && modeled > 0) ratios.push(observed / modeled);
} catch {
continue;
}
}
if (!ratios.length) return fail(409, { suggestionError: 'Ready cases do not contain compatible body-hit predictions.' });
const scale = Math.min(3, Math.max(0.25, ratios.reduce((sum, value) => sum + value, 0) / ratios.length));
const active = await (await database.prepare("SELECT * FROM calculation_revisions WHERE status='active'")).get();
if (!active) return fail(409, { suggestionError: 'No active calculation revision is available.' });
const activeSnapshot = await revisionSnapshot(database, String(active.id));
const revisionId = randomUUID();
const version = Number((await (await database.prepare('SELECT COALESCE(MAX(version),0)+1 AS version FROM calculation_revisions')).get())?.version ?? 1);
const now = new Date().toISOString();
await database.exec('BEGIN');
try {
await (await database.prepare(`INSERT INTO calculation_revisions
(id,version,name,status,notes,based_on_revision_id,created_by,created_at)
VALUES(?,?,?,'draft',?,?,?,?)`)).run(
revisionId, version, `Calibration suggestion ${version}`,
`Suggested from ${ratios.length} ready case${ratios.length === 1 ? '' : 's'}. Body-hit scale ${scale.toFixed(4)}.`,
active.id, operator.id, now
);
for (const stage of activeSnapshot.stages) {
await (await database.prepare(`INSERT INTO calculation_stages
(id,revision_id,stage_key,label,operator,display_order,locked_position) VALUES(?,?,?,?,?,?,?)`))
.run(randomUUID(), revisionId, stage.stage_key, stage.label, stage.operator, stage.display_order, stage.locked_position);
}
for (const profile of activeSnapshot.profiles) {
const shouldScale = ['flat_add', 'additive_percent', 'multiplicative_percent'].includes(
String(activeSnapshot.stages.find((stage) => stage.stage_key === profile.stage_key)?.operator)
);
await (await database.prepare(`INSERT INTO attribute_calculation_profiles
(id,revision_id,stat_attribute_id,stage_key,weight,soft_cap,hard_cap,curve_type,curve_parameter,weapon_category)
VALUES(?,?,?,?,?,?,?,?,?,?)`)).run(
randomUUID(), revisionId, profile.stat_attribute_id, profile.stage_key,
Number(profile.weight) * (shouldScale ? scale : 1), profile.soft_cap, profile.hard_cap,
profile.curve_type, profile.curve_parameter, profile.weapon_category
);
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
await recordIsacAudit({
actorId: operator.id,
category: 'calculation_configuration',
action: 'calculation_revision.suggested',
entityId: revisionId,
before: { activeRevisionId: active.id, readyCases: cases.map((item) => item.id) },
after: { ...(await revisionSnapshot(database, revisionId)), suggestedScale: scale }
});
return { suggestionSaved: true, suggestedRevisionId: revisionId, resultToken: randomUUID() };
}
};
+245
View File
@@ -0,0 +1,245 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import { createDraft } from '$lib/builder/loadout';
let { data, form } = $props();
type StageEditor = {
stageKey: string;
label: string;
operator: string;
displayOrder: number;
lockedPosition: string | null;
};
type ProfileEditor = {
statAttributeId: string;
stageKey: string;
weight: number | string;
softCap: number | string;
hardCap: number | string;
curveType: string;
curveParameter: number | string;
weaponCategory: string;
};
type RevisionEditor = {
id: string;
basedOnRevisionId: string;
name: string;
notes: string;
stages: StageEditor[];
profiles: ProfileEditor[];
};
type CalibrationEditor = {
id: string;
name: string;
status: string;
loadoutSnapshot: string;
selectedWeapon: string;
gameVersion: string;
observedAt: string;
notes: string;
bodyHit: number | string;
criticalBodyHit: number | string;
headshot: number | string;
criticalHeadshot: number | string;
magazineSeconds: number | string;
reloadSeconds: number | string;
encounterDps: number | string;
predictedJson: string;
};
const canPublish = $derived(data.permissions?.includes('*') || data.permissions?.includes('calculation.publish'));
const activeRevision = $derived(data.revisions.find((revision: any) => revision.status === 'active'));
const stageItems = (revision: RevisionEditor) => revision.stages.map((stage) => ({
id: stage.stageKey,
name: stage.label,
category: stage.operator.replaceAll('_', ' ').toUpperCase()
}));
const curveOptions = [
{ value: 'none', label: 'NONE' },
{ value: 'power', label: 'POWER' },
{ value: 'smoothstep', label: 'SMOOTHSTEP' }
];
const stateOptions = [{ value: 'draft', label: 'DRAFT' }, { value: 'ready', label: 'READY' }];
const weaponOptions = [{ value: '0', label: 'PRIMARY' }, { value: '1', label: 'SECONDARY' }];
const revisionFrom = (revision: any, edit = false): RevisionEditor => ({
id: edit ? String(revision?.id ?? '') : '',
basedOnRevisionId: edit ? String(revision?.based_on_revision_id ?? '') : String(revision?.id ?? ''),
name: edit ? String(revision?.name ?? '') : `Combat model ${Number(revision?.version ?? 0) + 1}`,
notes: edit ? String(revision?.notes ?? '') : `Based on revision ${revision?.version ?? 1}.`,
stages: (revision?.stages ?? []).map((stage: any) => ({
stageKey: String(stage.stage_key),
label: String(stage.label),
operator: String(stage.operator),
displayOrder: Number(stage.display_order),
lockedPosition: stage.locked_position ? String(stage.locked_position) : null
})),
profiles: (revision?.profiles ?? []).map((profile: any) => ({
statAttributeId: String(profile.stat_attribute_id),
stageKey: String(profile.stage_key),
weight: Number(profile.weight),
softCap: profile.soft_cap ?? '',
hardCap: profile.hard_cap ?? '',
curveType: String(profile.curve_type),
curveParameter: profile.curve_parameter ?? '',
weaponCategory: String(profile.weapon_category ?? '*')
}))
});
const blankCalibration = (): CalibrationEditor => ({
id: '', name: '', status: 'draft', loadoutSnapshot: JSON.stringify(createDraft(), null, 2),
selectedWeapon: '0', gameVersion: '', observedAt: '', notes: '', bodyHit: '',
criticalBodyHit: '', headshot: '', criticalHeadshot: '', magazineSeconds: '',
reloadSeconds: '', encounterDps: '', predictedJson: ''
});
const calibrationFrom = (item: any): CalibrationEditor => ({
id: String(item.id), name: String(item.name), status: String(item.status),
loadoutSnapshot: JSON.stringify(JSON.parse(String(item.loadout_snapshot_json)), null, 2),
selectedWeapon: String(item.selected_weapon), gameVersion: String(item.game_version ?? ''),
observedAt: String(item.observed_at ?? ''), notes: String(item.notes ?? ''),
bodyHit: item.body_hit ?? '', criticalBodyHit: item.critical_body_hit ?? '',
headshot: item.headshot ?? '', criticalHeadshot: item.critical_headshot ?? '',
magazineSeconds: item.magazine_seconds ?? '', reloadSeconds: item.reload_seconds ?? '',
encounterDps: item.encounter_dps ?? '', predictedJson: String(item.predicted_json ?? '')
});
let revisionEditing = $state<RevisionEditor | null>(null);
let calibrationEditing = $state<CalibrationEditor | null>(null);
let publishingId = $state('');
let publishConfirmed = $state(false);
let handledToken = $state('');
function addProfile() {
if (!revisionEditing) return;
revisionEditing.profiles = [...revisionEditing.profiles, {
statAttributeId: '', stageKey: revisionEditing.stages.find((stage) => stage.operator === 'additive_percent')?.stageKey ?? '',
weight: 1, softCap: '', hardCap: '', curveType: 'none', curveParameter: '', weaponCategory: '*'
}];
}
function moveStage(index: number, direction: -1 | 1) {
if (!revisionEditing) return;
const target = index + direction;
const stages = [...revisionEditing.stages].sort((left, right) => left.displayOrder - right.displayOrder);
if (index < 0 || target < 0 || target >= stages.length || stages[index].lockedPosition || stages[target].lockedPosition) return;
[stages[index], stages[target]] = [stages[target], stages[index]];
revisionEditing.stages = stages.map((stage, stageIndex) => ({ ...stage, displayOrder: stageIndex + 1 }));
}
$effect(() => {
const token = String(form?.resultToken ?? '');
if (!token || token === handledToken) return;
handledToken = token;
if (form?.revisionSaved) revisionEditing = form.revisionMode === 'add' ? revisionFrom(activeRevision) : null;
if (form?.calibrationSaved) calibrationEditing = form.calibrationMode === 'add' ? blankCalibration() : null;
if (form?.revisionPublished) {
publishingId = '';
publishConfirmed = false;
}
});
</script>
<svelte:head><title>Combat calculations — ISAC</title></svelte:head>
<div class="module-toolbar">
<div><p class="eyebrow">MODELED DAMAGE + EMPIRICAL CALIBRATION</p><h2>COMBAT CALCULATIONS</h2><p>Configure typed calculation stages, Attribute bindings, and field observations used by the loadout builder.</p></div>
<div class="calculation-actions"><button class="secondary" onclick={() => calibrationEditing = blankCalibration()}>ADD CALIBRATION CASE +</button><button class="primary" onclick={() => revisionEditing = revisionFrom(activeRevision)}>NEW REVISION +</button></div>
</div>
<div class="calculation-kpis">
<div><span>ACTIVE REVISION</span><b>{activeRevision ? `R${activeRevision.version}` : 'NONE'}</b><small>{activeRevision?.name ?? 'NO ACTIVE RULES'}</small></div>
<div><span>ATTRIBUTE PROFILES</span><b>{activeRevision?.profiles.length ?? 0}</b><small>TYPED BINDINGS</small></div>
<div><span>READY CASES</span><b>{Number(data.calibrationSummary?.ready ?? 0)}</b><small>{Number(data.calibrationSummary?.predicted ?? 0)} WITH PREDICTIONS</small></div>
</div>
{#if revisionEditing}
<div class="dashboard-editor calculation-revision-editor">
<div class="editor-title"><div><p class="eyebrow">{revisionEditing.id ? 'MODIFY DRAFT REVISION' : 'NEW DRAFT REVISION'}</p><h3>{revisionEditing.name || 'CALCULATION RULE SET'}</h3></div><button aria-label="Close revision editor" onclick={() => revisionEditing = null}>×</button></div>
<PendingForm action="?/saveRevision">
<input type="hidden" name="id" value={revisionEditing.id}/>
<input type="hidden" name="basedOnRevisionId" value={revisionEditing.basedOnRevisionId}/>
<input type="hidden" name="stagesJson" value={JSON.stringify(revisionEditing.stages)}/>
<input type="hidden" name="profilesJson" value={JSON.stringify(revisionEditing.profiles)}/>
<div class="calculation-revision-identity"><label class="field isac-record-name">REVISION NAME<input name="name" bind:value={revisionEditing.name} maxlength="120" required/></label><label class="field">NOTES<textarea name="notes" bind:value={revisionEditing.notes} maxlength="4000"></textarea></label></div>
<div class="selection-head"><div><span>ORDERED CALCULATION STAGES</span><small>BASE AND FIRING-CYCLE POSITIONS ARE LOCKED</small></div><b>{revisionEditing.stages.length} STAGES</b></div>
<div class="calculation-stage-list">{#each [...revisionEditing.stages].sort((left, right) => left.displayOrder - right.displayOrder) as stage, index}
<div class:locked={stage.lockedPosition}><b>{String(stage.displayOrder).padStart(2, '0')}</b><label class="field">LABEL<input bind:value={stage.label} required/></label><span>{stage.operator.replaceAll('_', ' ').toUpperCase()}</span><em>{stage.lockedPosition?.toUpperCase() ?? 'ORDERED'}</em><div class="stage-order-actions"><button type="button" class="secondary" aria-label={`Move ${stage.label} earlier`} disabled={Boolean(stage.lockedPosition) || index === 1} onclick={() => moveStage(index,-1)}>↑</button><button type="button" class="secondary" aria-label={`Move ${stage.label} later`} disabled={Boolean(stage.lockedPosition) || index === revisionEditing!.stages.length - 2} onclick={() => moveStage(index,1)}>↓</button></div>
</div>{/each}</div>
<div class="selection-head"><div><span>ATTRIBUTE CALCULATION PROFILES</span><small>WEIGHT, CAPS, AND SAFE CURVES</small></div><button type="button" class="secondary" onclick={addProfile}>ADD PROFILE +</button></div>
<div class="calculation-profile-list">{#each revisionEditing.profiles as profile, index}
<section>
<header><span>PROFILE {String(index + 1).padStart(2, '0')}</span><button type="button" class="moderation-action danger" onclick={() => revisionEditing && (revisionEditing.profiles = revisionEditing.profiles.filter((_, itemIndex) => itemIndex !== index))}>REMOVE</button></header>
<div class="field"><span>ATTRIBUTE</span><SearchableAssignment items={data.attributes} mode="single" compact fieldName={`profile-attribute-${index}`} subjectLabel="ATTRIBUTE" searchPlaceholder="Search Attributes…" bind:value={profile.statAttributeId}/></div>
<div class="field"><span>CALCULATION STAGE</span><SearchableAssignment items={stageItems(revisionEditing)} mode="single" compact fieldName={`profile-stage-${index}`} subjectLabel="STAGE" searchPlaceholder="Search stages…" bind:value={profile.stageKey}/></div>
<div class="calculation-profile-values">
<label class="field">WEIGHT<input type="number" min="-100" max="100" step="any" bind:value={profile.weight} required/></label>
<label class="field">SOFT CAP<input type="number" min="0" step="any" bind:value={profile.softCap} placeholder="NONE"/></label>
<label class="field">HARD CAP<input type="number" min="0" step="any" bind:value={profile.hardCap} placeholder="NONE"/></label>
<label class="field">CURVE PARAMETER<input type="number" min="0.01" step="any" bind:value={profile.curveParameter} disabled={profile.curveType === 'none'} placeholder="1"/></label>
</div>
<SegmentedChoice legend="CURVE" name={`profile-curve-${index}`} options={curveOptions} bind:value={profile.curveType} showIndicator={false}/>
</section>
{/each}</div>
{#if form?.revisionError}<p class="error">{form.revisionError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => revisionEditing = null}>CANCEL</button><button class="primary">SAVE DRAFT REVISION →</button></div>
</PendingForm>
</div>
{/if}
{#if calibrationEditing}
<div class="dashboard-editor calibration-editor">
<div class="editor-title"><div><p class="eyebrow">{calibrationEditing.id ? 'MODIFY CALIBRATION CASE' : 'NEW CALIBRATION CASE'}</p><h3>{calibrationEditing.name || 'FIELD OBSERVATION'}</h3></div><button aria-label="Close calibration editor" onclick={() => calibrationEditing = null}>×</button></div>
<PendingForm action="?/saveCalibration">
<input type="hidden" name="id" value={calibrationEditing.id}/>
<div class="calibration-identity"><label class="field isac-record-name">CASE NAME<input name="name" bind:value={calibrationEditing.name} maxlength="120" required/></label><SegmentedChoice legend="CASE STATE" name="status" options={stateOptions} bind:value={calibrationEditing.status} showIndicator={false}/><SegmentedChoice legend="MEASURED WEAPON" name="selectedWeapon" options={weaponOptions} bind:value={calibrationEditing.selectedWeapon} showIndicator={false}/><label class="field">GAME VERSION<input name="gameVersion" bind:value={calibrationEditing.gameVersion}/></label><label class="field">OBSERVED DATE<input name="observedAt" type="date" bind:value={calibrationEditing.observedAt}/></label></div>
<label class="field calibration-snapshot">LOADOUT SNAPSHOT · JSON<textarea name="loadoutSnapshot" bind:value={calibrationEditing.loadoutSnapshot} required spellcheck="false"></textarea></label>
<div class="calibration-observations">
<label class="field">BODY HIT<input name="bodyHit" type="number" min="0" step="any" bind:value={calibrationEditing.bodyHit}/></label>
<label class="field">CRITICAL BODY<input name="criticalBodyHit" type="number" min="0" step="any" bind:value={calibrationEditing.criticalBodyHit}/></label>
<label class="field">HEADSHOT<input name="headshot" type="number" min="0" step="any" bind:value={calibrationEditing.headshot}/></label>
<label class="field">CRITICAL HEADSHOT<input name="criticalHeadshot" type="number" min="0" step="any" bind:value={calibrationEditing.criticalHeadshot}/></label>
<label class="field">MAGAZINE TIME · SECONDS<input name="magazineSeconds" type="number" min="0.01" step="any" bind:value={calibrationEditing.magazineSeconds}/></label>
<label class="field">RELOAD TIME · SECONDS<input name="reloadSeconds" type="number" min="0.01" step="any" bind:value={calibrationEditing.reloadSeconds}/></label>
<label class="field">ENCOUNTER DPS · OPTIONAL<input name="encounterDps" type="number" min="0" step="any" bind:value={calibrationEditing.encounterDps}/></label>
</div>
<label class="field">NOTES<textarea name="notes" bind:value={calibrationEditing.notes} maxlength="4000"></textarea></label>
<label class="field calibration-prediction">PREDICTION SNAPSHOT · OPTIONAL JSON<textarea name="predictedJson" bind:value={calibrationEditing.predictedJson} spellcheck="false"></textarea></label>
{#if form?.calibrationError}<p class="error">{form.calibrationError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => calibrationEditing = null}>CANCEL</button><button class="primary">SAVE CALIBRATION CASE →</button></div>
</PendingForm>
</div>
{/if}
<div class="calculation-revision-registry">
<div class="selection-head"><div><span>CALCULATION REVISIONS</span><small>SAVED BUILDS USE THE ACTIVE REVISION</small></div><PendingForm action="?/suggestRevision">{#snippet children(pending)}<button class="secondary" disabled={pending}>{pending ? 'ANALYZING…' : 'GENERATE ASSISTED SUGGESTION'}</button>{/snippet}</PendingForm></div>
{#if form?.suggestionError}<p class="error">{form.suggestionError}</p>{/if}
{#each data.revisions as revision}
<article class:active={revision.status === 'active'}>
<header><div><span>REVISION {revision.version}</span><h3>{revision.name}</h3></div><b>{revision.status.toUpperCase()}</b></header>
<p>{revision.notes || 'No revision notes.'}</p>
<footer><span>{revision.stages.length} STAGES · {revision.profiles.length} ATTRIBUTE PROFILES</span><div>{#if revision.status === 'draft'}<button class="secondary" onclick={() => revisionEditing = revisionFrom(revision, true)}>EDIT</button>{#if canPublish}<button class="primary" onclick={() => { publishingId = revision.id; publishConfirmed = false; }}>REVIEW + ACTIVATE</button>{/if}{/if}</div></footer>
{#if publishingId === revision.id}
<div class="publish-confirmation">
<div><strong>ACTIVATE REVISION {revision.version}?</strong><span>Every saved and shared build will recalculate using these rules.</span></div>
<label><input type="checkbox" bind:checked={publishConfirmed}/> I understand this changes current builder results.</label>
<PendingForm action="?/publishRevision"><input type="hidden" name="id" value={revision.id}/><input type="hidden" name="confirmation" value="activate-current-rules"/><div><button type="button" class="secondary" onclick={() => publishingId = ''}>CANCEL</button><button class="primary" disabled={!publishConfirmed}>ACTIVATE REVISION →</button></div></PendingForm>
</div>
{/if}
</article>
{/each}
</div>
<div class="selection-head"><div><span>CALIBRATION REGISTRY</span><small>OBSERVED GAME VALUES + MODEL COMPARISON</small></div><b>{Number(data.calibrationSummary?.total ?? 0)} CASES</b></div>
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table">
<div class="dashboard-row calibration-row table-heading"><span>CASE</span><span>WEAPON</span><span>OBSERVATIONS</span><span>PREDICTION</span><span>STATE</span><span></span></div>
{#each data.calibrationCases as item}
<div class="dashboard-row calibration-row"><div><strong>{item.name}</strong><small>{item.game_version || 'GAME VERSION NOT RECORDED'} · {item.creator_name}</small></div><span>{Number(item.selected_weapon) === 0 ? 'PRIMARY' : 'SECONDARY'}</span><span>{[item.body_hit,item.critical_body_hit,item.headshot,item.critical_headshot,item.magazine_seconds,item.reload_seconds].filter((value) => value !== null).length} / 6 CORE</span><span>{item.predicted_json ? 'AVAILABLE' : 'INSUFFICIENT DATA'}</span><b class:configured={item.status === 'ready'} class="state-dot">{item.status.toUpperCase()}</b><button onclick={() => calibrationEditing = calibrationFrom(item)}>EDIT →</button></div>
{/each}
</div>
<style>
.module-toolbar>div:first-child{min-width:0;padding-right:12px}.calculation-actions{flex:0 0 auto}.calculation-actions,.calculation-revision-registry article footer div{display:flex;gap:7px;align-items:center}.calculation-kpis{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px;margin-bottom:9px}.calculation-kpis>div{display:grid;gap:2px;padding:10px;border:1px solid var(--shd-border);background:var(--shd-surface)}.calculation-kpis span,.calculation-kpis small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.calculation-kpis b{color:var(--shd-orange);font-size:22px}.calculation-revision-identity,.calibration-identity{display:grid;grid-template-columns:1.2fr 1.8fr;gap:8px}.calculation-stage-list{display:grid;gap:4px}.calculation-stage-list>div{display:grid;grid-template-columns:42px minmax(220px,1fr) minmax(170px,.7fr) 90px 76px;gap:7px;align-items:center;padding:6px;border:1px solid var(--shd-border);background:#0d1011}.calculation-stage-list>div>b{color:var(--shd-orange);text-align:center}.calculation-stage-list>div>span,.calculation-stage-list>div>em{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.stage-order-actions{display:grid;grid-template-columns:1fr 1fr;gap:3px}.stage-order-actions button{min-width:0;height:32px;padding:0}.calculation-profile-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.calculation-profile-list>section{display:grid;gap:7px;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.calculation-profile-list header{display:flex;justify-content:space-between;align-items:center}.calculation-profile-values{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.calibration-identity{grid-template-columns:1.4fr 1fr 1fr 1fr 1fr}.calibration-snapshot textarea{min-height:180px;font-family:var(--shd-font-mono)}.calibration-prediction textarea{min-height:90px;font-family:var(--shd-font-mono)}.calibration-observations{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px}.calculation-revision-registry{display:grid;gap:7px;margin:10px 0}.calculation-revision-registry>article{border:1px solid var(--shd-border);border-left:2px solid var(--shd-border);background:var(--shd-surface)}.calculation-revision-registry>article.active{border-left-color:var(--shd-orange)}.calculation-revision-registry article>header,.calculation-revision-registry article>footer{display:flex;justify-content:space-between;gap:8px;align-items:center;padding:9px}.calculation-revision-registry article>header{border-bottom:1px solid var(--shd-border)}.calculation-revision-registry h3,.calculation-revision-registry p{margin:0}.calculation-revision-registry article>p{padding:9px;color:var(--shd-text-muted)}.calculation-revision-registry article>footer{border-top:1px solid var(--shd-border);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.publish-confirmation{display:grid;grid-template-columns:1fr auto auto;gap:12px;align-items:center;padding:10px;border-top:1px solid var(--shd-orange);background:#1b160d}.publish-confirmation>div{display:grid}.publish-confirmation label{font-size:var(--shd-type-supporting)}.calibration-row{grid-template-columns:minmax(230px,1.4fr) .5fr .65fr .8fr .55fr 70px}
@media(max-width:1000px){.calculation-profile-list{grid-template-columns:1fr}.calibration-identity,.calibration-observations{grid-template-columns:repeat(2,minmax(0,1fr))}.publish-confirmation{grid-template-columns:1fr}.calibration-row{grid-template-columns:1fr .5fr .6fr}.calibration-row>*:nth-child(4),.calibration-row>*:nth-child(5){display:none}}
@media(max-width:650px){.calculation-actions{display:grid}.calculation-kpis{grid-template-columns:1fr}.calculation-revision-identity,.calibration-identity,.calibration-observations,.calculation-stage-list>div,.calculation-profile-values{grid-template-columns:1fr}.calculation-stage-list>div>b{text-align:left}.calculation-revision-registry article>header,.calculation-revision-registry article>footer{align-items:start;display:grid}.calibration-row{grid-template-columns:1fr 70px}.calibration-row>*:nth-child(2),.calibration-row>*:nth-child(3){display:none}}
</style>
@@ -10,7 +10,7 @@ import type { Actions, PageServerLoad } from './$types';
import { createNotification } from '$lib/server/notifications';
const statuses = ['pending', 'approved', 'rejected', 'failed'];
const modules = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips', 'specializations'];
const modules = ['configuration', 'calculations', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips', 'specializations'];
type SerializedEntry =
| { name: string; kind: 'text'; value: string }
| { name: string; kind: 'file'; value: string; fileName: string; type: string };
@@ -38,6 +38,14 @@ async function specializationProposalSnapshot(database: any, proposal: any) {
)).all(id)
};
}
if (proposal.action === 'saveCombatEffect') {
return {
record: await (await database.prepare('SELECT * FROM specialization_combat_effects WHERE id=?')).get(id) ?? null,
attachments: await (await database.prepare(
'SELECT id,node_id,rank,effect_order FROM specialization_tree_rank_combat_effects WHERE combat_effect_id=? ORDER BY node_id,rank,effect_order'
)).all(id)
};
}
if (proposal.action === 'saveFocusAbilities') {
return {
record: await (await database.prepare('SELECT * FROM specialization_focuses WHERE id=?')).get(id) ?? null,
@@ -57,6 +65,9 @@ async function specializationProposalSnapshot(database: any, proposal: any) {
)).all(id),
costs: await (await database.prepare(
'SELECT * FROM specialization_tree_rank_costs WHERE node_id=? ORDER BY rank,resource_id'
)).all(id),
combatEffects: await (await database.prepare(
'SELECT * FROM specialization_tree_rank_combat_effects WHERE node_id=? ORDER BY rank,effect_order'
)).all(id)
};
}
@@ -88,6 +99,22 @@ async function skillChipProposalSnapshot(database: any, id: string) {
};
}
async function calculationProposalSnapshot(database: any, proposal: any) {
const id = String(proposal.entity_id ?? '');
if (!id) return null;
if (proposal.action === 'saveRevision') return {
record: await (await database.prepare('SELECT * FROM calculation_revisions WHERE id=?')).get(id) ?? null,
stages: await (await database.prepare('SELECT * FROM calculation_stages WHERE revision_id=? ORDER BY display_order')).all(id),
profiles: await (await database.prepare(
'SELECT * FROM attribute_calculation_profiles WHERE revision_id=? ORDER BY stage_key,stat_attribute_id'
)).all(id)
};
if (proposal.action === 'saveCalibration') return {
record: await (await database.prepare('SELECT * FROM calculation_calibration_cases WHERE id=?')).get(id) ?? null
};
return null;
}
export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'data.approve');
const database = await getDatabase();
@@ -115,6 +142,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
? await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(String(proposal.entity_id)) ?? null
: proposal.module === 'skillchips' && proposal.entity_id
? await skillChipProposalSnapshot(database, String(proposal.entity_id))
: proposal.module === 'calculations'
? await calculationProposalSnapshot(database, proposal)
: proposal.module === 'specializations'
? await specializationProposalSnapshot(database, proposal)
: null
+2 -2
View File
@@ -20,7 +20,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const conditions = [
...(permissionPrefix ? [`EXISTS (
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
WHERE rp.role_id=roles.id AND p.name LIKE ?
WHERE rp.role_id=roles.id AND (p.name LIKE ?${filter === 'data' ? " OR p.name LIKE 'calculation.%'" : ''})
)`] : []),
...(search ? ["roles.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
];
@@ -29,7 +29,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const [summary, filtered, permissions] = await Promise.all([
(await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'isac.%') THEN 1 ELSE 0 END) AS isac,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'data.%') THEN 1 ELSE 0 END) AS data,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND (p.name LIKE 'data.%' OR p.name LIKE 'calculation.%')) THEN 1 ELSE 0 END) AS data,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'users.%') THEN 1 ELSE 0 END) AS users,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'audit.%') THEN 1 ELSE 0 END) AS audit
,SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'system.%') THEN 1 ELSE 0 END) AS system
+167 -2
View File
@@ -42,10 +42,27 @@ type RankEffectInput = AbilityEffectInput & {
abilityId?: string;
};
type RankCombatModifierInput = {
statId?: string;
valuePerStack?: number | string | null;
unit?: string;
};
type RankCombatEffectInput = {
effectId?: string;
triggerCount?: number | string;
triggerWindowSeconds?: number | string;
durationSeconds?: number | string;
maxStacks?: number | string;
stacksPerTrigger?: number | string;
modifiers?: RankCombatModifierInput[];
};
type RankInput = {
rank?: number;
description?: string;
effects?: RankEffectInput[];
combatEffects?: RankCombatEffectInput[];
costs?: Record<string, number | string>;
};
@@ -103,6 +120,9 @@ async function abilitySnapshot(database: Database, id: string) {
}
async function nodeSnapshot(database: Database, id: string) {
const combatEffects = await (await database.prepare(
'SELECT * FROM specialization_tree_rank_combat_effects WHERE node_id=? ORDER BY rank,effect_order'
)).all(id);
return {
record: await auditRecord('specialization_tree_nodes', id),
ranks: await (await database.prepare(
@@ -114,10 +134,25 @@ async function nodeSnapshot(database: Database, id: string) {
costs: await (await database.prepare(
'SELECT * FROM specialization_tree_rank_costs WHERE node_id=? ORDER BY rank,resource_id'
)).all(id),
combatEffects,
combatModifiers: combatEffects.length ? await (await database.prepare(
`SELECT modifier.* FROM specialization_tree_rank_combat_modifiers modifier
JOIN specialization_tree_rank_combat_effects attachment ON attachment.id=modifier.rank_combat_effect_id
WHERE attachment.node_id=? ORDER BY attachment.rank,attachment.effect_order,modifier.modifier_order`
)).all(id) : [],
icon: await gameDataIconMetadata(database, 'specialization_node', id)
};
}
async function combatEffectSnapshot(database: Database, id: string) {
return {
record: await auditRecord('specialization_combat_effects', id),
attachments: await (await database.prepare(
'SELECT id,node_id,rank,effect_order FROM specialization_tree_rank_combat_effects WHERE combat_effect_id=? ORDER BY node_id,rank,effect_order'
)).all(id)
};
}
async function validateAttributeIds(database: Database, ids: string[]) {
const unique = [...new Set(ids.filter(Boolean))];
if (!unique.length) return true;
@@ -158,6 +193,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
{ module: 'specializations', action: 'saveSpecialization', kind: 'specialization', fields: ['description'] },
{ module: 'specializations', action: 'saveFocus', kind: 'focus', fields: ['specializationId', 'description', 'primaryAttribute', 'displayOrder'] },
{ module: 'specializations', action: 'saveAbility', kind: 'ability', fields: ['specializationId', 'description', 'durationSeconds', 'cooldownSeconds', 'effectsJson'] },
{ module: 'specializations', action: 'saveCombatEffect', kind: 'combatEffect', fields: ['specializationId', 'descriptionTemplate'] },
{ module: 'specializations', action: 'saveNode', kind: 'node', fields: ['focusId', 'descriptionTemplate', 'rowNumber', 'columnNumber', 'maxRank', 'ranksJson'] }
]);
const pendingSpecializations = pending.filter((item) => item.kind === 'specialization')
@@ -180,7 +216,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const pendingOnPage = pagination.page === 1 ? pendingSpecializations.slice(0, pagination.perPage) : [];
const databaseOffset = Math.max(0, pagination.offset - pendingSpecializations.length);
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
const [specializations, focuses, abilities, abilityEffects, focusAbilities, nodes, nodeRanks, nodeEffects, nodeCosts, resources, rankProfiles, progression, attributes, chipCounts, icons, summary] = await Promise.all([
const [specializations, focuses, abilities, abilityEffects, focusAbilities, nodes, nodeRanks, nodeEffects, nodeCosts, combatEffects, nodeCombatEffects, nodeCombatModifiers, resources, rankProfiles, progression, attributes, chipCounts, icons, summary] = await Promise.all([
databaseLimit ? (await database.prepare(
`SELECT * FROM specializations ${where} ORDER BY active DESC,name COLLATE NOCASE LIMIT ? OFFSET ?`
)).all(...args, databaseLimit, databaseOffset) : [],
@@ -192,6 +228,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(await database.prepare('SELECT * FROM specialization_tree_node_ranks ORDER BY node_id,rank')).all(),
(await database.prepare('SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order')).all(),
(await database.prepare('SELECT * FROM specialization_tree_rank_costs ORDER BY node_id,rank,resource_id')).all(),
(await database.prepare('SELECT * FROM specialization_combat_effects ORDER BY specialization_id,active DESC,name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM specialization_tree_rank_combat_effects ORDER BY node_id,rank,effect_order')).all(),
(await database.prepare('SELECT * FROM specialization_tree_rank_combat_modifiers ORDER BY rank_combat_effect_id,modifier_order')).all(),
(await database.prepare('SELECT * FROM upgrade_resources WHERE active=1 ORDER BY resource_type,name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM specialization_rank_cost_profiles ORDER BY rank')).all(),
(await database.prepare("SELECT * FROM specialization_progression_config WHERE id='default'")).get(),
@@ -217,6 +256,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
]);
const pendingFocuses = pending.filter((item) => item.kind === 'focus');
const pendingAbilities = pending.filter((item) => item.kind === 'ability');
const pendingCombatEffects = pending.filter((item) => item.kind === 'combatEffect');
const pendingNodes = pending.filter((item) => item.kind === 'node');
const iconUrl = (type: 'specialization' | 'specialization_ability' | 'specialization_node', id: string) => {
const icon = icons.find((item) => item.entity_type === type && item.entity_id === id);
@@ -275,6 +315,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
ranks: nodeRanks.filter((rank) => rank.node_id === node.id).map((rank) => ({
...rank,
effects: nodeEffects.filter((effect) => effect.node_id === node.id && Number(effect.rank) === Number(rank.rank)),
combatEffects: nodeCombatEffects
.filter((effect) => effect.node_id === node.id && Number(effect.rank) === Number(rank.rank))
.map((effect) => ({
...effect,
modifiers: nodeCombatModifiers.filter((modifier) => modifier.rank_combat_effect_id === effect.id)
})),
costs: Object.fromEntries(nodeCosts.filter((cost) => cost.node_id === node.id && Number(cost.rank) === Number(rank.rank))
.map((cost) => [String(cost.resource_id), Number(cost.quantity)]))
})),
@@ -302,6 +348,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
};
}),
abilities: loadedAbilities,
combatEffects: [
...pendingCombatEffects.map((item) => ({
...item,
specialization_id: item.specializationId,
description_template: item.descriptionTemplate,
active: 1,
pending: true
})),
...combatEffects
],
nodes: loadedNodes,
resources,
rankProfiles,
@@ -310,7 +366,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
summary: {
...summary,
total: Number(summary?.total ?? 0) + pendingSpecializations.length,
pending: pending.filter((item) => ['specialization', 'focus', 'ability', 'node'].includes(item.kind)).length
pending: pending.filter((item) => ['specialization', 'focus', 'ability', 'combatEffect', 'node'].includes(item.kind)).length
},
pagination,
filter,
@@ -584,6 +640,61 @@ export const actions: Actions = {
return { abilitySaved: true, abilityMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
saveCombatEffect: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const specializationId = String(form.get('specializationId') ?? '');
const name = cleanName(form.get('name'));
const descriptionTemplate = cleanName(form.get('descriptionTemplate'));
if (name.length < 2 || name.length > 100) {
return fail(400, { combatEffectError: 'Combat-effect names must be 2–100 characters.' });
}
if (descriptionTemplate.length < 3 || descriptionTemplate.length > 2400) {
return fail(400, { combatEffectError: 'The effect description template must be 3–2,400 characters.' });
}
const database = await getDatabase();
const specialization = await (await database.prepare(
'SELECT id FROM specializations WHERE id=? AND active=1'
)).get(specializationId);
if (!specialization) return fail(404, { combatEffectError: 'That active specialization no longer exists.' });
if (id) {
const existing = await (await database.prepare(
'SELECT specialization_id FROM specialization_combat_effects WHERE id=?'
)).get(id);
if (!existing) return fail(404, { combatEffectError: 'That combat effect no longer exists.' });
if (String(existing.specialization_id) !== specializationId) {
return fail(400, { combatEffectError: 'A saved combat effect cannot be moved to another specialization.' });
}
}
if (await (await database.prepare(
'SELECT id FROM specialization_combat_effects WHERE specialization_id=? AND name=? COLLATE NOCASE AND id<>?'
)).get(specializationId, name, id || 'new')) {
return fail(409, { combatEffectError: 'That combat-effect name already exists in this specialization.' });
}
const effectId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await combatEffectSnapshot(database, id) : null;
const now = new Date().toISOString();
if (id) {
await (await database.prepare(`UPDATE specialization_combat_effects
SET name=?,description_template=?,active=1,updated_at=? WHERE id=?`))
.run(name, descriptionTemplate, now, id);
} else {
await (await database.prepare(`INSERT INTO specialization_combat_effects
(id,specialization_id,name,description_template,created_at,updated_at)
VALUES(?,?,?,?,?,?)`)).run(effectId, specializationId, name, descriptionTemplate, now, now);
}
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'specialization_combat_effect.updated' : 'specialization_combat_effect.created',
entityId: effectId,
before,
after: await combatEffectSnapshot(database, effectId)
});
return { combatEffectSaved: true, combatEffectMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
saveFocusAbilities: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
@@ -647,6 +758,19 @@ export const actions: Actions = {
value: effect.value === '' || effect.value === null || effect.value === undefined ? null : Number(effect.value),
unit: cleanName(effect.unit)
})) : [],
combatEffects: Array.isArray(rank.combatEffects) ? rank.combatEffects.map((effect) => ({
effectId: String(effect.effectId ?? ''),
triggerCount: Number(effect.triggerCount),
triggerWindowSeconds: Number(effect.triggerWindowSeconds),
durationSeconds: Number(effect.durationSeconds),
maxStacks: Number(effect.maxStacks),
stacksPerTrigger: Number(effect.stacksPerTrigger ?? 1),
modifiers: Array.isArray(effect.modifiers) ? effect.modifiers.map((modifier) => ({
statId: String(modifier.statId ?? ''),
valuePerStack: Number(modifier.valuePerStack),
unit: cleanName(modifier.unit)
})) : []
})) : [],
costs: Object.fromEntries(Object.entries(rank.costs ?? {}).map(([resourceId, quantity]) => [resourceId, Number(quantity)]))
}));
if (name.length < 2 || name.length > 100) return fail(400, { nodeError: 'Node names must be 2–100 characters.' });
@@ -665,6 +789,7 @@ export const actions: Actions = {
return fail(400, { nodeError: 'Configure one ordered description for every available node rank.' });
}
const allEffects = ranks.flatMap((rank) => rank.effects);
const allCombatEffects = ranks.flatMap((rank) => rank.combatEffects);
if (allEffects.length > 36 || allEffects.some((effect) => !['Attribute', 'Ability', 'Custom'].includes(effect.type)
|| effect.label.length < 2 || effect.label.length > 100
|| (effect.value !== null && !Number.isFinite(effect.value)) || !EFFECT_UNITS.has(effect.unit)
@@ -672,6 +797,18 @@ export const actions: Actions = {
|| (effect.type === 'Ability' && !effect.abilityId))) {
return fail(400, { nodeError: 'Every rank effect requires a valid type, target, label, and optional numeric value.' });
}
if (allCombatEffects.length > 18 || allCombatEffects.some((effect) =>
!effect.effectId
|| !Number.isInteger(effect.triggerCount) || effect.triggerCount < 1 || effect.triggerCount > 1000
|| !Number.isFinite(effect.triggerWindowSeconds) || effect.triggerWindowSeconds <= 0 || effect.triggerWindowSeconds > 3600
|| !Number.isFinite(effect.durationSeconds) || effect.durationSeconds <= 0 || effect.durationSeconds > 86400
|| !Number.isInteger(effect.maxStacks) || effect.maxStacks < 1 || effect.maxStacks > 1000
|| !Number.isInteger(effect.stacksPerTrigger) || effect.stacksPerTrigger < 1 || effect.stacksPerTrigger > effect.maxStacks
|| effect.modifiers.length < 1 || effect.modifiers.length > 12
|| effect.modifiers.some((modifier) => !modifier.statId || !Number.isFinite(modifier.valuePerStack) || !EFFECT_UNITS.has(modifier.unit))
)) {
return fail(400, { nodeError: 'Every conditional effect requires a valid trigger, stack lifecycle, and at least one Attribute modifier.' });
}
const rankOneCosts = ranks[0]?.costs ?? {};
if (Object.values(rankOneCosts).some((quantity) => !Number.isInteger(quantity) || quantity !== 0)) {
return fail(400, { nodeError: 'Rank 1 cannot require Electronics Components.' });
@@ -713,9 +850,22 @@ export const actions: Actions = {
if (!await validateAttributeIds(database, allEffects.map((effect) => effect.statId))) {
return fail(400, { nodeError: 'One or more effect Attributes are unavailable.' });
}
if (!await validateAttributeIds(database, allCombatEffects.flatMap((effect) => effect.modifiers.map((modifier) => modifier.statId)))) {
return fail(400, { nodeError: 'One or more conditional-effect Attributes are unavailable.' });
}
if (!await validateAbilityIds(database, String(focus.specialization_id), allEffects.map((effect) => effect.abilityId))) {
return fail(400, { nodeError: 'One or more effect abilities are unavailable for this specialization.' });
}
const combatEffectIds = [...new Set(allCombatEffects.map((effect) => effect.effectId))];
if (combatEffectIds.length) {
const count = Number((await (await database.prepare(
`SELECT COUNT(*) AS count FROM specialization_combat_effects
WHERE active=1 AND specialization_id=? AND id IN (${combatEffectIds.map(() => '?').join(',')})`
)).get(String(focus.specialization_id), ...combatEffectIds))?.count ?? 0);
if (count !== combatEffectIds.length) {
return fail(400, { nodeError: 'One or more conditional effects are unavailable for this specialization.' });
}
}
if (await (await database.prepare(
'SELECT id FROM specialization_tree_nodes WHERE focus_id=? AND name=? COLLATE NOCASE AND id<>?'
)).get(focusId, name, id || 'new')) return fail(409, { nodeError: 'That node name already exists in this focus.' });
@@ -755,6 +905,21 @@ export const actions: Actions = {
.run(randomUUID(), nodeId, rank.rank, index + 1, effect.type, effect.statId || null,
effect.abilityId || null, effect.label, effect.value, effect.unit || null);
}
for (const [index, effect] of rank.combatEffects.entries()) {
const attachmentId = randomUUID();
await (await database.prepare(`INSERT INTO specialization_tree_rank_combat_effects
(id,node_id,rank,combat_effect_id,effect_order,trigger_type,trigger_count,trigger_window_seconds,
duration_seconds,max_stacks,stacks_per_trigger,counter_policy,refresh_policy,expiry_policy)
VALUES(?,?,?,?,?,'enemy_hits_within_window',?,?,?,?,?,'reset_on_proc','refresh_on_stack_gain','clear_all')`))
.run(attachmentId, nodeId, rank.rank, effect.effectId, index + 1, effect.triggerCount,
effect.triggerWindowSeconds, effect.durationSeconds, effect.maxStacks, effect.stacksPerTrigger);
for (const [modifierIndex, modifier] of effect.modifiers.entries()) {
await (await database.prepare(`INSERT INTO specialization_tree_rank_combat_modifiers
(id,rank_combat_effect_id,modifier_order,stat_attribute_id,value_per_stack,unit)
VALUES(?,?,?,?,?,?)`))
.run(randomUUID(), attachmentId, modifierIndex + 1, modifier.statId, modifier.valuePerStack, modifier.unit || null);
}
}
for (const [resourceId, quantity] of Object.entries(rank.costs)) {
if (Number(quantity) > 0) {
await (await database.prepare(`INSERT INTO specialization_tree_rank_costs
@@ -32,10 +32,32 @@
active: number;
iconUrl?: string;
};
type CombatEffectEditor = {
id: string;
specializationId: string;
name: string;
descriptionTemplate: string;
active: number;
};
type RankCombatModifier = {
statId: string;
valuePerStack: number | string;
unit: string;
};
type RankCombatEffect = {
effectId: string;
triggerCount: number | string;
triggerWindowSeconds: number | string;
durationSeconds: number | string;
maxStacks: number | string;
stacksPerTrigger: number | string;
modifiers: RankCombatModifier[];
};
type RankEditor = {
rank: number;
description: string;
effects: StructuredEffect[];
combatEffects: RankCombatEffect[];
costs: Record<string, number | string>;
};
type NodeEditor = {
@@ -57,6 +79,13 @@
{ value: 'Engineering', label: 'ENGINEERING', icon: 'engineering' as const, iconOnly: true }
];
const rankOptions = [1, 2, 3].map((rank) => ({ value: String(rank), label: `RANK ${rank}` }));
const unitOptions = [
{ value: '', label: 'NONE' },
{ value: '%', label: '%' },
{ value: 's', label: 'SECONDS' },
{ value: 'Shots', label: 'SHOTS' },
{ value: 'm', label: 'METERS' }
];
const blankEffect = (): StructuredEffect => ({ type: 'Custom', statId: '', abilityId: '', label: '', value: '', unit: '' });
const blankSpecialization = (): SpecializationEditor => ({ id: '', name: '', description: '', active: 1, iconUrl: '' });
const blankFocus = (specializationId = ''): FocusEditor => ({
@@ -65,10 +94,19 @@
const blankAbility = (specializationId = ''): AbilityEditor => ({
id: '', specializationId, name: '', description: '', durationSeconds: '', cooldownSeconds: '', effects: [], active: 1, iconUrl: ''
});
const blankCombatEffect = (specializationId = ''): CombatEffectEditor => ({
id: '', specializationId, name: '', descriptionTemplate: '', active: 1
});
const blankCombatModifier = (): RankCombatModifier => ({ statId: '', valuePerStack: '', unit: '%' });
const blankRankCombatEffect = (): RankCombatEffect => ({
effectId: '', triggerCount: 1, triggerWindowSeconds: 1, durationSeconds: 10,
maxStacks: 1, stacksPerTrigger: 1, modifiers: [blankCombatModifier()]
});
const blankRank = (rank: number): RankEditor => ({
rank,
description: '',
effects: [],
combatEffects: [],
costs: rank === 2
? { 'resource-electronics-alpha': 0, 'resource-electronics-beta': 0, 'resource-electronics-gamma': 0 }
: rank === 3 ? { 'resource-electronics-omega': 1 } : {}
@@ -89,6 +127,7 @@
let specializationEditing = $state<SpecializationEditor | null>(null);
let focusEditing = $state<FocusEditor | null>(null);
let abilityEditing = $state<AbilityEditor | null>(null);
let combatEffectEditing = $state<CombatEffectEditor | null>(null);
let nodeEditing = $state<NodeEditor | null>(null);
let progressionEditing = $state(false);
let configuringAbilities = $state(false);
@@ -101,11 +140,15 @@
const specializationById = (id: string) => data.specializations.find((item: any) => item.id === id);
const focusById = (id: string) => data.focuses.find((item: any) => item.id === id);
const abilityById = (id: string) => data.abilities.find((item: any) => item.id === id);
const combatEffectById = (id: string) => data.combatEffects.find((item: any) => item.id === id);
const abilityChipCount = (ability: any) => Number(ability?.skillChipCount ?? 0);
const focusList = (specializationId: string) => data.focuses.filter((focus: any) => focus.specialization_id === specializationId);
const abilityList = (specializationId: string) => data.abilities.filter((ability: any) =>
ability.specialization_id === specializationId && Number(ability.active) === 1
);
const combatEffectList = (specializationId: string) => data.combatEffects.filter((effect: any) =>
effect.specialization_id === specializationId && Number(effect.active) === 1
);
const nodesForFocus = (focusId: string) => data.nodes.filter((node: any) => node.focus_id === focusId);
const activeNodesForFocus = (focusId: string) => nodesForFocus(focusId).filter((node: any) => Number(node.active) === 1);
const archivedNodesForFocus = (focusId: string) => nodesForFocus(focusId).filter((node: any) => Number(node.active) === 0);
@@ -139,6 +182,7 @@
: String(focuses[0]?.id ?? '');
configuringAbilities = false;
nodeEditing = null;
combatEffectEditing = null;
};
const selectFocus = (id: string) => {
selectedFocusId = id;
@@ -176,6 +220,17 @@
iconUrl: item.iconUrl ?? ''
};
specializationEditing = focusEditing = nodeEditing = null;
combatEffectEditing = null;
};
const editCombatEffect = (item: any) => {
combatEffectEditing = {
id: item.id,
specializationId: item.specialization_id,
name: item.name,
descriptionTemplate: item.description_template,
active: Number(item.active)
};
specializationEditing = focusEditing = abilityEditing = nodeEditing = null;
};
const editNode = (item: any) => {
nodeEditing = {
@@ -190,6 +245,19 @@
rank: Number(rank.rank),
description: rank.description,
effects: (rank.effects ?? []).map(normalizedEffect),
combatEffects: (rank.combatEffects ?? []).map((effect: any) => ({
effectId: String(effect.combat_effect_id ?? effect.effectId ?? ''),
triggerCount: effect.trigger_count ?? effect.triggerCount ?? 1,
triggerWindowSeconds: effect.trigger_window_seconds ?? effect.triggerWindowSeconds ?? 1,
durationSeconds: effect.duration_seconds ?? effect.durationSeconds ?? 10,
maxStacks: effect.max_stacks ?? effect.maxStacks ?? 1,
stacksPerTrigger: effect.stacks_per_trigger ?? effect.stacksPerTrigger ?? 1,
modifiers: (effect.modifiers ?? []).map((modifier: any) => ({
statId: String(modifier.stat_attribute_id ?? modifier.statId ?? ''),
valuePerStack: modifier.value_per_stack ?? modifier.valuePerStack ?? '',
unit: String(modifier.unit ?? '%')
}))
})),
costs: { ...(rank.costs ?? {}) }
})),
active: Number(item.active),
@@ -205,6 +273,19 @@
nodeEditing?.ranks[index] ?? blankRank(index + 1)
);
};
const addRankCombatEffect = (rank: RankEditor) => {
rank.combatEffects = [...rank.combatEffects, blankRankCombatEffect()];
};
const removeRankCombatEffect = (rank: RankEditor, index: number) => {
rank.combatEffects = rank.combatEffects.filter((_, itemIndex) => itemIndex !== index);
};
const addCombatModifier = (effect: RankCombatEffect) => {
effect.modifiers = [...effect.modifiers, blankCombatModifier()];
};
const removeCombatModifier = (effect: RankCombatEffect, index: number) => {
if (effect.modifiers.length <= 1) return;
effect.modifiers = effect.modifiers.filter((_, itemIndex) => itemIndex !== index);
};
const openAbilityConfiguration = () => {
if (!selectedFocus) return;
abilitySlots = [...(selectedFocus.abilityIds ?? ['', '', ''])];
@@ -244,6 +325,9 @@
iconResetToken = token;
} else abilityEditing = null;
}
if (form?.combatEffectSaved) {
combatEffectEditing = form.combatEffectMode === 'add' ? blankCombatEffect(selectedSpecializationId) : null;
}
if (form?.focusAbilitiesSaved) configuringAbilities = false;
if (form?.nodeSaved) {
if (form.nodeMode === 'add') {
@@ -394,6 +478,27 @@
</div>
{/if}
{#if combatEffectEditing}
<div class="dashboard-editor specialization-combat-effect-editor">
<div class="editor-title"><div><p class="eyebrow">{combatEffectEditing.id ? 'MODIFY COMBAT EFFECT' : 'NEW COMBAT EFFECT'}</p><h3>{combatEffectEditing.id ? combatEffectEditing.name : 'SHARED EFFECT DEFINITION'}</h3></div><button aria-label="Close combat effect editor" onclick={() => combatEffectEditing = null}>×</button></div>
<PendingForm action="?/saveCombatEffect">
<input type="hidden" name="id" value={combatEffectEditing.id}/>
{#if combatEffectEditing.id}
<input type="hidden" name="specializationId" value={combatEffectEditing.specializationId}/>
{:else}
<div class="field"><span>SPECIALIZATION</span><SearchableAssignment items={data.specializations} mode="single" compact fieldName="specializationId" subjectLabel="SPECIALIZATION" searchPlaceholder="Search specializations…" bind:value={combatEffectEditing.specializationId}/></div>
{/if}
<div class="form-grid combat-effect-definition-grid">
<label class="field isac-record-name">NAME<input name="name" bind:value={combatEffectEditing.name} maxlength="100" required placeholder="Relentless"/></label>
<label class="field combat-effect-template">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={combatEffectEditing.descriptionTemplate} maxlength="2400" required placeholder={'Grants {1} Weapon Damage and {2} Critical Damage for {duration} seconds, up to {maxStacks} stacks.'}></textarea></label>
</div>
<p class="configuration-note">Use {`{triggerCount}`}, {`{triggerWindow}`}, {`{duration}`}, {`{maxStacks}`}, and {`{1}`}, {`{2}`} for ordered Attribute modifiers. Trigger and stack values are configured on each tree rank that grants this effect.</p>
{#if form?.combatEffectError}<p class="error">{form.combatEffectError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => combatEffectEditing = null}>CANCEL</button><button class="primary">SAVE COMBAT EFFECT →</button></div>
</PendingForm>
</div>
{/if}
{#if nodeEditing}
<div class="dashboard-editor specialization-node-editor">
<div class="editor-title"><div><p class="eyebrow">{nodeEditing.id ? 'MODIFY TREE NODE' : 'NEW TREE NODE'}</p><h3>{nodeEditing.id ? nodeEditing.name : 'UPGRADE DEFINITION'}</h3></div><button aria-label="Close tree node editor" onclick={() => nodeEditing = null}>×</button></div>
@@ -426,6 +531,34 @@
</header>
<label class="field">RANK {rank.rank} DESCRIPTION<textarea bind:value={rank.description} maxlength="2400" required placeholder={`Describe the complete Rank ${rank.rank} effect.`}></textarea></label>
<StructuredEffectEditor bind:effects={rank.effects} attributes={data.attributes} abilities={abilityList(String(focusById(nodeEditing.focusId)?.specialization_id ?? ''))} idPrefix={`node-rank-${rank.rank}`} addLabel={`ADD RANK ${rank.rank} EFFECT`}/>
<div class="rank-combat-effects">
<div class="selection-head"><div><span>CONDITIONAL + STACKING EFFECTS</span><small>BUILDER-CONTROLLABLE COMBAT MODEL</small></div><button type="button" class="secondary" onclick={() => addRankCombatEffect(rank)}>ADD CONDITIONAL EFFECT +</button></div>
{#each rank.combatEffects as combatEffect, combatIndex}
<section class="rank-combat-effect">
<header><div><span>EFFECT {combatIndex + 1}</span><small>{combatEffectById(combatEffect.effectId)?.name ?? 'SELECT SHARED EFFECT'}</small></div><button type="button" class="moderation-action danger" onclick={() => removeRankCombatEffect(rank, combatIndex)}>REMOVE</button></header>
<div class="field"><span>SHARED EFFECT</span><SearchableAssignment items={combatEffectList(String(focusById(nodeEditing.focusId)?.specialization_id ?? ''))} mode="single" compact fieldName={`rank-${rank.rank}-combat-${combatIndex}`} subjectLabel="COMBAT EFFECT" searchPlaceholder="Search shared effects…" bind:value={combatEffect.effectId}/></div>
<div class="combat-trigger-grid">
<label class="field">ENEMY HITS<input type="number" min="1" max="1000" step="1" bind:value={combatEffect.triggerCount} required/></label>
<label class="field">WITHIN · SECONDS<input type="number" min="0.01" max="3600" step="any" bind:value={combatEffect.triggerWindowSeconds} required/></label>
<label class="field">BUFF DURATION · SECONDS<input type="number" min="0.01" max="86400" step="any" bind:value={combatEffect.durationSeconds} required/></label>
<label class="field">MAX STACKS<input type="number" min="1" max="1000" step="1" bind:value={combatEffect.maxStacks} required/></label>
<label class="field">STACKS PER TRIGGER<input type="number" min="1" max={Number(combatEffect.maxStacks) || 1} step="1" bind:value={combatEffect.stacksPerTrigger} required/></label>
</div>
<div class="combat-lifecycle-facts"><span>COUNTER <b>RESETS AFTER PROC</b></span><span>STACK GAIN <b>REFRESHES TIMER</b></span><span>EXPIRY <b>CLEARS ALL STACKS</b></span></div>
<div class="combat-modifier-list">
{#each combatEffect.modifiers as modifier, modifierIndex}
<div>
<div class="field"><span>ATTRIBUTE PER STACK</span><SearchableAssignment items={data.attributes} mode="single" compact fieldName={`rank-${rank.rank}-combat-${combatIndex}-modifier-${modifierIndex}`} subjectLabel="ATTRIBUTE" searchPlaceholder="Search Attributes…" bind:value={modifier.statId}/></div>
<label class="field">VALUE PER STACK<input type="number" step="any" bind:value={modifier.valuePerStack} required/></label>
<SegmentedChoice legend="UNIT" name={`rank-${rank.rank}-combat-${combatIndex}-modifier-${modifierIndex}-unit`} options={unitOptions} columns={5} bind:value={modifier.unit} showIndicator={false}/>
<button type="button" class="moderation-action danger" disabled={combatEffect.modifiers.length <= 1} onclick={() => removeCombatModifier(combatEffect, modifierIndex)}>REMOVE</button>
</div>
{/each}
<button type="button" class="secondary" onclick={() => addCombatModifier(combatEffect)}>ADD ATTRIBUTE MODIFIER +</button>
</div>
</section>
{/each}
</div>
{#if rank.rank === 2}
<fieldset class="specialization-component-costs specialized">
<legend>RANK 2 ELECTRONICS COMPONENTS · ONE OR MORE TYPES</legend>
@@ -484,6 +617,7 @@
<div class="specialization-workbench-actions specialization-mutation">
<button class="secondary" onclick={() => editSpecialization(selectedSpecialization)}>EDIT SPECIALIZATION</button>
<button class="secondary" onclick={() => { focusEditing = blankFocus(selectedSpecialization.id); specializationEditing = abilityEditing = nodeEditing = null; }}>ADD FOCUS +</button>
<button class="secondary" onclick={() => { combatEffectEditing = blankCombatEffect(selectedSpecialization.id); specializationEditing = focusEditing = abilityEditing = nodeEditing = null; }}>ADD COMBAT EFFECT +</button>
<button class="primary" onclick={() => { abilityEditing = blankAbility(selectedSpecialization.id); specializationEditing = focusEditing = nodeEditing = null; }}>ADD ABILITY +</button>
</div>
</header>
@@ -574,6 +708,22 @@
{/each}
</div>
</details>
<details class="specialization-record-drawer">
<summary>
<span>SHARED COMBAT EFFECT REGISTRY</span>
<b>{combatEffectList(selectedSpecialization.id).length} RECORDS</b>
</summary>
<div>
{#each data.combatEffects.filter((effect: any) => effect.specialization_id === selectedSpecialization.id) as effect}
<article class:archived={Number(effect.active) === 0}>
<div><strong>{effect.name}</strong><small>{effect.pending ? 'PENDING OVERSIGHT' : Number(effect.active) === 1 ? 'AVAILABLE TO FOCUS TREE RANKS' : 'ARCHIVED EFFECT'}</small><p>{effect.description_template}</p></div>
{#if !effect.pending}<button class="secondary specialization-mutation" onclick={() => editCombatEffect(effect)}>EDIT →</button>{/if}
</article>
{:else}
<p>No shared combat effects are registered for this specialization.</p>
{/each}
</div>
</details>
<div class="specialization-tree-console">
<header>