NAME {shown(attributeValue('current', 'name'))}
+
DESCRIPTION · OPTIONAL {shown(attributeValue('current', 'description'))}
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
{field[1]} {shown(attributeValue('current', field[0]))}
{/each}
@@ -66,6 +131,7 @@
NAME {shown(attributeValue('proposed', 'name'))}
+
DESCRIPTION · OPTIONAL {shown(attributeValue('proposed', 'description'))}
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
{field[1]} {shown(attributeValue('proposed', field[0]))}
{/each}
@@ -76,6 +142,70 @@
+ {:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveFocusAbilities','saveNode'].includes(action)}
+
PROPOSED {label(module)} RECORD AFTER
diff --git a/src/lib/components/StructuredEffectEditor.svelte b/src/lib/components/StructuredEffectEditor.svelte
new file mode 100644
index 0000000..51f546c
--- /dev/null
+++ b/src/lib/components/StructuredEffectEditor.svelte
@@ -0,0 +1,102 @@
+
+
+
+ {#each effects as effect, index}
+ {@const labelCanUseTarget = allowTargetLabelFallback && ['Attribute', 'Ability'].includes(effect.type)}
+
+ EFFECT {String(index + 1).padStart(2, '0')} effects = effects.filter((_, itemIndex) => itemIndex !== index)}>REMOVE
+
+
+ {:else}
+
{emptyLabel} Add structured values when this mechanic should contribute to calculations or an ability preview.
+ {/each}
+
effects = [...effects, blank()]}>{addLabel} +
+
diff --git a/src/lib/item-icons.ts b/src/lib/item-icons.ts
new file mode 100644
index 0000000..299305f
--- /dev/null
+++ b/src/lib/item-icons.ts
@@ -0,0 +1 @@
+export const MISSING_ITEM_ICON_URL = '/missing-item-icon.svg';
diff --git a/src/lib/server/audit.ts b/src/lib/server/audit.ts
index 25bbe57..9a5e350 100644
--- a/src/lib/server/audit.ts
+++ b/src/lib/server/audit.ts
@@ -27,7 +27,23 @@ export async function recordIsacAudit(input: {
}
export async function auditRecord(table: string, id: string): Promise
| null> {
- if (!['stat_attributes', 'talents', 'talent_triggers', 'brand_sets', 'gear', 'weapons', 'weapon_mods', 'gear_mods', 'os_protocols', 'skill_chip_sets', 'roles'].includes(table)) {
+ if (![
+ 'stat_attributes',
+ 'talents',
+ 'talent_triggers',
+ 'brand_sets',
+ 'gear',
+ 'weapons',
+ 'weapon_mods',
+ 'gear_mods',
+ 'os_protocols',
+ 'skill_chip_sets',
+ 'specializations',
+ 'specialization_focuses',
+ 'specialization_abilities',
+ 'specialization_tree_nodes',
+ 'roles'
+ ].includes(table)) {
throw new Error('Unsupported audit table');
}
const database = await getDatabase();
diff --git a/src/lib/server/data-moderation.ts b/src/lib/server/data-moderation.ts
index 212591d..33e4ffe 100644
--- a/src/lib/server/data-moderation.ts
+++ b/src/lib/server/data-moderation.ts
@@ -6,7 +6,7 @@ import { recordIsacAudit } from '$lib/server/audit';
import { getContributorMode } from '$lib/server/contributor-mode';
import { notifyOversightReview } from '$lib/server/oversight-notifications';
-export const DATA_MODULES = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips'] as const;
+export const DATA_MODULES = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips', 'specializations'] as const;
export type DataModule = typeof DATA_MODULES[number];
const proposalResultKeys: Partial>> = {
@@ -28,7 +28,15 @@ const proposalResultKeys: Partial {
module: 'attributes',
action: 'save',
kind: 'attribute',
- fields: ['type', 'category', 'calculationType', 'unit', 'gearMin', 'gearMax', 'weaponMin', 'weaponMax', 'chipMin', 'chipMax', 'capValue']
+ fields: ['description', 'type', 'category', 'calculationType', 'unit', 'gearMin', 'gearMax', 'weaponMin', 'weaponMax', 'chipMin', 'chipMax', 'capValue']
}])).map((stat) => ({
...stat,
calculation_type: stat.calculationType,
@@ -64,8 +64,10 @@ export const actions: Actions = {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').normalize('NFKC').trim();
+ const description = String(form.get('description') ?? '').normalize('NFKC').trim();
const type = String(form.get('type') ?? ''), category = String(form.get('category') ?? ''), calculation = String(form.get('calculationType') ?? ''), unit = String(form.get('unit') ?? '');
if (name.length < 2 || name.length > 80) return fail(400, { error: 'Attribute names must be 2–80 characters.' });
+ if (description.length > 1200) return fail(400, { error: 'Attribute descriptions must be 1,200 characters or fewer.' });
if (!['Offensive','Defensive'].includes(type) || !['Weapon','Skill','Character'].includes(category) || !['Additive','Multiplicative'].includes(calculation)) return fail(400, { error: 'Choose valid attribute options.' });
if (!['','%','s','Shots','m'].includes(unit)) return fail(400, { error: 'Choose a valid unit.' });
const number = (key: string) => { const raw = String(form.get(key) ?? '').trim(); if (!raw) return null; const value = Number(raw); if (!Number.isFinite(value) || value < 0) throw new Error(); return value; };
@@ -76,8 +78,8 @@ export const actions: Actions = {
if (await (await database.prepare('SELECT id FROM stat_attributes WHERE name = ? COLLATE NOCASE AND id <> ?')).get(name, id || 'new')) return fail(409, { error: 'That attribute already exists.' });
const now = new Date().toISOString(), statId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await auditRecord('stat_attributes', id) : null;
- if (id) await (await database.prepare(`UPDATE stat_attributes SET name=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`)).run(name,type,category,calculation,unit,...values,now,id);
- else await (await database.prepare(`INSERT INTO stat_attributes(id,name,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(statId,name,type,category,calculation,unit,...values,now,now);
+ if (id) await (await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`)).run(name,description || null,type,category,calculation,unit,...values,now,id);
+ else await (await database.prepare(`INSERT INTO stat_attributes(id,name,description,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(statId,name,description || null,type,category,calculation,unit,...values,now,now);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'attribute.updated' : 'attribute.created', entityId: statId, before, after: await auditRecord('stat_attributes', statId) });
const savedItem = await auditRecord('stat_attributes', statId);
return { saved: true, editorMode: id ? 'edit' : 'add', savedItem: { ...savedItem, unit: savedItem?.unit === 'flat' ? '' : savedItem?.unit }, resultToken: randomUUID() };
diff --git a/src/routes/isac/attributes/+page.svelte b/src/routes/isac/attributes/+page.svelte
index 05519f9..a7e6ae0 100644
--- a/src/routes/isac/attributes/+page.svelte
+++ b/src/routes/isac/attributes/+page.svelte
@@ -12,8 +12,11 @@
const categoryOptions = ['Weapon', 'Skill', 'Character'].map((value) => ({ value, label: value }));
const calculationOptions = ['Additive', 'Multiplicative'].map((value) => ({ value, label: value }));
const unitOptions = [{value:'',label:'None'},{value:'%',label:'%'},{value:'s',label:'s'},{value:'Shots',label:'Shots'},{value:'m',label:'m'}];
- const blank = () => ({id:'',name:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',skill_chip_min:'',skill_chip_max:'',cap_value:''});
+ const blank = () => ({id:'',name:'',description:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',skill_chip_min:'',skill_chip_max:'',cap_value:''});
const value = (input: unknown) => input ?? '';
+ const attributeMeta = (stat: any) => stat.pending
+ ? `PROPOSED BY ${stat.proposerName}`
+ : [stat.description, stat.unit || 'NO UNIT'].filter(Boolean).join(' · ');
$effect(() => {
if (form?.saved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
@@ -28,6 +31,6 @@
Attributes — SHD.build admin
-{#if editing}{editing.id?'MODIFY RECORD':'NEW RECORD'}
{editing.id?editing.name:'ATTRIBUTE DEFINITION'} editing=null}>× {#if form?.error}{form.error}
{/if} editing=null}>CANCEL SAVE ATTRIBUTE →
{/if}
+{#if editing}{editing.id?'MODIFY RECORD':'NEW RECORD'}
{editing.id?editing.name:'ATTRIBUTE DEFINITION'} editing=null}>× {#if form?.error}{form.error}
{/if} editing=null}>CANCEL SAVE ATTRIBUTE →
{/if}
-ATTRIBUTE CLASSIFICATION CATEGORY CALCULATION STATE
{#each visibleStats as stat}
{stat.name} {stat.pending ? `PROPOSED BY ${stat.proposerName}` : stat.unit || 'NO UNIT'}
{stat.type} {stat.category} {stat.calculation_type} {stat.pending?'PENDING':Number(stat.active)===1?'ACTIVE':'ARCHIVED'} {#if stat.pending}
{:else}
editing={...stat}}>EDIT {/if}
{/each}
+ATTRIBUTE CLASSIFICATION CATEGORY CALCULATION STATE
{#each visibleStats as stat}
{stat.name} {attributeMeta(stat)}
{stat.type} {stat.category} {stat.calculation_type} {stat.pending?'PENDING':Number(stat.active)===1?'ACTIVE':'ARCHIVED'} {#if stat.pending}
{:else}
editing={...stat}}>EDIT {/if}
{/each}
diff --git a/src/routes/isac/audit/+page.server.ts b/src/routes/isac/audit/+page.server.ts
index 7837704..3ca4a95 100644
--- a/src/routes/isac/audit/+page.server.ts
+++ b/src/routes/isac/audit/+page.server.ts
@@ -113,9 +113,9 @@ export const actions: Actions = {
if (action.startsWith('attribute.')) {
current = await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(targetId) ?? null;
if (!sameRecord(current, after)) return fail(409, { revertError: 'The attribute changed after this event. Reversion was blocked to preserve newer work.' });
- await (await database.prepare(`UPDATE stat_attributes SET name=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,
+ await (await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,
weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,active=?,updated_at=? WHERE id=?`))
- .run(before.name,before.type,before.category,before.calculation_type,before.unit,before.gear_min,before.gear_max,before.weapon_min,before.weapon_max,before.skill_chip_min,before.skill_chip_max,before.cap_value,before.active,new Date().toISOString(),targetId);
+ .run(before.name,before.description ?? null,before.type,before.category,before.calculation_type,before.unit,before.gear_min,before.gear_max,before.weapon_min,before.weapon_max,before.skill_chip_min,before.skill_chip_max,before.cap_value,before.active,new Date().toISOString(),targetId);
restored = await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(targetId) ?? null;
} else if (action.endsWith('.archived')) {
const table = action.startsWith('brand_set.') ? 'brand_sets' : action.startsWith('gear.') ? 'gear' : action.startsWith('weapon.') ? 'weapons' : '';
diff --git a/src/routes/isac/data-moderation/+page.server.ts b/src/routes/isac/data-moderation/+page.server.ts
index 5d56a27..94b6468 100644
--- a/src/routes/isac/data-moderation/+page.server.ts
+++ b/src/routes/isac/data-moderation/+page.server.ts
@@ -10,11 +10,59 @@ 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'];
+const modules = ['configuration', '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 };
+async function specializationProposalSnapshot(database: any, proposal: any) {
+ const id = String(proposal.entity_id ?? '');
+ if (proposal.action === 'saveProgression') {
+ return {
+ record: await (await database.prepare("SELECT * FROM specialization_progression_config WHERE id='default'")).get(),
+ rankProfiles: await (await database.prepare('SELECT * FROM specialization_rank_cost_profiles ORDER BY rank')).all()
+ };
+ }
+ if (!id) return null;
+ if (proposal.action === 'saveSpecialization') {
+ return { record: await (await database.prepare('SELECT * FROM specializations WHERE id=?')).get(id) ?? null };
+ }
+ if (proposal.action === 'saveFocus') {
+ return { record: await (await database.prepare('SELECT * FROM specialization_focuses WHERE id=?')).get(id) ?? null };
+ }
+ if (proposal.action === 'saveAbility') {
+ return {
+ record: await (await database.prepare('SELECT * FROM specialization_abilities WHERE id=?')).get(id) ?? null,
+ effects: await (await database.prepare(
+ 'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order'
+ )).all(id)
+ };
+ }
+ if (proposal.action === 'saveFocusAbilities') {
+ return {
+ record: await (await database.prepare('SELECT * FROM specialization_focuses WHERE id=?')).get(id) ?? null,
+ abilities: await (await database.prepare(
+ 'SELECT ability_slot,ability_id FROM specialization_focus_abilities WHERE focus_id=? ORDER BY ability_slot'
+ )).all(id)
+ };
+ }
+ if (proposal.action === 'saveNode') {
+ return {
+ record: await (await database.prepare('SELECT * FROM specialization_tree_nodes WHERE id=?')).get(id) ?? null,
+ ranks: await (await database.prepare(
+ 'SELECT * FROM specialization_tree_node_ranks WHERE node_id=? ORDER BY rank'
+ )).all(id),
+ effects: await (await database.prepare(
+ 'SELECT * FROM specialization_tree_rank_effects WHERE node_id=? ORDER BY rank,effect_order'
+ )).all(id),
+ costs: await (await database.prepare(
+ 'SELECT * FROM specialization_tree_rank_costs WHERE node_id=? ORDER BY rank,resource_id'
+ )).all(id)
+ };
+ }
+ return null;
+}
+
export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'data.approve');
const database = await getDatabase();
@@ -40,7 +88,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
...proposal,
current_snapshot: proposal.module === 'attributes' && proposal.entity_id
? await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(String(proposal.entity_id)) ?? null
- : null
+ : proposal.module === 'specializations'
+ ? await specializationProposalSnapshot(database, proposal)
+ : null
})));
const summaryRows = await (await database.prepare('SELECT status,COUNT(*) AS count FROM data_change_proposals GROUP BY status')).all();
return { proposals: proposalsWithSnapshots, pagination, status, module, search, summary: Object.fromEntries(summaryRows.map((row) => [String(row.status), Number(row.count)])), modules };
diff --git a/src/routes/isac/data-moderation/+page.svelte b/src/routes/isac/data-moderation/+page.svelte
index 9697df2..006a41d 100644
--- a/src/routes/isac/data-moderation/+page.svelte
+++ b/src/routes/isac/data-moderation/+page.svelte
@@ -55,7 +55,7 @@
CHANGE CONTRIBUTOR SUBMITTED STATUS
{#each data.proposals as proposal}
-
{proposal.module === 'skillchips' ? 'CHIP' : proposal.module.slice(0,4).toUpperCase()} {proposal.summary} {moduleLabel(proposal.module)} / {operationLabel(proposal.action)}
+
{proposal.module === 'skillchips' ? 'CHIP' : proposal.module === 'specializations' ? 'CLASS' : proposal.module.slice(0,4).toUpperCase()} {proposal.summary} {moduleLabel(proposal.module)} / {operationLabel(proposal.action)}
{proposal.proposer_name} AGENT {String(proposal.proposed_by).slice(0,8).toUpperCase()}
{date(proposal.created_at)}
{proposal.status.toUpperCase()}
diff --git a/src/routes/isac/images/+page.svelte b/src/routes/isac/images/+page.svelte
index 72168fc..e7b0b53 100644
--- a/src/routes/isac/images/+page.svelte
+++ b/src/routes/isac/images/+page.svelte
@@ -42,7 +42,7 @@
NEW MANAGED ASSET
UPLOAD + PROCESS adding = false}>×