feat: add source-owned combat mechanic programs
This commit is contained in:
@@ -8,6 +8,13 @@ import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
||||
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';
|
||||
|
||||
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
|
||||
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
|
||||
@@ -18,14 +25,11 @@ const expectedSecondaryAttributes: Record<string, Record<string, string>> = {
|
||||
Engineering: { Core: 'Engineering', Damage: 'Skill Damage', Health: 'Health' }
|
||||
};
|
||||
|
||||
const parseList = (value: FormDataEntryValue | null): any[] => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(value ?? '[]'));
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
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) {
|
||||
const database = await getDatabase();
|
||||
@@ -35,8 +39,8 @@ async function protocolSnapshot(id: string) {
|
||||
icon: await gameDataIconMetadata(database, 'os_protocol', id),
|
||||
primaryEffect: await (await database.prepare('SELECT * FROM os_protocol_primary_effects WHERE protocol_id=?')).get(id) ?? null,
|
||||
talent: talent ?? null,
|
||||
talentTriggers: talent ? await (await database.prepare('SELECT trigger_id FROM os_protocol_talent_triggers WHERE talent_id=? ORDER BY trigger_id')).all(talent.id) : [],
|
||||
talentEffects: talent ? await (await database.prepare('SELECT * FROM os_protocol_talent_effects WHERE talent_id=? ORDER BY sort_order')).all(talent.id) : []
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,21 +96,18 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
(await database.prepare(`SELECT COUNT(*) AS total FROM os_protocols ${where}`)).get(...args)
|
||||
]);
|
||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||
const [protocols, primaryEffects, profiles, secondaryEffects, talents, talentTriggers, talentEffects, stats, triggers, icons, pending] = await Promise.all([
|
||||
const [protocols, primaryEffects, profiles, secondaryEffects, talents, talentEffects, stats, icons, pending] = await Promise.all([
|
||||
(await database.prepare(`SELECT * FROM os_protocols ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset),
|
||||
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_secondary_profiles ORDER BY quality,primary_attribute_type')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_talent_triggers')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_talent_effects ORDER BY sort_order')).all(),
|
||||
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT id,name,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='os_protocol'")).all(),
|
||||
getPendingEntities(user.id, [
|
||||
{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] },
|
||||
{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] },
|
||||
{ module: 'protocols', action: 'save', kind: 'protocol', fields: ['quality', 'attributeType', 'primaryStatId', 'primaryBaseValue', 'talentName', 'descriptionTemplate', 'talentEffects', 'triggerIds'] }
|
||||
{ module: 'protocols', action: 'save', kind: 'protocol', fields: ['quality', 'attributeType', 'primaryStatId', 'primaryBaseValue', 'talentName', 'descriptionTemplate', 'conditionalRule'] }
|
||||
])
|
||||
]);
|
||||
const configuredProfiles = profiles.map((profile) => ({ ...profile, effects: secondaryEffects.filter((effect) => effect.profile_id === profile.id) }));
|
||||
@@ -125,12 +126,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
talent: {
|
||||
name: protocol.talentName,
|
||||
description_template: protocol.descriptionTemplate,
|
||||
effects: (() => { try { return JSON.parse(String(protocol.talentEffects ?? '[]')); } catch { return []; } })(),
|
||||
triggerIds: Array.isArray(protocol.triggerIds) ? protocol.triggerIds : [protocol.triggerIds].filter(Boolean)
|
||||
conditionalRule: (() => { try { return JSON.parse(String(protocol.conditionalRule ?? 'null')); } catch { return null; } })()
|
||||
}
|
||||
}));
|
||||
return {
|
||||
protocols: [...pendingProtocols, ...protocols.map((protocol) => {
|
||||
const configuredProtocols = await Promise.all(protocols.map(async (protocol) => {
|
||||
const talent = talents.find((item) => item.protocol_id === protocol.id);
|
||||
return {
|
||||
...protocol,
|
||||
@@ -139,16 +138,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
secondaryProfile: configuredProfiles.find((profile) => profile.quality === protocol.quality && profile.primary_attribute_type === protocol.primary_attribute_type) ?? null,
|
||||
talent: talent ? {
|
||||
...talent,
|
||||
triggerIds: talentTriggers.filter((item) => item.talent_id === talent.id).map((item) => String(item.trigger_id)),
|
||||
effects: talentEffects.filter((item) => item.talent_id === talent.id)
|
||||
effects: talentEffects.filter((item) => item.talent_id === talent.id),
|
||||
conditionalRule: await loadConditionalRuleDraft(database, 'os_protocol_talent', String(talent.id), ['base'])
|
||||
} : null
|
||||
};
|
||||
})],
|
||||
}));
|
||||
return {
|
||||
protocols: [...pendingProtocols, ...configuredProtocols],
|
||||
profiles: qualities.flatMap((quality) => attributeTypes.map((attributeType) => configuredProfiles.find((profile) =>
|
||||
profile.quality === quality && profile.primary_attribute_type === attributeType
|
||||
) ?? { id: '', quality, primary_attribute_type: attributeType, effects: [] })),
|
||||
stats: [...pending.filter((item) => item.kind === 'attribute'), ...stats],
|
||||
triggers: [...pending.filter((item) => item.kind === 'trigger'), ...triggers],
|
||||
summary: { ...summary, total: Number(summary?.total ?? 0) + pending.filter((item) => item.kind === 'protocol').length, pending: pending.filter((item) => item.kind === 'protocol').length }, filter, pagination, search, sort
|
||||
};
|
||||
};
|
||||
@@ -191,44 +191,73 @@ export const actions: Actions = {
|
||||
const primaryBaseValue = Number(form.get('primaryBaseValue'));
|
||||
const talentName = String(form.get('talentName') ?? '').normalize('NFKC').trim();
|
||||
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').normalize('NFKC').trim();
|
||||
const triggerIds = [...new Set(form.getAll('triggerIds').map(String).filter(Boolean))];
|
||||
const talentEffects = parseList(form.get('talentEffects')).map((item, index) => ({
|
||||
statId: String(item.statId ?? ''), baseValue: Number(item.baseValue), sortOrder: index + 1
|
||||
}));
|
||||
const database = await getDatabase();
|
||||
let conditionalRule;
|
||||
try {
|
||||
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))
|
||||
});
|
||||
} catch (error) {
|
||||
return fail(400, { protocolError: error instanceof Error ? error.message : 'The conditional mechanic is invalid.' });
|
||||
}
|
||||
if (name.length < 2 || name.length > 80) return fail(400, { protocolError: 'Protocol names must be 2–80 characters.' });
|
||||
if (!qualities.includes(quality) || !attributeTypes.includes(attributeType)) return fail(400, { protocolError: 'Choose a valid quality and primary attribute type.' });
|
||||
if (!primaryStatId || !Number.isFinite(primaryBaseValue) || primaryBaseValue < 0) return fail(400, { protocolError: 'Choose the protocol’s unique primary stat and a non-negative level-1 base value.' });
|
||||
if (talentName.length < 2 || talentName.length > 80 || !descriptionTemplate || !triggerIds.length || !talentEffects.length) return fail(400, { protocolError: 'The unique talent requires a name, description template, trigger, and at least one effect.' });
|
||||
if (talentEffects.length > 12 || talentEffects.some((item) => !item.statId || !Number.isFinite(item.baseValue))) return fail(400, { protocolError: 'Talent effects require valid attributes and base values.' });
|
||||
if (new Set(talentEffects.map((item) => item.statId)).size !== talentEffects.length) return fail(400, { protocolError: 'Each talent effect may reference an attribute only once.' });
|
||||
const outcomes = mechanicOutcomes(conditionalRule);
|
||||
const numberedOutcomes = 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;
|
||||
if (talentName.length < 2 || talentName.length > 80 || !descriptionTemplate || !conditionalRule.clauses.length || !outcomes.length) return fail(400, { protocolError: 'The unique talent requires a name, description template, mechanic branch, and at least one outcome.' });
|
||||
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1]));
|
||||
if (placeholders.some((index) => index < 1 || index > talentEffects.length)) return fail(400, { protocolError: 'Description placeholders must reference configured talent effects.' });
|
||||
const database = await getDatabase();
|
||||
if (placeholders.some((index) => !numberedOutcomes.some((outcome) => outcome.descriptionSlot === index))) return fail(400, { protocolError: 'Description placeholders must reference configured numbered outcomes.' });
|
||||
if (descriptionTemplate.includes('{duration}') && durationSeconds === null) return fail(400, { protocolError: 'The description uses {duration}, but no expiring state pool is defined.' });
|
||||
if (descriptionTemplate.includes('{max_stacks}') && !maxStacks) return fail(400, { protocolError: 'The description uses {max_stacks}, but no state pool maximum is defined.' });
|
||||
if (!await (await database.prepare('SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?')).get(quality, attributeType)) return fail(400, { protocolError: 'Configure the shared secondary profile for this quality and attribute type first.' });
|
||||
if (await (await database.prepare('SELECT id FROM os_protocols WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) return fail(409, { protocolError: 'That OS Protocol already exists.' });
|
||||
const existingTalent = id ? await (await database.prepare('SELECT id FROM os_protocol_talents WHERE protocol_id=?')).get(id) : null;
|
||||
if (await (await database.prepare('SELECT id FROM os_protocol_talents WHERE name=? COLLATE NOCASE AND id<>?')).get(talentName, existingTalent?.id ?? 'new')) return fail(409, { protocolError: 'That protocol talent name is already in use.' });
|
||||
const statIds = [...new Set([primaryStatId, ...talentEffects.map((item) => item.statId)])];
|
||||
const 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 validTriggers = await (await database.prepare(`SELECT COUNT(*) AS count FROM talent_triggers WHERE active=1 AND id IN (${triggerIds.map(() => '?').join(',')})`)).get(...triggerIds);
|
||||
if (Number(validTriggers?.count) !== triggerIds.length) return fail(400, { protocolError: 'One or more selected triggers are unavailable.' });
|
||||
const protocolId = id || randomUUID(), talentId = String(existingTalent?.id ?? randomUUID()), now = new Date().toISOString();
|
||||
const before = id ? await protocolSnapshot(id) : null;
|
||||
if (id) {
|
||||
if (!await auditRecord('os_protocols', id)) return fail(404, { protocolError: 'OS Protocol not found.' });
|
||||
await (await database.prepare('UPDATE os_protocols SET name=?,quality=?,primary_attribute_type=?,active=1,updated_at=? WHERE id=?')).run(name, quality, attributeType, now, id);
|
||||
} else await (await database.prepare('INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?,?)')).run(protocolId, name, quality, attributeType, now, now);
|
||||
await (await database.prepare(`INSERT INTO os_protocol_primary_effects(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?)
|
||||
ON CONFLICT(protocol_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,base_value=excluded.base_value`)).run(randomUUID(), protocolId, primaryStatId, primaryBaseValue);
|
||||
await (await database.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
|
||||
ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)).run(talentId, protocolId, talentName, descriptionTemplate, now, now);
|
||||
await (await database.prepare('DELETE FROM os_protocol_talent_triggers WHERE talent_id=?')).run(talentId);
|
||||
for (const triggerId of triggerIds) await (await database.prepare('INSERT INTO os_protocol_talent_triggers(talent_id,trigger_id) VALUES(?,?)')).run(talentId, triggerId);
|
||||
await (await database.prepare('DELETE FROM os_protocol_talent_effects WHERE talent_id=?')).run(talentId);
|
||||
for (const effect of talentEffects) await (await database.prepare('INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)')).run(randomUUID(), talentId, effect.statId, effect.baseValue, effect.sortOrder);
|
||||
await applyIconMutation(database, 'os_protocol', protocolId, iconMutation, 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) });
|
||||
if (id && !before?.record) return fail(404, { protocolError: 'OS Protocol not found.' });
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
if (id) {
|
||||
await (await database.prepare('UPDATE os_protocols SET name=?,quality=?,primary_attribute_type=?,active=1,updated_at=? WHERE id=?')).run(name, quality, attributeType, now, id);
|
||||
} else await (await database.prepare('INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?,?)')).run(protocolId, name, quality, attributeType, now, now);
|
||||
await (await database.prepare(`INSERT INTO os_protocol_primary_effects(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?)
|
||||
ON CONFLICT(protocol_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,base_value=excluded.base_value`)).run(randomUUID(), protocolId, primaryStatId, primaryBaseValue);
|
||||
await (await database.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
|
||||
ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)).run(talentId, protocolId, talentName, descriptionTemplate, now, now);
|
||||
await (await database.prepare('DELETE FROM os_protocol_talent_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 database.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(database, 'os_protocol_talent', talentId, conditionalRule);
|
||||
await applyIconMutation(database, 'os_protocol', protocolId, iconMutation, 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) });
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
return { protocolSaved: true, protocolMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||
},
|
||||
archive: async ({ request, cookies }) => {
|
||||
|
||||
Reference in New Issue
Block a user