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
+94 -456
View File
@@ -6,20 +6,8 @@ import {
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { getPendingEntities } from '$lib/server/data-moderation';
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 {
getPagination,
getRegistrySearch,
@@ -29,16 +17,28 @@ import {
refreshAbilityFocuses,
refreshSpecializationFocusReady,
} from '$lib/server/specialization-readiness';
import {
SkillChipFormValidationError,
parseSkillChipForm,
_saveSkillChipInTransaction,
SkillChipNotFoundError,
loadTalent,
refreshAllFocusReadiness,
snapshot,
} from './skillchip-save';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import {
conditionalRuleSnapshot,
loadConditionalRuleDraft,
parseConditionalRulePayload,
saveConditionalRuleInTransaction,
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
loadMechanicAuthoredSource,
MechanicSourceFingerprintConflictError,
} from '$lib/server/mechanic-authored-sources';
import {
loadSelectedMechanicModelRevision,
} from '$lib/server/mechanic-model-revisions';
import { loadMechanicPublicProjection } from '$lib/server/mechanic-public-projection';
import type { Database } from '@tursodatabase/database';
import { MediaMutationJournal } from '$lib/server/media-journal';
import {
normalizeSkillChipProfiles,
SKILL_CHIP_QUALITIES,
@@ -52,87 +52,7 @@ import {
replaceSkillChipSecondaryPool,
} from '$lib/server/skill-chip-profiles';
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),
);
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;
async function refreshAllFocusReadiness(
database: Awaited<ReturnType<typeof getDatabase>>,
) {
const focuses = await (
await database.prepare('SELECT id FROM specialization_focuses')
).all();
for (const focus of focuses)
await refreshSpecializationFocusReady(database, String(focus.id));
}
async function snapshot(id: string) {
const database = await getDatabase();
const talent = await (
await database.prepare(
'SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?',
)
).get(id);
return {
record: await auditRecord('skill_chip_sets', id),
icon: await gameDataIconMetadata(database, 'skill_chip', id),
render: renderAuditMetadata(
await (
await database.prepare(
"SELECT * FROM managed_images WHERE source_entity_type='skill_chip_render' AND source_entity_id=?",
)
).get(id),
),
twoPiece:
(await (
await database.prepare(
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
)
).get(id)) ?? null,
talent: talent ?? null,
talentEffects: talent
? await (
await database.prepare(
'SELECT * FROM skill_chip_talent_effects WHERE talent_id=? ORDER BY sort_order',
)
).all(talent.id)
: [],
conditional: talent
? await conditionalRuleSnapshot(
database,
'skill_chip_talent',
String(talent.id),
)
: null,
};
}
export { _saveSkillChipInTransaction } from './skillchip-save';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies));
@@ -240,8 +160,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'twoPieceMax',
'hasTalent',
'talentName',
'descriptionTemplate',
'conditionalRule',
'expectedTalentId',
'authoredSource',
'expectedSourceFingerprint',
'mechanicSelectionIntent',
],
},
]),
@@ -266,7 +188,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
sets,
twoPiece,
talents,
talentEffects,
stats,
abilities,
specializations,
@@ -285,10 +206,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
).all(),
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
(
await database.prepare(
'SELECT * FROM skill_chip_talent_effects ORDER BY sort_order',
`SELECT id,skill_chip_set_id,name,created_at,updated_at
FROM skill_chip_talents`,
)
).all(),
(
@@ -358,7 +279,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const currentPendingSets = pendingSets.filter(
(set) =>
Boolean(set.abilityId) &&
(String(set.hasTalent) !== '1' || Boolean(set.conditionalRule)),
(String(set.hasTalent) !== '1' || Boolean(set.authoredSource)),
);
const pendingSetRows = requestedItemId
? currentPendingSets.filter((set) => set.id === requestedItemId)
@@ -387,16 +308,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
String(set.hasTalent) === '1'
? {
name: set.talentName,
description_template: set.descriptionTemplate,
conditionalRule: (() => {
authoredSource: (() => {
try {
return JSON.parse(
String(set.conditionalRule ?? 'null'),
);
return JSON.parse(String(set.authoredSource ?? 'null'));
} catch {
return null;
}
})(),
expectedSourceFingerprint:
String(set.expectedSourceFingerprint ?? ''),
selectedRevision: null,
publicProjection: [],
mechanicSelectionIntent:
String(set.mechanicSelectionIntent ?? 'clear'),
}
: null,
}));
@@ -404,6 +328,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
sets.map(async (set) => {
const talent = talents.find((item) => item.skill_chip_set_id === set.id);
const icon = icons.find((item) => item.entity_id === set.id);
const authoredSource = talent
? await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
String(talent.id),
)
: null;
const selectedRevision = authoredSource
? await loadSelectedMechanicModelRevision(database, authoredSource.id)
: null;
const publicProjection = authoredSource
? await loadMechanicPublicProjection(database, authoredSource.id)
: [];
return {
...set,
iconUrl: icon
@@ -422,15 +359,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talent: talent
? {
...talent,
effects: talentEffects.filter(
(item) => item.talent_id === talent.id,
),
conditionalRule: await loadConditionalRuleDraft(
database,
'skill_chip_talent',
String(talent.id),
['minimum', 'maximum'],
),
authoredSource: authoredSource
? {
version: authoredSource.source.version,
template: authoredSource.source.template,
variants: authoredSource.source.variants,
}
: null,
expectedSourceFingerprint: authoredSource?.fingerprint ?? null,
selectedRevision,
publicProjection,
mechanicSelectionIntent: selectedRevision ? 'preserve' : 'clear',
}
: null,
};
@@ -472,340 +411,31 @@ 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, { skillChipError: 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, { skillChipError: error.message });
throw error;
}
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '')
.normalize('NFKC')
.trim();
const abilityId = String(form.get('abilityId') ?? '');
const twoPieceStatId = String(form.get('twoPieceStatId') ?? '');
const twoPieceMin = Number(form.get('twoPieceMin'));
const twoPieceMax = Number(form.get('twoPieceMax'));
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: 'minimum', variantKey: 'minimum' },
{ key: 'maximum', variantKey: 'maximum' },
],
{
allowedAttributeIds: new Set(
activeStats.map((row) => String(row.id)),
),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
},
);
}
const save = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const input = await parseSkillChipForm(tx, form, operator.id);
return _saveSkillChipInTransaction(tx, { ...input, journal });
});
const result = await save.immediate();
journal.clear();
return {
skillChipSaved: true,
skillChipMode: result.mode,
resultToken: randomUUID(),
};
} catch (error) {
return fail(400, {
skillChipError:
error instanceof Error
? error.message
: 'The conditional mechanic is invalid.',
});
}
if (name.length < 2 || name.length > 80)
return fail(400, {
skillChipError: 'Skill Chip names must be 2–80 characters.',
});
if (!abilityId)
return fail(400, {
skillChipError: 'Assign this Skill Chip to a specialization ability.',
});
if (
!twoPieceStatId ||
!Number.isFinite(twoPieceMin) ||
!Number.isFinite(twoPieceMax) ||
twoPieceMax < twoPieceMin
)
return fail(400, {
skillChipError: 'Configure a valid 2-piece stat range.',
});
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;
if (
hasTalent &&
(talentName.length < 2 ||
talentName.length > 80 ||
!descriptionTemplate ||
!conditionalRule?.clauses.length ||
!outcomes.length)
)
return fail(400, {
skillChipError:
'The 3-piece talent requires a name, description, mechanic branch, and outcome.',
});
if (
hasTalent &&
outcomes.some(
(outcome) =>
outcome.variants.maximum.value < outcome.variants.minimum.value,
)
)
return fail(400, {
skillChipError:
'Every talent outcome maximum must be at least its minimum.',
});
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map(
(match) => Number(match[1]),
);
if (
hasTalent &&
placeholders.some(
(index) =>
!numberedOutcomes.some(
(outcome) => outcome.descriptionSlot === index,
),
)
)
return fail(400, {
skillChipError:
'Description placeholders must reference configured numbered outcomes.',
});
if (
hasTalent &&
descriptionTemplate.includes('{duration}') &&
durationSeconds === null
)
return fail(400, {
skillChipError:
'The description uses {duration}, but no expiring state pool is defined.',
});
if (hasTalent && descriptionTemplate.includes('{max_stacks}') && !maxStacks)
return fail(400, {
skillChipError:
'The description uses {max_stacks}, but no state pool maximum is defined.',
});
if (
!(await (
await database.prepare(
'SELECT id FROM specialization_abilities WHERE id=? AND active=1',
)
).get(abilityId))
) {
return fail(400, {
skillChipError: 'Choose an active specialization ability.',
});
}
const assignedCount = Number(
(
await (
await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets
WHERE specialization_ability_id=? AND active=1 AND id<>?`)
).get(abilityId, id || 'new')
)?.count ?? 0,
);
if (assignedCount >= 3) {
return fail(409, {
skillChipError:
'That ability already has all three active Skill Chips assigned.',
});
}
if (
await (
await database.prepare(
'SELECT id FROM skill_chip_sets WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(name, id || 'new')
)
return fail(409, {
skillChipError: 'That Skill Chip set already exists.',
});
const existingTalent = id
? await (
await database.prepare(
'SELECT id FROM skill_chip_talents WHERE skill_chip_set_id=?',
)
).get(id)
: null;
if (
existingTalent &&
!hasTalent &&
form.get('confirmTalentRemoval') !== 'REMOVE'
)
return fail(400, {
skillChipError:
'Confirm removal of the existing 3-piece Talent and mechanic program.',
});
if (
hasTalent &&
(await (
await database.prepare(
'SELECT id FROM skill_chip_talents WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(talentName, existingTalent?.id ?? 'new'))
)
return fail(409, {
skillChipError: 'That Skill Chip talent name is already in use.',
});
const outcomeStatIds = outcomes
.map((effect) => effect.attributeId)
.filter((value): value is string => Boolean(value));
const allStatIds = [...new Set([twoPieceStatId, ...outcomeStatIds])];
const validStats = await (
await database.prepare(
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${allStatIds.map(() => '?').join(',')})`,
)
).get(...allStatIds);
if (Number(validStats?.count) !== allStatIds.length)
return fail(400, {
skillChipError: 'One or more selected stats are unavailable.',
});
const setId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const talentId = String(existingTalent?.id ?? randomUUID());
const now = new Date().toISOString();
const before = id ? await snapshot(id) : null;
if (id && !before?.record)
return fail(404, { skillChipError: 'Skill Chip set not found.' });
const previousAbilityId = String(
before?.record?.specialization_ability_id ?? '',
);
await database.exec('BEGIN');
try {
if (id) {
await (
await database.prepare(
'UPDATE skill_chip_sets SET name=?,specialization_ability_id=?,active=1,updated_at=? WHERE id=?',
)
).run(name, abilityId, now, id);
} else
await (
await database.prepare(
'INSERT INTO skill_chip_sets(id,name,specialization_ability_id,created_at,updated_at) VALUES(?,?,?,?,?)',
)
).run(setId, name, abilityId, now, now);
await (
await database.prepare(`INSERT INTO skill_chip_two_piece_bonuses(id,skill_chip_set_id,stat_attribute_id,min_value,max_value) VALUES(?,?,?,?,?)
ON CONFLICT(skill_chip_set_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,min_value=excluded.min_value,max_value=excluded.max_value`)
).run(randomUUID(), setId, twoPieceStatId, twoPieceMin, twoPieceMax);
if (hasTalent && conditionalRule) {
await (
await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(skill_chip_set_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)
).run(talentId, setId, talentName, descriptionTemplate, now, now);
await (
await database.prepare(
'DELETE FROM skill_chip_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 skill_chip_talent_effects(id,talent_id,stat_attribute_id,min_value,max_value,sort_order) VALUES(?,?,?,?,?,?)',
)
).run(
randomUUID(),
talentId,
outcome.attributeId,
outcome.variants.minimum.value,
outcome.variants.maximum.value,
compatibilitySortOrder,
);
}
await saveConditionalRuleInTransaction(
database,
'skill_chip_talent',
talentId,
conditionalRule,
);
} else if (existingTalent) {
await (
await database.prepare(
"DELETE FROM conditional_effect_rules WHERE source_type='skill_chip_talent' AND source_id=?",
)
).run(String(existingTalent.id));
await (
await database.prepare('DELETE FROM skill_chip_talents WHERE id=?')
).run(String(existingTalent.id));
}
await applyIconMutation(
database,
'skill_chip',
setId,
iconMutation,
operator.id,
);
await applyGameDataRender(
database,
'skill_chip',
setId,
name,
renderMutation,
operator.id,
);
await refreshAbilityFocuses(database, abilityId);
if (previousAbilityId && previousAbilityId !== abilityId)
await refreshAbilityFocuses(database, previousAbilityId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'skill_chip.updated' : 'skill_chip.created',
entityId: setId,
before,
after: await snapshot(setId),
database,
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
await journal.rollback(error);
if (error instanceof SkillChipFormValidationError)
return fail(error.status, { skillChipError: error.message });
if (error instanceof MechanicSourceFingerprintConflictError)
return fail(409, { skillChipError: error.message });
if (error instanceof SkillChipNotFoundError)
return fail(404, { skillChipError: error.message });
throw error;
}
return {
skillChipSaved: true,
skillChipMode: id ? 'edit' : 'add',
resultToken: randomUUID(),
};
},
savePrimaryProfile: async ({ request, cookies }) => {
const operator = await requirePermission(
@@ -936,7 +566,7 @@ export const actions: Actions = {
const database = await getDatabase();
await database.exec('BEGIN IMMEDIATE');
try {
const before = await snapshot(id);
const before = await snapshot(database, id);
if (!before.record) {
await database.exec('ROLLBACK');
return fail(404, { skillChipError: 'Skill Chip set not found.' });
@@ -956,7 +586,7 @@ export const actions: Actions = {
action: 'skill_chip.archived',
entityId: id,
before,
after: await snapshot(id),
after: await snapshot(database, id),
database,
});
await database.exec('COMMIT');
@@ -964,7 +594,11 @@ export const actions: Actions = {
await database.exec('ROLLBACK');
throw error;
}
return { skillChipArchived: true };
return {
skillChipArchived: true,
skillChipId: id,
resultToken: randomUUID(),
};
},
unarchive: async ({ request, cookies }) => {
const operator = await requirePermission(
@@ -975,7 +609,7 @@ export const actions: Actions = {
const database = await getDatabase();
await database.exec('BEGIN IMMEDIATE');
try {
const before = await snapshot(id);
const before = await snapshot(database, id);
if (!before.record) {
await database.exec('ROLLBACK');
return fail(404, { skillChipError: 'Skill Chip set not found.' });
@@ -1021,7 +655,7 @@ export const actions: Actions = {
action: 'skill_chip.unarchived',
entityId: id,
before,
after: await snapshot(id),
after: await snapshot(database, id),
database,
});
await database.exec('COMMIT');
@@ -1029,6 +663,10 @@ export const actions: Actions = {
await database.exec('ROLLBACK');
throw error;
}
return { skillChipUnarchived: true };
return {
skillChipUnarchived: true,
skillChipId: id,
resultToken: randomUUID(),
};
},
};
+91 -47
View File
@@ -10,34 +10,58 @@
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
import AuthoredDescriptionEditor from '$lib/components/isac/AuthoredDescriptionEditor.svelte';
import SkillChipSharedProfiles from '$lib/components/SkillChipSharedProfiles.svelte';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import type { MechanicAuthoredVariant } from '$lib/calculations/mechanic-authored-description';
let { data, form } = $props();
const talentValueColumns = [
{ key: 'minimum', variantKey: 'minimum', label: 'MINIMUM' },
{ key: 'maximum', variantKey: 'maximum', label: 'MAXIMUM' }
];
const authoredVariantLabels = [
{ key: 'minimum', label: 'MINIMUM' },
{ key: 'maximum', label: 'MAXIMUM' }
];
const mechanicSelectionOptions = [
{ value: 'preserve', label: 'PRESERVE CURRENT MODEL' },
{ value: 'save', label: 'SAVE MODEL' },
{ value: 'clear', label: 'CLEAR MODEL' }
];
const blankRule = (): ConditionalMechanicDraftV1 => ({
version:1,simulationReadiness:'captured_only',scenarioInputs:[],pools:[],
clauses:[{key:'primary',label:'Primary',event:'skill_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'assigned_ability',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{minimum:{value:0},maximum:{value:0}}}]}]
version: 1,
simulationReadiness: 'captured_only',
scenarioInputs: [],
pools: [],
clauses: []
});
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', renderUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, hasTalent: false, talentOpen: false, talent: blankTalent() });
const blankAuthoredVariants = (): MechanicAuthoredVariant[] => [
{ key: 'minimum', materialized: '', slots: [] },
{ key: 'maximum', materialized: '', slots: [] }
];
const blankAuthoredSource = () => ({ version: 1, template: '', variants: blankAuthoredVariants() });
const blankTalent = () => ({
id: '',
name: '',
authoredSource: blankAuthoredSource(),
expectedTalentId: '',
expectedSourceFingerprint: '',
selectedRevision: null,
mechanicSelectionIntent: 'clear',
modelDraft: blankRule()
});
const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', renderUrl: '', expectedOwnerUpdatedAt: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, hasTalent: false, talentOpen: false, talent: blankTalent() });
let editing = $state<any | null>(null);
let highEndCount = $state('0');
let handledResultToken = $state('');
let iconResetToken = $state(0);
let renderResetToken = $state(0);
let openedRequestedItem = $state(false);
const qualitySteps = [
{ value: '0', label: '0 HIGH-END' },
{ value: '1', label: '1 HIGH-END' },
{ value: '2', label: '2 HIGH-END' },
{ value: '3', label: '3 HIGH-END' }
];
const stat = (id: string) => data.stats.find((item: any) => item.id === id);
const ability = (id: string) => data.abilities.find((item: any) => item.id === id);
const mechanicModeled = (talent: any) =>
Boolean(talent?.publicProjection?.length) &&
talent.publicProjection.every((variant: any) => variant.status === 'modeled');
const format = (value: number | string, statId: string) => {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return '—';
@@ -45,32 +69,25 @@
const unit = stat(statId)?.unit ?? '';
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
};
const scaled = (minimum: number | string, maximum: number | string) => Number(minimum) + (Number(maximum) - Number(minimum)) * Number(highEndCount) / 3;
const previewOutcomes = () => editing.talent.conditionalRule.clauses.flatMap((clause: any) => clause.outcomes)
.filter((outcome: any) => outcome.descriptionSlot)
.sort((left: any, right: any) => left.descriptionSlot - right.descriptionSlot);
const talentPreview = () => editing.talent.description_template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
const duration = editing.talent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds;
const maxStacks = editing.talent.conditionalRule.pools.length ? Math.max(...editing.talent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null;
if (expression === 'duration') return duration == null ? placeholder : `${duration} seconds`;
if (expression === 'max_stacks') return maxStacks == null ? placeholder : `${maxStacks} stacks`;
const outcome = previewOutcomes().find((item: any) => item.descriptionSlot === Number(expression));
const minimum = outcome?.variants?.minimum?.value;
const maximum = outcome?.variants?.maximum?.value;
return outcome && minimum !== '' && maximum !== '' && minimum !== null && maximum !== null && minimum !== undefined && maximum !== undefined
? format(scaled(minimum, maximum), outcome.attributeId)
: placeholder;
});
const scaled = (minimum: number | string, _maximum: number | string) => Number(minimum);
const edit = (item: any) => {
const selectedRevision = item.talent?.selectedRevision ?? null;
const source = item.talent?.authoredSource ?? blankAuthoredSource();
editing = {
...item,
expectedOwnerUpdatedAt: item.updated_at ?? '',
abilityId: String(item.specialization_ability_id),
twoPiece: item.twoPiece ? { ...item.twoPiece } : { stat_attribute_id: '', min_value: '', max_value: '' },
hasTalent: Boolean(item.talent),
talentOpen: false,
talent: item.talent ? structuredClone({ ...item.talent, conditionalRule: item.talent.conditionalRule ?? blankRule() }) : blankTalent()
talent: item.talent ? structuredClone({
...item.talent,
expectedTalentId: String(item.talent.id ?? ''),
authoredSource: source,
modelDraft: selectedRevision?.draft ?? blankRule(),
mechanicSelectionIntent: item.talent.mechanicSelectionIntent ?? (selectedRevision ? 'preserve' : 'clear')
}) : blankTalent()
};
highEndCount = '0';
};
$effect(() => {
if (!openedRequestedItem && data.requestedItemId) {
@@ -78,6 +95,11 @@
if (requested) edit(requested);
openedRequestedItem = true;
}
if ((form?.skillChipArchived || form?.skillChipUnarchived) && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
if (editing?.id === form.skillChipId) editing = null;
return;
}
if (form?.skillChipSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
if (form.skillChipMode === 'add') {
@@ -85,7 +107,6 @@
renderResetToken += 1;
}
editing = form.skillChipMode === 'add' ? blank() : null;
highEndCount = '0';
}
if (form?.skillChipError && editing?.hasTalent) editing.talentOpen = true;
});
@@ -120,8 +141,17 @@
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY CHIP SET' : 'NEW CHIP SET'}</p><h3>{editing.id ? editing.name : 'SKILL CHIP DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="expectedOwnerUpdatedAt" value={editing.expectedOwnerUpdatedAt ?? ''}/>
<input type="hidden" name="hasTalent" value={editing.hasTalent ? '1' : '0'}/>
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)}/>
<input type="hidden" name="expectedTalentId" value={editing.talent.expectedTalentId ?? ''}/>
<input type="hidden" name="expectedSourceFingerprint" value={editing.talent.expectedSourceFingerprint ?? ''}/>
{#if editing.hasTalent}
<input type="hidden" name="authoredSource" value={JSON.stringify(editing.talent.authoredSource)}/>
<input type="hidden" name="mechanicSelectionIntent" value={editing.talent.mechanicSelectionIntent}/>
{#if editing.talent.mechanicSelectionIntent === 'save'}
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.modelDraft)}/>
{/if}
{/if}
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="SKILL CHIP RENDER" resetToken={renderResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b>STANDARD</b><b>SPECIALIZED</b><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Shared profiles define primary and secondary ranges.</small></div></div>
<section class="skill-chip-ability-assignment">
<div class="selection-head"><div><span>OWNING ABILITY</span><small>THREE ACTIVE DEFINITIONS · ABILITY SHARING IS CONFIGURED IN SPECIALIZATIONS</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 OWNED` : 'REQUIRED'}</b></div>
@@ -137,22 +167,36 @@
<div class="selection-head"><div><span>3-PIECE TALENT</span><small>OPTIONAL SET BONUS · QUALITY-SCALED WHEN ASSIGNED</small></div><SegmentedChoice legend="TALENT ASSIGNMENT" name="talent-assignment" options={[{value:'0',label:'NOT ASSIGNED'},{value:'1',label:'ASSIGNED'}]} value={editing.hasTalent ? '1' : '0'} onchange={(values) => { editing.hasTalent = values[0] === '1'; if (editing.hasTalent) editing.talentOpen = true; }}/></div>
{#if editing.hasTalent}
<details class="modeling-disclosure skill-chip-talent-disclosure" bind:open={editing.talentOpen}>
<summary><span><b>{editing.talent.name || 'TALENT WORKSPACE'}</b><small>{previewOutcomes().length} QUALITY-SCALED OUTCOME{previewOutcomes().length === 1 ? '' : 'S'}</small></span><strong>{editing.talentOpen ? 'COLLAPSE' : 'EXPAND'}</strong></summary>
<summary><span><b>{editing.talent.name || 'TALENT WORKSPACE'}</b><small>AUTHORED MINIMUM + MAXIMUM · {editing.talent.mechanicSelectionIntent.toUpperCase()} MODEL</small></span><strong>{editing.talentOpen ? 'COLLAPSE' : 'EXPAND'}</strong></summary>
<div class="modeling-content">
<div class="skill-chip-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} Skill Damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for scaled effect values and units.</small></label></div>
<div class="skill-chip-preview"><SegmentedChoice legend="QUALITY MIX PREVIEW" name="qualityPreview" options={qualitySteps} bind:value={highEndCount}/><div><span>{3 - Number(highEndCount)} NON-HIGH-END · {highEndCount} HIGH-END</span><p>{talentPreview() || 'Talent description preview'}</p></div></div>
<MechanicProgramEditor
attributes={data.stats}
allowAssignedAbility
valueColumns={talentValueColumns}
idPrefix={`skill-chip-talent-${editing.talent.id || 'new'}`}
aiContext={{
sourceKind: 'skill_chip_talent',
sourceName: editing?.talent?.name ?? '',
sourceDescription: editing?.talent?.description_template ?? ''
}}
bind:rule={editing.talent.conditionalRule}
/>
<div class="skill-chip-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label></div>
<AuthoredDescriptionEditor
variantLabels={authoredVariantLabels}
idPrefix={`skill-chip-authored-${editing.talent.id || 'new'}`}
bind:template={editing.talent.authoredSource.template}
bind:variants={editing.talent.authoredSource.variants}
/>
<div class="mechanic-selection">
<SegmentedChoice legend="MECHANIC PROGRAM" name="mechanic-selection" options={mechanicSelectionOptions} value={editing.talent.mechanicSelectionIntent} onchange={(values) => { editing.talent.mechanicSelectionIntent = values[0]; }}/>
{#if editing.talent.mechanicSelectionIntent === 'save' || editing.talent.selectedRevision}
<MechanicProgramEditor
attributes={data.stats}
allowAssignedAbility
valueColumns={talentValueColumns}
idPrefix={`skill-chip-talent-${editing.talent.id || 'new'}`}
disabled={editing.talent.mechanicSelectionIntent !== 'save'}
aiContext={{
sourceKind: 'skill_chip_talent',
sourceName: editing?.talent?.name ?? '',
sourceDescription: editing?.talent?.authoredSource?.template ?? ''
}}
bind:rule={editing.talent.modelDraft}
/>
{/if}
{#if editing.talent.mechanicSelectionIntent !== 'save'}
<p class="console-note">{editing.talent.mechanicSelectionIntent === 'preserve' ? 'The selected model revision is preserved exactly. Choose SAVE MODEL to replace it.' : 'No combat model will be selected. Complete authored prose is sufficient for this Talent.'}</p>
{/if}
</div>
</div>
</details>
{:else}<p class="console-note">This set has no 3-piece Talent. Its 2-piece bonus remains active when the requirement is met.</p>{#if editing.talent.id}<label class="skill-chip-talent-removal"><input type="checkbox" name="confirmTalentRemoval" value="REMOVE" required/> CONFIRM REMOVAL OF THE EXISTING TALENT AND MECHANIC PROGRAM</label>{/if}{/if}
@@ -169,7 +213,7 @@
<div class="dashboard-table">
<div class="dashboard-row skill-chip-row table-heading"><SortableColumnHeader label="SKILL CHIP" column="name" sort={data.sort}/><SortableColumnHeader label="ABILITY" column="ability" sort={data.sort}/><span>QUALITY</span><SortableColumnHeader label="2-PIECE BONUS" column="twoPiece" sort={data.sort}/><SortableColumnHeader label="3-PIECE TALENT" column="talent" sort={data.sort}/><span>ROLL PROFILE</span><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.sets as item}
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>OWNING ABILITY</small></div><span class="skill-chip-quality-pair"><b>STD</b><b>SP</b><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{item.talent ? item.talent.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED' : 'OPTIONAL'}</small></div><span>SHARED · 4 PRIMARY · {data.secondaryPool.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>OWNING ABILITY</small></div><span class="skill-chip-quality-pair"><b>STD</b><b>SP</b><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{item.talent ? mechanicModeled(item.talent) ? 'MODELED · CURRENT REVISION' : item.talent.authoredSource ? 'AUTHORED FALLBACK' : 'CONFIG REQUIRED' : 'OPTIONAL'}</small></div><span>SHARED · 4 PRIMARY · {data.secondaryPool.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
{#if editing?.id === item.id}<RegistryInlineEditor id={`skill-chip-editor-${item.id}`} label={`Edit ${item.name}`}>{@render skillChipEditor()}</RegistryInlineEditor>{/if}
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first ability-linked Skill Chip definition.</span></div>{/each}
</div>
@@ -0,0 +1,834 @@
import { connect, type Database } from '@tursodatabase/database';
import { readFile } from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import type { MechanicAuthoredSource } from '$lib/calculations/mechanic-authored-description';
const mocks = vi.hoisted(() => ({
database: null as Database | null,
applyIconMutation: vi.fn(),
applyGameDataRender: vi.fn(),
auditRecord: vi.fn(),
recordIsacAudit: vi.fn(),
refreshAbilityFocuses: vi.fn(),
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => mocks.database),
}));
vi.mock('$lib/server/audit', () => ({
auditRecord: mocks.auditRecord,
recordIsacAudit: mocks.recordIsacAudit,
}));
vi.mock('$lib/server/game-data-icons', () => ({
applyIconMutation: mocks.applyIconMutation,
gameDataIconMetadata: vi.fn(async () => null),
gameDataIconUrl: vi.fn(() => ''),
GameDataIconValidationError: class extends Error {},
readIconMutation: vi.fn(async () => ({})),
}));
vi.mock('$lib/server/managed-images', () => ({
applyGameDataRender: mocks.applyGameDataRender,
ManagedImageValidationError: class extends Error {},
managedImageUrl: vi.fn(() => ''),
processManagedImage: vi.fn(),
readGameDataRenderMutation: vi.fn(async () => ({})),
}));
vi.mock('$lib/server/specialization-readiness', () => ({
refreshAbilityFocuses: mocks.refreshAbilityFocuses,
refreshSpecializationFocusReady: vi.fn(),
}));
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: vi.fn(async () => ({ id: 'actor-1' })),
}));
vi.mock('$lib/server/auth/authorization', () => ({
requireDataView: vi.fn(async (user) => user),
requirePermission: vi.fn(async (user) => user),
}));
vi.mock('$lib/server/data-moderation', () => ({
getPendingEntities: vi.fn(async () => []),
}));
import { actions, _saveSkillChipInTransaction } from './+page.server';
import { parseSkillChipForm } from './skillchip-save';
import {
loadMechanicAuthoredSource,
MechanicSourceFingerprintConflictError,
} from '$lib/server/mechanic-authored-sources';
import { loadSelectedMechanicModelRevision } from '$lib/server/mechanic-model-revisions';
const source = (value = '10'): MechanicAuthoredSource => ({
version: 1,
owner: { type: 'skill_chip_talent', id: 'talent-1' },
template: 'Gain {1}% Skill Damage.',
variants: [
{
key: 'minimum',
materialized: 'Gain 10% Skill Damage.',
slots: [
{
key: '1',
value: '10',
order: 0,
unit: '%',
attributeId: 'skill-damage',
},
],
},
{
key: 'maximum',
materialized: `Gain ${value}% Skill Damage.`,
slots: [
{ key: '1', value, order: 0, unit: '%', attributeId: 'skill-damage' },
],
},
],
});
const model = (value = 10): ConditionalMechanicDraftV1 => ({
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [],
clauses: [
{
key: 'skill-hit',
label: 'Skill hit',
event: 'skill_hit',
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
key: 'skill-damage',
label: 'Skill Damage',
phase: 'while_active',
kind: 'attribute_modifier',
scope: 'assigned_ability',
basis: 'flat',
attributeId: 'skill-damage',
perStack: false,
variants: {
minimum: { value },
maximum: { value: value + 5 },
},
},
],
},
],
});
const input = (
databaseSource: MechanicAuthoredSource,
options: Partial<Parameters<typeof _saveSkillChipInTransaction>[1]> = {},
): Parameters<typeof _saveSkillChipInTransaction>[1] => ({
id: '',
proposedEntityId: 'set-1',
name: 'Pulse Matrix',
abilityId: 'ability-1',
twoPieceStatId: 'skill-duration',
twoPieceMin: 5,
twoPieceMax: 10,
hasTalent: true,
talentName: 'Signal Cascade',
talentId: 'talent-1',
expectedTalentId: '',
authoredSource: databaseSource,
expectedSourceFingerprint: null,
selection: { intent: 'clear' },
confirmTalentRemoval: false,
iconMutation: { output: null, remove: false },
renderMutation: { file: null, remove: false },
actorId: 'actor-1',
resolveAttributeLabel: (attributeId) =>
attributeId === 'skill-damage' ? 'Skill Damage' : null,
...options,
});
const removeTalentRequest = (
expectedSourceFingerprint: string,
expectedTalentId: string,
expectedOwnerUpdatedAt: string,
) => {
const form = new FormData();
form.set('id', 'set-1');
form.set('name', 'Pulse Matrix');
form.set('abilityId', 'ability-1');
form.set('twoPieceStatId', 'skill-duration');
form.set('twoPieceMin', '5');
form.set('twoPieceMax', '10');
form.set('hasTalent', '0');
form.set('expectedOwnerUpdatedAt', expectedOwnerUpdatedAt);
form.set('expectedTalentId', expectedTalentId);
form.set('expectedSourceFingerprint', expectedSourceFingerprint);
form.set('confirmTalentRemoval', 'REMOVE');
return new Request('http://localhost/isac/skillchips?/save', {
method: 'POST',
body: form,
});
};
const talentSaveRequest = (
expectedTalentId: string,
expectedOwnerUpdatedAt: string,
) => {
const form = new FormData();
form.set('id', 'set-1');
form.set('name', 'Pulse Matrix');
form.set('abilityId', 'ability-1');
form.set('twoPieceStatId', 'skill-duration');
form.set('twoPieceMin', '5');
form.set('twoPieceMax', '10');
form.set('hasTalent', '1');
form.set('expectedOwnerUpdatedAt', expectedOwnerUpdatedAt);
form.set('expectedTalentId', expectedTalentId);
form.set('talentName', 'Signal Cascade');
form.set('authoredSource', JSON.stringify(source()));
form.set('expectedSourceFingerprint', '');
form.set('mechanicSelectionIntent', 'clear');
return new Request('http://localhost/isac/skillchips?/save', {
method: 'POST',
body: form,
});
};
async function applySchema(database: Database) {
const schema = await readFile(
new URL('../../../../database/schema.sql', import.meta.url),
'utf8',
);
for (const statement of schema
.split(';')
.map((value) => value.trim())
.filter(Boolean)) {
await database.exec(statement);
}
}
const count = async (table: string) =>
Number(
(
await (
await mocks.database!.prepare(`SELECT COUNT(*) AS count FROM ${table}`)
).get()
)?.count ?? 0,
);
const ownerUpdatedAt = async (database: Database, id = 'set-1') =>
String(
(
await (await database.prepare('SELECT updated_at FROM skill_chip_sets WHERE id=?')).get(id)
)?.updated_at ?? '',
);
describe('Skill Chip Phase 2 owner persistence', () => {
let database: Database;
beforeEach(async () => {
vi.clearAllMocks();
database = await connect(':memory:');
mocks.database = database;
await database.exec('PRAGMA foreign_keys=ON');
await applySchema(database);
await database.exec(`INSERT INTO users(id,username,role,created_at)
VALUES('actor-1','Operator','admin','now')`);
await database.exec(`INSERT INTO specializations(id,name,created_at,updated_at)
VALUES('specialization-1','Tactical','now','now')`);
await database.exec(`INSERT INTO specialization_abilities(id,specialization_id,name,description,created_at,updated_at)
VALUES('ability-1','specialization-1','Pulse','Pulse description','now','now'),
('ability-2','specialization-1','Pulse Alt','Pulse alt description','now','now')`);
await database.exec(`INSERT INTO stat_attributes(id,name,type,category,calculation_type,unit,created_at,updated_at)
VALUES('skill-duration','Skill Duration','Offensive','Skill','Additive','s','now','now'),
('skill-damage','Skill Damage','Offensive','Skill','Additive','%','now','now')`);
mocks.auditRecord.mockImplementation(
async (_table: string, id: string, supplied: Database) =>
(await (
await supplied.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get(id)) ?? null,
);
mocks.recordIsacAudit.mockResolvedValue(undefined);
});
afterEach(async () => {
await database.close();
mocks.database = null;
});
it('parses an absent expected Talent identity as blank in the direct parser', async () => {
const form = new FormData();
form.set('name', 'Direct Pulse Matrix');
form.set('abilityId', 'ability-1');
form.set('twoPieceStatId', 'skill-duration');
form.set('twoPieceMin', '5');
form.set('twoPieceMax', '10');
form.set('hasTalent', '0');
await expect(parseSkillChipForm(database, form, 'actor-1')).resolves.toMatchObject({
expectedTalentId: '',
});
});
it('archives and unarchives a Skill Chip with lifecycle result tokens', async () => {
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(
database,
input(source(), { hasTalent: false, authoredSource: null }),
);
await database.exec('COMMIT');
const lifecycleRequest = (action: 'archive' | 'unarchive') => {
const form = new FormData();
form.set('id', 'set-1');
return new Request(`http://localhost/isac/skillchips?/${action}`, {
method: 'POST',
body: form,
});
};
const archived = await actions.archive?.({
cookies: {} as never,
request: lifecycleRequest('archive'),
} as never);
expect(archived).toMatchObject({
skillChipArchived: true,
skillChipId: 'set-1',
resultToken: expect.any(String),
});
expect(
await (
await database.prepare('SELECT active FROM skill_chip_sets WHERE id=?')
).get('set-1'),
).toEqual({ active: 0 });
const unarchived = await actions.unarchive?.({
cookies: {} as never,
request: lifecycleRequest('unarchive'),
} as never);
expect(unarchived).toMatchObject({
skillChipUnarchived: true,
skillChipId: 'set-1',
resultToken: expect.any(String),
});
expect(
await (
await database.prepare('SELECT active FROM skill_chip_sets WHERE id=?')
).get('set-1'),
).toEqual({ active: 1 });
});
it('accepts a prose-only action, returns a result token, and persists the canonical fields', async () => {
const payload = source();
delete (payload as unknown as { owner?: unknown }).owner;
const form = new FormData();
form.set('name', 'Action Pulse Matrix');
form.set('abilityId', 'ability-1');
form.set('twoPieceStatId', 'skill-duration');
form.set('twoPieceMin', '5');
form.set('twoPieceMax', '10');
form.set('hasTalent', '1');
form.set('expectedTalentId', '');
form.set('talentName', 'Action Signal Cascade');
form.set('authoredSource', JSON.stringify(payload));
form.set('expectedSourceFingerprint', '');
form.set('mechanicSelectionIntent', 'clear');
const result = await actions.save?.({
cookies: {} as never,
request: new Request('http://localhost/isac/skillchips?/save', {
method: 'POST',
body: form,
}),
} as never);
expect(result).toMatchObject({
skillChipSaved: true,
skillChipMode: 'add',
resultToken: expect.any(String),
});
expect(await count('mechanic_model_revisions')).toBe(0);
expect(await count('conditional_effect_rules')).toBe(0);
});
it('saves prose-only authored source without legacy mechanic writes', async () => {
await database.exec('BEGIN IMMEDIATE');
const result = await _saveSkillChipInTransaction(database, input(source()));
await database.exec('COMMIT');
expect(result).toEqual({ setId: 'set-1', mode: 'add' });
expect(
await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
'talent-1',
),
).toMatchObject({
source: source(),
});
expect(await count('mechanic_model_revisions')).toBe(0);
expect(await count('conditional_effect_rules')).toBe(0);
expect(await count('skill_chip_talent_effects')).toBe(0);
expect(
(
await (
await database.prepare(
'SELECT description_template FROM skill_chip_talents',
)
).get()
)?.description_template,
).toBe('');
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: 'skill_chip.created',
entityId: 'set-1',
before: null,
after: expect.objectContaining({
mechanic: expect.objectContaining({
authoredSource: expect.objectContaining({
owner: { type: 'skill_chip_talent', id: 'talent-1' },
}),
selectedRevision: null,
publicProjection: expect.objectContaining({
variants: expect.any(Array),
}),
}),
}),
}),
);
});
it('rejects a canonical authored-source owner mismatch before writing', async () => {
await database.exec('BEGIN IMMEDIATE');
await expect(
_saveSkillChipInTransaction(
database,
input(source(), {
authoredSource: {
...source(),
owner: { type: 'skill_chip_talent', id: 'client-supplied' },
},
}),
),
).rejects.toThrow(/owner does not match/);
await database.exec('ROLLBACK');
expect(await count('skill_chip_sets')).toBe(0);
expect(await count('mechanic_authored_sources')).toBe(0);
});
it('rechecks the active three-chip limit inside the write transaction', async () => {
await database.exec(`INSERT INTO skill_chip_sets
(id,name,specialization_ability_id,created_at,updated_at)
VALUES ('set-a','Set A','ability-1','now','now'),
('set-b','Set B','ability-1','now','now'),
('set-c','Set C','ability-1','now','now')`);
await database.exec('BEGIN IMMEDIATE');
await expect(
_saveSkillChipInTransaction(
database,
input(source(), {
hasTalent: false,
authoredSource: null,
talentName: '',
selection: { intent: 'clear' },
}),
),
).rejects.toThrow(/all three active Skill Chips/);
await database.exec('ROLLBACK');
expect(await count('skill_chip_sets')).toBe(3);
});
it('appends a revision, skips an identical retry, and clears selection without deleting history', async () => {
const first = input(source(), {
authoredSource: source(),
selection: { intent: 'save', draft: model() },
});
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, first);
await database.exec('COMMIT');
const savedSource = await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
'talent-1',
);
expect(savedSource).not.toBeNull();
const firstEditOwnerUpdatedAt = await ownerUpdatedAt(database);
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, {
...first,
id: 'set-1',
expectedTalentId: 'talent-1',
expectedOwnerUpdatedAt: firstEditOwnerUpdatedAt,
expectedSourceFingerprint: savedSource!.fingerprint,
});
await database.exec('COMMIT');
expect(await count('mechanic_model_revisions')).toBe(1);
expect(
await loadSelectedMechanicModelRevision(database, savedSource!.id),
).toMatchObject({ revisionNumber: 1 });
const secondEditOwnerUpdatedAt = await ownerUpdatedAt(database);
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, {
...first,
id: 'set-1',
expectedTalentId: 'talent-1',
expectedOwnerUpdatedAt: secondEditOwnerUpdatedAt,
expectedSourceFingerprint: savedSource!.fingerprint,
selection: { intent: 'clear' },
});
await database.exec('COMMIT');
expect(
await loadSelectedMechanicModelRevision(database, savedSource!.id),
).toBeNull();
expect(await count('mechanic_model_revisions')).toBe(1);
});
it('rejects a stale aggregate after scalar, relationship, and selected revision changes without writing', async () => {
const first = input(source(), {
selection: { intent: 'save', draft: model() },
});
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, first);
await database.exec('COMMIT');
const savedSource = await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
'talent-1',
);
expect(savedSource).not.toBeNull();
const staleOwnerUpdatedAt = await ownerUpdatedAt(database);
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, {
...first,
id: 'set-1',
name: 'Concurrent Pulse Matrix',
abilityId: 'ability-2',
twoPieceMin: 6,
twoPieceMax: 12,
expectedTalentId: 'talent-1',
expectedOwnerUpdatedAt: staleOwnerUpdatedAt,
expectedSourceFingerprint: savedSource!.fingerprint,
selection: { intent: 'save', draft: model(20) },
});
await database.exec('COMMIT');
await (
await database.prepare('UPDATE skill_chip_sets SET updated_at=? WHERE id=?')
).run('2026-08-02T12:00:01.000Z', 'set-1');
const ownerBeforeStale = await (
await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get('set-1');
const relationshipBeforeStale = await (
await database.prepare(
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
)
).get('set-1');
const selectedRevisionBeforeStale = await loadSelectedMechanicModelRevision(
database,
savedSource!.id,
);
expect(ownerBeforeStale).toMatchObject({
name: 'Concurrent Pulse Matrix',
specialization_ability_id: 'ability-2',
updated_at: '2026-08-02T12:00:01.000Z',
});
expect(relationshipBeforeStale).toMatchObject({
min_value: 6,
max_value: 12,
});
expect(selectedRevisionBeforeStale).toMatchObject({
revisionNumber: 2,
draft: model(20),
});
const revisionCountBeforeStale = await count('mechanic_model_revisions');
const auditCountBeforeStale = mocks.recordIsacAudit.mock.calls.length;
const iconCallsBeforeStale = mocks.applyIconMutation.mock.calls.length;
const renderCallsBeforeStale = mocks.applyGameDataRender.mock.calls.length;
await database.exec('BEGIN IMMEDIATE');
await expect(
_saveSkillChipInTransaction(database, {
...first,
id: 'set-1',
name: 'Stale Pulse Matrix',
abilityId: 'ability-1',
twoPieceMin: 7,
twoPieceMax: 14,
expectedTalentId: 'talent-1',
expectedOwnerUpdatedAt: staleOwnerUpdatedAt,
expectedSourceFingerprint: savedSource!.fingerprint,
selection: { intent: 'save', draft: model(30) },
}),
).rejects.toMatchObject({
status: 409,
message: expect.stringMatching(/changed after this editor was loaded/),
});
await database.exec('ROLLBACK');
expect(
await (
await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get('set-1'),
).toEqual(ownerBeforeStale);
expect(
await (
await database.prepare(
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
)
).get('set-1'),
).toEqual(relationshipBeforeStale);
expect(
await loadSelectedMechanicModelRevision(database, savedSource!.id),
).toEqual(selectedRevisionBeforeStale);
expect(await count('mechanic_model_revisions')).toBe(revisionCountBeforeStale);
expect(mocks.recordIsacAudit).toHaveBeenCalledTimes(auditCountBeforeStale);
expect(mocks.applyIconMutation).toHaveBeenCalledTimes(iconCallsBeforeStale);
expect(mocks.applyGameDataRender).toHaveBeenCalledTimes(renderCallsBeforeStale);
});
it('rejects a stale checked Talent after it was removed without writing', async () => {
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, input(source()));
await database.exec('COMMIT');
const expectedOwnerUpdatedAt = await ownerUpdatedAt(database);
const talentBefore = await (
await database.prepare('SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?')
).get('set-1');
await (
await database.prepare('DELETE FROM skill_chip_talents WHERE skill_chip_set_id=?')
).run('set-1');
const setBefore = await (
await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get('set-1');
const auditCountBefore = mocks.recordIsacAudit.mock.calls.length;
const iconCallsBefore = mocks.applyIconMutation.mock.calls.length;
const renderCallsBefore = mocks.applyGameDataRender.mock.calls.length;
const stale = await actions.save?.({
cookies: {} as never,
request: talentSaveRequest(
String(talentBefore?.id),
expectedOwnerUpdatedAt,
),
} as never);
expect(stale).toMatchObject({ status: 409 });
expect(
await (
await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get('set-1'),
).toEqual(setBefore);
expect(
await (
await database.prepare('SELECT COUNT(*) AS count FROM skill_chip_talents')
).get(),
).toEqual({ count: 0 });
expect(mocks.recordIsacAudit).toHaveBeenCalledTimes(auditCountBefore);
expect(mocks.applyIconMutation).toHaveBeenCalledTimes(iconCallsBefore);
expect(mocks.applyGameDataRender).toHaveBeenCalledTimes(renderCallsBefore);
});
it('rejects a stale blank Talent after another Talent was added without writing', async () => {
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(
database,
input(source(), { hasTalent: false, authoredSource: null }),
);
await database.exec('COMMIT');
const expectedOwnerUpdatedAt = await ownerUpdatedAt(database);
await database.exec(`INSERT INTO skill_chip_talents
(id,skill_chip_set_id,name,description_template,created_at,updated_at)
VALUES('late-talent','set-1','Late Talent','','now','now')`);
const setBefore = await (
await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get('set-1');
const talentBefore = await (
await database.prepare('SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?')
).get('set-1');
const auditCountBefore = mocks.recordIsacAudit.mock.calls.length;
const stale = await actions.save?.({
cookies: {} as never,
request: removeTalentRequest('', '', expectedOwnerUpdatedAt),
} as never);
expect(stale).toMatchObject({ status: 409 });
expect(
await (
await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
).get('set-1'),
).toEqual(setBefore);
expect(
await (
await database.prepare('SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?')
).get('set-1'),
).toEqual(talentBefore);
expect(mocks.recordIsacAudit).toHaveBeenCalledTimes(auditCountBefore);
expect(await count('game_data_icons')).toBe(0);
expect(await count('managed_images')).toBe(0);
});
it('rejects a direct save when the expected Talent identity is stale before writes', async () => {
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, input(source()));
await database.exec('COMMIT');
const expectedOwnerUpdatedAt = await ownerUpdatedAt(database);
await (
await database.prepare('DELETE FROM skill_chip_talents WHERE skill_chip_set_id=?')
).run('set-1');
await database.exec('BEGIN IMMEDIATE');
await expect(
_saveSkillChipInTransaction(
database,
input(source(), {
id: 'set-1',
expectedTalentId: 'talent-1',
expectedOwnerUpdatedAt,
}),
),
).rejects.toThrow(/Talent changed/);
await database.exec('ROLLBACK');
expect(
await (
await database.prepare('SELECT COUNT(*) AS count FROM skill_chip_talents')
).get(),
).toEqual({ count: 0 });
});
it('rejects stale Talent removal before changing identity, history, audit, or media', async () => {
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, input(source()));
await database.exec('COMMIT');
const savedSource = await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
'talent-1',
);
const expectedOwnerUpdatedAt = await ownerUpdatedAt(database);
const talentBefore = await (
await database.prepare(
'SELECT id,skill_chip_set_id,name FROM skill_chip_talents',
)
).get();
const stale = await actions.save?.({
cookies: {} as never,
request: removeTalentRequest(
'f'.repeat(64),
'talent-1',
expectedOwnerUpdatedAt,
),
} as never);
expect(stale).toMatchObject({ status: 409 });
expect(
await (
await database.prepare(
'SELECT id,skill_chip_set_id,name FROM skill_chip_talents',
)
).get(),
).toEqual(talentBefore);
expect(
await (
await database.prepare(
'SELECT source_fingerprint FROM mechanic_authored_sources WHERE owner_id=?',
)
).get('talent-1'),
).toEqual({ source_fingerprint: savedSource?.fingerprint });
expect(mocks.recordIsacAudit).toHaveBeenCalledOnce();
expect(await count('game_data_icons')).toBe(0);
expect(await count('managed_images')).toBe(0);
});
it('removes a current Talent while preserving authored and revision history', async () => {
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(
database,
input(source(), { selection: { intent: 'save', draft: model() } }),
);
await database.exec('COMMIT');
const savedSource = await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
'talent-1',
);
const expectedOwnerUpdatedAt = await ownerUpdatedAt(database);
const result = await actions.save?.({
cookies: {} as never,
request: removeTalentRequest(
String(savedSource?.fingerprint),
'talent-1',
expectedOwnerUpdatedAt,
),
} as never);
expect(result).toMatchObject({
skillChipSaved: true,
skillChipMode: 'edit',
});
expect(await count('skill_chip_talents')).toBe(0);
expect(await count('mechanic_authored_sources')).toBe(1);
expect(await count('mechanic_model_revisions')).toBe(1);
});
it('refreshes both abilities and rolls back a stale source or audit failure', async () => {
const first = input(source());
await database.exec('BEGIN IMMEDIATE');
await _saveSkillChipInTransaction(database, first);
await database.exec('COMMIT');
const savedSource = await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
'talent-1',
);
expect(savedSource).not.toBeNull();
const staleSourceOwnerUpdatedAt = await ownerUpdatedAt(database);
await database.exec('BEGIN IMMEDIATE');
await expect(
_saveSkillChipInTransaction(database, {
...first,
id: 'set-1',
abilityId: 'ability-2',
expectedTalentId: 'talent-1',
expectedOwnerUpdatedAt: staleSourceOwnerUpdatedAt,
expectedSourceFingerprint: 'f'.repeat(64),
selection: { intent: 'preserve' },
}),
).rejects.toBeInstanceOf(MechanicSourceFingerprintConflictError);
await database.exec('ROLLBACK');
expect(await count('mechanic_model_revisions')).toBe(0);
mocks.recordIsacAudit.mockRejectedValueOnce(new Error('audit failed'));
const auditFailureOwnerUpdatedAt = await ownerUpdatedAt(database);
await database.exec('BEGIN IMMEDIATE');
await expect(
_saveSkillChipInTransaction(database, {
...first,
id: 'set-1',
abilityId: 'ability-2',
expectedTalentId: 'talent-1',
expectedOwnerUpdatedAt: auditFailureOwnerUpdatedAt,
expectedSourceFingerprint: savedSource!.fingerprint,
}),
).rejects.toThrow('audit failed');
await database.exec('ROLLBACK');
expect(mocks.refreshAbilityFocuses).toHaveBeenCalledWith(
database,
'ability-1',
);
expect(mocks.refreshAbilityFocuses).toHaveBeenCalledWith(
database,
'ability-2',
);
expect(
(
await (
await database.prepare(
'SELECT specialization_ability_id FROM skill_chip_sets WHERE id=?',
)
).get('set-1')
)?.specialization_ability_id,
).toBe('ability-1');
});
});
@@ -0,0 +1,545 @@
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { getDatabase } from '$lib/server/database';
import type { Database } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
import {
applyIconMutation,
gameDataIconMetadata,
GameDataIconValidationError,
readIconMutation,
} from '$lib/server/game-data-icons';
import {
applyGameDataRender,
readGameDataRenderMutation,
ManagedImageValidationError,
processManagedImage,
} from '$lib/server/managed-images';
import {
refreshAbilityFocuses,
refreshSpecializationFocusReady,
} from '$lib/server/specialization-readiness';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
import {
loadMechanicAuthoredSource,
MechanicSourceFingerprintConflictError,
mechanicAuthoredSourceSnapshot,
} from '$lib/server/mechanic-authored-sources';
import {
loadMechanicPublicProjection,
mechanicPublicProjectionSnapshot,
} from '$lib/server/mechanic-public-projection';
import {
loadSelectedMechanicModelRevision,
mechanicModelRevisionSnapshot,
} from '$lib/server/mechanic-model-revisions';
import {
parseExpectedOwnerUpdatedAt,
parseExpectedSourceFingerprint,
parseMechanicAuthoredSourcePayload,
parseMechanicSelectionIntent,
persistMechanicOwnerStateInTransaction,
type MechanicOwnerSelection,
} from '$lib/server/mechanic-owner-mutation';
import type { MechanicAuthoredSource } from '$lib/calculations/mechanic-authored-description';
import type { MediaMutationJournal } from '$lib/server/media-journal';
export class SkillChipFormValidationError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = 'SkillChipFormValidationError';
this.status = status;
}
}
export class SkillChipTalentIdentityConflictError extends SkillChipFormValidationError {
constructor(expectedTalentId: string, currentTalentId: string) {
super(
409,
`The Skill Chip Talent changed after this editor was loaded (expected ${expectedTalentId || 'none'}, found ${currentTalentId || 'none'}). Reload the latest definition and try again.`,
);
this.name = 'SkillChipTalentIdentityConflictError';
}
}
function parseExpectedTalentId(value: FormDataEntryValue | null): string {
if (value === null) return '';
if (
typeof value !== 'string' ||
value !== value.trim() ||
value.length > 200 ||
/[\u0000-\u001f\u007f-\u009f]/u.test(value)
)
throw new SkillChipFormValidationError(
400,
'The expected Skill Chip Talent identity is invalid.',
);
return value;
}
/** Parses the canonical Skill Chip save form for direct saves and replay. */
export async function parseSkillChipForm(
database: Database,
form: FormData,
actorId: string,
mutationContext?: SkillChipMutationContext,
): Promise<SkillChipSaveInput> {
const id = String(form.get('id') ?? '').trim();
let expectedOwnerUpdatedAt: string | null;
try {
expectedOwnerUpdatedAt = parseExpectedOwnerUpdatedAt(
form.get('expectedOwnerUpdatedAt'),
Boolean(id),
);
} catch (error) {
throw new SkillChipFormValidationError(
400,
error instanceof Error ? error.message : 'The expected owner version is invalid.',
);
}
const existingSet = id
? await (
await database.prepare('SELECT updated_at FROM skill_chip_sets WHERE id=?')
).get(id)
: null;
if (id && !existingSet)
throw new SkillChipFormValidationError(404, 'Skill Chip set not found.');
if (id && expectedOwnerUpdatedAt !== String(existingSet?.updated_at ?? ''))
throw new SkillChipFormValidationError(
409,
'The Skill Chip set changed after this editor was loaded. Reload the latest definition and try again.',
);
const expectedTalentId = parseExpectedTalentId(form.get('expectedTalentId'));
const existingTalent = id ? await loadTalent(database, id) : null;
const currentTalentId = String(existingTalent?.id ?? '');
// The child identity is the optimistic concurrency token for this owner.
if (expectedTalentId !== currentTalentId)
throw new SkillChipTalentIdentityConflictError(
expectedTalentId,
currentTalentId,
);
let iconMutation;
try {
iconMutation = await readIconMutation(form);
} catch (error) {
if (error instanceof GameDataIconValidationError)
throw new SkillChipFormValidationError(400, 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)
throw new SkillChipFormValidationError(400, error.message);
throw error;
}
const proposedEntityId = String(form.get('_proposedEntityId') ?? '').trim();
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const abilityId = String(form.get('abilityId') ?? '').trim();
const twoPieceStatId = String(form.get('twoPieceStatId') ?? '').trim();
const twoPieceMin = Number(form.get('twoPieceMin'));
const twoPieceMax = Number(form.get('twoPieceMax'));
const hasTalent = form.get('hasTalent') === '1';
const talentName = String(form.get('talentName') ?? '').normalize('NFKC').trim();
const talentId = String(existingTalent?.id ?? randomUUID());
let expectedSourceFingerprint: string | null = null;
if (hasTalent || existingTalent)
try {
expectedSourceFingerprint = parseExpectedSourceFingerprint(
form.get('expectedSourceFingerprint'),
);
} catch (error) {
throw new SkillChipFormValidationError(
400,
error instanceof Error
? error.message
: 'The expected authored-source fingerprint is invalid.',
);
}
const invalid = (status: number, message: string): never => {
throw new SkillChipFormValidationError(status, message);
};
if (name.length < 2 || name.length > 80)
invalid(400, 'Skill Chip names must be 2–80 characters.');
if (!abilityId)
invalid(400, 'Assign this Skill Chip to a specialization ability.');
if (!twoPieceStatId || !Number.isFinite(twoPieceMin) || !Number.isFinite(twoPieceMax) || twoPieceMax < twoPieceMin)
invalid(400, 'Configure a valid 2-piece stat range.');
if (existingTalent && !hasTalent && form.get('confirmTalentRemoval') !== 'REMOVE')
invalid(400, 'Confirm removal of the existing 3-piece Talent and mechanic program.');
let authoredSource: MechanicAuthoredSource | null = null;
let selection: MechanicOwnerSelection = { intent: 'clear' };
let activeStats: Array<Record<string, unknown>> = [];
if (hasTalent) {
if (talentName.length < 2 || talentName.length > 80)
invalid(400, 'Talent names must be 2–80 characters.');
activeStats = await (await database.prepare('SELECT id,name FROM stat_attributes WHERE active=1')).all();
try {
authoredSource = parseMechanicAuthoredSourcePayload(
form.get('authoredSource'),
{ type: 'skill_chip_talent', id: talentId },
);
selection = parseMechanicSelectionIntent(
form.get('mechanicSelectionIntent'),
form.get('conditionalRule'),
{
variantKeys: ['minimum', 'maximum'],
allowedAttributeIds: new Set(activeStats.map((row) => String(row.id))),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
programName: talentName,
ownerType: 'skill_chip_talent',
},
);
const authoredAttributeIds = [
...new Set(authoredSource.variants.flatMap((variant) =>
variant.slots.map((slot) => slot.attributeId).filter((value): value is string => Boolean(value)),
)),
];
if (authoredAttributeIds.some((attributeId) => !activeStats.some((row) => String(row.id) === attributeId)))
throw new Error('One or more authored Attributes are unavailable.');
} catch (error) {
invalid(400, error instanceof Error ? error.message : 'The authored Talent source is invalid.');
}
}
if (!(await (await database.prepare('SELECT id FROM specialization_abilities WHERE id=? AND active=1')).get(abilityId)))
invalid(400, 'Choose an active specialization ability.');
const assignedCount = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets
WHERE specialization_ability_id=? AND active=1 AND id<>?`)).get(abilityId, id || 'new'))?.count ?? 0);
if (assignedCount >= 3)
invalid(409, 'That ability already has all three active Skill Chips assigned.');
if (await (await database.prepare('SELECT id FROM skill_chip_sets WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new'))
invalid(409, 'That Skill Chip set already exists.');
if (hasTalent && await (await database.prepare('SELECT id FROM skill_chip_talents WHERE name=? COLLATE NOCASE AND id<>?')).get(talentName, talentId))
invalid(409, 'That Skill Chip talent name is already in use.');
const validStats = await (await database.prepare('SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id=?')).get(twoPieceStatId);
if (Number(validStats?.count) !== 1)
invalid(400, 'One or more selected stats are unavailable.');
return {
id,
proposedEntityId,
name,
abilityId,
twoPieceStatId,
twoPieceMin,
twoPieceMax,
hasTalent,
talentName,
talentId,
expectedTalentId,
expectedOwnerUpdatedAt,
authoredSource,
expectedSourceFingerprint,
selection,
confirmTalentRemoval: form.get('confirmTalentRemoval') === 'REMOVE',
iconMutation,
renderMutation,
actorId,
mutationContext,
resolveAttributeLabel: (attributeId) =>
activeStats.find((row) => String(row.id) === attributeId)?.name as string | undefined,
};
}
const renderAuditMetadata = (
render: Record<string, unknown> | 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;
export async function refreshAllFocusReadiness(
database: Awaited<ReturnType<typeof getDatabase>>,
) {
const focuses = await (
await database.prepare('SELECT id FROM specialization_focuses')
).all();
for (const focus of focuses)
await refreshSpecializationFocusReady(database, String(focus.id));
}
export async function loadTalent(database: Database, setId: string) {
return (
(await (
await database.prepare(
`SELECT id,skill_chip_set_id,name,created_at,updated_at
FROM skill_chip_talents WHERE skill_chip_set_id=?`,
)
).get(setId)) ?? null
);
}
async function mechanicStateSnapshot(
database: Database,
talentId: string | null,
) {
if (!talentId) return null;
const authored = await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
talentId,
);
if (!authored) return null;
const [selectedRevision, projection] = await Promise.all([
loadSelectedMechanicModelRevision(database, authored.id),
loadMechanicPublicProjection(database, authored.id),
]);
return {
authoredSource: mechanicAuthoredSourceSnapshot(authored),
selectedRevision: mechanicModelRevisionSnapshot(selectedRevision),
publicProjection: mechanicPublicProjectionSnapshot(
selectedRevision,
projection,
),
};
}
export async function snapshot(database: Database, id: string) {
const talent = await loadTalent(database, id);
const mechanic = await mechanicStateSnapshot(
database,
talent ? String(talent.id) : null,
);
return {
record: await auditRecord('skill_chip_sets', id, database),
icon: await gameDataIconMetadata(database, 'skill_chip', id),
render: renderAuditMetadata(
await (
await database.prepare(
"SELECT * FROM managed_images WHERE source_entity_type='skill_chip_render' AND source_entity_id=?",
)
).get(id),
),
twoPiece:
(await (
await database.prepare(
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
)
).get(id)) ?? null,
talent: talent ?? null,
mechanic,
};
}
export type SkillChipMutationContext = {
origin?: 'isac' | 'proposal';
proposalId?: string;
};
export type SkillChipSaveInput = {
id: string;
proposedEntityId: string;
name: string;
abilityId: string;
twoPieceStatId: string;
twoPieceMin: number;
twoPieceMax: number;
hasTalent: boolean;
talentName: string;
talentId: string;
expectedTalentId: string;
expectedOwnerUpdatedAt?: string | null;
authoredSource: MechanicAuthoredSource | null;
expectedSourceFingerprint: string | null;
selection: MechanicOwnerSelection;
confirmTalentRemoval: boolean;
iconMutation: Awaited<ReturnType<typeof readIconMutation>>;
renderMutation: Awaited<ReturnType<typeof readGameDataRenderMutation>>;
actorId: string;
mutationContext?: SkillChipMutationContext;
journal?: MediaMutationJournal;
resolveAttributeLabel?: (attributeId: string) => string | null | undefined;
};
export class SkillChipNotFoundError extends Error {
constructor() {
super('Skill Chip set not found.');
this.name = 'SkillChipNotFoundError';
}
}
/**
* Persists a Skill Chip owner and all of its dependent state in the caller's
* active transaction. Keeping the transaction outside this function lets Data
* Oversight replay the exact same canonical save path.
*/
export async function saveSkillChipInTransaction(
database: Database,
input: SkillChipSaveInput,
): Promise<{ setId: string; mode: 'add' | 'edit' }> {
const setId = input.id || input.proposedEntityId || randomUUID();
const before = input.id ? await snapshot(database, input.id) : null;
if (input.id && !before?.record) throw new SkillChipNotFoundError();
if (
input.id &&
input.expectedOwnerUpdatedAt !== String(before?.record?.updated_at ?? '')
)
throw new SkillChipFormValidationError(
409,
'The Skill Chip set changed after this editor was loaded. Reload the latest definition and try again.',
);
const previousAbilityId = String(
before?.record?.specialization_ability_id ?? '',
);
const existingTalent = input.id ? await loadTalent(database, input.id) : null;
const currentTalentId = String(existingTalent?.id ?? '');
// Recheck inside the write transaction before any owner or media writes.
if (input.expectedTalentId !== currentTalentId)
throw new SkillChipTalentIdentityConflictError(
input.expectedTalentId,
currentTalentId,
);
const talentId = String(existingTalent?.id ?? input.talentId);
if (input.hasTalent && !input.authoredSource)
throw new Error('The authored description is required for a Talent.');
if (
input.authoredSource &&
(input.authoredSource.owner.type !== 'skill_chip_talent' ||
input.authoredSource.owner.id !== talentId)
) {
throw new Error('The authored mechanic owner does not match the Skill Chip Talent.');
}
if (existingTalent && !input.hasTalent && !input.confirmTalentRemoval)
throw new Error(
'Confirm removal of the existing 3-piece Talent and mechanic program.',
);
if (existingTalent && !input.hasTalent) {
const currentSource = await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
talentId,
);
if (input.expectedSourceFingerprint !== (currentSource?.fingerprint ?? null))
throw new MechanicSourceFingerprintConflictError(
input.expectedSourceFingerprint,
currentSource?.fingerprint ?? null,
);
}
// The parser checks this for immediate form feedback, but this query on the
// write transaction is authoritative for canonical callers and retries.
const assignedCount = Number(
(
await (
await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets
WHERE specialization_ability_id=? AND active=1 AND id<>?`)
).get(input.abilityId, setId)
)?.count ?? 0,
);
if (assignedCount >= 3)
throw new Error('That ability already has all three active Skill Chips assigned.');
const now = new Date().toISOString();
if (input.id) {
await (
await database.prepare(
'UPDATE skill_chip_sets SET name=?,specialization_ability_id=?,active=1,updated_at=? WHERE id=?',
)
).run(input.name, input.abilityId, now, input.id);
} else {
await (
await database.prepare(
'INSERT INTO skill_chip_sets(id,name,specialization_ability_id,created_at,updated_at) VALUES(?,?,?,?,?)',
)
).run(setId, input.name, input.abilityId, now, now);
}
await (
await database.prepare(`INSERT INTO skill_chip_two_piece_bonuses(id,skill_chip_set_id,stat_attribute_id,min_value,max_value) VALUES(?,?,?,?,?)
ON CONFLICT(skill_chip_set_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,min_value=excluded.min_value,max_value=excluded.max_value`)
).run(
randomUUID(),
setId,
input.twoPieceStatId,
input.twoPieceMin,
input.twoPieceMax,
);
if (input.hasTalent && input.authoredSource) {
await (
await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,'',?,?)
ON CONFLICT(skill_chip_set_id) DO UPDATE SET name=excluded.name,updated_at=excluded.updated_at`)
).run(talentId, setId, input.talentName, now, now);
await persistMechanicOwnerStateInTransaction(database, {
source: input.authoredSource,
expectedSourceFingerprint: input.expectedSourceFingerprint,
selection: input.selection,
programName: input.talentName,
origin: input.mutationContext?.origin ?? 'isac',
actorId: input.actorId,
resolveAttributeLabel: input.resolveAttributeLabel,
});
} else if (existingTalent) {
// Preserve the authored source, selection, projection, and immutable
// revisions as append-only history. Public readers start from the Talent
// identity row, so the detached mechanic cannot leak after this deletion.
await (
await database.prepare('DELETE FROM skill_chip_talents WHERE id=?')
).run(String(existingTalent.id));
}
await applyIconMutation(
database,
'skill_chip',
setId,
input.iconMutation,
input.actorId,
input.journal,
);
await applyGameDataRender(
database,
'skill_chip',
setId,
input.name,
input.renderMutation,
input.actorId,
input.journal,
);
await refreshAbilityFocuses(database, input.abilityId);
if (previousAbilityId && previousAbilityId !== input.abilityId)
await refreshAbilityFocuses(database, previousAbilityId);
const after = await snapshot(database, setId);
await recordIsacAudit({
actorId: input.actorId,
category: 'game_data',
action: input.id ? 'skill_chip.updated' : 'skill_chip.created',
entityId: setId,
before,
after: {
...after,
mutationContext: {
origin: input.mutationContext?.origin ?? 'isac',
...(input.mutationContext?.proposalId
? { proposalId: input.mutationContext.proposalId }
: {}),
},
},
database,
});
return { setId, mode: input.id ? 'edit' : 'add' };
}
/** Public for Data Oversight replay; SvelteKit page exports require an underscore prefix. */
export const _saveSkillChipInTransaction = saveSkillChipInTransaction;
@@ -6,7 +6,6 @@ const mocks = vi.hoisted(() => {
events,
talentExists: true,
auditRecord: vi.fn(),
conditionalRuleSnapshot: vi.fn(),
gameDataIconMetadata: vi.fn(async () => null),
recordIsacAudit: vi.fn(async () => {
events.push('audit');
@@ -16,6 +15,7 @@ const mocks = vi.hoisted(() => {
events.push(sql);
}),
prepare: vi.fn(),
transactionAsync: vi.fn(),
},
};
});
@@ -63,13 +63,6 @@ vi.mock('$lib/server/specialization-readiness', () => ({
refreshSpecializationFocusReady: vi.fn(),
}));
vi.mock('$lib/server/conditional-effects', () => ({
conditionalRuleSnapshot: mocks.conditionalRuleSnapshot,
loadConditionalRuleDraft: vi.fn(),
parseConditionalRulePayload: vi.fn(),
saveConditionalRuleInTransaction: vi.fn(),
}));
import { actions } from './+page.server';
const beforeRecord = {
@@ -77,13 +70,13 @@ const beforeRecord = {
name: 'Pulse Matrix',
specialization_ability_id: 'ability-1',
active: 1,
updated_at: '2026-08-02T11:00:00.000Z',
};
const afterRecord = { ...beforeRecord, updated_at: '2026-08-02T12:00:00.000Z' };
const existingTalent = {
id: 'talent-1',
skill_chip_set_id: 'set-1',
name: 'Signal Cascade',
description_template: 'Existing Talent',
};
const twoPiece = {
skill_chip_set_id: 'set-1',
@@ -91,17 +84,6 @@ const twoPiece = {
min_value: 5,
max_value: 10,
};
const talentEffects = [
{
id: 'effect-1',
talent_id: 'talent-1',
stat_attribute_id: 'skill-intensity',
min_value: 1,
max_value: 2,
sort_order: 1,
},
];
const conditional = { id: 'rule-1', source_id: 'talent-1' };
const saveRequest = (confirmRemoval = false) => {
const form = new FormData();
@@ -112,6 +94,8 @@ const saveRequest = (confirmRemoval = false) => {
form.set('twoPieceMin', '5');
form.set('twoPieceMax', '10');
form.set('hasTalent', '0');
form.set('expectedOwnerUpdatedAt', beforeRecord.updated_at);
form.set('expectedTalentId', 'talent-1');
if (confirmRemoval) form.set('confirmTalentRemoval', 'REMOVE');
return new Request('http://localhost/isac/skillchips?/save', {
method: 'POST',
@@ -128,14 +112,31 @@ const runSave = (confirmRemoval = false) =>
describe('Skill Chip Talent removal', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.database.transactionAsync.mockImplementation(
(callback: (transaction: unknown) => Promise<unknown>) => {
const run = vi.fn(async () => {
mocks.events.push('BEGIN IMMEDIATE');
try {
const result = await callback(mocks.database);
mocks.events.push('COMMIT');
return result;
} catch (error) {
mocks.events.push('ROLLBACK');
throw error;
}
});
return Object.assign(run, { immediate: run });
},
);
mocks.events.length = 0;
mocks.talentExists = true;
mocks.auditRecord
.mockResolvedValueOnce(beforeRecord)
.mockResolvedValueOnce(afterRecord);
mocks.conditionalRuleSnapshot.mockResolvedValue(conditional);
mocks.database.prepare.mockImplementation(async (sql: string) => ({
get: vi.fn(async (...args: unknown[]) => {
if (sql.includes('SELECT updated_at FROM skill_chip_sets'))
return { updated_at: beforeRecord.updated_at };
if (sql.includes('FROM specialization_abilities'))
return { id: 'ability-1' };
if (sql.includes('COUNT(*) AS count FROM skill_chip_sets'))
@@ -149,14 +150,8 @@ describe('Skill Chip Talent removal', () => {
if (sql.includes('FROM skill_chip_two_piece_bonuses')) return twoPiece;
return null;
}),
all: vi.fn(async () => {
if (sql.includes('FROM skill_chip_talent_effects'))
return talentEffects;
return [];
}),
all: vi.fn(async () => []),
run: vi.fn(async () => {
if (sql.includes('DELETE FROM conditional_effect_rules'))
mocks.events.push('delete conditional rule');
if (sql.includes('DELETE FROM skill_chip_talents')) {
mocks.events.push('delete talent');
mocks.talentExists = false;
@@ -181,7 +176,7 @@ describe('Skill Chip Talent removal', () => {
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
});
it('removes the Talent and mechanic in one audited transaction after confirmation', async () => {
it('removes the Talent while preserving immutable mechanic history', async () => {
const result = await runSave(true);
expect(result).toMatchObject({
@@ -189,13 +184,19 @@ describe('Skill Chip Talent removal', () => {
skillChipMode: 'edit',
resultToken: expect.any(String),
});
expect(mocks.database.transactionAsync).toHaveBeenCalledOnce();
expect(mocks.events).toEqual([
'BEGIN',
'delete conditional rule',
'BEGIN IMMEDIATE',
'delete talent',
'audit',
'COMMIT',
]);
expect(mocks.events).not.toContain('delete conditional rule');
expect(
mocks.database.prepare.mock.calls.some(([sql]) =>
String(sql).includes('DELETE FROM mechanic_authored_sources'),
),
).toBe(false);
expect(mocks.recordIsacAudit).toHaveBeenCalledWith({
actorId: 'operator-1',
category: 'game_data',
@@ -207,8 +208,7 @@ describe('Skill Chip Talent removal', () => {
render: null,
twoPiece,
talent: existingTalent,
talentEffects,
conditional,
mechanic: null,
},
after: {
record: afterRecord,
@@ -216,8 +216,8 @@ describe('Skill Chip Talent removal', () => {
render: null,
twoPiece,
talent: null,
talentEffects: [],
conditional: null,
mechanic: null,
mutationContext: { origin: 'isac' },
},
database: mocks.database,
});