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
@@ -98,6 +98,14 @@ async function specializationProposalSnapshot(database: any, proposal: any) {
'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order',
)
).all(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),
};
}
if (proposal.action === 'saveCombatEffect') {
+2 -2
View File
@@ -124,7 +124,7 @@
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)}/>
<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>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>
<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>
<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>
@@ -169,7 +169,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>EXCLUSIVE 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 ? 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>
{#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>
@@ -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,
+34 -3
View File
@@ -32,6 +32,7 @@
description: string;
durationSeconds: number | string;
cooldownSeconds: number | string;
skillChipCompatibleAbilityId: string;
attack: {
enabled: boolean;
baseDamage: number | string;
@@ -97,7 +98,7 @@
id: '', specializationId, name: '', description: '', primaryAttribute: 'Firepower', displayOrder: 0, active: 1
});
const blankAbility = (specializationId = ''): AbilityEditor => ({
id: '', specializationId, name: '', description: '', durationSeconds: '', cooldownSeconds: '',
id: '', specializationId, name: '', description: '', durationSeconds: '', cooldownSeconds: '', skillChipCompatibleAbilityId: '',
attack: {
enabled: false, baseDamage: '', damageType: 'Blast', delivery: 'direct',
hitsPerActivation: 1, tickIntervalSeconds: '', damageDurationSeconds: '',
@@ -161,10 +162,25 @@
const abilityById = (id: string) => data.abilities.find((item: any) => item.id === id);
const combatEffectById = (id: string) => data.combatEffects.find((item: any) => item.id === id);
const abilityChipCount = (ability: any) => Number(ability?.skillChipCount ?? 0);
const abilityEligibleChipCount = (ability: any) => abilityChipCount(ability)
+ abilityChipCount(abilityById(String(ability?.skillChipCompatibleAbilityId ?? '')));
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
);
const skillChipCompatibilityOptions = (editor: AbilityEditor) => abilityList(editor.specializationId)
.filter((ability: any) => ability.id !== editor.id)
.map((ability: any) => ({
...ability,
detail: `${abilityChipCount(ability)} OWN CHIP SETS`,
disabled: Boolean(
ability.skillChipCompatibleAbilityId
&& ability.skillChipCompatibleAbilityId !== editor.id
),
disabledReason: ability.skillChipCompatibleAbilityId && ability.skillChipCompatibleAbilityId !== editor.id
? 'ALREADY SHARES ANOTHER POOL'
: ''
}));
const combatEffectList = (specializationId: string) => data.combatEffects.filter((effect: any) =>
effect.specialization_id === specializationId && Number(effect.active) === 1
);
@@ -234,6 +250,7 @@
description: item.description,
durationSeconds: item.duration_seconds ?? '',
cooldownSeconds: item.cooldown_seconds ?? '',
skillChipCompatibleAbilityId: item.skillChipCompatibleAbilityId ?? '',
attack: item.attack ? {
enabled: true,
baseDamage: item.attack.base_damage,
@@ -499,6 +516,20 @@
<label class="field">COOLDOWN · SECONDS<input name="cooldownSeconds" type="number" min="0.01" step="any" bind:value={abilityEditing.cooldownSeconds} placeholder="OPTIONAL"/></label>
<div><span>AVAILABILITY</span><strong>IMMEDIATE · NO POINT OR MATERIAL COST</strong><small>Assigned ability slots 1 and 3 are mirrored into the top-left of the focus tree.</small></div>
</div>
<div class="specialization-skill-chip-sharing">
<div class="selection-head"><div><span>SHARED SKILL CHIP POOL</span><small>OPTIONAL · SAME SPECIALIZATION · 3 EQUIPPED SLOTS, UP TO 6 ELIGIBLE SETS</small></div><b>{abilityEditing.skillChipCompatibleAbilityId ? 'SHARED' : 'ISOLATED'}</b></div>
<SearchableAssignment
items={skillChipCompatibilityOptions(abilityEditing)}
mode="single"
compact
fieldName="skillChipCompatibleAbilityId"
subjectLabel="COMPATIBLE ABILITY"
searchPlaceholder="Search this specialization…"
emptySelectionLabel="NO SHARED SKILL CHIP POOL"
bind:value={abilityEditing.skillChipCompatibleAbilityId}
/>
<p class="configuration-note">Link only abilities that can equip each other's Skill Chips in game. Unlinked abilities remain isolated.</p>
</div>
<div class="specialization-attack-profile">
<div class="selection-head"><div><span>INTRINSIC ATTACK PROFILE</span><small>UNMODIFIED DAMAGE BEFORE ENGINEERING, ATTRIBUTES, TALENTS, AND TREE UNLOCKS</small></div><b>{abilityEditing.attack.enabled ? 'DAMAGING ABILITY' : 'NO DIRECT DAMAGE'}</b></div>
<SegmentedChoice legend="DAMAGE MODEL" name="ability-damage-model" options={[{value:'disabled',label:'NO DAMAGE'},{value:'enabled',label:'MODELS DAMAGE'}]} value={abilityEditing.attack.enabled ? 'enabled' : 'disabled'} onchange={(values) => setAbilityAttackEnabled(values[0] === 'enabled')}/>
@@ -728,7 +759,7 @@
<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>
<small class:ready={abilityChipCount(ability) === 3} class="ability-chip-readiness">{abilityChipCount(ability)} OWNED · {abilityEligibleChipCount(ability)} ELIGIBLE · {abilityChipCount(ability) === 3 ? 'READY' : 'INCOMPLETE'}</small>
{/if}
<p>{ability?.description ?? 'Assign an active class ability to complete this focus.'}</p>
</div>
@@ -746,7 +777,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 ? `${abilityChipCount(ability)} / 3 SKILL CHIPS` : 'ARCHIVED CLASS ABILITY'}</small></div>
<div><strong>{ability.name}</strong><small>{Number(ability.active) === 1 ? `${abilityChipCount(ability)} OWNED · ${abilityEligibleChipCount(ability)} ELIGIBLE` : 'ARCHIVED CLASS ABILITY'}</small></div>
<button class="secondary specialization-mutation" onclick={() => editAbility(ability)}>{Number(ability.active) === 1 ? 'EDIT' : 'REVIEW + RESTORE'} →</button>
</article>
{:else}
@@ -0,0 +1,227 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => {
const links: Array<{ ability_id: string; compatible_ability_id: string }> =
[];
return {
links,
auditRecord: vi.fn(async () => ({
id: 'ability-1',
specialization_id: 'specialization-1',
name: 'Tactical Link',
})),
recordIsacAudit: vi.fn(),
database: {
exec: vi.fn(),
prepare: vi.fn(),
},
};
});
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
}));
vi.mock('$lib/server/auth/authorization', () => ({
requireDataView: vi.fn(async (user) => user),
requirePermission: vi.fn(async (user) => user),
}));
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/data-moderation', () => ({
deserializeProposal: vi.fn(),
getPendingEntities: vi.fn(async () => []),
}));
vi.mock('$lib/server/game-data-icons', () => ({
applyIconMutation: vi.fn(),
gameDataIconStorageDirectory: vi.fn(),
gameDataIconMetadata: vi.fn(async () => null),
gameDataIconUrl: vi.fn(() => ''),
GameDataIconValidationError: class extends Error {},
readGameDataIcon: vi.fn(async () => null),
readIconMutation: vi.fn(async () => ({})),
}));
vi.mock('$lib/server/managed-images', () => ({
applyGameDataRender: vi.fn(),
ManagedImageValidationError: class extends Error {},
managedImageUrl: vi.fn(() => ''),
processManagedImage: vi.fn(),
readGameDataRenderMutation: vi.fn(async () => ({})),
}));
vi.mock('$lib/server/specialization-readiness', () => ({
refreshSpecializationFocusReady: vi.fn(),
refreshSpecializationFocuses: vi.fn(),
}));
vi.mock('$lib/server/conditional-effects', () => ({
conditionalRuleSnapshot: vi.fn(),
loadConditionalRuleDraft: vi.fn(),
parseConditionalRulePayload: vi.fn(),
saveConditionalRuleInTransaction: vi.fn(),
}));
import { actions } from './+page.server';
const saveRequest = (compatibleAbilityId: string) => {
const form = new FormData();
form.set('id', 'ability-1');
form.set('specializationId', 'specialization-1');
form.set('skillChipCompatibleAbilityId', compatibleAbilityId);
form.set('name', 'Tactical Link');
form.set('description', 'Deploy a tactical support link.');
form.set('effectsJson', '[]');
form.set('attackJson', '{"enabled":false}');
return new Request('http://localhost/isac/specializations?/saveAbility', {
method: 'POST',
body: form,
});
};
const runSave = (compatibleAbilityId: string) =>
actions.saveAbility?.({
cookies: {} as never,
request: saveRequest(compatibleAbilityId),
} as never);
const runArchive = () => {
const form = new FormData();
form.set('id', 'ability-1');
return actions.archiveAbility?.({
cookies: {} as never,
request: new Request(
'http://localhost/isac/specializations?/archiveAbility',
{ method: 'POST', body: form },
),
} as never);
};
describe('Specialization ability Skill Chip sharing', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.links.length = 0;
mocks.database.prepare.mockImplementation(async (sql: string) => ({
get: vi.fn(async (...args: unknown[]) => {
if (
sql.includes('SELECT specialization_id FROM specialization_abilities')
)
return { specialization_id: 'specialization-1' };
if (sql.includes('SELECT id FROM specializations'))
return { id: 'specialization-1' };
if (
sql.includes(
'SELECT id,specialization_id FROM specialization_abilities',
)
)
return {
id: String(args[0]),
specialization_id:
args[0] === 'cross-specialization-ability'
? 'specialization-2'
: 'specialization-1',
};
if (sql.includes('FROM specialization_ability_skill_chip_links'))
return null;
if (
sql.includes('FROM specialization_abilities WHERE specialization_id=')
)
return null;
if (sql.includes('FROM specialization_ability_attacks')) return null;
if (sql.includes('FROM managed_images')) return null;
return null;
}),
all: vi.fn(async () => {
if (sql.includes('FROM specialization_ability_skill_chip_links'))
return [...mocks.links];
return [];
}),
run: vi.fn(async (...args: unknown[]) => {
if (sql.includes('DELETE FROM specialization_ability_skill_chip_links'))
mocks.links.length = 0;
if (sql.includes('INSERT INTO specialization_ability_skill_chip_links'))
mocks.links.push({
ability_id: String(args[0]),
compatible_ability_id: String(args[1]),
});
}),
}));
});
it('saves and audits an explicit same-specialization ability pair', async () => {
const result = await runSave('ability-2');
expect(result).toMatchObject({
abilitySaved: true,
abilityMode: 'edit',
resultToken: expect.any(String),
});
expect(mocks.links).toEqual([
{ ability_id: 'ability-1', compatible_ability_id: 'ability-2' },
]);
expect(mocks.recordIsacAudit).toHaveBeenCalledWith({
actorId: 'operator-1',
category: 'game_data',
action: 'specialization_ability.updated',
entityId: 'ability-1',
before: expect.objectContaining({ record: expect.any(Object) }),
after: expect.objectContaining({
skillChipLinks: [
{ ability_id: 'ability-1', compatible_ability_id: 'ability-2' },
],
}),
});
});
it('rejects cross-specialization sharing without writing or auditing', async () => {
const result = await runSave('cross-specialization-ability');
expect(result).toMatchObject({
status: 400,
data: {
abilityError:
'Choose an active Skill Chip-compatible ability from the same specialization.',
},
});
expect(mocks.database.exec).not.toHaveBeenCalled();
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
});
it('clears the relationship when either ability is archived', async () => {
mocks.links.push({
ability_id: 'ability-1',
compatible_ability_id: 'ability-2',
});
const result = await runArchive();
expect(result).toMatchObject({
abilityArchived: true,
resultToken: expect.any(String),
});
expect(mocks.links).toEqual([]);
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
expect.objectContaining({
actorId: 'operator-1',
category: 'game_data',
action: 'specialization_ability.archived',
entityId: 'ability-1',
before: expect.objectContaining({
skillChipLinks: [
{ ability_id: 'ability-1', compatible_ability_id: 'ability-2' },
],
}),
after: expect.objectContaining({ skillChipLinks: [] }),
}),
);
});
});