feat: refine builder skill chip flows
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 44s
Validate and deploy staging / deploy (push) Successful in 1m14s

This commit is contained in:
jb
2026-08-06 01:00:15 -04:00
parent 46f64d37b6
commit 4777a3abd5
25 changed files with 951 additions and 122 deletions
@@ -187,6 +187,14 @@ async function focusSnapshot(database: Database, id: string) {
async function abilitySnapshot(database: Database, id: string) {
return {
record: await auditRecord('specialization_abilities', id),
skillChipLinks: await (
await database.prepare(
`SELECT ability_id,compatible_ability_id
FROM specialization_ability_skill_chip_links
WHERE ability_id=? OR compatible_ability_id=?
ORDER BY ability_id,compatible_ability_id`,
)
).all(id, id),
attack:
(await (
await database.prepare(
@@ -398,6 +406,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'cooldownSeconds',
'effectsJson',
'attackJson',
'skillChipCompatibleAbilityId',
],
},
{
@@ -470,6 +479,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
specializations,
focuses,
abilities,
abilitySkillChipLinks,
abilityAttacks,
abilityEffects,
focusAbilities,
@@ -506,6 +516,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'SELECT * FROM specialization_abilities ORDER BY specialization_id,active DESC,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_ability_skill_chip_links ORDER BY ability_id,compatible_ability_id',
)
).all(),
(
await database.prepare('SELECT * FROM specialization_ability_attacks')
).all(),
@@ -672,6 +687,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
})),
...focuses,
];
const linkedAbilityById = new Map<string, string>();
for (const link of abilitySkillChipLinks) {
linkedAbilityById.set(
String(link.ability_id),
String(link.compatible_ability_id),
);
linkedAbilityById.set(
String(link.compatible_ability_id),
String(link.ability_id),
);
}
const loadedAbilities = [
...pendingAbilities.map((item) => ({
...item,
@@ -680,6 +706,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
cooldown_seconds: item.cooldownSeconds || null,
attack: item.attackJson ? JSON.parse(String(item.attackJson)) : null,
effects: parseJsonArray<AbilityEffectInput>(item.effectsJson),
skillChipCompatibleAbilityId: item.skillChipCompatibleAbilityId || '',
active: 1,
pending: true,
skillChipCount: 0,
@@ -698,6 +725,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
chipCounts.find((item) => item.specialization_ability_id === ability.id)
?.count ?? 0,
),
skillChipCompatibleAbilityId:
linkedAbilityById.get(String(ability.id)) ?? '',
iconUrl: iconUrl('specialization_ability', String(ability.id)),
renderUrl: renderUrl('specialization_ability', String(ability.id)),
})),
@@ -1160,7 +1189,12 @@ export const actions: Actions = {
);
const form = await request.formData();
const id = String(form.get('id') ?? '');
const abilityId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const specializationId = String(form.get('specializationId') ?? '');
const skillChipCompatibleAbilityId = String(
form.get('skillChipCompatibleAbilityId') ?? '',
);
const name = cleanName(form.get('name'));
const description = cleanName(form.get('description'));
const durationSeconds = optionalPositive(form.get('durationSeconds'));
@@ -1312,6 +1346,46 @@ export const actions: Actions = {
) {
return fail(400, { abilityError: 'Choose an active specialization.' });
}
if (skillChipCompatibleAbilityId) {
if (skillChipCompatibleAbilityId === abilityId) {
return fail(400, {
abilityError: 'An ability cannot share Skill Chips with itself.',
});
}
const compatibleAbility = await (
await database.prepare(
'SELECT id,specialization_id FROM specialization_abilities WHERE id=? AND active=1',
)
).get(skillChipCompatibleAbilityId);
if (
!compatibleAbility ||
String(compatibleAbility.specialization_id) !== specializationId
) {
return fail(400, {
abilityError:
'Choose an active Skill Chip-compatible ability from the same specialization.',
});
}
const conflictingLink = await (
await database.prepare(
`SELECT ability_id,compatible_ability_id
FROM specialization_ability_skill_chip_links
WHERE (ability_id=? OR compatible_ability_id=?)
AND ability_id<>? AND compatible_ability_id<>?`,
)
).get(
skillChipCompatibleAbilityId,
skillChipCompatibleAbilityId,
abilityId,
abilityId,
);
if (conflictingLink) {
return fail(409, {
abilityError:
'Each ability can share one Skill Chip pool. Clear the existing link first.',
});
}
}
if (
!(await validateAttributeIds(
database,
@@ -1387,8 +1461,6 @@ export const actions: Actions = {
: 'The ability icon could not be processed.',
});
}
const abilityId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await abilitySnapshot(database, id) : null;
const iconChanged = Boolean(iconMutation.output || iconMutation.remove);
const previousIcon = iconChanged
@@ -1426,6 +1498,24 @@ export const actions: Actions = {
now,
);
}
await (
await database.prepare(
`DELETE FROM specialization_ability_skill_chip_links
WHERE ability_id=? OR compatible_ability_id=?`,
)
).run(abilityId, abilityId);
if (skillChipCompatibleAbilityId) {
const [leftAbilityId, rightAbilityId] = [
abilityId,
skillChipCompatibleAbilityId,
].sort();
await (
await database.prepare(
`INSERT INTO specialization_ability_skill_chip_links
(ability_id,compatible_ability_id) VALUES(?,?)`,
)
).run(leftAbilityId, rightAbilityId);
}
await (
await database.prepare(
'DELETE FROM specialization_ability_effects WHERE ability_id=?',
@@ -2326,6 +2416,12 @@ export const actions: Actions = {
'UPDATE specialization_abilities SET active=0,updated_at=? WHERE id=?',
)
).run(new Date().toISOString(), id);
await (
await database.prepare(
`DELETE FROM specialization_ability_skill_chip_links
WHERE ability_id=? OR compatible_ability_id=?`,
)
).run(id, id);
if (ability)
await refreshSpecializationFocuses(
database,