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
+11
View File
@@ -1422,6 +1422,17 @@ CREATE TABLE IF NOT EXISTS specialization_ability_attacks (
CHECK(charge_policy<>'none' OR charge_count=1)
);
CREATE INDEX IF NOT EXISTS specialization_abilities_specialization_idx ON specialization_abilities(specialization_id,name);
CREATE TABLE IF NOT EXISTS specialization_ability_skill_chip_links (
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
compatible_ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
PRIMARY KEY(ability_id,compatible_ability_id),
CHECK(ability_id<compatible_ability_id)
);
CREATE INDEX IF NOT EXISTS specialization_ability_skill_chip_links_compatible_idx
ON specialization_ability_skill_chip_links(compatible_ability_id,ability_id);
-- db-migrate.mjs installs insert/update triggers for active same-specialization
-- endpoints and one-pair-per-ability enforcement. Trigger bodies cannot live in
-- this semicolon-delimited schema file.
CREATE TABLE IF NOT EXISTS specialization_ability_effects (
id TEXT PRIMARY KEY,
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
+25 -6
View File
@@ -358,6 +358,13 @@ compatible configuration controls. Large Talent pools show the selected Talent
at rest and open their search grid through an explicit Change action. Do not
display a prototype option that the current loadout schema cannot save.
Builder cells use one leading icon track. Specializations, abilities, Skill
Chips, and OS Protocols use registered artwork; weapons and gear use static
category or slot fallbacks when no item artwork exists. Skill Chip slots keep
the icon beside the set name and quality so empty and configured slots share one
scan path. Visible builder labels stay short; full validation text remains
available through the cell's accessible name or title.
Each equipped gear or weapon Talent exposes two adjacent icon choices for Tier I
and Tier II. Both choices reuse the Talent artwork; Tier II applies a restrained
warm recolor so players can distinguish the pair before reading the labels.
@@ -2294,6 +2301,13 @@ active abilities, each ability has exactly three active Skill Chip definitions,
tree rows are contiguous, at least one paid node exists, and the tree uses the
configured available point total.
The ability editor may pair the open ability with one active ability from the
same specialization for Skill Chip sharing. Present this as an explicit optional
relationship, exclude the current ability, and disable abilities that already
belong to another pair. Do not infer compatibility from names or Focus
membership. Saving either side updates the shared pair and includes it in the
ability audit snapshot. Archiving either ability clears the pair.
Compose focus identity from uploaded specialization artwork and the canonical
Firepower, Toughness, or Engineering icon. Keep the specialization art primary
and place the attribute mark in a smaller corner badge. Mirror ability slots 1
@@ -2641,12 +2655,17 @@ The builder presents Specialization and Focus as the first loadout decision. The
Focus sets the primary Attribute, progression tree, and three ordered abilities.
Its identity combines Specialization artwork and the primary Attribute mark.
Each ability panel contains three fixed Skill Chip positions. A chip picker
shows definitions assigned to that ability and excludes definitions assigned
elsewhere. Skill Chips have no item-level control. The editor uses the shared
quality profiles for direct rolls. It labels the four ability-scoped primary
values `NOT YET DEFINED` and excludes them from current Agent and combat totals.
Secondary capacity is Standard zero, Specialized one, Superior two, and High-end
two; each equipped chip uses distinct selections from the shared pool.
shows the ability's three definitions. When ISAC explicitly pairs that ability
with one compatible ability, the picker also shows the partner's three
definitions and labels shared entries. The relationship is bidirectional and
does not apply to other abilities in the same specialization. Players still
equip three chips in each ability panel. Skill Chips have no item-level control.
The editor uses the shared quality profiles for direct rolls. It labels the four
ability-scoped primary values `NOT YET DEFINED` and excludes them from current
Agent and combat totals. Shared-chip primary values and 3-piece Talent outcomes
target the ability represented by the panel. Secondary capacity is Standard
zero, Specialized one, Superior two, and High-end two; each equipped chip uses
distinct selections from the shared pool.
The focus tree groups nodes by row and uses segmented rank controls. Disabled
ranks remain visible with their lock state. The builder permits a row after the
+18
View File
@@ -553,6 +553,24 @@ builder-ready only when each of its three assigned abilities has all three
active Skill Chip definitions. Skill Chip mutations refresh readiness for
focuses that reference both the previous and resulting ability.
`specialization_ability_skill_chip_links` may pair two active abilities from the
same specialization. Operators create each pair explicitly; the application does
not infer compatibility from ability names, focus membership, or shared
specialization. The pair is bidirectional, and an ability may belong to only one
pair. This permits compatible abilities such as two Tactical Link variants to
share definitions without making unrelated pairs such as two Smart Cover
variants compatible. Archiving either ability removes the pair; restoring an
ability does not recreate it.
A linked ability can equip its own three definitions or its partner's three
definitions, so each of its three Builder positions offers at most six choices.
The loadout still equips three Skill Chips per ability and nine per focus.
Ability-scoped primary values and 3-piece Talent outcomes bind to the ability
that owns the equipped position, not the definition's original owner. Two-piece
and 3-piece thresholds remain local to one equipped ability and never combine
matching definitions across ability panels. ISAC audit snapshots and game-data
transfers include the compatibility pair.
Every equipped quality counts toward the two-piece threshold. After the loadout
meets that threshold, only equipped High-end chips increase the interpolation
step. For `h` High-end chips, where `h` is zero through three, a configured
+38
View File
@@ -769,6 +769,44 @@ for (const statement of statements) {
await database.exec(statement);
}
await database.exec(`CREATE TRIGGER IF NOT EXISTS specialization_ability_skill_chip_links_validate_insert
BEFORE INSERT ON specialization_ability_skill_chip_links
BEGIN
SELECT CASE WHEN EXISTS (
SELECT 1 FROM specialization_ability_skill_chip_links link
WHERE link.ability_id IN (NEW.ability_id,NEW.compatible_ability_id)
OR link.compatible_ability_id IN (NEW.ability_id,NEW.compatible_ability_id)
) THEN RAISE(ABORT,'Each ability can share one Skill Chip pool') END;
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM specialization_abilities ability
JOIN specialization_abilities compatible
ON compatible.id=NEW.compatible_ability_id
WHERE ability.id=NEW.ability_id
AND ability.active=1
AND compatible.active=1
AND ability.specialization_id=compatible.specialization_id
) THEN RAISE(ABORT,'Skill Chip sharing requires active abilities from one specialization') END;
END`);
await database.exec(`CREATE TRIGGER IF NOT EXISTS specialization_ability_skill_chip_links_validate_update
BEFORE UPDATE ON specialization_ability_skill_chip_links
BEGIN
SELECT CASE WHEN EXISTS (
SELECT 1 FROM specialization_ability_skill_chip_links link
WHERE link.rowid<>OLD.rowid
AND (link.ability_id IN (NEW.ability_id,NEW.compatible_ability_id)
OR link.compatible_ability_id IN (NEW.ability_id,NEW.compatible_ability_id))
) THEN RAISE(ABORT,'Each ability can share one Skill Chip pool') END;
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM specialization_abilities ability
JOIN specialization_abilities compatible
ON compatible.id=NEW.compatible_ability_id
WHERE ability.id=NEW.ability_id
AND ability.active=1
AND compatible.active=1
AND ability.specialization_id=compatible.specialization_id
) THEN RAISE(ABORT,'Skill Chip sharing requires active abilities from one specialization') END;
END`);
const articleRevisionColumns = await (
await database.prepare("PRAGMA table_info('article_revisions')")
).all();
+6 -2
View File
@@ -2,7 +2,11 @@ import { connect } from '@tursodatabase/database';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, dirname, join, resolve } from 'node:path';
import process from 'node:process';
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs';
import {
ISAC_DATA_FORMAT_VERSION,
ISAC_DATA_TABLES,
quoteIdentifier,
} from './isac-data-tables.mjs';
const databasePath = resolve(
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
@@ -67,7 +71,7 @@ for (const row of [
const snapshot = {
format: 'shd-build-isac-data',
version: 4,
version: ISAC_DATA_FORMAT_VERSION,
createdAt: new Date().toISOString(),
sourceDatabase: basename(databasePath),
totalRows,
+87 -2
View File
@@ -3,7 +3,11 @@ import { spawnSync } from 'node:child_process';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, join, resolve } from 'node:path';
import process from 'node:process';
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs';
import {
ISAC_DATA_FORMAT_VERSION,
ISAC_DATA_TABLES,
quoteIdentifier,
} from './isac-data-tables.mjs';
const snapshotArgument = process.argv
.slice(2)
@@ -22,7 +26,7 @@ const snapshotPath = resolve(snapshotArgument);
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8'));
if (
snapshot?.format !== 'shd-build-isac-data' ||
snapshot?.version !== 4 ||
snapshot?.version !== ISAC_DATA_FORMAT_VERSION ||
!Array.isArray(snapshot.tables) ||
!Array.isArray(snapshot.icons)
) {
@@ -39,6 +43,87 @@ if (
'The ISAC snapshot table manifest is incomplete or out of order.',
);
}
const abilityRows = snapshot.tables.find(
(table) => table.name === 'specialization_abilities',
).rows;
const abilitiesById = new Map(
abilityRows.map((row) => [String(row.id ?? ''), row]),
);
const skillChipRows = snapshot.tables.find(
(table) => table.name === 'skill_chip_sets',
).rows;
const activeSkillChipCounts = new Map();
for (const row of skillChipRows) {
const abilityId = String(row.specialization_ability_id ?? '');
if (!abilityId || !abilitiesById.has(abilityId)) {
throw new Error(
`The ISAC snapshot contains a Skill Chip definition with a missing ability reference "${abilityId}".`,
);
}
if (Number(row.active) !== 1) continue;
const count = (activeSkillChipCounts.get(abilityId) ?? 0) + 1;
if (count > 3) {
throw new Error(
`The ISAC snapshot assigns more than three active Skill Chip definitions to ability "${abilityId}".`,
);
}
activeSkillChipCounts.set(abilityId, count);
}
const skillChipLinks = snapshot.tables.find(
(table) => table.name === 'specialization_ability_skill_chip_links',
).rows;
const linkedAbilityIds = new Set();
for (const row of skillChipLinks) {
const abilityId = row.ability_id;
const compatibleAbilityId = row.compatible_ability_id;
if (
typeof abilityId !== 'string' ||
!abilityId ||
typeof compatibleAbilityId !== 'string' ||
!compatibleAbilityId
) {
throw new Error(
'The ISAC snapshot contains a Skill Chip sharing link with malformed ability references.',
);
}
if (abilityId === compatibleAbilityId) {
throw new Error(
`The ISAC snapshot contains a self-referencing Skill Chip sharing link for ability "${abilityId}".`,
);
}
if (abilityId >= compatibleAbilityId) {
throw new Error(
`The ISAC snapshot contains a Skill Chip sharing link that is not in canonical ability order: "${abilityId}", "${compatibleAbilityId}".`,
);
}
const ability = abilitiesById.get(abilityId);
const compatibleAbility = abilitiesById.get(compatibleAbilityId);
if (!ability || !compatibleAbility) {
throw new Error(
`The ISAC snapshot contains a Skill Chip sharing link with a missing ability reference: "${abilityId}", "${compatibleAbilityId}".`,
);
}
if (Number(ability.active) !== 1 || Number(compatibleAbility.active) !== 1) {
throw new Error(
`The ISAC snapshot contains a Skill Chip sharing link with an inactive ability: "${abilityId}", "${compatibleAbilityId}".`,
);
}
if (ability.specialization_id !== compatibleAbility.specialization_id) {
throw new Error(
`The ISAC snapshot contains a cross-specialization Skill Chip sharing link: "${abilityId}", "${compatibleAbilityId}".`,
);
}
if (
linkedAbilityIds.has(abilityId) ||
linkedAbilityIds.has(compatibleAbilityId)
) {
throw new Error(
'The ISAC snapshot contains more than one Skill Chip sharing link touching an ability.',
);
}
linkedAbilityIds.add(abilityId);
linkedAbilityIds.add(compatibleAbilityId);
}
for (const kind of ['weapon', 'gear']) {
const options = snapshot.tables.find(
(table) => table.name === `${kind}_mod_secondary_options`,
+3
View File
@@ -57,6 +57,7 @@ export const ISAC_DATA_TABLES = [
'specialization_combat_effects',
'specialization_focuses',
'specialization_abilities',
'specialization_ability_skill_chip_links',
'specialization_ability_attacks',
'specialization_ability_effects',
'specialization_focus_abilities',
@@ -75,5 +76,7 @@ export const ISAC_DATA_TABLES = [
'managed_images',
];
export const ISAC_DATA_FORMAT_VERSION = 5;
export const quoteIdentifier = (identifier) =>
`"${String(identifier).replaceAll('"', '""')}"`;
+63 -5
View File
@@ -9505,6 +9505,10 @@ a.secondary:active {
width: 38px;
height: 38px;
}
.builder-focus-abilities .builder-ability-icon {
width: 34px;
height: 34px;
}
.builder-focus-abilities span {
min-width: 0;
display: grid;
@@ -9531,6 +9535,22 @@ a.secondary:active {
grid-column: 1/-1;
width: max-content;
}
.builder-specialization-identity .focus-composite-icon {
width: 40px;
height: 40px;
flex: 0 0 40px;
}
.builder-specialization-identity .focus-composite-icon > .item-icon {
width: 36px;
height: 36px;
}
.builder-specialization-identity .focus-composite-icon > .game-icon {
right: -1px;
bottom: -1px;
width: 17px;
height: 17px;
padding: 2px;
}
@container (max-width: 700px) {
.builder-editor-columns,
.builder-specialization-layout {
@@ -12277,11 +12297,11 @@ a.secondary:active {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.builder-specialization-identity.ability-identity {
grid-template-columns: 54px minmax(0, 1fr) auto;
grid-template-columns: 46px minmax(0, 1fr) auto;
}
.builder-specialization-identity.ability-identity > .item-icon {
width: 50px;
height: 50px;
width: 42px;
height: 42px;
}
.shared-build-guide {
margin-top: 10px;
@@ -15552,6 +15572,17 @@ a.secondary:active {
border-left: 2px solid var(--shd-info);
background: var(--shd-model-surface);
}
.specialization-skill-chip-sharing {
display: grid;
gap: var(--shd-gap-sm);
margin-top: var(--shd-gap-md);
padding: var(--shd-gap-md);
border: 1px solid var(--shd-border);
background: var(--shd-surface);
}
.specialization-skill-chip-sharing .assignment-picker {
margin: 0;
}
.specialization-attack-profile > .segmented-choice {
margin: 0;
}
@@ -15749,7 +15780,7 @@ a.secondary:active {
min-width: 0;
min-height: 54px;
display: grid;
grid-template-columns: 70px minmax(0, 1fr);
grid-template-columns: 36px minmax(0, 1fr);
grid-template-rows: auto auto;
gap: 1px 8px;
padding: 7px;
@@ -15763,8 +15794,13 @@ a.secondary:active {
.builder-ability-chip-panels > section > div > button:focus-visible {
border-color: var(--shd-orange);
}
.builder-ability-chip-panels button small {
.builder-ability-chip-panels button > .builder-chip-icon {
grid-row: 1/3;
width: 32px;
height: 32px;
object-fit: contain;
}
.builder-ability-chip-panels button small {
color: var(--shd-orange);
font-size: var(--shd-type-telemetry);
font-weight: 900;
@@ -15780,6 +15816,28 @@ a.secondary:active {
color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry);
}
.builder-ability-chip-panels button > span {
display: grid;
gap: 2px;
overflow: hidden;
white-space: normal;
}
.builder-ability-chip-panels button > span > strong,
.builder-ability-chip-panels button > span > em {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.builder-ability-chip-panels button em {
display: block;
color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry);
font-style: normal;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.builder-specialization-telemetry {
display: flex;
flex-wrap: wrap;
+60 -2
View File
@@ -207,7 +207,7 @@ describe('summarizeLoadout', () => {
});
expect(summary.equippedTalents).toEqual([
expect.objectContaining({
key: 'chips:chip-set',
key: 'chips:chip-set:ability',
tier: 1,
abilityId: 'ability',
description: 'Gain 25% Skill Damage.',
@@ -217,6 +217,7 @@ describe('summarizeLoadout', () => {
it('keeps Skill Chip primaries ability-scoped while adding secondaries to Agent stats', () => {
const draft = createDraft();
draft.specialization.focusId = 'focus-1';
draft.chips[0] = {
setId: 'chip-set',
quality: 'Superior',
@@ -239,11 +240,24 @@ describe('summarizeLoadout', () => {
{
id: 'chip-set',
name: 'Signal',
specialization_ability_id: 'ability-1',
specialization_ability_id: 'ability-2',
compatibleAbilityIds: ['ability-2', 'ability-1'],
twoPiece: null,
talent: null,
},
],
specializations: [
{
id: 'specialization-1',
focuses: [
{
id: 'focus-1',
abilities: [{ id: 'ability-1' }],
nodes: [],
},
],
},
],
}),
);
@@ -272,6 +286,50 @@ describe('summarizeLoadout', () => {
]);
});
it('ignores a shared Skill Chip after its ability link is removed', () => {
const draft = createDraft();
draft.specialization.focusId = 'focus-1';
draft.chips[0] = {
setId: 'chip-set',
quality: 'Standard',
primary: { statId: 'skill-duration', value: 12 },
secondary: [],
};
const summary = summarizeLoadout(
draft,
catalog({
attributes: [
attribute('skill-duration', 'Skill Duration', { unit: '%' }),
],
chipSets: [
{
id: 'chip-set',
name: 'Signal',
specialization_ability_id: 'ability-2',
compatibleAbilityIds: ['ability-2'],
twoPiece: null,
talent: null,
},
],
specializations: [
{
id: 'specialization-1',
focuses: [
{
id: 'focus-1',
abilities: [{ id: 'ability-1' }],
nodes: [],
},
],
},
],
}),
);
expect(summary.abilityStats).toEqual([]);
});
it.each([
[0, 10],
[1, 20],
+23 -6
View File
@@ -354,8 +354,9 @@ export function summarizeLoadout(
const chipGroups = new Map<
string,
{ count: number; highEnd: number; item: any }
{ count: number; highEnd: number; item: any; abilityId: string }
>();
const focus = selectedFocus(draft, catalog);
const validChipRoll = (
kind: 'primaryOptions' | 'secondaryOptions',
quality: string,
@@ -375,9 +376,19 @@ export function summarizeLoadout(
const selection = draft.chips[index];
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
if (!item) continue;
const slotAbilityId = String(
focus?.abilities?.[Math.floor(index / 3)]?.id ?? '',
);
const compatibleAbilityIds = item.compatibleAbilityIds ?? [
String(item.specialization_ability_id ?? ''),
];
if (slotAbilityId && !compatibleAbilityIds.includes(slotAbilityId))
continue;
const abilityId =
slotAbilityId || String(item.specialization_ability_id ?? '');
if (validChipRoll('primaryOptions', selection.quality, selection.primary))
addAbilityStat(
String(item.specialization_ability_id ?? ''),
abilityId,
selection.primary.statId,
selection.primary.value,
index,
@@ -386,10 +397,16 @@ export function summarizeLoadout(
for (const roll of selection.secondary)
if (validChipRoll('secondaryOptions', selection.quality, roll))
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
const groupKey = `${item.id}:${abilityId}`;
const group = chipGroups.get(groupKey) ?? {
count: 0,
highEnd: 0,
item,
abilityId,
};
group.count += 1;
if (selection.quality === 'High-end') group.highEnd += 1;
chipGroups.set(item.id, group);
chipGroups.set(groupKey, group);
}
for (const group of chipGroups.values()) {
if (group.count >= 2 && group.item.twoPiece) {
@@ -414,12 +431,12 @@ export function summarizeLoadout(
}
if (group.count === 3 && group.item.talent) {
equippedTalents.push({
key: `chips:${group.item.id}`,
key: `chips:${group.item.id}:${group.abilityId}`,
id: '',
custom: group.item.talent,
rule: group.item.talent.rule ?? null,
tier: group.highEnd,
abilityId: group.item.specialization_ability_id ?? null,
abilityId: group.abilityId || null,
source: '3 Skill Chips',
description: group.item.talent.description_template.replace(
/\{(\d+)\}/g,
+4 -4
View File
@@ -5,7 +5,7 @@
items,
value = $bindable(''),
label,
placeholder = 'Search available items…',
placeholder = 'Search…',
disabledIds = [],
onchange
}: {
@@ -27,19 +27,19 @@
<span>{label}</span>
<button type="button" class="builder-picker-current" aria-expanded={open} onclick={() => open = !open}>
<ItemIcon src={selected?.iconUrl} alt=""/>
<b>{selected?.name ?? 'SELECT ITEM'}</b><small>{selected?.category ?? (selected ? 'EQUIPPED' : 'UNASSIGNED')}</small><em>{open ? '×' : '+'}</em>
<b>{selected?.name ?? 'SELECT ITEM'}</b><small>{selected?.category ?? (selected ? 'EQUIPPED' : 'UNASSIGNED')}</small><em>{open ? '×' : '+'}</em>
</button>
{#if open}
<div class="builder-picker-panel">
<input type="search" bind:value={query} {placeholder} aria-label={placeholder}/>
<div>
{#if selected}<button type="button" class="builder-picker-clear" onclick={() => choose('')}><i>×</i><span><b>CLEAR SLOT</b><small>REMOVE CURRENT SELECTION</small></span><em>CLEAR</em></button>{/if}
{#if selected}<button type="button" class="builder-picker-clear" onclick={() => choose('')}><i>×</i><span><b>CLEAR</b><small>REMOVE SELECTION</small></span><em>CLEAR</em></button>{/if}
{#each results as item}
<button type="button" class:selected={item.id === value} disabled={disabledIds.includes(item.id)} onclick={() => choose(item.id)}>
<ItemIcon src={item.iconUrl} alt=""/>
<span><b>{item.name}</b><small>{item.category ?? 'AVAILABLE'}</small></span><em>{item.id === value ? 'EQUIPPED' : disabledIds.includes(item.id) ? 'CONFLICT' : 'SELECT'}</em>
</button>
{:else}<p>NO MATCHING ITEMS</p>{/each}
{:else}<p>NO MATCHES</p>{/each}
</div>
</div>
{/if}
+1 -1
View File
@@ -14,4 +14,4 @@
const resolvedAlt = $derived(src || !alt ? alt : `${alt} — icon awaiting contribution`);
</script>
<img src={src || MISSING_ITEM_ICON_URL} alt={resolvedAlt} class:missing-item-icon={!src} class={className}/>
<img src={src || MISSING_ITEM_ICON_URL} alt={resolvedAlt} class:missing-item-icon={!src} class={`item-icon ${className}`.trim()}/>
@@ -78,6 +78,7 @@
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
{ label: 'DURATION · SECONDS', current: specializationRecord?.duration_seconds, proposed: proposed.durationSeconds },
{ label: 'COOLDOWN · SECONDS', current: specializationRecord?.cooldown_seconds, proposed: proposed.cooldownSeconds },
{ label: 'SHARED SKILL CHIP ABILITY', current: (currentSnapshot?.skillChipLinks as any[])?.[0]?.ability_id === specializationRecord?.id ? (currentSnapshot?.skillChipLinks as any[])?.[0]?.compatible_ability_id : (currentSnapshot?.skillChipLinks as any[])?.[0]?.ability_id, proposed: proposed.skillChipCompatibleAbilityId },
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
]
: action === 'saveCombatEffect'
+11 -10
View File
@@ -37,7 +37,7 @@
&& Math.floor(Number(active.split(':')[1]) / 3) === Number(cell.id.split(':')[1]));
const issueLabel = (issue: string) => {
if (/not configured|required/i.test(issue)) return 'REQUIRED';
if (/not configured|required|select|choose/i.test(issue)) return 'REQUIRED';
if (/incomplete/i.test(issue)) return 'INCOMPLETE';
return 'CHECK';
};
@@ -45,12 +45,12 @@
<section class="loadout-matrix" aria-label="Loadout matrix">
<header>
<span>LOADOUT MATRIX</span>
<b class:ready={configured}>{configured ? 'COMBAT READY' : 'CONFIGURATION INCOMPLETE'}</b>
<span>LOADOUT</span>
<b class:ready={configured}>{configured ? 'COMPLETE' : 'INCOMPLETE'}</b>
</header>
<div class="matrix-section doctrine-section">
<small class="section-label">DOCTRINE · ROOT CHOICE</small>
<small class="section-label">SPECIALIZATION FOCUS</small>
<button
type="button"
class="doctrine-cell"
@@ -63,19 +63,20 @@
<span><small>{doctrine.label}</small><strong>{doctrine.name}</strong><em class:visible={selected(doctrine)}>{doctrine.summary}</em>{#if doctrine.issue}<mark title={doctrine.issue} aria-hidden="true">{issueLabel(doctrine.issue)}</mark><span class="sr-only">{doctrine.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
<div class="supply-line"><span>SUPPLIES 3 ABILITIES</span></div>
<div class="supply-line"><span>ABILITIES</span></div>
<div class="ability-cells">
{#each abilities as cell}
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em class:visible={selected(cell)}>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
{/each}
</div>
</div>
<div class="matrix-section">
<small class="section-label">WEAPONS · PRIMARY + SECONDARY</small>
<small class="section-label">WEAPONS</small>
<div class="equipment-cells">
{#each weapons as cell}
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
@@ -88,7 +89,7 @@
</div>
<div class="matrix-section">
<small class="section-label">GEAR · 6 POSITIONS</small>
<small class="section-label">GEAR</small>
<div class="equipment-cells gear-cells">
{#each gear as cell}
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
@@ -101,7 +102,7 @@
</div>
<div class="matrix-section protocol-section">
<small class="section-label">AGENT SYSTEM · ONE OS PROTOCOL</small>
<small class="section-label">OS PROTOCOL</small>
<button type="button" class:active={selected(protocol)} class:needs-attention={Boolean(protocol.issue)} aria-pressed={selected(protocol)} onclick={() => onselect(protocol.id)}>
<ItemIcon src={protocol.iconUrl} alt="" className="matrix-icon" />
<span><small>{protocol.label}</small><strong>{protocol.name}</strong><em class:visible={selected(protocol)}>{protocol.summary}</em>{#if protocol.issue}<mark title={protocol.issue} aria-hidden="true">{issueLabel(protocol.issue)}</mark><span class="sr-only">{protocol.issue}</span>{/if}</span>
@@ -123,9 +124,9 @@
button:hover,button:focus-visible{border-color:var(--shd-border-strong);background:var(--shd-surface-active);outline:none}button.active{border-color:var(--shd-builder-selection-border);background:var(--shd-builder-selection);box-shadow:inset 2px 0 var(--shd-orange)}
button.needs-attention{border-bottom-color:color-mix(in srgb,var(--shd-danger) 68%,var(--shd-border-soft))}button.needs-attention>i{background:var(--shd-danger)}button mark{width:fit-content;max-width:100%;overflow:hidden;margin-top:2px;padding:2px 4px;border:1px solid color-mix(in srgb,var(--shd-danger) 58%,transparent);background:#1d1212;color:var(--shd-danger);font-size:8px;font-weight:900;letter-spacing:.07em;line-height:1.2;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}
.supply-line{height:22px;display:grid;place-items:center;background:linear-gradient(90deg,transparent 49.7%,var(--shd-orange) 49.7% 50.3%,transparent 50.3%)}.supply-line span{padding:0 6px;background:var(--shd-surface);color:var(--shd-orange);font-size:8px;font-weight:900;letter-spacing:.08em}
.ability-cells{display:grid;grid-template-columns:1fr;gap:3px}.ability-cells button{min-height:48px;grid-template-columns:25px minmax(0,1fr);padding:6px}.ability-cells :global(.matrix-icon){width:24px;height:24px}
.ability-cells{display:grid;grid-template-columns:1fr;gap:3px}.ability-cells button{min-height:48px;grid-template-columns:25px minmax(0,1fr) 3px;padding:6px}.ability-cells :global(.matrix-icon){width:24px;height:24px}
.equipment-cells{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:3px}.equipment-cells button{min-height:69px}.protocol-section>button{width:100%;min-height:68px}
button:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
@media(max-width:800px) and (min-width:651px){.ability-cells{grid-template-columns:repeat(3,minmax(0,1fr))}.ability-cells button{min-height:68px}}
@media(max-width:520px){.ability-cells{grid-template-columns:1fr}.equipment-cells{grid-template-columns:1fr 1fr}}
@media(max-width:520px){.ability-cells{grid-template-columns:1fr}.equipment-cells{grid-template-columns:1fr 1fr}.gear-cells{grid-template-columns:1fr}}
</style>
@@ -37,7 +37,7 @@
<section class:page={layout === 'page'} class="loadout-known-stats" aria-labelledby={headingId}>
<div class="summary-head">
<div>
<p class="eyebrow"><span></span> KNOWN EQUIPMENT TOTALS</p>
<p class="eyebrow"><span></span> KNOWN STATS</p>
<h2 id={headingId}>Agent stats</h2>
</div>
</div>
@@ -52,29 +52,29 @@
</div>
<div class="summary-scroll">
<div class="summary-section">
<header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header>
<header><span>ATTRIBUTES</span><b>{summary.stats.length}</b></header>
{#each summary.stats as stat}
<div class="summary-stat">
<span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span>
<b>{unitValue(stat.value, stat.id)}</b>
</div>
{:else}
<p>Equip and configure items to calculate their known Attributes.</p>
<p>Equip items to see known Attributes.</p>
{/each}
</div>
<div class="summary-section">
<header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>
<header><span>SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>
{#each summary.activeBonuses as bonus}
<div class="summary-bonus">
<span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span>
<em>+{unitValue(bonus.value, bonus.statId)} {attribute(bonus.statId)?.name}</em>
</div>
{:else}
<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>
<p>No active bonuses.</p>
{/each}
</div>
<div class="summary-section">
<header><span>EQUIPPED TALENTS</span><b>{summary.equippedTalents.length}</b></header>
<header><span>TALENTS</span><b>{summary.equippedTalents.length}</b></header>
{#each summary.equippedTalents as equipped}
<div class="summary-talent">
<details open={layout === 'page'}>
@@ -84,11 +84,11 @@
</details>
</div>
{:else}
<p>Selected equipment Talents appear here.</p>
<p>No Talents equipped.</p>
{/each}
</div>
</div>
<p class="known-stats-note">Includes deterministic equipment values only. Conditional Talent effects remain outside current totals.</p>
<p class="known-stats-note">Known values only. Advanced effects are planned.</p>
</section>
<style>
@@ -10,20 +10,20 @@
} = $props();
const capabilities = [
{ label: 'WEAPON OUTPUT', detail: 'Hit values, burst DPS, sustained DPS, and reload-aware firing cycles' },
{ label: 'SKILL SIMULATION', detail: 'Ability cadence, target counts, critical hits, and multi-shot behavior' },
{ label: 'ENCOUNTER MODEL', detail: 'Duration, accuracy, movement, target flow, rotations, and downtime' },
{ label: 'CONDITIONAL EFFECTS', detail: 'Talent triggers, stack buildup, cooldowns, uptime, and resource changes' }
{ label: 'WEAPON OUTPUT', detail: 'Hits and firing cycles' },
{ label: 'SKILL SIMULATION', detail: 'Timing and target caps' },
{ label: 'ENCOUNTER MODEL', detail: 'Duration and rotations' },
{ label: 'CONDITIONAL EFFECTS', detail: 'Triggers and uptime' }
];
</script>
<section class:compact={variant === 'compact'} class="planned-combat-model" aria-labelledby={headingId}>
<header>
<div><span>ADVANCED COMBAT MODEL</span>{#if headingLevel === 'h2'}<h2 id={headingId}>DPS calculations</h2>{:else}<h3 id={headingId}>DPS calculations</h3>{/if}</div>
<b>COMING SOON</b>
<div><span>COMBAT MODEL</span>{#if headingLevel === 'h2'}<h2 id={headingId}>DPS calculations</h2>{:else}<h3 id={headingId}>DPS calculations</h3>{/if}</div>
<b>PLANNED</b>
</header>
{#if variant === 'full'}
<p>Known equipment totals remain available now. Advanced damage projections will return after their calculation rules and scenario controls are ready for player use.</p>
<p>Known stats are live. Damage projections are planned.</p>
{/if}
<div class="planned-capabilities" aria-label="Planned combat-model capabilities">
{#each capabilities as capability}
+43
View File
@@ -16,6 +16,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
talentEffects,
talentTemplates,
chipSets,
abilitySkillChipLinks,
chipTwoPiece,
chipPrimaryOptions,
chipPrimaryQualityValues,
@@ -31,6 +32,8 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
protocolTalentEffects,
specializations,
specializationFocuses,
specializationAbilities,
focusAbilities,
specializationNodes,
specializationRanks,
specializationRankEffects,
@@ -85,6 +88,14 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
'SELECT * FROM skill_chip_sets ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
`SELECT link.ability_id,link.compatible_ability_id
FROM specialization_ability_skill_chip_links link
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
JOIN specialization_abilities compatible ON compatible.id=link.compatible_ability_id AND compatible.active=1`,
)
).all(),
(
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
).all(),
@@ -136,6 +147,16 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
'SELECT * FROM specialization_focuses ORDER BY specialization_id,display_order,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_abilities ORDER BY specialization_id,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_nodes ORDER BY focus_id,row_number,column_number',
@@ -194,6 +215,16 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
);
return {
...set,
compatibleAbilityIds: [
String(set.specialization_ability_id),
...abilitySkillChipLinks.flatMap((link) =>
link.ability_id === set.specialization_ability_id
? [String(link.compatible_ability_id)]
: link.compatible_ability_id === set.specialization_ability_id
? [String(link.ability_id)]
: [],
),
],
twoPiece:
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
null,
@@ -242,6 +273,18 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
.filter((focus) => focus.specialization_id === specialization.id)
.map((focus) => ({
...focus,
abilities: focusAbilities
.filter((link) => link.focus_id === focus.id)
.sort(
(left, right) =>
Number(left.ability_slot) - Number(right.ability_slot),
)
.map((link) =>
specializationAbilities.find(
(ability) => ability.id === link.ability_id,
),
)
.filter(Boolean),
nodes: specializationNodes
.filter((node) => node.focus_id === focus.id)
.map((node) => ({
+53 -4
View File
@@ -36,6 +36,7 @@ async function validateSkillChipSelections(
if (!loadout.chips.some((chip) => chip.setId)) return null;
const [
sets,
abilityLinks,
focusAbilities,
primaryOptions,
primaryValues,
@@ -44,12 +45,28 @@ async function validateSkillChipSelections(
] = await Promise.all([
(
await database.prepare(
'SELECT id,specialization_ability_id FROM skill_chip_sets WHERE active=1',
`SELECT chip.id,chip.specialization_ability_id
FROM skill_chip_sets chip
JOIN specialization_abilities ability
ON ability.id=chip.specialization_ability_id AND ability.active=1
WHERE chip.active=1`,
)
).all(),
(
await database.prepare(
'SELECT ability_id,ability_slot FROM specialization_focus_abilities WHERE focus_id=? ORDER BY ability_slot',
`SELECT link.ability_id,link.compatible_ability_id
FROM specialization_ability_skill_chip_links link
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
JOIN specialization_abilities compatible ON compatible.id=link.compatible_ability_id AND compatible.active=1`,
)
).all(),
(
await database.prepare(
`SELECT link.ability_id,link.ability_slot
FROM specialization_focus_abilities link
JOIN specialization_focuses focus ON focus.id=link.focus_id AND focus.active=1
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
WHERE link.focus_id=? ORDER BY link.ability_slot`,
)
).all(loadout.specialization.focusId),
(
@@ -94,11 +111,20 @@ async function validateSkillChipSelections(
const expectedAbility = focusAbilities.find(
(row) => Number(row.ability_slot) === Math.floor(index / 3) + 1,
);
const ownerAbilityId = String(definition?.specialization_ability_id ?? '');
const expectedAbilityId = String(expectedAbility?.ability_id ?? '');
const sharedWithExpectedAbility = abilityLinks.some((link) => {
const left = String(link.ability_id);
const right = String(link.compatible_ability_id);
return (
(left === ownerAbilityId && right === expectedAbilityId) ||
(left === expectedAbilityId && right === ownerAbilityId)
);
});
if (
!definition ||
!expectedAbility ||
String(definition.specialization_ability_id) !==
String(expectedAbility.ability_id)
(ownerAbilityId !== expectedAbilityId && !sharedWithExpectedAbility)
)
return `Skill Chip ${index + 1} is not compatible with its assigned ability.`;
const primaryRange = rangeFor(
@@ -234,6 +260,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talentEffects,
talentTemplates,
chipSets,
abilitySkillChipLinks,
chipTwoPiece,
chipPrimaryOptions,
chipPrimaryQualityValues,
@@ -326,6 +353,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
`SELECT link.ability_id,link.compatible_ability_id
FROM specialization_ability_skill_chip_links link
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
JOIN specialization_abilities compatible ON compatible.id=link.compatible_ability_id AND compatible.active=1`,
)
).all(),
(
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
).all(),
@@ -528,6 +563,20 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
);
return {
...set,
category:
specializationAbilities.find(
(ability) => ability.id === set.specialization_ability_id,
)?.name ?? 'SKILL CHIP SET',
compatibleAbilityIds: [
String(set.specialization_ability_id),
...abilitySkillChipLinks.flatMap((link) =>
link.ability_id === set.specialization_ability_id
? [String(link.compatible_ability_id)]
: link.compatible_ability_id === set.specialization_ability_id
? [String(link.ability_id)]
: [],
),
],
iconUrl: iconUrl('skill_chip', set.id),
twoPiece:
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
+68 -58
View File
@@ -56,6 +56,9 @@
const gearItem = (slot: string) => data.gear.find((item) => item.id === draft.gear[slot].itemId);
const weaponItem = (index: number) => data.weapons.find((item) => item.id === draft.weapons[index].itemId);
const chipItem = (index: number) => data.chipSets.find((item) => item.id === draft.chips[index].setId);
const chipSupportsAbility = (item: any, abilityId: string) =>
item?.compatibleAbilityIds?.includes(abilityId)
?? item?.specialization_ability_id === abilityId;
const protocolItem = () => data.protocols.find((item) => item.id === draft.protocol.itemId);
const allFocuses = $derived(data.specializations.flatMap((specialization: any) =>
specialization.focuses.map((focus: any) => ({ ...focus, specialization }))
@@ -118,10 +121,11 @@
&& value >= Number(range.min)
&& value <= Number(range.max);
};
const chipSelectionConfigured = (chip: LoadoutDraft['chips'][number]) => {
const chipSelectionConfigured = (chip: LoadoutDraft['chips'][number], abilityId: string) => {
const capacity = skillChipSecondaryCapacity[chip.quality];
const secondaryIds = chip.secondary.map((roll) => roll.statId);
return Boolean(data.chipSets.some((item: any) => item.id === chip.setId))
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
return Boolean(definition && chipSupportsAbility(definition, abilityId))
&& chipProfileConfigured
&& chipRollConfigured(chip.primary, 'primary', chip.quality)
&& chip.secondary.length === capacity
@@ -171,14 +175,14 @@
const chip = draft.chips[index];
const ability = next.abilities[Math.floor(index / 3)];
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
if (chip.setId && (!ability || definition?.specialization_ability_id !== ability.id)) {
if (chip.setId && (!ability || !chipSupportsAbility(definition, ability.id))) {
displaced.push(structuredClone(chip));
draft.chips[index] = createDraft().chips[index];
}
}
for (const chip of displaced) {
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
const abilityIndex = next.abilities.findIndex((ability: any) => ability.id === definition?.specialization_ability_id);
const abilityIndex = next.abilities.findIndex((ability: any) => chipSupportsAbility(definition, ability.id));
if (abilityIndex < 0) continue;
const targetIndex = [0, 1, 2].map((slot) => abilityIndex * 3 + slot).find((slot) => !draft.chips[slot].setId);
if (targetIndex === undefined) continue;
@@ -352,18 +356,21 @@
const value = String(category ?? '').toLowerCase();
if (value.includes('shotgun')) return '/shotgun.png';
if (value.includes('smg') || value.includes('submachine')) return '/smg.png';
if (value.includes('mmr') || value.includes('marksman')) return '/mmr.png';
if (value.includes('lmg') || value.includes('light machine')) return '/lmg.png';
if (value.includes('ar') || value.includes('assault')) return '/ar.png';
return '/rifle.png';
};
const doctrineCell = $derived<MatrixCell>({
id: 'specialization:focus',
label: 'SPECIALIZATION + FOCUS',
name: selectedFocus ? `${selectedFocus.specialization.name} · ${selectedFocus.name}` : 'SELECT DOCTRINE',
label: 'FOCUS',
name: selectedFocus ? `${selectedFocus.specialization.name} · ${selectedFocus.name}` : 'SELECT FOCUS',
meta: selectedFocus?.primary_attribute ?? '',
summary: selectedFocus
? `${selectedFocus.primary_attribute} · ${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS`
: 'ROOT CHOICE · DEFINES ALL THREE ABILITIES',
: 'SETS 3 ABILITIES',
iconUrl: selectedFocus?.specialization.iconUrl,
issue: !selectedFocus ? 'SELECT TO COMPLETE LOADOUT' : selectedFocus.abilities.length !== 3 ? 'ABILITY DATA INCOMPLETE' : undefined
issue: !selectedFocus ? 'Select a focus.' : selectedFocus.abilities.length !== 3 ? 'Focus needs 3 abilities.' : undefined
});
const abilityCells = $derived.by((): MatrixCell[] => [0, 1, 2].map((index) => {
const ability = selectedFocus?.abilities[index];
@@ -372,12 +379,12 @@
const chipNames = configuredChips.map((chip) => data.chipSets.find((item) => item.id === chip.setId)?.name).filter(Boolean);
return {
id: `ability:${index}`,
label: `ABILITY 0${index + 1}`,
name: ability?.name ?? 'NOT ASSIGNED',
label: `ABILITY ${index + 1}`,
name: ability?.name ?? 'UNASSIGNED',
meta: ability?.attack?.damage_type ?? '',
summary: chipNames.length ? chipNames.join(' · ') : `${configuredChips.length} / 3 SKILL CHIPS`,
iconUrl: ability?.iconUrl,
issue: !ability ? 'ABILITY NOT CONFIGURED' : configuredChips.length < 3 ? `${3 - configuredChips.length} CHIP${configuredChips.length === 2 ? '' : 'S'} REQUIRED` : abilityChips.some((chip) => !chipSelectionConfigured(chip)) ? 'CONFIG REQUIRED' : undefined
issue: !ability ? 'Ability required.' : configuredChips.length < 3 ? `${3 - configuredChips.length} chip${configuredChips.length === 2 ? '' : 's'} required.` : abilityChips.some((chip) => !chipSelectionConfigured(chip, ability.id)) ? 'Chip setup required.' : undefined
};
}));
const weaponCells = $derived.by((): MatrixCell[] => draft.weapons.map((selection, index) => {
@@ -386,12 +393,12 @@
const details = [...talents, rollSummary(selection.rolls)].filter(Boolean).join(' · ');
return {
id: `weapon:${index}`,
label: `${index ? 'SECONDARY' : 'PRIMARY'} WEAPON`,
name: item?.name ?? 'EMPTY SLOT',
label: index ? 'SECONDARY' : 'PRIMARY',
name: item?.name ?? 'EMPTY',
meta: item?.category ?? '',
summary: item ? details || `T${selection.tier} · ${unitValue(selection.damage, '')} DAMAGE` : 'SELECT WEAPON',
summary: item ? details || `T${selection.tier} · ${unitValue(selection.damage, '')} DAMAGE` : 'CHOOSE WEAPON',
iconUrl: weaponIcon(item?.category),
issue: item ? undefined : 'SELECT TO COMPLETE LOADOUT'
issue: item ? undefined : 'Choose a weapon.'
};
}));
const gearCells = $derived.by((): MatrixCell[] => gearSlots.map((slot) => {
@@ -405,30 +412,33 @@
return {
id: `gear:${slot}`,
label: slot.toUpperCase(),
name: item?.name ?? 'EMPTY SLOT',
name: item?.name ?? 'EMPTY',
meta: item ? `T${item.tier}` : '',
summary: item ? details || `T${item.tier} · LEVEL ${selection.level}` : 'SELECT GEAR',
summary: item ? details || `T${item.tier} · LEVEL ${selection.level}` : 'CHOOSE GEAR',
iconUrl: gearIcon(slot),
issue: item ? undefined : 'SELECT TO COMPLETE LOADOUT'
issue: item ? undefined : 'Choose gear.'
};
}));
const protocolCell = $derived<MatrixCell>({
id: 'protocol:0',
label: 'OS PROTOCOL',
name: protocolItem()?.name ?? 'EMPTY SLOT',
label: 'PROTOCOL',
name: protocolItem()?.name ?? 'EMPTY',
meta: protocolItem()?.quality ?? '',
summary: protocolItem()
? `LEVEL ${draft.protocol.level} · ${draft.protocol.talentUpgrades} / 5 TALENT UPGRADES`
: 'SELECT ONE PROTOCOL',
: 'CHOOSE PROTOCOL',
iconUrl: protocolItem()?.iconUrl,
issue: protocolItem() ? undefined : 'SELECT TO COMPLETE LOADOUT'
issue: protocolItem() ? undefined : 'Choose a protocol.'
});
const loadoutConfigured = $derived(Boolean(
selectedFocus
&& selectedFocus.abilities.length === 3
&& draft.weapons.every((item) => item.itemId)
&& gearSlots.every((slot) => draft.gear[slot].itemId)
&& draft.chips.every(chipSelectionConfigured)
&& draft.chips.every((chip, index) => chipSelectionConfigured(
chip,
selectedFocus.abilities[Math.floor(index / 3)]?.id ?? ''
))
&& draft.protocol.itemId
));
@@ -481,10 +491,10 @@
<main class="app-page builder-page">
<div class="builder-command">
<div class="page-title">
<div><p class="eyebrow"><span></span> COMMAND GRID / LOADOUT 01</p><input id="builder-build-name" name="builder-build-name" bind:value={draft.buildName} aria-label="Build name" maxlength="100"/></div>
<div><p class="eyebrow"><span></span> BUILDER / LOADOUT 01</p><input id="builder-build-name" name="builder-build-name" bind:value={draft.buildName} aria-label="Build name" maxlength="100"/></div>
<div class="actions">
<span class:visible={saved}>SAVED LOCALLY</span>
<button class="secondary" onclick={saveDraft}>SAVE LOCALLY</button>
<span class:visible={saved}>LOCAL SAVED</span>
<button class="secondary" onclick={saveDraft}>SAVE</button>
{#if data.viewer}
<PendingForm action="?/save" class="builder-save-form">
{#snippet children(pending)}
@@ -500,21 +510,21 @@
</div>
{#if data.viewer}
<div class="builder-persistence">
<SegmentedChoice legend="ACCOUNT VISIBILITY" name="build-visibility" options={[
<SegmentedChoice legend="VISIBILITY" name="build-visibility" options={[
{ value:'private', label:'PRIVATE' },
{ value:'unlisted', label:'UNLISTED' },
{ value:'public', label:'PUBLIC' }
]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0] as BuildVisibility} required/>
<p>{persistedVisibility === 'private' ? 'Only you can open this build.' : persistedVisibility === 'unlisted' ? 'Anyone with its direct link can open this build.' : 'Listed publicly and accessible by direct link.'}</p>
<p>{persistedVisibility === 'private' ? 'Private to you.' : persistedVisibility === 'unlisted' ? 'Direct link only.' : 'Public listing and link.'}</p>
{#if persistedSlug && savedBuildVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
</div>
{/if}
<div class="builder-status"><span><i></i> KNOWN STATS ONLINE</span><span>ADVANCED MODEL · COMING SOON</span></div>
<div class="builder-status"><span><i></i> STATS ONLINE</span></div>
</div>
{#if message}<p class="success" role="status">{message}</p>{/if}
{#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if}
{#if data.viewer && persistedSlug}<BuildShareControl title={savedBuildTitle} slugOrUrl={new URL(`/builds/${encodeURIComponent(persistedSlug)}`, data.publicOrigin).toString()} visibility={savedBuildVisibility}/>{/if}
{#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}<div class="builder-empty-data"><strong>ISAC DATA LINK EMPTY</strong><span>Add active equipment definitions in ISAC to populate this workbench.</span></div>{/if}
{#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}<div class="builder-empty-data"><strong>NO BUILDER DATA</strong><span>Add active items in ISAC.</span></div>{/if}
<div class="loadout-workspace">
<LoadoutMatrix
{active}
@@ -529,24 +539,24 @@
<section class="loadout-editor">
{#if activeParts[0] === 'specialization'}
<div class="loadout-editor-head"><div><span>AGENT CLASS</span><h2>SPECIALIZATION FOCUS</h2></div><b>{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={specializationItems} value={draft.specialization.focusId} onchange={selectFocus} label="SPECIALIZATION + FOCUS"/>
<div class="loadout-editor-head"><div><span>SPECIALIZATION</span><h2>FOCUS</h2></div><b>{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={specializationItems} value={draft.specialization.focusId} onchange={selectFocus} label="FOCUS"/>
{#if selectedFocus}
<div class="builder-specialization-layout">
<div class="builder-editor-lane">
<div class="builder-specialization-identity"><span class="focus-composite-icon"><ItemIcon src={selectedFocus.specialization.iconUrl} alt={selectedFocus.specialization.name}/><GameIcon name={selectedFocus.primary_attribute.toLocaleLowerCase() as GameIconName} decorative/></span><div><small>{selectedFocus.specialization.name}</small><h3>{selectedFocus.name}</h3><p>{selectedFocus.description || 'No focus description has been entered.'}</p></div><b class:configured={Number(selectedFocus.builder_ready) === 1}>{Number(selectedFocus.builder_ready) === 1 ? 'BUILDER READY' : 'ISAC DATA INCOMPLETE'}</b></div>
<div class="builder-specialization-identity"><span class="focus-composite-icon"><ItemIcon src={selectedFocus.specialization.iconUrl} alt=""/><GameIcon name={selectedFocus.primary_attribute.toLocaleLowerCase() as GameIconName} decorative/></span><div><small>{selectedFocus.specialization.name}</small><h3>{selectedFocus.name}</h3><p>{selectedFocus.description || 'No focus description.'}</p></div><b class:configured={Number(selectedFocus.builder_ready) === 1}>{Number(selectedFocus.builder_ready) === 1 ? 'READY' : 'NEEDS DATA'}</b></div>
<div class="builder-focus-abilities" aria-label="Assigned abilities">
{#each [0,1,2] as abilityIndex}
{@const ability = selectedFocus.abilities[abilityIndex]}
<button type="button" onclick={() => active = `ability:${abilityIndex}`}><ItemIcon src={ability?.iconUrl} alt=""/><span><small>ABILITY {abilityIndex + 1}</small><strong>{ability?.name ?? 'NOT CONFIGURED'}</strong></span><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></button>
<button type="button" onclick={() => active = `ability:${abilityIndex}`}><ItemIcon src={ability?.iconUrl} alt="" className="builder-ability-icon"/><span><small>ABILITY {abilityIndex + 1}</small><strong>{ability?.name ?? 'UNASSIGNED'}</strong></span><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3</b></button>
{/each}
</div>
<div class="builder-specialization-telemetry"><span>POINTS <b>{selectedSpecializationPoints} / {data.specializationProgression.availablePoints}</b></span><span>CREDITS <b>{selectedSpecializationCosts.credits.toLocaleString()}</b></span>{#each selectedSpecializationCosts.resources as resource}<span>{String(resource[0]).replace('resource-electronics-','').replaceAll('-',' ').toUpperCase()} <b>× {resource[1]}</b></span>{/each}</div>
</div>
<div class="builder-focus-tree builder-editor-lane">
<div class="builder-section-title"><div><span>FOCUS UPGRADE TREE</span><small>COMPLETE EARLIER ROWS TO ADVANCE</small></div><b>{selectedFocus.nodes.length} NODES</b></div>
<div class="builder-section-title"><div><span>UPGRADE TREE</span><small>UNLOCK IN ORDER</small></div><b>{selectedFocus.nodes.length} NODES</b></div>
{#each [...new Set(selectedFocus.nodes.map((node: any) => Number(node.row_number)))] as row}
<section><header><span>ROW {String(row).padStart(2, '0')}</span><b>{row === 1 ? 'ENTRY ROW' : 'PREVIOUS ROWS REQUIRED'}</b></header><div>
<section><header><span>ROW {String(row).padStart(2, '0')}</span><b>{row === 1 ? 'START' : 'PRIOR ROWS'}</b></header><div>
{#each selectedFocus.nodes.filter((node: any) => Number(node.row_number) === row) as node}
<article><ItemIcon src={node.iconUrl} alt={node.name}/><div><strong>{node.name}</strong><small>{node.description_template}</small></div><SegmentedChoice legend={`${node.name} rank`} name={`specialization-node-${node.id}`} options={[{value:'0',label:'OFF'},...Array.from({length:Number(node.max_rank)},(_,index)=>({value:String(index+1),label:`R${index+1}`,className:canSelectNodeRank(node,index+1)?'':'unavailable',disabled:!canSelectNodeRank(node,index+1)}))]} value={String(draft.specialization.nodeRanks[node.id] ?? 0)} onchange={(values) => setNodeRank(node,Number(values[0]))}/></article>
{/each}
@@ -558,23 +568,23 @@
{:else if activeParts[0] === 'ability'}
{@const abilityIndex = Number(activeParts[1])}
{@const ability = selectedFocus?.abilities[abilityIndex]}
<div class="loadout-editor-head"><div><span>DOCTRINE DEPENDENCY · ABILITY 0{abilityIndex + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'ABILITY NOT ASSIGNED'}</h2></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></div>
<button class="builder-return-link" type="button" onclick={() => active = 'specialization:focus'}>← CHANGE SPECIALIZATION + FOCUS</button>
<div class="loadout-editor-head"><div><span>ABILITY {abilityIndex + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'UNASSIGNED'}</h2></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></div>
<button class="builder-return-link" type="button" onclick={() => active = 'specialization:focus'}>← BACK TO FOCUS</button>
{#if ability}
<div class="builder-specialization-identity ability-identity"><ItemIcon src={ability.iconUrl} alt={ability.name}/><div><small>{selectedFocus.specialization.name} · {selectedFocus.name}</small><h3>{ability.name}</h3><p>{ability.description || 'No ability description has been entered.'}</p></div><b>{ability.attack ? `${ability.attack.delivery.toUpperCase()} · ${ability.attack.damage_type}` : 'ATTACK DATA INCOMPLETE'}</b></div>
<div class="builder-section-title"><div><span>ATTACHED SKILL CHIPS</span><small>CHIPS ARE COMPATIBLE WITH THIS ABILITY AND MOVE WITH ITS FOCUS</small></div><b>03 POSITIONS</b></div>
<div class="builder-specialization-identity ability-identity"><ItemIcon src={ability.iconUrl} alt="" className="ability-icon"/><div><small>{selectedFocus.specialization.name} · {selectedFocus.name}</small><h3>{ability.name}</h3><p>{ability.description || 'No ability description.'}</p></div><b>{ability.attack ? `${ability.attack.delivery.toUpperCase()} · ${ability.attack.damage_type}` : 'ATTACK DATA NEEDED'}</b></div>
<div class="builder-section-title"><div><span>SKILL CHIPS</span><small>3 SETS</small></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3</b></div>
<div class="builder-ability-chip-panels single">
<section>
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${draft.chips[chipIndex].quality} · ${attribute(draft.chips[chipIndex].primary.statId)?.name ?? 'NO PRIMARY'}` : 'SELECT CHIP'}</span></button>{/each}</div>
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><ItemIcon src={item?.iconUrl} alt="" className="builder-chip-icon"/><span><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY'}</strong><em>{item ? `${draft.chips[chipIndex].quality} · ${attribute(draft.chips[chipIndex].primary.statId)?.name ?? 'NO PRIMARY'}` : 'SELECT SET'}</em></span></button>{/each}</div>
</section>
</div>
{:else}
<p class="error">Select a Specialization and Focus with configured abilities before attaching Skill Chips.</p>
<p class="error">Choose a focus with 3 abilities first.</p>
{/if}
{:else if activeParts[0] === 'gear'}
{@const slot = activeParts[1]}{@const selection = draft.gear[slot]}{@const item = gearItem(slot)}
<div class="loadout-editor-head"><div><span>GEAR SLOT</span><h2>{slot.toUpperCase()}</h2></div><b>{item ? `TIER ${item.tier} · BUILD LEVEL ${selection.level}` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.gear.filter((entry) => entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="ITEM DEFINITION"/>
<div class="loadout-editor-head"><div><span>GEAR</span><h2>{slot.toUpperCase()}</h2></div><b>{item ? `T${item.tier} · LV ${selection.level}` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.gear.filter((entry) => entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="GEAR ITEM"/>
{#if item}
{@const healthRange = gearBaseRange(item, 'health', selection.level)}
{@const armorRange = item.base_armor_min !== null ? gearBaseRange(item, 'armor', selection.level) : null}
@@ -602,8 +612,8 @@
{/if}
{:else if activeParts[0] === 'weapon'}
{@const index = Number(activeParts[1])}{@const selection = draft.weapons[index]}{@const item = weaponItem(index)}{@const other = weaponItem(index ? 0 : 1)}
<div class="loadout-editor-head"><div><span>WEAPON SLOT</span><h2>{index ? 'SECONDARY' : 'PRIMARY'} WEAPON</h2></div><b>{item ? item.damage_type : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.weapons} bind:value={selection.itemId} onchange={(id) => configureWeapon(index,id)} disabledIds={other && Number(other.is_exotic) ? data.weapons.filter((entry) => Number(entry.is_exotic)).map((entry) => entry.id) : []} label="WEAPON DEFINITION"/>
<div class="loadout-editor-head"><div><span>WEAPON</span><h2>{index ? 'SECONDARY' : 'PRIMARY'}</h2></div><b>{item ? item.damage_type : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.weapons} bind:value={selection.itemId} onchange={(id) => configureWeapon(index,id)} disabledIds={other && Number(other.is_exotic) ? data.weapons.filter((entry) => Number(entry.is_exotic)).map((entry) => entry.id) : []} label="WEAPON"/>
{#if item}
{#if Number(item.is_exotic) && other && Number(other.is_exotic)}<p class="error">Only one exotic weapon may be equipped. Choose a standard weapon in either slot.</p>{/if}
{@const range = item.ranges.find((entry: any) => Number(entry.tier) === selection.tier)}
@@ -635,29 +645,29 @@
{/if}
{:else if activeParts[0] === 'chip'}
{@const index = Number(activeParts[1])}{@const chip = draft.chips[index]}{@const item = chipItem(index)}{@const abilityIndex = Math.floor(index / 3)}{@const ability = selectedFocus?.abilities[abilityIndex]}
<div class="loadout-editor-head"><div><span>ABILITY {abilityIndex + 1} · CHIP {(index % 3) + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'SKILL CHIP'}</h2></div><b>{chip.quality.toUpperCase()}</b></div>
<button class="builder-return-link" type="button" onclick={() => active = `ability:${abilityIndex}`}>← RETURN TO {ability?.name?.toUpperCase() ?? 'ABILITY'}</button>
{#if ability}<BuilderPicker items={data.chipSets.filter((entry: any) => entry.specialization_ability_id === ability.id)} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="ABILITY-COMPATIBLE SKILL CHIP SET"/>{:else}<p class="error">Select a focus with three configured class abilities before equipping Skill Chips.</p>{/if}
{#if !chipProfileConfigured}<p class="error">SHARED SKILL CHIP PROFILE · CONFIG REQUIRED. Complete every global quality range in ISAC before this loadout can be complete.</p>{/if}
<div class="loadout-editor-head"><div><span>CHIP {(index % 3) + 1} / 3</span><h2>{ability?.name?.toUpperCase() ?? 'SKILL CHIP'}</h2></div><b>{chip.quality.toUpperCase()}</b></div>
<button class="builder-return-link" type="button" onclick={() => active = `ability:${abilityIndex}`}>← BACK TO ABILITY</button>
{#if ability}<BuilderPicker items={data.chipSets.filter((entry: any) => chipSupportsAbility(entry, ability.id))} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="CHIP SET"/>{:else}<p class="error">Choose a focus with 3 abilities first.</p>{/if}
{#if !chipProfileConfigured}<p class="error">CHIP PROFILE REQUIRED. Configure shared ranges in ISAC.</p>{/if}
{#if item}
<div class="builder-editor-columns builder-chip-editor">
<section class="builder-editor-lane" aria-label="Skill Chip quality and primary Attribute">
<div class="builder-field-grid"><SegmentedChoice legend="QUALITY" name={`chip-${index}-quality`} options={skillChipQualities.map((quality) => ({ value: quality, label: quality.toUpperCase() }))} value={chip.quality} onchange={(values) => changeChipQuality(index, values[0] as SkillChipQuality)} required/></div>
<div class="builder-section-title"><div><span>ABILITY-SCOPED PRIMARY</span><small>{ability?.name?.toUpperCase() ?? 'ASSIGNED ABILITY'} ONLY</small></div><b>NOT YET DEFINED</b></div>
<p class="builder-scaling-note">Excluded from current Agent totals.</p>
<div class="builder-chip-rolls"><div><BuilderPicker items={chipPrimaryAttributes} bind:value={chip.primary.statId} onchange={(id) => setChipRoll(chip.primary,id,'primary',chip.quality)} label="PRIMARY ATTRIBUTE"/>{#if validChipRange(chipRange('primary', chip.primary.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('primary', chip.primary.statId, chip.quality)} max={chipRangeMax('primary', chip.primary.statId, chip.quality)} step={0.01} bind:value={chip.primary.value}/>{:else}<p class="error">PRIMARY RANGE · CONFIG REQUIRED</p>{/if}</div></div>
<div class="builder-section-title"><div><span>PRIMARY</span><small>ABILITY ONLY</small></div><b>NOT IN TOTALS</b></div>
<p class="builder-scaling-note">Not included in totals.</p>
<div class="builder-chip-rolls"><div><BuilderPicker items={chipPrimaryAttributes} bind:value={chip.primary.statId} onchange={(id) => setChipRoll(chip.primary,id,'primary',chip.quality)} label="PRIMARY"/>{#if validChipRange(chipRange('primary', chip.primary.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('primary', chip.primary.statId, chip.quality)} max={chipRangeMax('primary', chip.primary.statId, chip.quality)} step={0.01} bind:value={chip.primary.value}/>{:else}<p class="error">PRIMARY RANGE REQUIRED</p>{/if}</div></div>
</section>
<section class="builder-editor-lane" aria-label="Skill Chip secondary Attributes">
<div class="builder-section-title"><div><span>SECONDARY ATTRIBUTES</span><small>{skillChipSecondaryCapacity[chip.quality]} SLOT{skillChipSecondaryCapacity[chip.quality] === 1 ? '' : 'S'}</small></div></div>
<div class="builder-chip-rolls">{#each chip.secondary as roll, secondaryIndex}<div><BuilderPicker items={chipSecondaryAttributes} bind:value={roll.statId} onchange={(id) => setChipRoll(roll,id,'secondary',chip.quality)} disabledIds={chip.secondary.filter((_,i) => i !== secondaryIndex).map((entry) => entry.statId).filter(Boolean)} label={`SECONDARY ATTRIBUTE ${secondaryIndex + 1}`}/>{#if validChipRange(chipRange('secondary', roll.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('secondary', roll.statId, chip.quality)} max={chipRangeMax('secondary', roll.statId, chip.quality)} step={0.01} bind:value={roll.value}/>{:else}<p class="error">SECONDARY RANGE · CONFIG REQUIRED</p>{/if}</div>{/each}</div>
{#if skillChipSecondaryCapacity[chip.quality] === 0}<p class="builder-scaling-note">STANDARD QUALITY · NO SECONDARY ATTRIBUTES</p>{/if}
<div class="builder-section-title"><div><span>SECONDARIES</span><small>{skillChipSecondaryCapacity[chip.quality]} SLOT{skillChipSecondaryCapacity[chip.quality] === 1 ? '' : 'S'}</small></div></div>
<div class="builder-chip-rolls">{#each chip.secondary as roll, secondaryIndex}<div><BuilderPicker items={chipSecondaryAttributes} bind:value={roll.statId} onchange={(id) => setChipRoll(roll,id,'secondary',chip.quality)} disabledIds={chip.secondary.filter((_,i) => i !== secondaryIndex).map((entry) => entry.statId).filter(Boolean)} label={`SECONDARY ${secondaryIndex + 1}`}/>{#if validChipRange(chipRange('secondary', roll.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('secondary', roll.statId, chip.quality)} max={chipRangeMax('secondary', roll.statId, chip.quality)} step={0.01} bind:value={roll.value}/>{:else}<p class="error">SECONDARY RANGE REQUIRED</p>{/if}</div>{/each}</div>
{#if skillChipSecondaryCapacity[chip.quality] === 0}<p class="builder-scaling-note">NO SECONDARIES</p>{/if}
</section>
</div>
{/if}
{:else}
{@const protocol = protocolItem()}
<div class="loadout-editor-head"><div><span>AGENT SYSTEM</span><h2>OS PROTOCOL</h2></div><b>{protocol?.quality?.toUpperCase() ?? 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.protocols} bind:value={draft.protocol.itemId} label="OS PROTOCOL"/>
<div class="loadout-editor-head"><div><span>PROTOCOL</span><h2>OS PROTOCOL</h2></div><b>{protocol?.quality?.toUpperCase() ?? 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.protocols} bind:value={draft.protocol.itemId} label="PROTOCOL"/>
{#if protocol}<div class="builder-range-grid"><label>PROTOCOL LEVEL <span>01–50</span><input type="number" min="1" max="50" bind:value={draft.protocol.level} onblur={() => draft.protocol.level = clamp(draft.protocol.level,1,50)}/></label><label>TALENT UPGRADES <span>0–5</span><input type="number" min="0" max="5" bind:value={draft.protocol.talentUpgrades} onblur={() => draft.protocol.talentUpgrades = clamp(draft.protocol.talentUpgrades,0,5)}/></label></div><div class="builder-inherited">{#if protocol.primaryEffect}<span><small>{attribute(protocol.primaryEffect.stat_attribute_id)?.name} · LEVEL {draft.protocol.level}</small><b>{unitValue(Number(protocol.primaryEffect.base_value)*(1+(draft.protocol.level-1)/49),protocol.primaryEffect.stat_attribute_id)}</b></span>{/if}{#each protocol.secondaryEffects as effect}<span><small>{attribute(effect.stat_attribute_id)?.name}</small><b>{unitValue(effect.value,effect.stat_attribute_id)}</b></span>{/each}</div>{/if}
{/if}
</section>
@@ -127,6 +127,58 @@ describe('Builder Skill Chip save validation', () => {
expect(mocks.insertBuild).not.toHaveBeenCalled();
});
it('accepts a Skill Chip owned by an explicitly compatible ability', async () => {
mocks.database.prepare.mockImplementation(async (sql: string) => ({
all: vi.fn(async () => {
if (sql.includes('FROM skill_chip_sets'))
return [
{
id: 'chip-set-1',
specialization_ability_id: 'ability-2',
},
];
if (sql.includes('FROM specialization_ability_skill_chip_links'))
return [
{
ability_id: 'ability-1',
compatible_ability_id: 'ability-2',
},
];
if (sql.includes('FROM specialization_focus_abilities'))
return [{ ability_id: 'ability-1', ability_slot: 1 }];
if (sql.includes('FROM skill_chip_primary_options'))
return [{ stat_attribute_id: primaryStatId }];
if (sql.includes('FROM skill_chip_primary_quality_values'))
return [
{
stat_attribute_id: primaryStatId,
quality: 'Superior',
min_value: 10,
max_value: 20,
},
];
if (sql.includes('FROM skill_chip_secondary_options'))
return secondaryStatIds.map((stat_attribute_id) => ({
stat_attribute_id,
}));
if (sql.includes('FROM skill_chip_secondary_quality_values'))
return secondaryStatIds.map((stat_attribute_id) => ({
stat_attribute_id,
quality: 'Superior',
min_value: 1,
max_value: 5,
}));
return [];
}),
run: mocks.insertBuild,
}));
const result = await runSave(validLoadout());
expect(result).toMatchObject({ buildSaved: true });
expect(mocks.insertBuild).toHaveBeenCalledOnce();
});
it.each([
{
label: 'primary',
@@ -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: [] }),
}),
);
});
});