feat: refine builder skill chip flows
This commit is contained in:
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [] }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user