feat: persist authored mechanic revisions
This commit is contained in:
@@ -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(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user