Fix talent outcome validation feedback

This commit is contained in:
JB
2026-07-27 22:01:20 -04:00
parent 37918bd411
commit 7e78d75f85
6 changed files with 248 additions and 50 deletions
+69 -36
View File
@@ -15,15 +15,14 @@ import {
saveConditionalRuleInTransaction
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
import {
missingTalentDescriptionSlots,
numberedTalentOutcomes
} from '$lib/calculations/talent-description';
type SharedEffect = { statId: string; valueI: number; valueII: number };
const descriptionOutcomes = (draft: ReturnType<typeof parseConditionalRulePayload>) => {
const outcomes = draft.clauses.flatMap((clause) => clause.outcomes)
.filter((outcome) => outcome.descriptionSlot)
.sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot));
return [...new Map(outcomes.map((outcome) => [outcome.descriptionSlot, outcome])).values()];
};
type SharedEffect = { slot: number; statId?: string; valueI: number; valueII: number };
type TalentErrorArea = 'icon' | 'identity' | 'description' | 'mechanic' | 'outcomes';
type TalentValidationError = { area: TalentErrorArea; message: string };
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies));
@@ -136,16 +135,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
};
};
const parseEffects = (raw: FormDataEntryValue | null): SharedEffect[] => {
const value = JSON.parse(String(raw ?? '[]')) as Array<{ statId: unknown; valueI: unknown; valueII: unknown }>;
if (!Array.isArray(value)) throw new Error();
return value.map((effect) => ({
statId: String(effect.statId),
valueI: Number(effect.valueI),
valueII: Number(effect.valueII)
}));
};
const displayValue = (value: number, unit: unknown) => {
const normalizedUnit = unit === 'flat' ? '' : String(unit ?? '');
return normalizedUnit === 'Shots' ? `${value} ${normalizedUnit}` : `${value}${normalizedUnit}`;
@@ -169,18 +158,34 @@ const renderDescription = (
) => template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder, expression) => {
if (expression === 'duration') return durationSeconds === null ? placeholder : renderMechanicValue(durationSeconds, 'second');
if (expression === 'max_stacks') return maxStacks === null ? placeholder : renderMechanicValue(maxStacks, 'stack');
const effect = effects[Number(expression) - 1];
return effect ? displayValue(tier === 'I' ? effect.valueI : effect.valueII, units.get(effect.statId)) : placeholder;
const effect = effects.find((item) => item.slot === Number(expression));
return effect
? displayValue(
tier === 'I' ? effect.valueI : effect.valueII,
effect.statId ? units.get(effect.statId) : undefined
)
: placeholder;
});
export const actions: Actions = {
saveTalent: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const talentMode = id ? 'edit' : 'add';
const reject = (status: number, errors: TalentValidationError[]) => fail(status, {
talentError: errors[0]?.message ?? 'The talent could not be saved.',
talentErrors: errors,
talentMode,
resultToken: randomUUID()
});
let iconMutation;
try { iconMutation = await readIconMutation(form); }
catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { talentError: error.message }); throw error; }
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? '');
catch (error) {
if (error instanceof GameDataIconValidationError) return reject(400, [{ area: 'icon', message: error.message }]);
throw error;
}
const name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? '');
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').trim();
const db = await getDatabase();
const conditionalRuleRaw = form.get('conditionalRule');
@@ -195,36 +200,64 @@ export const actions: Actions = {
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY))
});
} catch (error) {
return fail(400, { talentError: error instanceof Error ? error.message : 'The conditional mechanic is invalid.' });
return reject(400, [{
area: 'mechanic',
message: error instanceof Error ? error.message : 'The conditional mechanic is invalid.'
}]);
}
const cooldownSeconds = conditionalRule.clauses.find((clause) => clause.cooldownSeconds)?.cooldownSeconds ?? null;
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 sharedEffects: SharedEffect[] = descriptionOutcomes(conditionalRule)
.filter((effect) => effect.kind === 'attribute_modifier' && effect.attributeId)
const descriptionOutcomes = numberedTalentOutcomes(conditionalRule);
const sharedEffects: SharedEffect[] = descriptionOutcomes
.map((effect) => ({
statId: effect.attributeId ?? '',
slot: Number(effect.descriptionSlot),
...(effect.kind === 'attribute_modifier' && effect.attributeId ? { statId: effect.attributeId } : {}),
valueI: effect.variants.I.value,
valueII: effect.variants.II.value
}));
if (name.length < 2 || !['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].includes(category)) return fail(400, { talentError: 'Provide a valid talent name and category. Exotic talents are managed from their owning weapon.' });
if (!descriptionTemplate || !conditionalRule.clauses.length || !sharedEffects.length) return fail(400, { talentError: 'A description template, mechanic branch, and at least one numbered Attribute outcome are required.' });
if (sharedEffects.some((effect) => !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))) return fail(400, { talentError: 'Every outcome needs valid Tier I and Tier II values.' });
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1]));
if (placeholders.some((index) => index < 1 || index > sharedEffects.length)) return fail(400, { talentError: 'Description placeholders must reference an existing effect.' });
if (descriptionTemplate.includes('{duration}') && durationSeconds === null) return fail(400, { talentError: 'The description uses {duration}, but no duration is defined.' });
if (descriptionTemplate.includes('{max_stacks}') && maxStacks === null) return fail(400, { talentError: 'The description uses {max_stacks}, but no maximum stack count is defined.' });
const validationErrors: TalentValidationError[] = [];
if (name.length < 2 || !['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].includes(category)) {
validationErrors.push({ area: 'identity', message: 'Provide a valid talent name and category. Exotic talents are managed from their owning weapon.' });
}
if (!descriptionTemplate) validationErrors.push({ area: 'description', message: 'Enter a shared description template.' });
if (!conditionalRule.clauses.length) validationErrors.push({ area: 'mechanic', message: 'Add at least one mechanic branch.' });
if (!sharedEffects.length) validationErrors.push({ area: 'outcomes', message: 'Add at least one numbered value outcome.' });
if (sharedEffects.some((effect) => !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))) {
validationErrors.push({ area: 'outcomes', message: 'Every numbered outcome needs valid Tier I and Tier II values.' });
}
const missingSlots = missingTalentDescriptionSlots(descriptionTemplate, descriptionOutcomes);
if (missingSlots.length) {
validationErrors.push({
area: 'description',
message: `Description placeholder${missingSlots.length === 1 ? '' : 's'} ${missingSlots.map((slot) => `{${slot}}`).join(', ')} must reference numbered outcomes.`
});
validationErrors.push({ area: 'outcomes', message: 'Add or renumber outcomes to match every numbered description placeholder.' });
}
if (descriptionTemplate.includes('{duration}') && durationSeconds === null) {
validationErrors.push({ area: 'description', message: 'The description uses {duration}, but no duration is defined.' });
validationErrors.push({ area: 'mechanic', message: 'Define a duration for the {duration} description placeholder.' });
}
if (descriptionTemplate.includes('{max_stacks}') && maxStacks === null) {
validationErrors.push({ area: 'description', message: 'The description uses {max_stacks}, but no maximum stack count is defined.' });
validationErrors.push({ area: 'mechanic', message: 'Define a pool maximum for the {max_stacks} description placeholder.' });
}
if (validationErrors.length) return reject(400, validationErrors);
const storedCategory = category === 'Body Armor' ? 'Vest' : category.startsWith('Weapon Talent') ? 'Weapon' : category;
if (await (await db.prepare('SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?')).get(name, storedCategory, id || 'new')) return fail(409, { talentError: 'That talent already exists in this category.' });
if (await (await db.prepare('SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?')).get(name, storedCategory, id || 'new')) {
return reject(409, [{ area: 'identity', message: 'That talent already exists in this category.' }]);
}
const statIds = conditionalRule.clauses.flatMap((clause) => clause.outcomes)
.map((effect) => effect.attributeId)
.filter((value): value is string => Boolean(value));
const statRows = statIds.length
? await (await db.prepare(`SELECT id,unit FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).all(...statIds)
: [];
if (statRows.length !== new Set(statIds).size) return fail(400, { talentError: 'One or more selected attributes are unavailable.' });
if (statRows.length !== new Set(statIds).size) {
return reject(400, [{ area: 'outcomes', message: 'One or more selected attributes are unavailable.' }]);
}
const units = new Map(statRows.map((stat) => [String(stat.id), stat.unit]));
const talentId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString();
const before = id ? {
@@ -258,7 +291,7 @@ export const actions: Actions = {
const tierRow = await (await db.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(talentId, tier);
const tierId = String(tierRow?.id);
await (await db.prepare('DELETE FROM talent_tier_effects WHERE talent_tier_id=?')).run(tierId);
for (const outcome of descriptionOutcomes(conditionalRule)) {
for (const outcome of descriptionOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId) continue;
await (await db.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')).run(
randomUUID(),