feat(isac): link skill chips to abilities

This commit is contained in:
JB
2026-07-26 17:04:47 -04:00
parent 6722665f5c
commit b1656c430f
19 changed files with 681 additions and 424 deletions
+50
View File
@@ -1296,6 +1296,15 @@
.specialization-ability-summary strong{
font-size:13px
}
.specialization-ability-summary .ability-chip-readiness{
color:var(--shd-danger);
font-size:var(--shd-type-telemetry);
font-weight:900;
letter-spacing:.55px
}
.specialization-ability-summary .ability-chip-readiness.ready{
color:var(--shd-success)
}
.specialization-ability-summary p{
display:-webkit-box;
overflow:hidden;
@@ -1306,6 +1315,47 @@
-webkit-box-orient:vertical;
-webkit-line-clamp:2
}
/* Exclusive child-record assignments stay close to identity and expose capacity. */
.skill-chip-ability-assignment{
margin-top:10px;
padding:9px;
border:1px solid var(--shd-border);
border-left:2px solid var(--shd-orange);
background:#0d1011
}
.skill-chip-ability-assignment>.assignment-picker{
margin-top:7px
}
.skill-chip-row{
min-width:1240px;
grid-template-columns:1.05fr 1.15fr .4fr 1fr 1fr .78fr .48fr .3fr
}
.skill-chip-row>div:nth-child(5){
display:grid;
gap:3px
}
.skill-chip-row>div:nth-child(5) small,
.skill-chip-ability-cell small{
color:#626a6c;
font-size:var(--shd-type-telemetry)
}
.skill-chip-ability-cell{
min-width:0;
display:grid;
gap:3px
}
.skill-chip-ability-cell strong{
overflow:hidden;
font-size:var(--shd-type-telemetry);
line-height:1.35;
text-overflow:ellipsis
}
@media(max-width:700px){
.skill-chip-ability-assignment{
padding:8px
}
}
.specialization-ability-summary button{
align-self:start;
border:0;
@@ -44,6 +44,14 @@
: 'TREE NODE'
);
const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const skillChipRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const skillChipFields = $derived([
{ label: 'NAME', current: skillChipRecord?.name, proposed: proposed.name },
{ label: 'SPECIALIZATION ABILITY', current: skillChipRecord?.specialization_ability_id, proposed: proposed.abilityId },
{ label: '2-PIECE ATTRIBUTE', current: (currentSnapshot?.twoPiece as any)?.stat_attribute_id, proposed: proposed.twoPieceStatId },
{ label: '2-PIECE RANGE', current: `${(currentSnapshot?.twoPiece as any)?.min_value ?? '—'} – ${(currentSnapshot?.twoPiece as any)?.max_value ?? '—'}`, proposed: `${proposed.twoPieceMin || '—'} – ${proposed.twoPieceMax || '—'}` },
{ label: '3-PIECE TALENT', current: (currentSnapshot?.talent as any)?.name, proposed: proposed.talentName }
]);
const specializationFields = $derived(
action === 'saveProgression'
? [
@@ -142,6 +150,29 @@
</div>
</article>
</div>
{:else if module === 'skillchips' && action === 'save'}
<div class="proposal-compare-grid">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · SKILL CHIP</span><b>{skillChipRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
{#if skillChipRecord}
<div class="proposal-field-summary">
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>{/each}
<div><span>ROLL POOLS</span><strong>{(currentSnapshot?.primaryOptions as any[])?.length ?? 0} PRIMARY · {(currentSnapshot?.secondaryOptions as any[])?.length ?? 0} SECONDARY</strong></div>
<div><span>TALENT STRUCTURE</span><strong>{(currentSnapshot?.talentTriggers as any[])?.length ?? 0} TRIGGERS · {(currentSnapshot?.talentEffects as any[])?.length ?? 0} EFFECTS</strong></div>
</div>
{:else}
<div class="proposal-empty-state"><b>NEW SKILL CHIP</b><span>No live registry record will be replaced.</span></div>
{/if}
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED · SKILL CHIP</span><b>AFTER</b></header>
<div class="proposal-field-summary">
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>{/each}
<div><span>ROLL POOLS</span><strong>{Array.isArray(proposed.primaryStatIds) ? proposed.primaryStatIds.length : Number(Boolean(proposed.primaryStatIds))} PRIMARY · {Array.isArray(proposed.secondaryStatIds) ? proposed.secondaryStatIds.length : Number(Boolean(proposed.secondaryStatIds))} SECONDARY</strong></div>
<div><span>TALENT STRUCTURE</span><strong>{Array.isArray(proposed.triggerIds) ? proposed.triggerIds.length : Number(Boolean(proposed.triggerIds))} TRIGGERS · {((jsonValue(proposed.talentEffects) ?? []) as any[]).length} EFFECTS</strong></div>
</div>
</article>
</div>
{:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveFocusAbilities','saveNode'].includes(action)}
<div class="proposal-compare-grid specialization-proposal-preview">
<article class="proposal-editor-preview before">
@@ -0,0 +1,59 @@
import type { Database } from '@tursodatabase/database';
export async function refreshSpecializationFocusReady(database: Database, focusId: string) {
const focus = await (await database.prepare(
'SELECT id,active FROM specialization_focuses WHERE id=?'
)).get(focusId);
if (!focus) return { ready: false, pointTotal: 0, availablePoints: 0, abilityCount: 0, chipReadyCount: 0 };
const [config, abilityCountRow, chipReadyRow, nodes, rankProfiles] = await Promise.all([
(await database.prepare("SELECT xp_points,shd_cache_points FROM specialization_progression_config WHERE id='default'")).get(),
(await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
JOIN specialization_abilities ability ON ability.id=link.ability_id
WHERE link.focus_id=? AND ability.active=1`)).get(focusId),
(await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
WHERE link.focus_id=? AND (
SELECT COUNT(*) FROM skill_chip_sets chips
WHERE chips.specialization_ability_id=ability.id AND chips.active=1
)=3`)).get(focusId),
(await database.prepare(
'SELECT row_number,max_rank FROM specialization_tree_nodes WHERE focus_id=? AND active=1 ORDER BY row_number,column_number'
)).all(focusId),
(await database.prepare('SELECT rank,point_cost FROM specialization_rank_cost_profiles ORDER BY rank')).all()
]);
const pointForRank = new Map(rankProfiles.map((profile) => [Number(profile.rank), Number(profile.point_cost)]));
const pointTotal = nodes.reduce((sum, node) => {
for (let rank = 1; rank <= Number(node.max_rank); rank += 1) sum += pointForRank.get(rank) ?? rank;
return sum;
}, 0);
const rows = [...new Set(nodes.map((node) => Number(node.row_number)))];
const contiguousRows = rows.every((row, index) => row === index + 1);
const abilityCount = Number(abilityCountRow?.count ?? 0);
const chipReadyCount = Number(chipReadyRow?.count ?? 0);
const availablePoints = Number(config?.xp_points ?? 30) + Number(config?.shd_cache_points ?? 19);
const ready = Number(focus.active) === 1
&& abilityCount === 3
&& chipReadyCount === 3
&& nodes.length > 0
&& contiguousRows
&& pointTotal === availablePoints;
await (await database.prepare(
'UPDATE specialization_focuses SET builder_ready=?,updated_at=? WHERE id=?'
)).run(ready ? 1 : 0, new Date().toISOString(), focusId);
return { ready, pointTotal, availablePoints, abilityCount, chipReadyCount };
}
export async function refreshSpecializationFocuses(database: Database, specializationId: string) {
const focuses = await (await database.prepare(
'SELECT id FROM specialization_focuses WHERE specialization_id=?'
)).all(specializationId);
for (const focus of focuses) await refreshSpecializationFocusReady(database, String(focus.id));
}
export async function refreshAbilityFocuses(database: Database, abilityId: string) {
if (!abilityId) return;
const focuses = await (await database.prepare(
'SELECT focus_id FROM specialization_focus_abilities WHERE ability_id=?'
)).all(abilityId);
for (const focus of focuses) await refreshSpecializationFocusReady(database, String(focus.focus_id));
}
@@ -266,7 +266,7 @@ export const load: PageServerLoad = async ({ params, url }) => {
case 'skill-chips':
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`;
rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit,
talents.name AS talent_name,
talents.name AS talent_name,abilities.name AS ability_name,specializations.name AS specialization_name,
(SELECT COUNT(*) FROM skill_chip_primary_options options WHERE options.skill_chip_set_id=item.id) AS primary_count,
(SELECT COUNT(*) FROM skill_chip_secondary_options options WHERE options.skill_chip_set_id=item.id) AS secondary_count,
icons.updated_at AS icon_updated_at
@@ -274,6 +274,8 @@ export const load: PageServerLoad = async ({ params, url }) => {
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
LEFT JOIN stat_attributes attributes ON attributes.id=bonus.stat_attribute_id
LEFT JOIN skill_chip_talents talents ON talents.skill_chip_set_id=item.id
LEFT JOIN specialization_abilities abilities ON abilities.id=item.specialization_ability_id
LEFT JOIN specializations ON specializations.id=abilities.specialization_id
LEFT JOIN game_data_icons icons ON icons.entity_type='skill_chip' AND icons.entity_id=item.id
WHERE item.active=1${searchClause}
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
@@ -527,6 +529,7 @@ export const load: PageServerLoad = async ({ params, url }) => {
iconUrl: iconUrl('skill_chip',row.id,row.icon_updated_at),
badges: [{ label: 'SUPERIOR' }, { label: 'HIGH-END' }],
facts: [
{ label: 'SPECIALIZATION ABILITY', value: row.ability_name ? `${row.ability_name} · ${row.specialization_name}` : 'UNASSIGNED' },
{ label: '2-PIECE BONUS', value: row.bonus_stat ? `${row.bonus_stat} · ${range(row.min_value,row.max_value,row.bonus_unit)}` : 'NOT CONFIGURED' },
{ label: 'PRIMARY POOL', value: `${row.primary_count} OPTIONS` },
{ label: 'SECONDARY POOL', value: `${row.secondary_count} OPTIONS` }
@@ -63,6 +63,31 @@ async function specializationProposalSnapshot(database: any, proposal: any) {
return null;
}
async function skillChipProposalSnapshot(database: any, id: string) {
const talent = await (await database.prepare(
'SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?'
)).get(id);
return {
record: await (await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')).get(id) ?? null,
twoPiece: await (await database.prepare(
'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,
talentTriggers: talent ? await (await database.prepare(
'SELECT trigger_id FROM skill_chip_talent_triggers WHERE talent_id=? ORDER BY trigger_id'
)).all(talent.id) : [],
talentEffects: talent ? await (await database.prepare(
'SELECT * FROM skill_chip_talent_effects WHERE talent_id=? ORDER BY sort_order'
)).all(talent.id) : []
};
}
export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'data.approve');
const database = await getDatabase();
@@ -88,6 +113,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
...proposal,
current_snapshot: proposal.module === 'attributes' && proposal.entity_id
? await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(String(proposal.entity_id)) ?? null
: proposal.module === 'skillchips' && proposal.entity_id
? await skillChipProposalSnapshot(database, String(proposal.entity_id))
: proposal.module === 'specializations'
? await specializationProposalSnapshot(database, proposal)
: null
+77 -10
View File
@@ -5,6 +5,7 @@ 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 { getPagination, getRegistrySearch } from '$lib/server/pagination';
import { refreshAbilityFocuses } from '$lib/server/specialization-readiness';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
@@ -45,28 +46,34 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
const requestedFilter = url.searchParams.get('filter') ?? '';
const filter = ['active', 'archived', 'configured'].includes(requestedFilter) ? requestedFilter : '';
const filter = ['active', 'archived', 'configured', 'assigned', 'unassigned'].includes(requestedFilter) ? requestedFilter : '';
const { search, pattern } = getRegistrySearch(url);
const conditions = [
...(filter === 'active' ? ['sets.active=1'] : filter === 'archived' ? ['sets.active=0']
: filter === 'assigned' ? ['sets.specialization_ability_id IS NOT NULL']
: filter === 'unassigned' ? ['sets.specialization_ability_id IS NULL']
: 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)'] : []),
...(search ? ["sets.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
];
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const args = search ? [pattern] : [];
const [summary, filtered, pendingStats, pendingTriggers, pendingSets] = await Promise.all([
const [summary, filtered, pendingStats, pendingTriggers, pendingSets, pendingAbilities, pendingSpecializations] = await Promise.all([
(await database.prepare(`SELECT COUNT(*) AS total,
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 specialization_ability_id IS NULL THEN 1 ELSE 0 END) AS unassigned,
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
FROM skill_chip_sets`)).get(),
(await database.prepare(`SELECT COUNT(*) AS total FROM skill_chip_sets sets ${where}`)).get(...args),
getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'stat', fields: ['unit', 'chipMin', 'chipMax'] }]),
getPendingEntities(user.id, [{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] }]),
getPendingEntities(user.id, [{ module: 'skillchips', action: 'save', kind: 'skillchip', fields: ['twoPieceStatId', 'twoPieceMin', 'twoPieceMax', 'primaryStatIds', 'secondaryStatIds', 'talentName', 'descriptionTemplate', 'talentEffects', 'triggerIds'] }])
getPendingEntities(user.id, [{ module: 'skillchips', action: 'save', kind: 'skillchip', fields: ['abilityId', 'twoPieceStatId', 'twoPieceMin', 'twoPieceMax', 'primaryStatIds', 'secondaryStatIds', 'talentName', 'descriptionTemplate', 'talentEffects', 'triggerIds'] }]),
getPendingEntities(user.id, [{ module: 'specializations', action: 'saveAbility', kind: 'ability', fields: ['specializationId'] }]),
getPendingEntities(user.id, [{ module: 'specializations', action: 'saveSpecialization', kind: 'specialization' }])
]);
const pagination = getPagination(url, Number(filtered?.total ?? 0));
const [sets, twoPiece, primaryOptions, secondaryOptions, talents, talentTriggers, talentEffects, stats, triggers, icons] = await Promise.all([
const [sets, twoPiece, primaryOptions, secondaryOptions, talents, talentTriggers, talentEffects, stats, triggers, abilities, specializations, abilityChipCounts, icons] = await Promise.all([
(await database.prepare(`SELECT sets.* FROM skill_chip_sets sets ${where} ORDER BY sets.active DESC,sets.name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset),
(await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')).all(),
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
@@ -76,6 +83,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(await database.prepare('SELECT * FROM skill_chip_talent_effects ORDER BY sort_order')).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')).all(),
(await database.prepare('SELECT id,name,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT id,name,specialization_id FROM specialization_abilities WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT id,name FROM specializations WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare(`SELECT specialization_ability_id,COUNT(*) AS count FROM skill_chip_sets
WHERE active=1 AND specialization_ability_id IS NOT NULL GROUP BY specialization_ability_id`)).all(),
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='skill_chip'")).all()
]);
const availableStats = [...pendingStats.map((item) => ({
@@ -84,8 +95,29 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
skill_chip_min: item.chipMin === '' ? null : Number(item.chipMin),
skill_chip_max: item.chipMax === '' ? null : Number(item.chipMax)
})), ...stats];
const pendingSetRows = filter === 'archived' ? [] : pendingSets.filter((set) => !search || String(set.name).toLocaleLowerCase().includes(search.toLocaleLowerCase())).map((set) => ({
const specializationNames = new Map([
...pendingSpecializations.map((item) => [String(item.id), String(item.name)] as const),
...specializations.map((item) => [String(item.id), String(item.name)] as const)
]);
const loadedAbilities = [
...pendingAbilities.map((ability) => ({
...ability,
name: `${ability.name} · ${specializationNames.get(String(ability.specializationId)) ?? 'PENDING SPECIALIZATION'}`,
chipCount: 0
})),
...abilities.map((ability) => ({
...ability,
name: `${ability.name} · ${specializationNames.get(String(ability.specialization_id)) ?? 'SPECIALIZATION'}`,
chipCount: Number(abilityChipCounts.find((item) => item.specialization_ability_id === ability.id)?.count ?? 0)
}))
];
const pendingSetRows = filter === 'archived' ? [] : pendingSets
.filter((set) => filter !== 'assigned' || Boolean(set.abilityId))
.filter((set) => filter !== 'unassigned' || !set.abilityId)
.filter((set) => !search || String(set.name).toLocaleLowerCase().includes(search.toLocaleLowerCase()))
.map((set) => ({
...set,
specialization_ability_id: set.abilityId,
iconUrl: '',
twoPiece: {
stat_attribute_id: set.twoPieceStatId,
@@ -121,7 +153,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
stats: availableStats,
rollStats: availableStats.filter((stat) => stat.skill_chip_min !== null && stat.skill_chip_max !== null),
triggers: [...pendingTriggers, ...triggers],
summary: { ...summary, total: Number(summary?.total ?? 0) + pendingSets.length, pending: pendingSets.length }, filter, pagination, search
abilities: loadedAbilities,
summary: {
active: Number(summary?.active ?? 0),
archived: Number(summary?.archived ?? 0),
configured: Number(summary?.configured ?? 0),
total: Number(summary?.total ?? 0) + pendingSets.length,
assigned: Number(summary?.assigned ?? 0) + pendingSets.filter((set) => Boolean(set.abilityId)).length,
unassigned: Number(summary?.unassigned ?? 0) + pendingSets.filter((set) => !set.abilityId).length,
pending: pendingSets.length
},
filter, pagination, search
};
};
@@ -134,6 +176,7 @@ export const actions: Actions = {
catch (error) { if (error instanceof GameDataIconValidationError) 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'));
@@ -144,6 +187,7 @@ export const actions: Actions = {
const triggerIds = [...new Set(form.getAll('triggerIds').map(String).filter(Boolean))];
const effects = parseEffects(form.get('talentEffects'));
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.' });
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.' });
@@ -154,6 +198,14 @@ export const actions: Actions = {
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1]));
if (placeholders.some((index) => index < 1 || index > effects.length)) return fail(400, { skillChipError: 'Description placeholders must reference configured talent effects.' });
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: '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 (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.' });
@@ -169,10 +221,11 @@ export const actions: Actions = {
const talentId = String(existingTalent?.id ?? randomUUID());
const now = new Date().toISOString();
const before = id ? await snapshot(id) : null;
const previousAbilityId = String(before?.record?.specialization_ability_id ?? '');
if (id) {
if (!await auditRecord('skill_chip_sets', id)) return fail(404, { skillChipError: 'Skill Chip set not found.' });
await (await database.prepare('UPDATE skill_chip_sets SET name=?,active=1,updated_at=? WHERE id=?')).run(name, now, id);
} else await (await database.prepare('INSERT INTO skill_chip_sets(id,name,created_at,updated_at) VALUES(?,?,?,?)')).run(setId, name, now, now);
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);
await (await database.prepare('DELETE FROM skill_chip_primary_options WHERE skill_chip_set_id=?')).run(setId);
@@ -186,6 +239,8 @@ export const actions: Actions = {
await (await database.prepare('DELETE FROM skill_chip_talent_effects WHERE talent_id=?')).run(talentId);
for (const effect of effects) 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, effect.statId, effect.minValue, effect.maxValue, effect.sortOrder);
await applyIconMutation(database, 'skill_chip', setId, iconMutation);
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) });
return { skillChipSaved: true, skillChipMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
@@ -194,7 +249,9 @@ export const actions: Actions = {
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.' });
await (await (await getDatabase()).prepare('UPDATE skill_chip_sets SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
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) });
return { skillChipArchived: true };
},
@@ -203,7 +260,17 @@ export const actions: Actions = {
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.' });
await (await (await getDatabase()).prepare('UPDATE skill_chip_sets SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
const abilityId = String(before.record.specialization_ability_id ?? '');
if (!abilityId) return fail(400, { skillChipError: 'Assign this legacy Skill Chip to an ability before restoring it.' });
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
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.' });
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) });
return { skillChipUnarchived: true };
}
+12 -5
View File
@@ -10,7 +10,7 @@
let { data, form } = $props();
type TalentEffect = { stat_attribute_id: string; min_value: number | string; max_value: number | string };
const blankTalent = () => ({ id: '', name: '', description_template: '', triggerIds: [], effects: [{ stat_attribute_id: '', min_value: '', max_value: '' }] });
const blank = () => ({ id: '', name: '', iconUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, primaryStatIds: [], secondaryStatIds: [], talent: blankTalent() });
const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, primaryStatIds: [], secondaryStatIds: [], talent: blankTalent() });
let editing = $state<any | null>(null);
let highEndCount = $state('0');
let handledResultToken = $state('');
@@ -22,6 +22,7 @@
{ 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 format = (value: number | string, statId: string) => {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return '—';
@@ -37,6 +38,7 @@
const edit = (item: any) => {
editing = {
...item,
abilityId: item.specialization_ability_id ?? '',
twoPiece: item.twoPiece ? { ...item.twoPiece } : { stat_attribute_id: '', min_value: '', max_value: '' },
primaryStatIds: [...item.primaryStatIds],
secondaryStatIds: [...item.secondaryStatIds],
@@ -66,7 +68,8 @@
<KpiFilters active={data.filter} items={[
{value:'',label:'TOTAL SETS',count:data.summary.total,description:'Show all definitions'},
{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},
{value:'configured',label:'CONFIGURED',count:data.summary.configured,description:'Both bonuses defined'},
{value:'assigned',label:'ABILITY LINKED',count:data.summary.assigned,description:'Assigned to a class ability'},
{value:'unassigned',label:'UNASSIGNED',count:data.summary.unassigned,description:'Legacy records needing an ability'},
{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'}
]}/>
@@ -77,6 +80,10 @@
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="talentEffects" value={JSON.stringify(editing.talent.effects.map((effect: TalentEffect) => ({ statId: effect.stat_attribute_id, minValue: effect.min_value, maxValue: effect.max_value })))}/>
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><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 class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Only these qualities participate in chip scaling.</small></div></div>
<section class="skill-chip-ability-assignment">
<div class="selection-head"><div><span>SPECIALIZATION ABILITY</span><small>EXCLUSIVE RELATIONSHIP · THREE ACTIVE SKILL CHIPS PER ABILITY</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 ASSIGNED` : 'REQUIRED'}</b></div>
<SearchableAssignment items={data.abilities} fieldName="abilityId" mode="single" compact subjectLabel="SPECIALIZATION ABILITY" searchPlaceholder="Search abilities or specializations…" emptySelectionLabel="NO ABILITY ASSIGNED" bind:value={editing.abilityId}/>
</section>
<section class="skill-chip-bonus">
<div class="selection-head"><div><span>2-PIECE BONUS</span><small>MINIMUM AT ZERO HIGH-END · LINEAR TO MAXIMUM AT THREE HIGH-END</small></div><b>{format(scaled(editing.twoPiece.min_value, editing.twoPiece.max_value), editing.twoPiece.stat_attribute_id)}</b></div>
@@ -98,15 +105,15 @@
</div>
</section>
{#if form?.skillChipError}<p class="error">{form.skillChipError}</p>{/if}
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE CHIP SET' : 'UNARCHIVE CHIP SET'}</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!editing.primaryStatIds.length || editing.secondaryStatIds.length < 2 || !data.rollStats.length || !data.triggers.length}>SAVE SKILL CHIP →</button></div></div>
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE CHIP SET' : 'UNARCHIVE CHIP SET'}</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!editing.abilityId || !editing.primaryStatIds.length || editing.secondaryStatIds.length < 2 || !data.rollStats.length || !data.triggers.length}>SAVE SKILL CHIP →</button></div></div>
</PendingForm>
</div>
{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table">
<div class="dashboard-row skill-chip-row table-heading"><span>SKILL CHIP</span><span>QUALITY</span><span>2-PIECE BONUS</span><span>3-PIECE TALENT</span><span>ROLL POOLS</span><span>STATE</span><span></span></div>
<div class="dashboard-row skill-chip-row table-heading"><span>SKILL CHIP</span><span>ABILITY</span><span>QUALITY</span><span>2-PIECE BONUS</span><span>3-PIECE TALENT</span><span>ROLL POOLS</span><span>STATE</span><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}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}</small></div></div><span class="skill-chip-quality-pair"><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 CONFIGURED'}</strong><small>{item.talent?.effects.length ?? 0} SCALED EFFECTS</small></div><span>{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.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 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}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id ?? ''))?.name ?? 'UNASSIGNED'}</strong><small>{ability(String(item.specialization_ability_id ?? '')) ? 'EXCLUSIVE ABILITY' : 'LEGACY RECORD'}</small></div><span class="skill-chip-quality-pair"><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 CONFIGURED'}</strong><small>{item.talent?.effects.length ?? 0} SCALED EFFECTS</small></div><span>{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.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 onclick={() => edit(item)}>EDIT</button>{/if}</div>
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first Superior and High-end Skill Chip definition.</span></div>{/each}
</div>
+16 -49
View File
@@ -15,6 +15,10 @@ import {
GameDataIconValidationError,
readIconMutation
} from '$lib/server/game-data-icons';
import {
refreshSpecializationFocusReady,
refreshSpecializationFocuses
} from '$lib/server/specialization-readiness';
const PRIMARY_ATTRIBUTES = ['Firepower', 'Toughness', 'Engineering'] as const;
const RANK_TWO_RESOURCES = new Set([
@@ -114,48 +118,6 @@ async function nodeSnapshot(database: Database, id: string) {
};
}
async function refreshFocusReady(database: Database, focusId: string) {
const focus = await (await database.prepare(
'SELECT id,active FROM specialization_focuses WHERE id=?'
)).get(focusId);
if (!focus) return { ready: false, pointTotal: 0, availablePoints: 0, abilityCount: 0 };
const [config, abilityCountRow, nodes, rankProfiles] = await Promise.all([
(await database.prepare("SELECT xp_points,shd_cache_points FROM specialization_progression_config WHERE id='default'")).get(),
(await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
JOIN specialization_abilities ability ON ability.id=link.ability_id
WHERE link.focus_id=? AND ability.active=1`)).get(focusId),
(await database.prepare(
'SELECT row_number,max_rank FROM specialization_tree_nodes WHERE focus_id=? AND active=1 ORDER BY row_number,column_number'
)).all(focusId),
(await database.prepare('SELECT rank,point_cost FROM specialization_rank_cost_profiles ORDER BY rank')).all()
]);
const pointForRank = new Map(rankProfiles.map((profile) => [Number(profile.rank), Number(profile.point_cost)]));
const pointTotal = nodes.reduce((sum, node) => {
for (let rank = 1; rank <= Number(node.max_rank); rank += 1) sum += pointForRank.get(rank) ?? rank;
return sum;
}, 0);
const rows = [...new Set(nodes.map((node) => Number(node.row_number)))];
const contiguousRows = rows.every((row, index) => row === index + 1);
const abilityCount = Number(abilityCountRow?.count ?? 0);
const availablePoints = Number(config?.xp_points ?? 30) + Number(config?.shd_cache_points ?? 19);
const ready = Number(focus.active) === 1
&& abilityCount === 3
&& nodes.length > 0
&& contiguousRows
&& pointTotal === availablePoints;
await (await database.prepare(
'UPDATE specialization_focuses SET builder_ready=?,updated_at=? WHERE id=?'
)).run(ready ? 1 : 0, new Date().toISOString(), focusId);
return { ready, pointTotal, availablePoints, abilityCount };
}
async function refreshSpecializationFocuses(database: Database, specializationId: string) {
const focuses = await (await database.prepare(
'SELECT id FROM specialization_focuses WHERE specialization_id=?'
)).all(specializationId);
for (const focus of focuses) await refreshFocusReady(database, String(focus.id));
}
async function validateAttributeIds(database: Database, ids: string[]) {
const unique = [...new Set(ids.filter(Boolean))];
if (!unique.length) return true;
@@ -218,7 +180,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const pendingOnPage = pagination.page === 1 ? pendingSpecializations.slice(0, pagination.perPage) : [];
const databaseOffset = Math.max(0, pagination.offset - pendingSpecializations.length);
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
const [specializations, focuses, abilities, abilityEffects, focusAbilities, nodes, nodeRanks, nodeEffects, nodeCosts, resources, rankProfiles, progression, attributes, icons, summary] = await Promise.all([
const [specializations, focuses, abilities, abilityEffects, focusAbilities, nodes, nodeRanks, nodeEffects, nodeCosts, resources, rankProfiles, progression, attributes, chipCounts, icons, summary] = await Promise.all([
databaseLimit ? (await database.prepare(
`SELECT * FROM specializations ${where} ORDER BY active DESC,name COLLATE NOCASE LIMIT ? OFFSET ?`
)).all(...args, databaseLimit, databaseOffset) : [],
@@ -234,6 +196,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(await database.prepare('SELECT * FROM specialization_rank_cost_profiles ORDER BY rank')).all(),
(await database.prepare("SELECT * FROM specialization_progression_config WHERE id='default'")).get(),
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare(`SELECT specialization_ability_id,COUNT(*) AS count
FROM skill_chip_sets WHERE active=1 AND specialization_ability_id IS NOT NULL
GROUP BY specialization_ability_id`)).all(),
(await database.prepare(`SELECT entity_type,entity_id,updated_at FROM game_data_icons
WHERE entity_type IN ('specialization','specialization_ability','specialization_node')`)).all(),
(await database.prepare(`SELECT
@@ -282,11 +247,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
effects: parseJsonArray<AbilityEffectInput>(item.effectsJson),
active: 1,
pending: true,
skillChipCount: 0,
iconUrl: ''
})),
...abilities.map((ability) => ({
...ability,
effects: abilityEffects.filter((effect) => effect.ability_id === ability.id),
skillChipCount: Number(chipCounts.find((item) => item.specialization_ability_id === ability.id)?.count ?? 0),
iconUrl: iconUrl('specialization_ability', String(ability.id))
}))
];
@@ -494,7 +461,7 @@ export const actions: Actions = {
VALUES(?,?,?,?,?,?,?,?)`))
.run(focusId, specializationId, name, description || null, primaryAttribute, displayOrder, now, now);
}
await refreshFocusReady(database, focusId);
await refreshSpecializationFocusReady(database, focusId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
@@ -647,7 +614,7 @@ export const actions: Actions = {
await database.exec('ROLLBACK');
throw error;
}
const readiness = await refreshFocusReady(database, focusId);
const readiness = await refreshSpecializationFocusReady(database, focusId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
@@ -802,7 +769,7 @@ export const actions: Actions = {
throw error;
}
await applyIconMutation(database, 'specialization_node', nodeId, iconMutation);
const readiness = await refreshFocusReady(database, focusId);
const readiness = await refreshSpecializationFocusReady(database, focusId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
@@ -852,7 +819,7 @@ export const actions: Actions = {
const database = await getDatabase();
const before = await focusSnapshot(database, id);
await (await database.prepare('UPDATE specialization_focuses SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
const readiness = await refreshFocusReady(database, id);
const readiness = await refreshSpecializationFocusReady(database, id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_focus.unarchived', entityId: id, before, after: { ...(await focusSnapshot(database, id)), readiness } });
return { focusUnarchived: true, resultToken: randomUUID() };
},
@@ -888,7 +855,7 @@ export const actions: Actions = {
const node = await (await database.prepare('SELECT focus_id FROM specialization_tree_nodes WHERE id=?')).get(id);
const before = await nodeSnapshot(database, id);
await (await database.prepare('UPDATE specialization_tree_nodes SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
const readiness = node ? await refreshFocusReady(database, String(node.focus_id)) : null;
const readiness = node ? await refreshSpecializationFocusReady(database, String(node.focus_id)) : null;
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_node.archived', entityId: id, before, after: { ...(await nodeSnapshot(database, id)), readiness } });
return { nodeArchived: true, resultToken: randomUUID() };
},
@@ -900,7 +867,7 @@ export const actions: Actions = {
const node = await (await database.prepare('SELECT focus_id FROM specialization_tree_nodes WHERE id=?')).get(id);
const before = await nodeSnapshot(database, id);
await (await database.prepare('UPDATE specialization_tree_nodes SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
const readiness = node ? await refreshFocusReady(database, String(node.focus_id)) : null;
const readiness = node ? await refreshSpecializationFocusReady(database, String(node.focus_id)) : null;
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_node.unarchived', entityId: id, before, after: { ...(await nodeSnapshot(database, id)), readiness } });
return { nodeUnarchived: true, resultToken: randomUUID() };
}
+10 -2
View File
@@ -101,6 +101,7 @@
const specializationById = (id: string) => data.specializations.find((item: any) => item.id === id);
const focusById = (id: string) => data.focuses.find((item: any) => item.id === id);
const abilityById = (id: string) => data.abilities.find((item: any) => item.id === id);
const abilityChipCount = (ability: any) => Number(ability?.skillChipCount ?? 0);
const focusList = (specializationId: string) => data.focuses.filter((focus: any) => focus.specialization_id === specializationId);
const abilityList = (specializationId: string) => data.abilities.filter((ability: any) =>
ability.specialization_id === specializationId && Number(ability.active) === 1
@@ -543,7 +544,14 @@
{@const ability = assignedAbility(slot)}
<article class:tree-linked={slot !== 2}>
<ItemIcon src={ability?.iconUrl} alt={ability?.name ?? `Ability slot ${slot}`}/>
<div><span>ABILITY {slot} · {slot === 2 ? 'COMBAT SKILLS' : 'TREE + COMBAT SKILLS'}</span><strong>{ability?.name ?? 'NOT ASSIGNED'}</strong><p>{ability?.description ?? 'Assign an active class ability to complete this focus.'}</p></div>
<div>
<span>ABILITY {slot} · {slot === 2 ? 'COMBAT SKILLS' : 'TREE + COMBAT SKILLS'}</span>
<strong>{ability?.name ?? 'NOT ASSIGNED'}</strong>
{#if ability}
<small class:ready={abilityChipCount(ability) === 3} class="ability-chip-readiness">{abilityChipCount(ability)} / 3 SKILL CHIPS · {abilityChipCount(ability) === 3 ? 'READY' : 'INCOMPLETE'}</small>
{/if}
<p>{ability?.description ?? 'Assign an active class ability to complete this focus.'}</p>
</div>
{#if ability}<button class="specialization-mutation" onclick={() => editAbility(ability)}>EDIT</button>{/if}
</article>
{/each}
@@ -558,7 +566,7 @@
{#each data.abilities.filter((ability: any) => ability.specialization_id === selectedSpecialization.id) as ability}
<article class:archived={Number(ability.active) === 0}>
<ItemIcon src={ability.iconUrl} alt={ability.name}/>
<div><strong>{ability.name}</strong><small>{Number(ability.active) === 1 ? 'ACTIVE CLASS ABILITY' : 'ARCHIVED CLASS ABILITY'}</small></div>
<div><strong>{ability.name}</strong><small>{Number(ability.active) === 1 ? `${abilityChipCount(ability)} / 3 SKILL CHIPS` : 'ARCHIVED CLASS ABILITY'}</small></div>
<button class="secondary specialization-mutation" onclick={() => editAbility(ability)}>{Number(ability.active) === 1 ? 'EDIT' : 'REVIEW + RESTORE'} →</button>
</article>
{:else}