feat: persist authored mechanic revisions

This commit is contained in:
jb
2026-09-02 15:39:50 -04:00
parent 98f7f51e46
commit 7e392a1f37
56 changed files with 20720 additions and 3279 deletions
+131 -455
View File
@@ -1,4 +1,4 @@
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { recordIsacAudit } from '$lib/server/audit';
import {
requireDataView,
requirePermission,
@@ -13,110 +13,38 @@ import {
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import {
applyIconMutation,
gameDataIconMetadata,
gameDataIconUrl,
GameDataIconValidationError,
readIconMutation,
} from '$lib/server/game-data-icons';
import {
applyGameDataRender,
ManagedImageValidationError,
managedImageUrl,
processManagedImage,
readGameDataRenderMutation,
} from '$lib/server/managed-images';
import { gameDataIconUrl } from '$lib/server/game-data-icons';
import { managedImageUrl } from '$lib/server/managed-images';
import { getPendingEntities } from '$lib/server/data-moderation';
import {
conditionalRuleSnapshot,
loadConditionalRuleDraft,
parseConditionalRulePayload,
saveConditionalRuleInTransaction,
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
MechanicSourceFingerprintConflictError,
} from '$lib/server/mechanic-authored-sources';
import {
ProtocolFormValidationError,
ProtocolNotFoundError,
loadProtocolTalentMechanic,
parseProtocolForm,
protocolSnapshot,
saveProtocolInTransaction,
} from './protocol-save';
import {
getOsProtocolProfileAttributeIds,
type OsProtocolAttributeType,
} from '$lib/server/os-protocol-profiles';
import type { Database } from '@tursodatabase/database';
import { MediaMutationJournal } from '$lib/server/media-journal';
// SvelteKit permits underscored exports from route modules. Keep this alias
// available to Data Oversight and transaction-focused tests without exposing a
// non-route page export.
export {
saveProtocolInTransaction as _saveProtocolInTransaction,
} from './protocol-save';
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
const secondaryRoles = ['Core', 'Damage', 'Health'];
const renderAuditMetadata = (render: Record<string, any> | null | undefined) =>
render
? {
id: render.id,
name: render.name,
category: render.category,
tags_json: render.tags_json,
original_filename: render.original_filename,
byte_size: render.byte_size,
width: render.width,
height: render.height,
source_entity_type: render.source_entity_type,
source_entity_id: render.source_entity_id,
created_at: render.created_at,
updated_at: render.updated_at,
}
: null;
const mechanicOutcomes = (
rule: ReturnType<typeof parseConditionalRulePayload>,
) => rule.clauses.flatMap((clause) => clause.outcomes);
const descriptionOutcomes = (
rule: ReturnType<typeof parseConditionalRulePayload>,
) =>
mechanicOutcomes(rule)
.filter((outcome) => outcome.descriptionSlot)
.sort(
(left, right) =>
Number(left.descriptionSlot) - Number(right.descriptionSlot),
);
async function protocolSnapshot(id: string, suppliedDatabase?: Database) {
const database = suppliedDatabase ?? (await getDatabase());
const talent = await (
await database.prepare(
'SELECT * FROM os_protocol_talents WHERE protocol_id=?',
)
).get(id);
return {
record: await auditRecord('os_protocols', id, database),
icon: await gameDataIconMetadata(database, 'os_protocol', id),
render: renderAuditMetadata(
await (
await database.prepare(
"SELECT * FROM managed_images WHERE source_entity_type='os_protocol_render' AND source_entity_id=?",
)
).get(id),
),
primaryEffect:
(await (
await database.prepare(
'SELECT * FROM os_protocol_primary_effects WHERE protocol_id=?',
)
).get(id)) ?? null,
talent: talent ?? null,
talentEffects: talent
? await (
await database.prepare(
'SELECT * FROM os_protocol_talent_effects WHERE talent_id=? ORDER BY sort_order',
)
).all(talent.id)
: [],
conditional: talent
? await conditionalRuleSnapshot(
database,
'os_protocol_talent',
String(talent.id),
)
: null,
};
}
async function profileSnapshot(id: string, suppliedDatabase?: Database) {
const database = suppliedDatabase ?? (await getDatabase());
return {
@@ -221,7 +149,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
profiles,
secondaryEffects,
talents,
talentEffects,
stats,
icons,
renders,
@@ -243,10 +170,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role',
)
).all(),
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
(
await database.prepare(
'SELECT * FROM os_protocol_talent_effects ORDER BY sort_order',
'SELECT id,protocol_id,name,created_at,updated_at FROM os_protocol_talents',
)
).all(),
(
@@ -282,8 +208,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'primaryBaseValue',
'hasTalent',
'talentName',
'descriptionTemplate',
'conditionalRule',
'expectedTalentId',
'authoredSource',
'expectedSourceFingerprint',
'mechanicSelectionIntent',
],
},
]),
@@ -294,6 +222,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(effect) => effect.profile_id === profile.id,
),
}));
const talentMechanics = new Map(
await Promise.all(
talents.map(async (talent) => [
String(talent.id),
await loadProtocolTalentMechanic(database, String(talent.id)),
] as const),
),
);
const pendingProtocols = pending
.filter((item) => item.kind === 'protocol')
.filter((item) =>
@@ -334,14 +270,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
protocol.hasTalent === '1'
? {
name: protocol.talentName,
description_template: protocol.descriptionTemplate,
conditionalRule: (() => {
try {
return JSON.parse(String(protocol.conditionalRule ?? 'null'));
} catch {
return null;
}
})(),
authoredSource: null,
expectedSourceFingerprint: null,
selectedRevision: null,
modelDraft: null,
}
: null,
}));
@@ -381,15 +313,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talent: talent
? {
...talent,
effects: talentEffects.filter(
(item) => item.talent_id === talent.id,
),
conditionalRule: await loadConditionalRuleDraft(
database,
'os_protocol_talent',
String(talent.id),
['base'],
),
authoredSource:
talentMechanics.get(String(talent.id))?.authoredSource.source ??
null,
expectedSourceFingerprint:
talentMechanics.get(String(talent.id))?.authoredSource
.fingerprint ?? null,
selectedRevision:
talentMechanics.get(String(talent.id))?.selectedRevision ?? null,
publicProjection:
talentMechanics.get(String(talent.id))?.projection ?? [],
}
: null,
};
@@ -532,357 +465,100 @@ export const actions: Actions = {
'data.manage',
);
const form = await request.formData();
let iconMutation;
try {
iconMutation = await readIconMutation(form);
} catch (error) {
if (error instanceof GameDataIconValidationError)
return fail(400, { protocolError: error.message });
throw error;
}
const renderMutation = await readGameDataRenderMutation(form);
try {
if (renderMutation.file)
renderMutation.processed = await processManagedImage(
renderMutation.file,
false,
false,
true,
);
} catch (error) {
if (error instanceof ManagedImageValidationError)
return fail(400, { protocolError: error.message });
throw error;
}
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '')
.normalize('NFKC')
.trim();
const quality = String(form.get('quality') ?? '');
const attributeType = String(form.get('attributeType') ?? '');
const primaryStatId = String(form.get('primaryStatId') ?? '');
const primaryBaseValueRaw = form.get('primaryBaseValue');
const primaryBaseValue =
typeof primaryBaseValueRaw === 'string' && primaryBaseValueRaw.trim()
? Number(primaryBaseValueRaw)
: Number.NaN;
const hasTalent = form.get('hasTalent') === '1';
const talentName = String(form.get('talentName') ?? '')
.normalize('NFKC')
.trim();
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '')
.normalize('NFKC')
.trim();
const database = await getDatabase();
let conditionalRule: ReturnType<typeof parseConditionalRulePayload> | null =
null;
const journal = new MediaMutationJournal();
try {
if (hasTalent) {
const conditionalRuleRaw = form.get('conditionalRule');
const activeStats = await (
await database.prepare(
'SELECT id FROM stat_attributes WHERE active=1',
)
).all();
conditionalRule = parseConditionalRulePayload(
conditionalRuleRaw,
[{ key: 'base', variantKey: 'base' }],
{
allowedAttributeIds: new Set(
activeStats.map((row) => String(row.id)),
),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
},
);
}
const saveProtocol = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const input = await parseProtocolForm(tx, form, operator.id);
return saveProtocolInTransaction(tx, { ...input, journal });
});
const result = await saveProtocol.immediate();
journal.clear();
return {
protocolSaved: true,
protocolMode: result.mode,
resultToken: randomUUID(),
};
} catch (error) {
return fail(400, {
protocolError:
error instanceof Error
? error.message
: 'The conditional mechanic is invalid.',
});
await journal.rollback(error);
if (error instanceof ProtocolFormValidationError)
return fail(error.status, { protocolError: error.message });
if (error instanceof MechanicSourceFingerprintConflictError)
return fail(409, { protocolError: error.message });
if (error instanceof ProtocolNotFoundError)
return fail(404, { protocolError: error.message });
throw error;
}
if (name.length < 2 || name.length > 80)
return fail(400, {
protocolError: 'Protocol names must be 2–80 characters.',
});
if (!qualities.includes(quality) || !attributeTypes.includes(attributeType))
return fail(400, {
protocolError: 'Choose a valid quality and primary attribute type.',
});
if (
!primaryStatId ||
!Number.isFinite(primaryBaseValue) ||
primaryBaseValue < 0
)
return fail(400, {
protocolError:
'Choose the protocol’s unique primary stat and a non-negative level-1 base value.',
});
const outcomes = conditionalRule ? mechanicOutcomes(conditionalRule) : [];
const numberedOutcomes = conditionalRule
? descriptionOutcomes(conditionalRule)
: [];
const durationSeconds =
conditionalRule?.pools.find((pool) => pool.expiresAfterSeconds)
?.expiresAfterSeconds ?? null;
const maxStacks = conditionalRule?.pools.length
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
: null;
const requiresCompleteMechanic =
hasTalent && conditionalRule?.simulationReadiness !== 'captured_only';
if (
hasTalent &&
(talentName.length < 2 || talentName.length > 80 || !descriptionTemplate)
)
return fail(400, {
protocolError:
'The assigned Talent requires a name and description template.',
});
if (
requiresCompleteMechanic &&
(!conditionalRule?.clauses.length || !outcomes.length)
)
return fail(400, {
protocolError:
'Add a mechanic branch and outcome, or mark the Talent CAPTURE ONLY.',
});
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map(
(match) => Number(match[1]),
);
if (
requiresCompleteMechanic &&
placeholders.some(
(index) =>
!numberedOutcomes.some(
(outcome) => outcome.descriptionSlot === index,
),
)
)
return fail(400, {
protocolError:
'Description placeholders must reference configured numbered outcomes.',
});
if (
requiresCompleteMechanic &&
descriptionTemplate.includes('{duration}') &&
durationSeconds === null
)
return fail(400, {
protocolError:
'The description uses {duration}, but no expiring state pool is defined.',
});
if (
requiresCompleteMechanic &&
descriptionTemplate.includes('{max_stacks}') &&
!maxStacks
)
return fail(400, {
protocolError:
'The description uses {max_stacks}, but no state pool maximum is defined.',
});
const profileAttributeIds = await getOsProtocolProfileAttributeIds(
database,
attributeType as OsProtocolAttributeType,
);
const completeProfile =
Object.values(profileAttributeIds).every(Boolean) &&
(await (
await database.prepare(
`SELECT profile.id FROM os_protocol_secondary_profiles profile
JOIN os_protocol_secondary_effects effect ON effect.profile_id=profile.id
WHERE profile.quality=? AND profile.primary_attribute_type=?
GROUP BY profile.id
HAVING COUNT(effect.id)=3
AND SUM(CASE WHEN effect.effect_role='Core' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1
AND SUM(CASE WHEN effect.effect_role='Damage' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1
AND SUM(CASE WHEN effect.effect_role='Health' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1`,
)
).get(
quality,
attributeType,
profileAttributeIds.Core,
profileAttributeIds.Damage,
profileAttributeIds.Health,
));
if (!completeProfile)
return fail(400, {
protocolError:
'Configure the shared secondary profile for this quality and attribute type first.',
});
if (
await (
await database.prepare(
'SELECT id FROM os_protocols WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(name, id || 'new')
)
return fail(409, { protocolError: 'That OS Protocol already exists.' });
const existingTalent = id
? await (
await database.prepare(
'SELECT id FROM os_protocol_talents WHERE protocol_id=?',
)
).get(id)
: null;
if (
existingTalent &&
!hasTalent &&
form.get('confirmTalentRemoval') !== 'REMOVE'
)
return fail(400, {
protocolError:
'Confirm removal of the existing Talent and mechanic program.',
});
if (
hasTalent &&
(await (
await database.prepare(
'SELECT id FROM os_protocol_talents WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(talentName, existingTalent?.id ?? 'new'))
)
return fail(409, {
protocolError: 'That protocol talent name is already in use.',
});
const outcomeStatIds = outcomes
.map((item) => item.attributeId)
.filter((value): value is string => Boolean(value));
const statIds = [...new Set([primaryStatId, ...outcomeStatIds])];
const validStats = await (
await database.prepare(
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`,
)
).get(...statIds);
if (Number(validStats?.count) !== statIds.length)
return fail(400, {
protocolError: 'One or more selected attributes are unavailable.',
});
const protocolId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID(),
talentId = String(existingTalent?.id ?? randomUUID()),
now = new Date().toISOString();
const saveProtocol = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const before = id ? await protocolSnapshot(id, tx) : null;
if (id && !before?.record)
return fail(404, { protocolError: 'OS Protocol not found.' });
if (id) {
await (
await tx.prepare(
'UPDATE os_protocols SET name=?,quality=?,primary_attribute_type=?,active=1,updated_at=? WHERE id=?',
)
).run(name, quality, attributeType, now, id);
} else
await (
await tx.prepare(
'INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?,?)',
)
).run(protocolId, name, quality, attributeType, now, now);
await (
await tx.prepare(`INSERT INTO os_protocol_primary_effects(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,base_value=excluded.base_value`)
).run(randomUUID(), protocolId, primaryStatId, primaryBaseValue);
if (hasTalent && conditionalRule) {
await (
await tx.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)
).run(talentId, protocolId, talentName, descriptionTemplate, now, now);
await (
await tx.prepare(
'DELETE FROM os_protocol_talent_effects WHERE talent_id=?',
)
).run(talentId);
let compatibilitySortOrder = 0;
for (const outcome of numberedOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
continue;
compatibilitySortOrder += 1;
await (
await tx.prepare(
'INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)',
)
).run(
randomUUID(),
talentId,
outcome.attributeId,
outcome.variants.base.value,
compatibilitySortOrder,
);
}
await saveConditionalRuleInTransaction(
tx,
'os_protocol_talent',
talentId,
conditionalRule,
);
} else if (existingTalent) {
await (
await tx.prepare(
"DELETE FROM conditional_effect_rules WHERE source_type='os_protocol_talent' AND source_id=?",
)
).run(String(existingTalent.id));
await (
await tx.prepare('DELETE FROM os_protocol_talents WHERE id=?')
).run(String(existingTalent.id));
}
await applyIconMutation(
tx,
'os_protocol',
protocolId,
iconMutation,
operator.id,
);
await applyGameDataRender(
tx,
'os_protocol',
protocolId,
name,
renderMutation,
operator.id,
);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'os_protocol.updated' : 'os_protocol.created',
entityId: protocolId,
before,
after: await protocolSnapshot(protocolId, tx),
database: tx,
});
return null;
});
const transactionResult = await saveProtocol.immediate();
if (transactionResult) return transactionResult;
return {
protocolSaved: true,
protocolMode: id ? 'edit' : 'add',
resultToken: randomUUID(),
};
},
archive: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const id = String((await request.formData()).get('id') ?? '');
const before = await protocolSnapshot(id);
if (!before.record)
return fail(404, { protocolError: 'OS Protocol not found.' });
await (
const id = String((await request.formData()).get('id') ?? '').trim();
const database = await getDatabase();
const archiveProtocol = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const before = await protocolSnapshot(id, tx);
if (!before.record) throw new ProtocolNotFoundError();
await (
await getDatabase()
).prepare('UPDATE os_protocols SET active=0,updated_at=? WHERE id=?')
).run(new Date().toISOString(), id);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'os_protocol.archived',
entityId: id,
before,
after: await protocolSnapshot(id),
await tx.prepare(
'UPDATE os_protocols SET active=0,updated_at=? WHERE id=?',
)
).run(new Date().toISOString(), id);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'os_protocol.archived',
entityId: id,
before,
after: await protocolSnapshot(id, tx),
database: tx,
});
});
return { protocolArchived: true };
try {
await archiveProtocol.immediate();
} catch (error) {
if (error instanceof ProtocolNotFoundError)
return fail(404, { protocolError: error.message });
throw error;
}
return { protocolArchived: true, protocolId: id, resultToken: randomUUID() };
},
unarchive: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const id = String((await request.formData()).get('id') ?? '').trim();
const database = await getDatabase();
const unarchiveProtocol = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const before = await protocolSnapshot(id, tx);
if (!before.record) throw new ProtocolNotFoundError();
await (
await tx.prepare(
'UPDATE os_protocols SET active=1,updated_at=? WHERE id=?',
)
).run(new Date().toISOString(), id);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'os_protocol.unarchived',
entityId: id,
before,
after: await protocolSnapshot(id, tx),
database: tx,
});
});
try {
await unarchiveProtocol.immediate();
} catch (error) {
if (error instanceof ProtocolNotFoundError)
return fail(404, { protocolError: error.message });
throw error;
}
return { protocolUnarchived: true, protocolId: id, resultToken: randomUUID() };
},
};