feat: expand ISAC content modeling
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 28s
Validate and deploy staging / deploy (push) Successful in 1m3s

This commit is contained in:
jb
2026-08-03 01:23:20 -04:00
parent d9924a95b9
commit 57068b61cd
90 changed files with 9635 additions and 1270 deletions
+361 -227
View File
@@ -25,7 +25,10 @@ import {
getRegistrySearch,
getRegistrySort,
} from '$lib/server/pagination';
import { refreshAbilityFocuses } from '$lib/server/specialization-readiness';
import {
refreshAbilityFocuses,
refreshSpecializationFocusReady,
} from '$lib/server/specialization-readiness';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
@@ -36,6 +39,18 @@ import {
saveConditionalRuleInTransaction,
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
import {
normalizeSkillChipProfiles,
SKILL_CHIP_QUALITIES,
SKILL_CHIP_SECONDARY_QUALITIES,
} from '$lib/skill-chips/profiles';
import {
getSkillChipPrimaryAttributeIds,
getSkillChipPrimaryProfiles,
getSkillChipSecondaryPool,
replaceSkillChipPrimaryProfiles,
replaceSkillChipSecondaryPool,
} from '$lib/server/skill-chip-profiles';
const mechanicOutcomes = (
rule: ReturnType<typeof parseConditionalRulePayload>,
@@ -68,6 +83,16 @@ const renderAuditMetadata = (render: Record<string, any> | null | undefined) =>
}
: 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 (
@@ -91,16 +116,6 @@ async function snapshot(id: string) {
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
)
).get(id)) ?? null,
primaryOptions: await (
await database.prepare(
'SELECT stat_attribute_id FROM skill_chip_primary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id',
)
).all(id),
secondaryOptions: await (
await database.prepare(
'SELECT stat_attribute_id FROM skill_chip_secondary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id',
)
).all(id),
talent: talent ?? null,
talentEffects: talent
? await (
@@ -150,8 +165,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
desc: "COALESCE((SELECT talent.name FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id),'') COLLATE NOCASE DESC,sets.name COLLATE NOCASE ASC",
},
rollPools: {
asc: '((SELECT COUNT(*) FROM skill_chip_primary_options primary_option WHERE primary_option.skill_chip_set_id=sets.id)+(SELECT COUNT(*) FROM skill_chip_secondary_options secondary_option WHERE secondary_option.skill_chip_set_id=sets.id)) ASC,sets.name COLLATE NOCASE ASC',
desc: '((SELECT COUNT(*) FROM skill_chip_primary_options primary_option WHERE primary_option.skill_chip_set_id=sets.id)+(SELECT COUNT(*) FROM skill_chip_secondary_options secondary_option WHERE secondary_option.skill_chip_set_id=sets.id)) DESC,sets.name COLLATE NOCASE ASC',
asc: 'sets.name COLLATE NOCASE ASC,sets.id ASC',
desc: 'sets.name COLLATE NOCASE DESC,sets.id DESC',
},
state: {
asc: 'sets.active ASC,sets.name COLLATE NOCASE ASC',
@@ -173,7 +188,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
: filter === 'configured'
? [
'EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=sets.id)',
'EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id)',
]
: []),
...(!requestedItemId && search
@@ -198,7 +212,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived,
SUM(CASE WHEN specialization_ability_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned,
SUM(CASE WHEN EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=skill_chip_sets.id) THEN 1 ELSE 0 END) AS configured
SUM(CASE WHEN EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=skill_chip_sets.id) THEN 1 ELSE 0 END) AS configured
FROM skill_chip_sets`)
).get(),
(
@@ -211,7 +225,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
module: 'attributes',
action: 'save',
kind: 'stat',
fields: ['unit', 'chipMin', 'chipMax'],
fields: ['unit'],
},
]),
getPendingEntities(user.id, [
@@ -224,8 +238,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'twoPieceStatId',
'twoPieceMin',
'twoPieceMax',
'primaryStatIds',
'secondaryStatIds',
'hasTalent',
'talentName',
'descriptionTemplate',
'conditionalRule',
@@ -252,8 +265,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const [
sets,
twoPiece,
primaryOptions,
secondaryOptions,
talents,
talentEffects,
stats,
@@ -262,6 +273,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
abilityChipCounts,
icons,
renders,
primaryProfiles,
secondaryPool,
primaryAttributeIds,
] = await Promise.all([
(
await database.prepare(
@@ -271,10 +285,6 @@ 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_primary_options')).all(),
(
await database.prepare('SELECT * FROM skill_chip_secondary_options')
).all(),
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
(
await database.prepare(
@@ -283,7 +293,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
).all(),
(
await database.prepare(
'SELECT id,name,unit,skill_chip_min,skill_chip_max FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
@@ -310,13 +320,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='skill_chip_render'",
)
).all(),
getSkillChipPrimaryProfiles(database),
getSkillChipSecondaryPool(database),
getSkillChipPrimaryAttributeIds(database),
]);
const availableStats = [
...pendingStats.map((item) => ({
...item,
unit: String(item.unit ?? ''),
skill_chip_min: item.chipMin === '' ? null : Number(item.chipMin),
skill_chip_max: item.chipMax === '' ? null : Number(item.chipMax),
})),
...stats,
];
@@ -347,9 +358,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const currentPendingSets = pendingSets.filter(
(set) =>
Boolean(set.abilityId) &&
Array.isArray(set.primaryStatIds) &&
Array.isArray(set.secondaryStatIds) &&
Boolean(set.conditionalRule),
(String(set.hasTalent) !== '1' || Boolean(set.conditionalRule)),
);
const pendingSetRows = requestedItemId
? currentPendingSets.filter((set) => set.id === requestedItemId)
@@ -374,19 +383,22 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
min_value: Number(set.twoPieceMin),
max_value: Number(set.twoPieceMax),
},
primaryStatIds: set.primaryStatIds,
secondaryStatIds: set.secondaryStatIds,
talent: {
name: set.talentName,
description_template: set.descriptionTemplate,
conditionalRule: (() => {
try {
return JSON.parse(String(set.conditionalRule ?? 'null'));
} catch {
return null;
}
})(),
},
talent:
String(set.hasTalent) === '1'
? {
name: set.talentName,
description_template: set.descriptionTemplate,
conditionalRule: (() => {
try {
return JSON.parse(
String(set.conditionalRule ?? 'null'),
);
} catch {
return null;
}
})(),
}
: null,
}));
const configuredSets = await Promise.all(
sets.map(async (set) => {
@@ -407,12 +419,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
})(),
twoPiece:
twoPiece.find((item) => item.skill_chip_set_id === set.id) ?? null,
primaryStatIds: primaryOptions
.filter((item) => item.skill_chip_set_id === set.id)
.map((item) => String(item.stat_attribute_id)),
secondaryStatIds: secondaryOptions
.filter((item) => item.skill_chip_set_id === set.id)
.map((item) => String(item.stat_attribute_id)),
talent: talent
? {
...talent,
@@ -433,9 +439,15 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
return {
sets: [...pendingSetRows, ...configuredSets],
stats: availableStats,
rollStats: availableStats.filter(
(stat) => stat.skill_chip_min !== null && stat.skill_chip_max !== null,
primaryProfiles: primaryAttributeIds.map(
(statId) =>
primaryProfiles.find((profile) => profile.statId === statId) ?? {
statId,
values: {},
},
),
primaryProfileBase: primaryProfiles,
secondaryPool,
abilities: loadedAbilities,
summary: {
active: Number(summary?.active ?? 0),
@@ -490,12 +502,7 @@ export const actions: Actions = {
const twoPieceStatId = String(form.get('twoPieceStatId') ?? '');
const twoPieceMin = Number(form.get('twoPieceMin'));
const twoPieceMax = Number(form.get('twoPieceMax'));
const primaryStatIds = [
...new Set(form.getAll('primaryStatIds').map(String).filter(Boolean)),
];
const secondaryStatIds = [
...new Set(form.getAll('secondaryStatIds').map(String).filter(Boolean)),
];
const hasTalent = form.get('hasTalent') === '1';
const talentName = String(form.get('talentName') ?? '')
.normalize('NFKC')
.trim();
@@ -503,25 +510,30 @@ export const actions: Actions = {
.normalize('NFKC')
.trim();
const database = await getDatabase();
let conditionalRule;
let conditionalRule: ReturnType<typeof parseConditionalRulePayload> | null =
null;
try {
const conditionalRuleRaw = form.get('conditionalRule');
const activeStats = await (
await database.prepare('SELECT id FROM stat_attributes WHERE active=1')
).all();
conditionalRule = parseConditionalRulePayload(
conditionalRuleRaw,
[
{ key: 'minimum', variantKey: 'minimum' },
{ key: 'maximum', variantKey: 'maximum' },
],
{
allowedAttributeIds: new Set(
activeStats.map((row) => String(row.id)),
),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
},
);
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)),
},
);
}
} catch (error) {
return fail(400, {
skillChipError:
@@ -547,38 +559,30 @@ export const actions: Actions = {
return fail(400, {
skillChipError: 'Configure a valid 2-piece stat range.',
});
if (!primaryStatIds.length)
return fail(400, {
skillChipError: 'Configure at least one possible primary stat.',
});
if (secondaryStatIds.length < 2)
return fail(400, {
skillChipError: 'Configure at least two possible secondary stats.',
});
if (primaryStatIds.some((statId) => secondaryStatIds.includes(statId)))
return fail(400, {
skillChipError: 'Primary and secondary stat pools must be distinct.',
});
const outcomes = mechanicOutcomes(conditionalRule);
const numberedOutcomes = descriptionOutcomes(conditionalRule);
const outcomes = conditionalRule ? mechanicOutcomes(conditionalRule) : [];
const numberedOutcomes = conditionalRule
? descriptionOutcomes(conditionalRule)
: [];
const durationSeconds =
conditionalRule.pools.find((pool) => pool.expiresAfterSeconds)
conditionalRule?.pools.find((pool) => pool.expiresAfterSeconds)
?.expiresAfterSeconds ?? null;
const maxStacks = conditionalRule.pools.length
const maxStacks = conditionalRule?.pools.length
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
: null;
if (
talentName.length < 2 ||
talentName.length > 80 ||
!descriptionTemplate ||
!conditionalRule.clauses.length ||
!outcomes.length
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,
@@ -592,6 +596,7 @@ export const actions: Actions = {
(match) => Number(match[1]),
);
if (
hasTalent &&
placeholders.some(
(index) =>
!numberedOutcomes.some(
@@ -603,12 +608,16 @@ export const actions: Actions = {
skillChipError:
'Description placeholders must reference configured numbered outcomes.',
});
if (descriptionTemplate.includes('{duration}') && durationSeconds === null)
if (
hasTalent &&
descriptionTemplate.includes('{duration}') &&
durationSeconds === null
)
return fail(400, {
skillChipError:
'The description uses {duration}, but no expiring state pool is defined.',
});
if (descriptionTemplate.includes('{max_stacks}') && !maxStacks)
if (hasTalent && descriptionTemplate.includes('{max_stacks}') && !maxStacks)
return fail(400, {
skillChipError:
'The description uses {max_stacks}, but no state pool maximum is defined.',
@@ -656,11 +665,21 @@ export const actions: Actions = {
).get(id)
: null;
if (
await (
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')
).get(talentName, existingTalent?.id ?? 'new'))
)
return fail(409, {
skillChipError: 'That Skill Chip talent name is already in use.',
@@ -668,14 +687,7 @@ export const actions: Actions = {
const outcomeStatIds = outcomes
.map((effect) => effect.attributeId)
.filter((value): value is string => Boolean(value));
const allStatIds = [
...new Set([
twoPieceStatId,
...primaryStatIds,
...secondaryStatIds,
...outcomeStatIds,
]),
];
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(',')})`,
@@ -685,17 +697,6 @@ export const actions: Actions = {
return fail(400, {
skillChipError: 'One or more selected stats are unavailable.',
});
const rollStatIds = [...primaryStatIds, ...secondaryStatIds];
const validRollStats = await (
await database.prepare(
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND skill_chip_min IS NOT NULL AND skill_chip_max IS NOT NULL AND id IN (${rollStatIds.map(() => '?').join(',')})`,
)
).get(...rollStatIds);
if (Number(validRollStats?.count) !== rollStatIds.length)
return fail(400, {
skillChipError:
'Every primary and secondary option requires a configured Skill Chip range.',
});
const setId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const talentId = String(existingTalent?.id ?? randomUUID());
@@ -724,61 +725,50 @@ export const actions: Actions = {
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);
await (
await database.prepare(
'DELETE FROM skill_chip_primary_options WHERE skill_chip_set_id=?',
)
).run(setId);
for (const statId of primaryStatIds)
if (hasTalent && conditionalRule) {
await (
await database.prepare(
'INSERT INTO skill_chip_primary_options(skill_chip_set_id,stat_attribute_id) VALUES(?,?)',
)
).run(setId, statId);
await (
await database.prepare(
'DELETE FROM skill_chip_secondary_options WHERE skill_chip_set_id=?',
)
).run(setId);
for (const statId of secondaryStatIds)
await (
await database.prepare(
'INSERT INTO skill_chip_secondary_options(skill_chip_set_id,stat_attribute_id) VALUES(?,?)',
)
).run(setId, statId);
await (
await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
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;
).run(talentId, setId, talentName, descriptionTemplate, now, now);
await (
await database.prepare(
'INSERT INTO skill_chip_talent_effects(id,talent_id,stat_attribute_id,min_value,max_value,sort_order) VALUES(?,?,?,?,?,?)',
'DELETE FROM skill_chip_talent_effects WHERE talent_id=?',
)
).run(
randomUUID(),
).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,
outcome.attributeId,
outcome.variants.minimum.value,
outcome.variants.maximum.value,
compatibilitySortOrder,
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 saveConditionalRuleInTransaction(
database,
'skill_chip_talent',
talentId,
conditionalRule,
);
await applyIconMutation(
database,
'skill_chip',
@@ -804,6 +794,7 @@ export const actions: Actions = {
entityId: setId,
before,
after: await snapshot(setId),
database,
});
await database.exec('COMMIT');
} catch (error) {
@@ -816,33 +807,163 @@ export const actions: Actions = {
resultToken: randomUUID(),
};
},
savePrimaryProfile: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const database = await getDatabase();
const form = await request.formData();
const expectedStatIds = await getSkillChipPrimaryAttributeIds(database);
const normalized = normalizeSkillChipProfiles(
(() => {
try {
return JSON.parse(String(form.get('profiles') ?? ''));
} catch {
return null;
}
})(),
SKILL_CHIP_QUALITIES,
{ expectedStatIds, minimumOptions: 4 },
);
if (normalized.error)
return fail(400, { skillChipPrimaryProfileError: normalized.error });
await database.exec('BEGIN IMMEDIATE');
try {
const before = await getSkillChipPrimaryProfiles(database);
if (JSON.stringify(before) !== String(form.get('baseProfiles') ?? '')) {
await database.exec('ROLLBACK');
if (JSON.stringify(before) === JSON.stringify(normalized.profiles))
return {
skillChipPrimaryProfileSaved: true,
resultToken: randomUUID(),
};
return fail(409, {
skillChipPrimaryProfileError:
'The shared primary profile changed after this editor opened. Review the latest values and try again.',
});
}
await replaceSkillChipPrimaryProfiles(database, normalized.profiles);
await refreshAllFocusReadiness(database);
const after = await getSkillChipPrimaryProfiles(database);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'skill_chip_primary_profile.updated',
entityId: 'skill-chip-primary-profile',
before,
after,
database,
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return { skillChipPrimaryProfileSaved: true, resultToken: randomUUID() };
},
saveSecondaryPool: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const database = await getDatabase();
const form = await request.formData();
const normalized = normalizeSkillChipProfiles(
(() => {
try {
return JSON.parse(String(form.get('profiles') ?? ''));
} catch {
return null;
}
})(),
SKILL_CHIP_SECONDARY_QUALITIES,
{ minimumOptions: 2 },
);
if (normalized.error)
return fail(400, { skillChipSecondaryPoolError: normalized.error });
const statIds = normalized.profiles.map((profile) => profile.statId);
const validStats = await (
await database.prepare(
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`,
)
).get(...statIds);
if (Number(validStats?.count) !== statIds.length)
return fail(400, {
skillChipSecondaryPoolError:
'One or more selected Attributes are unavailable.',
});
await database.exec('BEGIN IMMEDIATE');
try {
const before = await getSkillChipSecondaryPool(database);
if (JSON.stringify(before) !== String(form.get('baseProfiles') ?? '')) {
await database.exec('ROLLBACK');
if (JSON.stringify(before) === JSON.stringify(normalized.profiles))
return {
skillChipSecondaryPoolSaved: true,
resultToken: randomUUID(),
};
return fail(409, {
skillChipSecondaryPoolError:
'The shared secondary pool changed after this editor opened. Review the latest values and try again.',
});
}
await replaceSkillChipSecondaryPool(database, normalized.profiles);
await refreshAllFocusReadiness(database);
const after = await getSkillChipSecondaryPool(database);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'skill_chip_secondary_pool.updated',
entityId: 'skill-chip-secondary-pool',
before,
after,
database,
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return { skillChipSecondaryPoolSaved: true, resultToken: randomUUID() };
},
archive: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const id = String((await request.formData()).get('id') ?? '');
const before = await snapshot(id);
if (!before.record)
return fail(404, { skillChipError: 'Skill Chip set not found.' });
const database = await getDatabase();
await (
await database.prepare(
'UPDATE skill_chip_sets SET active=0,updated_at=? WHERE id=?',
)
).run(new Date().toISOString(), id);
await refreshAbilityFocuses(
database,
String(before.record.specialization_ability_id),
);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'skill_chip.archived',
entityId: id,
before,
after: await snapshot(id),
});
await database.exec('BEGIN IMMEDIATE');
try {
const before = await snapshot(id);
if (!before.record) {
await database.exec('ROLLBACK');
return fail(404, { skillChipError: 'Skill Chip set not found.' });
}
await (
await database.prepare(
'UPDATE skill_chip_sets SET active=0,updated_at=? WHERE id=?',
)
).run(new Date().toISOString(), id);
await refreshAbilityFocuses(
database,
String(before.record.specialization_ability_id),
);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'skill_chip.archived',
entityId: id,
before,
after: await snapshot(id),
database,
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return { skillChipArchived: true };
},
unarchive: async ({ request, cookies }) => {
@@ -851,50 +972,63 @@ export const actions: Actions = {
'data.manage',
);
const id = String((await request.formData()).get('id') ?? '');
const before = await snapshot(id);
if (!before.record)
return fail(404, { skillChipError: 'Skill Chip set not found.' });
const abilityId = String(before.record.specialization_ability_id);
const database = await getDatabase();
if (
!(await (
await database.prepare(
'SELECT id FROM specialization_abilities WHERE id=? AND active=1',
)
).get(abilityId))
) {
return fail(400, {
skillChipError:
'The assigned specialization ability must be active before restoring this Skill Chip.',
});
}
const assignedCount = Number(
(
await (
await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets
await database.exec('BEGIN IMMEDIATE');
try {
const before = await snapshot(id);
if (!before.record) {
await database.exec('ROLLBACK');
return fail(404, { skillChipError: 'Skill Chip set not found.' });
}
const abilityId = String(before.record.specialization_ability_id);
if (
!(await (
await database.prepare(
'SELECT id FROM specialization_abilities WHERE id=? AND active=1',
)
).get(abilityId))
) {
await database.exec('ROLLBACK');
return fail(400, {
skillChipError:
'The assigned specialization ability must be active before restoring this Skill Chip.',
});
}
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)
)?.count ?? 0,
);
if (assignedCount >= 3)
return fail(409, {
skillChipError:
'That ability already has all three active Skill Chips assigned.',
).get(abilityId, id)
)?.count ?? 0,
);
if (assignedCount >= 3) {
await database.exec('ROLLBACK');
return fail(409, {
skillChipError:
'That ability already has all three active Skill Chips assigned.',
});
}
await (
await database.prepare(
'UPDATE skill_chip_sets SET active=1,updated_at=? WHERE id=?',
)
).run(new Date().toISOString(), id);
await refreshAbilityFocuses(database, abilityId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'skill_chip.unarchived',
entityId: id,
before,
after: await snapshot(id),
database,
});
await (
await database.prepare(
'UPDATE skill_chip_sets SET active=1,updated_at=? WHERE id=?',
)
).run(new Date().toISOString(), id);
await refreshAbilityFocuses(database, abilityId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'skill_chip.unarchived',
entityId: id,
before,
after: await snapshot(id),
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return { skillChipUnarchived: true };
},
};